Repository: pypeclub/OpenPype Branch: develop Commit: f67bacf11713 Files: 3831 Total size: 27.9 MB Directory structure: gitextract_6pkohrst/ ├── .all-contributorsrc ├── .dockerignore ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── enhancement_request.yml │ ├── pr-branch-labeler.yml │ ├── pr-glob-labeler.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── documentation.yml │ ├── milestone_assign.yml │ ├── milestone_create.yml │ ├── miletone_release_trigger.yml │ ├── nightly_merge.yml │ ├── pr_labels.yml │ ├── prerelease.yml │ ├── project_task_statuses.yml │ ├── test_build.yml │ └── update_bug_report.yml ├── .gitignore ├── .gitmodules ├── .hound.yml ├── .prettierrc ├── ARCHITECTURE.md ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.centos7 ├── Dockerfile.debian ├── HISTORY.md ├── LICENSE ├── README.md ├── app_launcher.py ├── conftest.py ├── docs/ │ ├── Makefile │ ├── README.md │ ├── make.bat │ └── source/ │ ├── _static/ │ │ └── README.md │ ├── _templates/ │ │ └── autoapi/ │ │ ├── index.rst │ │ └── python/ │ │ ├── attribute.rst │ │ ├── class.rst │ │ ├── data.rst │ │ ├── exception.rst │ │ ├── function.rst │ │ ├── method.rst │ │ ├── module.rst │ │ ├── package.rst │ │ └── property.rst │ ├── conf.py │ ├── index.rst │ └── readme.rst ├── igniter/ │ ├── Poppins/ │ │ └── OFL.txt │ ├── __init__.py │ ├── __main__.py │ ├── bootstrap_repos.py │ ├── install_dialog.py │ ├── install_thread.py │ ├── message_dialog.py │ ├── nice_progress_bar.py │ ├── openpype.icns │ ├── splash.txt │ ├── stylesheet.css │ ├── terminal_splash.py │ ├── tools.py │ ├── update_thread.py │ ├── update_window.py │ ├── user_settings.py │ └── version.py ├── inno_setup.iss ├── openpype/ │ ├── __init__.py │ ├── __main__.py │ ├── addons/ │ │ └── README.md │ ├── cli.py │ ├── client/ │ │ ├── __init__.py │ │ ├── entities.py │ │ ├── entity_links.py │ │ ├── mongo/ │ │ │ ├── __init__.py │ │ │ ├── entities.py │ │ │ ├── entity_links.py │ │ │ ├── mongo.py │ │ │ └── operations.py │ │ ├── notes.md │ │ ├── operations.py │ │ ├── operations_base.py │ │ └── server/ │ │ ├── __init__.py │ │ ├── constants.py │ │ ├── conversion_utils.py │ │ ├── entities.py │ │ ├── entity_links.py │ │ ├── openpype_comp.py │ │ ├── operations.py │ │ ├── thumbnails.py │ │ └── utils.py │ ├── hooks/ │ │ ├── pre_add_last_workfile_arg.py │ │ ├── pre_copy_template_workfile.py │ │ ├── pre_create_extra_workdir_folders.py │ │ ├── pre_global_host_data.py │ │ ├── pre_mac_launch.py │ │ ├── pre_new_console_apps.py │ │ ├── pre_non_python_host_launch.py │ │ └── pre_ocio_hook.py │ ├── host/ │ │ ├── __init__.py │ │ ├── dirmap.py │ │ ├── host.py │ │ └── interfaces.py │ ├── hosts/ │ │ ├── __init__.py │ │ ├── aftereffects/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── README.md │ │ │ │ ├── __init__.py │ │ │ │ ├── extension/ │ │ │ │ │ ├── .debug │ │ │ │ │ ├── CSXS/ │ │ │ │ │ │ └── manifest.xml │ │ │ │ │ ├── css/ │ │ │ │ │ │ ├── boilerplate.css │ │ │ │ │ │ └── styles.css │ │ │ │ │ ├── index.html │ │ │ │ │ ├── js/ │ │ │ │ │ │ ├── libs/ │ │ │ │ │ │ │ ├── CSInterface.js │ │ │ │ │ │ │ ├── json.js │ │ │ │ │ │ │ └── wsrpc.js │ │ │ │ │ │ ├── main.js │ │ │ │ │ │ └── themeManager.js │ │ │ │ │ └── jsx/ │ │ │ │ │ └── hostscript.jsx │ │ │ │ ├── extension.zxp │ │ │ │ ├── launch_logic.py │ │ │ │ ├── lib.py │ │ │ │ ├── pipeline.py │ │ │ │ ├── plugin.py │ │ │ │ ├── workfile_template_builder.py │ │ │ │ └── ws_stub.py │ │ │ ├── plugins/ │ │ │ │ ├── __init__.py │ │ │ │ ├── create/ │ │ │ │ │ ├── create_render.py │ │ │ │ │ └── workfile_creator.py │ │ │ │ ├── load/ │ │ │ │ │ ├── load_background.py │ │ │ │ │ └── load_file.py │ │ │ │ └── publish/ │ │ │ │ ├── add_publish_highlight.py │ │ │ │ ├── closeAE.py │ │ │ │ ├── collect_audio.py │ │ │ │ ├── collect_current_file.py │ │ │ │ ├── collect_extension_version.py │ │ │ │ ├── collect_render.py │ │ │ │ ├── collect_review.py │ │ │ │ ├── collect_workfile.py │ │ │ │ ├── extract_local_render.py │ │ │ │ ├── extract_save_scene.py │ │ │ │ ├── help/ │ │ │ │ │ ├── validate_footage_items.xml │ │ │ │ │ ├── validate_instance_asset.xml │ │ │ │ │ └── validate_scene_settings.xml │ │ │ │ ├── increment_workfile.py │ │ │ │ ├── pre_collect_render.py │ │ │ │ ├── remove_publish_highlight.py │ │ │ │ ├── validate_footage_items.py │ │ │ │ ├── validate_instance_asset.py │ │ │ │ └── validate_scene_settings.py │ │ │ └── resources/ │ │ │ └── template.aep │ │ ├── blender/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── action.py │ │ │ │ ├── capture.py │ │ │ │ ├── colorspace.py │ │ │ │ ├── lib.py │ │ │ │ ├── ops.py │ │ │ │ ├── pipeline.py │ │ │ │ ├── plugin.py │ │ │ │ ├── render_lib.py │ │ │ │ └── workio.py │ │ │ ├── blender_addon/ │ │ │ │ └── startup/ │ │ │ │ └── init.py │ │ │ ├── hooks/ │ │ │ │ ├── pre_add_run_python_script_arg.py │ │ │ │ ├── pre_pyside_install.py │ │ │ │ └── pre_windows_console.py │ │ │ └── plugins/ │ │ │ ├── create/ │ │ │ │ ├── convert_legacy.py │ │ │ │ ├── create_action.py │ │ │ │ ├── create_animation.py │ │ │ │ ├── create_blendScene.py │ │ │ │ ├── create_camera.py │ │ │ │ ├── create_layout.py │ │ │ │ ├── create_model.py │ │ │ │ ├── create_pointcache.py │ │ │ │ ├── create_render.py │ │ │ │ ├── create_review.py │ │ │ │ ├── create_rig.py │ │ │ │ └── create_workfile.py │ │ │ ├── load/ │ │ │ │ ├── import_workfile.py │ │ │ │ ├── load_abc.py │ │ │ │ ├── load_action.py │ │ │ │ ├── load_animation.py │ │ │ │ ├── load_audio.py │ │ │ │ ├── load_blend.py │ │ │ │ ├── load_blendscene.py │ │ │ │ ├── load_camera_abc.py │ │ │ │ ├── load_camera_fbx.py │ │ │ │ ├── load_fbx.py │ │ │ │ ├── load_layout_json.py │ │ │ │ └── load_look.py │ │ │ └── publish/ │ │ │ ├── collect_current_file.py │ │ │ ├── collect_instance.py │ │ │ ├── collect_render.py │ │ │ ├── collect_review.py │ │ │ ├── collect_workfile.py │ │ │ ├── extract_abc.py │ │ │ ├── extract_abc_animation.py │ │ │ ├── extract_blend.py │ │ │ ├── extract_blend_animation.py │ │ │ ├── extract_camera_abc.py │ │ │ ├── extract_camera_fbx.py │ │ │ ├── extract_fbx.py │ │ │ ├── extract_fbx_animation.py │ │ │ ├── extract_layout.py │ │ │ ├── extract_playblast.py │ │ │ ├── extract_thumbnail.py │ │ │ ├── increment_workfile_version.py │ │ │ ├── integrate_animation.py │ │ │ ├── validate_camera_zero_keyframe.py │ │ │ ├── validate_deadline_publish.py │ │ │ ├── validate_file_saved.py │ │ │ ├── validate_instance_empty.py │ │ │ ├── validate_mesh_has_uv.py │ │ │ ├── validate_mesh_no_negative_scale.py │ │ │ ├── validate_no_colons_in_name.py │ │ │ ├── validate_object_mode.py │ │ │ ├── validate_render_camera_is_set.py │ │ │ └── validate_transform_zero.py │ │ ├── celaction/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── hooks/ │ │ │ │ └── pre_celaction_setup.py │ │ │ ├── plugins/ │ │ │ │ ├── __init__.py │ │ │ │ └── publish/ │ │ │ │ ├── collect_celaction_cli_kwargs.py │ │ │ │ ├── collect_celaction_instances.py │ │ │ │ ├── collect_render_path.py │ │ │ │ └── integrate_version_up.py │ │ │ ├── resources/ │ │ │ │ └── celaction_template_scene.scn │ │ │ └── scripts/ │ │ │ ├── __init__.py │ │ │ └── publish_cli.py │ │ ├── equalizer/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── host.py │ │ │ │ ├── pipeline.py │ │ │ │ └── plugin.py │ │ │ ├── hooks/ │ │ │ │ └── pre_pyside2_install.py │ │ │ ├── plugins/ │ │ │ │ ├── __init__.py │ │ │ │ ├── create/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── create_lens_distortion_data.py │ │ │ │ │ └── create_matchmove.py │ │ │ │ ├── load/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── load_plate.py │ │ │ │ └── publish/ │ │ │ │ ├── __init__.py │ │ │ │ ├── collect_3de_installation_dir.py │ │ │ │ ├── collect_camera_data.py │ │ │ │ ├── collect_workfile.py │ │ │ │ ├── extract_lens_distortion_nuke.py │ │ │ │ ├── extract_matchmove_script_maya.py │ │ │ │ ├── extract_matchmove_script_nuke.py │ │ │ │ ├── validate_camera_pointgroup.py │ │ │ │ └── validate_instance_camera_data.py │ │ │ ├── startup/ │ │ │ │ ├── ayon_create.py │ │ │ │ ├── ayon_load.py │ │ │ │ ├── ayon_manage.py │ │ │ │ ├── ayon_publish.py │ │ │ │ └── ayon_workfile.py │ │ │ └── tests/ │ │ │ └── test_plugin.py │ │ ├── flame/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── batch_utils.py │ │ │ │ ├── constants.py │ │ │ │ ├── lib.py │ │ │ │ ├── menu.py │ │ │ │ ├── pipeline.py │ │ │ │ ├── plugin.py │ │ │ │ ├── render_utils.py │ │ │ │ ├── scripts/ │ │ │ │ │ └── wiretap_com.py │ │ │ │ ├── utils.py │ │ │ │ └── workio.py │ │ │ ├── hooks/ │ │ │ │ └── pre_flame_setup.py │ │ │ ├── otio/ │ │ │ │ ├── __init__.py │ │ │ │ ├── flame_export.py │ │ │ │ └── utils.py │ │ │ ├── plugins/ │ │ │ │ ├── create/ │ │ │ │ │ └── create_shot_clip.py │ │ │ │ ├── load/ │ │ │ │ │ ├── load_clip.py │ │ │ │ │ └── load_clip_batch.py │ │ │ │ └── publish/ │ │ │ │ ├── collect_test_selection.py │ │ │ │ ├── collect_timeline_instances.py │ │ │ │ ├── collect_timeline_otio.py │ │ │ │ ├── extract_otio_file.py │ │ │ │ ├── extract_subset_resources.py │ │ │ │ └── integrate_batch_group.py │ │ │ └── startup/ │ │ │ ├── openpype_babypublisher/ │ │ │ │ ├── export_preset/ │ │ │ │ │ ├── openpype_seg_thumbnails_jpg.xml │ │ │ │ │ └── openpype_seg_video_h264.xml │ │ │ │ ├── modules/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── app_utils.py │ │ │ │ │ ├── ftrack_lib.py │ │ │ │ │ ├── panel_app.py │ │ │ │ │ └── uiwidgets.py │ │ │ │ └── openpype_babypublisher.py │ │ │ └── openpype_in_flame.py │ │ ├── fusion/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── action.py │ │ │ │ ├── lib.py │ │ │ │ ├── menu.py │ │ │ │ ├── pipeline.py │ │ │ │ ├── plugin.py │ │ │ │ └── pulse.py │ │ │ ├── deploy/ │ │ │ │ ├── MenuScripts/ │ │ │ │ │ ├── README.md │ │ │ │ │ ├── install_pyside2.py │ │ │ │ │ └── launch_menu.py │ │ │ │ ├── ayon/ │ │ │ │ │ ├── Config/ │ │ │ │ │ │ └── menu.fu │ │ │ │ │ └── fusion_shared.prefs │ │ │ │ └── openpype/ │ │ │ │ ├── Config/ │ │ │ │ │ └── menu.fu │ │ │ │ └── fusion_shared.prefs │ │ │ ├── hooks/ │ │ │ │ ├── pre_fusion_profile_hook.py │ │ │ │ ├── pre_fusion_setup.py │ │ │ │ └── pre_pyside_install.py │ │ │ ├── plugins/ │ │ │ │ ├── create/ │ │ │ │ │ ├── create_image_saver.py │ │ │ │ │ ├── create_saver.py │ │ │ │ │ └── create_workfile.py │ │ │ │ ├── inventory/ │ │ │ │ │ ├── select_containers.py │ │ │ │ │ └── set_tool_color.py │ │ │ │ ├── load/ │ │ │ │ │ ├── actions.py │ │ │ │ │ ├── load_alembic.py │ │ │ │ │ ├── load_fbx.py │ │ │ │ │ ├── load_sequence.py │ │ │ │ │ ├── load_usd.py │ │ │ │ │ └── load_workfile.py │ │ │ │ └── publish/ │ │ │ │ ├── collect_comp.py │ │ │ │ ├── collect_comp_frame_range.py │ │ │ │ ├── collect_inputs.py │ │ │ │ ├── collect_instances.py │ │ │ │ ├── collect_render.py │ │ │ │ ├── collect_workfile.py │ │ │ │ ├── extract_render_local.py │ │ │ │ ├── increment_current_file.py │ │ │ │ ├── save_scene.py │ │ │ │ ├── validate_background_depth.py │ │ │ │ ├── validate_comp_saved.py │ │ │ │ ├── validate_create_folder_checked.py │ │ │ │ ├── validate_expected_frames_existence.py │ │ │ │ ├── validate_filename_has_extension.py │ │ │ │ ├── validate_image_frame.py │ │ │ │ ├── validate_instance_frame_range.py │ │ │ │ ├── validate_saver_has_input.py │ │ │ │ ├── validate_saver_passthrough.py │ │ │ │ ├── validate_saver_resolution.py │ │ │ │ └── validate_unique_subsets.py │ │ │ ├── scripts/ │ │ │ │ ├── __init__.py │ │ │ │ └── duplicate_with_inputs.py │ │ │ └── vendor/ │ │ │ ├── attr/ │ │ │ │ ├── __init__.py │ │ │ │ ├── __init__.pyi │ │ │ │ ├── _cmp.py │ │ │ │ ├── _cmp.pyi │ │ │ │ ├── _compat.py │ │ │ │ ├── _config.py │ │ │ │ ├── _funcs.py │ │ │ │ ├── _make.py │ │ │ │ ├── _next_gen.py │ │ │ │ ├── _version_info.py │ │ │ │ ├── _version_info.pyi │ │ │ │ ├── converters.py │ │ │ │ ├── converters.pyi │ │ │ │ ├── exceptions.py │ │ │ │ ├── exceptions.pyi │ │ │ │ ├── filters.py │ │ │ │ ├── filters.pyi │ │ │ │ ├── py.typed │ │ │ │ ├── setters.py │ │ │ │ ├── setters.pyi │ │ │ │ ├── validators.py │ │ │ │ └── validators.pyi │ │ │ └── urllib3/ │ │ │ ├── __init__.py │ │ │ ├── _collections.py │ │ │ ├── _version.py │ │ │ ├── connection.py │ │ │ ├── connectionpool.py │ │ │ ├── contrib/ │ │ │ │ ├── __init__.py │ │ │ │ ├── _appengine_environ.py │ │ │ │ ├── _securetransport/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── bindings.py │ │ │ │ │ └── low_level.py │ │ │ │ ├── appengine.py │ │ │ │ ├── ntlmpool.py │ │ │ │ ├── pyopenssl.py │ │ │ │ ├── securetransport.py │ │ │ │ └── socks.py │ │ │ ├── exceptions.py │ │ │ ├── fields.py │ │ │ ├── filepost.py │ │ │ ├── packages/ │ │ │ │ ├── __init__.py │ │ │ │ ├── backports/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── makefile.py │ │ │ │ ├── six.py │ │ │ │ └── ssl_match_hostname/ │ │ │ │ ├── __init__.py │ │ │ │ └── _implementation.py │ │ │ ├── poolmanager.py │ │ │ ├── request.py │ │ │ ├── response.py │ │ │ └── util/ │ │ │ ├── __init__.py │ │ │ ├── connection.py │ │ │ ├── proxy.py │ │ │ ├── queue.py │ │ │ ├── request.py │ │ │ ├── response.py │ │ │ ├── retry.py │ │ │ ├── ssl_.py │ │ │ ├── ssltransport.py │ │ │ ├── timeout.py │ │ │ ├── url.py │ │ │ └── wait.py │ │ ├── harmony/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── README.md │ │ │ │ ├── TB_sceneOpened.js │ │ │ │ ├── __init__.py │ │ │ │ ├── js/ │ │ │ │ │ ├── .eslintrc.json │ │ │ │ │ └── AvalonHarmony.js │ │ │ │ ├── lib.py │ │ │ │ ├── pipeline.py │ │ │ │ ├── plugin.py │ │ │ │ ├── server.py │ │ │ │ └── workio.py │ │ │ ├── js/ │ │ │ │ ├── .eslintrc.json │ │ │ │ ├── PypeHarmony.js │ │ │ │ ├── README.md │ │ │ │ ├── creators/ │ │ │ │ │ └── CreateRender.js │ │ │ │ ├── loaders/ │ │ │ │ │ ├── ImageSequenceLoader.js │ │ │ │ │ └── TemplateLoader.js │ │ │ │ └── publish/ │ │ │ │ ├── CollectCurrentFile.js │ │ │ │ ├── CollectFarmRender.js │ │ │ │ ├── CollectPalettes.js │ │ │ │ ├── ExtractPalette.js │ │ │ │ └── ExtractTemplate.js │ │ │ ├── plugins/ │ │ │ │ ├── __init__.py │ │ │ │ ├── create/ │ │ │ │ │ ├── create_farm_render.py │ │ │ │ │ ├── create_render.py │ │ │ │ │ └── create_template.py │ │ │ │ ├── load/ │ │ │ │ │ ├── load_audio.py │ │ │ │ │ ├── load_background.py │ │ │ │ │ ├── load_imagesequence.py │ │ │ │ │ ├── load_palette.py │ │ │ │ │ ├── load_template.py │ │ │ │ │ └── load_template_workfile.py │ │ │ │ └── publish/ │ │ │ │ ├── collect_audio.py │ │ │ │ ├── collect_current_file.py │ │ │ │ ├── collect_farm_render.py │ │ │ │ ├── collect_instances.py │ │ │ │ ├── collect_palettes.py │ │ │ │ ├── collect_scene.py │ │ │ │ ├── collect_workfile.py │ │ │ │ ├── extract_palette.py │ │ │ │ ├── extract_render.py │ │ │ │ ├── extract_save_scene.py │ │ │ │ ├── extract_template.py │ │ │ │ ├── extract_workfile.py │ │ │ │ ├── help/ │ │ │ │ │ ├── validate_audio.xml │ │ │ │ │ ├── validate_instances.xml │ │ │ │ │ └── validate_scene_settings.xml │ │ │ │ ├── increment_workfile.py │ │ │ │ ├── validate_audio.py │ │ │ │ ├── validate_instances.py │ │ │ │ └── validate_scene_settings.py │ │ │ └── vendor/ │ │ │ ├── .eslintrc.json │ │ │ └── OpenHarmony/ │ │ │ ├── .gitattributes │ │ │ ├── .gitignore │ │ │ ├── Install.bat │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── build_doc.bat │ │ │ ├── documentation.json │ │ │ ├── install.sh │ │ │ ├── openHarmony/ │ │ │ │ ├── openHarmony_actions.js │ │ │ │ ├── openHarmony_application.js │ │ │ │ ├── openHarmony_attribute.js │ │ │ │ ├── openHarmony_backdrop.js │ │ │ │ ├── openHarmony_color.js │ │ │ │ ├── openHarmony_column.js │ │ │ │ ├── openHarmony_database.js │ │ │ │ ├── openHarmony_dialog.js │ │ │ │ ├── openHarmony_drawing.js │ │ │ │ ├── openHarmony_element.js │ │ │ │ ├── openHarmony_file.js │ │ │ │ ├── openHarmony_frame.js │ │ │ │ ├── openHarmony_list.js │ │ │ │ ├── openHarmony_math.js │ │ │ │ ├── openHarmony_metadata.js │ │ │ │ ├── openHarmony_misc.js │ │ │ │ ├── openHarmony_network.js │ │ │ │ ├── openHarmony_node.js │ │ │ │ ├── openHarmony_nodeAttributes.js │ │ │ │ ├── openHarmony_nodeLink.js │ │ │ │ ├── openHarmony_palette.js │ │ │ │ ├── openHarmony_path.js │ │ │ │ ├── openHarmony_preferencedoc.js │ │ │ │ ├── openHarmony_preferences.js │ │ │ │ ├── openHarmony_scene.js │ │ │ │ ├── openHarmony_threading.js │ │ │ │ ├── openHarmony_timeline.js │ │ │ │ ├── openHarmony_tool.js │ │ │ │ └── openHarmony_toolInstall.ui │ │ │ ├── openHarmony.js │ │ │ ├── openHarmony_install.js │ │ │ ├── openHarmony_tools.js │ │ │ ├── reference/ │ │ │ │ └── Reference_view_currentToolManager().txt │ │ │ ├── tbpackage.json │ │ │ └── tools/ │ │ │ └── OpenHarmony_basic/ │ │ │ ├── INSTALL │ │ │ ├── README │ │ │ ├── openHarmony_anim_tools.js │ │ │ ├── openHarmony_basic_backdropPicker.ui │ │ │ └── openHarmony_rigging_tools.js │ │ ├── hiero/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── constants.py │ │ │ │ ├── events.py │ │ │ │ ├── launchforhiero.py │ │ │ │ ├── lib.py │ │ │ │ ├── menu.py │ │ │ │ ├── otio/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── hiero_export.py │ │ │ │ │ ├── hiero_import.py │ │ │ │ │ └── utils.py │ │ │ │ ├── pipeline.py │ │ │ │ ├── plugin.py │ │ │ │ ├── startup/ │ │ │ │ │ ├── HieroPlayer/ │ │ │ │ │ │ └── PlayerPresets.hrox │ │ │ │ │ ├── Icons/ │ │ │ │ │ │ ├── layers.psd │ │ │ │ │ │ ├── resolution.psd │ │ │ │ │ │ ├── retiming.psd │ │ │ │ │ │ └── review.psd │ │ │ │ │ ├── Python/ │ │ │ │ │ │ ├── Startup/ │ │ │ │ │ │ │ ├── SpreadsheetExport.py │ │ │ │ │ │ │ ├── Startup.py │ │ │ │ │ │ │ ├── otioexporter/ │ │ │ │ │ │ │ │ ├── OTIOExportTask.py │ │ │ │ │ │ │ │ ├── OTIOExportUI.py │ │ │ │ │ │ │ │ └── __init__.py │ │ │ │ │ │ │ ├── project_helpers.py │ │ │ │ │ │ │ ├── selection_tracker.py │ │ │ │ │ │ │ └── setFrameRate.py │ │ │ │ │ │ └── StartupUI/ │ │ │ │ │ │ ├── PimpMySpreadsheet.py │ │ │ │ │ │ ├── Purge.py │ │ │ │ │ │ ├── nukeStyleKeyboardShortcuts.py │ │ │ │ │ │ ├── otioimporter/ │ │ │ │ │ │ │ ├── OTIOImport.py │ │ │ │ │ │ │ └── __init__.py │ │ │ │ │ │ └── setPosterFrame.py │ │ │ │ │ └── TaskPresets/ │ │ │ │ │ ├── 10.5/ │ │ │ │ │ │ └── Processors/ │ │ │ │ │ │ └── hiero.exporters.FnShotProcessor.ShotProcessor/ │ │ │ │ │ │ └── pipeline.xml │ │ │ │ │ ├── 11.1/ │ │ │ │ │ │ └── Processors/ │ │ │ │ │ │ └── hiero.exporters.FnShotProcessor.ShotProcessor/ │ │ │ │ │ │ └── pipeline.xml │ │ │ │ │ └── 11.2/ │ │ │ │ │ └── hiero.exporters.FnShotProcessor.ShotProcessor/ │ │ │ │ │ └── pipeline.xml │ │ │ │ ├── style.css │ │ │ │ ├── tags.py │ │ │ │ └── workio.py │ │ │ ├── plugins/ │ │ │ │ ├── create/ │ │ │ │ │ └── create_shot_clip.py │ │ │ │ ├── load/ │ │ │ │ │ ├── load_clip.py │ │ │ │ │ └── load_effects.py │ │ │ │ ├── publish/ │ │ │ │ │ ├── collect_clip_effects.py │ │ │ │ │ ├── collect_frame_tag_instances.py │ │ │ │ │ ├── collect_tag_tasks.py │ │ │ │ │ ├── extract_clip_effects.py │ │ │ │ │ ├── extract_frames.py │ │ │ │ │ ├── extract_thumbnail.py │ │ │ │ │ ├── integrate_version_up_workfile.py │ │ │ │ │ ├── precollect_instances.py │ │ │ │ │ └── precollect_workfile.py │ │ │ │ └── publish_old_workflow/ │ │ │ │ ├── collect_assetbuilds.py │ │ │ │ ├── collect_tag_comments.py │ │ │ │ └── precollect_retime.py │ │ │ └── vendor/ │ │ │ └── google/ │ │ │ └── protobuf/ │ │ │ ├── __init__.py │ │ │ ├── any_pb2.py │ │ │ ├── api_pb2.py │ │ │ ├── compiler/ │ │ │ │ ├── __init__.py │ │ │ │ └── plugin_pb2.py │ │ │ ├── descriptor.py │ │ │ ├── descriptor_database.py │ │ │ ├── descriptor_pb2.py │ │ │ ├── descriptor_pool.py │ │ │ ├── duration_pb2.py │ │ │ ├── empty_pb2.py │ │ │ ├── field_mask_pb2.py │ │ │ ├── internal/ │ │ │ │ ├── __init__.py │ │ │ │ ├── _parameterized.py │ │ │ │ ├── api_implementation.py │ │ │ │ ├── builder.py │ │ │ │ ├── containers.py │ │ │ │ ├── decoder.py │ │ │ │ ├── encoder.py │ │ │ │ ├── enum_type_wrapper.py │ │ │ │ ├── extension_dict.py │ │ │ │ ├── message_listener.py │ │ │ │ ├── message_set_extensions_pb2.py │ │ │ │ ├── missing_enum_values_pb2.py │ │ │ │ ├── more_extensions_dynamic_pb2.py │ │ │ │ ├── more_extensions_pb2.py │ │ │ │ ├── more_messages_pb2.py │ │ │ │ ├── no_package_pb2.py │ │ │ │ ├── python_message.py │ │ │ │ ├── type_checkers.py │ │ │ │ ├── well_known_types.py │ │ │ │ └── wire_format.py │ │ │ ├── json_format.py │ │ │ ├── message.py │ │ │ ├── message_factory.py │ │ │ ├── proto_builder.py │ │ │ ├── pyext/ │ │ │ │ ├── __init__.py │ │ │ │ ├── cpp_message.py │ │ │ │ └── python_pb2.py │ │ │ ├── reflection.py │ │ │ ├── service.py │ │ │ ├── service_reflection.py │ │ │ ├── source_context_pb2.py │ │ │ ├── struct_pb2.py │ │ │ ├── symbol_database.py │ │ │ ├── text_encoding.py │ │ │ ├── text_format.py │ │ │ ├── timestamp_pb2.py │ │ │ ├── type_pb2.py │ │ │ ├── util/ │ │ │ │ ├── __init__.py │ │ │ │ ├── json_format_pb2.py │ │ │ │ └── json_format_proto3_pb2.py │ │ │ └── wrappers_pb2.py │ │ ├── houdini/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── action.py │ │ │ │ ├── colorspace.py │ │ │ │ ├── creator_node_shelves.py │ │ │ │ ├── lib.py │ │ │ │ ├── pipeline.py │ │ │ │ ├── plugin.py │ │ │ │ ├── shelves.py │ │ │ │ └── usd.py │ │ │ ├── hooks/ │ │ │ │ └── set_paths.py │ │ │ ├── plugins/ │ │ │ │ ├── create/ │ │ │ │ │ ├── convert_legacy.py │ │ │ │ │ ├── create_alembic_camera.py │ │ │ │ │ ├── create_arnold_ass.py │ │ │ │ │ ├── create_arnold_rop.py │ │ │ │ │ ├── create_bgeo.py │ │ │ │ │ ├── create_composite.py │ │ │ │ │ ├── create_hda.py │ │ │ │ │ ├── create_karma_rop.py │ │ │ │ │ ├── create_mantra_ifd.py │ │ │ │ │ ├── create_mantra_rop.py │ │ │ │ │ ├── create_pointcache.py │ │ │ │ │ ├── create_redshift_proxy.py │ │ │ │ │ ├── create_redshift_rop.py │ │ │ │ │ ├── create_review.py │ │ │ │ │ ├── create_staticmesh.py │ │ │ │ │ ├── create_usd.py │ │ │ │ │ ├── create_usdrender.py │ │ │ │ │ ├── create_vbd_cache.py │ │ │ │ │ ├── create_vray_rop.py │ │ │ │ │ └── create_workfile.py │ │ │ │ ├── inventory/ │ │ │ │ │ └── set_camera_resolution.py │ │ │ │ ├── load/ │ │ │ │ │ ├── actions.py │ │ │ │ │ ├── load_alembic.py │ │ │ │ │ ├── load_alembic_archive.py │ │ │ │ │ ├── load_ass.py │ │ │ │ │ ├── load_bgeo.py │ │ │ │ │ ├── load_camera.py │ │ │ │ │ ├── load_fbx.py │ │ │ │ │ ├── load_hda.py │ │ │ │ │ ├── load_image.py │ │ │ │ │ ├── load_redshift_proxy.py │ │ │ │ │ ├── load_usd_layer.py │ │ │ │ │ ├── load_usd_reference.py │ │ │ │ │ ├── load_vdb.py │ │ │ │ │ └── show_usdview.py │ │ │ │ └── publish/ │ │ │ │ ├── collect_active_state.py │ │ │ │ ├── collect_arnold_rop.py │ │ │ │ ├── collect_asset_handles.py │ │ │ │ ├── collect_cache_farm.py │ │ │ │ ├── collect_chunk_size.py │ │ │ │ ├── collect_current_file.py │ │ │ │ ├── collect_frames.py │ │ │ │ ├── collect_inputs.py │ │ │ │ ├── collect_instances.py │ │ │ │ ├── collect_instances_usd_layered.py │ │ │ │ ├── collect_karma_rop.py │ │ │ │ ├── collect_mantra_rop.py │ │ │ │ ├── collect_output_node.py │ │ │ │ ├── collect_pointcache_type.py │ │ │ │ ├── collect_redshift_rop.py │ │ │ │ ├── collect_remote_publish.py │ │ │ │ ├── collect_render_products.py │ │ │ │ ├── collect_review_data.py │ │ │ │ ├── collect_rop_frame_range.py │ │ │ │ ├── collect_staticmesh_type.py │ │ │ │ ├── collect_usd_bootstrap.py │ │ │ │ ├── collect_usd_layers.py │ │ │ │ ├── collect_vray_rop.py │ │ │ │ ├── collect_workfile.py │ │ │ │ ├── collect_workscene_fps.py │ │ │ │ ├── extract_alembic.py │ │ │ │ ├── extract_ass.py │ │ │ │ ├── extract_bgeo.py │ │ │ │ ├── extract_composite.py │ │ │ │ ├── extract_fbx.py │ │ │ │ ├── extract_hda.py │ │ │ │ ├── extract_mantra_ifd.py │ │ │ │ ├── extract_opengl.py │ │ │ │ ├── extract_redshift_proxy.py │ │ │ │ ├── extract_usd.py │ │ │ │ ├── extract_usd_layered.py │ │ │ │ ├── extract_vdb_cache.py │ │ │ │ ├── help/ │ │ │ │ │ └── validate_vdb_output_node.xml │ │ │ │ ├── increment_current_file.py │ │ │ │ ├── save_scene.py │ │ │ │ ├── validate_abc_primitive_to_detail.py │ │ │ │ ├── validate_alembic_face_sets.py │ │ │ │ ├── validate_alembic_input_node.py │ │ │ │ ├── validate_animation_settings.py │ │ │ │ ├── validate_bypass.py │ │ │ │ ├── validate_camera_rop.py │ │ │ │ ├── validate_cop_output_node.py │ │ │ │ ├── validate_fbx_output_node.py │ │ │ │ ├── validate_file_extension.py │ │ │ │ ├── validate_frame_range.py │ │ │ │ ├── validate_frame_token.py │ │ │ │ ├── validate_houdini_license_category.py │ │ │ │ ├── validate_mesh_is_static.py │ │ │ │ ├── validate_mkpaths_toggled.py │ │ │ │ ├── validate_no_errors.py │ │ │ │ ├── validate_primitive_hierarchy_paths.py │ │ │ │ ├── validate_remote_publish.py │ │ │ │ ├── validate_remote_publish_enabled.py │ │ │ │ ├── validate_review_colorspace.py │ │ │ │ ├── validate_scene_review.py │ │ │ │ ├── validate_sop_output_node.py │ │ │ │ ├── validate_subset_name.py │ │ │ │ ├── validate_unreal_staticmesh_naming.py │ │ │ │ ├── validate_usd_layer_path_backslashes.py │ │ │ │ ├── validate_usd_model_and_shade.py │ │ │ │ ├── validate_usd_output_node.py │ │ │ │ ├── validate_usd_render_product_names.py │ │ │ │ ├── validate_usd_setdress.py │ │ │ │ ├── validate_usd_shade_model_exists.py │ │ │ │ ├── validate_usd_shade_workspace.py │ │ │ │ ├── validate_vdb_output_node.py │ │ │ │ └── validate_workfile_paths.py │ │ │ └── startup/ │ │ │ ├── MainMenuCommon.xml │ │ │ ├── python2.7libs/ │ │ │ │ └── pythonrc.py │ │ │ ├── python3.10libs/ │ │ │ │ └── pythonrc.py │ │ │ ├── python3.7libs/ │ │ │ │ └── pythonrc.py │ │ │ └── python3.9libs/ │ │ │ └── pythonrc.py │ │ ├── max/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── action.py │ │ │ │ ├── colorspace.py │ │ │ │ ├── lib.py │ │ │ │ ├── lib_renderproducts.py │ │ │ │ ├── lib_rendersettings.py │ │ │ │ ├── menu.py │ │ │ │ ├── pipeline.py │ │ │ │ ├── plugin.py │ │ │ │ └── preview_animation.py │ │ │ ├── hooks/ │ │ │ │ ├── force_startup_script.py │ │ │ │ ├── inject_python.py │ │ │ │ └── set_paths.py │ │ │ ├── plugins/ │ │ │ │ ├── __init__.py │ │ │ │ ├── create/ │ │ │ │ │ ├── create_camera.py │ │ │ │ │ ├── create_maxScene.py │ │ │ │ │ ├── create_model.py │ │ │ │ │ ├── create_pointcache.py │ │ │ │ │ ├── create_pointcloud.py │ │ │ │ │ ├── create_redshift_proxy.py │ │ │ │ │ ├── create_render.py │ │ │ │ │ ├── create_review.py │ │ │ │ │ ├── create_tycache.py │ │ │ │ │ └── create_workfile.py │ │ │ │ ├── load/ │ │ │ │ │ ├── load_camera_fbx.py │ │ │ │ │ ├── load_max_scene.py │ │ │ │ │ ├── load_model.py │ │ │ │ │ ├── load_model_fbx.py │ │ │ │ │ ├── load_model_obj.py │ │ │ │ │ ├── load_model_usd.py │ │ │ │ │ ├── load_pointcache.py │ │ │ │ │ ├── load_pointcache_ornatrix.py │ │ │ │ │ ├── load_pointcloud.py │ │ │ │ │ ├── load_redshift_proxy.py │ │ │ │ │ └── load_tycache.py │ │ │ │ └── publish/ │ │ │ │ ├── collect_frame_range.py │ │ │ │ ├── collect_members.py │ │ │ │ ├── collect_render.py │ │ │ │ ├── collect_review.py │ │ │ │ ├── collect_tycache_attributes.py │ │ │ │ ├── collect_workfile.py │ │ │ │ ├── extract_alembic.py │ │ │ │ ├── extract_fbx.py │ │ │ │ ├── extract_max_scene_raw.py │ │ │ │ ├── extract_model_obj.py │ │ │ │ ├── extract_model_usd.py │ │ │ │ ├── extract_pointcloud.py │ │ │ │ ├── extract_redshift_proxy.py │ │ │ │ ├── extract_review_animation.py │ │ │ │ ├── extract_thumbnail.py │ │ │ │ ├── extract_tycache.py │ │ │ │ ├── increment_workfile_version.py │ │ │ │ ├── save_scene.py │ │ │ │ ├── save_scenes_for_cameras.py │ │ │ │ ├── validate_attributes.py │ │ │ │ ├── validate_camera_attributes.py │ │ │ │ ├── validate_camera_contents.py │ │ │ │ ├── validate_frame_range.py │ │ │ │ ├── validate_instance_has_members.py │ │ │ │ ├── validate_instance_in_context.py │ │ │ │ ├── validate_loaded_plugin.py │ │ │ │ ├── validate_model_contents.py │ │ │ │ ├── validate_pointcloud.py │ │ │ │ ├── validate_renderable_camera.py │ │ │ │ ├── validate_renderer_redshift_proxy.py │ │ │ │ ├── validate_renderpasses.py │ │ │ │ ├── validate_resolution_setting.py │ │ │ │ ├── validate_scene_saved.py │ │ │ │ └── validate_tyflow_data.py │ │ │ └── startup/ │ │ │ ├── startup.ms │ │ │ └── startup.py │ │ ├── maya/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── action.py │ │ │ │ ├── alembic.py │ │ │ │ ├── commands.py │ │ │ │ ├── customize.py │ │ │ │ ├── exitstack.py │ │ │ │ ├── fbx.py │ │ │ │ ├── gltf.py │ │ │ │ ├── lib.py │ │ │ │ ├── lib_renderproducts.py │ │ │ │ ├── lib_rendersettings.py │ │ │ │ ├── lib_rendersetup.py │ │ │ │ ├── menu.py │ │ │ │ ├── pipeline.py │ │ │ │ ├── plugin.py │ │ │ │ ├── render_setup_tools.py │ │ │ │ ├── setdress.py │ │ │ │ ├── shader_definition_editor.py │ │ │ │ ├── workfile_template_builder.py │ │ │ │ └── workio.py │ │ │ ├── hooks/ │ │ │ │ ├── pre_auto_load_plugins.py │ │ │ │ ├── pre_copy_mel.py │ │ │ │ └── pre_open_workfile_post_initialization.py │ │ │ ├── lib.py │ │ │ ├── plugins/ │ │ │ │ ├── __init__.py │ │ │ │ ├── create/ │ │ │ │ │ ├── convert_legacy.py │ │ │ │ │ ├── create_animation_pointcache.py │ │ │ │ │ ├── create_arnold_scene_source.py │ │ │ │ │ ├── create_assembly.py │ │ │ │ │ ├── create_camera.py │ │ │ │ │ ├── create_layout.py │ │ │ │ │ ├── create_look.py │ │ │ │ │ ├── create_matchmove.py │ │ │ │ │ ├── create_maya_usd.py │ │ │ │ │ ├── create_mayascene.py │ │ │ │ │ ├── create_model.py │ │ │ │ │ ├── create_multishot_layout.py │ │ │ │ │ ├── create_multiverse_look.py │ │ │ │ │ ├── create_multiverse_usd.py │ │ │ │ │ ├── create_multiverse_usd_comp.py │ │ │ │ │ ├── create_multiverse_usd_over.py │ │ │ │ │ ├── create_proxy_abc.py │ │ │ │ │ ├── create_redshift_proxy.py │ │ │ │ │ ├── create_render.py │ │ │ │ │ ├── create_rendersetup.py │ │ │ │ │ ├── create_review.py │ │ │ │ │ ├── create_rig.py │ │ │ │ │ ├── create_setdress.py │ │ │ │ │ ├── create_unreal_skeletalmesh.py │ │ │ │ │ ├── create_unreal_staticmesh.py │ │ │ │ │ ├── create_unreal_yeticache.py │ │ │ │ │ ├── create_vrayproxy.py │ │ │ │ │ ├── create_vrayscene.py │ │ │ │ │ ├── create_workfile.py │ │ │ │ │ ├── create_xgen.py │ │ │ │ │ ├── create_yeti_cache.py │ │ │ │ │ └── create_yeti_rig.py │ │ │ │ ├── inventory/ │ │ │ │ │ ├── connect_geometry.py │ │ │ │ │ ├── connect_xgen.py │ │ │ │ │ ├── connect_yeti_rig.py │ │ │ │ │ ├── import_modelrender.py │ │ │ │ │ ├── import_reference.py │ │ │ │ │ ├── rig_recreate_animation_instance.py │ │ │ │ │ └── select_containers.py │ │ │ │ ├── load/ │ │ │ │ │ ├── _load_animation.py │ │ │ │ │ ├── actions.py │ │ │ │ │ ├── load_arnold_standin.py │ │ │ │ │ ├── load_assembly.py │ │ │ │ │ ├── load_audio.py │ │ │ │ │ ├── load_gpucache.py │ │ │ │ │ ├── load_image.py │ │ │ │ │ ├── load_image_plane.py │ │ │ │ │ ├── load_look.py │ │ │ │ │ ├── load_matchmove.py │ │ │ │ │ ├── load_maya_usd.py │ │ │ │ │ ├── load_multiverse_usd.py │ │ │ │ │ ├── load_multiverse_usd_over.py │ │ │ │ │ ├── load_redshift_proxy.py │ │ │ │ │ ├── load_reference.py │ │ │ │ │ ├── load_rendersetup.py │ │ │ │ │ ├── load_vdb_to_arnold.py │ │ │ │ │ ├── load_vdb_to_redshift.py │ │ │ │ │ ├── load_vdb_to_vray.py │ │ │ │ │ ├── load_vrayproxy.py │ │ │ │ │ ├── load_vrayscene.py │ │ │ │ │ ├── load_xgen.py │ │ │ │ │ ├── load_yeti_cache.py │ │ │ │ │ └── load_yeti_rig.py │ │ │ │ └── publish/ │ │ │ │ ├── __init__.py │ │ │ │ ├── collect_animation.py │ │ │ │ ├── collect_arnold_scene_source.py │ │ │ │ ├── collect_assembly.py │ │ │ │ ├── collect_current_file.py │ │ │ │ ├── collect_fbx_animation.py │ │ │ │ ├── collect_fbx_camera.py │ │ │ │ ├── collect_file_dependencies.py │ │ │ │ ├── collect_gltf.py │ │ │ │ ├── collect_history.py │ │ │ │ ├── collect_inputs.py │ │ │ │ ├── collect_instances.py │ │ │ │ ├── collect_look.py │ │ │ │ ├── collect_maya_scene_time.py │ │ │ │ ├── collect_maya_units.py │ │ │ │ ├── collect_maya_workspace.py │ │ │ │ ├── collect_model.py │ │ │ │ ├── collect_multiverse_look.py │ │ │ │ ├── collect_pointcache.py │ │ │ │ ├── collect_remove_marked.py │ │ │ │ ├── collect_render.py │ │ │ │ ├── collect_render_layer_aovs.py │ │ │ │ ├── collect_renderable_camera.py │ │ │ │ ├── collect_review.py │ │ │ │ ├── collect_rig_sets.py │ │ │ │ ├── collect_skeleton_mesh.py │ │ │ │ ├── collect_unreal_skeletalmesh.py │ │ │ │ ├── collect_unreal_staticmesh.py │ │ │ │ ├── collect_user_defined_attributes.py │ │ │ │ ├── collect_vrayproxy.py │ │ │ │ ├── collect_vrayscene.py │ │ │ │ ├── collect_workfile.py │ │ │ │ ├── collect_workscene_fps.py │ │ │ │ ├── collect_xgen.py │ │ │ │ ├── collect_yeti_cache.py │ │ │ │ ├── collect_yeti_rig.py │ │ │ │ ├── determine_future_version.py │ │ │ │ ├── extract_active_view_thumbnail.py │ │ │ │ ├── extract_arnold_scene_source.py │ │ │ │ ├── extract_assembly.py │ │ │ │ ├── extract_camera_alembic.py │ │ │ │ ├── extract_camera_mayaScene.py │ │ │ │ ├── extract_fbx.py │ │ │ │ ├── extract_fbx_animation.py │ │ │ │ ├── extract_gltf.py │ │ │ │ ├── extract_gpu_cache.py │ │ │ │ ├── extract_import_reference.py │ │ │ │ ├── extract_layout.py │ │ │ │ ├── extract_look.py │ │ │ │ ├── extract_maya_scene_raw.py │ │ │ │ ├── extract_maya_usd.py │ │ │ │ ├── extract_model.py │ │ │ │ ├── extract_multiverse_look.py │ │ │ │ ├── extract_multiverse_usd.py │ │ │ │ ├── extract_multiverse_usd_comp.py │ │ │ │ ├── extract_multiverse_usd_over.py │ │ │ │ ├── extract_obj.py │ │ │ │ ├── extract_playblast.py │ │ │ │ ├── extract_pointcache.py │ │ │ │ ├── extract_proxy_abc.py │ │ │ │ ├── extract_redshift_proxy.py │ │ │ │ ├── extract_rendersetup.py │ │ │ │ ├── extract_rig.py │ │ │ │ ├── extract_skeleton_mesh.py │ │ │ │ ├── extract_thumbnail.py │ │ │ │ ├── extract_unreal_skeletalmesh_abc.py │ │ │ │ ├── extract_unreal_skeletalmesh_fbx.py │ │ │ │ ├── extract_unreal_staticmesh.py │ │ │ │ ├── extract_unreal_yeticache.py │ │ │ │ ├── extract_vrayproxy.py │ │ │ │ ├── extract_vrayscene.py │ │ │ │ ├── extract_workfile_xgen.py │ │ │ │ ├── extract_xgen.py │ │ │ │ ├── extract_yeti_cache.py │ │ │ │ ├── extract_yeti_rig.py │ │ │ │ ├── help/ │ │ │ │ │ ├── submit_maya_remote_publish_deadline.xml │ │ │ │ │ ├── validate_maya_units.xml │ │ │ │ │ ├── validate_node_ids.xml │ │ │ │ │ └── validate_skeletalmesh_hierarchy.xml │ │ │ │ ├── increment_current_file_deadline.py │ │ │ │ ├── reset_xgen_attributes.py │ │ │ │ ├── save_scene.py │ │ │ │ ├── validate_alembic_options_defaults.py │ │ │ │ ├── validate_animated_reference.py │ │ │ │ ├── validate_animation_content.py │ │ │ │ ├── validate_animation_out_set_related_node_ids.py │ │ │ │ ├── validate_arnold_scene_source.py │ │ │ │ ├── validate_arnold_scene_source_cbid.py │ │ │ │ ├── validate_ass_relative_paths.py │ │ │ │ ├── validate_assembly_name.py │ │ │ │ ├── validate_assembly_namespaces.py │ │ │ │ ├── validate_assembly_transforms.py │ │ │ │ ├── validate_attributes.py │ │ │ │ ├── validate_camera_attributes.py │ │ │ │ ├── validate_camera_contents.py │ │ │ │ ├── validate_color_sets.py │ │ │ │ ├── validate_current_renderlayer_renderable.py │ │ │ │ ├── validate_cycle_error.py │ │ │ │ ├── validate_frame_range.py │ │ │ │ ├── validate_glsl_material.py │ │ │ │ ├── validate_glsl_plugin.py │ │ │ │ ├── validate_instance_has_members.py │ │ │ │ ├── validate_instance_in_context.py │ │ │ │ ├── validate_instance_subset.py │ │ │ │ ├── validate_instancer_content.py │ │ │ │ ├── validate_instancer_frame_ranges.py │ │ │ │ ├── validate_loaded_plugin.py │ │ │ │ ├── validate_look_contents.py │ │ │ │ ├── validate_look_default_shaders_connections.py │ │ │ │ ├── validate_look_id_reference_edits.py │ │ │ │ ├── validate_look_no_default_shaders.py │ │ │ │ ├── validate_look_sets.py │ │ │ │ ├── validate_look_shading_group.py │ │ │ │ ├── validate_look_single_shader.py │ │ │ │ ├── validate_maya_units.py │ │ │ │ ├── validate_mesh_arnold_attributes.py │ │ │ │ ├── validate_mesh_empty.py │ │ │ │ ├── validate_mesh_has_uv.py │ │ │ │ ├── validate_mesh_lamina_faces.py │ │ │ │ ├── validate_mesh_ngons.py │ │ │ │ ├── validate_mesh_no_negative_scale.py │ │ │ │ ├── validate_mesh_non_manifold.py │ │ │ │ ├── validate_mesh_non_zero_edge.py │ │ │ │ ├── validate_mesh_normals_unlocked.py │ │ │ │ ├── validate_mesh_overlapping_uvs.py │ │ │ │ ├── validate_mesh_shader_connections.py │ │ │ │ ├── validate_mesh_single_uv_set.py │ │ │ │ ├── validate_mesh_uv_set_map1.py │ │ │ │ ├── validate_mesh_vertices_have_edges.py │ │ │ │ ├── validate_model_content.py │ │ │ │ ├── validate_model_name.py │ │ │ │ ├── validate_mvlook_contents.py │ │ │ │ ├── validate_no_animation.py │ │ │ │ ├── validate_no_default_camera.py │ │ │ │ ├── validate_no_namespace.py │ │ │ │ ├── validate_no_null_transforms.py │ │ │ │ ├── validate_no_unknown_nodes.py │ │ │ │ ├── validate_no_vraymesh.py │ │ │ │ ├── validate_node_ids.py │ │ │ │ ├── validate_node_ids_deformed_shapes.py │ │ │ │ ├── validate_node_ids_in_database.py │ │ │ │ ├── validate_node_ids_related.py │ │ │ │ ├── validate_node_ids_unique.py │ │ │ │ ├── validate_node_no_ghosting.py │ │ │ │ ├── validate_plugin_path_attributes.py │ │ │ │ ├── validate_render_image_rule.py │ │ │ │ ├── validate_render_no_default_cameras.py │ │ │ │ ├── validate_render_single_camera.py │ │ │ │ ├── validate_renderlayer_aovs.py │ │ │ │ ├── validate_rendersettings.py │ │ │ │ ├── validate_resolution.py │ │ │ │ ├── validate_resources.py │ │ │ │ ├── validate_review.py │ │ │ │ ├── validate_rig_contents.py │ │ │ │ ├── validate_rig_controllers.py │ │ │ │ ├── validate_rig_controllers_arnold_attributes.py │ │ │ │ ├── validate_rig_joints_hidden.py │ │ │ │ ├── validate_rig_out_set_node_ids.py │ │ │ │ ├── validate_rig_output_ids.py │ │ │ │ ├── validate_scene_set_workspace.py │ │ │ │ ├── validate_setdress_root.py │ │ │ │ ├── validate_shader_name.py │ │ │ │ ├── validate_shape_default_names.py │ │ │ │ ├── validate_shape_render_stats.py │ │ │ │ ├── validate_shape_zero.py │ │ │ │ ├── validate_single_assembly.py │ │ │ │ ├── validate_skeletalmesh_hierarchy.py │ │ │ │ ├── validate_skeletalmesh_triangulated.py │ │ │ │ ├── validate_skeleton_top_group_hierarchy.py │ │ │ │ ├── validate_skinCluster_deformer_set.py │ │ │ │ ├── validate_step_size.py │ │ │ │ ├── validate_transform_naming_suffix.py │ │ │ │ ├── validate_transform_zero.py │ │ │ │ ├── validate_unique_names.py │ │ │ │ ├── validate_unreal_mesh_triangulated.py │ │ │ │ ├── validate_unreal_staticmesh_naming.py │ │ │ │ ├── validate_unreal_up_axis.py │ │ │ │ ├── validate_visible_only.py │ │ │ │ ├── validate_vray.py │ │ │ │ ├── validate_vray_distributed_rendering.py │ │ │ │ ├── validate_vray_referenced_aovs.py │ │ │ │ ├── validate_vray_translator_settings.py │ │ │ │ ├── validate_vrayproxy.py │ │ │ │ ├── validate_vrayproxy_members.py │ │ │ │ ├── validate_xgen.py │ │ │ │ ├── validate_yeti_renderscript_callbacks.py │ │ │ │ ├── validate_yeti_rig_cache_state.py │ │ │ │ ├── validate_yeti_rig_input_in_instance.py │ │ │ │ └── validate_yeti_rig_settings.py │ │ │ ├── startup/ │ │ │ │ └── userSetup.py │ │ │ └── tools/ │ │ │ ├── __init__.py │ │ │ └── mayalookassigner/ │ │ │ ├── LICENSE │ │ │ ├── __init__.py │ │ │ ├── alembic.py │ │ │ ├── app.py │ │ │ ├── arnold_standin.py │ │ │ ├── commands.py │ │ │ ├── lib.py │ │ │ ├── models.py │ │ │ ├── usd.py │ │ │ ├── views.py │ │ │ ├── vray_proxies.py │ │ │ └── widgets.py │ │ ├── nuke/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── actions.py │ │ │ │ ├── command.py │ │ │ │ ├── constants.py │ │ │ │ ├── gizmo_menu.py │ │ │ │ ├── lib.py │ │ │ │ ├── pipeline.py │ │ │ │ ├── plugin.py │ │ │ │ ├── push_to_project.py │ │ │ │ ├── utils.py │ │ │ │ ├── workfile_template_builder.py │ │ │ │ └── workio.py │ │ │ ├── hooks/ │ │ │ │ └── pre_nukeassist_setup.py │ │ │ ├── plugins/ │ │ │ │ ├── __init__.py │ │ │ │ ├── create/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── convert_legacy.py │ │ │ │ │ ├── create_backdrop.py │ │ │ │ │ ├── create_camera.py │ │ │ │ │ ├── create_gizmo.py │ │ │ │ │ ├── create_model.py │ │ │ │ │ ├── create_source.py │ │ │ │ │ ├── create_write_image.py │ │ │ │ │ ├── create_write_prerender.py │ │ │ │ │ ├── create_write_render.py │ │ │ │ │ └── workfile_creator.py │ │ │ │ ├── inventory/ │ │ │ │ │ ├── repair_old_loaders.py │ │ │ │ │ └── select_containers.py │ │ │ │ ├── load/ │ │ │ │ │ ├── actions.py │ │ │ │ │ ├── load_backdrop.py │ │ │ │ │ ├── load_camera_abc.py │ │ │ │ │ ├── load_clip.py │ │ │ │ │ ├── load_effects.py │ │ │ │ │ ├── load_effects_ip.py │ │ │ │ │ ├── load_gizmo.py │ │ │ │ │ ├── load_gizmo_ip.py │ │ │ │ │ ├── load_image.py │ │ │ │ │ ├── load_matchmove.py │ │ │ │ │ ├── load_model.py │ │ │ │ │ ├── load_ociolook.py │ │ │ │ │ └── load_script_precomp.py │ │ │ │ └── publish/ │ │ │ │ ├── collect_backdrop.py │ │ │ │ ├── collect_context_data.py │ │ │ │ ├── collect_framerate.py │ │ │ │ ├── collect_gizmo.py │ │ │ │ ├── collect_model.py │ │ │ │ ├── collect_nuke_instance_data.py │ │ │ │ ├── collect_reads.py │ │ │ │ ├── collect_slate_node.py │ │ │ │ ├── collect_workfile.py │ │ │ │ ├── collect_writes.py │ │ │ │ ├── extract_backdrop.py │ │ │ │ ├── extract_camera.py │ │ │ │ ├── extract_gizmo.py │ │ │ │ ├── extract_model.py │ │ │ │ ├── extract_ouput_node.py │ │ │ │ ├── extract_output_directory.py │ │ │ │ ├── extract_render_local.py │ │ │ │ ├── extract_review_data.py │ │ │ │ ├── extract_review_data_lut.py │ │ │ │ ├── extract_review_intermediates.py │ │ │ │ ├── extract_script_save.py │ │ │ │ ├── extract_slate_frame.py │ │ │ │ ├── help/ │ │ │ │ │ ├── validate_asset_context.xml │ │ │ │ │ ├── validate_backdrop.xml │ │ │ │ │ ├── validate_gizmo.xml │ │ │ │ │ ├── validate_knobs.xml │ │ │ │ │ ├── validate_output_resolution.xml │ │ │ │ │ ├── validate_proxy_mode.xml │ │ │ │ │ ├── validate_rendered_frames.xml │ │ │ │ │ ├── validate_script_attributes.xml │ │ │ │ │ └── validate_write_nodes.xml │ │ │ │ ├── increment_script_version.py │ │ │ │ ├── remove_ouput_node.py │ │ │ │ ├── validate_asset_context.py │ │ │ │ ├── validate_backdrop.py │ │ │ │ ├── validate_exposed_knobs.py │ │ │ │ ├── validate_gizmo.py │ │ │ │ ├── validate_knobs.py │ │ │ │ ├── validate_output_resolution.py │ │ │ │ ├── validate_proxy_mode.py │ │ │ │ ├── validate_rendered_frames.py │ │ │ │ ├── validate_script_attributes.py │ │ │ │ └── validate_write_nodes.py │ │ │ ├── startup/ │ │ │ │ ├── __init__.py │ │ │ │ ├── clear_rendered.py │ │ │ │ ├── custom_write_node.py │ │ │ │ ├── frame_setting_for_read_nodes.py │ │ │ │ ├── menu.py │ │ │ │ └── write_to_read.py │ │ │ └── vendor/ │ │ │ └── google/ │ │ │ └── protobuf/ │ │ │ ├── __init__.py │ │ │ ├── any_pb2.py │ │ │ ├── api_pb2.py │ │ │ ├── compiler/ │ │ │ │ ├── __init__.py │ │ │ │ └── plugin_pb2.py │ │ │ ├── descriptor.py │ │ │ ├── descriptor_database.py │ │ │ ├── descriptor_pb2.py │ │ │ ├── descriptor_pool.py │ │ │ ├── duration_pb2.py │ │ │ ├── empty_pb2.py │ │ │ ├── field_mask_pb2.py │ │ │ ├── internal/ │ │ │ │ ├── __init__.py │ │ │ │ ├── _parameterized.py │ │ │ │ ├── api_implementation.py │ │ │ │ ├── builder.py │ │ │ │ ├── containers.py │ │ │ │ ├── decoder.py │ │ │ │ ├── encoder.py │ │ │ │ ├── enum_type_wrapper.py │ │ │ │ ├── extension_dict.py │ │ │ │ ├── message_listener.py │ │ │ │ ├── message_set_extensions_pb2.py │ │ │ │ ├── missing_enum_values_pb2.py │ │ │ │ ├── more_extensions_dynamic_pb2.py │ │ │ │ ├── more_extensions_pb2.py │ │ │ │ ├── more_messages_pb2.py │ │ │ │ ├── no_package_pb2.py │ │ │ │ ├── python_message.py │ │ │ │ ├── type_checkers.py │ │ │ │ ├── well_known_types.py │ │ │ │ └── wire_format.py │ │ │ ├── json_format.py │ │ │ ├── message.py │ │ │ ├── message_factory.py │ │ │ ├── proto_builder.py │ │ │ ├── pyext/ │ │ │ │ ├── __init__.py │ │ │ │ ├── cpp_message.py │ │ │ │ └── python_pb2.py │ │ │ ├── reflection.py │ │ │ ├── service.py │ │ │ ├── service_reflection.py │ │ │ ├── source_context_pb2.py │ │ │ ├── struct_pb2.py │ │ │ ├── symbol_database.py │ │ │ ├── text_encoding.py │ │ │ ├── text_format.py │ │ │ ├── timestamp_pb2.py │ │ │ ├── type_pb2.py │ │ │ ├── util/ │ │ │ │ ├── __init__.py │ │ │ │ ├── json_format_pb2.py │ │ │ │ └── json_format_proto3_pb2.py │ │ │ └── wrappers_pb2.py │ │ ├── photoshop/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── README.md │ │ │ │ ├── __init__.py │ │ │ │ ├── extension/ │ │ │ │ │ ├── .debug │ │ │ │ │ ├── CSXS/ │ │ │ │ │ │ └── manifest.xml │ │ │ │ │ ├── client/ │ │ │ │ │ │ ├── CSInterface.js │ │ │ │ │ │ ├── client.js │ │ │ │ │ │ └── wsrpc.js │ │ │ │ │ ├── host/ │ │ │ │ │ │ ├── JSX.js │ │ │ │ │ │ ├── index.jsx │ │ │ │ │ │ └── json.js │ │ │ │ │ └── index.html │ │ │ │ ├── extension.zxp │ │ │ │ ├── launch_logic.py │ │ │ │ ├── lib.py │ │ │ │ ├── pipeline.py │ │ │ │ ├── plugin.py │ │ │ │ └── ws_stub.py │ │ │ ├── lib.py │ │ │ ├── plugins/ │ │ │ │ ├── create/ │ │ │ │ │ ├── create_flatten_image.py │ │ │ │ │ ├── create_image.py │ │ │ │ │ ├── create_review.py │ │ │ │ │ └── create_workfile.py │ │ │ │ ├── load/ │ │ │ │ │ ├── load_image.py │ │ │ │ │ ├── load_image_from_sequence.py │ │ │ │ │ └── load_reference.py │ │ │ │ └── publish/ │ │ │ │ ├── closePS.py │ │ │ │ ├── collect_auto_image.py │ │ │ │ ├── collect_auto_image_refresh.py │ │ │ │ ├── collect_auto_review.py │ │ │ │ ├── collect_auto_workfile.py │ │ │ │ ├── collect_batch_data.py │ │ │ │ ├── collect_color_coded_instances.py │ │ │ │ ├── collect_current_file.py │ │ │ │ ├── collect_extension_version.py │ │ │ │ ├── collect_image.py │ │ │ │ ├── collect_published_version.py │ │ │ │ ├── collect_review.py │ │ │ │ ├── collect_version.py │ │ │ │ ├── collect_workfile.py │ │ │ │ ├── extract_image.py │ │ │ │ ├── extract_review.py │ │ │ │ ├── extract_save_scene.py │ │ │ │ ├── help/ │ │ │ │ │ ├── validate_instance_asset.xml │ │ │ │ │ └── validate_naming.xml │ │ │ │ ├── increment_workfile.py │ │ │ │ ├── validate_instance_asset.py │ │ │ │ └── validate_naming.py │ │ │ └── resources/ │ │ │ └── template.psd │ │ ├── resolve/ │ │ │ ├── README.markdown │ │ │ ├── RESOLVE_API_v18.5.1-build6.txt │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── action.py │ │ │ │ ├── lib.py │ │ │ │ ├── menu.py │ │ │ │ ├── menu_style.qss │ │ │ │ ├── pipeline.py │ │ │ │ ├── plugin.py │ │ │ │ ├── testing_utils.py │ │ │ │ ├── todo-rendering.py │ │ │ │ ├── utils.py │ │ │ │ └── workio.py │ │ │ ├── hooks/ │ │ │ │ ├── pre_resolve_last_workfile.py │ │ │ │ ├── pre_resolve_setup.py │ │ │ │ └── pre_resolve_startup.py │ │ │ ├── otio/ │ │ │ │ ├── __init__.py │ │ │ │ ├── davinci_export.py │ │ │ │ ├── davinci_import.py │ │ │ │ └── utils.py │ │ │ ├── plugins/ │ │ │ │ ├── create/ │ │ │ │ │ └── create_shot_clip.py │ │ │ │ ├── load/ │ │ │ │ │ └── load_clip.py │ │ │ │ └── publish/ │ │ │ │ ├── extract_workfile.py │ │ │ │ ├── precollect_instances.py │ │ │ │ └── precollect_workfile.py │ │ │ ├── startup.py │ │ │ ├── utility_scripts/ │ │ │ │ ├── AYON__Menu.py │ │ │ │ ├── OpenPype__Menu.py │ │ │ │ ├── develop/ │ │ │ │ │ ├── OTIO_export.py │ │ │ │ │ ├── OTIO_import.py │ │ │ │ │ └── OpenPype_sync_util_scripts.py │ │ │ │ └── openpype_startup.scriptlib │ │ │ └── utils.py │ │ ├── standalonepublisher/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ └── plugins/ │ │ │ └── publish/ │ │ │ ├── collect_app_name.py │ │ │ ├── collect_bulk_mov_instances.py │ │ │ ├── collect_context.py │ │ │ ├── collect_editorial.py │ │ │ ├── collect_editorial_instances.py │ │ │ ├── collect_editorial_resources.py │ │ │ ├── collect_harmony_scenes.py │ │ │ ├── collect_harmony_zips.py │ │ │ ├── collect_hierarchy.py │ │ │ ├── collect_instance_data.py │ │ │ ├── collect_matching_asset.py │ │ │ ├── collect_remove_marked.py │ │ │ ├── collect_representation_names.py │ │ │ ├── collect_texture.py │ │ │ ├── extract_resources.py │ │ │ ├── extract_thumbnail.py │ │ │ ├── extract_workfile_location.py │ │ │ ├── help/ │ │ │ │ ├── validate_editorial_resources.xml │ │ │ │ ├── validate_frame_ranges.xml │ │ │ │ ├── validate_shot_duplicates.xml │ │ │ │ ├── validate_simple_texture_naming.xml │ │ │ │ ├── validate_sources.xml │ │ │ │ ├── validate_task_existence.xml │ │ │ │ ├── validate_texture_batch.xml │ │ │ │ ├── validate_texture_has_workfile.xml │ │ │ │ ├── validate_texture_name.xml │ │ │ │ ├── validate_texture_versions.xml │ │ │ │ └── validate_texture_workfiles.xml │ │ │ ├── validate_editorial_resources.py │ │ │ ├── validate_frame_ranges.py │ │ │ ├── validate_shot_duplicates.py │ │ │ ├── validate_sources.py │ │ │ ├── validate_task_existence.py │ │ │ ├── validate_texture_batch.py │ │ │ ├── validate_texture_has_workfile.py │ │ │ ├── validate_texture_name.py │ │ │ ├── validate_texture_versions.py │ │ │ └── validate_texture_workfiles.py │ │ ├── substancepainter/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── colorspace.py │ │ │ │ ├── lib.py │ │ │ │ └── pipeline.py │ │ │ ├── deploy/ │ │ │ │ ├── plugins/ │ │ │ │ │ └── openpype_plugin.py │ │ │ │ └── startup/ │ │ │ │ └── openpype_load_on_first_run.py │ │ │ └── plugins/ │ │ │ ├── create/ │ │ │ │ ├── create_textures.py │ │ │ │ └── create_workfile.py │ │ │ ├── load/ │ │ │ │ └── load_mesh.py │ │ │ └── publish/ │ │ │ ├── collect_current_file.py │ │ │ ├── collect_textureset_images.py │ │ │ ├── collect_workfile_representation.py │ │ │ ├── extract_textures.py │ │ │ ├── increment_workfile.py │ │ │ ├── save_workfile.py │ │ │ └── validate_ouput_maps.py │ │ ├── traypublisher/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── editorial.py │ │ │ │ ├── pipeline.py │ │ │ │ └── plugin.py │ │ │ ├── batch_parsing.py │ │ │ └── plugins/ │ │ │ ├── create/ │ │ │ │ ├── create_colorspace_look.py │ │ │ │ ├── create_editorial.py │ │ │ │ ├── create_from_settings.py │ │ │ │ ├── create_movie_batch.py │ │ │ │ └── create_online.py │ │ │ └── publish/ │ │ │ ├── collect_app_name.py │ │ │ ├── collect_clip_instances.py │ │ │ ├── collect_colorspace_look.py │ │ │ ├── collect_editorial_instances.py │ │ │ ├── collect_editorial_reviewable.py │ │ │ ├── collect_explicit_colorspace.py │ │ │ ├── collect_frame_data_from_asset_entity.py │ │ │ ├── collect_movie_batch.py │ │ │ ├── collect_online_file.py │ │ │ ├── collect_review_frames.py │ │ │ ├── collect_sequence_frame_data.py │ │ │ ├── collect_shot_instances.py │ │ │ ├── collect_simple_instances.py │ │ │ ├── collect_source.py │ │ │ ├── extract_colorspace_look.py │ │ │ ├── help/ │ │ │ │ ├── validate_existing_version.xml │ │ │ │ └── validate_frame_ranges.xml │ │ │ ├── validate_colorspace.py │ │ │ ├── validate_colorspace_look.py │ │ │ ├── validate_existing_version.py │ │ │ ├── validate_filepaths.py │ │ │ ├── validate_frame_ranges.py │ │ │ └── validate_online_file.py │ │ ├── tvpaint/ │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── communication_server.py │ │ │ │ ├── launch_script.py │ │ │ │ ├── lib.py │ │ │ │ ├── pipeline.py │ │ │ │ └── plugin.py │ │ │ ├── hooks/ │ │ │ │ └── pre_launch_args.py │ │ │ ├── lib.py │ │ │ ├── plugins/ │ │ │ │ ├── create/ │ │ │ │ │ ├── convert_legacy.py │ │ │ │ │ ├── create_render.py │ │ │ │ │ ├── create_review.py │ │ │ │ │ └── create_workfile.py │ │ │ │ ├── load/ │ │ │ │ │ ├── load_image.py │ │ │ │ │ ├── load_reference_image.py │ │ │ │ │ ├── load_sound.py │ │ │ │ │ └── load_workfile.py │ │ │ │ └── publish/ │ │ │ │ ├── collect_instance_frames.py │ │ │ │ ├── collect_render_instances.py │ │ │ │ ├── collect_workfile.py │ │ │ │ ├── collect_workfile_data.py │ │ │ │ ├── extract_convert_to_exr.py │ │ │ │ ├── extract_sequence.py │ │ │ │ ├── help/ │ │ │ │ │ ├── validate_asset_name.xml │ │ │ │ │ ├── validate_duplicated_layer_names.xml │ │ │ │ │ ├── validate_layers_visibility.xml │ │ │ │ │ ├── validate_marks.xml │ │ │ │ │ ├── validate_missing_layer_names.xml │ │ │ │ │ ├── validate_render_layer_group.xml │ │ │ │ │ ├── validate_render_pass_group.xml │ │ │ │ │ ├── validate_scene_settings.xml │ │ │ │ │ ├── validate_start_frame.xml │ │ │ │ │ ├── validate_workfile_metadata.xml │ │ │ │ │ └── validate_workfile_project_name.xml │ │ │ │ ├── increment_workfile_version.py │ │ │ │ ├── validate_asset_name.py │ │ │ │ ├── validate_duplicated_layer_names.py │ │ │ │ ├── validate_layers_visibility.py │ │ │ │ ├── validate_marks.py │ │ │ │ ├── validate_missing_layer_names.py │ │ │ │ ├── validate_render_layer_group.py │ │ │ │ ├── validate_render_pass_group.py │ │ │ │ ├── validate_scene_settings.py │ │ │ │ ├── validate_start_frame.py │ │ │ │ ├── validate_workfile_metadata.py │ │ │ │ └── validate_workfile_project_name.py │ │ │ ├── resources/ │ │ │ │ └── template.tvpp │ │ │ ├── tvpaint_plugin/ │ │ │ │ ├── __init__.py │ │ │ │ └── plugin_code/ │ │ │ │ ├── CMakeLists.txt │ │ │ │ ├── README.md │ │ │ │ ├── library.cpp │ │ │ │ └── library.def │ │ │ └── worker/ │ │ │ ├── __init__.py │ │ │ ├── init_file.tvpp │ │ │ ├── worker.py │ │ │ └── worker_job.py │ │ ├── unreal/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── addon.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── helpers.py │ │ │ │ ├── pipeline.py │ │ │ │ ├── plugin.py │ │ │ │ ├── rendering.py │ │ │ │ └── tools_ui.py │ │ │ ├── hooks/ │ │ │ │ └── pre_workfile_preparation.py │ │ │ ├── lib.py │ │ │ ├── plugins/ │ │ │ │ ├── __init__.py │ │ │ │ ├── create/ │ │ │ │ │ ├── create_camera.py │ │ │ │ │ ├── create_layout.py │ │ │ │ │ ├── create_look.py │ │ │ │ │ ├── create_render.py │ │ │ │ │ ├── create_staticmeshfbx.py │ │ │ │ │ └── create_uasset.py │ │ │ │ ├── inventory/ │ │ │ │ │ ├── delete_unused_assets.py │ │ │ │ │ └── update_actors.py │ │ │ │ ├── load/ │ │ │ │ │ ├── load_alembic_animation.py │ │ │ │ │ ├── load_animation.py │ │ │ │ │ ├── load_camera.py │ │ │ │ │ ├── load_geometrycache_abc.py │ │ │ │ │ ├── load_layout.py │ │ │ │ │ ├── load_layout_existing.py │ │ │ │ │ ├── load_skeletalmesh_abc.py │ │ │ │ │ ├── load_skeletalmesh_fbx.py │ │ │ │ │ ├── load_staticmesh_abc.py │ │ │ │ │ ├── load_staticmesh_fbx.py │ │ │ │ │ ├── load_uasset.py │ │ │ │ │ └── load_yeticache.py │ │ │ │ └── publish/ │ │ │ │ ├── collect_current_file.py │ │ │ │ ├── collect_instance_members.py │ │ │ │ ├── collect_remove_marked.py │ │ │ │ ├── collect_render_instances.py │ │ │ │ ├── extract_camera.py │ │ │ │ ├── extract_layout.py │ │ │ │ ├── extract_look.py │ │ │ │ ├── extract_uasset.py │ │ │ │ ├── validate_no_dependencies.py │ │ │ │ └── validate_sequence_frames.py │ │ │ ├── ue_workers.py │ │ │ └── ui/ │ │ │ ├── __init__.py │ │ │ └── splash_screen.py │ │ └── webpublisher/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── addon.py │ │ ├── api/ │ │ │ └── __init__.py │ │ ├── lib.py │ │ ├── plugins/ │ │ │ └── publish/ │ │ │ ├── collect_batch_data.py │ │ │ ├── collect_fps.py │ │ │ ├── collect_published_files.py │ │ │ ├── collect_tvpaint_instances.py │ │ │ ├── collect_tvpaint_workfile_data.py │ │ │ ├── extract_tvpaint_workfile.py │ │ │ ├── others_cleanup_job_root.py │ │ │ └── validate_tvpaint_workfile_data.py │ │ ├── publish_functions.py │ │ └── webserver_service/ │ │ ├── __init__.py │ │ ├── webpublish_routes.py │ │ └── webserver.py │ ├── lib/ │ │ ├── __init__.py │ │ ├── applications.py │ │ ├── attribute_definitions.py │ │ ├── connections.py │ │ ├── dateutils.py │ │ ├── env_tools.py │ │ ├── events.py │ │ ├── execute.py │ │ ├── file_transaction.py │ │ ├── local_settings.py │ │ ├── log.py │ │ ├── openpype_version.py │ │ ├── path_templates.py │ │ ├── path_tools.py │ │ ├── plugin_tools.py │ │ ├── profiles_filtering.py │ │ ├── profiling.py │ │ ├── project_backpack.py │ │ ├── pype_info.py │ │ ├── python_2_comp.py │ │ ├── python_module_tools.py │ │ ├── terminal.py │ │ ├── transcoding.py │ │ ├── usdlib.py │ │ └── vendor_bin_utils.py │ ├── modules/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── asset_reporter/ │ │ │ ├── __init__.py │ │ │ ├── module.py │ │ │ └── window.py │ │ ├── avalon_apps/ │ │ │ ├── __init__.py │ │ │ ├── avalon_app.py │ │ │ └── rest_api.py │ │ ├── base.py │ │ ├── click_wrap.py │ │ ├── clockify/ │ │ │ ├── __init__.py │ │ │ ├── clockify_api.py │ │ │ ├── clockify_module.py │ │ │ ├── constants.py │ │ │ ├── ftrack/ │ │ │ │ ├── server/ │ │ │ │ │ └── action_clockify_sync_server.py │ │ │ │ └── user/ │ │ │ │ └── action_clockify_sync_local.py │ │ │ ├── launcher_actions/ │ │ │ │ ├── ClockifyStart.py │ │ │ │ └── ClockifySync.py │ │ │ └── widgets.py │ │ ├── deadline/ │ │ │ ├── __init__.py │ │ │ ├── abstract_submit_deadline.py │ │ │ ├── deadline_module.py │ │ │ ├── plugins/ │ │ │ │ └── publish/ │ │ │ │ ├── collect_deadline_server_from_instance.py │ │ │ │ ├── collect_default_deadline_server.py │ │ │ │ ├── collect_pools.py │ │ │ │ ├── collect_publishable_instances.py │ │ │ │ ├── help/ │ │ │ │ │ └── validate_deadline_pools.xml │ │ │ │ ├── submit_aftereffects_deadline.py │ │ │ │ ├── submit_blender_deadline.py │ │ │ │ ├── submit_celaction_deadline.py │ │ │ │ ├── submit_fusion_deadline.py │ │ │ │ ├── submit_harmony_deadline.py │ │ │ │ ├── submit_houdini_cache_deadline.py │ │ │ │ ├── submit_houdini_remote_publish.py │ │ │ │ ├── submit_houdini_render_deadline.py │ │ │ │ ├── submit_max_deadline.py │ │ │ │ ├── submit_maya_deadline.py │ │ │ │ ├── submit_maya_remote_publish_deadline.py │ │ │ │ ├── submit_nuke_deadline.py │ │ │ │ ├── submit_publish_cache_job.py │ │ │ │ ├── submit_publish_job.py │ │ │ │ ├── validate_deadline_connection.py │ │ │ │ ├── validate_deadline_pools.py │ │ │ │ └── validate_expected_and_rendered_files.py │ │ │ └── repository/ │ │ │ ├── custom/ │ │ │ │ └── plugins/ │ │ │ │ ├── Ayon/ │ │ │ │ │ ├── Ayon.options │ │ │ │ │ ├── Ayon.param │ │ │ │ │ └── Ayon.py │ │ │ │ ├── CelAction/ │ │ │ │ │ ├── CelAction.param │ │ │ │ │ └── CelAction.py │ │ │ │ ├── GlobalJobPreLoad.py │ │ │ │ ├── HarmonyOpenPype/ │ │ │ │ │ ├── HarmonyOpenPype.options │ │ │ │ │ ├── HarmonyOpenPype.param │ │ │ │ │ └── HarmonyOpenPype.py │ │ │ │ ├── OpenPype/ │ │ │ │ │ ├── OpenPype.options │ │ │ │ │ ├── OpenPype.param │ │ │ │ │ └── OpenPype.py │ │ │ │ └── OpenPypeTileAssembler/ │ │ │ │ ├── OpenPypeTileAssembler.options │ │ │ │ ├── OpenPypeTileAssembler.param │ │ │ │ └── OpenPypeTileAssembler.py │ │ │ └── readme.md │ │ ├── example_addons/ │ │ │ ├── example_addon/ │ │ │ │ ├── __init__.py │ │ │ │ ├── addon.py │ │ │ │ ├── plugins/ │ │ │ │ │ └── publish/ │ │ │ │ │ └── example_plugin.py │ │ │ │ ├── settings/ │ │ │ │ │ ├── defaults/ │ │ │ │ │ │ ├── project_settings.json │ │ │ │ │ │ └── system_settings.json │ │ │ │ │ ├── dynamic_schemas/ │ │ │ │ │ │ ├── project_dynamic_schemas.json │ │ │ │ │ │ └── system_dynamic_schemas.json │ │ │ │ │ └── schemas/ │ │ │ │ │ ├── project_schemas/ │ │ │ │ │ │ ├── main.json │ │ │ │ │ │ └── the_template.json │ │ │ │ │ └── system_schemas/ │ │ │ │ │ └── main.json │ │ │ │ └── widgets.py │ │ │ └── tiny_addon.py │ │ ├── ftrack/ │ │ │ ├── __init__.py │ │ │ ├── event_handlers_server/ │ │ │ │ ├── action_clone_review_session.py │ │ │ │ ├── action_create_review_session.py │ │ │ │ ├── action_multiple_notes.py │ │ │ │ ├── action_prepare_project.py │ │ │ │ ├── action_private_project_detection.py │ │ │ │ ├── action_push_frame_values_to_task.py │ │ │ │ ├── action_sync_to_avalon.py │ │ │ │ ├── action_tranfer_hierarchical_values.py │ │ │ │ ├── event_del_avalon_id_from_new.py │ │ │ │ ├── event_first_version_status.py │ │ │ │ ├── event_next_task_update.py │ │ │ │ ├── event_push_frame_values_to_task.py │ │ │ │ ├── event_radio_buttons.py │ │ │ │ ├── event_sync_links.py │ │ │ │ ├── event_sync_to_avalon.py │ │ │ │ ├── event_task_to_parent_status.py │ │ │ │ ├── event_task_to_version_status.py │ │ │ │ ├── event_thumbnail_updates.py │ │ │ │ ├── event_user_assigment.py │ │ │ │ └── event_version_to_task_statuses.py │ │ │ ├── event_handlers_user/ │ │ │ │ ├── action_applications.py │ │ │ │ ├── action_batch_task_creation.py │ │ │ │ ├── action_clean_hierarchical_attributes.py │ │ │ │ ├── action_client_review_sort.py │ │ │ │ ├── action_component_open.py │ │ │ │ ├── action_create_cust_attrs.py │ │ │ │ ├── action_create_folders.py │ │ │ │ ├── action_create_project_structure.py │ │ │ │ ├── action_delete_asset.py │ │ │ │ ├── action_delete_old_versions.py │ │ │ │ ├── action_delivery.py │ │ │ │ ├── action_djvview.py │ │ │ │ ├── action_fill_workfile_attr.py │ │ │ │ ├── action_job_killer.py │ │ │ │ ├── action_multiple_notes.py │ │ │ │ ├── action_prepare_project.py │ │ │ │ ├── action_rv.py │ │ │ │ ├── action_seed.py │ │ │ │ ├── action_store_thumbnails_to_avalon.py │ │ │ │ ├── action_sync_to_avalon.py │ │ │ │ ├── action_test.py │ │ │ │ ├── action_thumbnail_to_childern.py │ │ │ │ ├── action_thumbnail_to_parent.py │ │ │ │ └── action_where_run_ask.py │ │ │ ├── ftrack_module.py │ │ │ ├── ftrack_server/ │ │ │ │ ├── __init__.py │ │ │ │ ├── event_server_cli.py │ │ │ │ ├── ftrack_server.py │ │ │ │ ├── lib.py │ │ │ │ └── socket_thread.py │ │ │ ├── launch_hooks/ │ │ │ │ └── post_ftrack_changes.py │ │ │ ├── lib/ │ │ │ │ ├── __init__.py │ │ │ │ ├── avalon_sync.py │ │ │ │ ├── constants.py │ │ │ │ ├── credentials.py │ │ │ │ ├── custom_attributes.json │ │ │ │ ├── custom_attributes.py │ │ │ │ ├── ftrack_action_handler.py │ │ │ │ ├── ftrack_base_handler.py │ │ │ │ ├── ftrack_event_handler.py │ │ │ │ └── settings.py │ │ │ ├── plugins/ │ │ │ │ ├── _unused_publish/ │ │ │ │ │ └── integrate_ftrack_comments.py │ │ │ │ └── publish/ │ │ │ │ ├── collect_custom_attributes_data.py │ │ │ │ ├── collect_ftrack_api.py │ │ │ │ ├── collect_ftrack_family.py │ │ │ │ ├── collect_local_ftrack_creds.py │ │ │ │ ├── collect_username.py │ │ │ │ ├── integrate_ftrack_api.py │ │ │ │ ├── integrate_ftrack_component_overwrite.py │ │ │ │ ├── integrate_ftrack_description.py │ │ │ │ ├── integrate_ftrack_instances.py │ │ │ │ ├── integrate_ftrack_note.py │ │ │ │ ├── integrate_ftrack_status.py │ │ │ │ ├── integrate_hierarchy_ftrack.py │ │ │ │ └── validate_custom_ftrack_attributes.py │ │ │ ├── python2_vendor/ │ │ │ │ └── ftrack-python-api/ │ │ │ │ ├── .gitignore │ │ │ │ ├── LICENSE.python │ │ │ │ ├── LICENSE.txt │ │ │ │ ├── MANIFEST.in │ │ │ │ ├── README.rst │ │ │ │ ├── bitbucket-pipelines.yml │ │ │ │ ├── doc/ │ │ │ │ │ ├── _static/ │ │ │ │ │ │ └── ftrack.css │ │ │ │ │ ├── api_reference/ │ │ │ │ │ │ ├── accessor/ │ │ │ │ │ │ │ ├── base.rst │ │ │ │ │ │ │ ├── disk.rst │ │ │ │ │ │ │ ├── index.rst │ │ │ │ │ │ │ └── server.rst │ │ │ │ │ │ ├── attribute.rst │ │ │ │ │ │ ├── cache.rst │ │ │ │ │ │ ├── collection.rst │ │ │ │ │ │ ├── entity/ │ │ │ │ │ │ │ ├── asset_version.rst │ │ │ │ │ │ │ ├── base.rst │ │ │ │ │ │ │ ├── component.rst │ │ │ │ │ │ │ ├── factory.rst │ │ │ │ │ │ │ ├── index.rst │ │ │ │ │ │ │ ├── job.rst │ │ │ │ │ │ │ ├── location.rst │ │ │ │ │ │ │ ├── note.rst │ │ │ │ │ │ │ ├── project_schema.rst │ │ │ │ │ │ │ └── user.rst │ │ │ │ │ │ ├── event/ │ │ │ │ │ │ │ ├── base.rst │ │ │ │ │ │ │ ├── expression.rst │ │ │ │ │ │ │ ├── hub.rst │ │ │ │ │ │ │ ├── index.rst │ │ │ │ │ │ │ ├── subscriber.rst │ │ │ │ │ │ │ └── subscription.rst │ │ │ │ │ │ ├── exception.rst │ │ │ │ │ │ ├── formatter.rst │ │ │ │ │ │ ├── index.rst │ │ │ │ │ │ ├── inspection.rst │ │ │ │ │ │ ├── logging.rst │ │ │ │ │ │ ├── operation.rst │ │ │ │ │ │ ├── plugin.rst │ │ │ │ │ │ ├── query.rst │ │ │ │ │ │ ├── resource_identifier_transformer/ │ │ │ │ │ │ │ ├── base.rst │ │ │ │ │ │ │ └── index.rst │ │ │ │ │ │ ├── session.rst │ │ │ │ │ │ ├── structure/ │ │ │ │ │ │ │ ├── base.rst │ │ │ │ │ │ │ ├── id.rst │ │ │ │ │ │ │ ├── index.rst │ │ │ │ │ │ │ ├── origin.rst │ │ │ │ │ │ │ └── standard.rst │ │ │ │ │ │ └── symbol.rst │ │ │ │ │ ├── caching.rst │ │ │ │ │ ├── conf.py │ │ │ │ │ ├── docutils.conf │ │ │ │ │ ├── environment_variables.rst │ │ │ │ │ ├── event_list.rst │ │ │ │ │ ├── example/ │ │ │ │ │ │ ├── assignments_and_allocations.rst │ │ │ │ │ │ ├── component.rst │ │ │ │ │ │ ├── custom_attribute.rst │ │ │ │ │ │ ├── encode_media.rst │ │ │ │ │ │ ├── entity_links.rst │ │ │ │ │ │ ├── index.rst │ │ │ │ │ │ ├── invite_user.rst │ │ │ │ │ │ ├── job.rst │ │ │ │ │ │ ├── link_attribute.rst │ │ │ │ │ │ ├── list.rst │ │ │ │ │ │ ├── manage_custom_attribute_configuration.rst │ │ │ │ │ │ ├── metadata.rst │ │ │ │ │ │ ├── note.rst │ │ │ │ │ │ ├── project.rst │ │ │ │ │ │ ├── publishing.rst │ │ │ │ │ │ ├── review_session.rst │ │ │ │ │ │ ├── scope.rst │ │ │ │ │ │ ├── security_roles.rst │ │ │ │ │ │ ├── sync_ldap_users.rst │ │ │ │ │ │ ├── task_template.rst │ │ │ │ │ │ ├── thumbnail.rst │ │ │ │ │ │ ├── timer.rst │ │ │ │ │ │ └── web_review.rst │ │ │ │ │ ├── glossary.rst │ │ │ │ │ ├── handling_events.rst │ │ │ │ │ ├── index.rst │ │ │ │ │ ├── installing.rst │ │ │ │ │ ├── introduction.rst │ │ │ │ │ ├── locations/ │ │ │ │ │ │ ├── configuring.rst │ │ │ │ │ │ ├── index.rst │ │ │ │ │ │ ├── overview.rst │ │ │ │ │ │ └── tutorial.rst │ │ │ │ │ ├── querying.rst │ │ │ │ │ ├── release/ │ │ │ │ │ │ ├── index.rst │ │ │ │ │ │ ├── migrating_from_old_api.rst │ │ │ │ │ │ ├── migration.rst │ │ │ │ │ │ └── release_notes.rst │ │ │ │ │ ├── resource/ │ │ │ │ │ │ ├── example_plugin.py │ │ │ │ │ │ ├── example_plugin_safe.py │ │ │ │ │ │ └── example_plugin_using_session.py │ │ │ │ │ ├── security_and_authentication.rst │ │ │ │ │ ├── tutorial.rst │ │ │ │ │ ├── understanding_sessions.rst │ │ │ │ │ └── working_with_entities.rst │ │ │ │ ├── pytest.ini │ │ │ │ ├── resource/ │ │ │ │ │ └── plugin/ │ │ │ │ │ ├── configure_locations.py │ │ │ │ │ └── construct_entity_type.py │ │ │ │ ├── setup.cfg │ │ │ │ ├── setup.py │ │ │ │ ├── source/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── ftrack_api/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── _centralized_storage_scenario.py │ │ │ │ │ ├── _python_ntpath.py │ │ │ │ │ ├── _version.py │ │ │ │ │ ├── _weakref.py │ │ │ │ │ ├── accessor/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── base.py │ │ │ │ │ │ ├── disk.py │ │ │ │ │ │ └── server.py │ │ │ │ │ ├── attribute.py │ │ │ │ │ ├── cache.py │ │ │ │ │ ├── collection.py │ │ │ │ │ ├── data.py │ │ │ │ │ ├── entity/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── asset_version.py │ │ │ │ │ │ ├── base.py │ │ │ │ │ │ ├── component.py │ │ │ │ │ │ ├── factory.py │ │ │ │ │ │ ├── job.py │ │ │ │ │ │ ├── location.py │ │ │ │ │ │ ├── note.py │ │ │ │ │ │ ├── project_schema.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── event/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── base.py │ │ │ │ │ │ ├── expression.py │ │ │ │ │ │ ├── hub.py │ │ │ │ │ │ ├── subscriber.py │ │ │ │ │ │ └── subscription.py │ │ │ │ │ ├── exception.py │ │ │ │ │ ├── formatter.py │ │ │ │ │ ├── inspection.py │ │ │ │ │ ├── logging.py │ │ │ │ │ ├── operation.py │ │ │ │ │ ├── plugin.py │ │ │ │ │ ├── query.py │ │ │ │ │ ├── resource_identifier_transformer/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ └── base.py │ │ │ │ │ ├── session.py │ │ │ │ │ ├── structure/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── base.py │ │ │ │ │ │ ├── entity_id.py │ │ │ │ │ │ ├── id.py │ │ │ │ │ │ ├── origin.py │ │ │ │ │ │ └── standard.py │ │ │ │ │ └── symbol.py │ │ │ │ └── test/ │ │ │ │ ├── fixture/ │ │ │ │ │ └── plugin/ │ │ │ │ │ ├── configure_locations.py │ │ │ │ │ ├── construct_entity_type.py │ │ │ │ │ └── count_session_event.py │ │ │ │ └── unit/ │ │ │ │ ├── __init__.py │ │ │ │ ├── accessor/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── test_disk.py │ │ │ │ │ └── test_server.py │ │ │ │ ├── conftest.py │ │ │ │ ├── entity/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── test_asset_version.py │ │ │ │ │ ├── test_base.py │ │ │ │ │ ├── test_component.py │ │ │ │ │ ├── test_factory.py │ │ │ │ │ ├── test_job.py │ │ │ │ │ ├── test_location.py │ │ │ │ │ ├── test_metadata.py │ │ │ │ │ ├── test_note.py │ │ │ │ │ ├── test_project_schema.py │ │ │ │ │ ├── test_scopes.py │ │ │ │ │ └── test_user.py │ │ │ │ ├── event/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── event_hub_server_heartbeat.py │ │ │ │ │ ├── test_base.py │ │ │ │ │ ├── test_expression.py │ │ │ │ │ ├── test_hub.py │ │ │ │ │ ├── test_subscriber.py │ │ │ │ │ └── test_subscription.py │ │ │ │ ├── resource_identifier_transformer/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── test_base.py │ │ │ │ ├── structure/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── test_base.py │ │ │ │ │ ├── test_entity_id.py │ │ │ │ │ ├── test_id.py │ │ │ │ │ ├── test_origin.py │ │ │ │ │ └── test_standard.py │ │ │ │ ├── test_attribute.py │ │ │ │ ├── test_cache.py │ │ │ │ ├── test_collection.py │ │ │ │ ├── test_custom_attribute.py │ │ │ │ ├── test_data.py │ │ │ │ ├── test_formatter.py │ │ │ │ ├── test_inspection.py │ │ │ │ ├── test_operation.py │ │ │ │ ├── test_package.py │ │ │ │ ├── test_plugin.py │ │ │ │ ├── test_query.py │ │ │ │ ├── test_session.py │ │ │ │ └── test_timer.py │ │ │ ├── scripts/ │ │ │ │ ├── sub_event_processor.py │ │ │ │ ├── sub_event_status.py │ │ │ │ ├── sub_event_storer.py │ │ │ │ ├── sub_legacy_server.py │ │ │ │ └── sub_user_server.py │ │ │ └── tray/ │ │ │ ├── __init__.py │ │ │ ├── ftrack_tray.py │ │ │ ├── login_dialog.py │ │ │ └── login_tools.py │ │ ├── interfaces.py │ │ ├── job_queue/ │ │ │ ├── __init__.py │ │ │ ├── job_server/ │ │ │ │ ├── __init__.py │ │ │ │ ├── job_queue_route.py │ │ │ │ ├── jobs.py │ │ │ │ ├── server.py │ │ │ │ ├── utils.py │ │ │ │ ├── workers.py │ │ │ │ └── workers_rpc_route.py │ │ │ ├── job_workers/ │ │ │ │ ├── __init__.py │ │ │ │ └── base_worker.py │ │ │ └── module.py │ │ ├── kitsu/ │ │ │ ├── __init__.py │ │ │ ├── actions/ │ │ │ │ └── launcher_show_in_kitsu.py │ │ │ ├── kitsu_module.py │ │ │ ├── kitsu_widgets.py │ │ │ ├── plugins/ │ │ │ │ └── publish/ │ │ │ │ ├── collect_kitsu_credential.py │ │ │ │ ├── collect_kitsu_entities.py │ │ │ │ ├── collect_kitsu_username.py │ │ │ │ ├── integrate_kitsu_note.py │ │ │ │ ├── integrate_kitsu_review.py │ │ │ │ └── other_kitsu_log_out.py │ │ │ └── utils/ │ │ │ ├── __init__.py │ │ │ ├── credentials.py │ │ │ ├── sync_service.py │ │ │ ├── update_op_with_zou.py │ │ │ └── update_zou_with_op.py │ │ ├── launcher_action.py │ │ ├── log_viewer/ │ │ │ ├── __init__.py │ │ │ ├── log_view_module.py │ │ │ └── tray/ │ │ │ ├── __init__.py │ │ │ ├── app.py │ │ │ ├── models.py │ │ │ └── widgets.py │ │ ├── project_manager_action.py │ │ ├── python_console_interpreter/ │ │ │ ├── __init__.py │ │ │ ├── module.py │ │ │ └── window/ │ │ │ ├── __init__.py │ │ │ └── widgets.py │ │ ├── royalrender/ │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── lib.py │ │ │ ├── plugins/ │ │ │ │ └── publish/ │ │ │ │ ├── collect_rr_path_from_instance.py │ │ │ │ ├── collect_sequences_from_job.py │ │ │ │ ├── create_maya_royalrender_job.py │ │ │ │ ├── create_nuke_royalrender_job.py │ │ │ │ ├── create_publish_royalrender_job.py │ │ │ │ └── submit_jobs_to_royalrender.py │ │ │ ├── royal_render_module.py │ │ │ ├── rr_job.py │ │ │ └── rr_root/ │ │ │ ├── README.md │ │ │ └── render_apps/ │ │ │ ├── _config/ │ │ │ │ ├── E05__Ayon__PublishJob.cfg │ │ │ │ └── E05__Ayon___global.inc │ │ │ ├── _install_paths/ │ │ │ │ └── Ayon.cfg │ │ │ └── _prepost_scripts/ │ │ │ ├── Ayon_ayon_inject_envvar.cfg │ │ │ └── ayon_inject_envvar.py │ │ ├── settings_action.py │ │ ├── shotgrid/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── lib/ │ │ │ │ ├── __init__.py │ │ │ │ ├── const.py │ │ │ │ ├── credentials.py │ │ │ │ ├── record.py │ │ │ │ └── settings.py │ │ │ ├── plugins/ │ │ │ │ └── publish/ │ │ │ │ ├── collect_shotgrid_entities.py │ │ │ │ ├── collect_shotgrid_session.py │ │ │ │ ├── integrate_shotgrid_publish.py │ │ │ │ ├── integrate_shotgrid_version.py │ │ │ │ └── validate_shotgrid_user.py │ │ │ ├── server/ │ │ │ │ └── README.md │ │ │ ├── shotgrid_module.py │ │ │ ├── tests/ │ │ │ │ └── shotgrid/ │ │ │ │ └── lib/ │ │ │ │ └── test_credentials.py │ │ │ └── tray/ │ │ │ ├── credential_dialog.py │ │ │ └── shotgrid_tray.py │ │ ├── slack/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── launch_hooks/ │ │ │ │ └── pre_python2_vendor.py │ │ │ ├── manifest.yml │ │ │ ├── plugins/ │ │ │ │ └── publish/ │ │ │ │ ├── collect_slack_family.py │ │ │ │ └── integrate_slack_api.py │ │ │ ├── python2_vendor/ │ │ │ │ └── python-slack-sdk-1/ │ │ │ │ ├── .appveyor.yml │ │ │ │ ├── .coveragerc │ │ │ │ ├── .flake8 │ │ │ │ ├── .github/ │ │ │ │ │ ├── contributing.md │ │ │ │ │ ├── issue_template.md │ │ │ │ │ ├── maintainers_guide.md │ │ │ │ │ └── pull_request_template.md │ │ │ │ ├── .gitignore │ │ │ │ ├── .travis.yml │ │ │ │ ├── LICENSE │ │ │ │ ├── MANIFEST.in │ │ │ │ ├── README.rst │ │ │ │ ├── docs/ │ │ │ │ │ ├── .buildinfo │ │ │ │ │ ├── .nojekyll │ │ │ │ │ ├── _static/ │ │ │ │ │ │ ├── basic.css │ │ │ │ │ │ ├── classic.css │ │ │ │ │ │ ├── default.css │ │ │ │ │ │ ├── docs.css │ │ │ │ │ │ ├── doctools.js │ │ │ │ │ │ ├── documentation_options.js │ │ │ │ │ │ ├── jquery-3.2.1.js │ │ │ │ │ │ ├── jquery.js │ │ │ │ │ │ ├── language_data.js │ │ │ │ │ │ ├── pygments.css │ │ │ │ │ │ ├── searchtools.js │ │ │ │ │ │ ├── sidebar.js │ │ │ │ │ │ ├── underscore-1.3.1.js │ │ │ │ │ │ ├── underscore.js │ │ │ │ │ │ └── websupport.js │ │ │ │ │ ├── about.html │ │ │ │ │ ├── auth.html │ │ │ │ │ ├── basic_usage.html │ │ │ │ │ ├── changelog.html │ │ │ │ │ ├── conversations.html │ │ │ │ │ ├── faq.html │ │ │ │ │ ├── genindex.html │ │ │ │ │ ├── index.html │ │ │ │ │ ├── metadata.html │ │ │ │ │ ├── objects.inv │ │ │ │ │ ├── real_time_messaging.html │ │ │ │ │ ├── search.html │ │ │ │ │ └── searchindex.js │ │ │ │ ├── docs-src/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── Makefile │ │ │ │ │ ├── _themes/ │ │ │ │ │ │ └── slack/ │ │ │ │ │ │ ├── conf.py │ │ │ │ │ │ ├── layout.html │ │ │ │ │ │ ├── localtoc.html │ │ │ │ │ │ ├── relations.html │ │ │ │ │ │ ├── sidebar.html │ │ │ │ │ │ ├── static/ │ │ │ │ │ │ │ ├── default.css_t │ │ │ │ │ │ │ ├── docs.css_t │ │ │ │ │ │ │ └── pygments.css_t │ │ │ │ │ │ └── theme.conf │ │ │ │ │ ├── about.rst │ │ │ │ │ ├── auth.rst │ │ │ │ │ ├── basic_usage.rst │ │ │ │ │ ├── changelog.rst │ │ │ │ │ ├── conf.py │ │ │ │ │ ├── conversations.rst │ │ │ │ │ ├── faq.rst │ │ │ │ │ ├── index.rst │ │ │ │ │ ├── make.bat │ │ │ │ │ ├── metadata.rst │ │ │ │ │ └── real_time_messaging.rst │ │ │ │ ├── docs.sh │ │ │ │ ├── requirements.txt │ │ │ │ ├── setup.cfg │ │ │ │ ├── setup.py │ │ │ │ ├── slackclient/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── channel.py │ │ │ │ │ ├── client.py │ │ │ │ │ ├── exceptions.py │ │ │ │ │ ├── im.py │ │ │ │ │ ├── server.py │ │ │ │ │ ├── slackrequest.py │ │ │ │ │ ├── user.py │ │ │ │ │ ├── util.py │ │ │ │ │ └── version.py │ │ │ │ ├── test_requirements.txt │ │ │ │ ├── tests/ │ │ │ │ │ ├── conftest.py │ │ │ │ │ ├── data/ │ │ │ │ │ │ ├── channel.created.json │ │ │ │ │ │ ├── im.created.json │ │ │ │ │ │ └── rtm.start.json │ │ │ │ │ ├── test_channel.py │ │ │ │ │ ├── test_server.py │ │ │ │ │ ├── test_slackclient.py │ │ │ │ │ └── test_slackrequest.py │ │ │ │ └── tox.ini │ │ │ └── slack_module.py │ │ ├── sync_server/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── launch_hooks/ │ │ │ │ └── pre_copy_last_published_workfile.py │ │ │ ├── plugins/ │ │ │ │ └── load/ │ │ │ │ ├── add_site.py │ │ │ │ └── remove_site.py │ │ │ ├── providers/ │ │ │ │ ├── __init__.py │ │ │ │ ├── abstract_provider.py │ │ │ │ ├── dropbox.py │ │ │ │ ├── gdrive.py │ │ │ │ ├── lib.py │ │ │ │ ├── local_drive.py │ │ │ │ └── sftp.py │ │ │ ├── rest_api.py │ │ │ ├── sync_server.py │ │ │ ├── sync_server_module.py │ │ │ ├── tray/ │ │ │ │ ├── app.py │ │ │ │ ├── delegates.py │ │ │ │ ├── lib.py │ │ │ │ ├── models.py │ │ │ │ └── widgets.py │ │ │ └── utils.py │ │ ├── timers_manager/ │ │ │ ├── __init__.py │ │ │ ├── exceptions.py │ │ │ ├── idle_threads.py │ │ │ ├── launch_hooks/ │ │ │ │ └── post_start_timer.py │ │ │ ├── plugins/ │ │ │ │ └── publish/ │ │ │ │ ├── start_timer.py │ │ │ │ └── stop_timer.py │ │ │ ├── rest_api.py │ │ │ ├── timers_manager.py │ │ │ └── widget_user_idle.py │ │ └── webserver/ │ │ ├── __init__.py │ │ ├── base_routes.py │ │ ├── cors_middleware.py │ │ ├── host_console_listener.py │ │ ├── server.py │ │ └── webserver_module.py │ ├── pipeline/ │ │ ├── __init__.py │ │ ├── actions.py │ │ ├── anatomy.py │ │ ├── colorspace.py │ │ ├── constants.py │ │ ├── context_tools.py │ │ ├── create/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── constants.py │ │ │ ├── context.py │ │ │ ├── creator_plugins.py │ │ │ ├── legacy_create.py │ │ │ ├── subset_name.py │ │ │ └── utils.py │ │ ├── delivery.py │ │ ├── editorial.py │ │ ├── farm/ │ │ │ ├── __init__.py │ │ │ ├── patterning.py │ │ │ ├── pyblish_functions.py │ │ │ ├── pyblish_functions.pyi │ │ │ └── tools.py │ │ ├── legacy_io.py │ │ ├── load/ │ │ │ ├── __init__.py │ │ │ ├── plugins.py │ │ │ └── utils.py │ │ ├── mongodb.py │ │ ├── plugin_discover.py │ │ ├── project_folders.py │ │ ├── publish/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── abstract_collect_render.py │ │ │ ├── abstract_expected_files.py │ │ │ ├── constants.py │ │ │ ├── lib.py │ │ │ └── publish_plugins.py │ │ ├── schema/ │ │ │ ├── __init__.py │ │ │ ├── application-1.0.json │ │ │ ├── asset-1.0.json │ │ │ ├── asset-2.0.json │ │ │ ├── asset-3.0.json │ │ │ ├── config-1.0.json │ │ │ ├── config-1.1.json │ │ │ ├── config-2.0.json │ │ │ ├── container-1.0.json │ │ │ ├── container-2.0.json │ │ │ ├── hero_version-1.0.json │ │ │ ├── inventory-1.0.json │ │ │ ├── inventory-1.1.json │ │ │ ├── project-2.0.json │ │ │ ├── project-2.1.json │ │ │ ├── project-3.0.json │ │ │ ├── representation-1.0.json │ │ │ ├── representation-2.0.json │ │ │ ├── session-1.0.json │ │ │ ├── session-2.0.json │ │ │ ├── session-3.0.json │ │ │ ├── session-4.0.json │ │ │ ├── shaders-1.0.json │ │ │ ├── subset-1.0.json │ │ │ ├── subset-2.0.json │ │ │ ├── subset-3.0.json │ │ │ ├── thumbnail-1.0.json │ │ │ ├── version-1.0.json │ │ │ ├── version-2.0.json │ │ │ ├── version-3.0.json │ │ │ └── workfile-1.0.json │ │ ├── tempdir.py │ │ ├── template_data.py │ │ ├── thumbnail.py │ │ ├── version_start.py │ │ └── workfile/ │ │ ├── __init__.py │ │ ├── build_workfile.py │ │ ├── lock_workfile.py │ │ ├── path_resolving.py │ │ └── workfile_template_builder.py │ ├── plugins/ │ │ ├── actions/ │ │ │ └── open_file_explorer.py │ │ ├── inventory/ │ │ │ └── remove_and_load.py │ │ ├── load/ │ │ │ ├── copy_file.py │ │ │ ├── copy_file_path.py │ │ │ ├── delete_old_versions.py │ │ │ ├── delivery.py │ │ │ ├── open_djv.py │ │ │ ├── open_file.py │ │ │ └── push_to_library.py │ │ └── publish/ │ │ ├── cleanup.py │ │ ├── cleanup_explicit.py │ │ ├── cleanup_farm.py │ │ ├── collect_anatomy_context_data.py │ │ ├── collect_anatomy_instance_data.py │ │ ├── collect_anatomy_object.py │ │ ├── collect_audio.py │ │ ├── collect_cleanup_keys.py │ │ ├── collect_comment.py │ │ ├── collect_context_entities.py │ │ ├── collect_context_label.py │ │ ├── collect_current_context.py │ │ ├── collect_current_pype_user.py │ │ ├── collect_current_shell_file.py │ │ ├── collect_custom_staging_dir.py │ │ ├── collect_datetime_data.py │ │ ├── collect_farm_target.py │ │ ├── collect_frames_fix.py │ │ ├── collect_from_create_context.py │ │ ├── collect_hierarchy.py │ │ ├── collect_host_name.py │ │ ├── collect_input_representations_to_versions.py │ │ ├── collect_machine_name.py │ │ ├── collect_modules.py │ │ ├── collect_otio_frame_ranges.py │ │ ├── collect_otio_review.py │ │ ├── collect_otio_subset_resources.py │ │ ├── collect_rendered_files.py │ │ ├── collect_resources_path.py │ │ ├── collect_scene_loaded_versions.py │ │ ├── collect_scene_version.py │ │ ├── collect_settings.py │ │ ├── collect_shell_workspace.py │ │ ├── collect_source_for_source.py │ │ ├── collect_time.py │ │ ├── extract_burnin.py │ │ ├── extract_color_transcode.py │ │ ├── extract_colorspace_data.py │ │ ├── extract_hierarchy_avalon.py │ │ ├── extract_hierarchy_to_ayon.py │ │ ├── extract_otio_audio_tracks.py │ │ ├── extract_otio_file.py │ │ ├── extract_otio_review.py │ │ ├── extract_otio_trimming_video.py │ │ ├── extract_review.py │ │ ├── extract_review_slate.py │ │ ├── extract_scanline_exr.py │ │ ├── extract_thumbnail.py │ │ ├── extract_thumbnail_from_source.py │ │ ├── extract_trim_video_audio.py │ │ ├── help/ │ │ │ ├── validate_containers.xml │ │ │ ├── validate_publish_dir.xml │ │ │ └── validate_unique_subsets.xml │ │ ├── integrate.py │ │ ├── integrate_hero_version.py │ │ ├── integrate_inputlinks.py │ │ ├── integrate_inputlinks_ayon.py │ │ ├── integrate_resources_path.py │ │ ├── integrate_subset_group.py │ │ ├── integrate_thumbnail.py │ │ ├── integrate_thumbnail_ayon.py │ │ ├── integrate_version_attrs.py │ │ ├── preintegrate_thumbnail_representation.py │ │ ├── repair_unicode_strings.py │ │ ├── validate_asset_docs.py │ │ ├── validate_containers.py │ │ ├── validate_editorial_asset_name.py │ │ ├── validate_file_saved.py │ │ ├── validate_filesequences.py │ │ ├── validate_intent.py │ │ ├── validate_publish_dir.py │ │ ├── validate_resources.py │ │ ├── validate_unique_subsets.py │ │ └── validate_version.py │ ├── pype_commands.py │ ├── resources/ │ │ ├── __init__.py │ │ ├── fonts/ │ │ │ └── LiberationSans/ │ │ │ └── License.txt │ │ └── ftrack/ │ │ └── sign_in_message.html │ ├── scripts/ │ │ ├── __init__.py │ │ ├── non_python_host_launch.py │ │ ├── ocio_wrapper.py │ │ ├── otio_burnin.py │ │ ├── remote_publish.py │ │ └── slates/ │ │ ├── __init__.py │ │ ├── __main__.py │ │ └── slate_base/ │ │ ├── __init__.py │ │ ├── api.py │ │ ├── base.py │ │ ├── default_style.json │ │ ├── example.py │ │ ├── font_factory.py │ │ ├── items.py │ │ ├── layer.py │ │ ├── lib.py │ │ └── main_frame.py │ ├── settings/ │ │ ├── __init__.py │ │ ├── ayon_settings.py │ │ ├── constants.py │ │ ├── defaults/ │ │ │ ├── project_anatomy/ │ │ │ │ ├── attributes.json │ │ │ │ ├── imageio.json │ │ │ │ ├── roots.json │ │ │ │ ├── tasks.json │ │ │ │ └── templates.json │ │ │ ├── project_settings/ │ │ │ │ ├── aftereffects.json │ │ │ │ ├── applications.json │ │ │ │ ├── blender.json │ │ │ │ ├── celaction.json │ │ │ │ ├── deadline.json │ │ │ │ ├── equalizer.json │ │ │ │ ├── flame.json │ │ │ │ ├── ftrack.json │ │ │ │ ├── fusion.json │ │ │ │ ├── global.json │ │ │ │ ├── harmony.json │ │ │ │ ├── hiero.json │ │ │ │ ├── houdini.json │ │ │ │ ├── kitsu.json │ │ │ │ ├── max.json │ │ │ │ ├── maya.json │ │ │ │ ├── nuke.json │ │ │ │ ├── photoshop.json │ │ │ │ ├── resolve.json │ │ │ │ ├── royalrender.json │ │ │ │ ├── shotgrid.json │ │ │ │ ├── slack.json │ │ │ │ ├── standalonepublisher.json │ │ │ │ ├── substancepainter.json │ │ │ │ ├── traypublisher.json │ │ │ │ ├── tvpaint.json │ │ │ │ ├── unreal.json │ │ │ │ └── webpublisher.json │ │ │ └── system_settings/ │ │ │ ├── applications.json │ │ │ ├── general.json │ │ │ ├── modules.json │ │ │ └── tools.json │ │ ├── entities/ │ │ │ ├── __init__.py │ │ │ ├── anatomy_entities.py │ │ │ ├── base_entity.py │ │ │ ├── color_entity.py │ │ │ ├── dict_conditional.py │ │ │ ├── dict_immutable_keys_entity.py │ │ │ ├── dict_mutable_keys_entity.py │ │ │ ├── enum_entity.py │ │ │ ├── exceptions.py │ │ │ ├── input_entities.py │ │ │ ├── item_entities.py │ │ │ ├── lib.py │ │ │ ├── list_entity.py │ │ │ ├── op_version_entity.py │ │ │ ├── root_entities.py │ │ │ └── schemas/ │ │ │ ├── README.md │ │ │ ├── projects_schema/ │ │ │ │ ├── schema_main.json │ │ │ │ ├── schema_project_aftereffects.json │ │ │ │ ├── schema_project_applications.json │ │ │ │ ├── schema_project_blender.json │ │ │ │ ├── schema_project_celaction.json │ │ │ │ ├── schema_project_deadline.json │ │ │ │ ├── schema_project_equalizer.json │ │ │ │ ├── schema_project_flame.json │ │ │ │ ├── schema_project_ftrack.json │ │ │ │ ├── schema_project_fusion.json │ │ │ │ ├── schema_project_global.json │ │ │ │ ├── schema_project_harmony.json │ │ │ │ ├── schema_project_hiero.json │ │ │ │ ├── schema_project_houdini.json │ │ │ │ ├── schema_project_kitsu.json │ │ │ │ ├── schema_project_max.json │ │ │ │ ├── schema_project_maya.json │ │ │ │ ├── schema_project_nuke.json │ │ │ │ ├── schema_project_photoshop.json │ │ │ │ ├── schema_project_resolve.json │ │ │ │ ├── schema_project_royalrender.json │ │ │ │ ├── schema_project_shotgrid.json │ │ │ │ ├── schema_project_slack.json │ │ │ │ ├── schema_project_standalonepublisher.json │ │ │ │ ├── schema_project_substancepainter.json │ │ │ │ ├── schema_project_syncserver.json │ │ │ │ ├── schema_project_traypublisher.json │ │ │ │ ├── schema_project_tvpaint.json │ │ │ │ ├── schema_project_unreal.json │ │ │ │ ├── schema_project_webpublisher.json │ │ │ │ └── schemas/ │ │ │ │ ├── schema_anatomy_attributes.json │ │ │ │ ├── schema_anatomy_imageio.json │ │ │ │ ├── schema_anatomy_templates.json │ │ │ │ ├── schema_blender_publish.json │ │ │ │ ├── schema_equalizer_create.json │ │ │ │ ├── schema_global_publish.json │ │ │ │ ├── schema_global_tools.json │ │ │ │ ├── schema_houdini_create.json │ │ │ │ ├── schema_houdini_general.json │ │ │ │ ├── schema_houdini_publish.json │ │ │ │ ├── schema_houdini_scriptshelf.json │ │ │ │ ├── schema_max_publish.json │ │ │ │ ├── schema_maya_capture.json │ │ │ │ ├── schema_maya_create.json │ │ │ │ ├── schema_maya_load.json │ │ │ │ ├── schema_maya_publish.json │ │ │ │ ├── schema_maya_render_settings.json │ │ │ │ ├── schema_nuke_imageio.json │ │ │ │ ├── schema_nuke_load.json │ │ │ │ ├── schema_nuke_publish.json │ │ │ │ ├── schema_nuke_scriptsgizmo.json │ │ │ │ ├── schema_publish_gui_filter.json │ │ │ │ ├── schema_representation_tags.json │ │ │ │ ├── schema_scriptsmenu.json │ │ │ │ ├── schema_templated_workfile_build.json │ │ │ │ ├── schema_workfile_build.json │ │ │ │ ├── template_colorspace_remapping.json │ │ │ │ ├── template_create_plugin.json │ │ │ │ ├── template_host_color_management_derived.json │ │ │ │ ├── template_host_color_management_ocio.json │ │ │ │ ├── template_host_color_management_remapped.json │ │ │ │ ├── template_imageio_config.json │ │ │ │ ├── template_imageio_file_rules.json │ │ │ │ ├── template_loader_plugin_nuke.json │ │ │ │ ├── template_nuke_knob_inputs.json │ │ │ │ ├── template_nuke_write_attrs.json │ │ │ │ ├── template_publish_families.json │ │ │ │ ├── template_publish_plugin.json │ │ │ │ ├── template_validate_plugin.json │ │ │ │ ├── template_workfile_builder_simple.json │ │ │ │ └── template_workfile_options.json │ │ │ └── system_schema/ │ │ │ ├── example_infinite_hierarchy.json │ │ │ ├── example_schema.json │ │ │ ├── example_template.json │ │ │ ├── host_settings/ │ │ │ │ ├── schema_3dequalizer.json │ │ │ │ ├── schema_3dsmax.json │ │ │ │ ├── schema_aftereffects.json │ │ │ │ ├── schema_blender.json │ │ │ │ ├── schema_celaction.json │ │ │ │ ├── schema_djv.json │ │ │ │ ├── schema_flame.json │ │ │ │ ├── schema_fusion.json │ │ │ │ ├── schema_harmony.json │ │ │ │ ├── schema_houdini.json │ │ │ │ ├── schema_maya.json │ │ │ │ ├── schema_mayapy.json │ │ │ │ ├── schema_photoshop.json │ │ │ │ ├── schema_resolve.json │ │ │ │ ├── schema_substancepainter.json │ │ │ │ ├── schema_tvpaint.json │ │ │ │ ├── schema_unreal.json │ │ │ │ ├── template_host_unchangables.json │ │ │ │ ├── template_host_variant.json │ │ │ │ ├── template_host_variant_items.json │ │ │ │ └── template_nuke.json │ │ │ ├── module_settings/ │ │ │ │ ├── schema_ftrack.json │ │ │ │ ├── schema_kitsu.json │ │ │ │ └── template_custom_attribute.json │ │ │ ├── schema_applications.json │ │ │ ├── schema_general.json │ │ │ ├── schema_main.json │ │ │ ├── schema_modules.json │ │ │ └── schema_tools.json │ │ ├── exceptions.py │ │ ├── handlers.py │ │ ├── lib.py │ │ └── local_settings.md │ ├── style/ │ │ ├── __init__.py │ │ ├── color_defs.py │ │ ├── data.json │ │ ├── fonts/ │ │ │ ├── Noto_Sans/ │ │ │ │ └── OFL.txt │ │ │ └── Noto_Sans_Mono/ │ │ │ ├── OFL.txt │ │ │ └── README.txt │ │ ├── pyqt5_resources.py │ │ ├── pyside2_resources.py │ │ ├── pyside6_resources.py │ │ ├── qrc_resources.py │ │ ├── resources.qrc │ │ └── style.css │ ├── tests/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── lib.py │ │ ├── mongo_performance.py │ │ ├── test_avalon_plugin_presets.py │ │ ├── test_lib_restructuralization.py │ │ └── test_pyblish_filter.py │ ├── tools/ │ │ ├── __init__.py │ │ ├── adobe_webserver/ │ │ │ ├── app.py │ │ │ └── readme.txt │ │ ├── assetlinks/ │ │ │ ├── __init__.py │ │ │ └── widgets.py │ │ ├── attribute_defs/ │ │ │ ├── __init__.py │ │ │ ├── dialog.py │ │ │ ├── files_widget.py │ │ │ └── widgets.py │ │ ├── ayon_launcher/ │ │ │ ├── abstract.py │ │ │ ├── control.py │ │ │ ├── models/ │ │ │ │ ├── __init__.py │ │ │ │ ├── actions.py │ │ │ │ └── selection.py │ │ │ └── ui/ │ │ │ ├── __init__.py │ │ │ ├── actions_widget.py │ │ │ ├── hierarchy_page.py │ │ │ ├── projects_widget.py │ │ │ ├── resources/ │ │ │ │ └── __init__.py │ │ │ └── window.py │ │ ├── ayon_loader/ │ │ │ ├── __init__.py │ │ │ ├── abstract.py │ │ │ ├── control.py │ │ │ ├── models/ │ │ │ │ ├── __init__.py │ │ │ │ ├── actions.py │ │ │ │ ├── products.py │ │ │ │ ├── selection.py │ │ │ │ └── site_sync.py │ │ │ └── ui/ │ │ │ ├── __init__.py │ │ │ ├── actions_utils.py │ │ │ ├── folders_widget.py │ │ │ ├── info_widget.py │ │ │ ├── product_group_dialog.py │ │ │ ├── product_types_widget.py │ │ │ ├── products_delegates.py │ │ │ ├── products_model.py │ │ │ ├── products_widget.py │ │ │ ├── repres_widget.py │ │ │ └── window.py │ │ ├── ayon_push_to_project/ │ │ │ ├── __init__.py │ │ │ ├── control.py │ │ │ ├── main.py │ │ │ ├── models/ │ │ │ │ ├── __init__.py │ │ │ │ ├── integrate.py │ │ │ │ ├── selection.py │ │ │ │ └── user_values.py │ │ │ └── ui/ │ │ │ ├── __init__.py │ │ │ └── window.py │ │ ├── ayon_sceneinventory/ │ │ │ ├── __init__.py │ │ │ ├── control.py │ │ │ ├── model.py │ │ │ ├── models/ │ │ │ │ ├── __init__.py │ │ │ │ └── site_sync.py │ │ │ ├── switch_dialog/ │ │ │ │ ├── __init__.py │ │ │ │ ├── dialog.py │ │ │ │ ├── folders_input.py │ │ │ │ └── widgets.py │ │ │ ├── view.py │ │ │ └── window.py │ │ ├── ayon_utils/ │ │ │ ├── models/ │ │ │ │ ├── __init__.py │ │ │ │ ├── cache.py │ │ │ │ ├── hierarchy.py │ │ │ │ ├── projects.py │ │ │ │ ├── selection.py │ │ │ │ └── thumbnails.py │ │ │ └── widgets/ │ │ │ ├── __init__.py │ │ │ ├── folders_widget.py │ │ │ ├── projects_widget.py │ │ │ ├── tasks_widget.py │ │ │ └── utils.py │ │ ├── ayon_workfiles/ │ │ │ ├── __init__.py │ │ │ ├── abstract.py │ │ │ ├── control.py │ │ │ ├── models/ │ │ │ │ ├── __init__.py │ │ │ │ ├── selection.py │ │ │ │ └── workfiles.py │ │ │ └── widgets/ │ │ │ ├── __init__.py │ │ │ ├── constants.py │ │ │ ├── files_widget.py │ │ │ ├── files_widget_published.py │ │ │ ├── files_widget_workarea.py │ │ │ ├── save_as_dialog.py │ │ │ ├── side_panel.py │ │ │ ├── utils.py │ │ │ └── window.py │ │ ├── context_dialog/ │ │ │ ├── __init__.py │ │ │ ├── _ayon_window.py │ │ │ └── _openpype_window.py │ │ ├── creator/ │ │ │ ├── __init__.py │ │ │ ├── constants.py │ │ │ ├── model.py │ │ │ ├── widgets.py │ │ │ └── window.py │ │ ├── experimental_tools/ │ │ │ ├── __init__.py │ │ │ ├── dialog.py │ │ │ └── tools_def.py │ │ ├── flickcharm.py │ │ ├── launcher/ │ │ │ ├── __init__.py │ │ │ ├── actions.py │ │ │ ├── constants.py │ │ │ ├── delegates.py │ │ │ ├── lib.py │ │ │ ├── models.py │ │ │ ├── widgets.py │ │ │ └── window.py │ │ ├── libraryloader/ │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ └── app.py │ │ ├── loader/ │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ ├── app.py │ │ │ ├── delegates.py │ │ │ ├── lib.py │ │ │ ├── model.py │ │ │ └── widgets.py │ │ ├── project_manager/ │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ └── project_manager/ │ │ │ ├── __init__.py │ │ │ ├── constants.py │ │ │ ├── delegates.py │ │ │ ├── model.py │ │ │ ├── multiselection_combobox.py │ │ │ ├── style.py │ │ │ ├── view.py │ │ │ ├── widgets.py │ │ │ └── window.py │ │ ├── publisher/ │ │ │ ├── __init__.py │ │ │ ├── app.py │ │ │ ├── constants.py │ │ │ ├── control.py │ │ │ ├── control_qt.py │ │ │ ├── publish_report_viewer/ │ │ │ │ ├── __init__.py │ │ │ │ ├── constants.py │ │ │ │ ├── delegates.py │ │ │ │ ├── model.py │ │ │ │ ├── report_items.py │ │ │ │ ├── widgets.py │ │ │ │ └── window.py │ │ │ ├── widgets/ │ │ │ │ ├── __init__.py │ │ │ │ ├── assets_widget.py │ │ │ │ ├── border_label_widget.py │ │ │ │ ├── card_view_widgets.py │ │ │ │ ├── create_widget.py │ │ │ │ ├── help_widget.py │ │ │ │ ├── icons.py │ │ │ │ ├── list_view_widgets.py │ │ │ │ ├── overview_widget.py │ │ │ │ ├── precreate_widget.py │ │ │ │ ├── publish_frame.py │ │ │ │ ├── report_page.py │ │ │ │ ├── screenshot_widget.py │ │ │ │ ├── tabs_widget.py │ │ │ │ ├── tasks_widget.py │ │ │ │ ├── thumbnail_widget.py │ │ │ │ └── widgets.py │ │ │ └── window.py │ │ ├── push_to_project/ │ │ │ ├── __init__.py │ │ │ ├── app.py │ │ │ ├── control_context.py │ │ │ ├── control_integrate.py │ │ │ └── window.py │ │ ├── pyblish_pype/ │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ ├── app.css │ │ │ ├── app.py │ │ │ ├── awesome.py │ │ │ ├── constants.py │ │ │ ├── control.py │ │ │ ├── delegate.py │ │ │ ├── font/ │ │ │ │ └── opensans/ │ │ │ │ └── LICENSE.txt │ │ │ ├── i18n/ │ │ │ │ ├── pyblish_lite.pro │ │ │ │ ├── zh_CN.qm │ │ │ │ └── zh_CN.ts │ │ │ ├── mock.py │ │ │ ├── model.py │ │ │ ├── settings.py │ │ │ ├── util.py │ │ │ ├── vendor/ │ │ │ │ ├── __init__.py │ │ │ │ └── qtawesome/ │ │ │ │ ├── __init__.py │ │ │ │ ├── _version.py │ │ │ │ ├── animation.py │ │ │ │ ├── fonts/ │ │ │ │ │ ├── elusiveicons-webfont-charmap.json │ │ │ │ │ └── fontawesome-webfont-charmap.json │ │ │ │ └── iconic_font.py │ │ │ ├── version.py │ │ │ ├── view.py │ │ │ ├── widgets.py │ │ │ └── window.py │ │ ├── repack_version.py │ │ ├── resources/ │ │ │ └── __init__.py │ │ ├── sceneinventory/ │ │ │ ├── __init__.py │ │ │ ├── lib.py │ │ │ ├── model.py │ │ │ ├── switch_dialog.py │ │ │ ├── view.py │ │ │ ├── widgets.py │ │ │ └── window.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ ├── lib.py │ │ │ ├── local_settings/ │ │ │ │ ├── __init__.py │ │ │ │ ├── apps_widget.py │ │ │ │ ├── constants.py │ │ │ │ ├── environments_widget.py │ │ │ │ ├── experimental_widget.py │ │ │ │ ├── general_widget.py │ │ │ │ ├── mongo_widget.py │ │ │ │ ├── projects_widget.py │ │ │ │ ├── widgets.py │ │ │ │ └── window.py │ │ │ ├── resources/ │ │ │ │ └── __init__.py │ │ │ └── settings/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── breadcrumbs_widget.py │ │ │ ├── categories.py │ │ │ ├── color_widget.py │ │ │ ├── constants.py │ │ │ ├── dialogs.py │ │ │ ├── dict_conditional.py │ │ │ ├── dict_mutable_widget.py │ │ │ ├── images/ │ │ │ │ └── __init__.py │ │ │ ├── item_widgets.py │ │ │ ├── lib.py │ │ │ ├── list_item_widget.py │ │ │ ├── list_strict_widget.py │ │ │ ├── search_dialog.py │ │ │ ├── tests.py │ │ │ ├── widgets.py │ │ │ ├── window.py │ │ │ └── wrapper_widgets.py │ │ ├── standalonepublish/ │ │ │ ├── __init__.py │ │ │ ├── app.py │ │ │ ├── publish.py │ │ │ └── widgets/ │ │ │ ├── __init__.py │ │ │ ├── constants.py │ │ │ ├── model_asset.py │ │ │ ├── model_filter_proxy_exact_match.py │ │ │ ├── model_filter_proxy_recursive_sort.py │ │ │ ├── model_node.py │ │ │ ├── model_tasks_template.py │ │ │ ├── model_tree.py │ │ │ ├── model_tree_view_deselectable.py │ │ │ ├── resources/ │ │ │ │ └── __init__.py │ │ │ ├── widget_asset.py │ │ │ ├── widget_component_item.py │ │ │ ├── widget_components.py │ │ │ ├── widget_components_list.py │ │ │ ├── widget_drop_empty.py │ │ │ ├── widget_drop_frame.py │ │ │ ├── widget_family.py │ │ │ ├── widget_family_desc.py │ │ │ └── widget_shadow.py │ │ ├── stdout_broker/ │ │ │ ├── __init__.py │ │ │ ├── app.py │ │ │ └── window.py │ │ ├── subsetmanager/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── model.py │ │ │ ├── widgets.py │ │ │ └── window.py │ │ ├── texture_copy/ │ │ │ └── app.py │ │ ├── tray/ │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ ├── pype_info_widget.py │ │ │ └── pype_tray.py │ │ ├── traypublisher/ │ │ │ ├── __init__.py │ │ │ └── window.py │ │ ├── utils/ │ │ │ ├── __init__.py │ │ │ ├── assets_widget.py │ │ │ ├── constants.py │ │ │ ├── delegates.py │ │ │ ├── error_dialog.py │ │ │ ├── host_tools.py │ │ │ ├── images/ │ │ │ │ └── __init__.py │ │ │ ├── layouts.py │ │ │ ├── lib.py │ │ │ ├── models.py │ │ │ ├── multiselection_combobox.py │ │ │ ├── overlay_messages.py │ │ │ ├── tasks_widget.py │ │ │ ├── thumbnail_paint_widget.py │ │ │ ├── views.py │ │ │ └── widgets.py │ │ ├── workfile_template_build/ │ │ │ ├── __init__.py │ │ │ └── window.py │ │ └── workfiles/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── app.py │ │ ├── files_widget.py │ │ ├── lock_dialog.py │ │ ├── model.py │ │ ├── save_as_dialog.py │ │ └── window.py │ ├── vendor/ │ │ ├── __init__.py │ │ └── python/ │ │ ├── common/ │ │ │ ├── README.md │ │ │ ├── ayon_api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── _api.py │ │ │ │ ├── constants.py │ │ │ │ ├── entity_hub.py │ │ │ │ ├── events.py │ │ │ │ ├── exceptions.py │ │ │ │ ├── graphql.py │ │ │ │ ├── graphql_queries.py │ │ │ │ ├── operations.py │ │ │ │ ├── server_api.py │ │ │ │ ├── utils.py │ │ │ │ └── version.py │ │ │ ├── capture.py │ │ │ ├── pysync.py │ │ │ ├── qargparse.py │ │ │ └── scriptsmenu/ │ │ │ ├── __init__.py │ │ │ ├── action.py │ │ │ ├── launchformari.py │ │ │ ├── launchformaya.py │ │ │ ├── launchfornuke.py │ │ │ ├── scriptsmenu.py │ │ │ └── version.py │ │ ├── python_2/ │ │ │ ├── README.md │ │ │ ├── arrow/ │ │ │ │ ├── __init__.py │ │ │ │ ├── _version.py │ │ │ │ ├── api.py │ │ │ │ ├── arrow.py │ │ │ │ ├── constants.py │ │ │ │ ├── factory.py │ │ │ │ ├── formatter.py │ │ │ │ ├── locales.py │ │ │ │ ├── parser.py │ │ │ │ └── util.py │ │ │ ├── attr/ │ │ │ │ ├── __init__.py │ │ │ │ ├── __init__.pyi │ │ │ │ ├── _cmp.py │ │ │ │ ├── _cmp.pyi │ │ │ │ ├── _compat.py │ │ │ │ ├── _config.py │ │ │ │ ├── _funcs.py │ │ │ │ ├── _make.py │ │ │ │ ├── _next_gen.py │ │ │ │ ├── _version_info.py │ │ │ │ ├── _version_info.pyi │ │ │ │ ├── converters.py │ │ │ │ ├── converters.pyi │ │ │ │ ├── exceptions.py │ │ │ │ ├── exceptions.pyi │ │ │ │ ├── filters.py │ │ │ │ ├── filters.pyi │ │ │ │ ├── py.typed │ │ │ │ ├── setters.py │ │ │ │ ├── setters.pyi │ │ │ │ ├── validators.py │ │ │ │ └── validators.pyi │ │ │ ├── attrs/ │ │ │ │ ├── __init__.py │ │ │ │ ├── __init__.pyi │ │ │ │ ├── converters.py │ │ │ │ ├── exceptions.py │ │ │ │ ├── filters.py │ │ │ │ ├── py.typed │ │ │ │ ├── setters.py │ │ │ │ └── validators.py │ │ │ ├── backports/ │ │ │ │ ├── __init__.py │ │ │ │ ├── configparser/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── helpers.py │ │ │ │ └── functools_lru_cache.py │ │ │ ├── builtins/ │ │ │ │ └── __init__.py │ │ │ ├── certifi/ │ │ │ │ ├── __init__.py │ │ │ │ ├── __main__.py │ │ │ │ ├── cacert.pem │ │ │ │ └── core.py │ │ │ ├── chardet/ │ │ │ │ ├── __init__.py │ │ │ │ ├── big5freq.py │ │ │ │ ├── big5prober.py │ │ │ │ ├── chardistribution.py │ │ │ │ ├── charsetgroupprober.py │ │ │ │ ├── charsetprober.py │ │ │ │ ├── cli/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── chardetect.py │ │ │ │ ├── codingstatemachine.py │ │ │ │ ├── compat.py │ │ │ │ ├── cp949prober.py │ │ │ │ ├── enums.py │ │ │ │ ├── escprober.py │ │ │ │ ├── escsm.py │ │ │ │ ├── eucjpprober.py │ │ │ │ ├── euckrfreq.py │ │ │ │ ├── euckrprober.py │ │ │ │ ├── euctwfreq.py │ │ │ │ ├── euctwprober.py │ │ │ │ ├── gb2312freq.py │ │ │ │ ├── gb2312prober.py │ │ │ │ ├── hebrewprober.py │ │ │ │ ├── jisfreq.py │ │ │ │ ├── jpcntx.py │ │ │ │ ├── langbulgarianmodel.py │ │ │ │ ├── langgreekmodel.py │ │ │ │ ├── langhebrewmodel.py │ │ │ │ ├── langhungarianmodel.py │ │ │ │ ├── langrussianmodel.py │ │ │ │ ├── langthaimodel.py │ │ │ │ ├── langturkishmodel.py │ │ │ │ ├── latin1prober.py │ │ │ │ ├── mbcharsetprober.py │ │ │ │ ├── mbcsgroupprober.py │ │ │ │ ├── mbcssm.py │ │ │ │ ├── metadata/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── languages.py │ │ │ │ ├── sbcharsetprober.py │ │ │ │ ├── sbcsgroupprober.py │ │ │ │ ├── sjisprober.py │ │ │ │ ├── universaldetector.py │ │ │ │ ├── utf8prober.py │ │ │ │ └── version.py │ │ │ ├── charset_normalizer.py │ │ │ ├── click/ │ │ │ │ ├── __init__.py │ │ │ │ ├── _bashcomplete.py │ │ │ │ ├── _compat.py │ │ │ │ ├── _termui_impl.py │ │ │ │ ├── _textwrap.py │ │ │ │ ├── _unicodefun.py │ │ │ │ ├── _winconsole.py │ │ │ │ ├── core.py │ │ │ │ ├── decorators.py │ │ │ │ ├── exceptions.py │ │ │ │ ├── formatting.py │ │ │ │ ├── globals.py │ │ │ │ ├── parser.py │ │ │ │ ├── termui.py │ │ │ │ ├── testing.py │ │ │ │ ├── types.py │ │ │ │ └── utils.py │ │ │ ├── dns/ │ │ │ │ ├── __init__.py │ │ │ │ ├── _compat.py │ │ │ │ ├── dnssec.py │ │ │ │ ├── e164.py │ │ │ │ ├── edns.py │ │ │ │ ├── entropy.py │ │ │ │ ├── exception.py │ │ │ │ ├── flags.py │ │ │ │ ├── grange.py │ │ │ │ ├── hash.py │ │ │ │ ├── inet.py │ │ │ │ ├── ipv4.py │ │ │ │ ├── ipv6.py │ │ │ │ ├── message.py │ │ │ │ ├── name.py │ │ │ │ ├── namedict.py │ │ │ │ ├── node.py │ │ │ │ ├── opcode.py │ │ │ │ ├── py.typed │ │ │ │ ├── query.py │ │ │ │ ├── rcode.py │ │ │ │ ├── rdata.py │ │ │ │ ├── rdataclass.py │ │ │ │ ├── rdataset.py │ │ │ │ ├── rdatatype.py │ │ │ │ ├── rdtypes/ │ │ │ │ │ ├── ANY/ │ │ │ │ │ │ ├── AFSDB.py │ │ │ │ │ │ ├── AVC.py │ │ │ │ │ │ ├── CAA.py │ │ │ │ │ │ ├── CDNSKEY.py │ │ │ │ │ │ ├── CDS.py │ │ │ │ │ │ ├── CERT.py │ │ │ │ │ │ ├── CNAME.py │ │ │ │ │ │ ├── CSYNC.py │ │ │ │ │ │ ├── DLV.py │ │ │ │ │ │ ├── DNAME.py │ │ │ │ │ │ ├── DNSKEY.py │ │ │ │ │ │ ├── DS.py │ │ │ │ │ │ ├── EUI48.py │ │ │ │ │ │ ├── EUI64.py │ │ │ │ │ │ ├── GPOS.py │ │ │ │ │ │ ├── HINFO.py │ │ │ │ │ │ ├── HIP.py │ │ │ │ │ │ ├── ISDN.py │ │ │ │ │ │ ├── LOC.py │ │ │ │ │ │ ├── MX.py │ │ │ │ │ │ ├── NS.py │ │ │ │ │ │ ├── NSEC.py │ │ │ │ │ │ ├── NSEC3.py │ │ │ │ │ │ ├── NSEC3PARAM.py │ │ │ │ │ │ ├── OPENPGPKEY.py │ │ │ │ │ │ ├── PTR.py │ │ │ │ │ │ ├── RP.py │ │ │ │ │ │ ├── RRSIG.py │ │ │ │ │ │ ├── RT.py │ │ │ │ │ │ ├── SOA.py │ │ │ │ │ │ ├── SPF.py │ │ │ │ │ │ ├── SSHFP.py │ │ │ │ │ │ ├── TLSA.py │ │ │ │ │ │ ├── TXT.py │ │ │ │ │ │ ├── URI.py │ │ │ │ │ │ ├── X25.py │ │ │ │ │ │ └── __init__.py │ │ │ │ │ ├── CH/ │ │ │ │ │ │ ├── A.py │ │ │ │ │ │ └── __init__.py │ │ │ │ │ ├── IN/ │ │ │ │ │ │ ├── A.py │ │ │ │ │ │ ├── AAAA.py │ │ │ │ │ │ ├── APL.py │ │ │ │ │ │ ├── DHCID.py │ │ │ │ │ │ ├── IPSECKEY.py │ │ │ │ │ │ ├── KX.py │ │ │ │ │ │ ├── NAPTR.py │ │ │ │ │ │ ├── NSAP.py │ │ │ │ │ │ ├── NSAP_PTR.py │ │ │ │ │ │ ├── PX.py │ │ │ │ │ │ ├── SRV.py │ │ │ │ │ │ ├── WKS.py │ │ │ │ │ │ └── __init__.py │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── dnskeybase.py │ │ │ │ │ ├── dsbase.py │ │ │ │ │ ├── euibase.py │ │ │ │ │ ├── mxbase.py │ │ │ │ │ ├── nsbase.py │ │ │ │ │ └── txtbase.py │ │ │ │ ├── renderer.py │ │ │ │ ├── resolver.py │ │ │ │ ├── reversename.py │ │ │ │ ├── rrset.py │ │ │ │ ├── set.py │ │ │ │ ├── tokenizer.py │ │ │ │ ├── tsig.py │ │ │ │ ├── tsigkeyring.py │ │ │ │ ├── ttl.py │ │ │ │ ├── update.py │ │ │ │ ├── version.py │ │ │ │ ├── wiredata.py │ │ │ │ └── zone.py │ │ │ ├── engineio/ │ │ │ │ ├── __init__.py │ │ │ │ ├── async_aiohttp.py │ │ │ │ ├── async_asgi.py │ │ │ │ ├── async_drivers/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── aiohttp.py │ │ │ │ │ ├── asgi.py │ │ │ │ │ ├── eventlet.py │ │ │ │ │ ├── gevent.py │ │ │ │ │ ├── gevent_uwsgi.py │ │ │ │ │ ├── sanic.py │ │ │ │ │ ├── threading.py │ │ │ │ │ └── tornado.py │ │ │ │ ├── async_eventlet.py │ │ │ │ ├── async_gevent.py │ │ │ │ ├── async_gevent_uwsgi.py │ │ │ │ ├── async_sanic.py │ │ │ │ ├── async_threading.py │ │ │ │ ├── async_tornado.py │ │ │ │ ├── asyncio_client.py │ │ │ │ ├── asyncio_server.py │ │ │ │ ├── asyncio_socket.py │ │ │ │ ├── client.py │ │ │ │ ├── exceptions.py │ │ │ │ ├── middleware.py │ │ │ │ ├── packet.py │ │ │ │ ├── payload.py │ │ │ │ ├── server.py │ │ │ │ ├── socket.py │ │ │ │ └── static_files.py │ │ │ ├── functools32/ │ │ │ │ ├── __init__.py │ │ │ │ ├── _dummy_thread32.py │ │ │ │ ├── functools32.py │ │ │ │ └── reprlib32.py │ │ │ ├── idna/ │ │ │ │ ├── __init__.py │ │ │ │ ├── codec.py │ │ │ │ ├── compat.py │ │ │ │ ├── core.py │ │ │ │ ├── idnadata.py │ │ │ │ ├── intranges.py │ │ │ │ ├── package_data.py │ │ │ │ └── uts46data.py │ │ │ ├── opentimelineio/ │ │ │ │ ├── __init__.py │ │ │ │ ├── adapters/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── adapter.py │ │ │ │ │ ├── builtin_adapters.plugin_manifest.json │ │ │ │ │ ├── cmx_3600.py │ │ │ │ │ ├── fcp_xml.py │ │ │ │ │ └── otio_json.py │ │ │ │ ├── algorithms/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── filter.py │ │ │ │ │ ├── stack_algo.py │ │ │ │ │ ├── timeline_algo.py │ │ │ │ │ └── track_algo.py │ │ │ │ ├── console/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── autogen_serialized_datamodel.py │ │ │ │ │ ├── console_utils.py │ │ │ │ │ ├── otiocat.py │ │ │ │ │ ├── otioconvert.py │ │ │ │ │ └── otiostat.py │ │ │ │ ├── core/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── composable.py │ │ │ │ │ ├── composition.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── json_serializer.py │ │ │ │ │ ├── media_reference.py │ │ │ │ │ ├── serializable_object.py │ │ │ │ │ ├── type_registry.py │ │ │ │ │ └── unknown_schema.py │ │ │ │ ├── exceptions.py │ │ │ │ ├── hooks.py │ │ │ │ ├── media_linker.py │ │ │ │ ├── opentime.py │ │ │ │ ├── plugins/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── manifest.py │ │ │ │ │ └── python_plugin.py │ │ │ │ ├── schema/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── clip.py │ │ │ │ │ ├── effect.py │ │ │ │ │ ├── external_reference.py │ │ │ │ │ ├── gap.py │ │ │ │ │ ├── generator_reference.py │ │ │ │ │ ├── marker.py │ │ │ │ │ ├── missing_reference.py │ │ │ │ │ ├── schemadef.py │ │ │ │ │ ├── serializable_collection.py │ │ │ │ │ ├── stack.py │ │ │ │ │ ├── timeline.py │ │ │ │ │ ├── track.py │ │ │ │ │ └── transition.py │ │ │ │ ├── schemadef/ │ │ │ │ │ └── __init__.py │ │ │ │ └── test_utils.py │ │ │ ├── opentimelineio_contrib/ │ │ │ │ ├── __init__.py │ │ │ │ └── adapters/ │ │ │ │ ├── __init__.py │ │ │ │ ├── aaf_adapter/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── aaf_writer.py │ │ │ │ ├── advanced_authoring_format.py │ │ │ │ ├── ale.py │ │ │ │ ├── burnins.py │ │ │ │ ├── contrib_adapters.plugin_manifest.json │ │ │ │ ├── extern_maya_sequencer.py │ │ │ │ ├── extern_rv.py │ │ │ │ ├── fcpx_xml.py │ │ │ │ ├── ffmpeg_burnins.py │ │ │ │ ├── hls_playlist.py │ │ │ │ ├── maya_sequencer.py │ │ │ │ ├── rv.py │ │ │ │ └── xges.py │ │ │ ├── pkg_resources/ │ │ │ │ ├── __init__.py │ │ │ │ ├── _vendor/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── appdirs.py │ │ │ │ │ ├── packaging/ │ │ │ │ │ │ ├── __about__.py │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── _compat.py │ │ │ │ │ │ ├── _structures.py │ │ │ │ │ │ ├── markers.py │ │ │ │ │ │ ├── requirements.py │ │ │ │ │ │ ├── specifiers.py │ │ │ │ │ │ ├── utils.py │ │ │ │ │ │ └── version.py │ │ │ │ │ ├── pyparsing.py │ │ │ │ │ └── six.py │ │ │ │ ├── extern/ │ │ │ │ │ └── __init__.py │ │ │ │ ├── py2_warn.py │ │ │ │ └── py31compat.py │ │ │ ├── qtpy/ │ │ │ │ ├── Qt3DAnimation.py │ │ │ │ ├── Qt3DCore.py │ │ │ │ ├── Qt3DExtras.py │ │ │ │ ├── Qt3DInput.py │ │ │ │ ├── Qt3DLogic.py │ │ │ │ ├── Qt3DRender.py │ │ │ │ ├── QtCharts.py │ │ │ │ ├── QtCore.py │ │ │ │ ├── QtDataVisualization.py │ │ │ │ ├── QtDesigner.py │ │ │ │ ├── QtGui.py │ │ │ │ ├── QtHelp.py │ │ │ │ ├── QtLocation.py │ │ │ │ ├── QtMultimedia.py │ │ │ │ ├── QtMultimediaWidgets.py │ │ │ │ ├── QtNetwork.py │ │ │ │ ├── QtOpenGL.py │ │ │ │ ├── QtPositioning.py │ │ │ │ ├── QtPrintSupport.py │ │ │ │ ├── QtQml.py │ │ │ │ ├── QtQuick.py │ │ │ │ ├── QtQuickWidgets.py │ │ │ │ ├── QtSerialPort.py │ │ │ │ ├── QtSql.py │ │ │ │ ├── QtSvg.py │ │ │ │ ├── QtTest.py │ │ │ │ ├── QtWebChannel.py │ │ │ │ ├── QtWebEngineWidgets.py │ │ │ │ ├── QtWebSockets.py │ │ │ │ ├── QtWidgets.py │ │ │ │ ├── QtWinExtras.py │ │ │ │ ├── QtXmlPatterns.py │ │ │ │ ├── __init__.py │ │ │ │ ├── _patch/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── qcombobox.py │ │ │ │ │ └── qheaderview.py │ │ │ │ ├── _version.py │ │ │ │ ├── compat.py │ │ │ │ ├── py3compat.py │ │ │ │ ├── tests/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── conftest.py │ │ │ │ │ ├── runtests.py │ │ │ │ │ ├── test_macos_checks.py │ │ │ │ │ ├── test_main.py │ │ │ │ │ ├── test_patch_qcombobox.py │ │ │ │ │ ├── test_patch_qheaderview.py │ │ │ │ │ ├── test_qdesktopservice_split.py │ │ │ │ │ ├── test_qt3danimation.py │ │ │ │ │ ├── test_qt3dcore.py │ │ │ │ │ ├── test_qt3dextras.py │ │ │ │ │ ├── test_qt3dinput.py │ │ │ │ │ ├── test_qt3dlogic.py │ │ │ │ │ ├── test_qt3drender.py │ │ │ │ │ ├── test_qtcharts.py │ │ │ │ │ ├── test_qtcore.py │ │ │ │ │ ├── test_qtdatavisualization.py │ │ │ │ │ ├── test_qtdesigner.py │ │ │ │ │ ├── test_qthelp.py │ │ │ │ │ ├── test_qtlocation.py │ │ │ │ │ ├── test_qtmultimedia.py │ │ │ │ │ ├── test_qtmultimediawidgets.py │ │ │ │ │ ├── test_qtnetwork.py │ │ │ │ │ ├── test_qtpositioning.py │ │ │ │ │ ├── test_qtprintsupport.py │ │ │ │ │ ├── test_qtqml.py │ │ │ │ │ ├── test_qtquick.py │ │ │ │ │ ├── test_qtquickwidgets.py │ │ │ │ │ ├── test_qtserialport.py │ │ │ │ │ ├── test_qtsql.py │ │ │ │ │ ├── test_qtsvg.py │ │ │ │ │ ├── test_qttest.py │ │ │ │ │ ├── test_qtwebchannel.py │ │ │ │ │ ├── test_qtwebenginewidgets.py │ │ │ │ │ ├── test_qtwebsockets.py │ │ │ │ │ ├── test_qtwinextras.py │ │ │ │ │ ├── test_qtxmlpatterns.py │ │ │ │ │ └── test_uic.py │ │ │ │ └── uic.py │ │ │ ├── requests/ │ │ │ │ ├── __init__.py │ │ │ │ ├── __version__.py │ │ │ │ ├── _internal_utils.py │ │ │ │ ├── adapters.py │ │ │ │ ├── api.py │ │ │ │ ├── auth.py │ │ │ │ ├── certs.py │ │ │ │ ├── compat.py │ │ │ │ ├── cookies.py │ │ │ │ ├── exceptions.py │ │ │ │ ├── help.py │ │ │ │ ├── hooks.py │ │ │ │ ├── models.py │ │ │ │ ├── packages.py │ │ │ │ ├── sessions.py │ │ │ │ ├── status_codes.py │ │ │ │ ├── structures.py │ │ │ │ └── utils.py │ │ │ ├── secrets/ │ │ │ │ ├── LICENSE │ │ │ │ ├── __init__.py │ │ │ │ └── secrets.py │ │ │ ├── setuptools/ │ │ │ │ ├── __init__.py │ │ │ │ ├── _deprecation_warning.py │ │ │ │ ├── _imp.py │ │ │ │ ├── _vendor/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── ordered_set.py │ │ │ │ │ ├── packaging/ │ │ │ │ │ │ ├── __about__.py │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── _compat.py │ │ │ │ │ │ ├── _structures.py │ │ │ │ │ │ ├── markers.py │ │ │ │ │ │ ├── requirements.py │ │ │ │ │ │ ├── specifiers.py │ │ │ │ │ │ ├── tags.py │ │ │ │ │ │ ├── utils.py │ │ │ │ │ │ └── version.py │ │ │ │ │ ├── pyparsing.py │ │ │ │ │ └── six.py │ │ │ │ ├── archive_util.py │ │ │ │ ├── build_meta.py │ │ │ │ ├── command/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── alias.py │ │ │ │ │ ├── bdist_egg.py │ │ │ │ │ ├── bdist_rpm.py │ │ │ │ │ ├── bdist_wininst.py │ │ │ │ │ ├── build_clib.py │ │ │ │ │ ├── build_ext.py │ │ │ │ │ ├── build_py.py │ │ │ │ │ ├── develop.py │ │ │ │ │ ├── dist_info.py │ │ │ │ │ ├── easy_install.py │ │ │ │ │ ├── egg_info.py │ │ │ │ │ ├── install.py │ │ │ │ │ ├── install_egg_info.py │ │ │ │ │ ├── install_lib.py │ │ │ │ │ ├── install_scripts.py │ │ │ │ │ ├── launcher manifest.xml │ │ │ │ │ ├── py36compat.py │ │ │ │ │ ├── register.py │ │ │ │ │ ├── rotate.py │ │ │ │ │ ├── saveopts.py │ │ │ │ │ ├── sdist.py │ │ │ │ │ ├── setopt.py │ │ │ │ │ ├── test.py │ │ │ │ │ ├── upload.py │ │ │ │ │ └── upload_docs.py │ │ │ │ ├── config.py │ │ │ │ ├── dep_util.py │ │ │ │ ├── depends.py │ │ │ │ ├── dist.py │ │ │ │ ├── errors.py │ │ │ │ ├── extension.py │ │ │ │ ├── extern/ │ │ │ │ │ └── __init__.py │ │ │ │ ├── glob.py │ │ │ │ ├── installer.py │ │ │ │ ├── launch.py │ │ │ │ ├── lib2to3_ex.py │ │ │ │ ├── monkey.py │ │ │ │ ├── msvc.py │ │ │ │ ├── namespaces.py │ │ │ │ ├── package_index.py │ │ │ │ ├── py27compat.py │ │ │ │ ├── py31compat.py │ │ │ │ ├── py33compat.py │ │ │ │ ├── py34compat.py │ │ │ │ ├── sandbox.py │ │ │ │ ├── script (dev).tmpl │ │ │ │ ├── script.tmpl │ │ │ │ ├── site-patch.py │ │ │ │ ├── ssl_support.py │ │ │ │ ├── unicode_utils.py │ │ │ │ ├── version.py │ │ │ │ ├── wheel.py │ │ │ │ └── windows_support.py │ │ │ ├── setuptools-45.0.0.dist-info/ │ │ │ │ ├── INSTALLER │ │ │ │ ├── LICENSE │ │ │ │ ├── METADATA │ │ │ │ ├── RECORD │ │ │ │ ├── REQUESTED │ │ │ │ ├── WHEEL │ │ │ │ ├── dependency_links.txt │ │ │ │ ├── entry_points.txt │ │ │ │ ├── top_level.txt │ │ │ │ └── zip-safe │ │ │ ├── socketio/ │ │ │ │ ├── __init__.py │ │ │ │ ├── asgi.py │ │ │ │ ├── asyncio_client.py │ │ │ │ ├── asyncio_manager.py │ │ │ │ ├── asyncio_namespace.py │ │ │ │ ├── asyncio_pubsub_manager.py │ │ │ │ ├── asyncio_redis_manager.py │ │ │ │ ├── asyncio_server.py │ │ │ │ ├── base_manager.py │ │ │ │ ├── client.py │ │ │ │ ├── exceptions.py │ │ │ │ ├── kombu_manager.py │ │ │ │ ├── middleware.py │ │ │ │ ├── namespace.py │ │ │ │ ├── packet.py │ │ │ │ ├── pubsub_manager.py │ │ │ │ ├── redis_manager.py │ │ │ │ ├── server.py │ │ │ │ ├── tornado.py │ │ │ │ └── zmq_manager.py │ │ │ ├── urllib3/ │ │ │ │ ├── __init__.py │ │ │ │ ├── _collections.py │ │ │ │ ├── _version.py │ │ │ │ ├── connection.py │ │ │ │ ├── connectionpool.py │ │ │ │ ├── contrib/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── _appengine_environ.py │ │ │ │ │ ├── _securetransport/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── bindings.py │ │ │ │ │ │ └── low_level.py │ │ │ │ │ ├── appengine.py │ │ │ │ │ ├── ntlmpool.py │ │ │ │ │ ├── pyopenssl.py │ │ │ │ │ ├── securetransport.py │ │ │ │ │ └── socks.py │ │ │ │ ├── exceptions.py │ │ │ │ ├── fields.py │ │ │ │ ├── filepost.py │ │ │ │ ├── packages/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── backports/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ └── makefile.py │ │ │ │ │ └── six.py │ │ │ │ ├── poolmanager.py │ │ │ │ ├── request.py │ │ │ │ ├── response.py │ │ │ │ └── util/ │ │ │ │ ├── __init__.py │ │ │ │ ├── connection.py │ │ │ │ ├── proxy.py │ │ │ │ ├── queue.py │ │ │ │ ├── request.py │ │ │ │ ├── response.py │ │ │ │ ├── retry.py │ │ │ │ ├── ssl_.py │ │ │ │ ├── ssl_match_hostname.py │ │ │ │ ├── ssltransport.py │ │ │ │ ├── timeout.py │ │ │ │ ├── url.py │ │ │ │ └── wait.py │ │ │ └── websocket/ │ │ │ ├── __init__.py │ │ │ ├── _abnf.py │ │ │ ├── _app.py │ │ │ ├── _cookiejar.py │ │ │ ├── _core.py │ │ │ ├── _exceptions.py │ │ │ ├── _handshake.py │ │ │ ├── _http.py │ │ │ ├── _logging.py │ │ │ ├── _socket.py │ │ │ ├── _ssl_compat.py │ │ │ ├── _url.py │ │ │ ├── _utils.py │ │ │ └── tests/ │ │ │ ├── __init__.py │ │ │ ├── data/ │ │ │ │ ├── header01.txt │ │ │ │ ├── header02.txt │ │ │ │ └── header03.txt │ │ │ ├── test_abnf.py │ │ │ ├── test_app.py │ │ │ ├── test_cookiejar.py │ │ │ ├── test_http.py │ │ │ ├── test_url.py │ │ │ └── test_websocket.py │ │ └── python_3/ │ │ └── README.md │ ├── version.py │ └── widgets/ │ ├── README.md │ ├── __init__.py │ ├── color_widgets/ │ │ ├── __init__.py │ │ ├── color_inputs.py │ │ ├── color_picker_widget.py │ │ ├── color_screen_pick.py │ │ ├── color_triangle.py │ │ └── color_view.py │ ├── message_window.py │ ├── nice_checkbox.py │ ├── password_dialog.py │ ├── popup.py │ └── sliders.py ├── poetry.toml ├── pyproject.toml ├── server_addon/ │ ├── README.md │ ├── aftereffects/ │ │ ├── LICENSE │ │ ├── README.md │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── creator_plugins.py │ │ │ ├── imageio.py │ │ │ ├── main.py │ │ │ ├── publish_plugins.py │ │ │ ├── templated_workfile_build.py │ │ │ └── workfile_builder.py │ │ └── version.py │ ├── applications/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── applications.json │ │ ├── settings.py │ │ ├── tools.json │ │ └── version.py │ ├── blender/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── imageio.py │ │ │ ├── main.py │ │ │ ├── publish_plugins.py │ │ │ └── render_settings.py │ │ └── version.py │ ├── celaction/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── imageio.py │ │ ├── settings.py │ │ └── version.py │ ├── clockify/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings.py │ │ └── version.py │ ├── core/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── main.py │ │ │ ├── publish_plugins.py │ │ │ └── tools.py │ │ └── version.py │ ├── create_ayon_addons.py │ ├── deadline/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── main.py │ │ │ └── publish_plugins.py │ │ └── version.py │ ├── equalizer/ │ │ ├── LICENSE │ │ ├── README.md │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── creator_plugins.py │ │ │ └── main.py │ │ └── version.py │ ├── flame/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── create_plugins.py │ │ │ ├── imageio.py │ │ │ ├── loader_plugins.py │ │ │ ├── main.py │ │ │ └── publish_plugins.py │ │ └── version.py │ ├── fusion/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── imageio.py │ │ ├── settings.py │ │ └── version.py │ ├── harmony/ │ │ ├── LICENSE │ │ ├── README.md │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── imageio.py │ │ │ ├── main.py │ │ │ └── publish_plugins.py │ │ └── version.py │ ├── hiero/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── common.py │ │ │ ├── create_plugins.py │ │ │ ├── filters.py │ │ │ ├── imageio.py │ │ │ ├── loader_plugins.py │ │ │ ├── main.py │ │ │ ├── publish_plugins.py │ │ │ └── scriptsmenu.py │ │ └── version.py │ ├── houdini/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── create.py │ │ │ ├── general.py │ │ │ ├── imageio.py │ │ │ ├── main.py │ │ │ ├── publish.py │ │ │ └── shelves.py │ │ └── version.py │ ├── max/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── create_review_settings.py │ │ │ ├── imageio.py │ │ │ ├── main.py │ │ │ ├── publishers.py │ │ │ └── render_settings.py │ │ └── version.py │ ├── maya/ │ │ ├── LICENCE │ │ ├── README.md │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── creators.py │ │ │ ├── explicit_plugins_loading.py │ │ │ ├── imageio.py │ │ │ ├── include_handles.py │ │ │ ├── loaders.py │ │ │ ├── main.py │ │ │ ├── maya_dirmap.py │ │ │ ├── publish_playblast.py │ │ │ ├── publishers.py │ │ │ ├── render_settings.py │ │ │ ├── scriptsmenu.py │ │ │ ├── templated_workfile_settings.py │ │ │ └── workfile_build_settings.py │ │ └── version.py │ ├── nuke/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── common.py │ │ │ ├── create_plugins.py │ │ │ ├── dirmap.py │ │ │ ├── general.py │ │ │ ├── gizmo.py │ │ │ ├── imageio.py │ │ │ ├── loader_plugins.py │ │ │ ├── main.py │ │ │ ├── publish_plugins.py │ │ │ ├── scriptsmenu.py │ │ │ ├── templated_workfile_build.py │ │ │ └── workfile_builder.py │ │ └── version.py │ ├── openpype/ │ │ ├── client/ │ │ │ └── pyproject.toml │ │ └── server/ │ │ └── __init__.py │ ├── photoshop/ │ │ ├── LICENSE │ │ ├── README.md │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── creator_plugins.py │ │ │ ├── imageio.py │ │ │ ├── main.py │ │ │ ├── publish_plugins.py │ │ │ └── workfile_builder.py │ │ └── version.py │ ├── resolve/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── imageio.py │ │ ├── settings.py │ │ └── version.py │ ├── royal_render/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings.py │ │ └── version.py │ ├── substancepainter/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── imageio.py │ │ │ ├── load_plugins.py │ │ │ └── main.py │ │ └── version.py │ ├── timers_manager/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings.py │ │ └── version.py │ ├── traypublisher/ │ │ └── server/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── creator_plugins.py │ │ │ ├── editorial_creators.py │ │ │ ├── imageio.py │ │ │ ├── main.py │ │ │ ├── publish_plugins.py │ │ │ └── simple_creators.py │ │ └── version.py │ ├── tvpaint/ │ │ └── server/ │ │ ├── __init__.py │ │ ├── settings/ │ │ │ ├── __init__.py │ │ │ ├── create_plugins.py │ │ │ ├── filters.py │ │ │ ├── imageio.py │ │ │ ├── main.py │ │ │ ├── publish_plugins.py │ │ │ └── workfile_builder.py │ │ └── version.py │ └── unreal/ │ └── server/ │ ├── __init__.py │ ├── imageio.py │ ├── settings.py │ └── version.py ├── setup.cfg ├── setup.py ├── start.py ├── tests/ │ ├── README.md │ ├── __init__.py │ ├── conftest.py │ ├── integration/ │ │ ├── README.md │ │ └── hosts/ │ │ ├── aftereffects/ │ │ │ ├── lib.py │ │ │ ├── test_deadline_publish_in_aftereffects.py │ │ │ ├── test_deadline_publish_in_aftereffects_multicomposition.py │ │ │ ├── test_publish_in_aftereffects.py │ │ │ ├── test_publish_in_aftereffects_legacy.py │ │ │ └── test_publish_in_aftereffects_multiframe.py │ │ ├── maya/ │ │ │ ├── lib.py │ │ │ ├── test_deadline_publish_in_maya/ │ │ │ │ ├── README.md │ │ │ │ ├── expected/ │ │ │ │ │ └── test_project/ │ │ │ │ │ └── test_asset/ │ │ │ │ │ ├── publish/ │ │ │ │ │ │ ├── model/ │ │ │ │ │ │ │ └── modelMain/ │ │ │ │ │ │ │ ├── hero/ │ │ │ │ │ │ │ │ ├── test_project_test_asset_modelMain_hero.abc │ │ │ │ │ │ │ │ └── test_project_test_asset_modelMain_hero.ma │ │ │ │ │ │ │ └── v001/ │ │ │ │ │ │ │ ├── test_project_test_asset_modelMain_v001.abc │ │ │ │ │ │ │ └── test_project_test_asset_modelMain_v001.ma │ │ │ │ │ │ ├── render/ │ │ │ │ │ │ │ └── renderTest_taskMain_beauty/ │ │ │ │ │ │ │ └── v001/ │ │ │ │ │ │ │ └── test_project_test_asset_renderTest_taskMain_beauty_v001.exr │ │ │ │ │ │ └── workfile/ │ │ │ │ │ │ └── workfileTest_task/ │ │ │ │ │ │ └── v001/ │ │ │ │ │ │ └── test_project_test_asset_workfileTest_task_v001.ma │ │ │ │ │ └── work/ │ │ │ │ │ └── test_task/ │ │ │ │ │ ├── test_project_test_asset_test_task_v001.ma │ │ │ │ │ ├── test_project_test_asset_test_task_v002.ma │ │ │ │ │ └── workspace.mel │ │ │ │ └── input/ │ │ │ │ ├── dumps/ │ │ │ │ │ ├── avalon_tests/ │ │ │ │ │ │ ├── test_project.bson │ │ │ │ │ │ └── test_project.metadata.json │ │ │ │ │ └── openpype_tests/ │ │ │ │ │ ├── settings.bson │ │ │ │ │ └── settings.metadata.json │ │ │ │ ├── env_vars/ │ │ │ │ │ └── env_var.json │ │ │ │ ├── startup/ │ │ │ │ │ └── userSetup.py │ │ │ │ └── workfile/ │ │ │ │ └── test_project_test_asset_test_task_v001.ma │ │ │ ├── test_deadline_publish_in_maya.py │ │ │ ├── test_publish_in_maya/ │ │ │ │ ├── expected/ │ │ │ │ │ └── test_project/ │ │ │ │ │ └── test_asset/ │ │ │ │ │ ├── publish/ │ │ │ │ │ │ ├── model/ │ │ │ │ │ │ │ └── modelMain/ │ │ │ │ │ │ │ ├── hero/ │ │ │ │ │ │ │ │ ├── test_project_test_asset_modelMain_hero.abc │ │ │ │ │ │ │ │ └── test_project_test_asset_modelMain_hero.ma │ │ │ │ │ │ │ └── v001/ │ │ │ │ │ │ │ ├── test_project_test_asset_modelMain_v001.abc │ │ │ │ │ │ │ └── test_project_test_asset_modelMain_v001.ma │ │ │ │ │ │ └── workfile/ │ │ │ │ │ │ └── workfileTest_task/ │ │ │ │ │ │ └── v001/ │ │ │ │ │ │ └── test_project_test_asset_workfileTest_task_v001.ma │ │ │ │ │ └── work/ │ │ │ │ │ └── test_task/ │ │ │ │ │ ├── test_project_test_asset_test_task_v001.ma │ │ │ │ │ ├── test_project_test_asset_test_task_v002.ma │ │ │ │ │ └── workspace.mel │ │ │ │ └── input/ │ │ │ │ ├── dumps/ │ │ │ │ │ ├── avalon_tests/ │ │ │ │ │ │ ├── test_project.bson │ │ │ │ │ │ └── test_project.metadata.json │ │ │ │ │ └── openpype_tests/ │ │ │ │ │ ├── settings.bson │ │ │ │ │ └── settings.metadata.json │ │ │ │ ├── env_vars/ │ │ │ │ │ └── env_var.json │ │ │ │ ├── startup/ │ │ │ │ │ └── userSetup.py │ │ │ │ └── workfile/ │ │ │ │ └── test_project_test_asset_test_task_v001.ma │ │ │ └── test_publish_in_maya.py │ │ ├── nuke/ │ │ │ ├── lib.py │ │ │ ├── test_deadline_publish_in_nuke.py │ │ │ ├── test_deadline_publish_in_nuke_prerender.py │ │ │ └── test_publish_in_nuke.py │ │ └── photoshop/ │ │ ├── lib.py │ │ ├── test_publish_in_photoshop.py │ │ ├── test_publish_in_photoshop_auto_image.py │ │ └── test_publish_in_photoshop_review.py │ ├── lib/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── assert_classes.py │ │ ├── db_handler.py │ │ ├── file_handler.py │ │ └── testing_classes.py │ └── unit/ │ ├── igniter/ │ │ ├── test_bootstrap_repos.py │ │ └── test_tools.py │ └── openpype/ │ ├── conftest.py │ ├── hosts/ │ │ ├── photoshop/ │ │ │ └── test_lib.py │ │ └── unreal/ │ │ └── plugins/ │ │ └── publish/ │ │ └── test_validate_sequence_frames.py │ ├── lib/ │ │ ├── test_delivery.py │ │ ├── test_event_system.py │ │ └── test_user_settings.py │ ├── modules/ │ │ └── sync_server/ │ │ ├── test_module_api.py │ │ └── test_site_operations.py │ ├── pipeline/ │ │ ├── lib.py │ │ ├── publish/ │ │ │ └── test_publish_plugins.py │ │ ├── test_colorspace.py │ │ ├── test_colorspace_convert_colorspace_enumerator_item.py │ │ └── test_colorspace_get_colorspaces_enumerator_items.py │ └── plugins/ │ └── publish/ │ └── test_extract_review.py ├── tools/ │ ├── build.ps1 │ ├── build.sh │ ├── build_dependencies.py │ ├── build_win_installer.ps1 │ ├── ci_tools.py │ ├── create_env.ps1 │ ├── create_env.sh │ ├── create_zip.ps1 │ ├── create_zip.py │ ├── create_zip.sh │ ├── docker_build.ps1 │ ├── docker_build.sh │ ├── fetch_thirdparty_libs.ps1 │ ├── fetch_thirdparty_libs.py │ ├── fetch_thirdparty_libs.sh │ ├── get_python_packages_info.py │ ├── make_docs.ps1 │ ├── make_docs.sh │ ├── openpype_console.bat │ ├── pack_project.ps1 │ ├── parse_pyproject.py │ ├── run_documentation.ps1 │ ├── run_mongo.ps1 │ ├── run_mongo.sh │ ├── run_project_manager.ps1 │ ├── run_projectmanager.sh │ ├── run_publish_report_viewer.ps1 │ ├── run_settings.ps1 │ ├── run_settings.sh │ ├── run_tests.ps1 │ ├── run_tests.sh │ ├── run_tray.ps1 │ ├── run_tray.sh │ ├── unpack_project.ps1 │ ├── update_submodules.ps1 │ └── update_submodules.sh ├── vendor/ │ └── README.md └── website/ ├── README.md ├── docs/ │ ├── admin_builds.md │ ├── admin_distribute.md │ ├── admin_docsexamples.md │ ├── admin_environment.md │ ├── admin_hosts_aftereffects.md │ ├── admin_hosts_blender.md │ ├── admin_hosts_harmony.md │ ├── admin_hosts_hiero.md │ ├── admin_hosts_houdini.md │ ├── admin_hosts_maya.md │ ├── admin_hosts_nuke.md │ ├── admin_hosts_photoshop.md │ ├── admin_hosts_resolve.md │ ├── admin_hosts_tvpaint.md │ ├── admin_openpype_commands.md │ ├── admin_releases.md │ ├── admin_settings.md │ ├── admin_settings_local.md │ ├── admin_settings_project_anatomy.md │ ├── admin_settings_system.md │ ├── admin_use.md │ ├── admin_webserver_for_webpublisher.md │ ├── artist_concepts.md │ ├── artist_ftrack.md │ ├── artist_getting_started.md │ ├── artist_hosts_3dsmax.md │ ├── artist_hosts_aftereffects.md │ ├── artist_hosts_blender.md │ ├── artist_hosts_harmony.md │ ├── artist_hosts_hiero.md │ ├── artist_hosts_houdini.md │ ├── artist_hosts_maya.md │ ├── artist_hosts_maya_arnold.md │ ├── artist_hosts_maya_multiverse.md │ ├── artist_hosts_maya_redshift.md │ ├── artist_hosts_maya_vray.md │ ├── artist_hosts_maya_xgen.md │ ├── artist_hosts_maya_yeti.md │ ├── artist_hosts_nuke_tut.md │ ├── artist_hosts_photoshop.md │ ├── artist_hosts_resolve.md │ ├── artist_hosts_substancepainter.md │ ├── artist_hosts_tvpaint.md │ ├── artist_hosts_unreal.md │ ├── artist_install.md │ ├── artist_kitsu.md │ ├── artist_publish.md │ ├── artist_tools.md │ ├── artist_tools_context_manager.md │ ├── artist_tools_creator.md │ ├── artist_tools_inventory.md │ ├── artist_tools_library_loader.md │ ├── artist_tools_loader.md │ ├── artist_tools_look_assigner.md │ ├── artist_tools_publisher.md │ ├── artist_tools_subset_manager.md │ ├── artist_tools_sync_queu.md │ ├── artist_tools_workfiles.md │ ├── artist_work.md │ ├── dev_blender.md │ ├── dev_build.md │ ├── dev_colorspace.md │ ├── dev_contribute.md │ ├── dev_deadline.md │ ├── dev_host_implementation.md │ ├── dev_introduction.md │ ├── dev_publishing.md │ ├── dev_requirements.md │ ├── dev_settings.md │ ├── dev_testing.md │ ├── features.md │ ├── manager_ftrack.md │ ├── manager_ftrack_actions.md │ ├── module_clockify.md │ ├── module_deadline.md │ ├── module_ftrack.md │ ├── module_kitsu.md │ ├── module_royalrender.md │ ├── module_site_sync.md │ ├── module_slack.md │ ├── project_settings/ │ │ ├── settings_project_global.md │ │ ├── settings_project_nuke.md │ │ └── settings_project_standalone.md │ ├── pype2/ │ │ ├── admin_anatomy.md │ │ ├── admin_config.md │ │ ├── admin_ftrack.md │ │ ├── admin_hosts.md │ │ ├── admin_install.md │ │ ├── admin_introduction.md │ │ ├── admin_presets_ftrack.md │ │ ├── admin_presets_maya.md │ │ ├── admin_presets_nukestudio.md │ │ ├── admin_presets_plugins.md │ │ ├── admin_presets_tools.md │ │ ├── admin_pype_commands.md │ │ └── admin_setup_troubleshooting.md │ └── system_introduction.md ├── docusaurus.config.js ├── publish.cmd ├── sidebars.js ├── src/ │ ├── components/ │ │ ├── BadgesSection/ │ │ │ ├── badges.js │ │ │ ├── index.js │ │ │ └── styles.module.css │ │ ├── GithubButton/ │ │ │ └── index.js │ │ └── index.js │ ├── css/ │ │ └── custom.css │ └── pages/ │ ├── features.js │ ├── index.js │ └── styles.module.css └── static/ ├── .circleci/ │ └── config.yml └── img/ └── app_logos.psd ================================================ FILE CONTENTS ================================================ ================================================ FILE: .all-contributorsrc ================================================ { "projectName": "OpenPype", "projectOwner": "ynput", "repoType": "github", "repoHost": "https://github.com", "files": [ "README.md" ], "imageSize": 100, "commit": true, "commitConvention": "none", "contributors": [ { "login": "mkolar", "name": "Milan Kolar", "avatar_url": "https://avatars.githubusercontent.com/u/3333008?v=4", "profile": "http://pype.club/", "contributions": [ "code", "doc", "infra", "business", "content", "fundingFinding", "maintenance", "projectManagement", "review", "mentoring", "question" ] }, { "login": "jakubjezek001", "name": "Jakub Ježek", "avatar_url": "https://avatars.githubusercontent.com/u/40640033?v=4", "profile": "https://www.linkedin.com/in/jakubjezek79", "contributions": [ "code", "doc", "infra", "content", "review", "maintenance", "mentoring", "projectManagement", "question" ] }, { "login": "antirotor", "name": "Ondřej Samohel", "avatar_url": "https://avatars.githubusercontent.com/u/33513211?v=4", "profile": "https://github.com/antirotor", "contributions": [ "code", "doc", "infra", "content", "review", "maintenance", "mentoring", "projectManagement", "question" ] }, { "login": "iLLiCiTiT", "name": "Jakub Trllo", "avatar_url": "https://avatars.githubusercontent.com/u/43494761?v=4", "profile": "https://github.com/iLLiCiTiT", "contributions": [ "code", "doc", "infra", "review", "maintenance", "question" ] }, { "login": "kalisp", "name": "Petr Kalis", "avatar_url": "https://avatars.githubusercontent.com/u/4457962?v=4", "profile": "https://github.com/kalisp", "contributions": [ "code", "doc", "infra", "review", "maintenance", "question" ] }, { "login": "64qam", "name": "64qam", "avatar_url": "https://avatars.githubusercontent.com/u/26925793?v=4", "profile": "https://github.com/64qam", "contributions": [ "code", "review", "doc", "infra", "projectManagement", "maintenance", "content", "userTesting" ] }, { "login": "BigRoy", "name": "Roy Nieterau", "avatar_url": "https://avatars.githubusercontent.com/u/2439881?v=4", "profile": "http://www.colorbleed.nl/", "contributions": [ "code", "doc", "review", "mentoring", "question" ] }, { "login": "tokejepsen", "name": "Toke Jepsen", "avatar_url": "https://avatars.githubusercontent.com/u/1860085?v=4", "profile": "https://github.com/tokejepsen", "contributions": [ "code", "doc", "review", "mentoring", "question" ] }, { "login": "jrsndl", "name": "Jiri Sindelar", "avatar_url": "https://avatars.githubusercontent.com/u/45896205?v=4", "profile": "https://github.com/jrsndl", "contributions": [ "code", "review", "doc", "content", "tutorial", "userTesting" ] }, { "login": "simonebarbieri", "name": "Simone Barbieri", "avatar_url": "https://avatars.githubusercontent.com/u/1087869?v=4", "profile": "https://barbierisimone.com/", "contributions": [ "code", "doc" ] }, { "login": "karimmozilla", "name": "karimmozilla", "avatar_url": "https://avatars.githubusercontent.com/u/82811760?v=4", "profile": "http://karimmozilla.xyz/", "contributions": [ "code" ] }, { "login": "Allan-I", "name": "Allan I. A.", "avatar_url": "https://avatars.githubusercontent.com/u/76656700?v=4", "profile": "https://github.com/Allan-I", "contributions": [ "code" ] }, { "login": "m-u-r-p-h-y", "name": "murphy", "avatar_url": "https://avatars.githubusercontent.com/u/352795?v=4", "profile": "https://www.linkedin.com/in/mmuurrpphhyy/", "contributions": [ "code", "review", "userTesting", "doc", "projectManagement" ] }, { "login": "aardschok", "name": "Wijnand Koreman", "avatar_url": "https://avatars.githubusercontent.com/u/26920875?v=4", "profile": "https://github.com/aardschok", "contributions": [ "code" ] }, { "login": "zhoub", "name": "Bo Zhou", "avatar_url": "https://avatars.githubusercontent.com/u/1798206?v=4", "profile": "http://jedimaster.cnblogs.com/", "contributions": [ "code" ] }, { "login": "ClementHector", "name": "Clément Hector", "avatar_url": "https://avatars.githubusercontent.com/u/7068597?v=4", "profile": "https://www.linkedin.com/in/clementhector/", "contributions": [ "code", "review" ] }, { "login": "davidlatwe", "name": "David Lai", "avatar_url": "https://avatars.githubusercontent.com/u/3357009?v=4", "profile": "https://twitter.com/davidlatwe", "contributions": [ "code", "review" ] }, { "login": "2-REC", "name": "Derek ", "avatar_url": "https://avatars.githubusercontent.com/u/42170307?v=4", "profile": "https://github.com/2-REC", "contributions": [ "code", "doc" ] }, { "login": "gabormarinov", "name": "Gábor Marinov", "avatar_url": "https://avatars.githubusercontent.com/u/8620515?v=4", "profile": "https://github.com/gabormarinov", "contributions": [ "code", "doc" ] }, { "login": "icyvapor", "name": "icyvapor", "avatar_url": "https://avatars.githubusercontent.com/u/1195278?v=4", "profile": "https://github.com/icyvapor", "contributions": [ "code", "doc" ] }, { "login": "jlorrain", "name": "Jérôme LORRAIN", "avatar_url": "https://avatars.githubusercontent.com/u/7955673?v=4", "profile": "https://github.com/jlorrain", "contributions": [ "code" ] }, { "login": "dmo-j-cube", "name": "David Morris-Oliveros", "avatar_url": "https://avatars.githubusercontent.com/u/89823400?v=4", "profile": "https://github.com/dmo-j-cube", "contributions": [ "code" ] }, { "login": "BenoitConnan", "name": "BenoitConnan", "avatar_url": "https://avatars.githubusercontent.com/u/82808268?v=4", "profile": "https://github.com/BenoitConnan", "contributions": [ "code" ] }, { "login": "Malthaldar", "name": "Malthaldar", "avatar_url": "https://avatars.githubusercontent.com/u/33671694?v=4", "profile": "https://github.com/Malthaldar", "contributions": [ "code" ] }, { "login": "svenneve", "name": "Sven Neve", "avatar_url": "https://avatars.githubusercontent.com/u/2472863?v=4", "profile": "http://www.svenneve.com/", "contributions": [ "code" ] }, { "login": "zafrs", "name": "zafrs", "avatar_url": "https://avatars.githubusercontent.com/u/26890002?v=4", "profile": "https://github.com/zafrs", "contributions": [ "code" ] }, { "login": "Tilix4", "name": "Félix David", "avatar_url": "https://avatars.githubusercontent.com/u/22875539?v=4", "profile": "http://felixdavid.com/", "contributions": [ "code", "doc" ] }, { "login": "movalex", "name": "Alexey Bogomolov", "avatar_url": "https://avatars.githubusercontent.com/u/11698866?v=4", "profile": "http://abogomolov.com", "contributions": [ "code" ] } ], "contributorsPerLine": 7, "skipCi": true, "commitType": "docs" } ================================================ FILE: .dockerignore ================================================ # Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ .poetry/ .github/ vendor/bin/ vendor/python/ docs/ website/ ================================================ FILE: .gitattributes ================================================ * text=auto *.sh text eol=lf *.command eol=lf *.bat text eol=crlf *.js eol=lf *.c eol=lf ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: File a bug report title: 'Bug: ' labels: - 'type: bug' body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: checkboxes attributes: label: Is there an existing issue for this? description: >- Please search to see if an issue already exists for the bug you encountered. options: - label: I have searched the existing issues required: true - type: textarea attributes: label: 'Current Behavior:' description: A concise description of what you're experiencing. validations: required: true - type: textarea attributes: label: 'Expected Behavior:' description: A concise description of what you expected to happen. validations: required: false - type: dropdown id: _version attributes: label: Version description: What version are you running? Look to OpenPype Tray options: - 3.18.12-nightly.26 - 3.18.12-nightly.25 - 3.18.12-nightly.24 - 3.18.12-nightly.23 - 3.18.12-nightly.22 - 3.18.12-nightly.21 - 3.18.12-nightly.20 - 3.18.12-nightly.19 - 3.18.12-nightly.18 - 3.18.12-nightly.17 - 3.18.12-nightly.16 - 3.18.12-nightly.15 - 3.18.12-nightly.14 - 3.18.12-nightly.13 - 3.18.12-nightly.12 - 3.18.12-nightly.11 - 3.18.12-nightly.10 - 3.18.12-nightly.9 - 3.18.12-nightly.8 - 3.18.12-nightly.7 - 3.18.12-nightly.6 - 3.18.12-nightly.5 - 3.18.12-nightly.4 - 3.18.12-nightly.3 - 3.18.12-nightly.2 - 3.18.12-nightly.1 - 3.18.11 - 3.18.11-nightly.10 - 3.18.11-nightly.9 - 3.18.11-nightly.8 - 3.18.11-nightly.7 - 3.18.11-nightly.6 - 3.18.11-nightly.5 - 3.18.11-nightly.4 - 3.18.11-nightly.3 - 3.18.11-nightly.2 - 3.18.11-nightly.1 - 3.18.10 - 3.18.10-nightly.2 - 3.18.10-nightly.1 - 3.18.9 - 3.18.9-nightly.11 - 3.18.9-nightly.10 - 3.18.9-nightly.9 - 3.18.9-nightly.8 - 3.18.9-nightly.7 - 3.18.9-nightly.6 - 3.18.9-nightly.5 - 3.18.9-nightly.4 - 3.18.9-nightly.3 - 3.18.9-nightly.2 - 3.18.9-nightly.1 - 3.18.8 - 3.18.8-nightly.2 - 3.18.8-nightly.1 - 3.18.7 - 3.18.7-nightly.5 - 3.18.7-nightly.4 - 3.18.7-nightly.3 - 3.18.7-nightly.2 - 3.18.7-nightly.1 - 3.18.6 - 3.18.6-nightly.2 - 3.18.6-nightly.1 - 3.18.5 - 3.18.5-nightly.3 - 3.18.5-nightly.2 - 3.18.5-nightly.1 - 3.18.4 - 3.18.4-nightly.1 - 3.18.3 - 3.18.3-nightly.2 - 3.18.3-nightly.1 - 3.18.2 - 3.18.2-nightly.6 - 3.18.2-nightly.5 - 3.18.2-nightly.4 - 3.18.2-nightly.3 - 3.18.2-nightly.2 - 3.18.2-nightly.1 - 3.18.1 - 3.18.1-nightly.1 - 3.18.0 - 3.17.7 - 3.17.7-nightly.7 - 3.17.7-nightly.6 - 3.17.7-nightly.5 - 3.17.7-nightly.4 - 3.17.7-nightly.3 - 3.17.7-nightly.2 - 3.17.7-nightly.1 - 3.17.6 - 3.17.6-nightly.3 - 3.17.6-nightly.2 - 3.17.6-nightly.1 - 3.17.5 - 3.17.5-nightly.3 - 3.17.5-nightly.2 - 3.17.5-nightly.1 - 3.17.4 validations: required: true - type: dropdown validations: required: true attributes: label: What platform you are running OpenPype on? description: | Please specify the operating systems you are running OpenPype with. multiple: true options: - Windows - Linux / Centos - Linux / Ubuntu - Linux / RedHat - MacOS - type: textarea id: to-reproduce attributes: label: 'Steps To Reproduce:' description: Steps to reproduce the behavior. placeholder: | 1. How did the configuration look like 2. What type of action was made validations: required: true - type: checkboxes attributes: label: Are there any labels you wish to add? description: Please search labels and identify those related to your bug. options: - label: I have added the relevant labels to the bug report. required: true - type: textarea id: logs attributes: label: 'Relevant log output:' description: >- Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. render: shell - type: textarea id: additional-context attributes: label: 'Additional context:' description: Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Ynput Community Discussions url: https://community.ynput.io about: Please ask and answer questions here. - name: Ynput Discord Server url: https://discord.gg/ynput about: For community quick chats. ================================================ FILE: .github/ISSUE_TEMPLATE/enhancement_request.yml ================================================ name: Enhancement Request description: Create a report to help us enhance a particular feature title: "Enhancement: " labels: - "type: enhancement" body: - type: markdown attributes: value: | Thanks for taking the time to fill out this enhancement request report! - type: checkboxes attributes: label: Is there an existing issue for this? description: Please search to see if an issue already exists for the bug you encountered. options: - label: I have searched the existing issues. required: true - type: textarea id: related-feature attributes: label: Please describe the feature you have in mind and explain what the current shortcomings are? description: A clear and concise description of what the problem is. validations: required: true - type: textarea id: enhancement-proposal attributes: label: How would you imagine the implementation of the feature? description: A clear and concise description of what you want to happen. validations: required: true - type: checkboxes attributes: label: Are there any labels you wish to add? description: Please search labels and identify those related to your enhancement. options: - label: I have added the relevant labels to the enhancement request. required: true - type: textarea id: alternatives attributes: label: "Describe alternatives you've considered:" description: A clear and concise description of any alternative solutions or features you've considered. validations: required: false - type: textarea id: additional-context attributes: label: "Additional context:" description: Add any other context or screenshots about the enhancement request here. validations: required: false ================================================ FILE: .github/pr-branch-labeler.yml ================================================ # Apply label "feature" if head matches "feature/*" 'type: feature': head: "feature/*" # Apply label "feature" if head matches "feature/*" 'type: enhancement': head: "enhancement/*" # Apply label "bugfix" if head matches one of "bugfix/*" or "hotfix/*" 'type: bug': head: ["bugfix/*", "hotfix/*"] # Apply label "release" if base matches "release/*" 'Bump Minor': base: "release/next-minor" ================================================ FILE: .github/pr-glob-labeler.yml ================================================ # Add type: unittest label if any changes in tests folders 'type: unittest': - '*/*tests*/**/*' # any changes in documentation structure 'type: documentation': - '*/**/*website*/**/*' - '*/**/*docs*/**/*' # hosts triage 'host: Nuke': - '*/**/*nuke*' - '*/**/*nuke*/**/*' 'host: Photoshop': - '*/**/*photoshop*' - '*/**/*photoshop*/**/*' 'host: Harmony': - '*/**/*harmony*' - '*/**/*harmony*/**/*' 'host: UE': - '*/**/*unreal*' - '*/**/*unreal*/**/*' 'host: Houdini': - '*/**/*houdini*' - '*/**/*houdini*/**/*' 'host: Maya': - '*/**/*maya*' - '*/**/*maya*/**/*' 'host: Resolve': - '*/**/*resolve*' - '*/**/*resolve*/**/*' 'host: Blender': - '*/**/*blender*' - '*/**/*blender*/**/*' 'host: Hiero': - '*/**/*hiero*' - '*/**/*hiero*/**/*' 'host: Fusion': - '*/**/*fusion*' - '*/**/*fusion*/**/*' 'host: Flame': - '*/**/*flame*' - '*/**/*flame*/**/*' 'host: TrayPublisher': - '*/**/*traypublisher*' - '*/**/*traypublisher*/**/*' 'host: 3dsmax': - '*/**/*max*' - '*/**/*max*/**/*' 'host: TV Paint': - '*/**/*tvpaint*' - '*/**/*tvpaint*/**/*' 'host: CelAction': - '*/**/*celaction*' - '*/**/*celaction*/**/*' 'host: After Effects': - '*/**/*aftereffects*' - '*/**/*aftereffects*/**/*' 'host: Substance Painter': - '*/**/*substancepainter*' - '*/**/*substancepainter*/**/*' # modules triage 'module: Deadline': - '*/**/*deadline*' - '*/**/*deadline*/**/*' 'module: RoyalRender': - '*/**/*royalrender*' - '*/**/*royalrender*/**/*' 'module: Sitesync': - '*/**/*sync_server*' - '*/**/*sync_server*/**/*' 'module: Ftrack': - '*/**/*ftrack*' - '*/**/*ftrack*/**/*' 'module: Shotgrid': - '*/**/*shotgrid*' - '*/**/*shotgrid*/**/*' 'module: Kitsu': - '*/**/*kitsu*' - '*/**/*kitsu*/**/*' ================================================ FILE: .github/pull_request_template.md ================================================ ## Changelog Description Paragraphs contain detailed information on the changes made to the product or service, providing an in-depth description of the updates and enhancements. They can be used to explain the reasoning behind the changes, or to highlight the importance of the new features. Paragraphs can often include links to further information or support documentation. ## Additional info Paragraphs of text giving context of additional technical information or code examples. ## Testing notes: 1. start with this step 2. follow this step ================================================ FILE: .github/workflows/documentation.yml ================================================ name: 📜 Documentation on: pull_request: branches: [develop] types: [review_requested, ready_for_review] paths: - 'website/**' push: branches: [main] paths: - 'website/**' workflow_dispatch: jobs: check-build: if: github.event_name != 'push' runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - uses: actions/setup-node@v1 with: node-version: 14.x cache: yarn - name: Test Build run: | cd website if [ -e yarn.lock ]; then yarn install --frozen-lockfile elif [ -e package-lock.json ]; then npm ci else npm i fi npm run build deploy-website: if: github.event_name != 'pull_request' runs-on: ubuntu-latest steps: - name: 🚚 Get latest code uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: 14.x cache: yarn - name: 🔨 Build run: | cd website if [ -e yarn.lock ]; then yarn install --frozen-lockfile elif [ -e package-lock.json ]; then npm ci else npm i fi npm run build - name: 📂 Sync files uses: SamKirkland/FTP-Deploy-Action@4.0.0 with: server: ftp.openpype.io username: ${{ secrets.ftp_user }} password: ${{ secrets.ftp_password }} local-dir: ./website/build/ ================================================ FILE: .github/workflows/milestone_assign.yml ================================================ name: 👉🏻 Milestone - assign to PRs on: pull_request_target: types: [closed] jobs: run_if_release: if: startsWith(github.base_ref, 'release/') runs-on: ubuntu-latest steps: - name: 'Assign Milestone [next-minor]' if: github.event.pull_request.milestone == null uses: zoispag/action-assign-milestone@v1 with: repo-token: "${{ secrets.YNPUT_BOT_TOKEN }}" milestone: 'next-minor' run_if_develop: if: ${{ github.base_ref == 'develop' }} runs-on: ubuntu-latest steps: - name: 'Assign Milestone [next-patch]' if: github.event.pull_request.milestone == null uses: zoispag/action-assign-milestone@v1 with: repo-token: "${{ secrets.YNPUT_BOT_TOKEN }}" milestone: 'next-patch' ================================================ FILE: .github/workflows/milestone_create.yml ================================================ name: ➕ Milestone - create default on: milestone: types: [closed, edited] jobs: generate-next-patch: runs-on: ubuntu-latest steps: - name: 'Get Milestones' uses: "WyriHaximus/github-action-get-milestones@master" id: milestones env: GITHUB_TOKEN: "${{ secrets.YNPUT_BOT_TOKEN }}" - run: printf "name=number::%s" $(printenv MILESTONES | jq --arg MILESTONE $(printenv MILESTONE) '.[] | select(.title == $MILESTONE) | .number') id: querymilestone env: MILESTONES: ${{ steps.milestones.outputs.milestones }} MILESTONE: "next-patch" - name: Read output run: | echo "${{ steps.querymilestone.outputs.number }}" - name: 'Create `next-patch` milestone' if: steps.querymilestone.outputs.number == '' id: createmilestone uses: "WyriHaximus/github-action-create-milestone@v1" with: title: 'next-patch' env: GITHUB_TOKEN: "${{ secrets.YNPUT_BOT_TOKEN }}" generate-next-minor: runs-on: ubuntu-latest steps: - name: 'Get Milestones' uses: "WyriHaximus/github-action-get-milestones@master" id: milestones env: GITHUB_TOKEN: "${{ secrets.YNPUT_BOT_TOKEN }}" - run: printf "name=number::%s" $(printenv MILESTONES | jq --arg MILESTONE $(printenv MILESTONE) '.[] | select(.title == $MILESTONE) | .number') id: querymilestone env: MILESTONES: ${{ steps.milestones.outputs.milestones }} MILESTONE: "next-minor" - name: Read output run: | echo "${{ steps.querymilestone.outputs.number }}" - name: 'Create `next-minor` milestone' if: steps.querymilestone.outputs.number == '' id: createmilestone uses: "WyriHaximus/github-action-create-milestone@v1" with: title: 'next-minor' env: GITHUB_TOKEN: "${{ secrets.YNPUT_BOT_TOKEN }}" ================================================ FILE: .github/workflows/miletone_release_trigger.yml ================================================ name: 🚩 Milestone Release [trigger] on: workflow_dispatch: inputs: milestone: required: true milestone: types: closed jobs: milestone-title: runs-on: ubuntu-latest outputs: milestone: ${{ steps.milestoneTitle.outputs.value }} steps: - name: Switch input milestone uses: haya14busa/action-cond@v1 id: milestoneTitle with: cond: ${{ inputs.milestone == '' }} if_true: ${{ github.event.milestone.title }} if_false: ${{ inputs.milestone }} - name: Print resulted milestone run: | echo "${{ steps.milestoneTitle.outputs.value }}" call-ci-tools-milestone-release: needs: milestone-title uses: ynput/ci-tools/.github/workflows/milestone_release_ref.yml@main with: milestone: ${{ needs.milestone-title.outputs.milestone }} repo-owner: ${{ github.event.repository.owner.login }} repo-name: ${{ github.event.repository.name }} version-py-path: "./openpype/version.py" pyproject-path: "./pyproject.toml" secrets: token: ${{ secrets.YNPUT_BOT_TOKEN }} user_email: ${{ secrets.CI_EMAIL }} user_name: ${{ secrets.CI_USER }} cu_api_key: ${{ secrets.CLICKUP_API_KEY }} cu_team_id: ${{ secrets.CLICKUP_TEAM_ID }} cu_field_id: ${{ secrets.CLICKUP_RELEASE_FIELD_ID }} ================================================ FILE: .github/workflows/nightly_merge.yml ================================================ name: 🔀 Dev -> Main on: schedule: - cron: '21 3 * * 3,6' workflow_dispatch: jobs: develop-to-main: runs-on: ubuntu-latest steps: - name: 🚛 Checkout Code uses: actions/checkout@v2 - name: 🔨 Merge develop to main uses: everlytic/branch-merge@1.1.0 with: github_token: ${{ secrets.YNPUT_BOT_TOKEN }} source_ref: 'develop' target_branch: 'main' commit_message_template: '[Automated] Merged {source_ref} into {target_branch}' - name: Invoke pre-release workflow uses: benc-uk/workflow-dispatch@v1 with: workflow: prerelease.yml token: ${{ secrets.YNPUT_BOT_TOKEN }} ================================================ FILE: .github/workflows/pr_labels.yml ================================================ name: 🔖 PR labels on: pull_request_target: types: [opened, assigned] jobs: size-label: name: pr_size_label runs-on: ubuntu-latest if: github.event.action == 'assigned' || github.event.action == 'opened' steps: - name: Add size label uses: "pascalgn/size-label-action@v0.4.3" env: GITHUB_TOKEN: "${{ secrets.YNPUT_BOT_TOKEN }}" IGNORED: ".gitignore\n*.md\n*.json" with: sizes: > { "0": "XS", "100": "S", "500": "M", "1000": "L", "1500": "XL", "2500": "XXL" } label_prs_branch: name: pr_branch_label runs-on: ubuntu-latest if: github.event.action == 'assigned' || github.event.action == 'opened' steps: - name: Label PRs - Branch name detection uses: ffittschen/pr-branch-labeler@v1 with: repo-token: ${{ secrets.YNPUT_BOT_TOKEN }} label_prs_globe: name: pr_globe_label runs-on: ubuntu-latest if: github.event.action == 'assigned' || github.event.action == 'opened' steps: - name: Label PRs - Globe detection uses: actions/labeler@v4.0.3 with: repo-token: ${{ secrets.YNPUT_BOT_TOKEN }} configuration-path: ".github/pr-glob-labeler.yml" sync-labels: false ================================================ FILE: .github/workflows/prerelease.yml ================================================ name: ⏳ Nightly Prerelease on: workflow_dispatch: jobs: create_nightly: runs-on: ubuntu-latest steps: - name: 🚛 Checkout Code uses: actions/checkout@v2 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v2 with: python-version: 3.9 - name: Install Python requirements run: pip install gitpython semver PyGithub - name: 🔎 Determine next version type id: version_type run: | TYPE=$(python ./tools/ci_tools.py --bump --github_token ${{ secrets.YNPUT_BOT_TOKEN }}) echo "type=${TYPE}" >> $GITHUB_OUTPUT - name: 💉 Inject new version into files id: version if: steps.version_type.outputs.type != 'skip' run: | NEW_VERSION_TAG=$(python ./tools/ci_tools.py --nightly --github_token ${{ secrets.YNPUT_BOT_TOKEN }}) echo "next_tag=${NEW_VERSION_TAG}" >> $GITHUB_OUTPUT - name: 💾 Commit and Tag id: git_commit if: steps.version_type.outputs.type != 'skip' run: | git config user.email ${{ secrets.CI_EMAIL }} git config user.name ${{ secrets.CI_USER }} git checkout main git pull git add . git commit -m "[Automated] Bump version" tag_name="CI/${{ steps.version.outputs.next_tag }}" echo $tag_name git tag -a $tag_name -m "nightly build" - name: Push to protected main branch uses: CasperWA/push-protected@v2.10.0 with: token: ${{ secrets.YNPUT_BOT_TOKEN }} branch: main tags: true unprotect_reviews: true - name: 🔨 Merge main back to develop uses: everlytic/branch-merge@1.1.0 if: steps.version_type.outputs.type != 'skip' with: github_token: ${{ secrets.YNPUT_BOT_TOKEN }} source_ref: 'main' target_branch: 'develop' commit_message_template: '[Automated] Merged {source_ref} into {target_branch}' - name: Invoke Update bug report workflow uses: benc-uk/workflow-dispatch@v1 with: workflow: update_bug_report.yml token: ${{ secrets.YNPUT_BOT_TOKEN }} ================================================ FILE: .github/workflows/project_task_statuses.yml ================================================ name: 📊 Project task statuses on: pull_request_review: types: [submitted] issue_comment: types: [created] pull_request_review_comment: types: [created] jobs: pr_review_started: name: pr_review_started runs-on: ubuntu-latest # ----------------------------- # conditions are: # - PR issue comment which is not form Ynbot # - PR review comment which is not Hound (or any other bot) # - PR review submitted which is not from Hound (or any other bot) and is not 'Changes requested' # - make sure it only runs if not forked repo # ----------------------------- if: | (github.event_name == 'issue_comment' && github.event.pull_request.head.repo.owner.login == 'ynput' && github.event.comment.user.id != 82967070) || (github.event_name == 'pull_request_review_comment' && github.event.pull_request.head.repo.owner.login == 'ynput' && github.event.comment.user.type != 'Bot') || (github.event_name == 'pull_request_review' && github.event.pull_request.head.repo.owner.login == 'ynput' && github.event.review.state != 'changes_requested' && github.event.review.state != 'approved' && github.event.review.user.type != 'Bot') steps: - name: Move PR to 'Review In Progress' uses: leonsteinhaeuser/project-beta-automations@v2.1.0 with: gh_token: ${{ secrets.YNPUT_BOT_TOKEN }} organization: ynput project_id: 11 resource_node_id: ${{ github.event.pull_request.node_id || github.event.issue.node_id }} status_value: Review In Progress pr_review_requested: # ----------------------------- # Resets Clickup Task status to 'In Progress' after 'Changes Requested' were submitted to PR # It only runs if custom clickup task id was found in ref branch of PR # ----------------------------- name: pr_review_requested runs-on: ubuntu-latest if: github.event_name == 'pull_request_review' && github.event.pull_request.head.repo.owner.login == 'ynput' && github.event.review.state == 'changes_requested' steps: - name: Set branch env run: echo "BRANCH_NAME=${{ github.event.pull_request.head.ref}}" >> $GITHUB_ENV - name: Get ClickUp ID from ref head name id: get_cuID run: | echo ${{ env.BRANCH_NAME }} echo "cuID=$(echo $BRANCH_NAME | sed 's/.*\/\(OP\-[0-9]\{4\}\).*/\1/')" >> $GITHUB_OUTPUT - name: Print ClickUp ID run: echo ${{ steps.get_cuID.outputs.cuID }} - name: Move found Clickup task to 'Review in Progress' if: steps.get_cuID.outputs.cuID run: | curl -i -X PUT \ 'https://api.clickup.com/api/v2/task/${{ steps.get_cuID.outputs.cuID }}?custom_task_ids=true&team_id=${{secrets.CLICKUP_TEAM_ID}}' \ -H 'Authorization: ${{secrets.CLICKUP_API_KEY}}' \ -H 'Content-Type: application/json' \ -d '{ "status": "in progress" }' ================================================ FILE: .github/workflows/test_build.yml ================================================ # This workflow will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: 🏗️ Test Build on: pull_request: branches: [develop] types: [review_requested, ready_for_review] paths-ignore: - 'docs/**' - 'website/**' - 'vendor/**' jobs: Windows-latest: runs-on: windows-latest strategy: matrix: python-version: [3.9] steps: - name: 🚛 Checkout Code uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: 🧵 Install Requirements shell: pwsh run: | ./tools/create_env.ps1 - name: 🔨 Build shell: pwsh run: | $env:SKIP_THIRD_PARTY_VALIDATION="1" ./tools/build.ps1 Ubuntu-latest: runs-on: ubuntu-latest strategy: matrix: python-version: [3.9] steps: - name: 🚛 Checkout Code uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: 🧵 Install Requirements run: | ./tools/create_env.sh - name: 🔨 Build run: | export SKIP_THIRD_PARTY_VALIDATION="1" ./tools/build.sh ================================================ FILE: .github/workflows/update_bug_report.yml ================================================ name: 🐞 Update Bug Report on: workflow_dispatch: release: # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release types: [published] jobs: update-bug-report: runs-on: ubuntu-latest name: Update bug report steps: - uses: actions/checkout@v3 with: ref: ${{ github.event.release.target_commitish }} - name: Update version uses: ynput/gha-populate-form-version@main with: github_token: ${{ secrets.YNPUT_BOT_TOKEN }} registry: github dropdown: _version limit_to: 100 form: .github/ISSUE_TEMPLATE/bug_report.yml commit_message: 'chore(): update bug report / version' dry_run: no-push - name: Push to protected develop branch uses: CasperWA/push-protected@v2.10.0 with: token: ${{ secrets.YNPUT_BOT_TOKEN }} branch: develop unprotect_reviews: true ================================================ FILE: .gitignore ================================================ # Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # Mac Stuff ########### # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # rope project dir .ropeproject # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk # CX_Freeze ########### /build /dist/ /server_addon/packages/* /vendor/bin/* /vendor/python/* /.venv /venv/ # Documentation ############### /docs/build # Editor backup files # ####################### *~ # Unit test / coverage reports ############################## htmlcov/ .tox/ .nox/ .coverage .coverage.* /coverage .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Node JS packages ################## node_modules package-lock.json package.json yarn.lock openpype/premiere/ppro/js/debug.log # IDEA ###### .idea/ # VScode files .vscode/ .env dump.sql test_localsystem.txt # website ########## website/translated_docs website/build/ website/node_modules website/i18n/* website/debug.log website/.docusaurus # Poetry ######## .poetry/ .python-version .editorconfig .pre-commit-config.yaml mypy.ini tools/run_eventserver.* # Developer tools tools/dev_* .github_changelog_generator # Addons ######## /openpype/addons/* !/openpype/addons/README.md ================================================ FILE: .gitmodules ================================================ [submodule "tools/modules/powershell/BurntToast"] path = tools/modules/powershell/BurntToast url = https://github.com/Windos/BurntToast.git [submodule "tools/modules/powershell/PSWriteColor"] path = tools/modules/powershell/PSWriteColor url = https://github.com/EvotecIT/PSWriteColor.git [submodule "openpype/hosts/unreal/integration"] path = openpype/hosts/unreal/integration url = https://github.com/ynput/ayon-unreal-plugin.git ================================================ FILE: .hound.yml ================================================ flake8: enabled: true config_file: setup.cfg ================================================ FILE: .prettierrc ================================================ { "tabWidth": 4 } ================================================ FILE: ARCHITECTURE.md ================================================ # Architecture OpenPype is a monolithic Python project that bundles several parts, this document will try to give a birds eye overview of the project and, to a certain degree, each of the sub-projects. The current file structure looks like this: ``` . ├── common - Code in this folder is backend portion of Addon distribution logic for v4 server. ├── docs - Documentation of the source code. ├── igniter - The OpenPype bootstrapper, deals with running version resolution and setting up the connection to the mongodb. ├── openpype - The actual OpenPype core package. ├── schema - Collection of JSON files describing schematics of objects. This follows Avalon's convention. ├── tests - Integration and unit tests. ├── tools - Conveninece scripts to perform common actions (in both bash and ps1). ├── vendor - When using the igniter, it deploys third party tools in here, such as ffmpeg. └── website - Source files for https://openpype.io/ which is Docusaursus (https://docusaurus.io/). ``` The core functionality of the pipeline can be found in `igniter` and `openpype`, which in turn rely on the `schema` files, whenever you build (or download a pre-built) version of OpenPype, these two are bundled in there, and `Igniter` is the entry point. ## Igniter It's the setup and update tool for OpenPype, unless you want to package `openpype` separately and deal with all the config manually, this will most likely be your entry point. ``` igniter/ ├── bootstrap_repos.py - Module that will find or install OpenPype versions in the system. ├── __init__.py - Igniter entry point. ├── install_dialog.py- Show dialog for choosing central pype repository. ├── install_thread.py - Threading helpers for the install process. ├── __main__.py - Like `__init__.py` ? ├── message_dialog.py - Qt Dialog with a message and "Ok" button. ├── nice_progress_bar.py - Fancy Qt progress bar. ├── splash.txt - ASCII art for the terminal installer. ├── stylesheet.css - Installer Qt styles. ├── terminal_splash.py - Terminal installer animation, relies in `splash.txt`. ├── tools.py - Collection of methods that don't fit in other modules. ├── update_thread.py - Threading helper to update existing OpenPype installs. ├── update_window.py - Qt UI to update OpenPype installs. ├── user_settings.py - Interface for the OpenPype user settings. └── version.py - Igniter's version number. ``` ## OpenPype This is the main package of the OpenPype logic, it could be loosely described as a combination of [Avalon](https://getavalon.github.io), [Pyblish](https://pyblish.com/) and glue around those with custom OpenPype only elements, things are in progress of being moved around to better prepare for V4, which will be released under a new name AYON. ``` openpype/ ├── client - Interface for the MongoDB. ├── hooks - Hooks to be executed on certain OpenPype Applications defined in `openpype.lib.applications`. ├── host - Base class for the different hosts. ├── hosts - Integration with the different DCCs (hosts) using the `host` base class. ├── lib - Libraries that stitch together the package, some have been moved into other parts. ├── modules - OpenPype modules should contain separated logic of specific kind of implementation, such as Ftrack connection and its python API. ├── pipeline - Core of the OpenPype pipeline, handles creation of data, publishing, etc. ├── plugins - Global/core plugins for loader and publisher tool. ├── resources - Icons, fonts, etc. ├── scripts - Loose scipts that get run by tools/publishers. ├── settings - OpenPype settings interface. ├── style - Qt styling. ├── tests - Unit tests. ├── tools - Core tools, check out https://openpype.io/docs/artist_tools. ├── vendor - Vendoring of needed required Python packes. ├── widgets - Common re-usable Qt Widgets. ├── action.py - LEGACY: Lives now in `openpype.pipeline.publish.action` Pyblish actions. ├── cli.py - Command line interface, leverages `click`. ├── __init__.py - Sets two constants. ├── __main__.py - Entry point, calls the `cli.py` ├── plugin.py - Pyblish plugins. ├── pype_commands.py - Implementation of OpenPype commands. └── version.py - Current version number. ``` ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [3.18.11](https://github.com/ynput/OpenPype/tree/3.18.11) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.10...3.18.11) ### **🚀 Enhancements**
Deadline: Houdini submission settings in OP #6269 Make houdini submissions respect pools groups.This is done by: - Make collect pools works with some Houdini families/product types. - Make Ayon Houdini submitters get group names from Houdini deadline settings. ___
### **🐛 Bug fixes**
Maya: Ensure unique class name compared to `extract_yeti_cache.py` #6251 Fix duplicate `ExtractYetiCache` plug-in name. ___
Maya: Correct Alembic export defaults AY-5273 #6268 Missing `writeUVs` on the Alembic extraction. ___
## [3.18.10](https://github.com/ynput/OpenPype/tree/3.18.10) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.9...3.18.10) ### **🆕 New features**
Arnold Scene Source Raw - OP-8014 #6182 This PR is to try and re-instate some flexibility to the `Arnold Scene Source` family, which got restricted by https://github.com/ynput/OpenPype/pull/4449The proxy workflow introduced was actually broken due to https://github.com/ynput/OpenPype/pull/4460.We can now have any nodes directly in the instance set, which should be backwards compatible of the `Arnold Scene Source` before the overhaul in https://github.com/ynput/OpenPype/pull/4449.The `content` and `proxy` sets works as well, but not at the same time as the raw nodes directly in the instance set. There is a validator in place to prevent using a single instance for both workflows.Now the question is whether we should have this as a single family or split somehow?The workflow of having nodes directly in the instance set, compared to `content` and `proxy` set, can be documented, so I see this as most a matter of terminology.`Arnold Scene Source` makes sense to have as a family, but only if its a the raw output with little to no validation, similar to `Maya Scene`. But then I'm not sure what to call the other family that has more of a workflow in place, which is similar to `Model` and `Pointcache`. ___
Nuke: Push to project - AY-742 #6245 This introduces the "Push to Project" menu item in Nuke.This enables users to push the current workfile to a different project and copy all files from Read nodes to a `resources` folder next to the workfile. Containers will be baked to normal Read nodes.Also gizmos will be baked to groups. ___
### **🚀 Enhancements**
Max: Implementation of Validate Render Passes #6138 This PR is to enhance the current validator of checking the render output before deadline publish. It does the following: - The validator `Render Output for Deadline` would be renamed as `Validate Render Passes` - The validator would not only check on the invalid render output folder but the invalid filename of render passes. ___
Max : Optional validator to check invalid context data #6198 Add optional validator check on invalid context data for asset and task in 3dsMax ___
### **🐛 Bug fixes**
Maya: Account for no Alembic overrides. #6267 Fix for if no overrides are present in `project_settings/maya/publish/ExtractAlembic/overrides` ___
## [3.18.9](https://github.com/ynput/OpenPype/tree/3.18.9) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.8...3.18.9) ### **🆕 New features**
Integration: 3DEqualizer integration #5868 This PR is adding basic integration for 3DEqualizer4 from Science-D-Vision. Integration includes: - Workfiles - Loading plates (cameras) - Publishing scripts to Maya and Nuke - Publishing of lens data ___
### **🚀 Enhancements**
Maya: abc options for Pointcache/Animation family - OP-5920 #5173 Add all options for alembic extraction on `pointcache` and `animation` families. ___
RoyalRender: environment injection on the server #6160 Previously env vars were injected directly on the client during submission. That could have issues when environment on client machines is different than on workers.This PR tries similar approach as on DL when before job is rendered it queries Ayon to get environment variables for context.These variables are used to create `.rrEnv` file and attach it to the job. That should provide rendering environment controlled by Ayon. ___
Hiero: colorspace settings aligned with nuke - AY-978 #6249 In order to share the same colorspaces in the workfile in Hiero and Nuke, we need to bring back the workfile settings for colorspaces in Nuke.In Hiero we also need code to edit the project settings in memory and apply the colorspaces when launching Hiero so any new project gets the correct colorspaces. Due to Foundry not providing Python API methods for setting the project colorspaces, we need to go through the UI widgets to set them, when dealing with in-memory projects.Also small bugfix when saving the workfile without any sequences. ___
### **🐛 Bug fixes**
Maya: Make sure validators being shown in the Publisher UI when they set to be optional in AYON setting #6257 This PR is to make sure validators being shown correctly in the Publisher UI when they are being set to be optional in AYON setting.Ported from https://github.com/ynput/ayon-core/pull/201 ___
Maya: Fix Redshift cryptomatte multipartEXR #6240 When using Redshift and rendering multipart EXRs, the instances for cryptomatte AOVs are getting falsely marked as multipart EXR even though they are being forced to be separate files by Redshift.Since we cannot query the AOVs multipart individually, we'll need a hardcoded rule.Ideally I guess AOVs should be separate instances in the publishing process but that is too big of a scope atm. ___
Substance Painter: Allow users to set texture resolutions when loading mesh to create project #6262 This PR is to add the support of template settings in the mesh loaders for Substance project creation. User can customize and add template settings in AYON settings and apply it through the option mode(the button with memo icon). ___
Deadline: Submit Publish job error #6263 Use get env to get the value of `AVALON_DB``AVALON_DB` environment variable is not initialized when using OpenPype in Ayon mode. which raise an error when using `os.environ["AVALON_DB"]`This PR changes it to `os.getenv("AVALON_DB")` ___
Fix: Removed double conversion of limit_groups #6265 `limit_groups` settings got transformed twice. Kept nicer looking conversion. ___
## [3.18.8](https://github.com/ynput/OpenPype/tree/3.18.8) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.7...3.18.8) ### **🚀 Enhancements**
Max: Implementation of Camera Attributes Validator #6110 Implement Validate Camera Attributes in camera family in Max host ___
Max: Add missing workfile creator #6203 Add the missing workfile creator in 3dsMax. ___
Deadline: Expose families transfer setting - OP-8268 #6217 This PR exposes the `families_transfer` attribute on the `ProcessSubmittedJobOnFarm` plugin.The use case is to remove `ftrack` from the list if a studio does not want all render passes from Maya to become asset versions in Ftrack. ___
### **🐛 Bug fixes**
Deadline: Add AVALON_DB to Deadline submissions - OP-8270 #6218 Because testing uses a different database name https://github.com/ynput/OpenPype/blob/develop/tests/lib/testing_classes.py#L46 we need to add `AVALON_DB` to the environment for Deadline submissions. ___
Houdini: fix default render product name in Vray #6083 This is fixing key name for default render products in VRay. Original name `RGB Color` caused issues during job submission. ___
Resolve Clip Load - Slate support #6126 Loaded clip should ignore the slate, and be trimmed the same regardless of slate presence.closes: https://github.com/ynput/OpenPype/issues/6124#AY-1684 ___
Use duration from streams as its more precise #6171 When dealing with 30 fps mov of 2 frames, the duration was reduce to 3 decimal places (0.067) which meant that the flag for ffmpeg `-ss` ended up with a time that was not precise enough for ffmpeg to pick a frame; `0.0335`. Should be `0.0333`.Using the duration from the streams is more precise; `0.066667`. ___
Core: Headless publish failing without GL lib #6205 Trying to run a headless publish in the farm I hit another blocker: ``` 2024-02-07 20:42:45: 0: STDOUT: !!! AYON crashed: 2024-02-07 20:42:45: 0: STDOUT: Traceback (most recent call last): 2024-02-07 20:42:45: 0: STDOUT: File "start.py", line 740, in main_cli 2024-02-07 20:42:45: 0: STDOUT: )) 2024-02-07 20:42:45: 0: STDOUT: File "/usr/ayon-launcher/1.0.0+ax/dependencies/click/core.py", line 1157, in __call__ 2024-02-07 20:42:45: 0: STDOUT: return self.main(*args, **kwargs) 2024-02-07 20:42:45: 0: STDOUT: File "/usr/ayon-launcher/1.0.0+ax/dependencies/click/core.py", line 1078, in main 2024-02-07 20:42:45: 0: STDOUT: rv = self.invoke(ctx) 2024-02-07 20:42:45: 0: STDOUT: File "/usr/ayon-launcher/1.0.0+ax/dependencies/click/core.py", line 1688, in invoke 2024-02-07 20:42:45: 0: STDOUT: return _process_result(sub_ctx.command.invoke(sub_ctx)) 2024-02-07 20:42:45: 0: STDOUT: File "/usr/ayon-launcher/1.0.0+ax/dependencies/click/core.py", line 1434, in invoke 2024-02-07 20:42:45: 0: STDOUT: return ctx.invoke(self.callback, **ctx.params) 2024-02-07 20:42:45: 0: STDOUT: File "/usr/ayon-launcher/1.0.0+ax/dependencies/click/core.py", line 783, in invoke 2024-02-07 20:42:45: 0: STDOUT: return __callback(*args, **kwargs) 2024-02-07 20:42:45: 0: STDOUT: File "/pipe/dev/farrizabalaga/OpenPype/openpype/cli.py", line 197, in publish 2024-02-07 20:42:45: 0: STDOUT: PypeCommands.publish(list(paths), targets, gui) 2024-02-07 20:42:45: 0: STDOUT: File "/pipe/dev/farrizabalaga/OpenPype/openpype/pype_commands.py", line 100, in publish 2024-02-07 20:42:45: 0: STDOUT: from openpype.tools.utils.host_tools import show_publish 2024-02-07 20:42:45: 0: STDOUT: File "/pipe/dev/farrizabalaga/OpenPype/openpype/tools/utils/__init__.py", line 1, in 2024-02-07 20:42:45: 0: STDOUT: from .layouts import FlowLayout 2024-02-07 20:42:45: 0: STDOUT: File "/pipe/dev/farrizabalaga/OpenPype/openpype/tools/utils/layouts.py", line 1, in 2024-02-07 20:42:45: 0: STDOUT: from qtpy import QtWidgets, QtCore 2024-02-07 20:42:45: 0: STDOUT: File "/usr/ayon-launcher/1.0.0+ax/dependencies/qtpy/QtWidgets.py", line 111, in 2024-02-07 20:42:45: 0: STDOUT: from PySide2.QtWidgets import * 2024-02-07 20:42:45: 0: STDOUT: File "/usr/ayon-launcher/1.0.0+ax/vendor/python/shiboken2/files.dir/shibokensupport/__feature__.py", line 142, in _import 2024-02-07 20:42:45: 0: STDOUT: return original_import(name, *args, **kwargs) 2024-02-07 20:42:45: 0: STDOUT: ImportError: libGL.so.1: cannot open shared object file: No such file or directory ``` The imports of `openpype.tools.utils.host_tools.__init__.py` were throwing an error due to trying to import QtWidgets unnecessarily. ___
Nuke: LoadClip colorspace override - OP-6591 #6215 Setting the colorspace from the representation data was not supported. ___
Hiero: Add OP settings and convert in plugin - OP-8338 #6232 Missing settings for https://github.com/ynput/OpenPype/pull/6143. ___
Unreal: Fix Render Instance Collector to use folderPath #6233 Fix Render Instance Collector to use folderPath instead of just the asset name. ___
Bugfix - Fix "Action Failed" window not showing #6236 This PR targets to fix issue #6234. ___
Nuke: render use existing frames with slate offsets the published render - AY-1433 #6239 Due to `frameStart` data member on representation for existing frames, the frame indexes would be re-numbered when integrating due to this; https://github.com/ynput/OpenPype/blob/develop/openpype/plugins/publish/integrate.py#L712-L726Removing `frameStart` had no effect on publishing workflows, local or farm.Also fixed an issues with slate collection which could misbehave if the instance node had "slate" in the name.Resolves #5883 ___
AYON Workfiles tool: Copy and open of published workfile works #6241 Fix copy and open published workfiles. ___
Chore: OCIO and python2 compatibility fixes #6242 Nuke 12 is now fully supported with our OCIO wrapping functionalities. ___
### **Merged pull requests**
Tests: Fix failing maya automatic test #6235 Improvement on https://github.com/ynput/OpenPype/pull/6231 ___
## [3.18.7](https://github.com/ynput/OpenPype/tree/3.18.7) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.6...3.18.7) ### **🆕 New features**
Chore: Wrapper for click proposal #5928 This is a proposal how to resolve issues with `click` python module. Issue https://github.com/ynput/OpenPype/issues/5921 reported that in Houdini 20+ is our click clashing with click in houdini, where is expected higher version. We can't update our version to support older pythons (NOTE older Python 3). ___
### **🚀 Enhancements**
Maya: Add repair action to hidden joints validator #6214 Joints Hidden is missing repair action, this adds it back ___
Blender: output node and EXR #6086 Output node now works correctly for Multilayer EXR and keeps existing links. The output now is handled entirely by the compositor node tree. ___
AYON Switch tool: Keep version after switch #6104 Keep version if only representation did change. The AYON variant of https://github.com/ynput/OpenPype/pull/4629 ___
Loader AYON: Reset loader window on open #6170 Make sure loader tool is reset on each show. ___
Publisher: Show message with error on action failure #6179 This PR adds support for the publisher to show error message from running actions.Errors from actions will otherwise be hidden from user in various console outputs.Also include card for when action is finished. ___
AYON Applications: Remove djvview group from default applications #6188 The djv does not have group defined in models so the values are not used anywhere. ___
General: added fallback for broken ffprobe return #6189 Customer provided .exr returned width and height equal to 0 which caused error in `extract_thumbnail`. This tries to use oiiotool to get metadata about file, in our case it read it correctly. ___
Photoshop: High scaling in UIs #6190 Use `get_openpype_qt_app` to create `QApplication` in Photoshop. ___
Ftrack: Status update settings are not case insensitive. #6195 Make values for project_settings/ftrack/events/status_update case insensitive. ___
Thumbnail product filtering #6197 This PR introduces subset filtering for thumbnail extraction. This is to skip passes like zdepth which is not needed and can cause issues with extraction. Also speeds up publishing. ___
TimersManager: Idle dialog always on top #6201 Make stop timer dialog always on tophttps://app.clickup.com/t/6658547/OP-8033 ___
AfterEffects: added toggle for applying values from DB during creation #6204 Previously values (resolution, duration) from Asset (eg. DB) were applied explicitly when instance of `render` product type was created. This PR adds toggle to Settings to disable this. (This allows artist to publish non standard length of composition, disabling of `Validate Scene Settings` is still required.) ___
Unreal: Update plugin commit #6208 Updated unreal plugin to latest main. ___
### **🐛 Bug fixes**
Traypublisher: editorial avoid audio tracks processing #6038 Avoiding audio tracks from EDL editorial publishing. ___
Resolve Inventory offsets clips when swapping versions #6128 Swapped version retain the offset and IDT of the timelime clip.closes: https://github.com/ynput/OpenPype/issues/6125 ___
Publisher window as dialog #6176 Changing back Publisher window to QDialog. ___
Nuke: Validate write node fix error report - OP-8088 #6183 Report error was not printing the expected values from settings, but instead the values on the write node, leading to confusing messages like: ``` Traceback (most recent call last): File "C:\Users\tokejepsen\AppData\Local\Ynput\AYON\dependency_packages\ayon_2310271602_windows.zip\dependencies\pyblish\plugin.py", line 527, in __explicit_process runner(*args) File "C:\Users\tokejepsen\OpenPype\openpype\hosts\nuke\plugins\publish\validate_write_nodes.py", line 135, in process self._make_error(check) File "C:\Users\tokejepsen\OpenPype\openpype\hosts\nuke\plugins\publish\validate_write_nodes.py", line 149, in _make_error raise PublishXmlValidationError( openpype.pipeline.publish.publish_plugins.PublishXmlValidationError: Write node's knobs values are not correct! Knob 'channels' > Correct: `rgb` > Wrong: `rgb` ``` This PR changes the error report to: ``` Traceback (most recent call last): File "C:\Users\tokejepsen\AppData\Local\Ynput\AYON\dependency_packages\ayon_2310271602_windows.zip\dependencies\pyblish\plugin.py", line 527, in __explicit_process runner(*args) File "C:\Users\tokejepsen\OpenPype\openpype\hosts\nuke\plugins\publish\validate_write_nodes.py", line 135, in process self._make_error(check) File "C:\Users\tokejepsen\OpenPype\openpype\hosts\nuke\plugins\publish\validate_write_nodes.py", line 149, in _make_error raise PublishXmlValidationError( openpype.pipeline.publish.publish_plugins.PublishXmlValidationError: Write node's knobs values are not correct! Knob 'channels' > Expected: `['rg']` > Current: `rgb` ``` ___
Nuke: Camera product type loaded is not updating - OP-7973 #6184 When updating the camera this error would appear: ``` (...)openpype/hosts/nuke/plugins/load/load_camera_abc.py", line 142, in update camera_node = nuke.toNode(object_name) TypeError: toNode() argument 1 must be str, not Node ``` ___
AYON settings: Use bundle name as variant in dev mode #6187 Make sure the bundle name is used in dev mode for settings variant. ___
Fusion: fix unwanted change to field name in Settings #6193 It should be `image_format` but in previous refactoring PR it fell back to original `output_formats` which caused enum not to show up and propagate into plugin. ___
Bugfix: AYON menu disappeared when the workspace has been changed in 3dsMax #6200 AYON plugins are not correctly registered when switching to different workspaces. ___
TrayPublisher: adding settings category to base creator classes #6202 Settings are resolving correctly as they suppose to. ___
Nuke: expose knobs backward compatibility fix - OP-8164 #6211 Fix backwards compatibility for settings `project_settings/nuke/create/CreateWriteRender/exposed_knobs`. ___
AE: fix local render doesn't push thumbnail to Ftrack #6212 Without thumbnail review is not clickable from main Versions list ___
Nuke: openpype expose knobs validator - OP-8166 #6213 Fix exposed knobs validator for backwards compatibility with missing settings. ___
Ftrack: Post-launch hook fix value lowering #6221 Fix lowerin of values in status mapping. ___
### **🔀 Refactored code**
Maya: Remove `shelf` class and shelf build on maya `userSetup.py` #5837 Remove shelf builder logic. It appeared to be unused and had bugs. ___
### **Merged pull requests**
Max: updated implementation of save_scene + small QOL improvements to host #6186 - Removed `has_unsaved_changes` from Max host as it looks to have been unused and unimplemented. - Added and implemented `workfile_has_unsaved_changes` to Max host. - Mirrored the Houdini host to implement the above into `save_scene` publish for Max. - Added a line to `startup.ms` which opens the usual 'default' menu inside of Max (see screenshots).Current (Likely opens this menu due to one or more of the startup scripts used to insert OP menu):New: ___
Fusion: Use better resolution of Ayon apps on 4k display #6199 Changes size (makes it smaller) of Ayon apps (Workfiles, Loader) in Fusion on high definitions displays. ___
Update CONTRIBUTING.md #6210 Updating contributing guidelines to reflect the EOL state of repository ___
Deadline: Remove redundant instance_skeleton_data code - OP-8269 #6219 This PR https://github.com/ynput/OpenPype/pull/5186 re-introduced code about for the `instance_skeleton_data` but its actually not used since this variable gets overwritten later. ___
## [3.18.6](https://github.com/ynput/OpenPype/tree/3.18.6) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.5...3.18.6) ### **🚀 Enhancements**
AYON: Use `SettingsField` from ayon server #6173 This is preparation for new version of pydantic which will require to customize the field class for AYON purposes as raw pydantic Field could not be used. ___
Nuke: Expose write knobs - OP-7592 #6137 This PR adds `exposed_knobs` to the creator plugins settings at `ayon+settings://nuke/create/CreateWriteRender/exposed_knobs`.When exposed knobs will be linked from the write node to the outside publish group, for users to adjust. ___
AYON: Remove kitsu addon #6172 Removed kitsu addon from server addons because already has own repository. ___
### **🐛 Bug fixes**
Fusion: provide better logging for validate saver crash due type error #6082 Handles reported issue for `NoneType` error thrown in conversion `int(tool["Comments"][frame])`. It is most likely happening when saver node has no input connections.There is a validator for that, but it might be not obvious, that this error is caused by missing input connections and it has been already reported by `"Validate Saver Has Input"`. ___
Workfile Template Builder: Use correct variable in create placeholder #6141 Use correct variable where failed instances are stored for validation. ___
ExtractOIIOTranscode: Missing product_names to subsets conversion #6159 The `Product Names` filtering should be fixed with this. ___
Blender: Fix missing animation data when updating blend assets #6165 Fix missing animation data when updating blend assets. ___
TrayPublisher: Pre-fill of version works in AYON #6180 Use `folderPath` instead of `asset` in AYON mode to calculate next available version. ___
### **🔀 Refactored code**
Chore: remove Muster #6085 Muster isn't maintained for a long time and it wasn't working anyway. This is removing related code from the code base. If there is renewed interest in Muster, it needs to be re-implemented in modern AYON compatible way. ___
### **Merged pull requests**
Maya: change label in the render settings to be more readable #6134 AYON replacement for #5713. ___
## [3.18.5](https://github.com/ynput/OpenPype/tree/3.18.5) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.4...3.18.5) ### **🚀 Enhancements**
Chore: Add addons dir only if exists #6140 Do not add addons directory path for addons discovery if does not exists. ___
Hiero: Effect Categories - OP-7397 #6143 This PR introduces `Effect Categories` for the Hiero settings. This allows studios to split effect stacks into meaningful subsets. ___
Nuke: Render Workfile Attributes #6146 `Workfile Dependency` default value can now be controlled from project settings.`Use Published Workfile` makes using published workfiles for rendering optional. ___
### **🐛 Bug fixes**
Maya: Attributes are locked after publishing if they are locked in Camera Family #6073 This PR is to make sure unlock attributes only during the bake context, make sure attributes are relocked after to preserve the lock state of the original node being baked. ___
Missing nuke family Windows arguments #6131 Default Windows arguments for launching the Nuke family was missing. ___
AYON: Fix the bug on the limit group not being set correctly in Maya Deadline Setting #6139 This PR is to bug-fix the limit groups from maya deadline settings errored out when the user tries to edit the setting. ___
Chore: Transcoding extensions add missing '.tif' extension #6142 Image extensions in transcoding helper was missing `.tif` extension and had `.tiff` twice. ___
Blender: Use the new API for override context #6145 Blender 4.0 disabled the old API to override context. This API updates the code to use the new API. ___
BugFix: Include Model in FBX Loader in Houdini #6150 A quick bugfig where we can't load fbx exported from blender. The bug was reported here. ___
Blender: Restore actions to objects after update #6153 Restore the actions assigned to objects after updating assets from blend files. ___
Chore: Collect template data with hierarchy context #6154 Fixed queue loop where is used wrong variable to pop items from queue. ___
OP-6382 - Thumbnail Integration Problem #6156 This ticket alerted to 3 different cases of integration issues; - [x] Using the Tray Publisher with the same image format (extension) for representation and review representation. - [x] Clash on publish file path from output definitions in `ExtractOIIOTranscode`. - [x] Clash on publish file from thumbnail in `ExtractThumbnail`There might be an issue with this fix, if a studio does not use the `{output}` token in their `render` anatomy template. But thinking if they have customized it, they will be responsible to maintain these edge cases. ___
Max: Bugfix saving camera scene errored out when creating render instance with multi-camera option turned off #6163 This PR is to make sure the integrator of saving camera scene turned off and the render submitted successfully when multi-camera options being turned off in 3dsmax ___
Chore: Fix duplicated project name on create project structure #6166 Small fix in project folders. It is not used same variable name to change values which breaks values on any next loop. ___
### **Merged pull requests**
Maya: Remove duplicate plugin #6157 The two plugins below are doing the same work, so we can remove the one focused solely on lookdev.https://github.com/ynput/OpenPype/blob/develop/openpype/hosts/maya/plugins/publish/validate_look_members_unique.pyhttps://github.com/ynput/OpenPype/blob/develop/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py ___
Publish report viewer: Report items sorting #6092 Proposal of items sorting in Publish report viewer tool. Items are sorted by report creation time. Creation time is also added to publish report data when saved from publisher tool. ___
Maya: Extended error message #6161 Added more details to message ___
Fusion: Added settings for Fusion creators to legacy OP #6162 Added missing OP variant of setting for new Fusion creator. ___
## [3.18.4](https://github.com/ynput/OpenPype/tree/3.18.4) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.3...3.18.4) ### **🚀 Enhancements**
multiple render camera supports for 3dsmax #5124 Supports for rendering with multiple cameras in 3dsmax - [x] Add Batch Render Layers functions - [x] Rewrite lib.rendersetting and lib.renderproduct - [x] Add multi-camera options in creator. - [x] Collector with batch render-layer when multi-camera enabled. - [x] Add instance plugin for saving scene files with different cameras respectively by using subprocess - [x] Refactor submit_max_deadline - [x] Check with metadata.json in submit publish job ___
Fusion: new creator for image product type #6057 In many DCC `render` product type is expected to be sequence of files. This PR adds new explicit creator for `image` product type which is focused on single frame image. Workflows for both product types might be a bit different, this gives artists more granularity to choose better workflow. ___
### **🐛 Bug fixes**
Maya: Account and ignore free image planes. #5993 Free image planes do not have the `->` path separator, so we need to account for that. ___
Blender: Fix long names for instances #6070 Changed naming for instances to use only final part of the `folderPath`. ___
Traypublisher & Chore: Instance version on follow workfile version #6117 If `follow_workfile_version` is enabled but context does not have filled workfile version, a version on instance is used instead. ___
Substance Painter: Thumbnail errors with PBR Texture Set #6127 When publishing with PBR Metallic Roughness as Output Template, Emissive Map errors out because of the missing channel in the material and the map can't be generated in Substance Painter. This PR is to make sure `imagestance.data["publish"] = False` so that the related "empty" texture instance would be skipped to generate the output. ___
Transcoding: Fix reading image sequences through oiiotool #6129 When transcoding image sequences, the second image onwards includes the invalid xml line of `Reading path/to/file.exr` of the oiiotool output.This is most likely not the best solution, but it fixes the issue and illustrates the problem.Error: ``` ERROR:pyblish.plugin:Traceback (most recent call last): File "C:\Users\tokejepsen\AppData\Local\Ynput\AYON\dependency_packages\ayon_2310271602_windows.zip\dependencies\pyblish\plugin.py", line 527, in __explicit_process runner(*args) File "C:\Users\tokejepsen\OpenPype\openpype\plugins\publish\extract_color_transcode.py", line 152, in process File "C:\Users\tokejepsen\OpenPype\openpype\lib\transcoding.py", line 1136, in convert_colorspace input_info = get_oiio_info_for_input(input_path, logger=logger) File "C:\Users\tokejepsen\OpenPype\openpype\lib\transcoding.py", line 124, in get_oiio_info_for_input output.append(parse_oiio_xml_output(xml_text, logger=logger)) File "C:\Users\tokejepsen\OpenPype\openpype\lib\transcoding.py", line 276, in parse_oiio_xml_output tree = xml.etree.ElementTree.fromstring(xml_string) File "xml\etree\ElementTree.py", line 1347, in XML xml.etree.ElementTree.ParseError: syntax error: line 1, column 0 Traceback (most recent call last): File "C:\Users\tokejepsen\AppData\Local\Ynput\AYON\dependency_packages\ayon_2310271602_windows.zip\dependencies\pyblish\plugin.py", line 527, in __explicit_process runner(*args) File "", line 152, in process File "C:\Users\tokejepsen\OpenPype\openpype\lib\transcoding.py", line 1136, in convert_colorspace input_info = get_oiio_info_for_input(input_path, logger=logger) File "C:\Users\tokejepsen\OpenPype\openpype\lib\transcoding.py", line 124, in get_oiio_info_for_input output.append(parse_oiio_xml_output(xml_text, logger=logger)) File "C:\Users\tokejepsen\OpenPype\openpype\lib\transcoding.py", line 276, in parse_oiio_xml_output tree = xml.etree.ElementTree.fromstring(xml_string) File "xml\etree\ElementTree.py", line 1347, in XML xml.etree.ElementTree.ParseError: syntax error: line 1, column 0 ``` ___
AYON: Remove 'IntegrateHeroVersion' conversion #6130 Remove settings conversion for `IntegrateHeroVersion`. ___
Chore tools: Make sure style object is not garbage collected #6136 Minor fix in tool utils to make sure style C++ object is not garbage collected when not stored into variable. ___
## [3.18.3](https://github.com/ynput/OpenPype/tree/3.18.3) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.2...3.18.3) ### **🚀 Enhancements**
Maya: Apply initial viewport shader for Redshift Proxy after loading #6102 When the published redshift proxy is being loaded, the shader of the proxy is missing. This is different from the manual load through creating redshift proxy for files. This PR is to assign the default lambert to the redshift proxy, which replicates the same approach when the user manually loads the proxy with filepath. ___
General: We should keep current subset version when we switch only the representation type #4629 When we switch only the representation type of subsets, we should not get the representation from the last version of the subset. ___
Houdini: Add loader for redshift proxy family #5948 Loader for Redshift Proxy in Houdini (Thanks for @BigRoy contribution) ___
AfterEffects: exposing Deadline pools fields in Publisher UI #6079 Deadline pools might be adhoc set by an artist during publishing. AfterEffects implementation wasn't providing this. ___
Chore: Event callbacks can have order #6080 Event callbacks can have order in which are called, and fixed issue with getting function name and file when using `partial` function as callback. ___
AYON: OpenPype addon defines runtime dependencies #6095 Moved runtime dependencies from ayon-launcher to openpype addon. ___
Max: User's setting for scene unit scale #6097 Options for users to set the default scene unit scale for their scenes.AYONLegacy OP ___
Chore: Remove deprecated templates profiles #6103 Remove deprecated usage of template profiles from settings. ___
Publisher: Window is not always on top #6107 Goal of this PR is to avoid using `WindowStaysOnTopHint` which causes issues, especially in cases when DCC shows a popup dialog that is behind the window, in that case both Publisher and DCC are frozen and there is nothing to do. ___
Houdini: add split job export support for Redshift ROP #6108 This is adding support for splitting of export and render jobs for Redshift as is already implemented for Vray, Mantra and Arnold. ___
Fusion: automatic installation of PySide2 #6111 This PR adds hook which tries to check if PySide2 is installed in Python used by Fusion and if not, it tries to install it automatically. ___
AYON: OpenPype addon dependencies #6113 Added `click` and `six` to requirements of openpype addon, and removed `Qt.py` requirement, which is not used anywhere. ___
Chore: Thumbnail representation has 'outputName' #6114 Add thumbnail output name to thumbnail representation to prevent same output filename during integration. ___
Kitsu: Clear credentials is safe #6116 Do not remove not existing keyring items. ___
### **🐛 Bug fixes**
Maya: bug fix the playblast without textures #5942 Bug fix the texture not being displayed when users enable texture placement in the OP/AYON setting ___
Blender: Workfile instance update fix #6048 Make sure workfile instance has always available 'instance_node' in transient data. ___
Publisher: Fix issue with parenting of widgets #6106 Don't use publisher window parent (usually main DCC window) as parent for report widget. ___
:wrench: fix and update pydocstyle configuration #6109 Fix pydocstyle configuration and move it to `pyproject.toml` ___
Nuke: Create camera node with the latest camera node class in Nuke 14 #6118 Creating instance fails for certain cameras, and it seems to only exist in Nuke 14. The reason of causing that contributes to the new camera node class `Camera4` while the camera creator is working with the `Camera2` class. ___
Site Sync: small fixes in Loader #6119 Resolves issue: - local and studio icons were same, they should be different - `TypeError: string indices must be integers` error when downloading/uploading workfiles ___
Chore: Template data for editorial publishing #6120 Template data for editorial publishing are filled during `CollectInstanceAnatomyData`. The structure for editorial is determined, as it's required for ExtractHierarchy AYON/OpenPype plugins. ___
SceneInventory: Fix site sync icon conversion #6123 Use 'get_qt_icon' to convert icon definitions from site sync. ___
## [3.18.2](https://github.com/ynput/OpenPype/tree/3.18.2) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.1...3.18.2) ### **🚀 Enhancements**
Testing: Release Maya/Deadline job from pending when testing. #5988 When testing we wont put the Deadline jobs into pending with dependencies, so the worker can start as soon as possible. ___
Max: Tweaks on Extractions for the exporters #5814 With this PR - Suspend Refresh would be introduced in abc & obj extractors for optimization. - Allow users to choose the custom attributes to be included in abc exports ___
Maya: Optional preserve references. #5994 Optional preserve references when publishing Maya scenes. ___
AYON ftrack: Expect 'ayon' group in custom attributes #6066 Expect `ayon` group as one of options to get custom attributes. ___
AYON Chore: Remove dependencies related to separated addons #6074 Removed dependencies from openpype client pyproject.toml that are already defined by addons which require them. ___
Editorial & chore: Stop using pathlib2 #6075 Do not use `pathlib2` which is Python 2 backport for `pathlib` module in python 3. ___
Traypublisher: Correct validator label #6084 Use correct label for Validate filepaths. ___
Nuke: Extract Review Intermediate disabled when both Extract Review Mov and Extract Review Intermediate disabled in setting #6089 Report in Discord https://discord.com/channels/517362899170230292/563751989075378201/1187874498234556477 ___
### **🐛 Bug fixes**
Maya: Bug fix the file from texture node not being collected correctly in Yeti Rig #5990 Fix the bug of collect Yeti Rig not being able to get the file parameter(s) from the texture node(s), resulting to the failure of publishing the textures to the resource directory. ___
Bug: fix AYON settings for Maya workspace #6069 This is changing bug in default AYON setting for Maya workspace, where missing semicolumn caused workspace not being set. This is also syncing default workspace settings to OpenPype ___
Refactor colorspace handling in CollectColorspace plugin #6033 Traypublisher is now capable set available colorspaces or roles to publishing images sequence or video. This is fix of new implementation where we allowed to use roles in the enumerator selector. ___
Bugfix: Houdini render split bugs #6037 This PR is a follow up PR to https://github.com/ynput/OpenPype/pull/5420This PR does: - refactor `get_output_parameter` to what is used to be. - fix a bug with split render - rename `exportJob` flag to `split_render` ___
Fusion: fix for single frame rendering #6056 Fixes publishes of single frame of `render` product type. ___
Photoshop: fix layer publish thumbnail missing in loader #6061 Thumbnails from any products (either `review` nor separate layer instances) weren't stored in Ayon.This resulted in not showing them in Loader and Server UI. After this PR thumbnails should be shown in the Loader and on the Server (`http://YOUR_AYON_HOSTNAME:5000/projects/YOUR_PROJECT/browser`). ___
AYON Chore: Do not use thumbnailSource for thumbnail integration #6063 Do not use `thumbnailSource` for thumbnail integration. ___
Photoshop: fix creation of .mov #6064 Generation of .mov file with 1 frame per published layer was failing. ___
Photoshop: fix Collect Color Coded settings #6065 Fix for wrong default value for `Collect Color Coded Instances` Settings ___
Bug: Fix Publisher parent window in Nuke #6067 Fixing issue where publisher parent window wasn't set because wrong use of version constant. ___
Python console widget: Save registry fix #6076 Do not save registry until there is something to save. ___
Ftrack: update asset names for multiple reviewable items #6077 Multiple reviewable assetVersion components with better grouping to asset version name. ___
Ftrack: DJV action fixes #6098 Fix bugs in DJV ftrack action. ___
AYON Workfiles tool: Fix arrow to timezone typo #6099 Fix parenthesis typo with arrow local timezone function. ___
### **🔀 Refactored code**
Chore: Update folder-favorite icon to ayon icon #5718 Updates old "Pype-2.0-era" (from ancient greece times) to AYON logo equivalent.I believe it's only used in Nuke. ___
### **Merged pull requests**
Chore: Maya / Nuke remove publish gui filters from settings #5570 - Remove Publish GUI Filters from Nuke settings - Remove Publish GUI Filters from Maya settings ___
Fusion: Project/User option for output format (create_saver) #6045 Adds "Output Image Format" option which can be set via project settings and overwritten by users in "Create" menu. This replaces the current behaviour of being hardcoded to "exr". Replacing the need for people to manually edit the saver path if they require a different extension. ___
Fusion: Output Image Format Updating Instances (create_saver) #6060 Adds the ability to update Saver image output format if changed in the Publish UI.~~Adds an optional validator that compares "Output Image Format" in the Publish menu against the one currently found on the saver. It then offers a repair action to update the output extension on the saver.~~ ___
Tests: Fix representation count for AE legacy test #6072 ___
## [3.18.1](https://github.com/ynput/OpenPype/tree/3.18.1) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.18.0...3.18.1) ### **🚀 Enhancements**
AYON: Update ayon api to 1.0.0-rc.3 #6052 Updated ayon python api to 1.0.0-rc.3. ___
## [3.18.0](https://github.com/ynput/OpenPype/tree/3.18.0) [Full Changelog](https://github.com/ynput/OpenPype/compare/...3.18.0) ### **🐛 Bug fixes**
Chore: Fix subst paths handling #5702 Make sure that source disk ends with `\` instead of destination disk. ___
## [3.17.7](https://github.com/ynput/OpenPype/tree/3.17.7) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.6...3.17.7) ### **🆕 New features**
AYON: Use folder path as unique identifier #5817 Use folder path instead of asset name as unique identifier, with OpenPype compatibility. ___
Houdini: Farm caching submission to Deadline #4903 Implements functionality to offload instances of the specific families to be processed on Deadline instead of locally. This increases productivity as artist can use local machine could be used for other tasks.Implemented for families: - [x] ass - [x] redshift proxy - [x] ifd - [x] abc - [x] bgeo - [x] vdb ___
Houdini: Add support to split Deadline render tasks in export + render #5420 This adds initial support in Houdini so when submitting render jobs to Deadline it's not running as a single Houdini task but rather it gets split in two different tasks: Export + Render. This way it's more efficient as we only need a Houdini license during the export step and the render tasks can run exclusively with a render license. Moreover, we aren't wasting all the overhead time of opening the render scene in Houdini for every frame.I have also added the corresponding settings json files so we can set some of the default values for the Houdini deadline submitter. ___
Wrap: new integration #5823 These modifications are necessary for adding Wrap integration (DCC handling scans and textures) . ___
AYON: Prepare for 'data' via graphql #5923 AYON server does support to query 'data' field for hierarchy entities (project > ... > representation) using GraphQl since version 0.5.5. Because of this PR in ayon-python-api it is required to modify custom graphql function in `openpype.client` to support that option. ___
Chore AYON: AYON addon class #5937 Introduced base class for AYON addon in openpype modules discovery logic. ___
Asset Usage Reporter Tool #5946 This adds simple tool for OpenPype mode that will go over all published workfiles and print linked assets and their version:This is created per project and can be exported in csv file or copied to clipboard in _"ASCII Human readable form"_. ___
Testing: dump_databases flag #5955 This introduces a `dump_databases` flag which makes it convenient to output the resulting database of a successful test run. The flag supports two formats; `bson` and `json`.Due to outputting to the test data folder, when dumping the databases, the test data folder will persist.Split from https://github.com/ynput/OpenPype/pull/5644 ___
SiteSync: implemented in Ayon Loader #5962 Implemented `Availability` column in Ayon loader and redo of loaders to `ActionItems` in representation window there. ___
AYON: Workfile template build works #5975 Modified workfile template builder to work, to some degree, in AYON mode. ___
### **🚀 Enhancements**
Maya: Small Tweaks on Validator for Look Default Shader Connection for Maya 2024 #5957 Resolve https://github.com/ynput/OpenPype/issues/5269 ___
Settings: Changes in default settings #5983 We've made some changes in the default settings as several application versions were obsolete (Maya 18, Nuke 11, PS 2020, etc). Also added tools and changed settings for Blender, Maya, and Blender. All should work as usual. ___
Testing: Do not persist data by default in Maya/Deadline. #5987 This is similar to the Maya publishing test. ___
Max: Validate loaded plugins tweaks #5820 In the current development of 3dsMax, users need to use separate validators to validate if certain plugins being loaded before the extraction. For example, usd extractor in model family, prt/tycache extractor in pointcloud/tycache family.But with the PR where implements optional validate loaded plugin, users just need to put what kind of plugins they want to validate in the settings. They no longer need to go through all the separate plugin validators when publishing, and only one validator would do all the check on the loaded plugins before extraction. ___
Nuke: Change context label enhancement #5887 Use QAction to change label of context label in Nuke pipeline menu. ___
Chore: Do not use template data as source for context #5918 Use available information on context to receive context data instead of using `"anatomyData"` during publishing. ___
Houdini: Add python3.10 libs for Houdini 20 startup #5932 Add python3.10 libs for Houdini 20 startup ___
General: Use colorspace data when creating thumbnail #5938 Thumbnails with applied colormanagement. ___
Ftrack: rewriting component creation to support multiple thumbnails #5939 The creation of Ftrack components needs to allow for multiple thumbnails. This is important in situations where there could be several reviewable streams, like in the case of a nuke intermediate files preset. Customers have asked for unique thumbnails for each data stream.For instance, one stream might contain a baked LUT file along with Display and View. Another stream might only include the baked Display and View. These variations can change the overall look. Thus, we found it necessary to depict these differences via thumbnails. ___
Chore: PySide6 tree view style #5940 Define solid color for background of branch in QTreeView. ___
Nuke: Explicit Thumbnail workflow #5941 Nuke made a shift from using its own plugin to a global one for thumbnail creation. This was because it had to handle several thumbnail workflows for baking intermediate data streams. To manage this, the global plugin had to be upgraded. Now, each baking stream can set a unique tag 'need_thumbnail'. This tag is used to mark representations that need a thumbnail. ___
Global: extract thumbnail with new settings #5944 Settings are now configurable for the following: - target size of thumbnail - source or constrained to specific - where should be frame taken from in sequence or video file - if thumbnail should be integrated or not - background color for letter boxes - added AYON settings ___
RoyalRender: inject submitter environment to the royal render job #5958 This is an attempt to solve runtime environment injection for render jobs in RoyalRender as there is no easy way to implement something like `GlobalJobPreload` logic in Deadline. Idea is to inject OpenPype environments directly to the job itself. ___
General: Use manual thumbnail if present when publishing #5969 Use manual thumbnail added to the publisher instead of using it from published representation. ___
AYON: Change of server url should work as expected #5971 Using login action in tray menu to change server url should correctly start new process without issues of missing bundle or previous url. ___
AYON: make sure the AYON menu bar in 3dsMax is named AYON when AYON launches #5972 Renaming the menu bar in 3dsMax for AYON and some cosmetic fix in the docstring ___
Resolve: renaming menu to AYON #5974 Resolve in Ayon is now having aligned name. ___
Hiero: custom tools menu rename #5976 - OpenPype Tools are now Custom Tools menu - fixing order of tools. Create should be first. ___
nuke: updating name for custom tools menu item #5977 - Ayon variant of settings renamed `Custom Tools` menu item ___
fusion: AYON renaming menu #5978 Fusion is having Ayon menu. ___
Blender: Changed the labels for Layout JSON Extractor #5981 Changed the labels for Blender's Layout JSON Extractor. ___
Testing: Skip Arnold license for test rendering. #5984 Skip license check when rendering for testing. ___
Testing: Validate errors and failed status from Deadline jobs. #5986 While waiting for the Deadline jobs to finish, we query the errors on the job and its dependent jobs to fail as early as possible. Plus the failed status. ___
AYON: rename Openpype Tools as Custom Tools in Maya Host #5991 Rename Openpype Tools as Custom Tools in Maya Host in ___
AYON: Use AYON label in ayon mode #5995 Replaced OpenPype with AYON in AYON mode and added bundle nam to information. ___
AYON: Update ayon python api #6002 Updated ayon-python-api to '1.0.0-rc.1'. ___
Max: Add missing repair action in validate resolution setting #6014 Add missing repair action for validate resolution setting ___
Add the AYON/OP settings to enable extractor for model family in 3dsmax #6027 Add the AYON/OP settings to enable extractor for model family in 3dsmax ___
Bugfix: Fix error message formatting if ayon executable can't be found by deadline #6028 Without this fix the error message would report executables string with `;` between EACH character, similar to this PR: https://github.com/ynput/OpenPype/pull/5815However that PR apparently missed also fixing it in `GlobalJobPreLoad` and only fixed it in `Ayon.py` plugin. ___
Show slightly different info in AYON mode #6031 This PR changes what is shown in Tray menu in AYON mode. Previously, it showed version of OpenPype that is very confusing in AYON mode. So this now shows AYON version instead. When clicked, it will opene AYON info window, where OpenPype version is now added, for debugging purposes. ___
AYON Editorial: Hierarchy context have names as keys #6041 Use folder name as keys in `hierarchyContext` and modify hierachy extraction accordingly. ___
AYON: Convert the createAt value to local timezone #6043 Show correct create time in UIs. ___
### **🐛 Bug fixes**
Maya: Render creation - fix broken imports #5893 Maya specific imports were moved to specific methods but not in all cases by #5775. This is just quickly restoring functionality without questioning that decision. ___
Maya: fix crashing model renderset collector #5929 This fix is handling case where model is in some type of render sets but no other connections are made there. Publishing this model would fail with `RuntimeError: Found no items to list the history for.` ___
Maya: Remove duplicated attributes of MTOA verbosity level #5945 Remove duplicated attributes implementation mentioned in https://github.com/ynput/OpenPype/pull/5931#discussion_r1402175289 ___
Maya: Bug fix Redshift Proxy not being successfully published #5956 Bug fix redshift proxy family not being successfully published due to the error found in integrate.py ___
Maya: Bug fix load image for texturesetMain #6011 Bug fix load image with file node for texturesetMain ___
Maya: bug fix the repair function in validate_rendersettings #6021 The following error has been encountered below: ``` // pyblish.pyblish.plugin.Action : Finding failed instances.. // pyblish.pyblish.plugin.Action : Attempting repair for instance: renderLookdevMain ... // Error: pyblish.plugin : Traceback (most recent call last): // File "C:\Users\lbate\AppData\Local\Ynput\AYON\dependency_packages\ayon_2310271602_windows.zip\dependencies\pyblish\plugin.py", line 527, in __explicit_process // runner(*args) // File "C:\Users\lbate\AppData\Local\Ynput\AYON\addons\openpype_3.17.7-nightly.6\openpype\pipeline\publish\publish_plugins.py", line 241, in process // plugin.repair(instance) // File "C:\Users\lbate\AppData\Local\Ynput\AYON\addons\openpype_3.17.7-nightly.6\openpype\hosts\maya\plugins\publish\validate_rendersettings.py", line 395, in repair // cmds.setAttr("{}.{}".format(node, prefix_attr), // UnboundLocalError: local variable 'node' referenced before assignment // Traceback (most recent call last): // File "C:\Users\lbate\AppData\Local\Ynput\AYON\dependency_packages\ayon_2310271602_windows.zip\dependencies\pyblish\plugin.py", line 527, in __explicit_process // runner(*args) // File "C:\Users\lbate\AppData\Local\Ynput\AYON\addons\openpype_3.17.7-nightly.6\openpype\pipeline\publish\publish_plugins.py", line 241, in process // plugin.repair(instance) // File "C:\Users\lbate\AppData\Local\Ynput\AYON\addons\openpype_3.17.7-nightly.6\openpype\hosts\maya\plugins\publish\validate_rendersettings.py", line 395, in repair // cmds.setAttr("{}.{}".format(node, prefix_attr), // UnboundLocalError: local variable 'node' referenced before assignment ``` This PR is a fix for that ___
Fusion: Render avoid unhashable type `BlackmagicFusion.PyRemoteObject` error #5672 Fix Fusion 18.6+ support: Avoid issues with Fusion's `BlackmagicFusion.PyRemoteObject` instances being unhashable. ```python Traceback (most recent call last): File "E:\openpype\OpenPype\.venv\lib\site-packages\pyblish\plugin.py", line 527, in __explicit_process runner(*args) File "E:\openpype\OpenPype\openpype\hosts\fusion\plugins\publish\extract_render_local.py", line 61, in process result = self.render(instance) File "E:\openpype\OpenPype\openpype\hosts\fusion\plugins\publish\extract_render_local.py", line 118, in render with enabled_savers(current_comp, savers_to_render): File "C:\Users\User\AppData\Local\Programs\Python\Python39\lib\contextlib.py", line 119, in __enter__ return next(self.gen) File "E:\openpype\OpenPype\openpype\hosts\fusion\plugins\publish\extract_render_local.py", line 33, in enabled_savers original_states[saver] = original_state TypeError: unhashable type: 'BlackmagicFusion.PyRemoteObject' ``` ___
Nuke: Validate Nuke Write Nodes refactor to use variable `node_value` instead of `value` #5764 Nuke: Validate Nuke Write Nodes refactor to use variable `node_value` instead of `value`The variable `value` only exists as the last variable value in the `for value in values` loop and might not be declared if `values` is an empty iterable. ___
resolve: fixing loader handles calculation #5863 Resolve was not correctly calculating duration of database related duration. ___
Chore: Staging mode determination #5895 Resources use `is_staging_enabled` function instead of `is_running_staging` to determine if should use staging icon. And fixed comparison bug in `is_running_staging`. ___
AYON: Handle staging templates category #5905 Staging anatomy templates category is handled during project templates conversion. The keys are stored into `others` with `"staging_"` prefix. ___
Max: fix the subset name not changing accordingly after the variant name changes #5911 Resolve #5902 ___
AYON: Loader tool bugs hunt #5915 Fix issues with invalid representation ids in loaded containers and handle missing product type in server database. ___
Publisher: Bugfixes and enhancements #5924 Small fixes/enhancements in publisher UI. ___
Maya: Supports for additional Job Info and Plugin Info in deadline submission #5931 This PR is to resolve some of the attributes such as MTOA's `ArnoldVerbose` are not preserved on farm and users can use the project settings to add the attributes back to either job or plugin Info. ___
Bugfix: Houdini license validator missing families #5934 Adding missing families to Houdini license validator. ___
TrayPublisher: adding back `asset_doc` variable #5943 Returning variable which had been removed accidentally in previous PR. ___
Settings: Fix ModulesManager init args #5947 Remove usage of kwargs to create ModulesManager. ___
Blender: Fix Deadline Frames per task #5949 Fixed a problem with Frames per task setting not being applied when publishing a render. ___
Testing: Fix is_test_failed #5951 `is_test_failed` is used (exclusively) on module fixtures to determine whether the tests have failed or not. This determines whether to run tear down code like cleaning up the database and temporary files.But in the module scope `request.node.rep_call` is not available, which results in `is_test_failed` always returning `True`, and no tear down code get executed.The solution was taken from; https://github.com/pytest-dev/pytest/issues/5090 ___
Harmony: Fix local rendering #5953 Local rendering was throwing warning about license, but didn't fail per se. It just didn't produce anything. ___
Testing: hou module should be within class code. #5954 `hou` module should be within the class code else we'll get pyblish errors from needing to skip the plugin. ___
Maya: Add Label to MayaUSDReferenceLoader #5964 As the create placeholder dialog displays the two distinct loaders with the same name, this PR is to distinguish Maya USD Reference Loaders from the loaders of which inherited from. See the screenshot below: ___
Max: Bug fix the resolution not being shown correctly in review burnin #5965 The resolution is not being shown correctly in review burnin ___
AYON: Fix thumbnail integration #5970 Thumbnail integration could cause crash of server if thumbnail id was changed for the same entity id multiple times. Modified the code to avoid that issue. ___
Photoshop: Updated label in Settings #5980 Replaced wrong label from different plugin. ___
Photoshop: Fix removed unsupported Path #5996 Path is not json serializable by default, it is not necessary, better model reused. ___
AYON: Prepare functions for newer ayon-python-api #5997 Newer ayon python api will add new filtering options or change order of existing. Kwargs are used in client code to prevent issues on update. ___
AYON: Conversion of the new playblast settings in Maya #6000 Conversion of the new playblast settings in Maya ___
AYON: Bug fix for loading Mesh in Substance Painter as new project not working #6004 Substance Painter in AYON can't load mesh for creating a new project ___
Deadline: correct webservice couldn't be selected in Ayon #6007 Changed the Setting model to mimic more OP approach as it needs to live together for time being. ___
AYON tools: Fix refresh thread #6008 Trigger 'refresh_finished' signal out of 'run' method. ___
Ftrack: multiple reviewable components missing variable #6013 Missing variable in code for editorial publishing in traypublisher. ___
TVPaint: Expect legacy instances in metadata #6015 Do not expect `"workfileInstances"` constains only new type instance data with `creator_identifier`. ___
Bugfix: handle missing key in Deadline #6019 This quickly fixes bug introduced by #5420 ___
Revert `extractenvironments` behaviour #6020 This is returning original behaviour of `extractenvironments` command from before #5958 so we restore functionality. ___
OP-7535 - Fix renaming composition in AE #6025 Removing of `render` instance caused renaming of composition to `dummyComp` which caused issue in publishing in next attempt.This PR stores original composition name(cleaned up for product name creation) and uses it if instance needs to be removed. ___
Refactor code to skip instance creation for new assets #6029 Publishing effects from hiero during editorial publish is working as expected again. ___
Refactor code to handle missing "representations" key in instance data #6032 Minor code change for optimisation of thumbnail workflow. ___
Traypublisher: editorial preserve clip case sensitivity #6036 Keep EDL clip name inheritance with case sensitivity. ___
Bugfix/add missing houdini settings #6039 add missing settings. now, it looks like this:| Ayon | OpenPype || -- | -- | | | || | | ___
### **🔀 Refactored code**
Maya: Remove RenderSetup layer observers #5836 Remove RenderSetup layer observers that are not needed since new publisher since Renderlayer Creators manage these themselves on Collect and Save/Update of instances. ___
### **Merged pull requests**
Tests: Removed render instance #6026 This test was created as simple model and workfile publish, without Deadline rendering. Cleaned up render elements. ___
Tests: update after thumbnail default change #6040 https://github.com/ynput/OpenPype/pull/5944 changed default state of integration of Thumbnails to NOT integrate. This PR updates automatic tests to follow that. ___
Houdini: Remove legacy LOPs USD output processors #5861 Remove unused/broken legacy code for Houdini Solaris USD LOPs output processors. The code was originally written in Avalon, against early Houdini 18 betas which had a different API for output processors and thus the current state doesn't even work in recent versions of Houdini. ___
Chore: Substance Painter Addons for Ayon #5914 Substance Painter Addons for Ayon ___
Ayon: Updated name of Adobe extension to Ayon #5992 This changes name in menu in Adobe extensions to Ayon. ___
Chore/houdini update startup log #6003 print `Installing AYON ...` on startup when launching houdini from launcher in ayon mode.also update submenu to `ayon_menu` instead of `openpype_menu` ___
Revert "Ayon: Updated name of Adobe extension to Ayon" #6010 Reverts ynput/OpenPype#5992 That PR is only applicable to Ayon. ___
Standalone/Tray Publisher: Remove simple Unreal texture publishing #6012 We are removing _simple Unreal Texture publishing_ that was just renaming texture files to fit to Unreal naming conventions but without any additional functionality. We might return this functionality back with better texture publishing system.Related to #5983 ___
Deadline: Bump version because of Settings changes for Deadline #6023 ___
Change ASCII art in the Console based on the server mode #6030 This changes ASCII art in the console based on the AYON/OpenPype mode ___
## [3.17.6](https://github.com/ynput/OpenPype/tree/3.17.6) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.5...3.17.6) ### **🚀 Enhancements**
Testing: Validate Maya Logs #5775 This PR adds testing of the logs within Maya such as Python and Pyblish errors.The reason why we need to touch so many files outside of Maya is because of the pyblish errors below; ``` pyblish (ERROR) (line: 1371) pyblish.plugin: Skipped: "collect_otio_frame_ranges" (No module named 'opentimelineio') # Error: pyblish.plugin : Skipped: "collect_otio_frame_ranges" (No module named 'opentimelineio') # pyblish (ERROR) (line: 1371) pyblish.plugin: Skipped: "collect_otio_review" (No module named 'opentimelineio') # Error: pyblish.plugin : Skipped: "collect_otio_review" (No module named 'opentimelineio') # pyblish (ERROR) (line: 1371) pyblish.plugin: Skipped: "collect_otio_subset_resources" (No module named 'opentimelineio') # Error: pyblish.plugin : Skipped: "collect_otio_subset_resources" (No module named 'opentimelineio') # pyblish (ERROR) (line: 1371) pyblish.plugin: Skipped: "extract_otio_audio_tracks" (No module named 'opentimelineio') # Error: pyblish.plugin : Skipped: "extract_otio_audio_tracks" (No module named 'opentimelineio') # pyblish (ERROR) (line: 1371) pyblish.plugin: Skipped: "extract_otio_file" (No module named 'opentimelineio') # Error: pyblish.plugin : Skipped: "extract_otio_file" (No module named 'opentimelineio') # pyblish (ERROR) (line: 1371) pyblish.plugin: Skipped: "extract_otio_review" (No module named 'opentimelineio') # Error: pyblish.plugin : Skipped: "extract_otio_review" (No module named 'opentimelineio') # pyblish (ERROR) (line: 1371) pyblish.plugin: Skipped: "extract_otio_trimming_video" (No module named 'opentimelineio') # Error: pyblish.plugin : Skipped: "extract_otio_trimming_video" (No module named 'opentimelineio') # pyblish (ERROR) (line: 1371) pyblish.plugin: Skipped: "submit_blender_deadline" (No module named 'bpy') # Error: pyblish.plugin : Skipped: "submit_blender_deadline" (No module named 'bpy') # pyblish (ERROR) (line: 1371) pyblish.plugin: Skipped: "submit_houdini_remote_publish" (No module named 'hou') # Error: pyblish.plugin : Skipped: "submit_houdini_remote_publish" (No module named 'hou') # pyblish (ERROR) (line: 1371) pyblish.plugin: Skipped: "submit_houdini_render_deadline" (No module named 'hou') # Error: pyblish.plugin : Skipped: "submit_houdini_render_deadline" (No module named 'hou') # pyblish (ERROR) (line: 1371) pyblish.plugin: Skipped: "submit_max_deadline" (No module named 'pymxs') # Error: pyblish.plugin : Skipped: "submit_max_deadline" (No module named 'pymxs') # pyblish (ERROR) (line: 1371) pyblish.plugin: Skipped: "submit_nuke_deadline" (No module named 'nuke') # Error: pyblish.plugin : Skipped: "submit_nuke_deadline" (No module named 'nuke') # ``` We also needed to `stdout` and `stderr` from the launched application to capture the output.Split from #5644.Dependent on #5734 ___
Maya: Render Settings cleanup remove global `RENDER_ATTRS` #5801 Remove global `lib.RENDER_ATTRS` and implement a `RenderSettings.get_padding_attr(renderer)` method instead. ___
Testing: Ingest expected files and input workfile #5840 This ingests the Maya workfile from the Drive storage. Have changed the format to MayaAscii so its easier to see what changes are happening in a PR. This meant changing the expected files and database entries as well. ___
Chore: Create plugin auto-apply settings #5908 Create plugins can auto-apply settings. ___
Resolve: Add save current file button + "Save" shortcut when menu is active #5691 Adds a "Save current file" to the OpenPype menu.Also adds a "Save" shortcut key sequence (CTRL+S on Windows) to the button, so that clicking CTRL+S when the menu is active will save the current workfile. However this of course does not work if the menu does not receive the key press event (e.g. when Resolve UI is active instead)Resolves #5684 ___
Reference USD file as maya native geometry #5781 Add MayaUsdReferenceLoader to reference USD as Maya native geometry using `mayaUSDImport` file translator. ___
Max: Bug fix on wrong aspect ratio and viewport not being maximized during context in review family #5839 This PR will fix the bug on wrong aspect ratio and viewport not being maximized when creating preview animationBesides, the support of tga image format and the options for AA quality are implemented in this PR ___
Blender: Incorporate blender "Collections" into Publish/Load #5841 Allow `blendScene` family to include collections. ___
Max: Allows user preset the setting of preview animation in OP/AYON Setting #5859 Allows user preset the setting of preview animation in OP/AYON Setting for review family. - [x] Openpype - [x] AYON ___
Publisher: Center publisher window on first show #5877 Move publisher window to center of a screen on first show. ___
Publisher: Instance context changes confirm works #5881 Confirmation of context changes in publisher on existing instances does not cause glitches. ___
AYON workfiles tools: Revisit workfiles tool #5897 Revisited workfiles tool for AYON mode to reuse common models and widgets. ___
Nuke: updated colorspace settings #5906 Updating nuke colorspace settings into more convenient way with usage of ocio config roles rather then particular colorspace names. This way we should not have troubles to switch between linear Rec709 or ACES configs without any additional settings changes. ___
Blender: Refactor to new publisher #5910 Refactor Blender integration to use the new publisher ___
Enhancement: Some publish logs cosmetics #5917 General logging message tweaks: - Sort some lists of folder/filenames so they appear sorted in the logs - Fix some grammar / typos - In some cases provide slightly more information in a log ___
Blender: Better name of 'asset_name' function #5927 Renamed function `asset_name` to `prepare_scene_name`. ___
### **🐛 Bug fixes**
Maya: Bug fix the fbx animation export errored out when the skeletonAnim set is empty #5875 Resolve this bug discordIf the skeletonAnim SET is empty and fbx animation collect, the fbx animation extractor would skip the fbx extraction ___
Bugfix: fix few typos in houdini's and Maya's Ayon settings #5882 Fixing few typos - [x] Maya unreal static mesh - [x] Houdini static mesh - [x] Houdini collect asset handles ___
Bugfix: Ayon Deadline env vars + error message on no executable found #5815 Fix some Ayon x Deadline issues as came up in this topic: - missing Environment Variables issue explained here for `deadlinePlugin.RunProcess` for the AYON _extract environments_ call. - wrong error formatting described here with a `;` between each character like this: `Ayon executable was not found in the semicolon separated list "C;:;/;P;r;o;g;r;a;m; ;F;i;l;e;s;/;Y;n;p;u;t;/;A;Y;O;N; ;1;.;0;.;0;-;b;e;t;a;.;5;/;a;y;o;n;_;c;o;n;s;o;l;e;.;e;x;e". The path to the render executable can be configured from the Plugin Configuration in the Deadline Monitor.` ___
AYON: Fix bundles access in settings #5856 Fixed access to bundles data in settings to define correct develop variant. ___
AYON 3dsMax settings: 'ValidateAttributes' settings converte only if available #5878 Convert `ValidateAttributes` settings only if are available in AYON settings. ___
AYON: Fix TrayPublisher editorial settings #5880 Fixing Traypublisher settings for adding task in simple editorial. ___
TrayPublisher: editorial frame range check not needed #5884 Validator for frame ranges is not needed during editorial publishing since entity data are not yet in database. ___
Update houdini license validator #5886 As reported in this community commentHoudini USD publishing is only restricted in Houdini apprentice. ___
Blender: Fix blend extraction and packed images #5888 Fixed a with blend extractor and packed images. ___
AYON: Initialize connection with all information #5890 Create global AYON api connection with all informations all the time. ___
AYON: Scene inventory tool without site sync #5896 Skip 'get_site_icons' if site sync addon is disabled. ___
Publish report tool: Fix PySide6 #5898 Use constants from classes instead of objects. ___
fusion: removing hardcoded template name for saver #5907 Fusion is not hardcoded for `render` anatomy template only anymore. This was blocking AYON deployment. ___
## [3.17.5](https://github.com/ynput/OpenPype/tree/3.17.5) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.4...3.17.5) ### **🆕 New features**
Fusion: Add USD loader #4896 Add an OpenPype managed USD loader (`uLoader`) for Fusion. ___
Fusion: Resolution validator #5325 Added a resolution validator.The code is from my old PR (https://github.com/ynput/OpenPype/pull/4921) that I closed because the PR also contained a frame range validator that no longer is needed. ___
Context Selection tool: Refactor Context tool (for AYON) #5766 Context selection tool has AYON variant. ___
AYON: Use AYON username for user in template data #5842 Use ayon username for template data in AYON mode. ___
Testing: app_group flag #5869 `app_group` command flag. This is for changing which flavour of the host to launch. In the case of Maya, you can launch Maya and MayaPy, but it can be used for the Nuke family as well.Split from #5644 ___
### **🚀 Enhancements**
Enhancement: Fusion fix saver creation + minor Blender/Fusion logging tweaks #5558 - Blender change logs to `debug` level in preparation for new publisher artist facing reports (note that it currently still uses the old publisher) - Fusion: Create Saver fix redeclaration of default_variants - Fusion: Fix saver being created in incorrect state without saving directly after create - Fusion: Allow reset frame range on render family - Fusion: Tweak logging level for artist-facing report ___
Resolve: load clip to timeline at set time #5665 It is possible to load clip to correct place on timeline. ___
Nuke: Optional Deadline workfile dependency. #5732 Adds option to add the workfile as dependency for the Deadline job.Think it used to have something like this, but it disappeared. Usecase is for remote workflow where the Nuke script needs to be synced before the job can start. ___
Enhancement/houdini rearrange ayon houdini settings files #5748 Rearranging Houdini Settings to be more readable, easier to edit, update settings (include all families/product types)This PR is mainly for Ayon Settings to have more organized files. For Openpype, I'll make sure that each Houdini setting in Ayon has an equivalent in Openpype. - [x] update Ayon settings, fix typos and remove deprecated settings. - [x] Sync with Openpype - [x] Test in Openpype - [x] Test in Ayon ___
Chore: updating create ayon addon script #5822 Adding developers environment options. ___
Max: Implement Validator for Properties/Attributes Value Check #5824 Add optional validator which can check if the property attributes are valid in Max ___
Nuke: Remove unused 'get_render_path' function #5826 Remove unused function `get_render_path` from nuke integration. ___
Chore: Limit current context template data function #5845 Current implementation of `get_current_context_template_data` does return the same values as base template data function `get_template_data`. ___
Max: Make sure Collect Render not ignoring instance asset #5847 - Make sure Collect Render is not always using asset from context. - Make sure Scene version being collected - Clean up unnecessary uses of code in the collector. ___
Ftrack: Events are not processed if project is not available in OpenPype #5853 Events that happened on project which is not in OpenPype is not processed. ___
Nuke: Add Nuke 11.0 as default setting #5855 Found I needed Nuke 11.0 in the default settings to help with unit testing. ___
TVPaint: Code cleanup #5857 Removed unused import. Use `AYON` label in ayon mode. Removed unused data in publish context `"previous_context"`. ___
AYON settings: Use correct label for follow workfile version #5874 Follow workfile version label was marked as Collect Anatomy Instance Data label. ___
### **🐛 Bug fixes**
Nuke: Fix workfile template builder so representations get loaded next to each other #5061 Refactor when the cleanup of the placeholder happens for the cases where multiple representations are loaded by a single placeholder.The existing code didn't take into account the case where a template placeholder can load multiple representations so it was trying to do the cleanup of the placeholder node and the re-arrangement of the imported nodes too early. I assume this was designed only for the cases where a single representation can load multiple nodes. ___
Nuke: Dont update node name on update #5704 When updating `Image` containers the code is trying to set the name of the node. This results in a warning message from Nuke shown below;Suggesting to not change the node name when updating. ___
UIDefLabel can be unique #5827 `UILabelDef` have implemented comparison and uniqueness. ___
AYON: Skip kitsu module when creating ayon addons #5828 Create AYON packages is skipping kitsu module in creation of modules/addons and kitsu module is not loaded from modules on start. The addon already has it's repository https://github.com/ynput/ayon-kitsu. ___
Bugfix: Collect Rendered Files only collecting first instance #5832 Collect all instances from the metadata file - don't return on first instance iteration. ___
Houdini: set frame range for the created composite ROP #5833 Quick bug fix for created composite ROP, set its frame range to the frame range of the playbar. ___
Fix registering launcher actions from OpenPypeModules #5843 Fix typo `actions_dir` -> `path` to fix register launcher actions fromm OpenPypeModule ___
Bugfix in houdini shelves manager and beautify settings #5844 This PR fixes the problem in this PR https://github.com/ynput/OpenPype/issues/5457 by using the right function to load a pre-made houdini `.shelf` fileAlso, it beautifies houdini shelves settings to provide better guidance for users which helps with other issue https://github.com/ynput/OpenPype/issues/5458 , Rather adding default shelf and set names, I'll educate users how to use the tool correctly.Users now are able to select between the two options.| OpenPype | Ayon || -- | -- || | | ___
Blender: Fix missing Grease Pencils in review #5848 Fix Grease Pencil missing in review when isolating objects. ___
Blender: Fix Render Settings in Ayon #5849 Fix Render Settings in Ayon for Blender. ___
Bugfix: houdini tab menu working as expected #5850 This PR:Tab menu name changes to Ayon when using ayon get_network_categories is checked in all creator plugins. | Product | Network Category | | -- | -- | | Alembic camera | rop, obj | | Arnold Ass | rop | | Arnold ROP | rop | | Bgeo | rop, sop | | composite sequence | cop2, rop | | hda | obj | | Karma ROP | rop | | Mantra ROP | rop | | ABC | rop, sop | | RS proxy | rop, sop| | RS ROP | rop | | Review | rop | | Static mesh | rop, obj, sop | | USD | lop, rop | | USD Render | rop | | VDB | rop, obj, sop | | V Ray | rop | ___
Bigfix: Houdini skip frame_range_validator if node has no 'trange' parameter #5851 I faced a bug when publishing HDA instance as it has no `trange` parameter. As this PR title says : skip frame_range_validator if node has no 'trange' parameter ___
Bugfix: houdini image sequence loading and missing frames #5852 I made this PR in to fix issues mentioned here https://github.com/ynput/OpenPype/pull/5833#issuecomment-1789207727in short: - image load doesn't work - publisher only publish one frame ___
Nuke: loaders' containers updating as nodes #5854 Nuke loaded containers are updating correctly even they have been duplicating of originally loaded nodes. This had previously been removed duplicated nodes. ___
deadline: settings are not blocking extension input #5864 Settings are not blocking user input. ___
Blender: Fix loading of blend layouts #5866 Fix a problem with loading blend layouts. ___
AYON: Launcher refresh issues #5867 Fixed refresh of projects issue in launcher tool. And renamed Qt models to contain `Qt` in their name (it was really hard to find out where were used). It is not possible to click on disabled item in launcher's projects view. ___
Fix the Wrong key words for tycache workfile template settings in AYON #5870 Fix the wrong key words for the tycache workfile template settings in AYON(i.e. Instead of families, product_types should be used) ___
AYON tools: Handle empty icon definition #5876 Ignore if passed icon definition is `None`. ___
### **🔀 Refactored code**
Houdini: Remove on instance toggled callback #5860 Remove on instance toggled callback which isn't relevant to the new publisher ___
Chore: Remove unused `instanceToggled` callbacks #5862 The `instanceToggled` callbacks should be irrelevant for new publisher. ___
## [3.17.4](https://github.com/ynput/OpenPype/tree/3.17.4) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.3...3.17.4) ### **🆕 New features**
Add Support for Husk-AYON Integration #5816 This draft pull request introduces support for integrating Husk with AYON within the OpenPype repository. ___
Push to project tool: Prepare push to project tool for AYON #5770 Cloned Push to project tool for AYON and modified it. ___
### **🚀 Enhancements**
Max: tycache family support #5624 Tycache family supports for Tyflow Plugin in Max ___
Unreal: Changed behaviour for updating assets #5670 Changed how assets are updated in Unreal. ___
Unreal: Improved error reporting for Sequence Frame Validator #5730 Improved error reporting for Sequence Frame Validator. ___
Max: Setting tweaks on Review Family #5744 - Bug fix of not being able to publish the preferred visual style when creating preview animation - Exposes the parameters after creating instance - Add the Quality settings and viewport texture settings for preview animation - add use selection for create review ___
Max: Add families with frame range extractions back to the frame range validator #5757 In 3dsMax, there are some instances which exports the files in frame range but not being added to the optional frame range validator. In this PR, these instances would have the optional frame range validators to allow users to check if frame range aligns with the context data from DB.The following families have been added to have optional frame range validator: - maxrender - review - camera - redshift proxy - pointcache - point cloud(tyFlow PRT) ___
TimersManager: Use available data to get context info #5804 Get context information from pyblish context data instead of using `legacy_io`. ___
Chore: Removed unused variable from `AbstractCollectRender` #5805 Removed unused `_asset` variable from `RenderInstance`. ___
### **🐛 Bug fixes**
Bugfix/houdini: wrong frame calculation with handles #5698 This PR make collect plugins to consider `handleStart` and `handleEnd` when collecting frame range it affects three parts: - get frame range in collect plugins - expected file in render plugins - submit houdini job deadline plugin ___
Nuke: ayon server settings improvements #5746 Nuke settings were not aligned with OpenPype settings. Also labels needed to be improved. ___
Blender: Fix pointcache family and fix alembic extractor #5747 Fixed `pointcache` family and fixed behaviour of the alembic extractor. ___
AYON: Remove 'shotgun_api3' from dependencies #5803 Removed `shotgun_api3` dependency from openpype dependencies for AYON launcher. The dependency is already defined in shotgrid addon and change of version causes clashes. ___
Chore: Fix typo in filename #5807 Move content of `contants.py` into `constants.py`. ___
Chore: Create context respects instance changes #5809 Fix issue with unrespected change propagation in `CreateContext`. All successfully saved instances are marked as saved so they have no changes. Origin data of an instance are explicitly not handled directly by the object but by the attribute wrappers. ___
Blender: Fix tools handling in AYON mode #5811 Skip logic in `before_window_show` in blender when in AYON mode. Most of the stuff called there happes on show automatically. ___
Blender: Include Grease Pencil in review and thumbnails #5812 Include Grease Pencil in review and thumbnails. ___
Workfiles tool AYON: Fix double click of workfile #5813 Fix double click on workfiles in workfiles tool to open the file. ___
Webpublisher: removal of usage of no_of_frames in error message #5819 If it throws exception, `no_of_frames` value wont be available, so it doesn't make sense to log it. ___
Attribute Defs: Hide multivalue widget in Number by default #5821 Fixed default look of `NumberAttrWidget` by hiding its multiselection widget. ___
### **Merged pull requests**
Corrected a typo in Readme.md (Top -> To) #5800 ___
Photoshop: Removed redundant copy of extension.zxp #5802 `extension.zxp` shouldn't be inside of extension folder. ___
## [3.17.3](https://github.com/ynput/OpenPype/tree/3.17.3) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.2...3.17.3) ### **🆕 New features**
Maya: Multi-shot Layout Creator #5710 New Multi-shot Layout creator is a way of automating creation of the new Layout instances in Maya, associated with correct shots, frame ranges and Camera Sequencer in Maya. ___
Colorspace: ociolook file product type workflow #5541 Traypublisher support for publishing of colorspace look files (ociolook) which are json files holding any LUT files. This new product is available for loading in Nuke host at the moment.Added colorspace selector to publisher attribute with better labeling. We are supporting also Roles and Alias (only v2 configs). ___
Scene Inventory tool: Refactor Scene Inventory tool (for AYON) #5758 Modified scene inventory tool for AYON. The main difference is in how project name is defined and replacement of assets combobox with folders dialog. ___
AYON: Support dev bundles #5783 Modules can be loaded in AYON dev mode from different location. ___
### **🚀 Enhancements**
Testing: Ingest Maya userSetup #5734 Suggesting to ingest `userSetup.py` startup script for easier collaboration and transparency of testing. ___
Fusion: Work with pathmaps #5329 Path maps are a big part of our Fusion workflow. We map the project folder to a path map within Fusion so all loaders and savers point to the path map variable. This way any computer on any OS can open any comp no matter where the project folder is located. ___
Maya: Add Maya 2024 and remove pre 2022. #5674 Adding Maya 2024 as default application variant.Removing Maya 2020 and older, as these are not supported anymore. ___
Enhancement: Houdini: Allow using template keys in Houdini shelves manager #5727 Allow using Template keys in Houdini shelves manager. ___
Houdini: Fix Show in usdview loader action #5737 Fix the "Show in USD View" loader to show up in Houdini ___
Nuke: validator of asset context with repair actions #5749 Instance nodes with different context of asset and task can be now validated and repaired via repair action. ___
AYON: Tools enhancements #5753 Few enhancements and tweaks of AYON related tools. ___
Max: Tweaks on ValidateMaxContents #5759 This PR provides enhancements on ValidateMaxContent as follow: - Rename `ValidateMaxContents` to `ValidateContainers` - Add related families which are required to pass the validation(All families except `Render` as the render instance is the one which only allows empty container) ___
Enhancement: Nuke refactor `SelectInvalidAction` #5762 Refactor `SelectInvalidAction` to behave like other action for other host, create `SelectInstanceNodeAction` as dedicated action to select the instance node for a failed plugin. - Note: Selecting Instance Node will still select the instance node even if the user has currently 'fixed' the problem. ___
Enhancement: Tweak logging for Nuke for artist facing reports #5763 Tweak logs that are not artist-facing to debug level + in some cases clarify what the logged value is. ___
AYON Settings: Disk mapping #5786 Added disk mapping settings to core addon settings. ___
### **🐛 Bug fixes**
Maya: add colorspace argument to redshiftTextureProcessor #5645 In color managed Maya, texture processing during Look Extraction wasn't passing texture colorspaces set on textures to `redshiftTextureProcessor` tool. This in effect caused this tool to produce non-zero exit code (even though the texture was converted into wrong colorspace) and therefor crash of the extractor. This PR is passing colorspace to that tool if color management is enabled. ___
Maya: don't call `cmds.ogs()` in headless mode #5769 `cmds.ogs()` is a call that will crash if Maya is running in headless mode (mayabatch, mayapy). This is handling that case. ___
Resolve: inventory management fix #5673 Loaded Timeline item containers are now updating correctly and version management is working as it suppose to. - [x] updating loaded timeline items - [x] Removing of loaded timeline items ___
Blender: Remove 'update_hierarchy' #5756 Remove `update_hierarchy` function which is causing crashes in scene inventory tool. ___
Max: bug fix on the settings in pointcloud family #5768 Bug fix on the settings being errored out in validate point cloud(see links:https://github.com/ynput/OpenPype/pull/5759#pullrequestreview-1676681705) and passibly in point cloud extractor. ___
AYON settings: Fix default factory of tools #5773 Fix default factory of application tools. ___
Fusion: added missing OPENPYPE_VERSION #5776 Fusion submission to Deadline was missing OPENPYPE_VERSION env var when submitting from build (not source code directly). This missing env var might break rendering on DL if path to OP executable (openpype_console.exe) is not set explicitly and might cause an issue when different versions of OP are deployed.This PR adds this environment variable. ___
Ftrack: Skip tasks when looking for asset equivalent entity #5777 Skip tasks when looking for asset equivalent entity. ___
Nuke: loading gizmos fixes #5779 Gizmo product is not offered in Loader as plugin. It is also updating as expected. ___
General: thumbnail extractor as last extractor #5780 Fixing issue with the order of the `ExtractOIIOTranscode` and `ExtractThumbnail` plugins. The problem was that the `ExtractThumbnail` plugin was processed before the `ExtractOIIOTranscode` plugin. As a result, the `ExtractThumbnail` plugin did not inherit the `review` tag into the representation data. This caused the `ExtractThumbnail` plugin to fail in processing and creating thumbnails. ___
Bug: fix key in application json #5787 In PR #5705 `maya` was wrongly used instead of `mayapy`, breaking AYON defaults in AYON Application Addon. ___
'NumberAttrWidget' shows 'Multiselection' label on multiselection #5792 Attribute definition widget 'NumberAttrWidget' shows `< Multiselection >` label on multiselection. ___
Publisher: Selection change by enabled checkbox on instance update attributes #5793 Change of instance by clicking on enabled checkbox will actually update attributes on right side to match the selection. ___
Houdini: Remove `setParms` call since it's responsibility of `self.imprint` to set the values #5796 Revert a recent change made in #5621 due to this comment. However the change is faulty as can be seen mentioned here ___
AYON loader: Fix SubsetLoader functionality #5799 Fix SubsetLoader plugin processing in AYON loader tool. ___
### **Merged pull requests**
Houdini: Add self publish button #5621 This PR allows single publishing by adding a publish button to created rop nodes in HoudiniAdmins are much welcomed to enable it from houdini general settingsPublish Button also includes all input publish instances. in this screen shot the alembic instance is ignored because the switch is turned off ___
Nuke: fixing UNC support for OCIO path #5771 UNC paths were broken on windows for custom OCIO path and this is solving the issue with removed double slash at start of path ___
## [3.17.2](https://github.com/ynput/OpenPype/tree/3.17.2) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.1...3.17.2) ### **🆕 New features**
Maya: Add MayaPy application. #5705 This adds mayapy to the application to be launched from a task. ___
Feature: Copy resources when downloading last workfile #4944 When the last published workfile is downloaded as a prelaunch hook, all resource files referenced in the workfile representation are copied to the `resources` folder, which is inside the local workfile folder. ___
Blender: Deadline support #5438 Add Deadline support for Blender. ___
Fusion: implement toggle to use Deadline plugin FusionCmd #5678 Fusion 17 doesn't work in DL 10.3, but FusionCmd does. It might be probably better option as headless variant.Fusion plugin seems to be closing and reopening application when worker is running on artist machine, not so with FusionCmdAdded configuration to Project Settings for admin to select appropriate Deadline plugin: ___
Loader tool: Refactor loader tool (for AYON) #5729 Refactored loader tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. The tool is also replacing library loader. ___
### **🚀 Enhancements**
Maya: implement matchmove publishing #5445 Add possibility to export multiple cameras in single `matchmove` family instance, both in `abc` and `ma`.Exposed flag 'Keep image planes' to control export of image planes. ___
Maya: Add optional Fbx extractors in Rig and Animation family #5589 This PR allows user to export control rigs(optionally with mesh) and animated rig in fbx optionally by attaching the rig objects to the two newly introduced sets. ___
Maya: Optional Resolution Validator for Render #5693 Adding optional resolution validator for maya in render family, similar to the one in Max.It checks if the resolution in render setting aligns with that in setting from the db. ___
Use host's node uniqueness for instance id in new publisher #5490 Instead of writing `instance_id` as parm or attributes on the publish instances we can, for some hosts, just rely on a unique name or path within the scene to refer to that particular instance. By doing so we fix #4820 because upon duplicating such a publish instance using the host's (DCC) functionality the uniqueness for the duplicate is then already ensured instead of attributes remaining exact same value as where to were duplicated from, making `instance_id` a non-unique value. ___
Max: Implementation of OCIO configuration #5499 Resolve #5473 Implementation of OCIO configuration for Max 2024 regarding to the update of Max 2024 ___
Nuke: Multiple format supports for ExtractReviewDataMov #5623 This PR would fix the bug of the plugin `ExtractReviewDataMov` not being able to support extensions other than `mov`. The plugin is also renamed to `ExtractReviewDataBakingStreams` as i provides multiple format supoort. ___
Bugfix: houdini switching context doesnt update variables #5651 Allows admins to have a list of vars (e.g. JOB) with (dynamic) values that will be updated on context changes, e.g. when switching to another asset or task.Using template keys is supported but formatting keys capitalization variants is not, e.g. {Asset} and {ASSET} won't workDisabling Update Houdini vars on context change feature will leave all Houdini vars unmanaged and thus no context update changes will occur.Also, this PR adds a new button in menu to update vars on demand. ___
Publisher: Fix report maker memory leak + optimize lookups using set #5667 Fixes a memory leak where resetting publisher does not clear the stored plugins for the Publish Report Maker.Also changes the stored plugins to a `set` to optimize the lookup speeds. ___
Add openpype_mongo command flag for testing. #5676 Instead of changing the environment, this command flag allows for changing the database. ___
Nuke: minor docstring and code tweaks for ExtractReviewMov #5695 Code and docstring tweaks on https://github.com/ynput/OpenPype/pull/5623 ___
AYON: Small settings fixes #5699 Small changes/fixes related to AYON settings. All foundry apps variant `13-0` has label `13.0`. Key `"ExtractReviewIntermediates"` is not mandatory in settings. ___
Blender: Alembic Animation loader #5711 Implemented loading Alembic Animations in Blender. ___
### **🐛 Bug fixes**
Maya: Missing "data" field and enabling of audio #5618 When updating audio containers, the field "data" was missing and the audio node was not enabled on the timeline. ___
Maya: Bug in validate Plug-in Path Attribute #5687 Overwriting list with string is causing `TypeError: string indices must be integers` in subsequent iterations, crashing the validator plugin. ___
General: Avoid fallback if value is 0 for handle start/end #5652 There's a bug on the `pyblish_functions.get_time_data_from_instance_or_context` where if `handleStart` or `handleEnd` on the instance are set to value 0 it's falling back to grabbing the handles from the instance context. Instead, the logic should be that it only falls back to the `instance.context` if the key doesn't exist.This change was only affecting me on the `handleStart`/`handleEnd` and it's unlikely it could cause issues on `frameStart`, `frameEnd` or `fps` but regardless, the `get` logic is wrong. ___
Fusion: added missing env vars to Deadline submission #5659 Environment variables discerning type of job was missing. Without this injection of environment variables won't start. ___
Nuke: workfile version synchronization settings fixed #5662 Settings for synchronizing workfile version to published products is fixed. ___
AYON Workfiles Tool: Open workfile changes context #5671 Change context when workfile is opened. ___
Blender: Fix remove/update in new layout instance #5679 Fixes an error that occurs when removing or updating an asset in a new layout instance. ___
AYON Launcher tool: Fix refresh btn #5685 Refresh button does propagate refreshed content properly. Folders and tasks are cached for 60 seconds instead of 10 seconds. Auto-refresh in launcher will refresh only actions and related data which is project and project settings. ___
Deadline: handle all valid paths in RenderExecutable #5694 This commit enhances the path resolution mechanism in the RenderExecutable function of the Ayon plugin. Previously, the function only considered paths starting with a tilde (~), ignoring other valid paths listed in exe_list. This limitation led to an empty expanded_paths list when none of the paths in exe_list started with a tilde, causing the function to fail in finding the Ayon executable.With this fix, the RenderExecutable function now correctly processes and includes all valid paths from exe_list, improving its reliability and preventing unnecessary errors related to Ayon executable location. ___
AYON Launcher tool: Fix skip last workfile boolean #5700 Skip last workfile boolean works as expected. ___
Chore: Explore here action can work without task #5703 Explore here action does not crash when task is not selected, and change error message a little. ___
Testing: Inject mongo_url argument earlier #5706 Fix for https://github.com/ynput/OpenPype/pull/5676The Mongo url is used earlier in the execution. ___
Blender: Add support to auto-install PySide2 in blender 4 #5723 Change version regex to support blender 4 subfolder. ___
Fix: Hardcoded main site and wrongly copied workfile #5733 Fixing these two issues: - Hardcoded main site -> Replaced by `anatomy.fill_root`. - Workfiles can sometimes be copied while they shouldn't. ___
Bugfix: ServerDeleteOperation asset -> folder conversion typo #5735 Fix ServerDeleteOperation asset -> folder conversion typo ___
Nuke: loaders are filtering correctly #5739 Variable name for filtering by extensions were not correct - it suppose to be plural. It is fixed now and filtering is working as suppose to. ___
Nuke: failing multiple thumbnails integration #5741 This handles the situation when `ExtractReviewIntermediates` (previously `ExtractReviewDataMov`) has multiple outputs, including thumbnails that need to be integrated. Previously, integrating the thumbnail representation was causing an issue in the integration process. However, we have now resolved this issue by no longer integrating thumbnails as loadable representations.NOW default is that thumbnail representation are NOT integrated (eg. they will not show up in DB > couldn't be Loaded in Loader) and no `_thumb.jpg` will be left in `render` (most likely) publish folder.IF there would be need to override this behavior, please use `project_settings/global/publish/PreIntegrateThumbnails` ___
AYON Settings: Fix global overrides #5745 The `output` dictionary that gets passed into `ayon_settings._convert_global_project_settings` gets replaced when converting the settings for `ExtractOIIOTranscode`. This results in `global` not being in the output dictionary and thus the defaults being used and not the project overrides. ___
Chore: AYON query functions arguments #5752 Fixed how `archived` argument is handled in get subsets/assets function. ___
### **🔀 Refactored code**
Publisher: Refactor Report Maker plugin data storage to be a dict by plugin.id #5668 Refactor Report Maker plugin data storage to be a dict by `plugin.id`Also fixes `_current_plugin_data` type on `__init__` ___
Chore: Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost #5701 Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost ___
### **Merged pull requests**
Chore: Maya reduce get project settings calls #5669 Re-use system settings / project settings where we can instead of requerying. ___
Extended error message when getting subset name #5649 Each Creator is using `get_subset_name` functions which collects context data and fills configured template with placeholders.If any key is missing in the template, non descriptive error is thrown.This should provide more verbose message: ___
Tests: Remove checks for env var #5696 Env var will be filled in `env_var` fixture, here it is too early to check ___
## [3.17.1](https://github.com/ynput/OpenPype/tree/3.17.1) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.0...3.17.1) ### **🆕 New features**
Unreal: Yeti support #5643 Implemented Yeti support for Unreal. ___
Houdini: Add Static Mesh product-type (family) #5481 This PR adds support to publish Unreal Static Mesh in Houdini as FBXQuick recap - [x] Add UE Static Mesh Creator - [x] Dynamic subset name like in Maya - [x] Collect Static Mesh Type - [x] Update collect output node - [x] Validate FBX output node - [x] Validate mesh is static - [x] Validate Unreal Static Mesh Name - [x] Validate Subset Name - [x] FBX Extractor - [x] FBX Loader - [x] Update OP Settings - [x] Update AYON Settings ___
Launcher tool: Refactor launcher tool (for AYON) #5612 Refactored launcher tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. ___
### **🚀 Enhancements**
Maya: Use custom staging dir function for Maya renders - OP-5265 #5186 Check for custom staging dir when setting the renders output folder in Maya. ___
Colorspace: updating file path detection methods #5273 Support for OCIO v2 file rules integrated into the available color management API ___
Chore: add default isort config #5572 Add default configuration for isort tool ___
Deadline: set PATH environment in deadline jobs by GlobalJobPreLoad #5622 This PR makes `GlobalJobPreLoad` to set `PATH` environment in deadline jobs so that we don't have to use the full executable path for deadline to launch the dcc app. This trick should save us adding logic to pass houdini patch version and modifying Houdini deadline plugin. This trick should work with other DCCs ___
nuke: extract review data mov read node with expression #5635 Some productions might have set default values for read nodes, those settings are not colliding anymore now. ___
### **🐛 Bug fixes**
Maya: Support new publisher for colorsets validation. #5630 Fix `validate_color_sets` for the new publisher.In current `develop` the repair option does not appear due to wrong error raising. ___
Houdini: Camera Loader fix mismatch for Maya cameras #5584 This PR adds - A workaround to match Maya render mask in Houdini - `SetCameraResolution` inventory action - set camera resolution when loading or updating camera ___
Nuke: fix set colorspace on writes #5634 Colorspace is set correctly to any write node created from publisher. ___
TVPaint: Fix review family extraction #5637 Extractor marks representation of review instance with review tag. ___
AYON settings: Extract OIIO transcode settings #5639 Output definitions of Extract OIIO transcode have name to match OpenPype settings, and the settings are converted to dictionary in settings conversion. ___
AYON: Fix task type short name conversion #5641 Convert AYON task type short name for OpenPype correctly. ___
colorspace: missing `allowed_exts` fix #5646 Colorspace module is not failing due to missing `allowed_exts` attribute. ___
Photoshop: remove trailing underscore in subset name #5647 If {layer} placeholder is at the end of subset name template and not used (for example in `auto_image` where separating it by layer doesn't make any sense) trailing '_' was kept. This updates cleaning logic and extracts it as it might be similar in regular `image` instance. ___
traypublisher: missing `assetEntity` in context data #5648 Issue with missing `assetEnity` key in context data is not problem anymore. ___
AYON: Workfiles tool save button works #5653 Fix save as button in workfiles tool.(It is mystery why this stopped to work??) ___
Max: bug fix delete items from container #5658 Fix the bug shown when clicking "Delete Items from Container" and selecting nothing and press ok. ___
### **🔀 Refactored code**
Chore: Remove unused functions from Fusion integration #5617 Cleanup unused code from Fusion integration ___
### **Merged pull requests**
Increase timout for deadline test #5654 DL picks up jobs quite slow, so bump up delay. ___
## [3.17.0](https://github.com/ynput/OpenPype/tree/3.17.0) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.7...3.17.0) ### **🚀 Enhancements**
Chore: Remove schema from OpenPype root #5355 Remove unused schema directory in root of repository which was moved inside openpype/pipeline/schema. ___
Igniter: Allow custom Qt scale factor rounding policy #5554 Do not force `PassThrough` rounding policy if different policy is defined via env variable. ___
### **🐛 Bug fixes**
Chore: Lower urllib3 to support older OpenSSL #5538 Lowered `urllib3` to `1.26.16` to support older OpenSSL. ___
Chore: Do not try to add schema to zip files #5557 Do not add `schema` folder to zip file. This fixes issue cause by https://github.com/ynput/OpenPype/pull/5355 . ___
Chore: Lower click dependency version #5629 Lower click version to support older versions of python. ___
### **Merged pull requests**
Bump certifi from 2023.5.7 to 2023.7.22 #5351 Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.5.7 to 2023.7.22.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=certifi&package-manager=pip&previous-version=2023.5.7&new-version=2023.7.22)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) You can trigger a rebase of this PR by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/ynput/OpenPype/network/alerts).
> **Note** > Automatic rebases have been disabled on this pull request as it has been open for over 30 days. ___
## [3.16.7](https://github.com/ynput/OpenPype/tree/3.16.7) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.6...3.16.7) ### **🆕 New features**
Maya: Extract active view as thumbnail when no thumbnail set #5426 This sets the Maya instance's thumbnail to the current active view if no thumbnail was set yet. ___
Maya: Implement USD publish and load using native `mayaUsdPlugin` #5573 Implement Creator and Loaders for extraction and loading of USD files using Maya's own `mayaUsdPlugin`.Also adds support to load a `usd` file into an Arnold Standin (`aiStandin`) and assigning looks to it. ___
AYON: Ignore separated modules #5619 Do not load already separated modules from default directory. ___
### **🚀 Enhancements**
Maya: Reduce amount of code for Collect Looks #5253 - Refactor `get_file_node_files` because popping from `paths` by index should have been done in reversed order anyway. It's now changed to not need popping at all. - Removed unused `RENDERER_NODE_TYPES` and if-branch which collected `node_attrs` list which was unused + collected members which was also done outside of the if branch and thus generated no extra data. - Collected all materials from look set attributes at once instead of multiple queries - Collected all file nodes in history from a single query instead of per type - Restructured assignment of `instance.data["resources"]` to be more readable - Cached `PXR_NODES` only ones (Note: plugin load is checked on discovery of the collect look plugin) instead of querying plugin load and its nodes per file node per attribute - Removed some debug logs or combined some messages ___
AYON: Mark deprecated settings in Maya #5627 Added deprecated info to docstrings of maya colormanagement settings.Resolves: https://github.com/ynput/OpenPype/issues/5556 ___
Max: switching versions of maxScene maintain parentage/links with the loaders #5424 When using scene inventory to manage or update the version of the loading objects, the linked modifiers or parentage of the objects would be kept.Meanwhile, loaded objects from all loaders no longer parented to the container with OP Data. ___
3ds max: small tweaks to obj extractor and model publishing flow #5605 There migh be situation where OBJ Extractor passes without failure, but no obj file is produced. This is adding simple check directly into the extractor to catch it earlier then in the integration phase. Also switched `Validate USD Plugin` to optional, because it was always run no matter if the Extract USD was enabled or not, hindering testing (and publishing). ___
TVPaint: Plugin can be reopened #5610 TVPaint plugin can be reopened. ___
Maya: Remove context prompt #5632 More of a plea than a PR, but could we please remove the context prompt in Maya when switching tasks? ___
General: Create a desktop icon is checked #5636 In OP Installer `Create a desktop icon` is checked by default. ___
### **🐛 Bug fixes**
Maya: Extract look is not AYON compatible - OP-5375 #5341 The textures that would use hardlinking are going through texture processors. Currently all texture processors are hardcoded to copy texture instead of respecting the settings of forcing to copy.The texture processors were last modified 4 months ago, so effectively all clients that are on any pipeline updated in the last 4 months wont be utilizing hardlinking at all, since the hardcoded texture processors will copy texture no matter the OS.This opts for completely disabling the hardlinking feature, while we figure out what to do about it. ___
Maya: Multiverse USD Override inherit from correct new style creator #5566 Fix Creator for Multiverse USD Override by inheriting from correct new style creator class type ___
Max: Bug Fix Alembic Loaders with Ornatrix #5434 Bugfix the alembic loader with both ornatrix alembic and max alembic supportsAdd the ornatrix alembic loaders for loading the alembic with Ornatrix-related modifiers. ___
AYON: Avoid creation of duplicated links #5593 Handle cases when an existing link should be recreated and do not create the same link multitple times during single publishing. ___
Extract Review: Multilayer specification for ffmpeg #5613 Extract review is specifying layer name when exr is multilayer. ___
Fussion: added support for Fusion 17 #5614 Fusion 17 still uses Python 3.6 which causes issues with some our delivered libraries. Vendorized necessary set for Python 3.6 ___
Publisher: Fix screenshot widget #5615 Use correct super method name.EDITED:Removed fade animation which is not triggered at some cases, e.g. in Nuke the animation does not start. I do expect that is caused by `exec_` on the dialog, which blocks event processing to the animation, even when I've added the window as parent it still didn't trigger registered callback.Modified how the "empty" space is not filled by using paths instead of clear mode on painter. Added render hints to add antialiasing. ___
Photoshop: auto_images without alpha will not fail #5620 ExtractReview caused issue on `auto_image` instance without alpha channel, this fixes it. ___
Fix - _id key used instead of id in get_last_version_by_subset_name #5626 Just 'id' is not returned because value in fields. Caused KeyError. ___
Bugfix: create symlinks for ssl libs on Centos 7 #5633 Docker build was missing `libssl.1.1.so` and `libcrypto.1.1.so` symlinks needed by the executable itself, because Python is now explicitly built with OpenSSL 1.1.1 ___
### **📃 Documentation**
Documentation/local settings #5102 I completed the "Working with local settings" page. I updated the screenshot, wrote an explanation for each empty category, and if available, linked the more detailed pages already existing. I also added the "Environments" category. ___
## [3.16.6](https://github.com/ynput/OpenPype/tree/3.16.6) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.5...3.16.6) ### **🆕 New features**
Workfiles tool: Refactor workfiles tool (for AYON) #5550 Refactored workfiles tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. ___
AfterEffects: added validator for missing files in FootageItems #5590 Published composition in AE could contain multiple FootageItems as a layers. If FootageItem contains imported file and it doesn't exist, render triggered by Publish process will silently fail and no output is generated. This could cause failure later in the process with unclear reason. (In `ExtractReview`).This PR adds validation to protect from this. ___
### **🚀 Enhancements**
Maya: Yeti Cache Include viewport preview settings from source #5561 When publishing and loading yeti caches persist the display output and preview colors + settings to ensure consistency in the view ___
Houdini: validate colorspace in review rop #5322 Adding a validator that checks if 'OCIO Colorspace' parameter on review rop was set to a valid value.It is a step towards managing colorspace in review ropvalid values are the ones in the dropdown menuthis validator also provides some helper actions This PR is related to #4836 and #4833 ___
Colorspace: adding abstraction of publishing related functions #5497 The functionality of Colorspace has been abstracted for greater usability. ___
Nuke: removing redundant workfile colorspace attributes #5580 Nuke root workfile colorspace data type knobs are long time configured automatically via config roles or the default values are also working well. Therefore there is no need for pipeline managed knobs. ___
Ftrack: Less verbose logs for Ftrack integration in artist facing logs #5596 - Reduce artist-facing logs for component integration for Ftrack - Avoid "Comment is not set" log in artist facing report for Kitsu and Ftrack - Remove info log about `ffprobe` inspecting a file (changed to debug log) - interesting to see however that it ffprobes the same jpeg twice - but maybe once for thumbnail? ___
### **🐛 Bug fixes**
Maya: Fix rig validators for new out_SET and controls_SET names #5595 Fix usage of `out_SET` and `controls_SET` since #5310 because they can now be prefixed by the subset name. ___
TrayPublisher: set default frame values to sequential data #5530 We are inheriting default frame handles and fps data either from project or setting them to 0. This is just for case a production will decide not to injest the sequential representations with asset based metadata. ___
Publisher: Screenshot opacity value fix #5576 Fix opacity value. ___
AfterEffects: fix imports of image sequences #5581 #4602 broke imports of image sequences. ___
AYON: Fix representation context conversion #5591 Do not fix `"folder"` key in representation context until it is needed. ___
ayon-nuke: default factory to lists #5594 Default factory were missing in settings schemas for complicated objects like lists and it was causing settings to be failing saving. ___
Maya: Fix look assigner showing no asset if 'not found' representations are present #5597 Fix Maya Look assigner failing to show any content if it finds an invalid container for which it can't find the asset in the current project. (This can happen when e.g. loading something from a library project).There was logic already to avoid this but there was a bug where it used variable `_id` which did not exist and likely had to be `asset_id`.I've fixed that and improved the logged message a bit, e.g.: ``` // Warning: openpype.hosts.maya.tools.mayalookassigner.commands : Id found on 22 nodes for which no asset is found database, skipping '641d78ec85c3c5b102e836b0' ``` Example not found representation in Loader:The issue isn't necessarily related to NOT FOUND representations but in essence boils down to finding nodes with asset ids that do not exist in the current project which could very well just be local meshes in your scene.**Note:**I've excluded logging the nodes themselves because that tends to be a very long list of nodes. Only downside to removing that is that it's unclear which nodes are related to that `id`. If there are any ideas on how to still provide a concise informational message about that that'd be great so I could add it. Things I had considered: - Report the containers, issue here is that it's about asset ids on nodes which don't HAVE to be in containers - it could be local geometry - Report the namespaces, issue here is that it could be nodes without namespaces (plus potentially not about ALL nodes in a namespace) - Report the short names of the nodes; it's shorter and readable but still likely a lot of nodes.@tokejepsen @LiborBatek any other ideas? ___
Photoshop: fixed blank Flatten image #5600 Flatten image is simplified publishing approach where all visible layers are "flatten" and published together. This image could be used as a reference etc.This is implemented by auto creator which wasn't updated after first publish. This would result in missing newly created layers after `auto_image` instance was created. ___
Blender: Remove Hardcoded Subset Name for Reviews #5603 Fixes hardcoded subset name for Reviews in Blender. ___
TVPaint: Fix tool callbacks #5608 Do not wait for callback to finish. ___
### **🔀 Refactored code**
Chore: Remove unused variables and cleanup #5588 Removing some unused variables. In some cases the unused variables _seemed like they should've been used - maybe?_ so please **double check the code whether it doesn't hint to an already existing bug**.Also tweaked some other small bugs in code + tweaked logging levels. ___
### **Merged pull requests**
Chore: Loader log deprecation warning for 'fname' attribute #5587 Since https://github.com/ynput/OpenPype/pull/4602 the `fname` attribute on the `LoaderPlugin` should've been deprecated and set for removal over time. However, no deprecation warning was logged whatsoever and thus one usage appears to have sneaked in (fixed with this PR) and a new one tried to sneak in with a recent PR ___
## [3.16.5](https://github.com/ynput/OpenPype/tree/3.16.5) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.4...3.16.5) ### **🆕 New features**
Attribute Definitions: Multiselection enum def #5547 Added `multiselection` option to `EnumDef`. ___
### **🚀 Enhancements**
Farm: adding target collector #5494 Enhancing farm publishing workflow. ___
Maya: Optimize validate plug-in path attributes #5522 - Optimize query (use `cmds.ls` once) - Add Select Invalid action - Improve validation report - Avoid "Unknown object type" errors ___
Maya: Remove Validate Instance Attributes plug-in #5525 Remove Validate Instance Attributes plug-in. ___
Enhancement: Tweak logging for artist facing reports #5537 Tweak the logging of publishing for global, deadline, maya and a fusion plugin to have a cleaner artist-facing report. - Fix context being reported correctly from CollectContext - Fix ValidateMeshArnoldAttributes: fix when arnold is not loaded, fix applying settings, fix for when ai attributes do not exist ___
AYON: Update settings #5544 Updated settings in AYON addons and conversion of AYON settings in OpenPype. ___
Chore: Removed Ass export script #5560 Removed Arnold render script, which was obsolete and unused. ___
Nuke: Allow for knob values to be validated against multiple values. #5042 Knob values can now be validated against multiple values, so you can allow write nodes to be `exr` and `png`, or `16-bit` and `32-bit`. ___
Enhancement: Cosmetics for Higher version of publish already exists validation error #5190 Fix double spaces in message.Example output **after** the PR: ___
Nuke: publish existing frames on farm #5409 This PR proposes adding a fourth option in Nuke render publish called "Use Existing Frames - Farm". This would be useful when the farm is busy or when the artist lacks enough farm licenses. Additionally, some artists prefer rendering on the farm but still want to check frames before publishing.By adding the "Use Existing Frames - Farm" option, artists will have more flexibility and control over their render publishing process. This enhancement will streamline the workflow and improve efficiency for Nuke users. ___
Unreal: Create project in temp location and move to final when done #5476 Create Unreal project in local temporary folder and when done, move it to final destination. ___
TrayPublisher: adding audio product type into default presets #5489 Adding Audio product type into default presets so anybody can publish audio to their shots. ___
Global: avoiding cleanup of flagged representation #5502 Publishing folder can be flagged as persistent at representation level. ___
General: missing tag could raise error #5511 - avoiding potential situation where missing Tag key could raise error ___
Chore: Queued event system #5514 Implemented event system with more expected behavior of event system. If an event is triggered during other event callback, it is not processed immediately but waits until all callbacks of previous events are done. The event system also allows to not trigger events directly once `emit_event` is called which gives option to process events in custom loops. ___
Publisher: Tweak log message to provide plugin name after "Plugin" #5521 Fix logged message for settings automatically applied to plugin attributes ___
Houdini: Improve VDB Selection #5523 Improves VDB selection if selection is `SopNode`: return the selected sop nodeif selection is `ObjNode`: get the output node with the minimum 'outputidx' or the node with display flag ___
Maya: Refactor/tweak Validate Instance In same Context plug-in #5526 - Chore/Refactor: Re-use existing select invalid and repair actions - Enhancement: provide more elaborate PublishValidationError report - Bugfix: fix "optional" support by using `OptionalPyblishPluginMixin` base class. ___
Enhancement: Update houdini main menu #5527 This PR adds two updates: - dynamic main menu - dynamic asset name and task ___
Houdini: Reset FPS when clicking Set Frame Range #5528 _Similar to Maya,_ Make `Set Frame Range` resets FPS, issue https://github.com/ynput/OpenPype/issues/5516 ___
Enhancement: Deadline plugins optimize, cleanup and fix optional support for validate deadline pools #5531 - Fix optional support of validate deadline pools - Query deadline webservice only once per URL for verification, and once for available deadline pools instead of for every instance - Use `deadlineUrl` in `instance.data` when validating pools if it is set. - Code cleanup: Re-use existing `requests_get` implementation ___
Chore: PowerShell script for docker build #5535 Added PowerShell script to run docker build. ___
AYON: Deadline expand userpaths in executables list #5540 Expande `~` paths in executables list. ___
Chore: Use correct git url #5542 Fixed github url in README.md. ___
Chore: Create plugin does not expect system settings #5553 System settings are not passed to initialization of create plugin initialization (and `apply_settings`). ___
Chore: Allow custom Qt scale factor rounding policy #5555 Do not force `PassThrough` rounding policy if different policy is defined via env variable. ___
Houdini: Fix outdated containers pop-up on opening last workfile on launch #5567 Fix Houdini not showing outdated containers pop-up on scene open when launching with last workfile argument ___
Houdini: Improve errors e.g. raise PublishValidationError or cosmetics #5568 Improve errors e.g. raise PublishValidationError or cosmeticsThis also fixes the Increment Current File plug-in since due to an invalid import it was previously broken ___
Fusion: Code updates #5569 Update fusion code which contains obsolete code. Removed `switch_ui.py` script from fusion with related script in scripts. ___
### **🐛 Bug fixes**
Maya: Validate Shape Zero fix repair action + provide informational artist-facing report #5524 Refactor to PublishValidationError to allow the RepairAction to work + provide informational report message ___
Maya: Fix attribute definitions for `CreateYetiCache` #5574 Fix attribute definitions for `CreateYetiCache` ___
Max: Optional Renderable Camera Validator for Render Instance #5286 Optional validation to check on renderable camera being set up correctly for deadline submission.If not being set up correctly, it wont pass the validation and user can perform repair actions. ___
Max: Adding custom modifiers back to the loaded objects #5378 The custom parameters OpenpypeData doesn't show in the loaded container when it is being loaded through the loader. ___
Houdini: Use default_variant to Houdini Node TAB Creator #5421 Use the default variant of the creator plugins on the interactive creator from the TAB node search instead of hard-coding it to `Main`. ___
Nuke: adding inherited colorspace from instance #5454 Thumbnails are extracted with inherited colorspace collected from rendering write node. ___
Add kitsu credentials to deadline publish job #5455 This PR hopefully fixes this issue #5440 ___
AYON: Fill entities during editorial #5475 Fill entities and update template data on instances during extract AYON hierarchy. ___
Ftrack: Fix version 0 when integrating to Ftrack - OP-6595 #5477 Fix publishing version 0 to Ftrack. ___
OCIO: windows unc path support in Nuke and Hiero #5479 Hiero and Nuke is not supporting windows unc path formatting in OCIO environment variable. ___
Deadline: Added super call to init #5480 DL 10.3 requires plugin inheriting from DeadlinePlugin to call super's **init** explicitly. ___
Nuke: fixing thumbnail and monitor out root attributes #5483 Nuke Root Colorspace settings for Thumbnail and Monitor Out schema was gradually changed between version 12, 13, 14 and we needed to address those changes individually for particular version. ___
Nuke: fixing missing `instance_id` error #5484 Workfiles with Instances created in old publisher workflow were rising error during converting method since they were missing `instance_id` key introduced in new publisher workflow. ___
Nuke: existing frames validator is repairing render target #5486 Nuke is now correctly repairing render target after the existing frames validator finds missing frames and repair action is used. ___
added UE to extract burnins families #5487 This PR fixes missing burnins in reviewables when rendering from UE. ___
Harmony: refresh code for current Deadline #5493 - Added support in Deadline Plug-in for new versions of Harmony, in particular version 21 and 22. - Remove review=False flag on render instance - Add farm=True flag on render instance - Fix is_in_tests function call in Harmony Deadline submission plugin - Force HarmonyOpenPype.py Deadline Python plug-in to py3 - Fix cosmetics/hound in HarmonyOpenPype.py Deadline Python plug-in ___
Publisher: Fix multiselection value #5505 Selection of multiple instances in Publisher does not cause that all instances change all publish attributes to the same value. ___
Publisher: Avoid warnings on thumbnails if source image also has alpha channel #5510 Avoids the following warning from `ExtractThumbnailFromSource`: ``` // pyblish.ExtractThumbnailFromSource : oiiotool WARNING: -o : Can't save 4 channels to jpeg... saving only R,G,B ``` ___
Update ayon-python-api #5512 Update ayon python api and related callbacks. ___
Max: Fixing the bug of falling back to use workfile for Arnold or any renderers except Redshift #5520 Fix the bug of falling back to use workfile for Arnold ___
General: Fix Validate Publish Dir Validator #5534 Nonsensical "family" key was used instead of real value (as 'render' etc.) which would result in wrong translation of intermediate family names.Updated docstring. ___
have the addons loading respect a custom AYON_ADDONS_DIR #5539 When using a custom AYON_ADDONS_DIR environment variable that variable is used in the launcher correctly and downloads and extracts addons to there, however when running Ayon does not respect this environment variable ___
Deadline: files on representation cannot be single item list #5545 Further logic expects that single item files will be only 'string' not 'list' (eg. repre["files"] = "abc.exr" not repre["files"] = ["abc.exr"].This would cause an issue in ExtractReview later.This could happen if DL rendered single frame file with different frame value. ___
Webpublisher: better encode list values for click #5546 Targets could be a list, original implementation pushed it as a separate items, it must be added as `--targets webpulish --targets filepublish`.`wepublish_routes` handles triggering from UI, changes in `publish_functions` handle triggering from cmd (for tests, api access). ___
Houdini: Introduce imprint function for correct version in hda loader #5548 Resolve #5478 ___
AYON: Fill entities during editorial (2) #5549 Fix changes made in https://github.com/ynput/OpenPype/pull/5475. ___
Max: OP Data updates in Loaders #5563 Fix the bug on the loaders not being able to load the objects when iterating key and values with the dict.Max prefers list over the list in dict. ___
Create Plugins: Better check of overriden '__init__' method #5571 Create plugins do not log warning messages about each create plugin because of wrong `__init__` method check. ___
### **Merged pull requests**
Tests: fix unit tests #5533 Fixed failing tests.Updated Unreal's validator to match removed general one which had a couple of issues fixed. ___
## [3.16.4](https://github.com/ynput/OpenPype/tree/3.16.4) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.3...3.16.4) ### **🆕 New features**
Feature: Download last published workfile specify version #4998 Setting `workfile_version` key to hook's `self.launch_context.data` allow you to specify the workfile version you want sync service to download if none is matched locally. This is helpful if the last version hasn't been correctly published/synchronized, and you want to recover the previous one (or some you'd like).Version could be set in two ways: - OP's absolute version, matching the `version` index in DB. - Relative version in reverse order from the last one: `-2`, `-3`...I don't know where I should write documentation about that. ___
### **🚀 Enhancements**
Maya: allow not creation of group for Import loaders #5427 This PR enhances previous one. All ReferenceLoaders could not wrap imported products into explicit group.Also `Import` Loaders have same options. Control for this is separate in Settings, eg. Reference might wrap loaded items in group, `Import` might not. ___
3dsMax: Settings for Ayon #5388 Max Addon Setting for Ayon ___
General: Navigation to Folder from Launcher #5404 Adds an action in launcher to open the directory of the asset. ___
Chore: Default variant in create plugin #5429 Attribute `default_variant` on create plugins always returns string and if default variant is not filled other ways how to get one are implemented. ___
Publisher: Thumbnail widget enhancements #5439 Thumbnails widget in Publisher has new 3 options to choose from: Paste (from clipboard), Take screenshot and Browse. Clear button and new options are not visible by default, user must expand options button to show them. ___
AYON: Update ayon api to '0.3.5' #5460 Updated ayon-python-api to 0.3.5. ___
### **🐛 Bug fixes**
AYON: Apply unknown ayon settings first #5435 Settings of custom addons are available in converted settings. ___
Maya: Fix wrong subset name of render family in deadline #5442 New Publisher is creating different subset names than previously which resulted in duplication of `render` string in final subset name of `render` family published on Deadline.This PR solves that, it also fixes issues with legacy instances from old publisher, it matches the subset name as was before.This solves same issue in Max implementation. ___
Maya: Fix setting of version to workfile instance #5452 If there are multiple instances of renderlayer published, previous logic resulted in unpredictable rewrite of instance family to 'workfile' if `Sync render version with workfile` was on. ___
Maya: Context plugin shouldn't be tied to family #5464 `Maya Current File` collector was tied to `workfile` unnecessary. It should run even if `workile` instance is not being published. ___
Unreal: Fix loading hero version for static and skeletal meshes #5393 Fixed a problem with loading hero versions for static ans skeletal meshes. ___
TVPaint: Fix 'repeat' behavior #5412 Calculation of frames for repeat behavior is working correctly. ___
AYON: Thumbnails cache and api prep #5437 Moved thumbnails cache from ayon python api to OpenPype and prepare AYON thumbnail resolver for new api functions. Current implementation should work with old and new ayon-python-api. ___
Nuke: Name of the Read Node should be updated correctly when switching versions or assets. #5444 Bug fixing of the read node's name not being updated correctly when setting version or switching asset. ___
Farm publishing: asymmetric handles fixed #5446 Handles are now set correctly on farm published product version if asymmetric were set to shot attributes. ___
Scene Inventory: Provider icons fix #5450 Fix how provider icons are accessed in scene inventory. ___
Fix typo on Deadline OP plugin name #5453 Surprised that no one has hit this bug yet... but it seems like there was a typo on the name of the OP Deadline plugin when submitting jobs to it. ___
AYON: Fix version attributes update #5472 Fixed updates of attribs in AYON mode. ___
### **Merged pull requests**
Added missing defaults for import_loader #5447 ___
Bug: Local settings don't open on 3.14.7 #5220 ### Before posting a new ticket, have you looked through the documentation to find an answer? Yes I have ### Have you looked through the existing tickets to find any related issues ? Not yet ### Author of the bug @FadyFS ### Version 3.15.11-nightly.3 ### What platform you are running OpenPype on? Linux / Centos ### Current Behavior: the previous behavior (bug) : ![image](https://github.com/quadproduction/OpenPype/assets/135602303/09bff9d5-3f8b-4339-a1e5-30c04ade828c) ### Expected Behavior: ![image](https://github.com/quadproduction/OpenPype/assets/135602303/c505a103-7965-4796-bcdf-73bcc48a469b) ### What type of bug is it ? Happened only once in a particular configuration ### Which project / workfile / asset / ... open settings with 3.14.7 ### Steps To Reproduce: 1. Run openpype on the 3.15.11-nightly.3 version 2. Open settings in 3.14.7 version ### Relevant log output: _No response_ ### Additional context: _No response_ ___
Tests: Add automated targets for tests #5443 Without it plugins with 'automated' targets won't be triggered (eg `CloseAE` etc.) ___
## [3.16.3](https://github.com/ynput/OpenPype/tree/3.16.3) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.2...3.16.3) ### **🆕 New features**
AYON: 3rd party addon usage #5300 Prepare OpenPype code to be able use `ayon-third-party` addon which supply ffmpeg and OpenImageIO executables. Because they both can support to define custom arguments (more than one) a new functions were needed to supply.New functions are `get_ffmpeg_tool_args` and `get_oiio_tool_args`. They work similar to previous but instead of string are returning list of strings. All places using previous functions `get_ffmpeg_tool_path` and `get_oiio_tool_path` are now using new ones. They should be backwards compatible and even with addon if returns single argument. ___
AYON: Addon settings in OpenPype #5347 Moved settings addons to OpenPype server addon. Modified create package to create zip files for server for each settings addon and for openpype addon. ___
AYON: Add folder to template data #5417 Added `folder` to template data, so `{folder[name]}` can be used in templates. ___
Option to start versioning from 0 #5262 This PR adds a settings option to start all versioning from 0.This PR will replace #4455. ___
Ayon: deadline implementation #5321 Quick implementation of deadline in Ayon. New Ayon plugin added for Deadline repository ___
AYON: Remove AYON launch logic from OpenPype #5348 Removed AYON launch logic from OpenPype. The logic is outdated at this moment and is replaced by `ayon-launcher`. ___
### **🚀 Enhancements**
Bug: Error on multiple instance rig with maya #5310 I change endswith method by startswith method because the set are automacaly name out_SET, out_SET1, out_SET2 ... ___
Applications: Use prelaunch hooks to extract environments #5387 Environment variable preparation is based on prelaunch hooks. This should allow to pass OCIO environment variables to farm jobs. ___
Applications: Launch hooks cleanup #5395 Use `set` instead of `list` for filtering attributes in launch hooks. Celaction hooks dir does not contain `__init__.py`. Celaction prelaunch hook is reusing `CELACTION_ROOT_DIR`. Launch hooks are using full import from `openpype.lib.applications`. ___
Applications: Environment variables order #5245 Changed order of set environment variables. First are set context environment variables and then project environment overrides. Also asset and task environemnt variables are optional. ___
Autosave preferences can be read after Nuke opens the script #5295 Looks like I need to open the script in Nuke to be able to correctly load the autosave preferences.This PR reads the Nuke script in context, and offers owerwriting the current script with autosaved one if autosave exists. ___
Resolve: Update with compatible resolve version and latest docs #5317 Missing information about compatible Resolve version and latest docs from https://github.com/ynput/OpenPype/tree/develop/openpype/hosts/resolve ___
Chore: Remove deprecated functions #5323 Removed functions/classes that are deprecated and marked to be removed. ___
Nuke Render and Prerender nodes Process Order - OP-3555 #5332 This PR exposes control over the order of processing of the instances, by sorting the instances created. The sorting happens on the `render_order` and subset name. If the knob `render_order` is found on the instance, we'll sort by that first before sorting by subset name.`render_order` instances are processed before nodes without `render_order`. This could be extended in the future by querying other knobs but I dont know of a usecase for this.Hardcoded the creator `order` attribute of the `prerender` class to be before the `render`. Could be exposed to the user/studio but dont know of a use case for this. ___
Unreal: Python Environment Improvements #5344 Automatically set `UE_PYTHONPATH` as `PYTHONPATH` when launching Unreal. ___
Unreal: Custom location for Unreal Ayon Plugin #5346 Added a new environment variable `AYON_BUILT_UNREAL_PLUGIN` to set an already existing and built Ayon Plugin for Unreal. ___
Unreal: Better handling of Exceptions in UE Worker threads #5349 Implemented a new `UEWorker` base class to handle exception during the execution of UE Workers. ___
Houdini: Add farm toggle on creation menu #5350 Deadline Farm publishing and Rendering for Houdini was possible with this PR #4825 farm publishing is enabled by default some ROP nodes which may surprise new users (like me).I think adding a toggle (on by default) on creation UI is better so that users will be aware that there's a farm option for this publish instance.ROPs Modified : - [x] Mantra ROP - [x] Karma ROP - [x] Arnold ROP - [x] Redshift ROP - [x] Vray ROP ___
Ftrack: Sync to avalon settings #5353 Added roles settings for sync to avalon action. ___
Chore: Schemas inside OpenPype #5354 Moved/copied schemas from repository root inside openpype/pipeline. ___
AYON: Addons creation enhancements #5356 Enhanced AYON addons creation. Fix issue with `Pattern` typehint. Zip filenames contain version. OpenPype package is skipping modules that are already separated in AYON. Updated settings of addons. ___
AYON: Update staging icons #5372 Updated staging icons for staging mode. ___
Enhancement: Houdini Update pointcache labels #5373 To me it's logical to find pointcaches types listed one after another, but they were named differentlySo, I made this PR to update their labels ___
nuke: split write node product instance features #5389 Improving Write node product instances by allowing precise activation of specific features. ___
Max: Use the empty modifiers in container to store AYON Parameter #5396 Instead of adding AYON/OP Parameter along with other attributes inside the container, empty modifiers would be created to store AYON/OP custom attributes ___
AfterEffects: Removed unused imports #5397 Removed unused import from extract local render plugin file. ___
Nuke: adding BBox knob type to settings #5405 Nuke knob types in settings having new `Box` type for reposition nodes like Crop or Reformat. ___
SyncServer: Existence of module is optional #5413 Existence of SyncServer module is optional and not required. Added `sync_server` module back to ignored modules when openpype addon is created for AYON. Command `syncserver` is marked as deprecated and redirected to sync server cli. ___
Webpublisher: Self contain test publish logic #5414 Moved test logic of publishing to webpublisher. Simplified `remote_publish` to remove webpublisher specific logic. ___
Webpublisher: Cleanup targets #5418 Removed `remote` target from webpublisher and replaced it with 2 targets `webpublisher` and `automated`. ___
nuke: update server addon settings with box #5419 updtaing nuke ayon server settings for Box option in knob types. ___
### **🐛 Bug fixes**
Maya: fix validate frame range on review attached to other instances #5296 Fixes situation where frame range validator can't be turned off on models if they are attached to reviewable camera in Maya. ___
Maya: Apply project settings to creators #5303 Project settings were not applied to the creators. ___
Maya: Validate Model Content #5336 `assemblies` in `cmds.ls` does not seem to work; ```python from maya import cmds content_instance = ['|group2|pSphere1_GEO', '|group2|pSphere1_GEO|pSphere1_GEOShape', '|group1|pSphere1_GEO', '|group1|pSphere1_GEO|pSphere1_GEOShape'] assemblies = cmds.ls(content_instance, assemblies=True, long=True) print(assemblies) ``` Fixing with string splitting instead. ___
Bugfix: Maya update defaults variable #5368 So, something was forgotten while moving out from `LegacyCreator` to `NewCreator``LegacyCreator` used `defaults` to list suggested subset names which was changed into `default_variants` in the the `NewCreator`and setting `defaults` to any values has no effect!This update affects: - [x] Model - [x] Set Dress ___
Chore: Python 2 support fix #5375 Fix Python 2 support by adding `click` into python 2 dependencies and removing f-string from maya. ___
Maya: do not create top level group on reference #5402 This PR allows to not wrapping loaded referenced assets in top level group either explicitly for artist or by configuration in Settings.Artists can control group creation in ReferenceLoader options.Default no group creation could be set by emptying `Group Name` in `project_settings/maya/load/reference_loader` ___
Settings: Houdini & Maya create plugin settings #5436 Fixes related to Maya and Houdini settings. Renamed `defaults` to `default_variants` in plugin settings to match attribute name on create plugin in both OpenPype and AYON settings. Fixed Houdini AYON settings where were missing settings for defautlt varaints and fixed Maya AYON settings where default factory had wrong assignment. ___
Maya: Hide CreateAnimation #5297 When converting `animation` family or loading a `rig` family, need to include the `animation` creator but hide it in creator context. ___
Nuke Anamorphic slate - Read pixel aspect from input #5304 When asset pixel aspect differs from rendered pixel aspect, Nuke slate pixel aspect is not longer taken from asset, but is readed via ffprobe. ___
Nuke - Allow ExtractReviewDataMov with no timecode knob #5305 ExtractReviewDataMov allows to specify file type. Trying to write some other extension than mov fails on generate_mov assuming that mov64_write_timecode knob exists. ___
Nuke: removing settings schema with defaults for OpenPype #5306 continuation of https://github.com/ynput/OpenPype/pull/5275 ___
Bugfix: Dependency without 'inputLinks' not downloaded #5337 Remove condition that avoids downloading dependency without `inputLinks`. ___
Bugfix: Houdini Creator use selection even if it was toggled off #5359 When creating many product types (families) one after another without refreshing the creator window manually if you toggled `Use selection` once, all the later product types will use selection even if it was toggled offHere's Before it will keep use selection even if it was toggled off, unless you refresh window manuallyhttps://github.com/ynput/OpenPype/assets/20871534/8b890122-5b53-4c6b-897d-6a2f3aa3388aHere's After it works as expectedhttps://github.com/ynput/OpenPype/assets/20871534/6b1db990-de1b-428e-8828-04ab59a44e28 ___
Houdini: Correct camera selection for karma renderer when using selected node #5360 When user creates the karma rop with selected camera by use selection, it will give the error message of "no render camera found in selection".This PR is to fix the bug of creating karma rop when using selected camera node in Houdini ___
AYON: Environment variables and functions #5361 Prepare code for ayon-launcher compatibility. Fix ayon launcher subprocess calls, added more checks for `AYON_SERVER_ENABLED`, use ayon launcher suitable environment variables in AYON mode and changed outputs of some functions. Replaced usages of `OPENPYPE_REPOS_ROOT` environment variable with `PACKAGE_DIR` variable -> correct paths are used. ___
Nuke: farm rendering of prerender ignore roots in nuke #5366 `prerender` family was using wrong subset, same as `render` which should be different. ___
Bugfix: Houdini update defaults variable #5367 So, something was forgotten while moving out from `LegacyCreator` to `NewCreator``LegacyCreator` used `defaults` to list suggested subset names which was changed into `default_variants` in the the `NewCreator`and setting `defaults` to any values has no effect!This update affects: - [x] Arnold ASS - [x] Arnold ROP - [x] Karma ROP - [x] Mantra ROP - [x] Redshift ROP - [x] VRay ROP ___
Publisher: Fix create/publish animation #5369 Use geometry movement instead of changing min/max width. ___
Unreal: Move unreal splash screen to unreal #5370 Moved splash screen code to unreal integration and removed import from Igniter. ___
Nuke: returned not cleaning of renders folder on the farm #5374 Previous PR enabled explicit cleanup of `renders` folder after farm publishing. This is not matching customer's workflows. Customer wants to have access to files in `renders` folder and potentially redo some frames for long frame sequences.This PR extends logic of marking rendered files for deletion only if instance doesn't have `stagingDir_persistent`.For backwards compatibility all Nuke instances have `stagingDir_persistent` set to True, eg. `renders` folder won't be cleaned after farm publish. ___
Nuke: loading sequences is working #5376 Loading image sequences was broken after the latest release, version 3.16. However, I am pleased to inform you that it is now functioning as expected. ___
AYON: Fix settings conversion for ayon addons #5377 AYON addon settings are available in system settings and does not have available the same values in `"modules"` subkey. ___
Nuke: OCIO env var workflow #5379 The OCIO environment variable needs to be consistently handled across all platforms. Nuke resolves the custom OCIO config path differently depending on the platform, so we included the ocio config path in the workfile with a partial replacement using an environment variable. Additionally, for Windows sessions, we replaced backward slashes with a TCL expression. ___
Unreal: Fix Unreal build script #5381 Define 'AYON_UNREAL_ROOT' environment variable in unreal addon. ___
3dsMax: Use relative path to MAX_HOST_DIR #5382 Use `MAX_HOST_DIR` to calculate startup script path instead of use relative path to `OPENPYPE_ROOT` environment variable. ___
Bugfix: Houdini abc validator error message #5386 When ABC path validator fails, it prints node objects not node paths or namesThis bug happened because of updating `get_invalid` method to return nodes instead of node pathsBeforeAfter ___
Nuke: node name influence product (subset) name #5392 Nuke now allows users to duplicate publishing instances, making the workflow easier. By duplicating a node and changing its name, users can set the product (subset) name in the publishing context.Users now have the ability to change the variant name in Publisher, which will automatically rename the associated instance node. ___
Houdini: delete redundant bgeo sop validator #5394 I found out that this `Validate BGEO SOP Path` validator is redundant, it catches two cases that are already implemented in "Validate Output Node". "Validate Output Node" works with `bgeo` as well as `abc` because `"pointcache"` is listed in its families ___
Nuke: workfile is not reopening after change of context #5399 Nuke no longer reopens the latest workfile when the context is changed to a different task using the Workfile tool. The issue also affected the Script Clean (from Nuke File menu) and Close feature, but it has now been fixed. ___
Bugfix: houdini hard coded project settings #5400 I made this PR to solve the issue with hard-coded settings in houdini ___
AYON: 3dsMax settings #5401 Keep `adsk_3dsmax` group in applications settings. ___
Bugfix: update defaults to default_variants in maya and houdini OP DCC settings #5407 On moving out to new creator in Maya and Houdini updating settings was missed. ___
Applications: Attributes creation #5408 Applications addon does not cause infinite server restart loop. ___
Max: fix the bug of handling Object deletion in OP Parameter #5410 If the object is added to the OP parameter and user delete it in the scene thereafter, it will error out the container with OP attributes. This PR resolves the bug.This PR also fixes the bug of not adding the attribute into OP parameter correctly when the user enables "use selections" to link the object into the OP parameter. ___
Colorspace: including environments from launcher process #5411 Fixed bug in GitHub PR where the OCIO config template was not properly formatting environment variables from System Settings `general/environment`. ___
Nuke: workfile template fixes #5428 Some bunch of small bugs needed to be fixed ___
Houdini, Max: Fix missed function interface change #5430 This PR https://github.com/ynput/OpenPype/pull/5321/files from @kalisp missed updating the `add_render_job_env_var` in Houdini and Max as they are passing an extra arg: ``` TypeError: add_render_job_env_var() takes 1 positional argument but 2 were given ``` ___
Scene Inventory: Fix issue with 'sync_server' #5431 Fix accesss to `sync_server` attribute in scene inventory. ___
Unpack project: Fix import issue #5433 Added `load_json_file`, `replace_project_documents` and `store_project_documents` to mongo init. ___
Chore: Versions post fixes #5441 Fixed issues caused by my fault. Filled right version value to anatomy data. ___
### **📃 Testing**
Tests: Copy file_handler as it will be removed by purging ayon code #5357 Ayon code will get purged in the future from this repo/addon, therefore all `ayon_common` will be gone. `file_handler` gets internalized to tests as it is not used anywhere else. ___
## [3.16.2](https://github.com/ynput/OpenPype/tree/3.16.2) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.1...3.16.2) ### **🆕 New features**
Fusion - Set selected tool to active #5327 When you run the action to select a node, this PR makes the node-flow show the selected node + you'll see the nodes controls in the inspector. ___
### **🚀 Enhancements**
Maya: All base create plugins #5326 Prepared base classes for each creator type in Maya. Extended `MayaCreatorBase` to have default implementations of common logic with instances which is used in each type of plugin. ___
Windows: Support long paths on zip updates. #5265 Support long paths for version extract on Windows.Use case is when having long paths in for example an addon. You can install to the C drive but because the zip files are extracted in the local users folder, it'll add additional sub directories to the paths and quickly get too long paths for Windows to handle the zip updates. ___
Blender: Added setting to set resolution and start/end frames at startup #5338 This PR adds `set_resolution_startup`and `set_frames_startup` settings. They automatically set respectively the resolution and start/end frames and FPS in Blender when opening a file or creating a new one. ___
Blender: Support for ExtractBurnin #5339 This PR adds support for ExtractBurnin for Blender, when publishing a Review. ___
Blender: Extract Camera as Alembic #5343 Added support to extract Alembic Cameras in Blender. ___
### **🐛 Bug fixes**
Maya: Validate Instance In Context #5335 Missing new publisher error so the repair action shows up. ___
Settings: Fix default settings #5311 Fixed defautl settings for shotgrid. Renamed `FarmRootEnumEntity` to `DynamicEnumEntity` and removed doubled ABC metaclass definition (all settings entities have abstract metaclass). ___
Deadline: missing context argument #5312 Updated function arguments ___
Qt UI: Multiselection combobox PySide6 compatibility #5314 - The check states are replaced with the values for PySide6 - `QtCore.Qt.ItemIsUserTristate` is used instead of `QtCore.Qt.ItemIsTristate` to avoid crashes on PySide6 ___
Docker: handle openssl 1.1.1 for centos 7 docker build #5319 Move to python 3.9 has added need to use openssl 1.1.x - but it is not by default available on centos 7 image. This is fixing it. ___
houdini: fix typo in redshift proxy #5320 I believe there's a typo in `create_redshift_proxy.py` ( extra ` ) in filename, and I made this PR to suggest a fix ___
Houdini: fix wrong creator identifier in pointCache workflow #5324 FIxing a bug in publishing alembics, were invalid creator identifier caused missing family association. ___
Fix colorspace compatibility check #5334 for some reason a user may have `PyOpenColorIO` installed to his machine, _in my case it came with renderman._it can trick the compatibility check as `import PyOpenColorIO` won't raise an error however it may be an old version _like my case_Beforecompatibility check was true and It used wrapper directly After Fix It will use wrapper via subprocess instead ___
### **Merged pull requests**
Remove forgotten dev logging #5315 ___
## [3.16.1](https://github.com/ynput/OpenPype/tree/3.16.1) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.0...3.16.1) ### **🆕 New features**
Royal Render: Maya and Nuke support #5191 Basic working implementation of Royal Render support in Maya.It expects New publisher implemented in Maya. ___
Blender: Blend File Family #4321 Implementation of the Blend File family analogue to the Maya Scene one. ___
Houdini: simple bgeo publishing #4588 Support for simple publishing of bgeo files. This is adding basic support for bgeo publishing in Houdini. It will allow publishing bgeo in all supported formats (selectable in the creator options). If selected node has `output` on sop level, it will be used automatically as path in file node. ___
### **🚀 Enhancements**
General: delivery action add renamed frame number in Loader #5024 Frame Offset options for delivery in Openpype loader ___
Enhancement/houdini add path action for abc validator #5237 Add a default path attribute Action.it's a helper action more than a repair action, which used to add a default single value. ___
Nuke: auto apply all settings after template build #5277 Adding auto run of Apply All Settings after template is builder is finishing its process. This will apply Frame-range, Image size, Colorspace found in context of a task shot. ___
Harmony:Removed loader settings for Harmony #5289 It shouldn't be configurable, it is internal logic. By adding additional extension it wouldn't start to work magically. ___
### **🐛 Bug fixes**
AYON: Make appdirs case sensitive #5298 Appdirs for AYON are case sensitive for linux and mac so we needed to change them to match ayon launcher. Changed 'ayon' to 'AYON' and 'ynput' to 'Ynput'. ___
Traypublisher: Fix plugin order #5299 Frame range collector for traypublisher was moved to traypublisher plugins and changed order to make sure `assetEntity` is filled in `instance.data`. ___
Deadline: removing OPENPYPE_VERSION from some host submitters #5302 Removing deprecated method of adding OPENPYPE_VERSION to job environment. It was leftover and other hosts have already been cleared. ___
AYON: Fix args for workfile conversion util #5308 Workfile update conversion util function have right expected arguments. ___
### **🔀 Refactored code**
Maya: Refactor imports to `lib.get_reference_node` since the other function… #5258 Refactor imports to `lib.get_reference_node` since the other function is deprecated. ___
## [3.16.0](https://github.com/ynput/OpenPype/tree/3.16.0) [Full Changelog](https://github.com/ynput/OpenPype/compare/...3.16.0) ### **🆕 New features**
General: Reduce usage of legacy io #4723 Replace usages of `legacy_io` with getter methods or reuse already available information. Create plugins using CreateContext are using context from CreateContext object. Loaders are usign getter function from context tools. Publish plugin are using information instance.data or context.data. In some cases were pieces of code refactored a little e.g. fps getter in maya. ___
Documentation: API docs reborn - yet again #4419 ## Feature Add functional base for API Documentation using Sphinx and AutoAPI. After unsuccessful #2512, #834 and #210 this is yet another try. But this time without ambition to solve the whole issue. This is making Shinx script to work and nothing else. Any changes and improvements in API docs should be made in subsequent PRs. ## How to use it You can run: ```sh cd .\docs make.bat html ``` or ```sh cd ./docs make html ``` This will go over our code and generate **.rst** files in `/docs/source/autoapi` and from those it will generate full html documentation in `/docs/build/html`. During the build you'll see tons of red errors that are pointing to our issues: 1) **Wrong imports** Invalid import are usually wrong relative imports (too deep) or circular imports. 2) **Invalid doc-strings** Doc-strings to be processed into documentation needs to follow some syntax - this can be checked by running `pydocstyle` that is already included with OpenPype 3) **Invalid markdown/rst files** md/rst files can be included inside rst files using `.. include::` directive. But they have to be properly formatted. ## Editing rst templates Everything starts with `/docs/source/index.rst` - this file should be properly edited, Right now it just includes `readme.rst` that in turn include and parse main `README.md`. This is entrypoint to API documentation. All templates generated by AutoAPI are in `/docs/source/autoapi`. They should be eventually commited to repository and edited too. ## Steps for enhancing API documentation 1) Run `/docs/make.bat html` 2) Read the red errors/warnings - fix it in the code 3) Run `/docs/make.bat html` again until there are not red lines 4) Edit rst files and add some meaningfull content there > **Note** > This can (should) be merged as is without doc-string fixes in the code or changes in templates. All additional improvements on API documentation should be made in new PRs. > **Warning** > You need to add new dependencies to use it. Run `create_venv`. Connected to #2490 ___
Global: custom location for OP local versions #4673 This provides configurable location to unzip Openpype version zips. By default, it was hardcoded to artist's app data folder, which might be problematic/slow with roaming profiles.Location must be accessible by user running OP Tray with write permissions (so `Program Files` might be problematic) ___
AYON: Update settings conversion #4837 Updated conversion script of AYON settings to v3 settings. PR is related to changes in addons repository https://github.com/ynput/ayon-addons/pull/6 . Changed how the conversion happens -> conversion output does not start with openpype defaults but as empty dictionary. ___
AYON: Implement integrate links publish plugin #4842 Implemented entity links get/create functions. Added new integrator which replaces v3 integrator for links. ___
General: Version attributes integration #4991 Implemented unified integrate plugin to update version attributes after all integrations for AYON. The goal is to be able update attribute values in a unified way to a version when all addon integrators are done, so e.g. ftrack can add ftrack id to matching version in AYON server etc.The can be stored under `"versionAttributes"` key. ___
AYON: Staging versions can be used #4992 Added ability to use staging versions in AYON mode. ___
AYON: Preparation for products #5038 Prepare ayon settings conversion script for `product` settings conversion. ___
Loader: Hide inactive versions in UI #5101 Added support for `active` argument to hide versions with active set to False in Loader UI when in AYON mode. ___
General: CLI addon command #5109 Added `addon` alias for `module` in OpenPype cli commands. ___
AYON: OpenPype as server addon #5199 OpenPype repository can be converted to AYON addon for distribution. Addon has defined dependencies that are required to use it and are not in base ayon-launcher (desktop application). ___
General: Runtime dependencies #5206 Defined runtime dependencies in pyproject toml. Moved python ocio and otio modules there. ___
AYON: Bundle distribution #5209 Since AYON server 0.3.0 are addon versions defined by bundles which affects how addons, dependency packages and installers are handled. Only source of truth, about any version of anything that should be used, is server bundle. ___
Feature/blender handle q application #5264 This edit is to change the way the QApplication is run for Blender. It calls in the singleton (QApplication) during the register. This is made so that other Qt applications and addons are able to run on Blender. In its current implementation, if a QApplication is already running, all functionality of OpenPype becomes unavailable. ___
### **🚀 Enhancements**
General: Connect to AYON server (base) #3924 Initial implementation of being able use AYON server in current OpenPype client. Added ability to connect to AYON server and use base queries. AYON mode has it's own executable (and start script). To start in AYON mode just replace `start.py` with `ayon_start.py` (added tray start script to tools). Added constant `AYON_SERVER_ENABLED` to `openpype/__init__.py` to know if ayon mode is enabled. In that case Mongo is not used at all and any attempts will cause crashes.I had to modify `~/openpype/client` content to be able do this switch. Mongo implementation was moved to `mongo` subfolder and use "star imports" in files from where current imports are used. Logic of any tool or query in code was not changed at all. Since functions were based on mongo queries they don't use full potential of AYON server abilities.ATM implementation has login UI, distribution of files from server and replacement of mongo queries. For queries is used `ayon_api` module. Which is in live development so the versions may change from day to day. ___
Enhancement kitsu note with exceptions #4537 Adding a setting to choose some exceptions to IntegrateKitsuNote task status changes. ___
General: Environment variable for default OCIO configs #4670 Define environment variable which lead to root of builtin ocio configs to be able change the root without changing settings. For the path in settings was used `"{OPENPYPE_ROOT}/vendor/bin/ocioconfig/OpenColorIOConfig"` which disallow to change the root somewhere else. That will be needed in AYON where configs won't be part of desktop application but downloaded from server. ___
AYON: Editorial hierarchy creation #4699 Implemented extract hierarchy to AYON plugin which created entities in AYON using ayon api. ___
AYON: Vendorize ayon api #4753 Vendorize ayon api into openpype vendor directory. The reason is that `ayon-python-api` is in live development and will fix/add features often in next few weeks/months, and because update of dependency requires new release -> new build, we want to avoid the need of doing that as it would affect OpenPype development. ___
General: Update PySide 6 for MacOs #4764 New version of PySide6 does not have issues with settings UI. It is still breaking UI stylesheets so it is not changed for other plaforms but it is enhancement from previous state. ___
General: Removed unused cli commands #4902 Removed `texturecopy` and `launch` cli commands from cli commands. ___
AYON: Linux & MacOS launch script #4970 Added shell script to launch tray in AYON mode. ___
General: Qt scale enhancement #5059 Set ~~'QT_SCALE_FACTOR_ROUNDING_POLICY'~~ scale factor rounding policy of QApplication to `PassThrough` so the scaling can be 'float' number and not just 'int' (150% -> 1.5 scale). ___
CI: WPS linting instead of Hound (rebase) 2 #5115 Because Hound currently used to lint the code on GH ships with really old flake8 support, it fails miserably on any newer Python syntax. This PR is adding WPS linter to GitHub workflows that should step in. ___
Max: OP parameters only displays what is attached to the container #5229 The OP parameter in 3dsmax only displays what is currently attached to the container while deleting while you can see the items which is not added when you are adding to the container. ___
Testing: improving logging during testing #5271 Unit testing logging was crashing on more then one nested layers of inherited loggers. ___
Nuke: removing deprecated settings in baking #5275 Removing deprecated settings for baking with reformat. This option was only for single reformat node and it had been substituted with multiple reposition nodes. ___
### **🐛 Bug fixes**
AYON: General fixes and updates #4975 Few smaller fixes related to AYON connection. Some of fixes were taken from this PR. ___
Start script: Change returncode on validate or list versions #4515 Change exit code from `1` to `0` when versions are printed or when version is validated. Return code `1` is indicating error but there didn't happen any error. ___
AYON: Change login UI works #4754 Fixed change of login UI. Logic change UI did show up, new login was successful, but after restart was used the previous login. This change fix the issue. ___
AYON: General issues #4763 Vendorized `ayon_api` from PR broke OpenPype launch, because `ayon_api` is not available. Moved `ayon_api` from ayon specific subforlder to `common` python vendor in OpenPype, and removed login in ayon start script (which was invalid anyway). Also made fixed compatibility with PySide6 by using `qtpy` instead of `Qt` and changing code which is not PySide6 compatible. ___
AYON: Small fixes #4841 Bugsfixes and enhancements related to AYON logic. Define `BUILTIN_OCIO_ROOT` environment variable so OCIO configs are working. Use constants from ayon api instead of hardcoding them in codebase. Change process name from "openpype" to "ayon". Don't execute login dialog when application is not yet running but use `open` method instead. Fixed missing modules settings which were not taken from openpype defaults. Updated ayon api to `0.1.17`. ___
Bugfix - Update gazu to 0.9.3 #4845 This updates Gazu to 0.9.3 to make sure Gazu works with Kitsu and Zou 0.16.x+ ___
Igniter: fix error reports in silent mode #4909 Some errors in silent mode commands in Igniter were suppressed and not visible for example in Deadline log. ___
General: Remove ayon api from poetry lock #4964 Remove AYON python api from pyproject.toml and poetry.lock again. ___
Ftrack: Fix AYON settings conversion #4967 Fix conversion of ftrack settings in AYON mode. ___
AYON: ISO date format conversion issues #4981 Function `datetime.fromisoformat` was replaced with `arrow.get` to be used instead. ___
AYON: Missing files on representations #4989 Fix integration of files into representation in server database. ___
General: Fix Python 2 vendor for arrow #4993 Moved remaining dependencies for arrow from ftrack to python 2 vendor. ___
General: Fix new load plugins for next minor relase #5000 Fix access to `fname` attribute which is not available on load plugin anymore. ___
General: Fix mongo secure connection #5031 Fix `ssl` and `tls` keys checks in mongo uri query string. ___
AYON: Fix site sync settings #5069 Fixed settings for AYON variant of sync server. ___
General: Replace deprecated keyword argument in PyMongo #5080 Use argument `tlsCAFile` instead of `ssl_ca_certs` to avoid deprecation warnings. ___
Igniter: QApplication is created #5081 Function `_get_qt_app` actually creates new `QApplication` if was not created yet. ___
General: Lower unidecode version #5090 Use older version of Unidecode module to support Python 2. ___
General: Lower cryptography to 39.0.0 #5099 Lower cryptography to 39.0.0 to avoid breaking of DCCs like Maya and Nuke. ___
AYON: Global environments key fix #5118 Seems that when converting ayon settings to OP settings the `environments` setting is put under the `environments` key in `general` however when populating the environment the `environment` key gets picked up, which does not contain the environment variables from the `core/environments` setting ___
Add collector to tray publisher for getting frame range data #5152 Add collector to tray publisher to get frame range data. User can choose to enable this collector if they need this in the publisher.Resolve #5136 ___
Unreal: get current project settings not using unreal project name #5170 There was a bug where Unreal project name was used to query project settings. But Unreal project name can differ from the "real" one because of naming convention rules set by Unreal. This is fixing it by asking for current project settings. ___
Substance Painter: Fix Collect Texture Set Images unable to copy.deepcopy due to QMenu #5238 Fix `copy.deepcopy` of `instance.data`. ___
Ayon: server returns different key #5251 Package returned from server has `filename` instead of `name`. ___
Substance Painter: Fix default color management settings #5259 The default settings for color management for Substance Painter were invalid, it was set to override the global config by default but specified no valid config paths of its own - and thus errored that the paths were not correct.This sets the defaults correctly to match other hosts._I quickly checked - this seems to be the only host with the wrong default settings_ ___
Nuke: fixing container data if windows path in value #5267 Windows path in container data are reformatted. Previously it was reported that Nuke was rising `utf8 0xc0` error if backward slashes were in data values. ___
Houdini: fix typo error in collect arnold rop #5281 Fixing a typo error in `collect_arnold_rop.py`Reference: #5280 ___
Slack - enhanced logging and protection against failure #5287 Covered issues found in production on customer site. SlackAPI exception doesn't need to have 'error', covered uncaught exception. ___
Maya: Removed unnecessary import of pyblish.cli #5292 This import resulted in adding additional logging handler which lead to duplication of logs in hosts with plugins containing `is_in_tests` method. Import is unnecessary for testing functionality. ___
### **🔀 Refactored code**
Loader: Remove `context` argument from Loader.__init__() #4602 Remove the previously required `context` argument. ___
Global: Remove legacy integrator #4786 Remove the legacy integrator. ___
### **📃 Documentation**
Next Minor Release #5291 ___
### **Merged pull requests**
Maya: Refactor to new publisher #4388 **Refactor Maya to use the new publisher with new creators.** - [x] Legacy instance can be converted in UI using `SubsetConvertorPlugin` - [x] Fix support for old style "render" and "vrayscene" instance to the new per layer format. - [x] Context data is stored with scene - [x] Workfile instance converted to AutoCreator - [x] Converted Creator classes - [x] Create animation - [x] Create ass - [x] Create assembly - [x] Create camera - [x] Create layout - [x] Create look - [x] Create mayascene - [x] Create model - [x] Create multiverse look - [x] Create multiverse usd - [x] Create multiverse usd comp - [x] Create multiverse usd over - [x] Create pointcache - [x] Create proxy abc - [x] Create redshift proxy - [x] Create render - [x] Create rendersetup - [x] Create review - [x] Create rig - [x] Create setdress - [x] Create unreal skeletalmesh - [x] Create unreal staticmesh - [x] Create vrayproxy - [x] Create vrayscene - [x] Create xgen - [x] Create yeti cache - [x] Create yeti rig - [ ] Tested new Creator publishes - [x] Publish animation - [x] Publish ass - [x] Publish assembly - [x] Publish camera - [x] Publish layout - [x] Publish look - [x] Publish mayascene - [x] Publish model - [ ] Publish multiverse look - [ ] Publish multiverse usd - [ ] Publish multiverse usd comp - [ ] Publish multiverse usd over - [x] Publish pointcache - [x] Publish proxy abc - [x] Publish redshift proxy - [x] Publish render - [x] Publish rendersetup - [x] Publish review - [x] Publish rig - [x] Publish setdress - [x] Publish unreal skeletalmesh - [x] Publish unreal staticmesh - [x] Publish vrayproxy - [x] Publish vrayscene - [x] Publish xgen - [x] Publish yeti cache - [x] Publish yeti rig - [x] Publish workfile - [x] Rig loader correctly generates a new style animation creator instance - [ ] Validations / Error messages for common validation failures look nice and usable as a report. - [ ] Make Create Animation hidden to the user (should not create manually?) - [x] Correctly detect difference between **'creator_attributes'** and **'instance_data'** since both are "flattened" to the top node. ___
Start script: Fix possible issues with destination drive path #4478 Drive paths for windows are fixing possibly missing slash at the end of destination path. Windows `subst` command require to have destination path with slash if it's a drive (it should be `G:\` not `G:`). ___
Global: Move PyOpenColorIO to vendor/python #4946 So that DCCs don't conflict with their own. See https://github.com/ynput/OpenPype/pull/4267#issuecomment-1537153263 for the issue with Gaffer. I'm not sure if this is the correct approach, but I assume PySide/Shiboken is under `vendor/python` for this reason as well... ___
RuntimeError with Click on deadline publish #5065 I changed Click to version 8.0 instead of 7.1.2 to solve this error: ``` 2023-05-30 16:16:51: 0: STDOUT: Traceback (most recent call last): 2023-05-30 16:16:51: 0: STDOUT: File "start.py", line 1126, in boot 2023-05-30 16:16:51: 0: STDOUT: File "/prod/softprod/apps/openpype/LINUX/3.15/dependencies/click/core.py", line 829, in __call__ 2023-05-30 16:16:51: 0: STDOUT: return self.main(*args, **kwargs) 2023-05-30 16:16:51: 0: STDOUT: File "/prod/softprod/apps/openpype/LINUX/3.15/dependencies/click/core.py", line 760, in main 2023-05-30 16:16:51: 0: STDOUT: _verify_python3_env() 2023-05-30 16:16:51: 0: STDOUT: File "/prod/softprod/apps/openpype/LINUX/3.15/dependencies/click/_unicodefun.py", line 126, in _verify_python3_env 2023-05-30 16:16:51: 0: STDOUT: raise RuntimeError( 2023-05-30 16:16:51: 0: STDOUT: RuntimeError: Click will abort further execution because Python 3 was configured to use ASCII as encoding for the environment. Consult https://click.palletsprojects.com/python3/ for mitigation steps. ``` ___
## [3.15.12](https://github.com/ynput/OpenPype/tree/3.15.12) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.11...3.15.12) ### **🆕 New features**
Tray Publisher: User can set colorspace per instance explicitly #4901 With this feature a user can set/override the colorspace for the representations of an instance explicitly instead of relying on the File Rules from project settings or alike. This way you can ingest any file and explicitly say "this file is colorspace X". ___
Review Family in Max #5001 Review Feature by creating preview animation in 3dsmax(The code is still cleaning up so there is going to be some updates until it is ready for review) ___
AfterEffects: support for workfile template builder #5163 This PR add functionality of templated workfile builder. It allows someone to prepare AE workfile with placeholders as for automatically loading particular representation of particular subset of particular asset from context where workfile is opened.Selection from multiple prepared workfiles is provided with usage of templates, specific type of tasks could use particular workfile template etc.Artists then can build workfile from template when opening new workfile. ___
CreatePlugin: Get next version helper #5242 Implemented helper functions to get next available versions for create instances. ___
### **🚀 Enhancements**
Maya: Improve Templates #4854 Use library method for fetching reference node and support parent in hierarchy. ___
Bug: Maya - xgen sidecar files arent moved when saving workfile as an new asset workfile changing context - OP-6222 #5215 This PR manages the Xgen files when switching context in the Workfiles app. ___
node references to check for duplicates in Max #5192 No duplicates for node references in Max when users trying to select nodes before publishing ___
Tweak profiles logging to debug level #5194 Tweak profiles logging to debug level since they aren't artist facing logs. ___
Enhancement: Reduce more visual clutter for artists in new publisher reports #5208 Got this from one of our artists' reports - figured some of these logs were definitely not for the artist, reduced those logs to debug level. ___
Cosmetics: Tweak pyblish repair actions (icon, logs, docstring) #5213 - Add icon to RepairContextAction - logs to debug level - also add attempt repair for RepairAction for consistency - fix RepairContextAction docstring to mention correct argument name #### Additional info We should not forget to remove this ["deprecated" actions.py file](https://github.com/ynput/OpenPype/blob/3501d0d23a78fbaef106da2fffe946cb49bef855/openpype/action.py) in 3.16 (next-minor) ## Testing notes: 1. Run some fabulous repairs! ___
Maya: fix save file prompt on launch last workfile with color management enabled + restructure `set_colorspace` #5225 - Only set `configFilePath` when OCIO env var is not set since it doesn't do anything if OCIO var is set anyway. - Set the Maya 2022+ default OCIO path using the resources path instead of "" to avoid Maya Save File on new file after launch - **Bugfix: This is what fixes the Save prompt on open last workfile feature with Global color management enabled** - Move all code related to applying the maya settings together after querying the settings - Swap around the `if use_workfile_settings` since the check was reversed - Use `get_current_project_name()` instead of environment vars ___
Enhancement: More descriptive error messages for Loaders #5227 Tweak raised errors and error messages for loader errors. ___
Houdini: add select invalid action for ValidateSopOutputNode #5231 This PR adds `SelectROPAction` action to `houdini\api\action.py`and it's used in `Validate Output Node``SelectROPAction` is used to select the associated ROPs with the errored instances. ___
Remove new lines from the delivery template string #5235 If the delivery template has a new line symbol at the end, say it was copied from the text editor, the delivery process will fail with `OSError` due to incorrect destination path. To avoid that I added `rstrip()` to the `delivery_path` processing. ___
Houdini: better selection on pointcache creation #5250 Houdini allows `ObjNode` path as `sop_path` in the `ROP` unlike OP/ Ayon require `sop_path` to be set to a sop node path explicitly In this code, better selection is used to filter out invalid selections from OP/ Ayon point of viewValid selections are - `SopNode` that has parent of type `geo` or `subnet` - `ObjNode` of type `geo` that has - `SopNode` of type `output` - `SopNode` with render flag `on` (if no `Sopnode` of type `output`)this effectively filter - empty `ObjNode` - `ObjNode`(s) of other types like `cam` and `dopnet` - `SopNode`(s) that thier parents of other types like `cam` and `sop solver` ___
Update scene inventory even if any errors occurred during update #5252 When selecting many items in the scene inventory to update versions and one of the items would error out the updating stops. However, before this PR the scene inventory would also NOT refresh making you think it did nothing.Also implemented as method to allow some code deduplication. ___
### **🐛 Bug fixes**
Maya: Convert frame values to integers #5188 Convert frame values to integers. ___
Maya: fix the register_event_callback correctly collecting workfile save after #5214 fixing the bug of register_event_callback not being able to collect action of "workfile_save_after" for lock file action ___
Maya: aligning default settings to distributed aces 1.2 config #5233 Maya colorspace setttings defaults are set the way they align our distributed ACES 1.2 config file set in global colorspace configs. ___
RepairAction and SelectInvalidAction filter instances failed on the exact plugin #5240 RepairAction and SelectInvalidAction actually filter to instances that failed on the exact plugin - not on "any failure" ___
Maya: Bugfix look update nodes by id with non-unique shape names (query with `fullPath`) #5257 Fixes a bug where updating attributes on nodes with assigned shader if shape name existed more than once in the scene due to `cmds.listRelatives` call not being done with the `fullPath=True` flag.Original error: ```python # Traceback (most recent call last): # File "E:\openpype\OpenPype\openpype\tools\sceneinventory\view.py", line 264, in # lambda: self._show_version_dialog(items)) # File "E:\openpype\OpenPype\openpype\tools\sceneinventory\view.py", line 722, in _show_version_dialog # self._update_containers(items, version) # File "E:\openpype\OpenPype\openpype\tools\sceneinventory\view.py", line 849, in _update_containers # update_container(item, item_version) # File "E:\openpype\OpenPype\openpype\pipeline\load\utils.py", line 502, in update_container # return loader.update(container, new_representation) # File "E:\openpype\OpenPype\openpype\hosts\maya\plugins\load\load_look.py", line 119, in update # nodes_by_id[lib.get_id(n)].append(n) # File "E:\openpype\OpenPype\openpype\hosts\maya\api\lib.py", line 1420, in get_id # sel.add(node) ``` ___
Nuke: Create nodes with inpanel=False #5051 This PR is meant to remove the annoyance of the UI changing focus to the properties window just for the property window of the newly created node to disappear. Instead of using node.hideControlPanel I'm implementing the concealment during the creation of the node which will not change the focus of the current window. ___
Fix the reset frame range not setting up the right timeline in Max #5187 Resolve #5181 ___
Resolve: after launch automatization fixes #5193 Workfile is no correctly created and aligned witch actual project. Also the launching mechanism is now fixed so even no workfile had been saved yet it will open OpenPype menu automatically. ___
General: Revert backward incompatible change of path to template to multiplatform #5197 Now platformity is still handed by usage of `work[root]` (or any other root that is accessible across platforms.) ___
Nuke: root set format updating in node graph #5198 Nuke root node needs to be reset on some values so any knobs could be updated in node graph. This works the same way as an user would change frame number so expressions would update its values in knobs. ___
Hiero: fixing otio current project and cosmetics #5200 Otio were not returning correct current project once additional Untitled project was open in project manager stack. ___
Max: Publisher instances dont hold its enabled disabled states when Publisher reopened again #5202 Resolve #5183, general maxscript conversion issue to python (e.g. bool conversion, true in maxscript while True in Python)(Also resolve the ValueError when you change the subset to publish into list view menu) ___
Burnins: Filter script is defined only for video streams #5205 Burnins are working for inputs with audio. ___
Colorspace lib fix compatible python version comparison #5212 Fix python version comparison. ___
Houdini: Fix `get_color_management_preferences` #5217 Fix the issue described here where the logic for retrieving the current OCIO display and view was incorrectly trying to apply a regex to it. ___
Houdini: Redshift ROP image format bug #5218 Problem : "RS_outputFileFormat" parm value was missing and there were more "image_format" than redshift rop supports Fix: 1) removed unnecessary formats from `image_format_enum` 2) add the selected format value to `RS_outputFileFormat` ___
Colorspace: check PyOpenColorIO rather then python version #5223 Fixing previously merged PR (https://github.com/ynput/OpenPype/pull/5212) And applying better way to check compatibility with PyOpenColorIO python api. ___
Validate delivery action representations status #5228 - disable delivery button if no representations checked - fix macos combobox layout - add error message if no delivery templates found ___
Houdini: Add geometry check for pointcache family #5230 When `sop_path` on ABC ROP node points to a non `SopNode`, these validators `validate_abc_primitive_to_detail.py`, `validate_primitive_hierarchy_paths.py` will error and crash when this line is executed `geo = output_node.geometryAtFrame(frame)` ___
Houdini: Add geometry check for VDB family #5232 When `sop_path` on Geometry ROP node points to a non SopNode, this validator `validate_vdb_output_node.py` will error and crash when this line is executed`sop_node.geometryAtFrame(frame)` ___
Substance Painter: Include the setting only in publish tab #5234 Instead of having two settings in both create and publish tab, there is solely one setting in the publish tab for users to set up the parameters.Resolve #5172 ___
Maya: Fix collecting arnold prefix when none #5243 When no prefix is specified in render settings, the renderlayer collector would error. ___
Deadline: OPENPYPE_VERSION should only be added when running from build #5244 When running from source the environment variable `OPENPYPE_VERSION` should not be added. This is a bugfix for the feature #4489 ___
Fix no prompt for "unsaved changes" showing when opening workfile in Houdini #5246 Fix no prompt for "unsaved changes" showing when opening workfile in Houdini. ___
Fix no prompt for "unsaved changes" showing when opening workfile in Substance Painter #5248 Fix no prompt for "unsaved changes" showing when opening workfile in Substance Painter. ___
General: add the os library before os.environ.get #5249 Adding os library into `creator_plugins.py` due to `os.environ.get` in line 667 ___
Maya: Fix set_attribute for enum attributes #5261 Fix for #5260 ___
Unreal: Move Qt imports away from module init #5268 Importing `Window` creates errors in headless mode. ``` *** WRN: >>> { ModulesLoader }: [ FAILED to import host folder unreal ] ============================= No Qt bindings could be found ============================= Traceback (most recent call last): File "C:\Users\tokejepsen\OpenPype\.venv\lib\site-packages\qtpy\__init__.py", line 252, in from PySide6 import __version__ as PYSIDE_VERSION # analysis:ignore ModuleNotFoundERROR: No module named 'PySide6' During handling of the above exception, another exception occurred: Traceback (most recent call last): File "C:\Users\tokejepsen\OpenPype\openpype\modules\base.py", line 385, in _load_modules default_module = __import__( File "C:\Users\tokejepsen\OpenPype\openpype\hosts\unreal\__init__.py", line 1, in from .addon import UnrealAddon File "C:\Users\tokejepsen\OpenPype\openpype\hosts\unreal\addon.py", line 4, in from openpype.widgets.message_window import Window File "C:\Users\tokejepsen\OpenPype\openpype\widgets\__init__.py", line 1, in from .password_dialog import PasswordDialog File "C:\Users\tokejepsen\OpenPype\openpype\widgets\password_dialog.py", line 1, in from qtpy import QtWidgets, QtCore, QtGui File "C:\Users\tokejepsen\OpenPype\.venv\lib\site-packages\qtpy\__init__.py", line 259, in raise QtBindingsNotFoundERROR() qtpy.QtBindingsNotFoundERROR: No Qt bindings could be found ``` ___
### **🔀 Refactored code**
Maya: Minor refactoring and code cleanup #5226 Some small cleanup and refactoring of logic. Removing old comments, unused imports and some minor optimization. Also removed the prints of the loader names of each container the scene in `fix_incompatible_containers` + optimizing by using `set` and defining only once. Moved some UI related code/tweaks to run `on_init` only if not in headless mode. Removed an empty `obj.py` file.Each commit message kind of describes why the change was made. ___
### **Merged pull requests**
Bug: Template builder fails when loading data without outliner representation #5222 I add an assertion management in case the container does not have a represention in outliner. ___
AfterEffects - add container check validator to AE settings #5203 Adds check if scene contains only latest version of loaded containers. ___
## [3.15.11](https://github.com/ynput/OpenPype/tree/3.15.11) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.10...3.15.11) ### **🆕 New features**
Ftrack: Task status during publishing #5123 Added option to change task status during publishing for 3 possible cases: "sending to farm", "local integration" and "on farm integration". ___
Nuke: Allow for more complex temp rendering paths #5132 When changing the temporary rendering template (i.e., add `{asset}` to the path) to something a bit more complex the formatting was erroring due to missing keys. ___
Blender: Add support for custom path for app templates #5137 This PR adds support for a custom App Templates path in Blender by setting the `BLENDER_USER_SCRIPTS` environment variable to the path specified in `OPENPYPE_APP_TEMPLATES_PATH`. This allows users to use their own custom app templates in Blender. ___
TrayPublisher & StandalonePublisher: Specify version #5142 Simple creators in TrayPublisher can affect which version will be integrated. Standalone publisher respects the version change from UI. ___
### **🚀 Enhancements**
Workfile Builder UI: Workfile builder window is not modal #5131 Workfile Templates Builder: - Create dialog is not a modal dialog - Create dialog remains open after create, so you can directly create a new placeholder with similar settings - In Maya allow to create root level placeholders (no selection during create) - **this felt more like a bugfix than anything else.** ___
3dsmax: Use custom modifiers to hold instance members #4931 Moving logic to handle members of publishing instance from children/parent relationship on Container to tracking via custom attribute on modifier. This eliminates limitations where you couldn't have one node multiple times under one Container and because it stores those relationships as weak references, they are easily transferable even when original nodes are renamed. ___
Add height, width and fps setup to project manager #5075 Add Width, Height, FPS, Pixel Aspect and Frame Start/End to the Project creation dialogue in the Project Manager.I understand that the Project manager will be replaced in the upcoming Ayon, but for the time being I believe setting new project with these options available would be more fun. ___
Nuke: connect custom write node script to the OP setting #5113 Allows user to customize the values of knobs attribute in the OP setting and use it in custom write node ___
Keep `publisher.create_widget` variant when creating subsets #5119 Whenever a person is creating a subset to publish, the "creator" widget resets (where you choose the variant, product, etc.) so if the person is publishing several images of the a variant which is not the default one, they have to keep selecting the correct one after every "create". This commit resets the original variant upon successful creation of a subset for publishing. Demo: [Screencast from 2023-06-08 10-46-40.webm](https://github.com/ynput/OpenPype/assets/1800151/ca1c91d4-b8f3-43d2-a7b7-35987f5b6a3f) ## Testing notes: 1. Launch AYON/OP 2. Launch the publisher (select a project, shot, etc.) 3. Crete a publish type (any works) 4. Choose a variant for the publish that is not the default 5. "Create >>" The Variant fields should still have the variant you choose. ___
Color Management- added color management support for simple expected files on Deadline #5122 Running of `ExtractOIIOTranscode` during Deadline publish was previously implemented only on DCCs with AOVs (Maya, Max).This PR extends this for other DCCs with flat structure of expected files. ___
hide macos dock icon on build #5133 Set `LSUIElement` to `1` in the `Info.plist` to hide OP icon from the macos dock by default. ___
Pack project: Raise exception with reasonable message #5145 Pack project crashes with relevant message when destination directory is not set. ___
Allow "inventory" actions to be supplied by a Module/Addon. #5146 Adds "inventory" as a possible key to the plugin paths to be returned from a module. ___
3dsmax: make code compatible with 3dsmax 2022 #5164 Python 3.7 in 3dsmax 2022 is not supporting walrus operator. This is removing it from the code for the sake of compatibility ___
### **🐛 Bug fixes**
Maya: Support same attribute names on different node types. #5054 When validating render settings attributes, support same attribute names on different node types. ___
Maya: bug fix the standin being not loaded when they are first loaded #5143 fix the bug of raising error when the first two standins are loaded through the loaderThe bug mentioned in the related issue: https://github.com/ynput/OpenPype/issues/5129For some reason, `defaultArnoldRenderOptions.operator` is not listed in the connection node attribute even if `cmds.loadPlugin("mtoa", quiet=True)` executed before loading the object as standins for the first time.But if you manually turn on mtoa through plugin preference and load the standins for the first time, it won't raise the related `defaultArnoldRenderOptions.operator` error. ___
Maya: bug fix arnoldExportAss unable to export selected set members #5150 See #5108 fix the bug arnoldExportAss being not able to export and error out during extraction. ___
Maya: Xgen multiple descriptions on single shape - OP-6039 #5160 When having multiple descriptions on the same geometry, the extraction would produce redundant duplicate geometries. ___
Maya: Xgen export of Abc's during Render Publishing - OP-6206 #5167 Shading assignments was missing duplicating the setup for Xgen publishing and the exporting of patches was getting the end frame incorrectly. ___
Maya: Include handles - OP-6236 #5175 Render range was missing the handles. ___
OCIO: Support working with single frame renders #5053 When there is only 1 file, the datamember `files` on the representation should be a string. ___
Burnins: Refactored burnins script #5094 Refactored list value for burnins and fixed command length limit by using temp file for filters string. ___
Nuke: open_file function can open autosave script #5107 Fix the bug of the workfile dialog being unable to open autosave nuke script ___
ImageIO: Minor fixes #5147 Resolve few minor fixes related to latest image io changes from PR. ___
Publisher: Fix save shortcut #5148 Save shortcut should work for both PySide2 and PySide6. ___
Pack Project: Fix files packing #5154 Packing of project with files does work again. ___
Maya: Xgen version mismatch after publish - OP-6204 #5161 Xgen was not updating correctly when for example adding or removing descriptions. This resolve the issue by overwritting the workspace xgen file. ___
Publisher: Edge case fixes #5165 Fix few edge case issues that may cause issues in Publisher UI. ___
Colorspace: host config path backward compatibility #5166 Old project settings overrides are now fully backward compatible. The issue with host config paths overrides were solved and now once a project used to be set to ocio_config **enabled** with found filepaths - this is now considered as activated host ocio_config paths overrides.Nuke is having an popup dialogue which is letting know to a user that settings for config path were changed. ___
Maya: import workfile missing - OP-6233 #5174 Missing `workfile` family to import. ___
Ftrack: Fix ignore sync filter #5176 Ftrack ignore filter does not crash because of dictionary modifications during it's iteration. ___
Webpublisher - headless publish shouldn't be blocking operation #5177 `subprocess.call` was blocking, which resulted in UI non responsiveness as it was waiting for publish to finish. ___
Publisher: Fix disappearing actions #5184 Pyblish plugin actions are visible as expected. ___
### **Merged pull requests**
Enhancement:animation family loaded as standing (abc) uses "use file sequence" #5110 The changes are the following. We started by updating the the is_sequence(files) function allowing it to return True for a list of files which has only one file, since our animation in this provides just one alembic file. For the correct FPS number, we got the fps from the published ass/abc from the version data. ___
add label to matching family #5128 I added the possibility to filter the `family smart select` with the label in addition to the family. ___
## [3.15.10](https://github.com/ynput/OpenPype/tree/3.15.10) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.9...3.15.10) ### **🆕 New features**
ImageIO: Adding ImageIO activation toggle to all hosts #4700 Colorspace management can now be enabled at the project level, although it is disabled by default. Once enabled, all hosts will use the OCIO config file defined in the settings. If settings are disabled, the system switches to DCC's native color space management, and we do not store colorspace information at the representative level. ___
Redshift Proxy Support in 3dsMax #4625 Redshift Proxy Support for 3dsMax. - [x] Creator - [x] Loader - [x] Extractor - [x] Validator - [x] Add documentation ___
Houdini farm publishing and rendering #4825 Deadline Farm publishing and Rendering for Houdini - [x] Mantra - [x] Karma(including usd renders) - [x] Arnold - [x] Elaborate Redshift ROP for deadline submission - [x] fix the existing bug in Redshift ROP - [x] Vray - [x] add docs ___
Feature: Blender hook to execute python scripts at launch #4905 Hook to allow hooks to add path to a python script that will be executed when Blender starts. ___
Feature: Resolve: Open last workfile on launch through .scriptlib #5047 Added implementation to Resolve integration to open last workfile on launch. ___
General: Remove default windowFlags from publisher #5089 The default windowFlags is making the publisher window (in Linux at least) only show the close button and it's frustrating as many times you just want to minimize the window and get back to the validation after. Removing that line I get what I'd expect.**Before:****After:** ___
General: Show user who created the workfile on the details pane of workfile manager #5093 New PR for https://github.com/ynput/OpenPype/pull/5087, which was closed after merging `next-minor` branch and then realizing we don't need to target it as it was decided it's not required to support windows. More info on that PR discussion.Small addition to add name of the `user` who created the workfile on the details pane of the workfile manager: ___
Loader: Hide inactive versions in UI #5100 Hide versions with `active` set to `False` in Loader UI. ___
### **🚀 Enhancements**
Maya: Repair RenderPass token when merging AOVs. #5055 Validator was flagging that `` was in the image prefix, but did not repair the issue. ___
Maya: Improve error feedback when no renderable cameras exist for ASS family. #5092 When collecting cameras for `ass` family, this improves the error message when no cameras are renderable. ___
Nuke: Custom script to set frame range of read nodes #5039 Adding option to set frame range specifically for the read nodes in Openpype Panel. User can set up their preferred frame range with the frame range dialog, which can be showed after clicking `Set Frame Range (Read Node)` in Openpype Tools ___
Update extract review letterbox docs #5074 Update Extract Review - Letter Box section in Docs. Letterbox type description is removed. ___
Project pack: Documents only skips roots validation #5082 Single roots validation is skipped if only documents are extracted. ___
Nuke: custom settings for write node without publish #5084 Set Render Output and other settings to write nodes for non-publish purposes. ___
### **🐛 Bug fixes**
Maya: Deadline servers #5052 Fix working with multiple Deadline servers in Maya. - Pools (primary and secondary) attributes were not recreated correctly. - Order of collector plugins were wrong, so collected data was not injected into render instances. - Server attribute was not converted to string so comparing with settings was incorrect. - Improve debug logging for where the webservice url is getting fetched from. ___
Maya: Fix Load Reference. #5091 Fix bug introduced with https://github.com/ynput/OpenPype/pull/4751 where `cmds.ls` returns a list. ___
3dsmax: Publishing Deadline jobs from RedShift #4960 Fix the bug of being uable to publish deadline jobs from RedshiftUse Current File instead of Published Scene for just Redshift. - add save scene before rendering to ensure the scene is saved after the modification. - add separated aov files option to allow users to choose to have aovs in render output - add validator for render publish to aovid overriding the previous renders ___
Houdini: Fix missing frame range for pointcache and camera exports #5026 Fix missing frame range for pointcache and camera exports on published version. ___
Global: collect_frame_fix plugin fix and cleanup #5064 Previous implementation https://github.com/ynput/OpenPype/pull/5036 was broken this is fixing the issue where attribute is found in instance data although the settings were disabled for the plugin. ___
Hiero: Fix apply settings Clip Load #5073 Changed `apply_settings` to classmethod which fixes the issue with settings. ___
Resolve: Make sure scripts dir exists #5078 Make sure the scripts directory exists before looping over it's content. ___
removing info knob from nuke creators #5083 - removing instance node if removed via publisher - removing info knob since it is not needed any more (was there only for the transition phase) ___
Tray: Fix restart arguments on update #5085 Fix arguments on restart. ___
Maya: bug fix on repair action in Arnold Scene Source CBID Validator #5096 Fix the bug of not being able to use repair action in Arnold Scene Source CBID Validator ___
Nuke: batch of small fixes #5103 - default settings for `imageio.requiredNodes` **CreateWriteImage** - default settings for **LoadImage** representations - **Create** and **Publish** menu items with `parent=main_window` (version > 14) ___
Deadline: make prerender check safer #5104 Prerender wasn't correctly recognized and was replaced with just 'render' family.In Nuke it is correctly `prerender.farm` in families, which wasn't handled here. It resulted into using `render` in templates even if `render` and `prerender` templates were split. ___
General: Sort launcher actions alphabetically #5106 The launcher actions weren't being sorted by its label but its name (which on the case of the apps it's the version number) and thus the order wasn't consistent and we kept getting a different order on every launch. From my debugging session, this was the result of what the `actions` variable held after the `filter_compatible_actions` function before these changes: ``` (Pdb) for p in actions: print(p.order, p.name) 0 14-02 0 14-02 0 14-02 0 14-02 0 14-02 0 19-5-493 0 2023 0 3-41 0 6-01 ```This caused already a couple bugs from our artists thinking they had launched Nuke X and instead launched Nuke and telling us their Nuke was missing nodes**Before:****After:** ___
TrayPublisher: Editorial video stream discovery #5120 Editorial create plugin in traypublisher does not expect that first stream in input is video. ___
### **🔀 Refactored code**
3dsmax: Move from deprecated interface #5117 `INewPublisher` interface is deprecated, this PR is changing the use to `IPublishHost` instead. ___
### **Merged pull requests**
add movalex as a contributor for code #5076 Adds @movalex as a contributor for code. This was requested by mkolar [in this comment](https://github.com/ynput/OpenPype/pull/4916#issuecomment-1571498425) [skip ci] ___
3dsmax: refactor load plugins #5079 ___
## [3.15.9](https://github.com/ynput/OpenPype/tree/3.15.9) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.8...3.15.9) ### **🆕 New features**
Blender: Implemented Loading of Alembic Camera #4990 Implemented loading of Alembic cameras in Blender. ___
Unreal: Implemented Creator, Loader and Extractor for Levels #5008 Creator, Loader and Extractor for Unreal Levels have been implemented. ___
### **🚀 Enhancements**
Blender: Added setting for base unit scale #4987 A setting for the base unit scale has been added for Blender.The unit scale is automatically applied when opening a file or creating a new one. ___
Unreal: Changed naming and path of Camera Levels #5010 The levels created for the camera in Unreal now include `_camera` in the name, to be better identifiable, and are placed in the camera folder. ___
Settings: Added option to nest settings templates #5022 It is possible to nest settings templates in another templates. ___
Enhancement/publisher: Remove "hit play to continue" label on continue #5029 Remove "hit play to continue" message on continue so that it doesn't show anymore when play was clicked. ___
Ftrack: Limit number of ftrack events to query at once #5033 Limit the amount of ftrack events received from mongo at once to 100. ___
General: Small code cleanups #5034 Small code cleanup and updates. ___
Global: collect frames to fix with settings #5036 Settings for `Collect Frames to Fix` will allow disable per project the plugin. Also `Rewriting latest version` attribute is hiddable from settings. ___
General: Publish plugin apply settings can expect only project settings #5037 Only project settings are passed to optional `apply_settings` method, if the method expects only one argument. ___
### **🐛 Bug fixes**
Maya: Load Assembly fix invalid imports #4859 Refactors imports so they are now correct. ___
Maya: Skipping rendersetup for members. #4973 When publishing a `rendersetup`, the objectset is and should be empty. ___
Maya: Validate Rig Output IDs #5016 Absolute names of node were not used, so plugin did not fetch the nodes properly.Also missed pymel command. ___
Deadline: escape rootless path in publish job #4910 If the publish path on Deadline job contains spaces or other characters, command was failing because the path wasn't properly escaped. This is fixing it. ___
General: Company name and URL changed #4974 The current records were obsolete in inno_setup, changed to the up-to-date. ___
Unreal: Fix usage of 'get_full_path' function #5014 This PR changes all the occurrences of `get_full_path` functions to alternatives to get the path of the objects. ___
Unreal: Fix sequence frames validator to use correct data #5021 Fix sequence frames validator to use clipIn and clipOut data instead of frameStart and frameEnd. ___
Unreal: Fix render instances collection to use correct data #5023 Fix render instances collection to use `frameStart` and `frameEnd` from the Project Manager, instead of the sequence's ones. ___
Resolve: loader is opening even if no timeline in project #5025 Loader is opening now even no timeline is available in a project. ___
nuke: callback for dirmapping is on demand #5030 Nuke was slowed down on processing due this callback. Since it is disabled by default it made sense to add it only on demand. ___
Publisher: UI works with instances without label #5032 Publisher UI does not crash if instance don't have filled 'label' key in instance data. ___
Publisher: Call explicitly prepared tab methods #5044 It is not possible to go to Create tab during publishing from OpenPype menu. ___
Ftrack: Role names are not case sensitive in ftrack event server status action #5058 Event server status action is not case sensitive for role names of user. ___
Publisher: Fix border widget #5063 Fixed border lines in Publisher UI to be painted correctly with correct indentation and size. ___
Unreal: Fix Commandlet Project and Permissions #5066 Fix problem when creating an Unreal Project when Commandlet Project is in a protected location. ___
Unreal: Added verification for Unreal app name format #5070 The Unreal app name is used to determine the Unreal version folder, so it is necessary that if follows the format `x-x`, where `x` is any integer. This PR adds a verification that the app name follows that format. ___
### **📃 Documentation**
Docs: Display wrong image in ExtractOIIOTranscode #5045 Wrong image display in `https://openpype.io/docs/project_settings/settings_project_global#extract-oiio-transcode`. ___
### **Merged pull requests**
Drop-down menu to list all families in create placeholder #4928 Currently in the create placeholder window, we need to write the family manually. This replace the text field by an enum field with all families for the current software. ___
add sync to specific projects or listen only #4919 Extend kitsu sync service with additional arguments to sync specific projects. ___
## [3.15.8](https://github.com/ynput/OpenPype/tree/3.15.8) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.7...3.15.8) ### **🆕 New features**
Publisher: Show instances in report page #4915 Show publish instances in report page. Also added basic log view with logs grouped by instance. Validation error detail now have 2 colums, one with erro details second with logs. Crashed state shows fast access to report action buttons. Success will show only logs. Publish frame is shrunked automatically on publish stop. ___
Fusion - Loader plugins updates #4920 Update to some Fusion loader plugins:The sequence loader can now load footage from the image and online family.The FBX loader can now import all formats Fusions FBX node can read.You can now import the content of another workfile into your current comp with the workfile loader. ___
Fusion: deadline farm rendering #4955 Enabling Fusion for deadline farm rendering. ___
AfterEffects: set frame range and resolution #4983 Frame information (frame start, duration, fps) and resolution (width and height) is applied to selected composition from Asset Management System (Ftrack or DB) automatically when published instance is created.It is also possible explicitly propagate both values from DB to selected composition by newly added menu buttons. ___
Publish: Enhance automated publish plugin settings #4986 Added plugins option to define settings category where to look for settings of a plugin and added public helper functions to apply settings `get_plugin_settings` and `apply_plugin_settings_automatically`. ___
### **🚀 Enhancements**
Load Rig References - Change Rig to Animation in Animation instance #4877 We are using the template builder to build an animation scene. All the rig placeholders are imported correctly, but the automatically created animation instances retain the rig family in their names and subsets. In our example, we need animationMain instead of rigMain, because this name will be used in the following steps like lighting.Here is the result we need. I checked, and it's not a template builder problem, because even if I load a rig as a reference, the result is the same. For me, since we are in the animation instance, it makes more sense to have animation instead of rig in the name. The naming is just fine if we use create from the Openpype menu. ___
Enhancement: Resolve prelaunch code refactoring and update defaults #4916 The main reason of this PR is wrong default settings in `openpype/settings/defaults/system_settings/applications.json` for Resolve host. The `bin` folder should not be a part of the macos and Linux `RESOLVE_PYTHON3_PATH` variable.The rest of this PR is some code cleanups for Resolve prelaunch hook to simplify further development.Also added a .gitignore for vscode workspace files. ___
Unreal: 🚚 move Unreal plugin to separate repository #4980 To support Epic Marketplace have to move AYON Unreal integration plugins to separate repository. This is replacing current files with git submodule, so the change should be functionally without impact.New repository lives here: https://github.com/ynput/ayon-unreal-plugin ___
General: Lib code cleanup #5003 Small cleanup in lib files in openpype. ___
Allow to open with djv by extension instead of representation name #5004 Filter open in djv action by extension instead of representation. ___
DJV open action `extensions` as `set` #5005 Change `extensions` attribute to `set`. ___
Nuke: extract thumbnail with multiple reposition nodes #5011 Added support for multiple reposition nodes. ___
Enhancement: Improve logging levels and messages for artist facing publish reports #5018 Tweak the logging levels and messages to try and only show those logs that an artist should see and could understand. Move anything that's slightly more involved into a "debug" message instead. ___
### **🐛 Bug fixes**
Bugfix/frame variable fix #4978 Renamed variables to match OpenPype terminology to reduce confusion and add consistency. ___
Global: plugins cleanup plugin will leave beauty rendered files #4790 Attempt to mark more files to be cleaned up explicitly in intermediate `renders` folder in work area for farm jobs. ___
Fix: Download last workfile doesn't work if not already downloaded #4942 Some optimization condition is messing with the feature: if the published workfile is not already downloaded, it won't download it... ___
Unreal: Fix transform when loading layout to match existing assets #4972 Fixed transform when loading layout to match existing assets. ___
fix the bug of fbx loaders in Max #4977 bug fix of fbx loaders for not being able to parent to the CON instances while importing cameras(and models) which is published from other DCCs such as Maya. ___
AfterEffects: allow returning stub with not saved workfile #4984 Allows to use Workfile app to Save first empty workfile. ___
Blender: Fix Alembic loading #4985 Fixed problem occurring when trying to load an Alembic model in Blender. ___
Unreal: Addon Py2 compatibility #4994 Fixed Python 2 compatibility of unreal addon. ___
Nuke: fixed missing files key in representation #4999 Issue with missing keys once rendering target set to existing frames is fixed. Instance has to be evaluated in validation for missing files. ___
Unreal: Fix the frame range when loading camera #5002 The keyframes of the camera, when loaded, were not using the correct frame range. ___
Fusion: fixing frame range targeting #5013 Frame range targeting at Rendering instances is now following configured options. ___
Deadline: fix selection from multiple webservices #5015 Multiple different DL webservice could be configured. First they must by configured in System Settings., then they could be configured per project in `project_settings/deadline/deadline_servers`.Only single webservice could be a target of publish though. ___
### **Merged pull requests**
3dsmax: Refactored publish plugins to use proper implementation of pymxs #4988 ___
## [3.15.7](https://github.com/ynput/OpenPype/tree/3.15.7) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.6...3.15.7) ### **🆕 New features**
Addons directory #4893 This adds a directory for Addons, for easier distribution of studio specific code. ___
Kitsu - Add "image", "online" and "plate" to review families #4923 This PR adds "image", "online" and "plate" to the review families so they also can be uploaded to Kitsu.It also adds the `Add review to Kitsu` tag to the default png review. Without it the user would manually need to add it for single image uploads to Kitsu and might confuse users (it confused me first for a while as movies did work). ___
Feature/remove and load inv action #4930 Added the ability to remove and load a container, as a way to reset it.This can be useful in cases where a container breaks in a way that can be fixed by removing it, then reloading it.Also added the ability to add `InventoryAction` plugins by placing them in `openpype/plugins/inventory`. ___
### **🚀 Enhancements**
Load Rig References - Change Rig to Animation in Animation instance #4877 We are using the template builder to build an animation scene. All the rig placeholders are imported correctly, but the automatically created animation instances retain the rig family in their names and subsets. In our example, we need animationMain instead of rigMain, because this name will be used in the following steps like lighting.Here is the result we need. I checked, and it's not a template builder problem, because even if I load a rig as a reference, the result is the same. For me, since we are in the animation instance, it makes more sense to have animation instead of rig in the name. The naming is just fine if we use create from the Openpype menu. ___
Maya template builder - preserve all references when importing a template #4797 When building a template with Maya template builder, we import the template and also the references inside the template file. This causes some problems: - We cannot use the references to version assets imported by the template. - When we import the file, the internal reference files are also imported. As a side effect, Maya complains about a reference that no longer exists.`// Error: file: /xxx/maya/2023.3/linux/scripts/AETemplates/AEtransformRelated.mel line 58: Reference node 'turntable_mayaSceneMain_01_RN' is not associated with a reference file.` ___
Unreal: Renaming the integration plugin to Ayon. #4646 Renamed the .h, and .cpp files to Ayon. Also renamed the classes to with the Ayon keyword. ___
3dsMax: render dialogue needs to be closed #4729 Make sure the render setup dialog is in a closed state for the update of resolution and other render settings ___
Maya Template Builder - Remove default cameras from renderable cameras #4815 When we build an asset workfile with build workfile from template inside Maya, we load our turntable camera. But then we end up with 2 renderables camera : **persp** the one imported from the template.We need to remove the **persp** camera (or any other default camera) from renderable cameras when building the work file. ___
Validators for Frame Range in Max #4914 Switch Render Frame Range Type to 3 for specific ranges (initial setup for the range type is 4)Reset Frame Range will also set the frame range for render settingsRender Collector won't take the frame range from context data but take the range directly from render settingAdd validators for render frame range type and frame range respectively with repair action ___
Fusion: Saver creator settings #4943 Adding Saver creator settings and enhanced rendering path with template. ___
General: Project Anatomy on creators #4962 Anatomy object of current project is available on `CreateContext` and create plugins. ___
### **🐛 Bug fixes**
Maya: Validate shader name - OP-5903 #4971 Running the plugin would error with: ``` // TypeError: 'str' object cannot be interpreted as an integer ```Fixed and added setting `active`. ___
Houdini: Fix slow Houdini launch due to shelves generation #4829 Shelf generation during Houdini startup would add an insane amount of delay for the Houdini UI to launch correctly. By deferring the shelf generation this takes away the 5+ minutes of delay for the Houdini UI to launch. ___
Fusion - Fixed "optional validation" #4912 Added OptionalPyblishPluginMixin and is_active checks for all publish tools that should be optional ___
Bug: add missing `pyblish.util` import #4937 remote publishing was missing import of `remote_publish`. This is adding it back. ___
Unreal: Fix missing 'object_path' property #4938 Epic removed the `object_path` property from `AssetData`. This PR fixes usages of that property.Fixes #4936 ___
Remove obsolete global validator #4939 Removing `Validate Sequence Frames` validator from global plugins as it wasn't handling correctly many things and was by mistake enabled, breaking functionality on Deadline. ___
General: fix build_workfile get_linked_assets missing project_name arg #4940 Linked assets collection don't work within `build_workfile` because `get_linked_assets` function call has a missing `project_name`argument. - Added the `project_name` arg to the `get_linked_assets` function call. ___
General: fix Scene Inventory switch version error dialog missing parent arg on init #4941 QuickFix for the switch version error dialog to set inventory widget as parent. ___
Unreal: Fix camera frame range #4956 Fix the frame range of the level sequence for the Camera in Unreal. ___
Unreal: Fix missing parameter when updating Alembic StaticMesh #4957 Fix an error when updating an Alembic StaticMesh in Unreal, due to a missing parameter in a function call. ___
Unreal: Fix render extraction #4963 Fix a problem with the extraction of renders in Unreal. ___
Unreal: Remove Python 3.8 syntax from addon #4965 Removed Python 3.8 syntax from addon. ___
Ftrack: Fix editorial task creation #4966 Fix key assignment on instance data during editorial publishing in ftrack hierarchy integration. ___
### **Merged pull requests**
Add "shortcut" to Scripts Menu Definition #4927 Add the possibility to associate a shorcut for an entry in the script menu definition with the key "shortcut" ___
## [3.15.6](https://github.com/ynput/OpenPype/tree/3.15.6) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.5...3.15.6) ### **🆕 New features**
Substance Painter Integration #4283 This implements a part of #4205 by implementing a Substance Painter integration Status: - [x] Implement Host - [x] start substance with last workfile using `AddLastWorkfileToLaunchArgs` prelaunch hook - [x] Implement Qt tools - [x] Implement loaders - [x] Implemented a Set project mesh loader (this is relatively special case because a Project will always have exactly one mesh - a Substance Painter project cannot exist without a mesh). - [x] Implement project open callback - [x] On project open it notifies the user if the loaded model is outdated - [x] Implement publishing logic - [x] Workfile publishing - [x] Export Texture Sets - [x] Support OCIO using #4195 (draft brach is set up - see comment) - [ ] Likely needs more testing on the OCIO front - [x] Validate all outputs of the Export template are exported/generated - [x] Allow validation to be optional **(issue: there's no API method to detect what maps will be exported without doing an actual export to disk)** - [x] Support extracting/integration if not all outputs are generated - [x] Support multiple materials/texture sets per instance - [ ] Add validator that can enforce only a single texture set output if studio prefers that. - [ ] Implement Export File Format (extensions) override in Creator - [ ] Add settings so Admin can choose which extensions are available. ___
Data Exchange: Geometry in 3dsMax #4555 Introduces and updates a creator, extractors and loaders for model family Introduces new creator, extractors and loaders for model family while adding model families into the existing max scene loader and extractor - [x] creators - [x] adding model family into max scene loader and extractor - [x] fbx loader - [x] fbx extractor - [x] usd loader - [x] usd extractor - [x] validator for model family - [x] obj loader(update function) - [x] fix the update function of the loader as #4675 - [x] Add documentation ___
AfterEffects: add review flag to each instance #4884 Adds `mark_for_review` flag to the Creator to allow artists to disable review if necessary.Exposed this flag in Settings, by default set to True (eg. same behavior as previously). ___
### **🚀 Enhancements**
Houdini: Fix Validate Output Node (VDB) #4819 - Removes plug-in that was a duplicate of this plug-in. - Optimize logging of many prims slightly - Fix error reporting like https://github.com/ynput/OpenPype/pull/4818 did ___
Houdini: Add null node as output indicator when using TAB search #4834 ___
Houdini: Don't error in collect review if camera is not set correctly #4874 Do not raise an error in collector when invalid path is set as camera path. Allow camera path to not be set correctly in review instance until validation so it's nicely shown in a validation report. ___
Project packager: Backup and restore can store only database #4879 Pack project functionality have option to zip only project database without project files. Unpack project can skip project copy if the folder is not found.Added helper functions to `openpype.client.mongo` that can be also used for tests as replacement of mongo dump. ___
Houdini: ExtractOpenGL for Review instance not optional #4881 Don't make ExtractOpenGL optional for review instance optional. ___
Publisher: Small style changes #4894 Small changes in styles and form of publisher UI. ___
Houdini: Workfile icon in new publisher #4898 Fix icon for the workfile instance in new publisher ___
Fusion: Simplify creator icons code #4899 Simplify code for setting the icons for the Fusion creators ___
Enhancement: Fix PySide 6.5 support for loader #4900 Fixes PySide 6.5 support in Loader. ___
### **🐛 Bug fixes**
Maya: Validate Attributes #4917 This plugin was broken due to bad fetching of data and wrong repair action. ___
Fix: Locally copied version of last published workfile is not incremented #4722 ### Fix 1 When copied, the local workfile version keeps the published version number, when it must be +1 to follow OP's naming convention. ### Fix 2 Local workfile version's name is built from anatomy. This avoids to get workfiles with their publish template naming. ### Fix 3 In the case a subset has at least two tasks with published workfiles, for example `Modeling` and `Rigging`, launching `Rigging` was getting the first one with the `next` and trying to find representations, therefore `workfileModeling` and trying to match the current `task_name` (`Rigging`) with the `representation["context"]["task"]["name"]` of a Modeling representation, which was ending up to a `workfile_representation` to `None`, and exiting the process. Trying to find the `task_name` in the `subset['name']` fixes it. ### Fix 4 Fetch input dependencies of workfile. Replacing https://github.com/ynput/OpenPype/pull/4102 for changes to bring this home. ___
Maya: soft-fail when pan/zoom locked on camera when playblasting #4929 When pan/zoom enabled attribute on camera is locked, playblasting with pan/zoom fails because it is trying to restore it. This is fixing it by skipping over with warning. ___
### **Merged pull requests**
Maya Load References - Add Display Handle Setting #4904 When we load a reference in Maya using OpenPype loader, display handle is checked by default and prevent us to select easily the object in the viewport. I understand that some productions like to keep this option, so I propose to add display handle to the reference loader settings. ___
Photoshop: add autocreators for review and flat image #4871 Review and flatten image (produced when no instance of `image` family was created) were created somehow magically. This PRintroduces two new auto creators which allow artists to disable review or flatten image.For all `image` instances `Review` flag was added to provide functionality to create separate review per `image` instance. Previously was possible only to have separate instance of `review` family.Review is not enabled on `image` family by default. (Eg. follows original behavior)Review auto creator is enabled by default as it was before.Flatten image creator must be set in Settings in `project_settings/photoshop/create/AutoImageCreator`. ___
## [3.15.5](https://github.com/ynput/OpenPype/tree/3.15.5) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.4...3.15.5) ### **🚀 Enhancements**
Maya: Playblast profiles #4777 Support playblast profiles.This enables studios to customize what playblast settings should be on a per task and/or subset basis. For example `modeling` should have `Wireframe On Shaded` enabled, while all other tasks should have it disabled. ___
Maya: Support .abc files directly for Arnold standin look assignment #4856 If `.abc` file is loaded into arnold standin support look assignment through the `cbId` attributes in the alembic file. ___
Maya: Hide animation instance in creator #4872 - Hide animation instance in creator - Add inventory action to recreate animation publish instance for loaded rigs ___
Unreal: Render Creator enhancements #4477 Improvements to the creator for render family This PR introduces some enhancements to the creator for the render family in Unreal Engine: - Added the option to create a new, empty sequence for the render. - Added the option to not include the whole hierarchy for the selected sequence. - Improvements of the error messages. ___
Unreal: Added settings for rendering #4575 Added settings for rendering in Unreal Engine. Two settings has been added: - Pre roll frames, to set how many frames are used to load the scene before starting the actual rendering. - Configuration path, to allow to save a preset of settings from Unreal, and use it for rendering. ___
Global: Optimize anatomy formatting by only formatting used templates instead #4784 Optimization to not format full anatomy when only a single template is used. Instead format only the single template instead. ___
Patchelf version locked #4853 For Centos dockerfile it is necessary to lock the patchelf version to the older, otherwise the build process fails. ___
Houdini: Implement `switch` method on loaders #4866 Implement `switch` method on loaders ___
Code: Tweak docstrings and return type hints #4875 Tweak docstrings and return type hints for functions in `openpype.client.entities`. ___
Publisher: Clear comment on successful publish and on window close #4885 Clear comment text field on successful publish and on window close. ___
Publisher: Make sure to reset asset widget when hidden and reshown #4886 Make sure to reset asset widget when hidden and reshown. Without this the asset list would never refresh in the set asset widget when changing context on an existing instance and thus would not show new assets from after the first time launching that widget. ___
### **🐛 Bug fixes**
Maya: Fix nested model instances. #4852 Fix nested model instance under review instance, where data collection was not including "Display Lights" and "Focal Length". ___
Maya: Make default namespace naming backwards compatible #4873 Namespaces of loaded references are now _by default_ back to what they were before #4511 ___
Nuke: Legacy convertor skips deprecation warnings #4846 Nuke legacy convertor was triggering deprecated function which is causing a lot of logs which slows down whole process. Changed the convertor to skip all nodes without `AVALON_TAB` to avoid the warnings. ___
3dsmax: move startup script logic to hook #4849 Startup script for OpenPype was interfering with Open Last Workfile feature. Moving this loggic from simple command line argument in the Settings to pre-launch hook is solving the order of command line arguments and making both features work. ___
Maya: Don't change time slider ranges in `get_frame_range` #4858 Don't change time slider ranges in `get_frame_range` ___
Maya: Looks - calculate hash for tx texture #4878 Texture hash is calculated for textures used in published look and it is used as key in dictionary. In recent changes, this hash is not calculated for TX files, resulting in `None` value as key in dictionary, crashing publishing. This PR is adding texture hash for TX files to solve that issue. ___
Houdini: Collect `currentFile` context data separate from workfile instance #4883 Fix publishing without an active workfile instance due to missing `currentFile` data.Now collect `currentFile` into context in houdini through context plugin no matter the active instances. ___
Nuke: fixed broken slate workflow once published on deadline #4887 Slate workflow is now working as expected and Validate Sequence Frames is not raising the once slate frame is included. ___
Add fps as instance.data in collect review in Houdini. #4888 fix the bug of failing to publish extract review in HoudiniOriginal error: ```python File "OpenPype\build\exe.win-amd64-3.9\openpype\plugins\publish\extract_review.py", line 516, in prepare_temp_data "fps": float(instance.data["fps"]), KeyError: 'fps' ``` ___
TrayPublisher: Fill missing data for instances with review #4891 Fill required data to instance in traypublisher if instance has review family. The data are required by ExtractReview and it would be complicated to do proper fix at this moment! The collector does for review instances what did https://github.com/ynput/OpenPype/pull/4383 ___
Publisher: Keep track about current context and fix context selection widget #4892 Change selected context to current context on reset. Fix bug when context widget is re-enabled. ___
Scene inventory: Model refresh fix with cherry picking #4895 Fix cherry pick issue in scene inventory. ___
Nuke: Pre-render and missing review flag on instance causing crash #4897 If instance created in nuke was missing `review` flag, collector crashed. ___
### **Merged pull requests**
After Effects: fix handles KeyError #4727 Sometimes when publishing with AE (we only saw this error on AE 2023), we got a KeyError for the handles in the "Collect Workfile" step. So I did get the handles from the context if ther's no handles in the asset entity. ___
## [3.15.4](https://github.com/ynput/OpenPype/tree/3.15.4) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.3...3.15.4) ### **🆕 New features**
Maya: Cant assign shaders to the ass file - OP-4859 #4460 Support AiStandIn nodes for look assignment. Using operators we assign shaders and attribute/parameters to nodes within standins. Initially there is only support for a limited mount of attributes but we can add support as needed; ``` primaryVisibility castsShadows receiveShadows aiSelfShadows aiOpaque aiMatte aiVisibleInDiffuseTransmission aiVisibleInSpecularTransmission aiVisibleInVolume aiVisibleInDiffuseReflection aiVisibleInSpecularReflection aiSubdivUvSmoothing aiDispHeight aiDispPadding aiDispZeroValue aiStepSize aiVolumePadding aiSubdivType aiSubdivIterations ``` ___
Maya: GPU cache representation #4649 Implement GPU cache for model, animation and pointcache. ___
Houdini: Implement review family with opengl node #3839 Implements a first pass for Reviews publishing in Houdini. Resolves #2720 Uses the `opengl` ROP node to produce PNG images. ___
Maya: Camera focal length visible in review - OP-3278 #4531 Camera focal length visible in review. Support camera focal length in review; static and dynamic.Resolves #3220 ___
Maya: Defining plugins to load on Maya start - OP-4994 #4714 Feature to define plugins to load on Maya launch. ___
Nuke, DL: Returning Suspended Publishing attribute #4715 Old Nuke Publisher's feature for suspended publishing job on render farm was added back to the current Publisher. ___
Settings UI: Allow setting a size hint for text fields #4821 Text entity have `minimum_lines_count` which allows to change minimum size hint of UI input. ___
TrayPublisher: Move 'BatchMovieCreator' settings to 'create' subcategory #4827 Moved settings for `BatchMoviewCreator` into subcategory `create` in settings. Changes are made to match other hosts settings chema and structure. ___
### **🚀 Enhancements**
Maya looks: support for native Redshift texture format #2971 Add support for native Redshift textures handling. Closes #2599 Uses Redshift's Texture Processor executable to convert textures being used in renders to the Redshift ".rstexbin" format. ___
Maya: custom namespace for references #4511 Adding an option in Project Settings > Maya > Loader plugins to set custom namespace. If no namespace is set, the default one is used. ___
Maya: Set correct framerange with handles on file opening #4664 Set the range of playback from the asset data, counting handles, to get the correct data when calling the "collect_animation_data" function. ___
Maya: Fix camera update #4751 Fix resetting any modelPanel to a different camera when loading a camera and updating. ___
Maya: Remove single assembly validation for animation instances #4840 Rig groups may now be parented to others groups when `includeParentHierarchy` attribute on the instance is "off". ___
Maya: Optional control of display lights on playblast. #4145 Optional control of display lights on playblast. Giving control to what display lights are on the playblasts. ___
Kitsu: note family requirements #4551 Allowing to add family requirements to `IntegrateKitsuNote` task status change. Adds a `Family requirements` setting to `Integrate Kitsu Note`, so you can add requirements to determine if kitsu task status should be changed based on which families are published or not. For instance you could have the status change only if another subset than workfile is published (but workfile can still be included) by adding an item set to `Not equal` and `workfile`. ___
Deactivate closed Kitsu projects on OP #4619 Deactivate project on OP when the project is closed on Kitsu. ___
Maya: Suggestion to change capture labels. #4691 Change capture labels. ___
Houdini: Change node type for OpenPypeContext `null` -> `subnet` #4745 Change the node type for OpenPype's hidden context node in Houdini from `null` to `subnet`. This fixes #4734 ___
General: Extract burnin hosts filters #4749 Removed hosts filter from ExtractBurnin plugin. Instance without representations won't cause crash but just skip the instance. We've discovered because Blender already has review but did not create burnins. ___
Global: Improve speed of Collect Custom Staging Directory #4768 Improve speed of Collect Custom Staging Directory. ___
General: Anatomy templates formatting #4773 Added option to format only single template from anatomy instead of formatting all of them all the time. Formatting of all templates is causing slowdowns e.g. during publishing of hundreds of instances. ___
Harmony: Handle zip files with deeper structure #4782 External Harmony zip files might contain one additional level with scene name. ___
Unreal: Use common logic to configure executable #4788 Unreal Editor location and version was autodetected. This easied configuration in some cases but was not flexible enought. This PR is changing the way Unreal Editor location is set, unifying it with the logic other hosts are using. ___
Github: Grammar tweaks + uppercase issue title #4813 Tweak some of the grammar in the issue form templates. ___
Houdini: Allow creation of publish instances via Houdini TAB menu #4831 Register the available Creator's as houdini tools so an artist can add publish instances via the Houdini TAB node search menu from within the network editor. ___
### **🐛 Bug fixes**
Maya: Fix Collect Render for V-Ray, Redshift and Renderman for missing colorspace #4650 Fix Collect Render not working for Redshift, V-Ray and Renderman due to missing `colorspace` argument to `RenderProduct` dataclass. ___
Maya: Xgen fixes #4707 Fix for Xgen extraction of world parented nodes and validation for required namespace. ___
Maya: Fix extract review and thumbnail for Maya 2020 #4744 Fix playblasting in Maya 2020 with override viewport options enabled. Fixes #4730. ___
Maya: local variable 'arnold_standins' referenced before assignment - OP-5542 #4778 MayaLookAssigner erroring when MTOA is not loaded: ``` # Traceback (most recent call last): # File "\openpype\hosts\maya\tools\mayalookassigner\app.py", line 272, in on_process_selected # nodes = list(set(item["nodes"]).difference(arnold_standins)) # UnboundLocalError: local variable 'arnold_standins' referenced before assignment ``` ___
Maya: Fix getting view and display in Maya 2020 - OP-5035 #4795 The `view_transform` returns a different format in Maya 2020. Fixes #4540 (hopefully). ___
Maya: Fix Look Maya 2020 Py2 support for Extract Look #4808 Fix Extract Look supporting python 2.7 for Maya 2020. ___
Maya: Fix Validate Mesh Overlapping UVs plugin #4816 Fix typo in the code where a maya command returns a `list` instead of `str`. ___
Maya: Fix tile rendering with Vray - OP-5566 #4832 Fixes tile rendering with Vray. ___
Deadline: checking existing frames fails when there is number in file name #4698 Previous implementation of validator failed on files with any other number in rendered file names.Used regular expression pattern now handles numbers in the file names (eg "Main_beauty.v001.1001.exr", "Main_beauty_v001.1001.exr", "Main_beauty.1001.1001.exr") but not numbers behind frames (eg. "Main_beauty.1001.v001.exr") ___
Maya: Validate Render Settings. #4735 Fixes error message when using attribute validation. ___
General: Hero version sites recalculation #4737 Sites recalculation in integrate hero version did expect that it is integrated exactly same amount of files as in previous integration. This is not the case in many cases, so the sites recalculation happens in a different way, first are prepared all sites from previous representation files, and all of them are added to each file in new representation. ___
Houdini: Fix collect current file #4739 Fixes the Workfile publishing getting added into every instance being published from Houdini ___
Global: Fix Extract Burnin + Colorspace functions for conflicting python environments with PYTHONHOME #4740 This fixes the running of openpype processes from e.g. a host with conflicting python versions that had `PYTHONHOME` said additionally to `PYTHONPATH`, like e.g. Houdini Py3.7 together with OpenPype Py3.9 when using Extract Burnin for a review in #3839This fix applies to Extract Burnin and some of the colorspace functions that use `run_openpype_process` ___
Harmony: render what is in timeline in Harmony locally #4741 Previously it wasn't possible to render according to what was set in Timeline in scene start/end, just by what it was set in whole timeline.This allows artist to override what is in DB with what they require (with disabled `Validate Scene Settings`). Now artist can extend scene by additional frames, that shouldn't be rendered, but which might be desired.Removed explicit set scene settings (eg. applying frames and resolution directly to the scene after launch), added separate menu item to allow artist to do it themselves. ___
Maya: Extract Review settings add Use Background Gradient #4747 Add Display Gradient Background toggle in settings to fix support for setting flat background color for reviews. ___
Nuke: publisher is offering review on write families on demand #4755 Original idea where reviewable toggle will be offered in publisher on demand is fixed and now `review` attribute can be disabled in settings. ___
Workfiles: keep Browse always enabled #4766 Browse might make sense even if there are no workfiles present, actually in that case it makes the most sense (eg. I want to locate workfile from outside - from Desktop for example). ___
Global: label key in instance data is optional #4779 Collect OTIO review plugin is not crashing if `label` key is missing in instance data. ___
Loader: Fix missing variable #4781 There is missing variable `handles` in loader tool after https://github.com/ynput/OpenPype/pull/4746. The variable was renamed to `handles_label` and is initialized to `None` if handles are not available. ___
Nuke: Workfile Template builder fixes #4783 Popup window after Nuke start is not showing. Knobs with X/Y coordination on nodes where were converted from placeholders are not added if `keepPlaceholders` is witched off. ___
Maya: Add family filter 'review' to burnin profile with focal length #4791 Avoid profile burnin with `focalLength` key for renders, but use only for playblast reviews. ___
add farm instance to the render collector in 3dsMax #4794 bug fix for the failure of submitting publish job in 3dsmax ___
Publisher: Plugin active attribute is respected #4798 Publisher consider plugin's `active` attribute, so the plugin is not processed when `active` is set to `False`. But we use the attribute in `OptionalPyblishPluginMixin` for different purposes, so I've added hack bypass of the active state validation when plugin inherit from the mixin. This is temporary solution which cannot be changed until all hosts use Publisher otherwise global plugins would be broken. Also plugins which have `enabled` set to `False` are filtered out -> this happened only when automated settings were applied and the settings contained `"enabled"` key se to `False`. ___
Nuke: settings and optional attribute in publisher for some validators #4811 New publisher is supporting optional switch for plugins which is offered in Publisher in Right panel. Some plugins were missing this switch and also settings which would offer the optionality. ___
Settings: Version settings popup fix #4822 Version completer popup have issues on some platforms, this should fix those edge cases. Also fixed issue when completer stayed shown fater reset (save). ___
Hiero/Nuke: adding monitorOut key to settings #4826 New versions of Hiero were introduced with new colorspace property for Monitor Out. It have been added into project settings. Also added new config names into settings enumerator option. ___
Nuke: removed default workfile template builder preset #4835 Default for workfile template builder should have been empty. ___
TVPaint: Review can be made from any instance #4843 Add `"review"` tag to output of extract sequence if instance is marked for review. At this moment only instances with family `"review"` were able to define input for `ExtractReview` plugin which is not right. ___
### **🔀 Refactored code**
Deadline: Remove unused FramesPerTask job info submission #4657 Remove unused `FramesPerTask` job info submission to Deadline. ___
Maya: Remove pymel dependency #4724 Refactors code written using `pymel` to use standard maya python libraries instead like `maya.cmds` or `maya.api.OpenMaya` ___
Remove "preview" data from representation #4759 Remove "preview" data from representation ___
Maya: Collect Review cleanup code for attached subsets #4720 Refactor some code for Maya: Collect Review for attached subsets. ___
Refactor: Remove `handles`, `edit_in` and `edit_out` backwards compatibility #4746 Removes backward compatibiliy fallback for data called `handles`, `edit_in` and `edit_out`. ___
### **📃 Documentation**
Bump webpack from 5.69.1 to 5.76.1 in /website #4624 Bumps [webpack](https://github.com/webpack/webpack) from 5.69.1 to 5.76.1.
Release notes

Sourced from webpack's releases.

v5.76.1

Fixed

  • Added assert/strict built-in to NodeTargetPlugin

Revert

v5.76.0

Bugfixes

Features

Security

Repo Changes

New Contributors

Full Changelog: https://github.com/webpack/webpack/compare/v5.75.0...v5.76.0

v5.75.0

Bugfixes

  • experiments.* normalize to false when opt-out
  • avoid NaN%
  • show the correct error when using a conflicting chunk name in code
  • HMR code tests existance of window before trying to access it
  • fix eval-nosources-* actually exclude sources
  • fix race condition where no module is returned from processing module
  • fix position of standalong semicolon in runtime code

Features

  • add support for @import to extenal CSS when using experimental CSS in node

... (truncated)

Commits
Maintainer changes

This version was pushed to npm by evilebottnawi, a new releaser for webpack since your current version.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=webpack&package-manager=npm_and_yarn&previous-version=5.69.1&new-version=5.76.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) - `@dependabot use these labels` will set the current labels as the default for future PRs for this repo and language - `@dependabot use these reviewers` will set the current reviewers as the default for future PRs for this repo and language - `@dependabot use these assignees` will set the current assignees as the default for future PRs for this repo and language - `@dependabot use this milestone` will set the current milestone as the default for future PRs for this repo and language You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/ynput/OpenPype/network/alerts).
___
Documentation: Add Extract Burnin documentation #4765 Add documentation for Extract Burnin global plugin settings. ___
Documentation: Move publisher related tips to publisher area #4772 Move publisher related tips for After Effects artist documentation to the correct position. ___
Documentation: Add extra terminology to the key concepts glossary #4838 Tweak some of the key concepts in the documentation. ___
### **Merged pull requests**
Maya: Refactor Extract Look with dedicated processors for maketx #4711 Refactor Maya extract look to fix some issues: - [x] Allow Extraction with maketx with OCIO Color Management enabled in Maya. - [x] Fix file hashing so it includes arguments to maketx, so that when arguments change it correctly generates a new hash - [x] Fix maketx destination colorspace when OCIO is enabled - [x] Use pre-collected colorspaces of the resources instead of trying to retrieve again in Extract Look - [x] Fix colorspace attributes being reinterpreted by maya on export (fix remapping) - goal is to resolve #2337 - [x] Fix support for checking config path of maya default OCIO config (due to using `lib.get_color_management_preferences` which remaps that path) - [x] Merged in #2971 to refactor MakeTX into TextureProcessor and also support generating Redshift `.rstexbin` files. - goal is to resolve #2599 - [x] Allow custom arguments to `maketx` from OpenPype Settings like mentioned here by @fabiaserra for arguments like: `--monochrome-detect`, `--opaque-detect`, `--checknan`. - [x] Actually fix the code and make it work. :) (I'll try to keep below checkboxes in sync with my code changes) - [x] Publishing without texture processor should work (no maketx + no rstexbin) - [x] Publishing with maketx should work - [x] Publishing with rstexbin should work - [x] Test it. (This is just me doing some test-runs, please still test the PR!) ___
Maya template builder load all assets linked to the shot #4761 Problem All the assets of the ftrack project are loaded and not those linked to the shot How get error Open maya in the context of shot, then build a new scene with the "Build Workfile from template" button in "OpenPype" menu. ![image](https://user-images.githubusercontent.com/7068597/229124652-573a23d7-a2b2-4d50-81bf-7592c00d24dc.png) ___
Global: Do not force instance data with frame ranges of the asset #4383 This aims to resolve #4317 ___
Cosmetics: Fix some grammar in docstrings and messages (and some code) #4752 Tweak some grammar in codebase ___
Deadline: Submit publish job fails due root work hardcode - OP-5528 #4775 Generating config templates was hardcoded to `root[work]`. This PR fixes that. ___
CreateContext: Added option to remove Unknown attributes #4776 Added option to remove attributes with UnkownAttrDef on instances. Pop of key will also remove the attribute definition from attribute values, so they're not recreated again. ___
## [3.15.3](https://github.com/ynput/OpenPype/tree/3.15.3) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.2...3.15.3) ### **🆕 New features**
Blender: Extract Review #3616 Added Review to Blender. This implementation is based on #3508 but made compatible for the current implementation of OpenPype for Blender. ___
Data Exchanges: Point Cloud for 3dsMax #4532 Publish PRT format with tyFlow in 3dsmax Publish PRT format with tyFlow in 3dsmax and possibly set up loader to load the format too. - [x] creator - [x] extractor - [x] validator - [x] loader ___
Global: persistent staging directory for renders #4583 Allows configure if staging directory (`stagingDir`) should be persistent with use of profiles. With this feature, users can specify a transient data folder path based on presets, which can be used during the creation and publishing stages. In some cases, these DCCs automatically add a rendering path during the creation stage, which is then used in publishing.One of the key advantages of this feature is that it allows users to take advantage of faster storages for rendering, which can help improve workflow efficiency. Additionally, this feature allows users to keep their rendered data persistent, and use their own infrastructure for regular cleaning.However, it should be noted that some productions may want to use this feature without persistency. Furthermore, there may be a need for retargeting the rendering folder to faster storages, which is also not supported at the moment.It is studio responsibility to clean up obsolete folders with data.Location of the folder is configured in `project_anatomy/templates/others`. ('transient' key is expected, with 'folder' key, could be more templates)Which family/task type/subset is applicable is configured in:`project_settings/global/tools/publish/transient_dir_profiles` ___
Kitsu custom comment template #4599 Kitsu allows to write markdown in its comment field. This can be something very powerful to deliver dynamic comments with the help the data from the instance.This feature is defaults to off so the admin have to manually set up the comment field the way they want.I have added a basic example on how the comment can look like as the comment-fields default value.To this I want to add some documentation also but that's on its way when the code itself looks good for the reviewers. ___
MaxScene Family #4615 Introduction of the Max Scene Family ___
### **🚀 Enhancements**
Maya: Multiple values on single render attribute - OP-4131 #4631 When validating render attributes, this adds support for multiple values. When repairing first value in list is used. ___
Maya: enable 2D Pan/Zoom for playblasts - OP-5213 #4687 Setting for enabling 2D Pan/Zoom on reviews. ___
Copy existing or generate new Fusion profile on prelaunch #4572 Fusion preferences will be copied to the predefined `~/.openpype/hosts/fusion/prefs` folder (or any other folder set in system settings) on launch. The idea is to create a copy of existing Fusion profile, adding an OpenPype menu to the Fusion instance.By default the copy setting is turned off, so no file copying is performed. Instead the clean Fusion profile is created by Fusion in the predefined folder. The default locaion is set to `~/.openpype/hosts/fusion/prefs`, to better comply with the other os platforms. After creating the default profile, some modifications are applied: - forced Python3 - forced English interface - setup Openpype specific path maps.If the `copy_prefs` checkbox is toggled, a copy of existing Fusion profile folder will be placed in the mentioned location. Then they are altered the same way as described above. The operation is run only once, on the first launch, unless the `force_sync [Resync profile on each launch]` is toggled.English interface is forced because the `FUSION16_PROFILE_DIR` environment variable is not read otherwise (seems to be a Fusion bug). ___
Houdini: Create button open new publisher's "create" tab #4601 During a talk with @maxpareschi he mentioned that the new publisher in Houdini felt super confusing due to "Create" going to the older creator but now being completely empty and the publish button directly went to the publish tab.This resolves that by fixing the Create button to now open the new publisher but on the Create tab.Also made publish button enforce going to the "publish" tab for consistency in usage.@antirotor I think changing the Create button's callback was just missed in this commit or was there a specific reason to not change that around yet? ___
Clockify: refresh and fix the integration #4607 Due to recent API changes, Clockify requires `user_id` to operate with the timers. I updated this part and currently it is a WIP for making it fully functional. Most functions, such as start and stop timer, and projects sync are currently working. For the rate limiting task new dependency is added: https://pypi.org/project/ratelimiter/ ___
Fusion publish existing frames #4611 This PR adds the function to publish existing frames instead of having to re-render all of them for each new publish.I have split the render_locally plugin so the review-part is its own plugin now.I also change the saver-creator-plugin's label from Saver to Render (saver) as I intend to add a Prerender creator like in Nuke. ___
Resolution settings referenced from DB record for 3dsMax #4652 - Add Callback for setting the resolution according to DB after the new scene is created. - Add a new Action into openpype menu which allows the user to reset the resolution in 3dsMax ___
3dsmax: render instance settings in Publish tab #4658 Allows user preset the pools, group and use_published settings in Render Creator in the Max Hosts.User can set the settings before or after creating instance in the new publisher ___
scene length setting referenced from DB record for 3dsMax #4665 Setting the timeline length based on DB record in 3dsMax Hosts ___
Publisher: Windows reduce command window pop-ups during Publishing #4672 Reduce the command line pop-ups that show on Windows during publishing. ___
Publisher: Explicit save #4676 Publisher have explicit button to save changes, so reset can happen without saving any changes. Save still happens automatically when publishing is started or on publisher window close. But a popup is shown if context of host has changed. Important context was enhanced by workfile path (if host integration supports it) so workfile changes are captured too. In that case a dialog with confirmation is shown to user. All callbacks that may require save of context were moved to main window to be able handle dialog show at one place. Save changes now returns success so the rest of logic is skipped -> publishing won't start, when save of instances fails.Save and reset buttons have shortcuts (Ctrl + s and Ctrls + r). ___
CelAction: conditional workfile parameters from settings #4677 Since some productions were requesting excluding some workfile parameters from publishing submission, we needed to move them to settings so those could be altered per project. ___
Improve logging of used app + tool envs on application launch #4682 Improve logging of what apps + tool environments got loaded for an application launch. ___
Fix name and docstring for Create Workdir Extra Folders prelaunch hook #4683 Fix class name and docstring for Create Workdir Extra Folders prelaunch hookThe class name and docstring were originally copied from another plug-in and didn't match the plug-in logic.This also fixes potentially seeing this twice in your logs. Before:After:Where it was actually running both this prelaunch hook and the actual `AddLastWorkfileToLaunchArgs` plugin. ___
Application launch context: Include app group name in logger #4684 Clarify in logs better what app group the ApplicationLaunchContext belongs to and what application is being launched.Before:After: ___
increment workfile version 3dsmax #4685 increment workfile version in 3dsmax as if in blender and maya hosts. ___
### **🐛 Bug fixes**
Maya: Fix getting non-active model panel. #2968 When capturing multiple cameras with image planes that have file sequences playing, only the active (first) camera will play through the file sequence. ___
Maya: Fix broken review publishing. #4549 Resolves #4547 ___
Maya: Avoid error on right click in Loader if `mtoa` is not loaded #4616 Fix an error on right clicking in the Loader when `mtoa` is not a loaded plug-in.Additionally if `mtoa` isn't loaded the loader will now load the plug-in before trying to create the arnold standin. ___
Maya: Fix extract look colorspace detection #4618 Fix the logic which guesses the colorspace using `arnold` python library. - Previously it'd error if `mtoa` was not available on path so it still required `mtoa` to be available. - The guessing colorspace logic doesn't actually require `mtoa` to be loaded, but just the `arnold` python library to be available. This changes the logic so it doesn't require the `mtoa` plugin to get loaded to guess the colorspace. - The if/else branch was likely not doing what was intended `cmds.loadPlugin("mtoa", quiet=True)` returns None if the plug-in was already loaded. So this would only ever be true if it ends up loading the `mtoa` plugin the first time. ```python # Tested in Maya 2022.1 print(cmds.loadPlugin("mtoa", quiet=True)) # ['mtoa'] print(cmds.loadPlugin("mtoa", quiet=True)) # None ``` ___
Maya: Maya Playblast Options overrides - OP-3847 #4634 When publishing a review in Maya, the extractor would fail due to wrong (long) panel name. ___
Bugfix/op 2834 fix extract playblast #4701 Paragraphs contain detailed information on the changes made to the product or service, providing an in-depth description of the updates and enhancements. They can be used to explain the reasoning behind the changes, or to highlight the importance of the new features. Paragraphs can often include links to further information or support documentation. ___
Bugfix/op 2834 fix extract playblast #4704 Paragraphs contain detailed information on the changes made to the product or service, providing an in-depth description of the updates and enhancements. They can be used to explain the reasoning behind the changes, or to highlight the importance of the new features. Paragraphs can often include links to further information or support documentation. ___
Maya: bug fix for passing zoom settings if review is attached to subset #4716 Fix for attaching review to subset with pan/zoom option. ___
Maya: tile assembly fail in draft - OP-4820 #4416 Tile assembly in Deadline was broken. Initial bug report revealed other areas of the tile assembly that needed fixing. ___
Maya: Yeti Validate Rig Input - OP-3454 #4554 Fix Yeti Validate Rig Input Existing workflow was broken due to this #3297. ___
Scene inventory: Fix code errors when "not found" entries are found #4594 Whenever a "NOT FOUND" entry is present a lot of errors happened in the Scene Inventory: - It started spamming a lot of errors for the VersionDelegate since it had no numeric version (no version at all).Error reported on Discord: ```python Traceback (most recent call last): File "C:\Users\videopro\Documents\github\OpenPype\openpype\tools\utils\delegates.py", line 65, in paint text = self.displayText( File "C:\Users\videopro\Documents\github\OpenPype\openpype\tools\utils\delegates.py", line 33, in displayText assert isinstance(value, numbers.Integral), ( AssertionError: Version is not integer. "None" ``` - Right click menu would error on NOT FOUND entries, and thus not show. With this PR it will now _disregard_ not found items for "Set version" and "Remove" but still allow actions.This PR resolves those. ___
Kitsu: Sync OP with zou, make sure value-data is int or float #4596 Currently the data zou pulls is a string and not a value causing some bugs in the pipe where a value is expected (like `Set frame range` in Fusion). This PR makes sure each value is set with int() or float() so these bugs can't happen later on. _(A request to cgwire has also bin sent to allow force values only for some metadata columns, but currently the user can enter what ever they want in there)_ ___
Max: fix the bug of removing an instance #4617 fix the bug of removing an instance in 3dsMax ___
Global | Nuke: fixing farm publishing workflow #4623 After Nuke had adopted new publisher with new creators new issues were introduced. Those issues were addressed with this PR. Those are for example broken reviewable video files publishing if published via farm. Also fixed local publishing. ___
Ftrack: Ftrack additional families filtering #4633 Ftrack family collector makes sure the subset family is also in instance families for additional families filtering. ___
Ftrack: Hierarchical <> Non-Hierarchical attributes sync fix #4635 Sync between hierarchical and non-hierarchical attributes should be fixed and work as expected. Action should sync the values as expected and event handler should do it too and only on newly created entities. ___
bugfix for 3dsmax publishing error #4637 fix the bug of failing publishing job in 3dsMax ___
General: Use right validation for ffmpeg executable #4640 Use ffmpeg exec validation for ffmpeg executables instead of oiio exec validation. The validation is used as last possible source of ffmpeg from `PATH` environment variables, which is an edge case but can cause issues. ___
3dsmax: opening last workfile #4644 Supports opening last saved workfile in 3dsmax host. ___
Fixed a bug where a QThread in the splash screen could be destroyed before finishing execution #4647 This should fix the occasional behavior of the QThread being destroyed before even its worker returns from the `run()` function.After quiting, it should wait for the QThread object to properly close itself. ___
General: Use right plugin class for Collect Comment #4653 Collect Comment plugin is instance plugin so should inherit from `InstancePlugin` instead of `ContextPlugin`. ___
Global: add tags field to thumbnail representation #4660 Thumbnail representation might be missing tags field. ___
Integrator: Enforce unique destination transfers, disallow overwrites in queued transfers #4662 Fix #4656 by enforcing unique destination transfers in the Integrator. It's now disallowed to a destination in the file transaction queue with a new source path during the publish. ___
Hiero: Creator with correct workfile numeric padding input #4666 Creator was showing 99 in workfile input for long time, even if users set default value to 1001 in studio settings. This has been fixed now. ___
Nuke: Nukenodes family instance without frame range #4669 No need to add frame range data into `nukenodes` (backdrop) family publishes - since those are timeless. ___
TVPaint: Optional Validation plugins can be de/activated by user #4674 Added `OptionalPyblishPluginMixin` to TVpaint plugins that can be optional. ___
Kitsu: Slightly less strict with instance data #4678 - Allow to take task name from context if asset doesn't have any. Fixes an issue with Photoshop's review instance not having `task` in data. - Allow to match "review" against both `instance.data["family"]` and `instance.data["families"]` because some instances don't have the primary family in families, e.g. in Photoshop and TVPaint. - Do not error on Integrate Kitsu Review whenever for whatever reason Integrate Kitsu Note did not created a comment but just log the message that it was unable to connect a review. ___
Publisher: Fix reset shortcut sequence #4694 Fix bug created in https://github.com/ynput/OpenPype/pull/4676 where key sequence is checked using unsupported method. The check was changed to convert event into `QKeySequence` object which can be compared to prepared sequence. ___
Refactor _capture #4702 Paragraphs contain detailed information on the changes made to the product or service, providing an in-depth description of the updates and enhancements. They can be used to explain the reasoning behind the changes, or to highlight the importance of the new features. Paragraphs can often include links to further information or support documentation. ___
Hiero: correct container colors if UpToDate #4708 Colors on loaded containers are now correctly identifying real state of version. `Red` for out of date and `green` for up to date. ___
### **🔀 Refactored code**
Look Assigner: Move Look Assigner tool since it's Maya only #4604 Fix #4357: Move Look Assigner tool to maya since it's Maya only ___
Maya: Remove unused functions from Extract Look #4671 Remove unused functions from Maya Extract Look plug-in ___
Extract Review code refactor #3930 Trying to reduce complexity of Extract Review plug-in - Re-use profile filtering from lib - Remove "combination families" additional filtering which supposedly was from OP v2 - Simplify 'formatting' for filling gaps - Use `legacy_io.Session` over `os.environ` ___
Maya: Replace last usages of Qt module #4610 Replace last usage of `Qt` module with `qtpy`. This change is needed for `PySide6` support. All changes happened in Maya loader plugins. ___
Update tests and documentation for `ColormanagedPyblishPluginMixin` #4612 Refactor `ExtractorColormanaged` to `ColormanagedPyblishPluginMixin` in tests and documentation. ___
Improve logging of used app + tool envs on application launch (minor tweak) #4686 Use `app.full_name` for change done in #4682 ___
### **📃 Documentation**
Docs/add architecture document #4344 Add `ARCHITECTURE.md` document. his document attemps to give a quick overview of the project to help onboarding, it's not an extensive documentation but more of a elevator pitch one-line descriptions of files/directories and what the attempt to do. ___
Documentation: Tweak grammar and fix some typos #4613 This resolves some grammar and typos in the documentation.Also fixes the extension of some images in after effects docs which used uppercase extension even though files were lowercase extension. ___
Docs: Fix some minor grammar/typos #4680 Typo/grammar fixes in documentation. ___
### **Merged pull requests**
Maya: Implement image file node loader #4313 Implements a loader for loading texture image into a `file` node in Maya. Similar to Maya's hypershade creation of textures on load you have the option to choose for three modes of creating: - Texture - Projection - StencilThese should match what Maya generates if you create those in Maya. - [x] Load and manage file nodes - [x] Apply color spaces after #4195 - [x] Support for _either_ UDIM or image sequence - currently it seems to always load sequences as UDIM automatically. - [ ] Add support for animation sequences of UDIM textures using the `..exr` path format? ___
Maya Look Assigner: Don't rely on containers for get all assets #4600 This resolves #4044 by not actually relying on containers in the scene but instead just rely on finding nodes with `cbId` attributes. As such, imported nodes would also be found and a shader can be assigned (similar to when using get from selection).**Please take into consideration the potential downsides below**Potential downsides would be: - IF an already loaded look has any dagNodes, say a 3D Projection node - then that will also show up as a loaded asset where previously nodes from loaded looks were ignored. - If any dag nodes were created locally - they would have gotten `cbId` attributes on scene save and thus the current asset would almost always show? ___
Maya: Unify menu labels for "Set Frame Range" and "Set Resolution" #4605 Fix #4109: Unify menu labels for "Set Frame Range" and "Set Resolution"This also tweaks it in Houdini from Reset Frame Range to Set Frame Range. ___
Resolve missing OPENPYPE_MONGO in deadline global job preload #4484 In the GlobalJobPreLoad plugin, we propose to replace the SpawnProcess by a sub-process and to pass the environment variables in the parameters, since the SpawnProcess under Centos Linux does not pass the environment variables. In the GlobalJobPreLoad plugin, the Deadline SpawnProcess is used to start the OpenPype process. The problem is that the SpawnProcess does not pass environment variables, including OPENPYPE_MONGO, to the process when it is under Centos7 linux, and the process gets stuck. We propose to replace it by a subprocess and to pass the variable in the parameters. ___
Tests: Added setup_only to tests #4591 Allows to download test zip, unzip and restore DB in preparation for new test. ___
Maya: Arnold don't reset maya timeline frame range on render creation (or setting render settings) #4603 Fix #4429: Do not reset fps or playback timeline on applying or creating render settings ___
Bump @sideway/formula from 3.0.0 to 3.0.1 in /website #4609 Bumps [@sideway/formula](https://github.com/sideway/formula) from 3.0.0 to 3.0.1.
Commits
Maintainer changes

This version was pushed to npm by marsup, a new releaser for @​sideway/formula since your current version.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@sideway/formula&package-manager=npm_and_yarn&previous-version=3.0.0&new-version=3.0.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) - `@dependabot use these labels` will set the current labels as the default for future PRs for this repo and language - `@dependabot use these reviewers` will set the current reviewers as the default for future PRs for this repo and language - `@dependabot use these assignees` will set the current assignees as the default for future PRs for this repo and language - `@dependabot use this milestone` will set the current milestone as the default for future PRs for this repo and language You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/ynput/OpenPype/network/alerts).
___
Update artist_hosts_maya_arnold.md #4626 Correct Arnold docs. ___
Maya: Add "Include Parent Hierarchy" option in animation creator plugin #4645 Add an option in Project Settings > Maya > Creator Plugins > Create Animation to include (or not) parent hierarchy. This is to avoid artists to check manually the option for all create animation. ___
General: Filter available applications #4667 Added option to filter applications that don't have valid executable available in settings in launcher and ftrack actions. This option can be disabled in new settings category `Applications`. The filtering is by default disabled. ___
3dsmax: make sure that startup script executes #4695 Fixing reliability of OpenPype startup in 3dsmax. ___
Project Manager: Change minimum frame start/end to '0' #4719 Project manager can have frame start/end set to `0`. ___
## [3.15.2](https://github.com/ynput/OpenPype/tree/3.15.2) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.1...3.15.2) ### **🆕 New features**
maya gltf texture convertor and validator #4261 Continuity of the gltf extractor implementation Continuity of the gltf extractor https://github.com/pypeclub/OpenPype/pull/4192UPDATE:**Validator for GLSL Shader**: Validate whether the mesh uses GLSL Shader. If not it will error out. The user can choose to perform the repair action and it will help to assign glsl shader. If the mesh with Stringray PBS, the repair action will also check to see if there is any linked texture such as Color, Occulsion, and Normal Map. If yes, it will help to relink the related textures to the glsl shader.*****If the mesh uses the PBS Shader, ___
Unreal: New Publisher #4370 Implementation of the new publisher for Unreal. The implementation of the new publisher for Unreal. This PR includes the changes for all the existing creators to be compatible with the new publisher.The basic creator has been split in two distinct creators: - `UnrealAssetCreator`, works with assets in the Content Browser. - `UnrealActorCreator` that works with actors in the scene. ___
Implementation of a new splash screen #4592 Implemented a new splash screen widget to reflect a process running in the background. This widget can be used for other tasks than UE. **Also fixed the compilation error of the AssetContainer.cpp when trying to build the plugin in UE 5.0** ___
Deadline for 3dsMax #4439 Setting up deadline for 3dsmax Setting up deadline for 3dsmax by setting render outputs and viewport camera ___
Nuke: adding nukeassist #4494 Adding support for NukeAssist For support of NukeAssist we had to limit some Nuke features since NukeAssist itself Nuke with limitations. We do not support Creator and Publisher. User can only Load versions with version control. User can also set Framerange and Colorspace. ___
### **🚀 Enhancements**
Maya: OP-2630 acescg maya #4340 Resolves #2712 ___
Default Ftrack Family on RenderLayer #4458 With default settings, renderlayers in Maya were not being tagged with the Ftrack family leading to confusion when doing reviews. ___
Maya: Maya Playblast Options - OP-3783 #4487 Replacement PR for #3912. Adds more options for playblasts to preferences/settings. Adds the following as options in generating playblasts, matching viewport settings. - Use default material - Wireframe on shaded - X-ray - X-ray Joints - X-ray active component ___
Maya: Passing custom attributes to alembic - OP-4111 #4516 Passing custom attributes to alembic This PR makes it possible to pass all user defined attributes along to the alembic representation. ___
Maya: Options for VrayProxy output - OP-2010 #4525 Options for output of VrayProxy. Client requested more granular control of output from VrayProxy instance. Exposed options on the instance and settings for vrmesh and alembic. ___
Maya: Validate missing instance attributes #4559 Validate missing instance attributes. New attributes can be introduced as new features come in. Old instances will need to be updated with these attributes for the documentation to make sense, and users do not have to recreate the instances. ___
Refactored Generation of UE Projects, installation of plugins moved to the engine #4369 Improved the way how OpenPype works with generation of UE projects. Also the installation of the plugin has been altered to install into the engine OpenPype now uses the appropriate tools to generate UE projects. Unreal Build Tool (UBT) and a "Commandlet Project" is needed to properly generate a BP project, or C++ code in case that `dev_mode = True`, folders, the .uproject file and many other resources.On the plugin's side, it is built seperately with the UnrealAutomationTool (UAT) and then it's contents are moved under the `Engine/Plugins/Marketplace/OpenPype` directory. ___
Unreal: Use client functions in Layout loader #4578 Use 'get_representations' instead of 'legacy_io' query in layout loader. This is removing usage of `find_one` called on `legacy_io` and use rather client functions as preparation for AYON connection. Also all representations are queried at once instead of one by one. ___
General: Support for extensions filtering in loaders #4492 Added extensions filtering support to loader plugins. To avoid possible backwards compatibility break is filtering exactly the same and filtering by extensions is enabled only if class attribute 'extensions' is set. ___
Nuke: multiple reformat in baking review profiles #4514 Added support for multiple reformat nodes in baking profiles. Old settings for single reformat node is supported and prioritised just in case studios are using it and backward compatibility is needed. Warnings in Nuke terminal are notifying users to switch settings to new workflow. Settings are also explaining the migration way. ___
Nuke: Add option to use new creating system in workfile template builder #4545 Nuke workfile template builder can use new creators instead of legacy creators. Modified workfile template builder to have option to say if legacy creators should be used or new creators. Legacy creators are disabled by default, so Maya has changed the value. ___
Global, Nuke: Workfile first version with template processing #4579 Supporting new template workfile builder with toggle for creation of first version of workfile in case there is none yet. ___
Fusion: New Publisher #4523 This is an updated PR for @BigRoy 's old PR (https://github.com/ynput/OpenPype/pull/3892).I have merged it with code from OP 3.15.1-nightly.6 and made sure it works as expected.This converts the old publishing system to the new one. It implements Fusion as a new host addon. - Create button removed in OpenPype menu in favor of the new Publisher - Draft refactor validations to raise PublishValidationError - Implement Creator for New Publisher - Implement Fusion as Host addon ___
TVPaint: Use Publisher tool #4471 Use Publisher tool and new creation system in TVPaint integration. Using new creation system makes TVPaint integration a little bit easier to maintain for artists. Removed unneeded tools Creator and Subset Manager tools. Goal is to keep the integration work as close as possible to previous integration. Some changes were made but primarilly because they were not right using previous system.All creators create instance with final family instead of changing the family during extraction. Render passes are not related to group id but to render layer instance. Render layer is still related to group. Workfile, review and scene render instances are created using autocreators instead of auto-collection during publishing. Subset names are fully filled during publishing but instance labels are filled on refresh with the last known right value. Implemented basic of legacy convertor which should convert render layers and render passes. ___
TVPaint: Auto-detect render creation #4496 Create plugin which will create Render Layer and Render Pass instances based on information in the scene. Added new creator that must be triggered by artist. The create plugin will first create Render Layer instances if were not created yet. For variant is used color group name. The creator has option to rename color groups by template defined in settings -> Template may use index of group by it's usage in scene (from bottom to top). After Render Layers will create Render Passes. Render Pass is created for each individual TVPaint layer in any group that had created Render Layer. It's name is used as variant (pass). ___
TVPaint: Small enhancements #4501 Small enhancements in TVPaint integration which did not get to https://github.com/ynput/OpenPype/pull/4471. It was found out that `opacity` returned from `tv_layerinfo` is always empty and is dangerous to add it to layer information. Added information about "current" layer to layers information. Disable review of Render Layer and Render Pass instances by default. In most of productions is used only "scene review". Skip usage of `"enabled"` key from settings in automated layer/pass creation. ___
Global: color v3 global oiio transcoder plugin #4291 Implements possibility to use `oiiotool` to transcode image sequences from one color space to another(s). Uses collected `colorspaceData` information about source color spaces, these information needs to be collected previously in each DCC interested in color management.Uses profiles configured in Settings to create single or multiple new representations (and file extensions) with different color spaces.New representations might replace existing one, each new representation might contain different tags and custom tags to control its integration step. ___
Deadline: Added support for multiple install dirs in Deadline #4451 SearchDirectoryList returns FIRST existing so if you would have multiple OP install dirs, it won't search for appropriate version in later ones. ___
Ftrack: Upload reviewables with original name #4483 Ftrack can integrate reviewables with original filenames. As ftrack have restrictions about names of components the only way how to achieve the result was to upload the same file twice, one with required name and one with origin name. ___
TVPaint: Ignore transparency in Render Pass #4499 It is possible to ignore layers transparency during Render Pass extraction. Render pass extraction does not respect opacity of TVPaint layers set in scene during extraction. It can be enabled/disabled in settings. ___
Anatomy: Preparation for different root overrides #4521 Prepare Anatomy to handle only 'studio' site override on it's own. Change how Anatomy fill root overrides based on requested site name. The logic which decide what is active site was moved to sync server addon and the same for receiving root overrides of local site. The Anatomy resolve only studio site overrides anything else is handled by sync server. BaseAnatomy only expect root overrides value and does not need site name. Validation of site name happens in sync server same as resolving if site name is local or not. ___
Nuke | Global: colormanaged plugin in collection #4556 Colormanaged extractor had changed to Mixin class so it can be added to any stage of publishing rather then just to Exctracting.Nuke is no collecting colorspaceData to representation collected on already rendered images. Mixin class can no be used as secondary parent in publishing plugins. ___
### **🐛 Bug fixes**
look publishing and srgb colorspace in maya #4276 Check the OCIO color management is enabled before doing linearize colorspace for converting the texture maps into tx files. Check whether the OCIO color management is enabled before the condition of converting the texture to tx extension. ___
Maya: extract Thumbnail "No active model panel found" - OP-3849 #4421 Error when extracting playblast with no model panel. If `project_settings/maya/publish/ExtractPlayblast/capture_preset/Viewport Options/override_viewport_options` were off and publishing without showing any model panel, the extraction would fail. ___
Maya: Fix setting scene fps with float input #4488 Returned value of float fps on integer values would return float. This PR fixes the case when switching between integer fps values for example 24 > 25. Issue was when setting the scene fps, the original float value was used which makes it unpredictable whether the value is float or integer when mapping the fps values. ___
Maya: Multipart fix #4497 Fix multipart logic in render products. Each renderer has a different way of defining whether output images is multipart, so we need to define it for each renderer. Also before the `multipart` class variable was defined multiple times in several places, which made it tricky to debug where `multipart` was defined. Now its created on initialization and referenced as `self.multipart` ___
Maya: Set pool on tile assembly - OP-2012 #4520 Set pool on tile assembly Pool for publishing and tiling jobs, need to use the settings (`project_settings/deadline/publish/ProcessSubmittedJobOnFarm/deadline_pool`) else fallback on primary pool (`project_settings/deadline/publish/CollectDeadlinePools/primary_pool`) ___
Maya: Extract review with handles #4527 Review was not extracting properly with/without handles. Review instance was not created properly resulting in the frame range on the instance including handles. ___
Maya: Fix broken lib. #4529 Fix broken lib. This commit from this PR broke the Maya lib module. ___
Maya: Validate model name - OP-4983 #4539 Validate model name issues. Couple of issues with validate model name; - missing platform extraction from settings - map function should be list comprehension - code cosmetics ___
Maya: SkeletalMesh family loadable as reference #4573 In Maya, fix the SkeletalMesh family not loadable as reference. ___
Unreal: fix loaders because of missing AssetContainer #4536 Fixing Unreal loaders, where changes in OpenPype Unreal integration plugin deleted AssetContainer. `AssetContainer` and `AssetContainerFactory` are still used to mark loaded instances. Because of optimizations in Integration plugin we've accidentally removed them but that broke loader. ___
3dsmax unable to delete loaded asset in the scene inventory #4507 Fix the bug of being unable to delete loaded asset in the Scene Inventory Fix the bug of being unable to delete loaded asset in the Scene Inventory ___
Hiero/Nuke: originalBasename editorial publishing and loading #4453 Publishing and loading `originalBasename` is working as expected Frame-ranges on version document is now correctly defined to fit original media frame range which is published. It means loading is now correctly identifying frame start and end on clip loader in Nuke. ___
Nuke: Fix workfile template placeholder creation #4512 Template placeholder creation was erroring out in Nuke due to the Workfile template builder not being able to find any of the plugins for the Nuke host. Move `get_workfile_build_placeholder_plugins` function to NukeHost class as workfile template builder expects. ___
Nuke: creator farm attributes from deadline submit plugin settings #4519 Defaults in farm attributes are sourced from settings. Settings for deadline nuke submitter are now used during nuke render and prerender creator plugins. ___
Nuke: fix clip sequence loading #4574 Nuke is loading correctly clip from image sequence created without "{originalBasename}" token in anatomy template. ___
Fusion: Fix files collection and small bug-fixes #4423 Fixed Fusion review-representation and small bug-fixes This fixes the problem with review-file generation that stopped the publishing on second publish before the fix.The problem was that Fusion simply looked at all the files in the render-folder instead of only gathering the needed frames for the review.Also includes a fix to get the handle start/end that before throw an error if the data didn't exist (like from a kitsu sync). ___
Fusion: Updated render_local.py to not only process the first instance #4522 Moved the `__hasRun` to `render_once()` so the check only happens with the rendering. Currently only the first render node gets the representations added.Critical PR ___
Fusion: Load sequence fix filepath resolving from representation #4580 Resolves issue mentioned on discord by @movalex:The loader was incorrectly trying to find the file in the publish folder which resulted in just picking 'any first file'. This gets the filepath from representation instead of taking the first file from listing files from publish folder. ___
Fusion: Fix review burnin start and end frame #4590 Fix the burnin start and end frame for reviews. Without this the asset document's start and end handle would've been added to the _burnin_ frame range even though that would've been incorrect since the handles are based on the comp saver's render range instead. ___
Harmony: missing set of frame range when opening scene #4485 Frame range gets set from DB everytime scene is opened. Added also check for not up-to-date loaded containers. ___
Photoshop: context is not changed in publisher #4570 When PS is already open and artists launch new task, it should keep only opened PS open, but change context. Problem were occurring in Workfile app where under new task files from old task were shown. This fixes this and adds opening of last workfile for new context if workfile exists. ___
hiero: fix effect item node class #4543 Collected effect name after renaming is saving correct class name. ___
Bugfix/OP-4616 vray multipart #4297 This fixes a bug where multipart vray renders would not make a review in Ftrack. ___
Maya: Fix changed location of reset_frame_range #4491 Location in commands caused cyclic import ___
global: source template fixed frame duplication #4503 Duplication is not happening. Template is using `originalBasename` which already assume all necessary elements are part of the file name so there was no need for additional optional name elements. ___
Deadline: Hint to use Python 3 #4518 Added shebank to give deadline hint which python should be used. Deadline has issues with Python 2 (especially with `os.scandir`). When a shebank is added to file header deadline will use python 3 mode instead of python 2 which fix the issue. ___
Publisher: Prevent access to create tab after publish start #4528 Prevent access to create tab after publish start. Disable create button in instance view on publish start and enable it again on reset. Even with that make sure that it is not possible to go to create tab if the tab is disabled. ___
Color Transcoding: store target_colorspace as new colorspace #4544 When transcoding into new colorspace, representation must carry this information instead original color space. ___
Deadline: fix submit_publish_job #4552 Fix submit_publish_job Resolves #4541 ___
Kitsu: Fix task itteration in update-op-with-zou #4577 From the last PR (https://github.com/ynput/OpenPype/pull/4425) a comment-commit last second messed up the code and resulted in two lines being the same, crashing the script. This PR fixes that. ___
AttrDefs: Fix type for PySide6 #4584 Use right type in signal emit for value change of attribute definitions. Changed `UUID` type to `str`. This is not an issue with PySide2 but it is with PySide6. ___
### **🔀 Refactored code**
Scene Inventory: Avoid using ObjectId #4524 Avoid using conversion to ObjectId type in scene inventory tool. Preparation for AYON compatibility where ObjectId won't be used for ids. Representation ids from loaded containers are not converted to ObjectId but kept as strings which also required some changes when working with representation documents. ___
### **Merged pull requests**
SiteSync: host dirmap is not working properly #4563 If artists uses SiteSync with real remote (gdrive, dropbox, sftp) drive, Local Settings were throwing error `string indices must be integers`. Logic was reworked to provide only `local_drive` values to be overrriden by Local Settings. If remote site is `gdrive` etc. mapping to `studio` is provided as it is expected that workfiles will have imported from `studio` location and not from `gdrive` folder.Also Nuke dirmap was reworked to be less verbose and much faster. ___
General: Input representation ids are not ObjectIds #4576 Don't use `ObjectId` as representation ids during publishing. Representation ids are kept as strings during publishing instead of converting them to `ObjectId`. This change is pre-requirement for AYON connection.Inputs are used for integration of links and for farm publishing (or at least it looks like). ___
Shotgrid: Fixes on Deadline submissions #4498 A few other bug fixes for getting Nuke submission to Deadline work smoothly using Shotgrid integration. Continuing on the work done on this other PR this fixes a few other bugs I came across with further tests. ___
Fusion: New Publisher #3892 This converts the old publishing system to the new one. It implements Fusion as a new host addon. - Create button removed in OpenPype menu in favor of the new Publisher - Draft refactor validations to raise `PublishValidationError` - Implement Creator for New Publisher - Implement Fusion as Host addon ___
Make Kitsu work with Tray Publisher, added kitsureview tag, fixed sync-problems. #4425 Make Kitsu work with Tray Publisher, added kitsureview tag, fixed sync-problems. This PR updates the way the module gather info for the current publish so it now works with Tray Publisher.It fixes the data that gets synced from Kitsu to OP so all needed data gets registered even if it doesn't exist on Kitsus side.It also adds the tag "Add review to Kitsu" and adds it to Burn In so previews gets generated by default to Kitsu. ___
Maya: V-Ray Set Image Format from settings #4566 Resolves #4565 Set V-Ray Image Format using settings. ___
## [3.15.1](https://github.com/ynput/OpenPype/tree/3.15.1) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.0...3.15.1) ### **🆕 New features**
Maya: Xgen (3d / maya ) - #4256 ___ #### Brief description Initial Xgen implementation. #### Description Client request of Xgen pipeline. ___
Data exchange cameras for 3d Studio Max (3d / 3dsmax ) - #4376 ___ #### Brief description Add Camera Family into the 3d Studio Max #### Description Adding Camera Extractors(extract abc camera and extract fbx camera) and validators(for camera contents) into 3dMaxAlso add the extractor for exporting 3d max raw scene (which is also related to 3dMax Scene Family) for camera family ___
### **🚀 Enhancements**
Adding path validator for non-maya nodes (3d / maya ) - #4271 ___ #### Brief description Adding a path validator for filepaths from non-maya nodes, which are created by plugins such as Renderman, Yeti and abcImport. #### Description As File Path Editor cannot catch the wrong filenpaths from non-maya nodes such as AlembicNodes, It is neccessary to have a new validator to ensure the existence of the filepaths from the nodes. ___
Deadline: Allow disabling strict error check in Maya submissions (3d / maya / deadline ) - #4420 ___ #### Brief description DL by default has Strict error checking, but some errors are not fatal. #### Description This allows to set profile based on Task and Subset values to temporarily disable Strict Error Checks.Subset and task names should support regular expressions. (not wildcard notation though). ___
Houdini: New publisher code tweak (3d / houdini ) - #4374 ___ #### Brief description This is cosmetics only - the previous code to me felt quite unreadable due to the lengthy strings being used. #### Description Code should do roughly the same, but just be reformatted. ___
3dsmax: enhance alembic loader update function (3d / 3dsmax ) - #4387 ___ ## Enhancement This PR is adding update/switch ability to pointcache/alembic loader in 3dsmax and fixing wrong tool shown when clicking on "Manage" item on OpenPype menu, that is now correctly Scene Inventory (but was Subset Manager). Alembic update has still one caveat - it doesn't cope with changed number of object inside alembic, since loading alembic in max involves creating all those objects as first class nodes. So it will keep the objects in scene, just update path to alembic file on them. ___
Global: supporting `OPENPYPE_TMPDIR` in staging dir maker (editorial / hiero ) - #4398 ___ #### Brief description Productions can use OPENPYPE_TMPDIR for staging temp publishing directory #### Description Studios were demanding to be able to configure their own shared storages as temporary staging directories. Template formatting is also supported with optional keys formatting and following anatomy keys: - root[work | ] - project[name | code] ___
General: Functions for current context (other ) - #4324 ___ #### Brief description Defined more functions to receive current context information and added the methods to host integration so host can affect the result. #### Description This is one of steps to reduce usage of `legacy_io.Session`. This change define how to receive current context information -> call functions instead of accessing `legacy_io.Session` or `os.environ` directly. Plus, direct access on session or environments is unfortunatelly not enough for some DCCs where multiple workfiles can be opened at one time which can heavily affect the context but host integration sometimes can't affect that at all.`HostBase` already had implemented `get_current_context`, that was enhanced by adding more specific methods `get_current_project_name`, `get_current_asset_name` and `get_current_task_name`. The same functions were added to `~/openpype/pipeline/cotext_tools.py`. The functions in context tools are calling host integration methods (if are available) otherwise are using environent variables as default implementation does. Also was added `get_current_host_name` to receive host name from registered host if is available or from environment variable. ___
Houdini: Do not visualize the hidden OpenPypeContext node (other / houdini ) - #4382 ___ #### Brief description Using the new publisher UI would generate a visible 'null' locator at the origin. It's confusing to the user since it's supposed to be 'hidden'. #### Description Before this PR the user would see a locator/null at the origin which was the 'hidden' `/obj/OpenPypeContext` node. This null would suddenly appear if the user would've ever opened the Publisher UI once.After this PR it will not show:Nice and tidy. ___
Maya + Blender: Pyblish plugins removed unused `version` and `category` attributes (other ) - #4402 ___ #### Brief description Once upon a time in a land far far away there lived a few plug-ins who felt like they didn't belong in generic boxes and felt they needed to be versioned well above others. They tried, but with no success. #### Description Even though they now lived in a universe with elaborate `version` and `category` attributes embedded into their tiny little plug-in DNA this particular deviation has been greatly unused. There is nothing special about the version, nothing special about the category.It does nothing. ___
General: Fix original basename frame issues (other ) - #4452 ___ #### Brief description Treat `{originalBasename}` in different way then standard files processing. In case template should use `{originalBasename}` the transfers will use them as they are without any changes or handling of frames. #### Description Frames handling is problematic with original basename because their padding can't be defined to match padding in source filenames. Also it limits the usage of functionality to "must have frame at end of fiename". This is proposal how that could be solved by simply ignoring frame handling and using filenames as are on representation. First frame is still stored to representation context but is not used in formatting part. This way we don't have to care about padding of frames at all. ___
Publisher: Report also crashed creators and convertors (other ) - #4473 ___ #### Brief description Added crashes of creators and convertos discovery (lazy solution). #### Description Report in Publisher also contains information about crashed files caused during creator plugin discovery and convertor plugin discovery. They're not separated into categroies and there is no other information in the report about them, but this helps a lot during development. This change does not need to change format/schema of the report nor UI logic. ___
### **🐛 Bug fixes**
Maya: Fix Validate Attributes plugin (3d / maya ) - #4401 ___ #### Brief description Code was broken. So either plug-in was unused or it had gone unnoticed. #### Description Looking at the commit history of the plug-in itself it seems this might have been broken somewhere between two to three years. I think it's broken since two years since this commit.Should this plug-in be removed completely?@tokejepsen Is there still a use case where we should have this plug-in? (You created the original one) ___
Maya: Ignore workfile lock in Untitled scene (3d / maya ) - #4414 ___ #### Brief description Skip workfile lock check if current scene is 'Untitled'. ___
Maya: fps rounding - OP-2549 (3d / maya ) - #4424 ___ #### Brief description When FPS is registered in for example Ftrack and round either down or up (floor/ceil), comparing to Maya FPS can fail. Example:23.97 (Ftrack/Mongo) != 23.976023976023978 (Maya) #### Description Since Maya only has a select number of supported framerates, I've taken the approach of converting any fps to supported framerates in Maya. We validate the input fps to make sure they are supported in Maya in two ways:Whole Numbers - are validated straight against the supported framerates in Maya.Demical Numbers - we find the closest supported framerate in Maya. If the difference to the closest supported framerate, is more than 0.5 we'll throw an error.If Maya ever supports arbitrary framerates, then we might have a problem but I'm not holding my breath... ___
Strict Error Checking Default (3d / maya ) - #4457 ___ #### Brief description Provide default of strict error checking for instances created prior to PR. ___
Create: Enhance instance & context changes (3d / houdini,after effects,3dsmax ) - #4375 ___ #### Brief description Changes of instances and context have complex, hard to get structure. The structure did not change but instead of complex dictionaries are used objected data. #### Description This is poposal of changes data improvement for creators. Implemented `TrackChangesItem` which handles the changes for us. The item is creating changes based on old and new value and can provide information about changed keys or access to full old or new value. Can give the values on any "sub-dictionary".Used this new approach to fix change in houdini and 3ds max and also modified one aftereffects plugin using changes. ___
Houdini: hotfix condition (3d / houdini ) - #4391 ___ ## Hotfix This is fixing bug introduced int #4374 ___
Houdini: Houdini shelf tools fixes (3d / houdini ) - #4428 ___ #### Brief description Fix Houdini shelf tools. #### Description Use `label` as mandatory key instead of `name`. Changed how shelves are created. If the script is empty it is gracefully skipping it instead of crashing. ___
3dsmax: startup fixes (3d / 3dsmax ) - #4412 ___ #### Brief description This is fixing various issues that can occur on some of the 3dsmax versions. #### Description On displays with +4K resolution UI was broken, some 3dsmax versions couldn't process `PYTHONPATH` correctly. This PR is forcing `sys.path` and disabling `QT_AUTO_SCREEN_SCALE_FACTOR` ___
Fix features for gizmo menu (2d / nuke ) - #4280 ___ #### Brief description Fix features for the Gizmo Menu project settings (shortcut for python type of usage and file type of usage functionality) ___
Photoshop: fix missing legacy io for legacy instances (2d / photoshop,after effects ) - #4467 ___ #### Brief description `legacy_io` import was removed, but usage stayed. #### Description Usage of `legacy_io` should be eradicated, in creators it should be replaced by `self.create_context.get_current_project_name/asset_name/task_name`. ___
Fix - addSite loader handles hero version (other / sitesync ) - #4359 ___ #### Brief description If adding site to representation presence of hero version is checked, if found hero version is marked to be donwloaded too.Replacing https://github.com/ynput/OpenPype/pull/4191 ___
Remove OIIO build for macos (other ) - #4381 ___ ## Fix Since we are not able to provide OpenImageIO tools binaries for macos, we should remove the item from th `pyproject.toml`. This PR is taking care of it. It is also changing the way `fetch_thirdparty_libs` script works in that it doesn't crash when lib cannot be processed, it only issue warning. Resolves #3858 ___
General: Attribute definitions fixes (other ) - #4392 ___ #### Brief description Fix possible issues with attribute definitions in publisher if there is unknown attribute on an instance. #### Description Source of the issue is that attribute definitions from creator plugin could be "expanded" during `CreatedInstance` initialization. Which would affect all other instances using the same list of attributes -> literally object of list. If the same list object is used in "BaseClass" for other creators it would affect all instances (because of 1 instance). There had to be implemented other changes to fix the issue and keep behavior the same.Object of `CreatedInstance` can be created without reference to creator object. `CreatedInstance` is responsible to give UI attribute definitions (technically is prepared for cases when each instance may have different attribute definitions -> not yet).Attribute definition has added more conditions for `__eq__` method and have implemented `__ne__` method (which is required for Py 2 compatibility). Renamed `AbtractAttrDef` to `AbstractAttrDef` (fix typo). ___
Ftrack: Don't force ftrackapp endpoint (other / ftrack ) - #4411 ___ #### Brief description Auto-fill of ftrack url don't break custom urls. Custom urls couldn't be used as `ftrackapp.com` is added if is not in the url. #### Description The code was changed in a way that auto-fill is still supported but before `ftrackapp` is added it will try to use url as is. If the connection works as is it is used. ___
Fix: DL on MacOS (other ) - #4418 ___ #### Brief description This works if DL Openpype plugin Installation Directories is set to level of app bundle (eg. '/Applications/OpenPype 3.15.0.app') ___
Photoshop: make usage of layer name in subset name more controllable (other ) - #4432 ___ #### Brief description Layer name was previously used in subset name only if multiple instances were being created in single step. This adds explicit toggle. #### Description Toggling this button allows to use layer name in created subset name even if single instance is being created.This follows more closely implementation if AE. ___
SiteSync: fix dirmap (other ) - #4436 ___ #### Brief description Fixed issue in dirmap in Maya and Nuke #### Description Loads of error were thrown in Nuke console about dictionary value.`AttributeError: 'dict' object has no attribute 'lower'` ___
General: Ignore decode error of stdout/stderr in run_subprocess (other ) - #4446 ___ #### Brief description Ignore decode errors and replace invalid character (byte) with escaped byte character. #### Description Calling of `run_subprocess` may cause crashes if output contains some unicode character which (for example Polish name of encoder handler). ___
Publisher: Fix reopen bug (other ) - #4463 ___ #### Brief description Use right name of constant 'ActiveWindow' -> 'WindowActive'. ___
Publisher: Fix compatibility of QAction in Publisher (other ) - #4474 ___ #### Brief description Fix `QAction` for older version of Qt bindings where QAction requires a parent on initialization. #### Description This bug was discovered in Nuke 11. Fixed by creating QAction when QMenu is already available and can be used as parent. ___
### **🔀 Refactored code**
General: Remove 'openpype.api' (other ) - #4413 ___ #### Brief description PR is removing `openpype/api.py` file which is causing a lot of troubles and cross-imports. #### Description I wanted to remove the file slowly function by function but it always reappear somewhere in codebase even if most of the functionality imported from there is triggering deprecation warnings. This is small change which may have huge impact.There shouldn't be anything in openpype codebase which is using `openpype.api` anymore so only possible issues are in customized repositories or custom addons. ___
### **📃 Documentation**
docs-user-Getting Started adjustments (other ) - #4365 ___ #### Brief description Small typo fixes here and there, additional info on install/ running OP. ___
### **Merged pull requests**
Renderman support for sample and display filters (3d / maya ) - #4003 ___ #### Brief description User can set up both sample and display filters in Openpype settings if they are using Renderman as renderer. #### Description You can preset which sample and display filters for renderman , including the cryptomatte renderpass, in Openpype settings. Once you select which filters to be included in openpype settings and then create render instance for your camera in maya, it would automatically tell the system to generate your selected filters in render settings.The place you can find for setting up the filters: _Maya > Render Settings > Renderman Renderer > Display Filters/ Sample Filters_ ___
Maya: Create Arnold options on repair. (3d / maya ) - #4448 ___ #### Brief description When validating/repairing we previously required users to open render settings to create the Arnold options. This is done through code now. ___
Update Asset field of creator Instances in Maya Template Builder (3d / maya ) - #4470 ___ #### Brief description When we build a template with Maya Template Builder, it will update the asset field of the sets (creator instances) that are imported from the template. #### Description When building a template, we also want to define the publishable content in advance: create an instance of a model, or look, etc., to speed up the workflow and reduce the number of questions we are asked. After building a work file from a saved template that contains pre-created instances, the template builder should update the asset field to the current asset. ___
Blender: fix import workfile all families (3d / blender ) - #4405 ___ #### Brief description Having this feature related to workfile available for any family is absurd. ___
Nuke: update rendered frames in latest version (2d / nuke ) - #4362 ___ #### Brief description Introduced new field to insert frame(s) to rerender only. #### Description Rendering is expensive, sometimes it is helpful only to re-render changed frames and reuse existing.Artists can in Publisher fill which frame(s) should be re-rendered.If there is already published version of currently publishing subset, all representation files are collected (currently for `render` family only) and then when Nuke is rendering (locally only for now), old published files are copied into into temporary render folder where will be rewritten only by frames explicitly set in new field.That way review/burnin process could also reuse old files and recreate reviews/burnins.New version is produced during this process! ___
Feature: Keep synced hero representations up-to-date. (other ) - #4343 ___ #### Brief description Keep previously synchronized sites up-to-date by comparing old and new sites and adding old sites if missing in new ones.Fix #4331 ___
Maya: Fix template builder bug where assets are not put in the right hierarchy (other ) - #4367 ___ #### Brief description When buiding scene from template, the assets loaded from the placeholders are not put in the hierarchy. Plus, the assets are loaded in double. ___
Bump ua-parser-js from 0.7.31 to 0.7.33 in /website (other ) - #4371 ___ Bumps [ua-parser-js](https://github.com/faisalman/ua-parser-js) from 0.7.31 to 0.7.33.
Changelog

Sourced from ua-parser-js's changelog.

Version 0.7.31 / 1.0.2

  • Fix OPPO Reno A5 incorrect detection
  • Fix TypeError Bug
  • Use AST to extract regexes and verify them with safe-regex

Version 0.7.32 / 1.0.32

  • Add new browser : DuckDuckGo, Huawei Browser, LinkedIn
  • Add new OS : HarmonyOS
  • Add some Huawei models
  • Add Sharp Aquos TV
  • Improve detection Xiaomi Mi CC9
  • Fix Sony Xperia 1 III misidentified as Acer tablet
  • Fix Detect Sony BRAVIA as SmartTV
  • Fix Detect Xiaomi Mi TV as SmartTV
  • Fix Detect Galaxy Tab S8 as tablet
  • Fix WeGame mistakenly identified as WeChat
  • Fix included commas in Safari / Mobile Safari version
  • Increase UA_MAX_LENGTH to 350

Version 0.7.33 / 1.0.33

  • Add new browser : Cobalt
  • Identify Macintosh as an Apple device
  • Fix ReDoS vulnerability

Version 0.8

Version 0.8 was created by accident. This version is now deprecated and no longer maintained, please update to version 0.7 / 1.0.

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ua-parser-js&package-manager=npm_and_yarn&previous-version=0.7.31&new-version=0.7.33)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) - `@dependabot use these labels` will set the current labels as the default for future PRs for this repo and language - `@dependabot use these reviewers` will set the current reviewers as the default for future PRs for this repo and language - `@dependabot use these assignees` will set the current assignees as the default for future PRs for this repo and language - `@dependabot use this milestone` will set the current milestone as the default for future PRs for this repo and language You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/ynput/OpenPype/network/alerts).
___
Docs: Question about renaming in Kitsu (other ) - #4384 ___ #### Brief description To keep memory of this discussion: https://discord.com/channels/517362899170230292/563751989075378201/1068112668491255818 ___
New Publisher: Fix Creator error typo (other ) - #4396 ___ #### Brief description Fixes typo in error message. ___
Chore: pyproject.toml version because of Poetry (other ) - #4408 ___ #### Brief description Automatization injects wrong format ___
Fix - remove minor part in toml (other ) - #4437 ___ #### Brief description Causes issue in create_env and new Poetry ___
General: Add project code to anatomy (other ) - #4445 ___ #### Brief description Added attribute `project_code` to `Anatomy` object. #### Description Anatomy already have access to almost all attributes from project anatomy except project code. This PR changing it. Technically `Anatomy` is everything what would be needed to get fill data of project. ``` { "project": { "name": anatomy.project_name, "code": anatomy.project_code } } ``` ___
Maya: Arnold Scene Source overhaul - OP-4865 (other / maya ) - #4449 ___ #### Brief description General overhaul of the Arnold Scene Source (ASS) workflow. #### Description This originally was to support static files (non-sequencial) ASS publishing, but digging deeper whole workflow needed an update to get ready for further issues. During this overhaul the following changes were made: - Generalized Arnold Standin workflow to a single loader. - Support multiple nodes as proxies. - Support proxies for `pointcache` family. - Generalized approach to proxies as resources, so they can be the same file format as the original.This workflow should allow further expansion to utilize operators and eventually USD. ___
## [3.15.0](https://github.com/ynput/OpenPype/tree/3.15.0) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.14.10...3.15.0) **Deprecated:** - General: Fill default values of new publish template profiles [\#4245](https://github.com/ynput/OpenPype/pull/4245) ### 📖 Documentation - documentation: Split tools into separate entries [\#4342](https://github.com/ynput/OpenPype/pull/4342) - Documentation: Fix harmony docs [\#4301](https://github.com/ynput/OpenPype/pull/4301) - Remove staging logic set by OpenPype version [\#3979](https://github.com/ynput/OpenPype/pull/3979) **🆕 New features** - General: Push to studio library [\#4284](https://github.com/ynput/OpenPype/pull/4284) - Colorspace Management and Distribution [\#4195](https://github.com/ynput/OpenPype/pull/4195) - Nuke: refactor to latest publisher workfow [\#4006](https://github.com/ynput/OpenPype/pull/4006) - Update to Python 3.9 [\#3546](https://github.com/ynput/OpenPype/pull/3546) **🚀 Enhancements** - Unreal: Don't use mongo queries in 'ExistingLayoutLoader' [\#4356](https://github.com/ynput/OpenPype/pull/4356) - General: Loader and Creator plugins can be disabled [\#4310](https://github.com/ynput/OpenPype/pull/4310) - General: Unbind poetry version [\#4306](https://github.com/ynput/OpenPype/pull/4306) - General: Enhanced enum def items [\#4295](https://github.com/ynput/OpenPype/pull/4295) - Git: add pre-commit hooks [\#4289](https://github.com/ynput/OpenPype/pull/4289) - Tray Publisher: Improve Online family functionality [\#4263](https://github.com/ynput/OpenPype/pull/4263) - General: Update MacOs to PySide6 [\#4255](https://github.com/ynput/OpenPype/pull/4255) - Build: update to Gazu in toml [\#4208](https://github.com/ynput/OpenPype/pull/4208) - Global: adding imageio to settings [\#4158](https://github.com/ynput/OpenPype/pull/4158) - Blender: added project settings for validator no colons in name [\#4149](https://github.com/ynput/OpenPype/pull/4149) - Dockerfile for Debian Bullseye [\#4108](https://github.com/ynput/OpenPype/pull/4108) - AfterEffects: publish multiple compositions [\#4092](https://github.com/ynput/OpenPype/pull/4092) - AfterEffects: make new publisher default [\#4056](https://github.com/ynput/OpenPype/pull/4056) - Photoshop: make new publisher default [\#4051](https://github.com/ynput/OpenPype/pull/4051) - Feature/multiverse [\#4046](https://github.com/ynput/OpenPype/pull/4046) - Tests: add support for deadline for automatic tests [\#3989](https://github.com/ynput/OpenPype/pull/3989) - Add version to shortcut name [\#3906](https://github.com/ynput/OpenPype/pull/3906) - TrayPublisher: Removed from experimental tools [\#3667](https://github.com/ynput/OpenPype/pull/3667) **🐛 Bug fixes** - change 3.7 to 3.9 in folder name [\#4354](https://github.com/ynput/OpenPype/pull/4354) - PushToProject: Fix hierarchy of project change [\#4350](https://github.com/ynput/OpenPype/pull/4350) - Fix photoshop workfile save-as [\#4347](https://github.com/ynput/OpenPype/pull/4347) - Nuke Input process node sourcing improvements [\#4341](https://github.com/ynput/OpenPype/pull/4341) - New publisher: Some validation plugin tweaks [\#4339](https://github.com/ynput/OpenPype/pull/4339) - Harmony: fix unable to change workfile on Mac [\#4334](https://github.com/ynput/OpenPype/pull/4334) - Global: fixing in-place source publishing for editorial [\#4333](https://github.com/ynput/OpenPype/pull/4333) - General: Use class constants of QMessageBox [\#4332](https://github.com/ynput/OpenPype/pull/4332) - TVPaint: Fix plugin for TVPaint 11.7 [\#4328](https://github.com/ynput/OpenPype/pull/4328) - Exctract OTIO review has improved quality [\#4325](https://github.com/ynput/OpenPype/pull/4325) - Ftrack: fix typos causing bugs in sync [\#4322](https://github.com/ynput/OpenPype/pull/4322) - General: Python 2 compatibility of instance collector [\#4320](https://github.com/ynput/OpenPype/pull/4320) - Slack: user groups speedup [\#4318](https://github.com/ynput/OpenPype/pull/4318) - Maya: Bug - Multiverse extractor executed on plain animation family [\#4315](https://github.com/ynput/OpenPype/pull/4315) - Fix run\_documentation.ps1 [\#4312](https://github.com/ynput/OpenPype/pull/4312) - Nuke: new creators fixes [\#4308](https://github.com/ynput/OpenPype/pull/4308) - General: missing comment on standalone and tray publisher [\#4303](https://github.com/ynput/OpenPype/pull/4303) - AfterEffects: Fix for audio from mp4 layer [\#4296](https://github.com/ynput/OpenPype/pull/4296) - General: Update gazu in poetry lock [\#4247](https://github.com/ynput/OpenPype/pull/4247) - Bug: Fixing version detection and filtering in Igniter [\#3914](https://github.com/ynput/OpenPype/pull/3914) - Bug: Create missing version dir [\#3903](https://github.com/ynput/OpenPype/pull/3903) **🔀 Refactored code** - Remove redundant export\_alembic method. [\#4293](https://github.com/ynput/OpenPype/pull/4293) - Igniter: Use qtpy modules instead of Qt [\#4237](https://github.com/ynput/OpenPype/pull/4237) **Merged pull requests:** - Sort families by alphabetical order in the Create plugin [\#4346](https://github.com/ynput/OpenPype/pull/4346) - Global: Validate unique subsets [\#4336](https://github.com/ynput/OpenPype/pull/4336) - Maya: Collect instances preserve handles even if frameStart + frameEnd matches context [\#3437](https://github.com/ynput/OpenPype/pull/3437) ## [3.14.10](https://github.com/ynput/OpenPype/tree/HEAD) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.14.9...3.14.10) **🆕 New features** - Global | Nuke: Creator placeholders in workfile template builder [\#4266](https://github.com/ynput/OpenPype/pull/4266) - Slack: Added dynamic message [\#4265](https://github.com/ynput/OpenPype/pull/4265) - Blender: Workfile Loader [\#4234](https://github.com/ynput/OpenPype/pull/4234) - Unreal: Publishing and Loading for UAssets [\#4198](https://github.com/ynput/OpenPype/pull/4198) - Publish: register publishes without copying them [\#4157](https://github.com/ynput/OpenPype/pull/4157) **🚀 Enhancements** - General: Added install method with docstring to HostBase [\#4298](https://github.com/ynput/OpenPype/pull/4298) - Traypublisher: simple editorial multiple edl [\#4248](https://github.com/ynput/OpenPype/pull/4248) - General: Extend 'IPluginPaths' to have more available methods [\#4214](https://github.com/ynput/OpenPype/pull/4214) - Refactorization of folder coloring [\#4211](https://github.com/ynput/OpenPype/pull/4211) - Flame - loading multilayer with controlled layer names [\#4204](https://github.com/ynput/OpenPype/pull/4204) **🐛 Bug fixes** - Unreal: fix missing `maintained_selection` call [\#4300](https://github.com/ynput/OpenPype/pull/4300) - Ftrack: Fix receive of host ip on MacOs [\#4288](https://github.com/ynput/OpenPype/pull/4288) - SiteSync: sftp connection failing when shouldnt be tested [\#4278](https://github.com/ynput/OpenPype/pull/4278) - Deadline: fix default value for passing mongo url [\#4275](https://github.com/ynput/OpenPype/pull/4275) - Scene Manager: Fix variable name [\#4268](https://github.com/ynput/OpenPype/pull/4268) - Slack: notification fails because of missing published path [\#4264](https://github.com/ynput/OpenPype/pull/4264) - hiero: creator gui with min max [\#4257](https://github.com/ynput/OpenPype/pull/4257) - NiceCheckbox: Fix checker positioning in Python 2 [\#4253](https://github.com/ynput/OpenPype/pull/4253) - Publisher: Fix 'CreatorType' not equal for Python 2 DCCs [\#4249](https://github.com/ynput/OpenPype/pull/4249) - Deadline: fix dependencies [\#4242](https://github.com/ynput/OpenPype/pull/4242) - Houdini: hotfix instance data access [\#4236](https://github.com/ynput/OpenPype/pull/4236) - bugfix/image plane load error [\#4222](https://github.com/ynput/OpenPype/pull/4222) - Hiero: thumbnail from multilayer exr [\#4209](https://github.com/ynput/OpenPype/pull/4209) **🔀 Refactored code** - Resolve: Use qtpy in Resolve [\#4254](https://github.com/ynput/OpenPype/pull/4254) - Houdini: Use qtpy in Houdini [\#4252](https://github.com/ynput/OpenPype/pull/4252) - Max: Use qtpy in Max [\#4251](https://github.com/ynput/OpenPype/pull/4251) - Maya: Use qtpy in Maya [\#4250](https://github.com/ynput/OpenPype/pull/4250) - Hiero: Use qtpy in Hiero [\#4240](https://github.com/ynput/OpenPype/pull/4240) - Nuke: Use qtpy in Nuke [\#4239](https://github.com/ynput/OpenPype/pull/4239) - Flame: Use qtpy in flame [\#4238](https://github.com/ynput/OpenPype/pull/4238) - General: Legacy io not used in global plugins [\#4134](https://github.com/ynput/OpenPype/pull/4134) **Merged pull requests:** - Bump json5 from 1.0.1 to 1.0.2 in /website [\#4292](https://github.com/ynput/OpenPype/pull/4292) - Maya: Fix validate frame range repair + fix create render with deadline disabled [\#4279](https://github.com/ynput/OpenPype/pull/4279) ## [3.14.9](https://github.com/pypeclub/OpenPype/tree/3.14.9) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.8...3.14.9) ### 📖 Documentation - Documentation: Testing on Deadline [\#4185](https://github.com/pypeclub/OpenPype/pull/4185) - Consistent Python version [\#4160](https://github.com/pypeclub/OpenPype/pull/4160) **🆕 New features** - Feature/op 4397 gl tf extractor for maya [\#4192](https://github.com/pypeclub/OpenPype/pull/4192) - Maya: Extractor for Unreal SkeletalMesh [\#4174](https://github.com/pypeclub/OpenPype/pull/4174) - 3dsmax: integration [\#4168](https://github.com/pypeclub/OpenPype/pull/4168) - Blender: Extract Alembic Animations [\#4128](https://github.com/pypeclub/OpenPype/pull/4128) - Unreal: Load Alembic Animations [\#4127](https://github.com/pypeclub/OpenPype/pull/4127) **🚀 Enhancements** - Houdini: Use new interface class name for publish host [\#4220](https://github.com/pypeclub/OpenPype/pull/4220) - General: Default command for headless mode is interactive [\#4203](https://github.com/pypeclub/OpenPype/pull/4203) - Maya: Enhanced ASS publishing [\#4196](https://github.com/pypeclub/OpenPype/pull/4196) - Feature/op 3924 implement ass extractor [\#4188](https://github.com/pypeclub/OpenPype/pull/4188) - File transactions: Source path is destination path [\#4184](https://github.com/pypeclub/OpenPype/pull/4184) - Deadline: improve environment processing [\#4182](https://github.com/pypeclub/OpenPype/pull/4182) - General: Comment per instance in Publisher [\#4178](https://github.com/pypeclub/OpenPype/pull/4178) - Ensure Mongo database directory exists in Windows. [\#4166](https://github.com/pypeclub/OpenPype/pull/4166) - Note about unrestricted execution on Windows. [\#4161](https://github.com/pypeclub/OpenPype/pull/4161) - Maya: Enable thumbnail transparency on extraction. [\#4147](https://github.com/pypeclub/OpenPype/pull/4147) - Maya: Disable viewport Pan/Zoom on playblast extraction. [\#4146](https://github.com/pypeclub/OpenPype/pull/4146) - Maya: Optional viewport refresh on pointcache extraction [\#4144](https://github.com/pypeclub/OpenPype/pull/4144) - CelAction: refactory integration to current openpype [\#4140](https://github.com/pypeclub/OpenPype/pull/4140) - Maya: create and publish bounding box geometry [\#4131](https://github.com/pypeclub/OpenPype/pull/4131) - Changed the UOpenPypePublishInstance to use the UDataAsset class [\#4124](https://github.com/pypeclub/OpenPype/pull/4124) - General: Collection Audio speed up [\#4110](https://github.com/pypeclub/OpenPype/pull/4110) - Maya: keep existing AOVs when creating render instance [\#4087](https://github.com/pypeclub/OpenPype/pull/4087) - General: Oiio conversion multipart fix [\#4060](https://github.com/pypeclub/OpenPype/pull/4060) **🐛 Bug fixes** - Publisher: Signal type issues in Python 2 DCCs [\#4230](https://github.com/pypeclub/OpenPype/pull/4230) - Blender: Fix Layout Family Versioning [\#4228](https://github.com/pypeclub/OpenPype/pull/4228) - Blender: Fix Create Camera "Use selection" [\#4226](https://github.com/pypeclub/OpenPype/pull/4226) - TrayPublisher - join needs list [\#4224](https://github.com/pypeclub/OpenPype/pull/4224) - General: Event callbacks pass event to callbacks as expected [\#4210](https://github.com/pypeclub/OpenPype/pull/4210) - Build:Revert .toml update of Gazu [\#4207](https://github.com/pypeclub/OpenPype/pull/4207) - Nuke: fixed imageio node overrides subset filter [\#4202](https://github.com/pypeclub/OpenPype/pull/4202) - Maya: pointcache [\#4201](https://github.com/pypeclub/OpenPype/pull/4201) - Unreal: Support for Unreal Engine 5.1 [\#4199](https://github.com/pypeclub/OpenPype/pull/4199) - General: Integrate thumbnail looks for thumbnail to multiple places [\#4181](https://github.com/pypeclub/OpenPype/pull/4181) - Various minor bugfixes [\#4172](https://github.com/pypeclub/OpenPype/pull/4172) - Nuke/Hiero: Remove tkinter library paths before launch [\#4171](https://github.com/pypeclub/OpenPype/pull/4171) - Flame: vertical alignment of layers [\#4169](https://github.com/pypeclub/OpenPype/pull/4169) - Nuke: correct detection of viewer and display [\#4165](https://github.com/pypeclub/OpenPype/pull/4165) - Settings UI: Don't create QApplication if already exists [\#4156](https://github.com/pypeclub/OpenPype/pull/4156) - General: Extract review handle start offset of sequences [\#4152](https://github.com/pypeclub/OpenPype/pull/4152) - Maya: Maintain time connections on Alembic update. [\#4143](https://github.com/pypeclub/OpenPype/pull/4143) **🔀 Refactored code** - General: Use qtpy in modules and hosts UIs which are running in OpenPype process [\#4225](https://github.com/pypeclub/OpenPype/pull/4225) - Tools: Use qtpy instead of Qt in standalone tools [\#4223](https://github.com/pypeclub/OpenPype/pull/4223) - General: Use qtpy in settings UI [\#4215](https://github.com/pypeclub/OpenPype/pull/4215) **Merged pull requests:** - layout publish more than one container issue [\#4098](https://github.com/pypeclub/OpenPype/pull/4098) ## [3.14.8](https://github.com/pypeclub/OpenPype/tree/3.14.8) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.7...3.14.8) **🚀 Enhancements** - General: Refactored extract hierarchy plugin [\#4139](https://github.com/pypeclub/OpenPype/pull/4139) - General: Find executable enhancement [\#4137](https://github.com/pypeclub/OpenPype/pull/4137) - Ftrack: Reset session before instance processing [\#4129](https://github.com/pypeclub/OpenPype/pull/4129) - Ftrack: Editorial asset sync issue [\#4126](https://github.com/pypeclub/OpenPype/pull/4126) - Deadline: Build version resolving [\#4115](https://github.com/pypeclub/OpenPype/pull/4115) - Houdini: New Publisher [\#3046](https://github.com/pypeclub/OpenPype/pull/3046) - Fix: Standalone Publish Directories [\#4148](https://github.com/pypeclub/OpenPype/pull/4148) **🐛 Bug fixes** - Ftrack: Fix occational double parents issue [\#4153](https://github.com/pypeclub/OpenPype/pull/4153) - General: Maketx executable issue [\#4136](https://github.com/pypeclub/OpenPype/pull/4136) - Maya: Looks - add all connections [\#4135](https://github.com/pypeclub/OpenPype/pull/4135) - General: Fix variable check in collect anatomy instance data [\#4117](https://github.com/pypeclub/OpenPype/pull/4117) ## [3.14.7](https://github.com/pypeclub/OpenPype/tree/3.14.7) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.6...3.14.7) **🆕 New features** - Hiero: loading effect family to timeline [\#4055](https://github.com/pypeclub/OpenPype/pull/4055) **🚀 Enhancements** - Photoshop: bug with pop-up window on Instance Creator [\#4121](https://github.com/pypeclub/OpenPype/pull/4121) - Publisher: Open on specific tab [\#4120](https://github.com/pypeclub/OpenPype/pull/4120) - Publisher: Hide unknown publish values [\#4116](https://github.com/pypeclub/OpenPype/pull/4116) - Ftrack: Event server status give more information about version locations [\#4112](https://github.com/pypeclub/OpenPype/pull/4112) - General: Allow higher numbers in frames and clips [\#4101](https://github.com/pypeclub/OpenPype/pull/4101) - Publisher: Settings for validate frame range [\#4097](https://github.com/pypeclub/OpenPype/pull/4097) - Publisher: Ignore escape button [\#4090](https://github.com/pypeclub/OpenPype/pull/4090) - Flame: Loading clip with native colorspace resolved from mapping [\#4079](https://github.com/pypeclub/OpenPype/pull/4079) - General: Extract review single frame output [\#4064](https://github.com/pypeclub/OpenPype/pull/4064) - Publisher: Prepared common function for instance data cache [\#4063](https://github.com/pypeclub/OpenPype/pull/4063) - Publisher: Easy access to publish page from create page [\#4058](https://github.com/pypeclub/OpenPype/pull/4058) - General/TVPaint: Attribute defs dialog [\#4052](https://github.com/pypeclub/OpenPype/pull/4052) - Publisher: Better reset defer [\#4048](https://github.com/pypeclub/OpenPype/pull/4048) - Publisher: Add thumbnail sources [\#4042](https://github.com/pypeclub/OpenPype/pull/4042) **🐛 Bug fixes** - General: Move default settings for template name [\#4119](https://github.com/pypeclub/OpenPype/pull/4119) - Slack: notification fail in new tray publisher [\#4118](https://github.com/pypeclub/OpenPype/pull/4118) - Nuke: loaded nodes set to first tab [\#4114](https://github.com/pypeclub/OpenPype/pull/4114) - Nuke: load image first frame [\#4113](https://github.com/pypeclub/OpenPype/pull/4113) - Files Widget: Ignore case sensitivity of extensions [\#4096](https://github.com/pypeclub/OpenPype/pull/4096) - Webpublisher: extension is lowercased in Setting and in uploaded files [\#4095](https://github.com/pypeclub/OpenPype/pull/4095) - Publish Report Viewer: Fix small bugs [\#4086](https://github.com/pypeclub/OpenPype/pull/4086) - Igniter: fix regex to match semver better [\#4085](https://github.com/pypeclub/OpenPype/pull/4085) - Maya: aov filtering [\#4083](https://github.com/pypeclub/OpenPype/pull/4083) - Flame/Flare: Loading to multiple batches [\#4080](https://github.com/pypeclub/OpenPype/pull/4080) - hiero: creator from settings with set maximum [\#4077](https://github.com/pypeclub/OpenPype/pull/4077) - Nuke: resolve hashes in file name only for frame token [\#4074](https://github.com/pypeclub/OpenPype/pull/4074) - Publisher: Fix cache of asset docs [\#4070](https://github.com/pypeclub/OpenPype/pull/4070) - Webpublisher: cleanup wp extract thumbnail [\#4067](https://github.com/pypeclub/OpenPype/pull/4067) - Settings UI: Locked setting can't bypass lock [\#4066](https://github.com/pypeclub/OpenPype/pull/4066) - Loader: Fix comparison of repre name [\#4053](https://github.com/pypeclub/OpenPype/pull/4053) - Deadline: Extract environment subprocess failure [\#4050](https://github.com/pypeclub/OpenPype/pull/4050) **🔀 Refactored code** - General: Collect entities plugin minor changes [\#4089](https://github.com/pypeclub/OpenPype/pull/4089) - General: Direct interfaces import [\#4065](https://github.com/pypeclub/OpenPype/pull/4065) **Merged pull requests:** - Bump loader-utils from 1.4.1 to 1.4.2 in /website [\#4100](https://github.com/pypeclub/OpenPype/pull/4100) - Online family for Tray Publisher [\#4093](https://github.com/pypeclub/OpenPype/pull/4093) - Bump loader-utils from 1.4.0 to 1.4.1 in /website [\#4081](https://github.com/pypeclub/OpenPype/pull/4081) - remove underscore from subset name [\#4059](https://github.com/pypeclub/OpenPype/pull/4059) - Alembic Loader as Arnold Standin [\#4047](https://github.com/pypeclub/OpenPype/pull/4047) ## [3.14.6](https://github.com/pypeclub/OpenPype/tree/3.14.6) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.5...3.14.6) ### 📖 Documentation - Documentation: Minor updates to dev\_requirements.md [\#4025](https://github.com/pypeclub/OpenPype/pull/4025) **🆕 New features** - Nuke: add 13.2 variant [\#4041](https://github.com/pypeclub/OpenPype/pull/4041) **🚀 Enhancements** - Publish Report Viewer: Store reports locally on machine [\#4040](https://github.com/pypeclub/OpenPype/pull/4040) - General: More specific error in burnins script [\#4026](https://github.com/pypeclub/OpenPype/pull/4026) - General: Extract review does not crash with old settings overrides [\#4023](https://github.com/pypeclub/OpenPype/pull/4023) - Publisher: Convertors for legacy instances [\#4020](https://github.com/pypeclub/OpenPype/pull/4020) - workflows: adding milestone creator and assigner [\#4018](https://github.com/pypeclub/OpenPype/pull/4018) - Publisher: Catch creator errors [\#4015](https://github.com/pypeclub/OpenPype/pull/4015) **🐛 Bug fixes** - Hiero - effect collection fixes [\#4038](https://github.com/pypeclub/OpenPype/pull/4038) - Nuke - loader clip correct hash conversion in path [\#4037](https://github.com/pypeclub/OpenPype/pull/4037) - Maya: Soft fail when applying capture preset [\#4034](https://github.com/pypeclub/OpenPype/pull/4034) - Igniter: handle missing directory [\#4032](https://github.com/pypeclub/OpenPype/pull/4032) - StandalonePublisher: Fix thumbnail publishing [\#4029](https://github.com/pypeclub/OpenPype/pull/4029) - Experimental Tools: Fix publisher import [\#4027](https://github.com/pypeclub/OpenPype/pull/4027) - Houdini: fix wrong path in ASS loader [\#4016](https://github.com/pypeclub/OpenPype/pull/4016) **🔀 Refactored code** - General: Import lib functions from lib [\#4017](https://github.com/pypeclub/OpenPype/pull/4017) ## [3.14.5](https://github.com/pypeclub/OpenPype/tree/3.14.5) (2022-10-24) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.4...3.14.5) **🚀 Enhancements** - Maya: add OBJ extractor to model family [\#4021](https://github.com/pypeclub/OpenPype/pull/4021) - Publish report viewer tool [\#4010](https://github.com/pypeclub/OpenPype/pull/4010) - Nuke | Global: adding custom tags representation filtering [\#4009](https://github.com/pypeclub/OpenPype/pull/4009) - Publisher: Create context has shared data for collection phase [\#3995](https://github.com/pypeclub/OpenPype/pull/3995) - Resolve: updating to v18 compatibility [\#3986](https://github.com/pypeclub/OpenPype/pull/3986) **🐛 Bug fixes** - TrayPublisher: Fix missing argument [\#4019](https://github.com/pypeclub/OpenPype/pull/4019) - General: Fix python 2 compatibility of ffmpeg and oiio tools discovery [\#4011](https://github.com/pypeclub/OpenPype/pull/4011) **🔀 Refactored code** - Maya: Removed unused imports [\#4008](https://github.com/pypeclub/OpenPype/pull/4008) - Unreal: Fix import of moved function [\#4007](https://github.com/pypeclub/OpenPype/pull/4007) - Houdini: Change import of RepairAction [\#4005](https://github.com/pypeclub/OpenPype/pull/4005) - Nuke/Hiero: Refactor openpype.api imports [\#4000](https://github.com/pypeclub/OpenPype/pull/4000) - TVPaint: Defined with HostBase [\#3994](https://github.com/pypeclub/OpenPype/pull/3994) **Merged pull requests:** - Unreal: Remove redundant Creator stub [\#4012](https://github.com/pypeclub/OpenPype/pull/4012) - Unreal: add `uproject` extension to Unreal project template [\#4004](https://github.com/pypeclub/OpenPype/pull/4004) - Unreal: fix order of includes [\#4002](https://github.com/pypeclub/OpenPype/pull/4002) - Fusion: Implement backwards compatibility \(+/- Fusion 17.2\) [\#3958](https://github.com/pypeclub/OpenPype/pull/3958) ## [3.14.4](https://github.com/pypeclub/OpenPype/tree/3.14.4) (2022-10-19) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.3...3.14.4) **🆕 New features** - Webpublisher: use max next published version number for all items in batch [\#3961](https://github.com/pypeclub/OpenPype/pull/3961) - General: Control Thumbnail integration via explicit configuration profiles [\#3951](https://github.com/pypeclub/OpenPype/pull/3951) **🚀 Enhancements** - Publisher: Multiselection in card view [\#3993](https://github.com/pypeclub/OpenPype/pull/3993) - TrayPublisher: Original Basename cause crash too early [\#3990](https://github.com/pypeclub/OpenPype/pull/3990) - Tray Publisher: add `originalBasename` data to simple creators [\#3988](https://github.com/pypeclub/OpenPype/pull/3988) - General: Custom paths to ffmpeg and OpenImageIO tools [\#3982](https://github.com/pypeclub/OpenPype/pull/3982) - Integrate: Preserve existing subset group if instance does not set it for new version [\#3976](https://github.com/pypeclub/OpenPype/pull/3976) - Publisher: Prepare publisher controller for remote publishing [\#3972](https://github.com/pypeclub/OpenPype/pull/3972) - Maya: new style dataclasses in maya deadline submitter plugin [\#3968](https://github.com/pypeclub/OpenPype/pull/3968) - Maya: Define preffered Qt bindings for Qt.py and qtpy [\#3963](https://github.com/pypeclub/OpenPype/pull/3963) - Settings: Move imageio from project anatomy to project settings \[pypeclub\] [\#3959](https://github.com/pypeclub/OpenPype/pull/3959) - TrayPublisher: Extract thumbnail for other families [\#3952](https://github.com/pypeclub/OpenPype/pull/3952) - Publisher: Pass instance to subset name method on update [\#3949](https://github.com/pypeclub/OpenPype/pull/3949) - General: Set root environments before DCC launch [\#3947](https://github.com/pypeclub/OpenPype/pull/3947) - Refactor: changed legacy way to update database for Hero version integrate [\#3941](https://github.com/pypeclub/OpenPype/pull/3941) - Maya: Moved plugin from global to maya [\#3939](https://github.com/pypeclub/OpenPype/pull/3939) - Publisher: Create dialog is part of main window [\#3936](https://github.com/pypeclub/OpenPype/pull/3936) - Fusion: Implement Alembic and FBX mesh loader [\#3927](https://github.com/pypeclub/OpenPype/pull/3927) **🐛 Bug fixes** - TrayPublisher: Disable sequences in batch mov creator [\#3996](https://github.com/pypeclub/OpenPype/pull/3996) - Fix - tags might be missing on representation [\#3985](https://github.com/pypeclub/OpenPype/pull/3985) - Resolve: Fix usage of functions from lib [\#3983](https://github.com/pypeclub/OpenPype/pull/3983) - Maya: remove invalid prefix token for non-multipart outputs [\#3981](https://github.com/pypeclub/OpenPype/pull/3981) - Ftrack: Fix schema cache for Python 2 [\#3980](https://github.com/pypeclub/OpenPype/pull/3980) - Maya: add object to attr.s declaration [\#3973](https://github.com/pypeclub/OpenPype/pull/3973) - Maya: Deadline OutputFilePath hack regression for Renderman [\#3950](https://github.com/pypeclub/OpenPype/pull/3950) - Houdini: Fix validate workfile paths for non-parm file references [\#3948](https://github.com/pypeclub/OpenPype/pull/3948) - Photoshop: missed sync published version of workfile with workfile [\#3946](https://github.com/pypeclub/OpenPype/pull/3946) - Maya: Set default value for RenderSetupIncludeLights option [\#3944](https://github.com/pypeclub/OpenPype/pull/3944) - Maya: fix regression of Renderman Deadline hack [\#3943](https://github.com/pypeclub/OpenPype/pull/3943) - Kitsu: 2 fixes, nb\_frames and Shot type error [\#3940](https://github.com/pypeclub/OpenPype/pull/3940) - Tray: Change order of attribute changes [\#3938](https://github.com/pypeclub/OpenPype/pull/3938) - AttributeDefs: Fix crashing multivalue of files widget [\#3937](https://github.com/pypeclub/OpenPype/pull/3937) - General: Fix links query on hero version [\#3900](https://github.com/pypeclub/OpenPype/pull/3900) - Publisher: Files Drag n Drop cleanup [\#3888](https://github.com/pypeclub/OpenPype/pull/3888) **🔀 Refactored code** - Flame: Import lib functions from lib [\#3992](https://github.com/pypeclub/OpenPype/pull/3992) - General: Fix deprecated warning in legacy creator [\#3978](https://github.com/pypeclub/OpenPype/pull/3978) - Blender: Remove openpype api imports [\#3977](https://github.com/pypeclub/OpenPype/pull/3977) - General: Use direct import of resources [\#3964](https://github.com/pypeclub/OpenPype/pull/3964) - General: Direct settings imports [\#3934](https://github.com/pypeclub/OpenPype/pull/3934) - General: import 'Logger' from 'openpype.lib' [\#3926](https://github.com/pypeclub/OpenPype/pull/3926) - General: Remove deprecated functions from lib [\#3907](https://github.com/pypeclub/OpenPype/pull/3907) **Merged pull requests:** - Maya + Yeti: Load Yeti Cache fix frame number recognition [\#3942](https://github.com/pypeclub/OpenPype/pull/3942) - Fusion: Implement callbacks to Fusion's event system thread [\#3928](https://github.com/pypeclub/OpenPype/pull/3928) - Photoshop: create single frame image in Ftrack as review [\#3908](https://github.com/pypeclub/OpenPype/pull/3908) ## [3.14.3](https://github.com/pypeclub/OpenPype/tree/3.14.3) (2022-10-03) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.2...3.14.3) **🚀 Enhancements** - Publisher: Enhancement proposals [\#3897](https://github.com/pypeclub/OpenPype/pull/3897) **🐛 Bug fixes** - Maya: Fix Render single camera validator [\#3929](https://github.com/pypeclub/OpenPype/pull/3929) - Flame: loading multilayer exr to batch/reel is working [\#3901](https://github.com/pypeclub/OpenPype/pull/3901) - Hiero: Fix inventory check on launch [\#3895](https://github.com/pypeclub/OpenPype/pull/3895) - WebPublisher: Fix import after refactor [\#3891](https://github.com/pypeclub/OpenPype/pull/3891) **🔀 Refactored code** - Maya: Remove unused 'openpype.api' imports in plugins [\#3925](https://github.com/pypeclub/OpenPype/pull/3925) - Resolve: Use new Extractor location [\#3918](https://github.com/pypeclub/OpenPype/pull/3918) - Unreal: Use new Extractor location [\#3917](https://github.com/pypeclub/OpenPype/pull/3917) - Flame: Use new Extractor location [\#3916](https://github.com/pypeclub/OpenPype/pull/3916) - Houdini: Use new Extractor location [\#3894](https://github.com/pypeclub/OpenPype/pull/3894) - Harmony: Use new Extractor location [\#3893](https://github.com/pypeclub/OpenPype/pull/3893) **Merged pull requests:** - Maya: Fix Scene Inventory possibly starting off-screen due to maya preferences [\#3923](https://github.com/pypeclub/OpenPype/pull/3923) ## [3.14.2](https://github.com/pypeclub/OpenPype/tree/3.14.2) (2022-09-12) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.1...3.14.2) ### 📖 Documentation - Documentation: Anatomy templates [\#3618](https://github.com/pypeclub/OpenPype/pull/3618) **🆕 New features** - Nuke: Build workfile by template [\#3763](https://github.com/pypeclub/OpenPype/pull/3763) - Houdini: Publishing workfiles [\#3697](https://github.com/pypeclub/OpenPype/pull/3697) - Global: making collect audio plugin global [\#3679](https://github.com/pypeclub/OpenPype/pull/3679) **🚀 Enhancements** - Flame: Adding Creator's retimed shot and handles switch [\#3826](https://github.com/pypeclub/OpenPype/pull/3826) - Flame: OpenPype submenu to batch and media manager [\#3825](https://github.com/pypeclub/OpenPype/pull/3825) - General: Better pixmap scaling [\#3809](https://github.com/pypeclub/OpenPype/pull/3809) - Photoshop: attempt to speed up ExtractImage [\#3793](https://github.com/pypeclub/OpenPype/pull/3793) - SyncServer: Added cli commands for sync server [\#3765](https://github.com/pypeclub/OpenPype/pull/3765) - Kitsu: Drop 'entities root' setting. [\#3739](https://github.com/pypeclub/OpenPype/pull/3739) - git: update gitignore [\#3722](https://github.com/pypeclub/OpenPype/pull/3722) - Blender: Publisher collect workfile representation [\#3670](https://github.com/pypeclub/OpenPype/pull/3670) - Maya: move set render settings menu entry [\#3669](https://github.com/pypeclub/OpenPype/pull/3669) - Scene Inventory: Maya add actions to select from or to scene [\#3659](https://github.com/pypeclub/OpenPype/pull/3659) - Scene Inventory: Add subsetGroup column [\#3658](https://github.com/pypeclub/OpenPype/pull/3658) **🐛 Bug fixes** - General: Fix Pattern access in client code [\#3828](https://github.com/pypeclub/OpenPype/pull/3828) - Launcher: Skip opening last work file works for groups [\#3822](https://github.com/pypeclub/OpenPype/pull/3822) - Maya: Publishing data key change [\#3811](https://github.com/pypeclub/OpenPype/pull/3811) - Igniter: Fix status handling when version is already installed [\#3804](https://github.com/pypeclub/OpenPype/pull/3804) - Resolve: Addon import is Python 2 compatible [\#3798](https://github.com/pypeclub/OpenPype/pull/3798) - Hiero: retimed clip publishing is working [\#3792](https://github.com/pypeclub/OpenPype/pull/3792) - nuke: validate write node is not failing due wrong type [\#3780](https://github.com/pypeclub/OpenPype/pull/3780) - Fix - changed format of version string in pyproject.toml [\#3777](https://github.com/pypeclub/OpenPype/pull/3777) - Ftrack status fix typo prgoress -\> progress [\#3761](https://github.com/pypeclub/OpenPype/pull/3761) - Fix version resolution [\#3757](https://github.com/pypeclub/OpenPype/pull/3757) - Maya: `containerise` dont skip empty values [\#3674](https://github.com/pypeclub/OpenPype/pull/3674) **🔀 Refactored code** - Photoshop: Use new Extractor location [\#3789](https://github.com/pypeclub/OpenPype/pull/3789) - Blender: Use new Extractor location [\#3787](https://github.com/pypeclub/OpenPype/pull/3787) - AfterEffects: Use new Extractor location [\#3784](https://github.com/pypeclub/OpenPype/pull/3784) - General: Remove unused teshost [\#3773](https://github.com/pypeclub/OpenPype/pull/3773) - General: Copied 'Extractor' plugin to publish pipeline [\#3771](https://github.com/pypeclub/OpenPype/pull/3771) - General: Move queries of asset and representation links [\#3770](https://github.com/pypeclub/OpenPype/pull/3770) - General: Move create project folders to pipeline [\#3768](https://github.com/pypeclub/OpenPype/pull/3768) - General: Create project function moved to client code [\#3766](https://github.com/pypeclub/OpenPype/pull/3766) - Maya: Refactor submit deadline to use AbstractSubmitDeadline [\#3759](https://github.com/pypeclub/OpenPype/pull/3759) - General: Change publish template settings location [\#3755](https://github.com/pypeclub/OpenPype/pull/3755) - General: Move hostdirname functionality into host [\#3749](https://github.com/pypeclub/OpenPype/pull/3749) - General: Move publish utils to pipeline [\#3745](https://github.com/pypeclub/OpenPype/pull/3745) - Houdini: Define houdini as addon [\#3735](https://github.com/pypeclub/OpenPype/pull/3735) - Fusion: Defined fusion as addon [\#3733](https://github.com/pypeclub/OpenPype/pull/3733) - Flame: Defined flame as addon [\#3732](https://github.com/pypeclub/OpenPype/pull/3732) - Resolve: Define resolve as addon [\#3727](https://github.com/pypeclub/OpenPype/pull/3727) **Merged pull requests:** - Standalone Publisher: Ignore empty labels, then still use name like other asset models [\#3779](https://github.com/pypeclub/OpenPype/pull/3779) - Kitsu - sync\_all\_project - add list ignore\_projects [\#3776](https://github.com/pypeclub/OpenPype/pull/3776) ## [3.14.1](https://github.com/pypeclub/OpenPype/tree/3.14.1) (2022-08-30) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.0...3.14.1) ### 📖 Documentation - Documentation: Few updates [\#3698](https://github.com/pypeclub/OpenPype/pull/3698) - Documentation: Settings development [\#3660](https://github.com/pypeclub/OpenPype/pull/3660) **🆕 New features** - Webpublisher:change create flatten image into tri state [\#3678](https://github.com/pypeclub/OpenPype/pull/3678) - Blender: validators code correction with settings and defaults [\#3662](https://github.com/pypeclub/OpenPype/pull/3662) **🚀 Enhancements** - General: Thumbnail can use project roots [\#3750](https://github.com/pypeclub/OpenPype/pull/3750) - Settings: Remove settings lock on tray exit [\#3720](https://github.com/pypeclub/OpenPype/pull/3720) - General: Added helper getters to modules manager [\#3712](https://github.com/pypeclub/OpenPype/pull/3712) - Unreal: Define unreal as module and use host class [\#3701](https://github.com/pypeclub/OpenPype/pull/3701) - Settings: Lock settings UI session [\#3700](https://github.com/pypeclub/OpenPype/pull/3700) - General: Benevolent context label collector [\#3686](https://github.com/pypeclub/OpenPype/pull/3686) - Ftrack: Store ftrack entities on hierarchy integration to instances [\#3677](https://github.com/pypeclub/OpenPype/pull/3677) - Ftrack: More logs related to auto sync value change [\#3671](https://github.com/pypeclub/OpenPype/pull/3671) - Blender: ops refresh manager after process events [\#3663](https://github.com/pypeclub/OpenPype/pull/3663) **🐛 Bug fixes** - Maya: Fix typo in getPanel argument `with_focus` -\> `withFocus` [\#3753](https://github.com/pypeclub/OpenPype/pull/3753) - General: Smaller fixes of imports [\#3748](https://github.com/pypeclub/OpenPype/pull/3748) - General: Logger tweaks [\#3741](https://github.com/pypeclub/OpenPype/pull/3741) - Nuke: missing job dependency if multiple bake streams [\#3737](https://github.com/pypeclub/OpenPype/pull/3737) - Nuke: color-space settings from anatomy is working [\#3721](https://github.com/pypeclub/OpenPype/pull/3721) - Settings: Fix studio default anatomy save [\#3716](https://github.com/pypeclub/OpenPype/pull/3716) - Maya: Use project name instead of project code [\#3709](https://github.com/pypeclub/OpenPype/pull/3709) - Settings: Fix project overrides save [\#3708](https://github.com/pypeclub/OpenPype/pull/3708) - Workfiles tool: Fix published workfile filtering [\#3704](https://github.com/pypeclub/OpenPype/pull/3704) - PS, AE: Provide default variant value for workfile subset [\#3703](https://github.com/pypeclub/OpenPype/pull/3703) - RoyalRender: handle host name that is not set [\#3695](https://github.com/pypeclub/OpenPype/pull/3695) - Flame: retime is working on clip publishing [\#3684](https://github.com/pypeclub/OpenPype/pull/3684) - Webpublisher: added check for empty context [\#3682](https://github.com/pypeclub/OpenPype/pull/3682) **🔀 Refactored code** - General: Move delivery logic to pipeline [\#3751](https://github.com/pypeclub/OpenPype/pull/3751) - General: Host addons cleanup [\#3744](https://github.com/pypeclub/OpenPype/pull/3744) - Webpublisher: Webpublisher is used as addon [\#3740](https://github.com/pypeclub/OpenPype/pull/3740) - Photoshop: Defined photoshop as addon [\#3736](https://github.com/pypeclub/OpenPype/pull/3736) - Harmony: Defined harmony as addon [\#3734](https://github.com/pypeclub/OpenPype/pull/3734) - General: Module interfaces cleanup [\#3731](https://github.com/pypeclub/OpenPype/pull/3731) - AfterEffects: Move AE functions from general lib [\#3730](https://github.com/pypeclub/OpenPype/pull/3730) - Blender: Define blender as module [\#3729](https://github.com/pypeclub/OpenPype/pull/3729) - AfterEffects: Define AfterEffects as module [\#3728](https://github.com/pypeclub/OpenPype/pull/3728) - General: Replace PypeLogger with Logger [\#3725](https://github.com/pypeclub/OpenPype/pull/3725) - Nuke: Define nuke as module [\#3724](https://github.com/pypeclub/OpenPype/pull/3724) - General: Move subset name functionality [\#3723](https://github.com/pypeclub/OpenPype/pull/3723) - General: Move creators plugin getter [\#3714](https://github.com/pypeclub/OpenPype/pull/3714) - General: Move constants from lib to client [\#3713](https://github.com/pypeclub/OpenPype/pull/3713) - Loader: Subset groups using client operations [\#3710](https://github.com/pypeclub/OpenPype/pull/3710) - TVPaint: Defined as module [\#3707](https://github.com/pypeclub/OpenPype/pull/3707) - StandalonePublisher: Define StandalonePublisher as module [\#3706](https://github.com/pypeclub/OpenPype/pull/3706) - TrayPublisher: Define TrayPublisher as module [\#3705](https://github.com/pypeclub/OpenPype/pull/3705) - General: Move context specific functions to context tools [\#3702](https://github.com/pypeclub/OpenPype/pull/3702) **Merged pull requests:** - Hiero: Define hiero as module [\#3717](https://github.com/pypeclub/OpenPype/pull/3717) - Deadline: better logging for DL webservice failures [\#3694](https://github.com/pypeclub/OpenPype/pull/3694) - Photoshop: resize saved images in ExtractReview for ffmpeg [\#3676](https://github.com/pypeclub/OpenPype/pull/3676) - Nuke: Validation refactory to new publisher [\#3567](https://github.com/pypeclub/OpenPype/pull/3567) ## [3.14.0](https://github.com/pypeclub/OpenPype/tree/3.14.0) (2022-08-18) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.13.0...3.14.0) **🆕 New features** - Maya: Build workfile by template [\#3578](https://github.com/pypeclub/OpenPype/pull/3578) - Maya: Implementation of JSON layout for Unreal workflow [\#3353](https://github.com/pypeclub/OpenPype/pull/3353) - Maya: Build workfile by template [\#3315](https://github.com/pypeclub/OpenPype/pull/3315) **🚀 Enhancements** - Ftrack: Addiotional component metadata [\#3685](https://github.com/pypeclub/OpenPype/pull/3685) - Ftrack: Set task status on farm publishing [\#3680](https://github.com/pypeclub/OpenPype/pull/3680) - Ftrack: Set task status on task creation in integrate hierarchy [\#3675](https://github.com/pypeclub/OpenPype/pull/3675) - Maya: Disable rendering of all lights for render instances submitted through Deadline. [\#3661](https://github.com/pypeclub/OpenPype/pull/3661) - General: Optimized OCIO configs [\#3650](https://github.com/pypeclub/OpenPype/pull/3650) **🐛 Bug fixes** - General: Switch from hero version to versioned works [\#3691](https://github.com/pypeclub/OpenPype/pull/3691) - General: Fix finding of last version [\#3656](https://github.com/pypeclub/OpenPype/pull/3656) - General: Extract Review can scale with pixel aspect ratio [\#3644](https://github.com/pypeclub/OpenPype/pull/3644) - Maya: Refactor moved usage of CreateRender settings [\#3643](https://github.com/pypeclub/OpenPype/pull/3643) - General: Hero version representations have full context [\#3638](https://github.com/pypeclub/OpenPype/pull/3638) - Nuke: color settings for render write node is working now [\#3632](https://github.com/pypeclub/OpenPype/pull/3632) - Maya: FBX support for update in reference loader [\#3631](https://github.com/pypeclub/OpenPype/pull/3631) **🔀 Refactored code** - General: Use client projects getter [\#3673](https://github.com/pypeclub/OpenPype/pull/3673) - Resolve: Match folder structure to other hosts [\#3653](https://github.com/pypeclub/OpenPype/pull/3653) - Maya: Hosts as modules [\#3647](https://github.com/pypeclub/OpenPype/pull/3647) - TimersManager: Plugins are in timers manager module [\#3639](https://github.com/pypeclub/OpenPype/pull/3639) - General: Move workfiles functions into pipeline [\#3637](https://github.com/pypeclub/OpenPype/pull/3637) - General: Workfiles builder using query functions [\#3598](https://github.com/pypeclub/OpenPype/pull/3598) **Merged pull requests:** - Deadline: Global job pre load is not Pype 2 compatible [\#3666](https://github.com/pypeclub/OpenPype/pull/3666) - Maya: Remove unused get current renderer logic [\#3645](https://github.com/pypeclub/OpenPype/pull/3645) - Kitsu|Fix: Movie project type fails & first loop children names [\#3636](https://github.com/pypeclub/OpenPype/pull/3636) - fix the bug of failing to extract look when UDIMs format used in AiImage [\#3628](https://github.com/pypeclub/OpenPype/pull/3628) ## [3.13.0](https://github.com/pypeclub/OpenPype/tree/3.13.0) (2022-08-09) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.12.2...3.13.0) **🆕 New features** - Support for mutliple installed versions - 3.13 [\#3605](https://github.com/pypeclub/OpenPype/pull/3605) - Traypublisher: simple editorial publishing [\#3492](https://github.com/pypeclub/OpenPype/pull/3492) **🚀 Enhancements** - Editorial: Mix audio use side file for ffmpeg filters [\#3630](https://github.com/pypeclub/OpenPype/pull/3630) - Ftrack: Comment template can contain optional keys [\#3615](https://github.com/pypeclub/OpenPype/pull/3615) - Ftrack: Add more metadata to ftrack components [\#3612](https://github.com/pypeclub/OpenPype/pull/3612) - General: Add context to pyblish context [\#3594](https://github.com/pypeclub/OpenPype/pull/3594) - Kitsu: Shot&Sequence name with prefix over appends [\#3593](https://github.com/pypeclub/OpenPype/pull/3593) - Photoshop: implemented {layer} placeholder in subset template [\#3591](https://github.com/pypeclub/OpenPype/pull/3591) - General: Python module appdirs from git [\#3589](https://github.com/pypeclub/OpenPype/pull/3589) - Ftrack: Update ftrack api to 2.3.3 [\#3588](https://github.com/pypeclub/OpenPype/pull/3588) - General: New Integrator small fixes [\#3583](https://github.com/pypeclub/OpenPype/pull/3583) - Maya: Render Creator has configurable options. [\#3097](https://github.com/pypeclub/OpenPype/pull/3097) **🐛 Bug fixes** - Maya: fix aov separator in Redshift [\#3625](https://github.com/pypeclub/OpenPype/pull/3625) - Fix for multi-version build on Mac [\#3622](https://github.com/pypeclub/OpenPype/pull/3622) - Ftrack: Sync hierarchical attributes can handle new created entities [\#3621](https://github.com/pypeclub/OpenPype/pull/3621) - General: Extract review aspect ratio scale is calculated by ffmpeg [\#3620](https://github.com/pypeclub/OpenPype/pull/3620) - Maya: Fix types of default settings [\#3617](https://github.com/pypeclub/OpenPype/pull/3617) - Integrator: Don't force to have dot before frame [\#3611](https://github.com/pypeclub/OpenPype/pull/3611) - AfterEffects: refactored integrate doesnt work formulti frame publishes [\#3610](https://github.com/pypeclub/OpenPype/pull/3610) - Maya look data contents fails with custom attribute on group [\#3607](https://github.com/pypeclub/OpenPype/pull/3607) - TrayPublisher: Fix wrong conflict merge [\#3600](https://github.com/pypeclub/OpenPype/pull/3600) - Bugfix: Add OCIO as submodule to prepare for handling `maketx` color space conversion. [\#3590](https://github.com/pypeclub/OpenPype/pull/3590) - Fix general settings environment variables resolution [\#3587](https://github.com/pypeclub/OpenPype/pull/3587) - Editorial publishing workflow improvements [\#3580](https://github.com/pypeclub/OpenPype/pull/3580) - General: Update imports in start script [\#3579](https://github.com/pypeclub/OpenPype/pull/3579) - Nuke: render family integration consistency [\#3576](https://github.com/pypeclub/OpenPype/pull/3576) - Ftrack: Handle missing published path in integrator [\#3570](https://github.com/pypeclub/OpenPype/pull/3570) - Nuke: publish existing frames with slate with correct range [\#3555](https://github.com/pypeclub/OpenPype/pull/3555) **🔀 Refactored code** - General: Plugin settings handled by plugins [\#3623](https://github.com/pypeclub/OpenPype/pull/3623) - General: Naive implementation of document create, update, delete [\#3601](https://github.com/pypeclub/OpenPype/pull/3601) - General: Use query functions in general code [\#3596](https://github.com/pypeclub/OpenPype/pull/3596) - General: Separate extraction of template data into more functions [\#3574](https://github.com/pypeclub/OpenPype/pull/3574) - General: Lib cleanup [\#3571](https://github.com/pypeclub/OpenPype/pull/3571) **Merged pull requests:** - Webpublisher: timeout for PS studio processing [\#3619](https://github.com/pypeclub/OpenPype/pull/3619) - Core: translated validate\_containers.py into New publisher style [\#3614](https://github.com/pypeclub/OpenPype/pull/3614) - Enable write color sets on animation publish automatically [\#3582](https://github.com/pypeclub/OpenPype/pull/3582) ## [3.12.2](https://github.com/pypeclub/OpenPype/tree/3.12.2) (2022-07-27) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.12.1...3.12.2) ### 📖 Documentation - Update website with more studios [\#3554](https://github.com/pypeclub/OpenPype/pull/3554) - Documentation: Update publishing dev docs [\#3549](https://github.com/pypeclub/OpenPype/pull/3549) **🚀 Enhancements** - General: Global thumbnail extractor is ready for more cases [\#3561](https://github.com/pypeclub/OpenPype/pull/3561) - Maya: add additional validators to Settings [\#3540](https://github.com/pypeclub/OpenPype/pull/3540) - General: Interactive console in cli [\#3526](https://github.com/pypeclub/OpenPype/pull/3526) - Ftrack: Automatic daily review session creation can define trigger hour [\#3516](https://github.com/pypeclub/OpenPype/pull/3516) - Ftrack: add source into Note [\#3509](https://github.com/pypeclub/OpenPype/pull/3509) - Ftrack: Trigger custom ftrack topic of project structure creation [\#3506](https://github.com/pypeclub/OpenPype/pull/3506) - Settings UI: Add extract to file action on project view [\#3505](https://github.com/pypeclub/OpenPype/pull/3505) - Add pack and unpack convenience scripts [\#3502](https://github.com/pypeclub/OpenPype/pull/3502) - General: Event system [\#3499](https://github.com/pypeclub/OpenPype/pull/3499) - NewPublisher: Keep plugins with mismatch target in report [\#3498](https://github.com/pypeclub/OpenPype/pull/3498) - Nuke: load clip with options from settings [\#3497](https://github.com/pypeclub/OpenPype/pull/3497) - TrayPublisher: implemented render\_mov\_batch [\#3486](https://github.com/pypeclub/OpenPype/pull/3486) - Migrate basic families to the new Tray Publisher [\#3469](https://github.com/pypeclub/OpenPype/pull/3469) - Enhance powershell build scripts [\#1827](https://github.com/pypeclub/OpenPype/pull/1827) **🐛 Bug fixes** - Maya: fix Review image plane attribute [\#3569](https://github.com/pypeclub/OpenPype/pull/3569) - Maya: Fix animated attributes \(ie. overscan\) on loaded cameras breaking review publishing. [\#3562](https://github.com/pypeclub/OpenPype/pull/3562) - NewPublisher: Python 2 compatible html escape [\#3559](https://github.com/pypeclub/OpenPype/pull/3559) - Remove invalid submodules from `/vendor` [\#3557](https://github.com/pypeclub/OpenPype/pull/3557) - General: Remove hosts filter on integrator plugins [\#3556](https://github.com/pypeclub/OpenPype/pull/3556) - Settings: Clean default values of environments [\#3550](https://github.com/pypeclub/OpenPype/pull/3550) - Module interfaces: Fix import error [\#3547](https://github.com/pypeclub/OpenPype/pull/3547) - Workfiles tool: Show of tool and it's flags [\#3539](https://github.com/pypeclub/OpenPype/pull/3539) - General: Create workfile documents works again [\#3538](https://github.com/pypeclub/OpenPype/pull/3538) - Additional fixes for powershell scripts [\#3525](https://github.com/pypeclub/OpenPype/pull/3525) - Maya: Added wrapper around cmds.setAttr [\#3523](https://github.com/pypeclub/OpenPype/pull/3523) - Nuke: double slate [\#3521](https://github.com/pypeclub/OpenPype/pull/3521) - General: Fix hash of centos oiio archive [\#3519](https://github.com/pypeclub/OpenPype/pull/3519) - Maya: Renderman display output fix [\#3514](https://github.com/pypeclub/OpenPype/pull/3514) - TrayPublisher: Simple creation enhancements and fixes [\#3513](https://github.com/pypeclub/OpenPype/pull/3513) - NewPublisher: Publish attributes are properly collected [\#3510](https://github.com/pypeclub/OpenPype/pull/3510) - TrayPublisher: Make sure host name is filled [\#3504](https://github.com/pypeclub/OpenPype/pull/3504) - NewPublisher: Groups work and enum multivalue [\#3501](https://github.com/pypeclub/OpenPype/pull/3501) **🔀 Refactored code** - General: Use query functions in integrator [\#3563](https://github.com/pypeclub/OpenPype/pull/3563) - General: Mongo core connection moved to client [\#3531](https://github.com/pypeclub/OpenPype/pull/3531) - Refactor Integrate Asset [\#3530](https://github.com/pypeclub/OpenPype/pull/3530) - General: Client docstrings cleanup [\#3529](https://github.com/pypeclub/OpenPype/pull/3529) - General: Move load related functions into pipeline [\#3527](https://github.com/pypeclub/OpenPype/pull/3527) - General: Get current context document functions [\#3522](https://github.com/pypeclub/OpenPype/pull/3522) - Kitsu: Use query function from client [\#3496](https://github.com/pypeclub/OpenPype/pull/3496) - TimersManager: Use query functions [\#3495](https://github.com/pypeclub/OpenPype/pull/3495) - Deadline: Use query functions [\#3466](https://github.com/pypeclub/OpenPype/pull/3466) - Refactor Integrate Asset [\#2898](https://github.com/pypeclub/OpenPype/pull/2898) **Merged pull requests:** - Maya: fix active pane loss [\#3566](https://github.com/pypeclub/OpenPype/pull/3566) ## [3.12.1](https://github.com/pypeclub/OpenPype/tree/3.12.1) (2022-07-13) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.12.0...3.12.1) ### 📖 Documentation - Docs: Added minimal permissions for MongoDB [\#3441](https://github.com/pypeclub/OpenPype/pull/3441) **🆕 New features** - Maya: Add VDB to Arnold loader [\#3433](https://github.com/pypeclub/OpenPype/pull/3433) **🚀 Enhancements** - TrayPublisher: Added more options for grouping of instances [\#3494](https://github.com/pypeclub/OpenPype/pull/3494) - NewPublisher: Align creator attributes from top to bottom [\#3487](https://github.com/pypeclub/OpenPype/pull/3487) - NewPublisher: Added ability to use label of instance [\#3484](https://github.com/pypeclub/OpenPype/pull/3484) - General: Creator Plugins have access to project [\#3476](https://github.com/pypeclub/OpenPype/pull/3476) - General: Better arguments order in creator init [\#3475](https://github.com/pypeclub/OpenPype/pull/3475) - Ftrack: Trigger custom ftrack events on project creation and preparation [\#3465](https://github.com/pypeclub/OpenPype/pull/3465) - Windows installer: Clean old files and add version subfolder [\#3445](https://github.com/pypeclub/OpenPype/pull/3445) - Blender: Bugfix - Set fps properly on open [\#3426](https://github.com/pypeclub/OpenPype/pull/3426) - Hiero: Add custom scripts menu [\#3425](https://github.com/pypeclub/OpenPype/pull/3425) - Blender: pre pyside install for all platforms [\#3400](https://github.com/pypeclub/OpenPype/pull/3400) - Maya: Add additional playblast options to review Extractor. [\#3384](https://github.com/pypeclub/OpenPype/pull/3384) - Maya: Ability to set resolution for playblasts from asset, and override through review instance. [\#3360](https://github.com/pypeclub/OpenPype/pull/3360) - Maya: Redshift Volume Loader Implement update, remove, switch + fix vdb sequence support [\#3197](https://github.com/pypeclub/OpenPype/pull/3197) - Maya: Implement `iter_visible_nodes_in_range` for extracting Alembics [\#3100](https://github.com/pypeclub/OpenPype/pull/3100) **🐛 Bug fixes** - TrayPublisher: Keep use instance label in list view [\#3493](https://github.com/pypeclub/OpenPype/pull/3493) - General: Extract review use first frame of input sequence [\#3491](https://github.com/pypeclub/OpenPype/pull/3491) - General: Fix Plist loading for application launch [\#3485](https://github.com/pypeclub/OpenPype/pull/3485) - Nuke: Workfile tools open on start [\#3479](https://github.com/pypeclub/OpenPype/pull/3479) - New Publisher: Disabled context change allows creation [\#3478](https://github.com/pypeclub/OpenPype/pull/3478) - General: thumbnail extractor fix [\#3474](https://github.com/pypeclub/OpenPype/pull/3474) - Kitsu: bugfix with sync-service ans publish plugins [\#3473](https://github.com/pypeclub/OpenPype/pull/3473) - Flame: solved problem with multi-selected loading [\#3470](https://github.com/pypeclub/OpenPype/pull/3470) - General: Fix query function in update logic [\#3468](https://github.com/pypeclub/OpenPype/pull/3468) - Resolve: removed few bugs [\#3464](https://github.com/pypeclub/OpenPype/pull/3464) - General: Delete old versions is safer when ftrack is disabled [\#3462](https://github.com/pypeclub/OpenPype/pull/3462) - Nuke: fixing metadata slate TC difference [\#3455](https://github.com/pypeclub/OpenPype/pull/3455) - Nuke: prerender reviewable fails [\#3450](https://github.com/pypeclub/OpenPype/pull/3450) - Maya: fix hashing in Python 3 for tile rendering [\#3447](https://github.com/pypeclub/OpenPype/pull/3447) - LogViewer: Escape html characters in log message [\#3443](https://github.com/pypeclub/OpenPype/pull/3443) - Nuke: Slate frame is integrated [\#3427](https://github.com/pypeclub/OpenPype/pull/3427) - Maya: Camera extra data - additional fix for \#3304 [\#3386](https://github.com/pypeclub/OpenPype/pull/3386) - Maya: Handle excluding `model` family from frame range validator. [\#3370](https://github.com/pypeclub/OpenPype/pull/3370) **🔀 Refactored code** - Maya: Merge animation + pointcache extractor logic [\#3461](https://github.com/pypeclub/OpenPype/pull/3461) - Maya: Re-use `maintained_time` from lib [\#3460](https://github.com/pypeclub/OpenPype/pull/3460) - General: Use query functions in global plugins [\#3459](https://github.com/pypeclub/OpenPype/pull/3459) - Clockify: Use query functions in clockify actions [\#3458](https://github.com/pypeclub/OpenPype/pull/3458) - General: Use query functions in rest api calls [\#3457](https://github.com/pypeclub/OpenPype/pull/3457) - General: Use query functions in openpype lib functions [\#3454](https://github.com/pypeclub/OpenPype/pull/3454) - General: Use query functions in load utils [\#3446](https://github.com/pypeclub/OpenPype/pull/3446) - General: Move publish plugin and publish render abstractions [\#3442](https://github.com/pypeclub/OpenPype/pull/3442) - General: Use Anatomy after move to pipeline [\#3436](https://github.com/pypeclub/OpenPype/pull/3436) - General: Anatomy moved to pipeline [\#3435](https://github.com/pypeclub/OpenPype/pull/3435) - Fusion: Use client query functions [\#3380](https://github.com/pypeclub/OpenPype/pull/3380) - Resolve: Use client query functions [\#3379](https://github.com/pypeclub/OpenPype/pull/3379) - General: Host implementation defined with class [\#3337](https://github.com/pypeclub/OpenPype/pull/3337) ## [3.12.0](https://github.com/pypeclub/OpenPype/tree/3.12.0) (2022-06-28) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.11.1...3.12.0) ### 📖 Documentation - Fix typo in documentation: pyenv on mac [\#3417](https://github.com/pypeclub/OpenPype/pull/3417) - Linux: update OIIO package [\#3401](https://github.com/pypeclub/OpenPype/pull/3401) **🆕 New features** - Shotgrid: Add production beta of shotgrid integration [\#2921](https://github.com/pypeclub/OpenPype/pull/2921) **🚀 Enhancements** - Webserver: Added CORS middleware [\#3422](https://github.com/pypeclub/OpenPype/pull/3422) - Attribute Defs UI: Files widget show what is allowed to drop in [\#3411](https://github.com/pypeclub/OpenPype/pull/3411) - General: Add ability to change user value for templates [\#3366](https://github.com/pypeclub/OpenPype/pull/3366) - Hosts: More options for in-host callbacks [\#3357](https://github.com/pypeclub/OpenPype/pull/3357) - Multiverse: expose some settings to GUI [\#3350](https://github.com/pypeclub/OpenPype/pull/3350) - Maya: Allow more data to be published along camera 🎥 [\#3304](https://github.com/pypeclub/OpenPype/pull/3304) - Add root keys and project keys to create starting folder [\#2755](https://github.com/pypeclub/OpenPype/pull/2755) **🐛 Bug fixes** - NewPublisher: Fix subset name change on change of creator plugin [\#3420](https://github.com/pypeclub/OpenPype/pull/3420) - Bug: fix invalid avalon import [\#3418](https://github.com/pypeclub/OpenPype/pull/3418) - Nuke: Fix keyword argument in query function [\#3414](https://github.com/pypeclub/OpenPype/pull/3414) - Houdini: fix loading and updating vbd/bgeo sequences [\#3408](https://github.com/pypeclub/OpenPype/pull/3408) - Nuke: Collect representation files based on Write [\#3407](https://github.com/pypeclub/OpenPype/pull/3407) - General: Filter representations before integration start [\#3398](https://github.com/pypeclub/OpenPype/pull/3398) - Maya: look collector typo [\#3392](https://github.com/pypeclub/OpenPype/pull/3392) - TVPaint: Make sure exit code is set to not None [\#3382](https://github.com/pypeclub/OpenPype/pull/3382) - Maya: vray device aspect ratio fix [\#3381](https://github.com/pypeclub/OpenPype/pull/3381) - Flame: bunch of publishing issues [\#3377](https://github.com/pypeclub/OpenPype/pull/3377) - Harmony: added unc path to zifile command in Harmony [\#3372](https://github.com/pypeclub/OpenPype/pull/3372) - Standalone: settings improvements [\#3355](https://github.com/pypeclub/OpenPype/pull/3355) - Nuke: Load full model hierarchy by default [\#3328](https://github.com/pypeclub/OpenPype/pull/3328) - Nuke: multiple baking streams with correct slate [\#3245](https://github.com/pypeclub/OpenPype/pull/3245) - Maya: fix image prefix warning in validator [\#3128](https://github.com/pypeclub/OpenPype/pull/3128) **🔀 Refactored code** - Unreal: Use client query functions [\#3421](https://github.com/pypeclub/OpenPype/pull/3421) - General: Move editorial lib to pipeline [\#3419](https://github.com/pypeclub/OpenPype/pull/3419) - Kitsu: renaming to plural func sync\_all\_projects [\#3397](https://github.com/pypeclub/OpenPype/pull/3397) - Houdini: Use client query functions [\#3395](https://github.com/pypeclub/OpenPype/pull/3395) - Hiero: Use client query functions [\#3393](https://github.com/pypeclub/OpenPype/pull/3393) - Nuke: Use client query functions [\#3391](https://github.com/pypeclub/OpenPype/pull/3391) - Maya: Use client query functions [\#3385](https://github.com/pypeclub/OpenPype/pull/3385) - Harmony: Use client query functions [\#3378](https://github.com/pypeclub/OpenPype/pull/3378) - Celaction: Use client query functions [\#3376](https://github.com/pypeclub/OpenPype/pull/3376) - Photoshop: Use client query functions [\#3375](https://github.com/pypeclub/OpenPype/pull/3375) - AfterEffects: Use client query functions [\#3374](https://github.com/pypeclub/OpenPype/pull/3374) - TVPaint: Use client query functions [\#3340](https://github.com/pypeclub/OpenPype/pull/3340) - Ftrack: Use client query functions [\#3339](https://github.com/pypeclub/OpenPype/pull/3339) - Standalone Publisher: Use client query functions [\#3330](https://github.com/pypeclub/OpenPype/pull/3330) **Merged pull requests:** - Sync Queue: Added far future value for null values for dates [\#3371](https://github.com/pypeclub/OpenPype/pull/3371) - Maya - added support for single frame playblast review [\#3369](https://github.com/pypeclub/OpenPype/pull/3369) - Houdini: Implement Redshift Proxy Export [\#3196](https://github.com/pypeclub/OpenPype/pull/3196) ## [3.11.1](https://github.com/pypeclub/OpenPype/tree/3.11.1) (2022-06-20) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.11.0...3.11.1) **🆕 New features** - Flame: custom export temp folder [\#3346](https://github.com/pypeclub/OpenPype/pull/3346) - Nuke: removing third-party plugins [\#3344](https://github.com/pypeclub/OpenPype/pull/3344) **🚀 Enhancements** - Pyblish Pype: Hiding/Close issues [\#3367](https://github.com/pypeclub/OpenPype/pull/3367) - Ftrack: Removed requirement of pypeclub role from default settings [\#3354](https://github.com/pypeclub/OpenPype/pull/3354) - Kitsu: Prevent crash on missing frames information [\#3352](https://github.com/pypeclub/OpenPype/pull/3352) - Ftrack: Open browser from tray [\#3320](https://github.com/pypeclub/OpenPype/pull/3320) - Enhancement: More control over thumbnail processing. [\#3259](https://github.com/pypeclub/OpenPype/pull/3259) **🐛 Bug fixes** - Nuke: bake streams with slate on farm [\#3368](https://github.com/pypeclub/OpenPype/pull/3368) - Harmony: audio validator has wrong logic [\#3364](https://github.com/pypeclub/OpenPype/pull/3364) - Nuke: Fix missing variable in extract thumbnail [\#3363](https://github.com/pypeclub/OpenPype/pull/3363) - Nuke: Fix precollect writes [\#3361](https://github.com/pypeclub/OpenPype/pull/3361) - AE- fix validate\_scene\_settings and renderLocal [\#3358](https://github.com/pypeclub/OpenPype/pull/3358) - deadline: fixing misidentification of revieables [\#3356](https://github.com/pypeclub/OpenPype/pull/3356) - General: Create only one thumbnail per instance [\#3351](https://github.com/pypeclub/OpenPype/pull/3351) - nuke: adding extract thumbnail settings 3.10 [\#3347](https://github.com/pypeclub/OpenPype/pull/3347) - General: Fix last version function [\#3345](https://github.com/pypeclub/OpenPype/pull/3345) - Deadline: added OPENPYPE\_MONGO to filter [\#3336](https://github.com/pypeclub/OpenPype/pull/3336) - Nuke: fixing farm publishing if review is disabled [\#3306](https://github.com/pypeclub/OpenPype/pull/3306) - Maya: Fix Yeti errors on Create, Publish and Load [\#3198](https://github.com/pypeclub/OpenPype/pull/3198) **🔀 Refactored code** - Webpublisher: Use client query functions [\#3333](https://github.com/pypeclub/OpenPype/pull/3333) ## [3.11.0](https://github.com/pypeclub/OpenPype/tree/3.11.0) (2022-06-17) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.10.0...3.11.0) ### 📖 Documentation - Documentation: Add app key to template documentation [\#3299](https://github.com/pypeclub/OpenPype/pull/3299) - doc: adding royal render and multiverse to the web site [\#3285](https://github.com/pypeclub/OpenPype/pull/3285) - Module: Kitsu module [\#2650](https://github.com/pypeclub/OpenPype/pull/2650) **🆕 New features** - Multiverse: fixed composition write, full docs, cosmetics [\#3178](https://github.com/pypeclub/OpenPype/pull/3178) **🚀 Enhancements** - Settings: Settings can be extracted from UI [\#3323](https://github.com/pypeclub/OpenPype/pull/3323) - updated poetry installation source [\#3316](https://github.com/pypeclub/OpenPype/pull/3316) - Ftrack: Action to easily create daily review session [\#3310](https://github.com/pypeclub/OpenPype/pull/3310) - TVPaint: Extractor use mark in/out range to render [\#3309](https://github.com/pypeclub/OpenPype/pull/3309) - Ftrack: Delivery action can work on ReviewSessions [\#3307](https://github.com/pypeclub/OpenPype/pull/3307) - Maya: Look assigner UI improvements [\#3298](https://github.com/pypeclub/OpenPype/pull/3298) - Ftrack: Action to transfer values of hierarchical attributes [\#3284](https://github.com/pypeclub/OpenPype/pull/3284) - Maya: better handling of legacy review subsets names [\#3269](https://github.com/pypeclub/OpenPype/pull/3269) - General: Updated windows oiio tool [\#3268](https://github.com/pypeclub/OpenPype/pull/3268) - Unreal: add support for skeletalMesh and staticMesh to loaders [\#3267](https://github.com/pypeclub/OpenPype/pull/3267) - Maya: reference loaders could store placeholder in referenced url [\#3264](https://github.com/pypeclub/OpenPype/pull/3264) - TVPaint: Init file for TVPaint worker also handle guideline images [\#3250](https://github.com/pypeclub/OpenPype/pull/3250) - Nuke: Change default icon path in settings [\#3247](https://github.com/pypeclub/OpenPype/pull/3247) - Maya: publishing of animation and pointcache on a farm [\#3225](https://github.com/pypeclub/OpenPype/pull/3225) - Maya: Look assigner UI improvements [\#3208](https://github.com/pypeclub/OpenPype/pull/3208) - Nuke: add pointcache and animation to loader [\#3186](https://github.com/pypeclub/OpenPype/pull/3186) - Nuke: Add a gizmo menu [\#3172](https://github.com/pypeclub/OpenPype/pull/3172) - Support for Unreal 5 [\#3122](https://github.com/pypeclub/OpenPype/pull/3122) **🐛 Bug fixes** - General: Handle empty source key on instance [\#3342](https://github.com/pypeclub/OpenPype/pull/3342) - Houdini: Fix Houdini VDB manage update wrong file attribute name [\#3322](https://github.com/pypeclub/OpenPype/pull/3322) - Nuke: anatomy compatibility issue hacks [\#3321](https://github.com/pypeclub/OpenPype/pull/3321) - hiero: otio p3 compatibility issue - metadata on effect use update 3.11 [\#3314](https://github.com/pypeclub/OpenPype/pull/3314) - General: Vendorized modules for Python 2 and update poetry lock [\#3305](https://github.com/pypeclub/OpenPype/pull/3305) - Fix - added local targets to install host [\#3303](https://github.com/pypeclub/OpenPype/pull/3303) - Settings: Add missing default settings for nuke gizmo [\#3301](https://github.com/pypeclub/OpenPype/pull/3301) - Maya: Fix swaped width and height in reviews [\#3300](https://github.com/pypeclub/OpenPype/pull/3300) - Maya: point cache publish handles Maya instances [\#3297](https://github.com/pypeclub/OpenPype/pull/3297) - Global: extract review slate issues [\#3286](https://github.com/pypeclub/OpenPype/pull/3286) - Webpublisher: return only active projects in ProjectsEndpoint [\#3281](https://github.com/pypeclub/OpenPype/pull/3281) - Hiero: add support for task tags 3.10.x [\#3279](https://github.com/pypeclub/OpenPype/pull/3279) - General: Fix Oiio tool path resolving [\#3278](https://github.com/pypeclub/OpenPype/pull/3278) - Maya: Fix udim support for e.g. uppercase \ tag [\#3266](https://github.com/pypeclub/OpenPype/pull/3266) - Nuke: bake reformat was failing on string type [\#3261](https://github.com/pypeclub/OpenPype/pull/3261) - Maya: hotfix Pxr multitexture in looks [\#3260](https://github.com/pypeclub/OpenPype/pull/3260) - Unreal: Fix Camera Loading if Layout is missing [\#3255](https://github.com/pypeclub/OpenPype/pull/3255) - Unreal: Fixed Animation loading in UE5 [\#3240](https://github.com/pypeclub/OpenPype/pull/3240) - Unreal: Fixed Render creation in UE5 [\#3239](https://github.com/pypeclub/OpenPype/pull/3239) - Unreal: Fixed Camera loading in UE5 [\#3238](https://github.com/pypeclub/OpenPype/pull/3238) - Flame: debugging [\#3224](https://github.com/pypeclub/OpenPype/pull/3224) - add silent audio to slate [\#3162](https://github.com/pypeclub/OpenPype/pull/3162) - Add timecode to slate [\#2929](https://github.com/pypeclub/OpenPype/pull/2929) **🔀 Refactored code** - Blender: Use client query functions [\#3331](https://github.com/pypeclub/OpenPype/pull/3331) - General: Define query functions [\#3288](https://github.com/pypeclub/OpenPype/pull/3288) **Merged pull requests:** - Maya: add pointcache family to gpu cache loader [\#3318](https://github.com/pypeclub/OpenPype/pull/3318) - Maya look: skip empty file attributes [\#3274](https://github.com/pypeclub/OpenPype/pull/3274) ## [3.10.0](https://github.com/pypeclub/OpenPype/tree/3.10.0) (2022-05-26) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.8...3.10.0) ### 📖 Documentation - Docs: add all-contributors config and initial list [\#3094](https://github.com/pypeclub/OpenPype/pull/3094) - Nuke docs with videos [\#3052](https://github.com/pypeclub/OpenPype/pull/3052) **🆕 New features** - General: OpenPype modules publish plugins are registered in host [\#3180](https://github.com/pypeclub/OpenPype/pull/3180) - General: Creator plugins from addons can be registered [\#3179](https://github.com/pypeclub/OpenPype/pull/3179) - Ftrack: Single image reviewable [\#3157](https://github.com/pypeclub/OpenPype/pull/3157) - Nuke: Expose write attributes to settings [\#3123](https://github.com/pypeclub/OpenPype/pull/3123) - Hiero: Initial frame publish support [\#3106](https://github.com/pypeclub/OpenPype/pull/3106) - Unreal: Render Publishing [\#2917](https://github.com/pypeclub/OpenPype/pull/2917) - AfterEffects: Implemented New Publisher [\#2838](https://github.com/pypeclub/OpenPype/pull/2838) - Unreal: Rendering implementation [\#2410](https://github.com/pypeclub/OpenPype/pull/2410) **🚀 Enhancements** - Maya: FBX camera export [\#3253](https://github.com/pypeclub/OpenPype/pull/3253) - General: updating common vendor `scriptmenu` to 1.5.2 [\#3246](https://github.com/pypeclub/OpenPype/pull/3246) - Project Manager: Allow to paste Tasks into multiple assets at the same time [\#3226](https://github.com/pypeclub/OpenPype/pull/3226) - Project manager: Sped up project load [\#3216](https://github.com/pypeclub/OpenPype/pull/3216) - Loader UI: Speed issues of loader with sync server [\#3199](https://github.com/pypeclub/OpenPype/pull/3199) - Looks: add basic support for Renderman [\#3190](https://github.com/pypeclub/OpenPype/pull/3190) - Maya: added clean\_import option to Import loader [\#3181](https://github.com/pypeclub/OpenPype/pull/3181) - Add the scripts menu definition to nuke [\#3168](https://github.com/pypeclub/OpenPype/pull/3168) - Maya: add maya 2023 to default applications [\#3167](https://github.com/pypeclub/OpenPype/pull/3167) - Compressed bgeo publishing in SAP and Houdini loader [\#3153](https://github.com/pypeclub/OpenPype/pull/3153) - General: Add 'dataclasses' to required python modules [\#3149](https://github.com/pypeclub/OpenPype/pull/3149) - Hooks: Tweak logging grammar [\#3147](https://github.com/pypeclub/OpenPype/pull/3147) - Nuke: settings for reformat node in CreateWriteRender node [\#3143](https://github.com/pypeclub/OpenPype/pull/3143) - Houdini: Add loader for alembic through Alembic Archive node [\#3140](https://github.com/pypeclub/OpenPype/pull/3140) - Publisher: UI Modifications and fixes [\#3139](https://github.com/pypeclub/OpenPype/pull/3139) - General: Simplified OP modules/addons import [\#3137](https://github.com/pypeclub/OpenPype/pull/3137) - Terminal: Tweak coloring of TrayModuleManager logging enabled states [\#3133](https://github.com/pypeclub/OpenPype/pull/3133) - General: Cleanup some Loader docstrings [\#3131](https://github.com/pypeclub/OpenPype/pull/3131) - Nuke: render instance with subset name filtered overrides [\#3117](https://github.com/pypeclub/OpenPype/pull/3117) - Unreal: Layout and Camera update and remove functions reimplemented and improvements [\#3116](https://github.com/pypeclub/OpenPype/pull/3116) - Settings: Remove environment groups from settings [\#3115](https://github.com/pypeclub/OpenPype/pull/3115) - TVPaint: Match renderlayer key with other hosts [\#3110](https://github.com/pypeclub/OpenPype/pull/3110) - Ftrack: AssetVersion status on publish [\#3108](https://github.com/pypeclub/OpenPype/pull/3108) - Tray publisher: Simple families from settings [\#3105](https://github.com/pypeclub/OpenPype/pull/3105) - Local Settings UI: Overlay messages on save and reset [\#3104](https://github.com/pypeclub/OpenPype/pull/3104) - General: Remove repos related logic [\#3087](https://github.com/pypeclub/OpenPype/pull/3087) - Standalone publisher: add support for bgeo and vdb [\#3080](https://github.com/pypeclub/OpenPype/pull/3080) - Houdini: Fix FPS + outdated content pop-ups [\#3079](https://github.com/pypeclub/OpenPype/pull/3079) - General: Add global log verbose arguments [\#3070](https://github.com/pypeclub/OpenPype/pull/3070) - Flame: extract presets distribution [\#3063](https://github.com/pypeclub/OpenPype/pull/3063) - Update collect\_render.py [\#3055](https://github.com/pypeclub/OpenPype/pull/3055) - SiteSync: Added compute\_resource\_sync\_sites to sync\_server\_module [\#2983](https://github.com/pypeclub/OpenPype/pull/2983) - Maya: Implement Hardware Renderer 2.0 support for Render Products [\#2611](https://github.com/pypeclub/OpenPype/pull/2611) **🐛 Bug fixes** - nuke: use framerange issue [\#3254](https://github.com/pypeclub/OpenPype/pull/3254) - Ftrack: Chunk sizes for queries has minimal condition [\#3244](https://github.com/pypeclub/OpenPype/pull/3244) - Maya: renderman displays needs to be filtered [\#3242](https://github.com/pypeclub/OpenPype/pull/3242) - Ftrack: Validate that the user exists on ftrack [\#3237](https://github.com/pypeclub/OpenPype/pull/3237) - Maya: Fix support for multiple resolutions [\#3236](https://github.com/pypeclub/OpenPype/pull/3236) - TVPaint: Look for more groups than 12 [\#3228](https://github.com/pypeclub/OpenPype/pull/3228) - Hiero: debugging frame range and other 3.10 [\#3222](https://github.com/pypeclub/OpenPype/pull/3222) - Project Manager: Fix persistent editors on project change [\#3218](https://github.com/pypeclub/OpenPype/pull/3218) - Deadline: instance data overwrite fix [\#3214](https://github.com/pypeclub/OpenPype/pull/3214) - Ftrack: Push hierarchical attributes action works [\#3210](https://github.com/pypeclub/OpenPype/pull/3210) - Standalone Publisher: Always create new representation for thumbnail [\#3203](https://github.com/pypeclub/OpenPype/pull/3203) - Photoshop: skip collector when automatic testing [\#3202](https://github.com/pypeclub/OpenPype/pull/3202) - Nuke: render/workfile version sync doesn't work on farm [\#3185](https://github.com/pypeclub/OpenPype/pull/3185) - Ftrack: Review image only if there are no mp4 reviews [\#3183](https://github.com/pypeclub/OpenPype/pull/3183) - Ftrack: Locations deepcopy issue [\#3177](https://github.com/pypeclub/OpenPype/pull/3177) - General: Avoid creating multiple thumbnails [\#3176](https://github.com/pypeclub/OpenPype/pull/3176) - General/Hiero: better clip duration calculation [\#3169](https://github.com/pypeclub/OpenPype/pull/3169) - General: Oiio conversion for ffmpeg checks for invalid characters [\#3166](https://github.com/pypeclub/OpenPype/pull/3166) - Fix for attaching render to subset [\#3164](https://github.com/pypeclub/OpenPype/pull/3164) - Harmony: fixed missing task name in render instance [\#3163](https://github.com/pypeclub/OpenPype/pull/3163) - Ftrack: Action delete old versions formatting works [\#3152](https://github.com/pypeclub/OpenPype/pull/3152) - Deadline: fix the output directory [\#3144](https://github.com/pypeclub/OpenPype/pull/3144) - General: New Session schema [\#3141](https://github.com/pypeclub/OpenPype/pull/3141) - General: Missing version on headless mode crash properly [\#3136](https://github.com/pypeclub/OpenPype/pull/3136) - TVPaint: Composite layers in reversed order [\#3135](https://github.com/pypeclub/OpenPype/pull/3135) - Nuke: fixing default settings for workfile builder loaders [\#3120](https://github.com/pypeclub/OpenPype/pull/3120) - Nuke: fix anatomy imageio regex default [\#3119](https://github.com/pypeclub/OpenPype/pull/3119) - General: Python 3 compatibility in queries [\#3112](https://github.com/pypeclub/OpenPype/pull/3112) - General: TemplateResult can be copied [\#3099](https://github.com/pypeclub/OpenPype/pull/3099) - General: Collect loaded versions skips not existing representations [\#3095](https://github.com/pypeclub/OpenPype/pull/3095) - RoyalRender Control Submission - AVALON\_APP\_NAME default [\#3091](https://github.com/pypeclub/OpenPype/pull/3091) - Ftrack: Update Create Folders action [\#3089](https://github.com/pypeclub/OpenPype/pull/3089) - Maya: Collect Render fix any render cameras check [\#3088](https://github.com/pypeclub/OpenPype/pull/3088) - Project Manager: Avoid unnecessary updates of asset documents [\#3083](https://github.com/pypeclub/OpenPype/pull/3083) - Standalone publisher: Fix plugins install [\#3077](https://github.com/pypeclub/OpenPype/pull/3077) - General: Extract review sequence is not converted with same names [\#3076](https://github.com/pypeclub/OpenPype/pull/3076) - Webpublisher: Use variant value [\#3068](https://github.com/pypeclub/OpenPype/pull/3068) - Nuke: Add aov matching even for remainder and prerender [\#3060](https://github.com/pypeclub/OpenPype/pull/3060) - Fix support for Renderman in Maya [\#3006](https://github.com/pypeclub/OpenPype/pull/3006) **🔀 Refactored code** - Avalon repo removed from Jobs workflow [\#3193](https://github.com/pypeclub/OpenPype/pull/3193) - General: Remove remaining imports from avalon [\#3130](https://github.com/pypeclub/OpenPype/pull/3130) - General: Move mongo db logic and remove avalon repository [\#3066](https://github.com/pypeclub/OpenPype/pull/3066) - General: Move host install [\#3009](https://github.com/pypeclub/OpenPype/pull/3009) **Merged pull requests:** - Harmony: message length in 21.1 [\#3257](https://github.com/pypeclub/OpenPype/pull/3257) - Harmony: 21.1 fix [\#3249](https://github.com/pypeclub/OpenPype/pull/3249) - Maya: added jpg to filter for Image Plane Loader [\#3223](https://github.com/pypeclub/OpenPype/pull/3223) - Webpublisher: replace space by underscore in subset names [\#3160](https://github.com/pypeclub/OpenPype/pull/3160) - StandalonePublisher: removed Extract Background plugins [\#3093](https://github.com/pypeclub/OpenPype/pull/3093) - Nuke: added suspend\_publish knob [\#3078](https://github.com/pypeclub/OpenPype/pull/3078) - Bump async from 2.6.3 to 2.6.4 in /website [\#3065](https://github.com/pypeclub/OpenPype/pull/3065) - SiteSync: Download all workfile inputs [\#2966](https://github.com/pypeclub/OpenPype/pull/2966) - Photoshop: New Publisher [\#2933](https://github.com/pypeclub/OpenPype/pull/2933) - Bump pillow from 9.0.0 to 9.0.1 [\#2880](https://github.com/pypeclub/OpenPype/pull/2880) - AfterEffects: Allow configuration of default variant via Settings [\#2856](https://github.com/pypeclub/OpenPype/pull/2856) ## [3.9.8](https://github.com/pypeclub/OpenPype/tree/3.9.8) (2022-05-19) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.7...3.9.8) ## [3.9.7](https://github.com/pypeclub/OpenPype/tree/3.9.7) (2022-05-11) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.6...3.9.7) ## [3.9.6](https://github.com/pypeclub/OpenPype/tree/3.9.6) (2022-05-03) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.5...3.9.6) ## [3.9.5](https://github.com/pypeclub/OpenPype/tree/3.9.5) (2022-04-25) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.4...3.9.5) ## [3.9.4](https://github.com/pypeclub/OpenPype/tree/3.9.4) (2022-04-15) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.3...3.9.4) ### 📖 Documentation - Documentation: more info about Tasks [\#3062](https://github.com/pypeclub/OpenPype/pull/3062) - Documentation: Python requirements to 3.7.9 [\#3035](https://github.com/pypeclub/OpenPype/pull/3035) - Website Docs: Remove unused pages [\#2974](https://github.com/pypeclub/OpenPype/pull/2974) **🆕 New features** - General: Local overrides for environment variables [\#3045](https://github.com/pypeclub/OpenPype/pull/3045) - Flame: Flare integration preparation [\#2928](https://github.com/pypeclub/OpenPype/pull/2928) **🚀 Enhancements** - TVPaint: Added init file for worker to triggers missing sound file dialog [\#3053](https://github.com/pypeclub/OpenPype/pull/3053) - Ftrack: Custom attributes can be filled in slate values [\#3036](https://github.com/pypeclub/OpenPype/pull/3036) - Resolve environment variable in google drive credential path [\#3008](https://github.com/pypeclub/OpenPype/pull/3008) **🐛 Bug fixes** - GitHub: Updated push-protected action in github workflow [\#3064](https://github.com/pypeclub/OpenPype/pull/3064) - Nuke: Typos in imports from Nuke implementation [\#3061](https://github.com/pypeclub/OpenPype/pull/3061) - Hotfix: fixing deadline job publishing [\#3059](https://github.com/pypeclub/OpenPype/pull/3059) - General: Extract Review handle invalid characters for ffmpeg [\#3050](https://github.com/pypeclub/OpenPype/pull/3050) - Slate Review: Support to keep format on slate concatenation [\#3049](https://github.com/pypeclub/OpenPype/pull/3049) - Webpublisher: fix processing of workfile [\#3048](https://github.com/pypeclub/OpenPype/pull/3048) - Ftrack: Integrate ftrack api fix [\#3044](https://github.com/pypeclub/OpenPype/pull/3044) - Webpublisher - removed wrong hardcoded family [\#3043](https://github.com/pypeclub/OpenPype/pull/3043) - LibraryLoader: Use current project for asset query in families filter [\#3042](https://github.com/pypeclub/OpenPype/pull/3042) - SiteSync: Providers ignore that site is disabled [\#3041](https://github.com/pypeclub/OpenPype/pull/3041) - Unreal: Creator import fixes [\#3040](https://github.com/pypeclub/OpenPype/pull/3040) - SiteSync: fix transitive alternate sites, fix dropdown in Local Settings [\#3018](https://github.com/pypeclub/OpenPype/pull/3018) - Maya: invalid review flag on rendered AOVs [\#2915](https://github.com/pypeclub/OpenPype/pull/2915) **Merged pull requests:** - Deadline: reworked pools assignment [\#3051](https://github.com/pypeclub/OpenPype/pull/3051) - Houdini: Avoid ImportError on `hdefereval` when Houdini runs without UI [\#2987](https://github.com/pypeclub/OpenPype/pull/2987) ## [3.9.3](https://github.com/pypeclub/OpenPype/tree/3.9.3) (2022-04-07) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.2...3.9.3) ### 📖 Documentation - Documentation: Added mention of adding My Drive as a root [\#2999](https://github.com/pypeclub/OpenPype/pull/2999) - Website Docs: Manager Ftrack fix broken links [\#2979](https://github.com/pypeclub/OpenPype/pull/2979) - Docs: Added MongoDB requirements [\#2951](https://github.com/pypeclub/OpenPype/pull/2951) - Documentation: New publisher develop docs [\#2896](https://github.com/pypeclub/OpenPype/pull/2896) **🆕 New features** - Ftrack: Add description integrator [\#3027](https://github.com/pypeclub/OpenPype/pull/3027) - nuke: bypass baking [\#2992](https://github.com/pypeclub/OpenPype/pull/2992) - Publishing textures for Unreal [\#2988](https://github.com/pypeclub/OpenPype/pull/2988) - Maya to Unreal: Static and Skeletal Meshes [\#2978](https://github.com/pypeclub/OpenPype/pull/2978) - Multiverse: Initial Support [\#2908](https://github.com/pypeclub/OpenPype/pull/2908) **🚀 Enhancements** - General: default workfile subset name for workfile [\#3011](https://github.com/pypeclub/OpenPype/pull/3011) - Ftrack: Add more options for note text of integrate ftrack note [\#3025](https://github.com/pypeclub/OpenPype/pull/3025) - Console Interpreter: Changed how console splitter size are reused on show [\#3016](https://github.com/pypeclub/OpenPype/pull/3016) - Deadline: Use more suitable name for sequence review logic [\#3015](https://github.com/pypeclub/OpenPype/pull/3015) - Nuke: add concurrency attr to deadline job [\#3005](https://github.com/pypeclub/OpenPype/pull/3005) - Photoshop: create image without instance [\#3001](https://github.com/pypeclub/OpenPype/pull/3001) - TVPaint: Render scene family [\#3000](https://github.com/pypeclub/OpenPype/pull/3000) - Deadline: priority configurable in Maya jobs [\#2995](https://github.com/pypeclub/OpenPype/pull/2995) - Nuke: ReviewDataMov Read RAW attribute [\#2985](https://github.com/pypeclub/OpenPype/pull/2985) - General: `METADATA_KEYS` constant as `frozenset` for optimal immutable lookup [\#2980](https://github.com/pypeclub/OpenPype/pull/2980) - General: Tools with host filters [\#2975](https://github.com/pypeclub/OpenPype/pull/2975) - Hero versions: Use custom templates [\#2967](https://github.com/pypeclub/OpenPype/pull/2967) - Slack: Added configurable maximum file size of review upload to Slack [\#2945](https://github.com/pypeclub/OpenPype/pull/2945) - NewPublisher: Prepared implementation of optional pyblish plugin [\#2943](https://github.com/pypeclub/OpenPype/pull/2943) - TVPaint: Extractor to convert PNG into EXR [\#2942](https://github.com/pypeclub/OpenPype/pull/2942) - Workfiles tool: Save as published workfiles [\#2937](https://github.com/pypeclub/OpenPype/pull/2937) - Workfiles: Open published workfiles [\#2925](https://github.com/pypeclub/OpenPype/pull/2925) - General: Default modules loaded dynamically [\#2923](https://github.com/pypeclub/OpenPype/pull/2923) - CI: change the version bump logic [\#2919](https://github.com/pypeclub/OpenPype/pull/2919) - Deadline: Add headless argument [\#2916](https://github.com/pypeclub/OpenPype/pull/2916) - Nuke: Add no-audio Tag [\#2911](https://github.com/pypeclub/OpenPype/pull/2911) - Ftrack: Fill workfile in custom attribute [\#2906](https://github.com/pypeclub/OpenPype/pull/2906) - Nuke: improving readability [\#2903](https://github.com/pypeclub/OpenPype/pull/2903) - Settings UI: Add simple tooltips for settings entities [\#2901](https://github.com/pypeclub/OpenPype/pull/2901) **🐛 Bug fixes** - General: Fix validate asset docs plug-in filename and class name [\#3029](https://github.com/pypeclub/OpenPype/pull/3029) - Deadline: Fixed default value of use sequence for review [\#3033](https://github.com/pypeclub/OpenPype/pull/3033) - Settings UI: Version column can be extended so version are visible [\#3032](https://github.com/pypeclub/OpenPype/pull/3032) - General: Fix import after movements [\#3028](https://github.com/pypeclub/OpenPype/pull/3028) - Harmony: Added creating subset name for workfile from template [\#3024](https://github.com/pypeclub/OpenPype/pull/3024) - AfterEffects: Added creating subset name for workfile from template [\#3023](https://github.com/pypeclub/OpenPype/pull/3023) - General: Add example addons to ignored [\#3022](https://github.com/pypeclub/OpenPype/pull/3022) - Maya: Remove missing import [\#3017](https://github.com/pypeclub/OpenPype/pull/3017) - Ftrack: multiple reviewable componets [\#3012](https://github.com/pypeclub/OpenPype/pull/3012) - Tray publisher: Fixes after code movement [\#3010](https://github.com/pypeclub/OpenPype/pull/3010) - Hosts: Remove path existence checks in 'add\_implementation\_envs' [\#3004](https://github.com/pypeclub/OpenPype/pull/3004) - Nuke: fixing unicode type detection in effect loaders [\#3002](https://github.com/pypeclub/OpenPype/pull/3002) - Fix - remove doubled dot in workfile created from template [\#2998](https://github.com/pypeclub/OpenPype/pull/2998) - Nuke: removing redundant Ftrack asset when farm publishing [\#2996](https://github.com/pypeclub/OpenPype/pull/2996) - PS: fix renaming subset incorrectly in PS [\#2991](https://github.com/pypeclub/OpenPype/pull/2991) - Fix: Disable setuptools auto discovery [\#2990](https://github.com/pypeclub/OpenPype/pull/2990) - AEL: fix opening existing workfile if no scene opened [\#2989](https://github.com/pypeclub/OpenPype/pull/2989) - Maya: Don't do hardlinks on windows for look publishing [\#2986](https://github.com/pypeclub/OpenPype/pull/2986) - Settings UI: Fix version completer on linux [\#2981](https://github.com/pypeclub/OpenPype/pull/2981) - Photoshop: Fix creation of subset names in PS review and workfile [\#2969](https://github.com/pypeclub/OpenPype/pull/2969) - Slack: Added default for review\_upload\_limit for Slack [\#2965](https://github.com/pypeclub/OpenPype/pull/2965) - General: OIIO conversion for ffmeg can handle sequences [\#2958](https://github.com/pypeclub/OpenPype/pull/2958) - Settings: Conditional dictionary avoid invalid logs [\#2956](https://github.com/pypeclub/OpenPype/pull/2956) - General: Smaller fixes and typos [\#2950](https://github.com/pypeclub/OpenPype/pull/2950) - LogViewer: Don't refresh on initialization [\#2949](https://github.com/pypeclub/OpenPype/pull/2949) - nuke: python3 compatibility issue with `iteritems` [\#2948](https://github.com/pypeclub/OpenPype/pull/2948) - General: anatomy data with correct task short key [\#2947](https://github.com/pypeclub/OpenPype/pull/2947) - SceneInventory: Fix imports in UI [\#2944](https://github.com/pypeclub/OpenPype/pull/2944) - Slack: add generic exception [\#2941](https://github.com/pypeclub/OpenPype/pull/2941) - General: Python specific vendor paths on env injection [\#2939](https://github.com/pypeclub/OpenPype/pull/2939) - General: More fail safe delete old versions [\#2936](https://github.com/pypeclub/OpenPype/pull/2936) - Settings UI: Collapsed of collapsible wrapper works as expected [\#2934](https://github.com/pypeclub/OpenPype/pull/2934) - Maya: Do not pass `set` to maya commands \(fixes support for older maya versions\) [\#2932](https://github.com/pypeclub/OpenPype/pull/2932) - General: Don't print log record on OSError [\#2926](https://github.com/pypeclub/OpenPype/pull/2926) - Hiero: Fix import of 'register\_event\_callback' [\#2924](https://github.com/pypeclub/OpenPype/pull/2924) - Flame: centos related debugging [\#2922](https://github.com/pypeclub/OpenPype/pull/2922) - Ftrack: Missing Ftrack id after editorial publish [\#2905](https://github.com/pypeclub/OpenPype/pull/2905) - AfterEffects: Fix rendering for single frame in DL [\#2875](https://github.com/pypeclub/OpenPype/pull/2875) **🔀 Refactored code** - General: Move plugins register and discover [\#2935](https://github.com/pypeclub/OpenPype/pull/2935) - General: Move Attribute Definitions from pipeline [\#2931](https://github.com/pypeclub/OpenPype/pull/2931) - General: Removed silo references and terminal splash [\#2927](https://github.com/pypeclub/OpenPype/pull/2927) - General: Move pipeline constants to OpenPype [\#2918](https://github.com/pypeclub/OpenPype/pull/2918) - General: Move formatting and workfile functions [\#2914](https://github.com/pypeclub/OpenPype/pull/2914) - General: Move remaining plugins from avalon [\#2912](https://github.com/pypeclub/OpenPype/pull/2912) **Merged pull requests:** - Maya: Allow to select invalid camera contents if no cameras found [\#3030](https://github.com/pypeclub/OpenPype/pull/3030) - Bump paramiko from 2.9.2 to 2.10.1 [\#2973](https://github.com/pypeclub/OpenPype/pull/2973) - Bump minimist from 1.2.5 to 1.2.6 in /website [\#2954](https://github.com/pypeclub/OpenPype/pull/2954) - Bump node-forge from 1.2.1 to 1.3.0 in /website [\#2953](https://github.com/pypeclub/OpenPype/pull/2953) - Maya - added transparency into review creator [\#2952](https://github.com/pypeclub/OpenPype/pull/2952) ## [3.9.2](https://github.com/pypeclub/OpenPype/tree/3.9.2) (2022-04-04) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.1...3.9.2) ## [3.9.1](https://github.com/pypeclub/OpenPype/tree/3.9.1) (2022-03-18) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.0...3.9.1) **🚀 Enhancements** - General: Change how OPENPYPE\_DEBUG value is handled [\#2907](https://github.com/pypeclub/OpenPype/pull/2907) - nuke: imageio adding ocio config version 1.2 [\#2897](https://github.com/pypeclub/OpenPype/pull/2897) - Flame: support for comment with xml attribute overrides [\#2892](https://github.com/pypeclub/OpenPype/pull/2892) - Nuke: ExtractReviewSlate can handle more codes and profiles [\#2879](https://github.com/pypeclub/OpenPype/pull/2879) - Flame: sequence used for reference video [\#2869](https://github.com/pypeclub/OpenPype/pull/2869) **🐛 Bug fixes** - General: Fix use of Anatomy roots [\#2904](https://github.com/pypeclub/OpenPype/pull/2904) - Fixing gap detection in extract review [\#2902](https://github.com/pypeclub/OpenPype/pull/2902) - Pyblish Pype - ensure current state is correct when entering new group order [\#2899](https://github.com/pypeclub/OpenPype/pull/2899) - SceneInventory: Fix import of load function [\#2894](https://github.com/pypeclub/OpenPype/pull/2894) - Harmony - fixed creator issue [\#2891](https://github.com/pypeclub/OpenPype/pull/2891) - General: Remove forgotten use of avalon Creator [\#2885](https://github.com/pypeclub/OpenPype/pull/2885) - General: Avoid circular import [\#2884](https://github.com/pypeclub/OpenPype/pull/2884) - Fixes for attaching loaded containers \(\#2837\) [\#2874](https://github.com/pypeclub/OpenPype/pull/2874) - Maya: Deformer node ids validation plugin [\#2826](https://github.com/pypeclub/OpenPype/pull/2826) - Flame Babypublisher optimalization [\#2806](https://github.com/pypeclub/OpenPype/pull/2806) - hotfix: OIIO tool path - add extension on windows [\#2618](https://github.com/pypeclub/OpenPype/pull/2618) **🔀 Refactored code** - General: Reduce style usage to OpenPype repository [\#2889](https://github.com/pypeclub/OpenPype/pull/2889) - General: Move loader logic from avalon to openpype [\#2886](https://github.com/pypeclub/OpenPype/pull/2886) ## [3.9.0](https://github.com/pypeclub/OpenPype/tree/3.9.0) (2022-03-14) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...3.9.0) **Deprecated:** - Houdini: Remove unused code [\#2779](https://github.com/pypeclub/OpenPype/pull/2779) - Loader: Remove default family states for hosts from code [\#2706](https://github.com/pypeclub/OpenPype/pull/2706) - AssetCreator: Remove the tool [\#2845](https://github.com/pypeclub/OpenPype/pull/2845) ### 📖 Documentation - Documentation: fixed broken links [\#2799](https://github.com/pypeclub/OpenPype/pull/2799) - Documentation: broken link fix [\#2785](https://github.com/pypeclub/OpenPype/pull/2785) - Documentation: link fixes [\#2772](https://github.com/pypeclub/OpenPype/pull/2772) - Update docusaurus to latest version [\#2760](https://github.com/pypeclub/OpenPype/pull/2760) - Various testing updates [\#2726](https://github.com/pypeclub/OpenPype/pull/2726) - documentation: add example to `repack-version` command [\#2669](https://github.com/pypeclub/OpenPype/pull/2669) - Update docusaurus [\#2639](https://github.com/pypeclub/OpenPype/pull/2639) - Documentation: Fixed relative links [\#2621](https://github.com/pypeclub/OpenPype/pull/2621) - Documentation: Change Photoshop & AfterEffects plugin path [\#2878](https://github.com/pypeclub/OpenPype/pull/2878) **🆕 New features** - Flame: loading clips to reels [\#2622](https://github.com/pypeclub/OpenPype/pull/2622) - General: Store settings by OpenPype version [\#2570](https://github.com/pypeclub/OpenPype/pull/2570) **🚀 Enhancements** - New: Validation exceptions [\#2841](https://github.com/pypeclub/OpenPype/pull/2841) - General: Set context environments for non host applications [\#2803](https://github.com/pypeclub/OpenPype/pull/2803) - Houdini: Remove duplicate ValidateOutputNode plug-in [\#2780](https://github.com/pypeclub/OpenPype/pull/2780) - Tray publisher: New Tray Publisher host \(beta\) [\#2778](https://github.com/pypeclub/OpenPype/pull/2778) - Slack: Added regex for filtering on subset names [\#2775](https://github.com/pypeclub/OpenPype/pull/2775) - Houdini: Implement Reset Frame Range [\#2770](https://github.com/pypeclub/OpenPype/pull/2770) - Pyblish Pype: Remove redundant new line in installed fonts printing [\#2758](https://github.com/pypeclub/OpenPype/pull/2758) - Flame: use Shot Name on segment for asset name [\#2751](https://github.com/pypeclub/OpenPype/pull/2751) - Flame: adding validator source clip [\#2746](https://github.com/pypeclub/OpenPype/pull/2746) - Work Files: Preserve subversion comment of current filename by default [\#2734](https://github.com/pypeclub/OpenPype/pull/2734) - Maya: set Deadline job/batch name to original source workfile name instead of published workfile [\#2733](https://github.com/pypeclub/OpenPype/pull/2733) - Ftrack: Disable ftrack module by default [\#2732](https://github.com/pypeclub/OpenPype/pull/2732) - Project Manager: Disable add task, add asset and save button when not in a project [\#2727](https://github.com/pypeclub/OpenPype/pull/2727) - dropbox handle big file [\#2718](https://github.com/pypeclub/OpenPype/pull/2718) - Fusion Move PR: Minor tweaks to Fusion integration [\#2716](https://github.com/pypeclub/OpenPype/pull/2716) - RoyalRender: Minor enhancements [\#2700](https://github.com/pypeclub/OpenPype/pull/2700) - Nuke: prerender with review knob [\#2691](https://github.com/pypeclub/OpenPype/pull/2691) - Maya configurable unit validator [\#2680](https://github.com/pypeclub/OpenPype/pull/2680) - General: Add settings for CleanUpFarm and disable the plugin by default [\#2679](https://github.com/pypeclub/OpenPype/pull/2679) - Project Manager: Only allow scroll wheel edits when spinbox is active [\#2678](https://github.com/pypeclub/OpenPype/pull/2678) - Ftrack: Sync description to assets [\#2670](https://github.com/pypeclub/OpenPype/pull/2670) - Houdini: Moved to OpenPype [\#2658](https://github.com/pypeclub/OpenPype/pull/2658) - Maya: Move implementation to OpenPype [\#2649](https://github.com/pypeclub/OpenPype/pull/2649) - General: FFmpeg conversion also check attribute string length [\#2635](https://github.com/pypeclub/OpenPype/pull/2635) - Houdini: Load Arnold .ass procedurals into Houdini [\#2606](https://github.com/pypeclub/OpenPype/pull/2606) - Deadline: Simplify GlobalJobPreLoad logic [\#2605](https://github.com/pypeclub/OpenPype/pull/2605) - Houdini: Implement Arnold .ass standin extraction from Houdini \(also support .ass.gz\) [\#2603](https://github.com/pypeclub/OpenPype/pull/2603) - New Publisher: New features and preparations for new standalone publisher [\#2556](https://github.com/pypeclub/OpenPype/pull/2556) - Fix Maya 2022 Python 3 compatibility [\#2445](https://github.com/pypeclub/OpenPype/pull/2445) - TVPaint: Use new publisher exceptions in validators [\#2435](https://github.com/pypeclub/OpenPype/pull/2435) - Harmony: Added new style validations for New Publisher [\#2434](https://github.com/pypeclub/OpenPype/pull/2434) - Aftereffects: New style validations for New publisher [\#2430](https://github.com/pypeclub/OpenPype/pull/2430) - Farm publishing: New cleanup plugin for Maya renders on farm [\#2390](https://github.com/pypeclub/OpenPype/pull/2390) - General: Subset name filtering in ExtractReview outpus [\#2872](https://github.com/pypeclub/OpenPype/pull/2872) - NewPublisher: Descriptions and Icons in creator dialog [\#2867](https://github.com/pypeclub/OpenPype/pull/2867) - NewPublisher: Changing task on publishing instance [\#2863](https://github.com/pypeclub/OpenPype/pull/2863) - TrayPublisher: Choose project widget is more clear [\#2859](https://github.com/pypeclub/OpenPype/pull/2859) - Maya: add loaded containers to published instance [\#2837](https://github.com/pypeclub/OpenPype/pull/2837) - Ftrack: Can sync fps as string [\#2836](https://github.com/pypeclub/OpenPype/pull/2836) - General: Custom function for find executable [\#2822](https://github.com/pypeclub/OpenPype/pull/2822) - General: Color dialog UI fixes [\#2817](https://github.com/pypeclub/OpenPype/pull/2817) - global: letter box calculated on output as last process [\#2812](https://github.com/pypeclub/OpenPype/pull/2812) - Nuke: adding Reformat to baking mov plugin [\#2811](https://github.com/pypeclub/OpenPype/pull/2811) - Manager: Update all to latest button [\#2805](https://github.com/pypeclub/OpenPype/pull/2805) - Houdini: Move Houdini Save Current File to beginning of ExtractorOrder [\#2747](https://github.com/pypeclub/OpenPype/pull/2747) - Global: adding studio name/code to anatomy template formatting data [\#2630](https://github.com/pypeclub/OpenPype/pull/2630) **🐛 Bug fixes** - Settings UI: Search case sensitivity [\#2810](https://github.com/pypeclub/OpenPype/pull/2810) - resolve: fixing fusion module loading [\#2802](https://github.com/pypeclub/OpenPype/pull/2802) - Ftrack: Unset task ids from asset versions before tasks are removed [\#2800](https://github.com/pypeclub/OpenPype/pull/2800) - Slack: fail gracefully if slack exception [\#2798](https://github.com/pypeclub/OpenPype/pull/2798) - Flame: Fix version string in default settings [\#2783](https://github.com/pypeclub/OpenPype/pull/2783) - After Effects: Fix typo in name `afftereffects` -\> `aftereffects` [\#2768](https://github.com/pypeclub/OpenPype/pull/2768) - Houdini: Fix open last workfile [\#2767](https://github.com/pypeclub/OpenPype/pull/2767) - Avoid renaming udim indexes [\#2765](https://github.com/pypeclub/OpenPype/pull/2765) - Maya: Fix `unique_namespace` when in an namespace that is empty [\#2759](https://github.com/pypeclub/OpenPype/pull/2759) - Loader UI: Fix right click in representation widget [\#2757](https://github.com/pypeclub/OpenPype/pull/2757) - Harmony: Rendering in Deadline didn't work in other machines than submitter [\#2754](https://github.com/pypeclub/OpenPype/pull/2754) - Aftereffects 2022 and Deadline [\#2748](https://github.com/pypeclub/OpenPype/pull/2748) - Flame: bunch of bugs [\#2745](https://github.com/pypeclub/OpenPype/pull/2745) - Maya: Save current scene on workfile publish [\#2744](https://github.com/pypeclub/OpenPype/pull/2744) - Version Up: Preserve parts of filename after version number \(like subversion\) on version\_up [\#2741](https://github.com/pypeclub/OpenPype/pull/2741) - Loader UI: Multiple asset selection and underline colors fixed [\#2731](https://github.com/pypeclub/OpenPype/pull/2731) - General: Fix loading of unused chars in xml format [\#2729](https://github.com/pypeclub/OpenPype/pull/2729) - TVPaint: Set objectName with members [\#2725](https://github.com/pypeclub/OpenPype/pull/2725) - General: Don't use 'objectName' from loaded references [\#2715](https://github.com/pypeclub/OpenPype/pull/2715) - Settings: Studio Project anatomy is queried using right keys [\#2711](https://github.com/pypeclub/OpenPype/pull/2711) - Local Settings: Additional applications don't break UI [\#2710](https://github.com/pypeclub/OpenPype/pull/2710) - Maya: Remove some unused code [\#2709](https://github.com/pypeclub/OpenPype/pull/2709) - Houdini: Fix refactor of Houdini host move for CreateArnoldAss [\#2704](https://github.com/pypeclub/OpenPype/pull/2704) - LookAssigner: Fix imports after moving code to OpenPype repository [\#2701](https://github.com/pypeclub/OpenPype/pull/2701) - Multiple hosts: unify menu style across hosts [\#2693](https://github.com/pypeclub/OpenPype/pull/2693) - Maya Redshift fixes [\#2692](https://github.com/pypeclub/OpenPype/pull/2692) - Maya: fix fps validation popup [\#2685](https://github.com/pypeclub/OpenPype/pull/2685) - Houdini Explicitly collect correct frame name even in case of single frame render when `frameStart` is provided [\#2676](https://github.com/pypeclub/OpenPype/pull/2676) - hiero: fix effect collector name and order [\#2673](https://github.com/pypeclub/OpenPype/pull/2673) - Maya: Fix menu callbacks [\#2671](https://github.com/pypeclub/OpenPype/pull/2671) - hiero: removing obsolete unsupported plugin [\#2667](https://github.com/pypeclub/OpenPype/pull/2667) - Launcher: Fix access to 'data' attribute on actions [\#2659](https://github.com/pypeclub/OpenPype/pull/2659) - Maya `vrscene` loader fixes [\#2633](https://github.com/pypeclub/OpenPype/pull/2633) - Houdini: fix usd family in loader and integrators [\#2631](https://github.com/pypeclub/OpenPype/pull/2631) - Maya: Add only reference node to look family container like with other families [\#2508](https://github.com/pypeclub/OpenPype/pull/2508) - General: Missing time function [\#2877](https://github.com/pypeclub/OpenPype/pull/2877) - Deadline: Fix plugin name for tile assemble [\#2868](https://github.com/pypeclub/OpenPype/pull/2868) - Nuke: gizmo precollect fix [\#2866](https://github.com/pypeclub/OpenPype/pull/2866) - General: Fix hardlink for windows [\#2864](https://github.com/pypeclub/OpenPype/pull/2864) - General: ffmpeg was crashing on slate merge [\#2860](https://github.com/pypeclub/OpenPype/pull/2860) - WebPublisher: Video file was published with one too many frame [\#2858](https://github.com/pypeclub/OpenPype/pull/2858) - New Publisher: Error dialog got right styles [\#2857](https://github.com/pypeclub/OpenPype/pull/2857) - General: Fix getattr clalback on dynamic modules [\#2855](https://github.com/pypeclub/OpenPype/pull/2855) - Nuke: slate resolution to input video resolution [\#2853](https://github.com/pypeclub/OpenPype/pull/2853) - WebPublisher: Fix username stored in DB [\#2852](https://github.com/pypeclub/OpenPype/pull/2852) - WebPublisher: Fix wrong number of frames for video file [\#2851](https://github.com/pypeclub/OpenPype/pull/2851) - Nuke: Fix family test in validate\_write\_legacy to work with stillImage [\#2847](https://github.com/pypeclub/OpenPype/pull/2847) - Nuke: fix multiple baking profile farm publishing [\#2842](https://github.com/pypeclub/OpenPype/pull/2842) - Blender: Fixed parameters for FBX export of the camera [\#2840](https://github.com/pypeclub/OpenPype/pull/2840) - Maya: Stop creation of reviews for Cryptomattes [\#2832](https://github.com/pypeclub/OpenPype/pull/2832) - Deadline: Remove recreated event [\#2828](https://github.com/pypeclub/OpenPype/pull/2828) - Deadline: Added missing events folder [\#2827](https://github.com/pypeclub/OpenPype/pull/2827) - Settings: Missing document with OP versions may break start of OpenPype [\#2825](https://github.com/pypeclub/OpenPype/pull/2825) - Deadline: more detailed temp file name for environment json [\#2824](https://github.com/pypeclub/OpenPype/pull/2824) - General: Host name was formed from obsolete code [\#2821](https://github.com/pypeclub/OpenPype/pull/2821) - Settings UI: Fix "Apply from" action [\#2820](https://github.com/pypeclub/OpenPype/pull/2820) - Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819) - Nuke: Use AVALON\_APP to get value for "app" key [\#2818](https://github.com/pypeclub/OpenPype/pull/2818) - StandalonePublisher: use dynamic groups in subset names [\#2816](https://github.com/pypeclub/OpenPype/pull/2816) **🔀 Refactored code** - Ftrack: Moved module one hierarchy level higher [\#2792](https://github.com/pypeclub/OpenPype/pull/2792) - SyncServer: Moved module one hierarchy level higher [\#2791](https://github.com/pypeclub/OpenPype/pull/2791) - Royal render: Move module one hierarchy level higher [\#2790](https://github.com/pypeclub/OpenPype/pull/2790) - Deadline: Move module one hierarchy level higher [\#2789](https://github.com/pypeclub/OpenPype/pull/2789) - Refactor: move webserver tool to openpype [\#2876](https://github.com/pypeclub/OpenPype/pull/2876) - General: Move create logic from avalon to OpenPype [\#2854](https://github.com/pypeclub/OpenPype/pull/2854) - General: Add vendors from avalon [\#2848](https://github.com/pypeclub/OpenPype/pull/2848) - General: Basic event system [\#2846](https://github.com/pypeclub/OpenPype/pull/2846) - General: Move change context functions [\#2839](https://github.com/pypeclub/OpenPype/pull/2839) - Tools: Don't use avalon tools code [\#2829](https://github.com/pypeclub/OpenPype/pull/2829) - Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823) - General: Extract template formatting from anatomy [\#2766](https://github.com/pypeclub/OpenPype/pull/2766) **Merged pull requests:** - Fusion: Moved implementation into OpenPype [\#2713](https://github.com/pypeclub/OpenPype/pull/2713) - TVPaint: Plugin build without dependencies [\#2705](https://github.com/pypeclub/OpenPype/pull/2705) - Webpublisher: Photoshop create a beauty png [\#2689](https://github.com/pypeclub/OpenPype/pull/2689) - Ftrack: Hierarchical attributes are queried properly [\#2682](https://github.com/pypeclub/OpenPype/pull/2682) - Maya: Add Validate Frame Range settings [\#2661](https://github.com/pypeclub/OpenPype/pull/2661) - Harmony: move to Openpype [\#2657](https://github.com/pypeclub/OpenPype/pull/2657) - Maya: cleanup duplicate rendersetup code [\#2642](https://github.com/pypeclub/OpenPype/pull/2642) - Deadline: Be able to pass Mongo url to job [\#2616](https://github.com/pypeclub/OpenPype/pull/2616) ## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.1...3.8.2) ### 📖 Documentation - Cosmetics: Fix common typos in openpype/website [\#2617](https://github.com/pypeclub/OpenPype/pull/2617) **🚀 Enhancements** - TVPaint: Image loaders also work on review family [\#2638](https://github.com/pypeclub/OpenPype/pull/2638) - General: Project backup tools [\#2629](https://github.com/pypeclub/OpenPype/pull/2629) - nuke: adding clear button to write nodes [\#2627](https://github.com/pypeclub/OpenPype/pull/2627) - Ftrack: Family to Asset type mapping is in settings [\#2602](https://github.com/pypeclub/OpenPype/pull/2602) - Nuke: load color space from representation data [\#2576](https://github.com/pypeclub/OpenPype/pull/2576) **🐛 Bug fixes** - Fix pulling of cx\_freeze 6.10 [\#2628](https://github.com/pypeclub/OpenPype/pull/2628) - Global: fix broken otio review extractor [\#2590](https://github.com/pypeclub/OpenPype/pull/2590) **Merged pull requests:** - WebPublisher: fix instance duplicates [\#2641](https://github.com/pypeclub/OpenPype/pull/2641) - Fix - safer pulling of task name for webpublishing from PS [\#2613](https://github.com/pypeclub/OpenPype/pull/2613) ## [3.8.1](https://github.com/pypeclub/OpenPype/tree/3.8.1) (2022-02-01) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.0...3.8.1) **🚀 Enhancements** - Webpublisher: Thumbnail extractor [\#2600](https://github.com/pypeclub/OpenPype/pull/2600) - Loader: Allow to toggle default family filters between "include" or "exclude" filtering [\#2541](https://github.com/pypeclub/OpenPype/pull/2541) - Launcher: Added context menu to to skip opening last workfile [\#2536](https://github.com/pypeclub/OpenPype/pull/2536) - Unreal: JSON Layout Loading support [\#2066](https://github.com/pypeclub/OpenPype/pull/2066) **🐛 Bug fixes** - Release/3.8.0 [\#2619](https://github.com/pypeclub/OpenPype/pull/2619) - Settings: Enum does not store empty string if has single item to select [\#2615](https://github.com/pypeclub/OpenPype/pull/2615) - switch distutils to sysconfig for `get_platform()` [\#2594](https://github.com/pypeclub/OpenPype/pull/2594) - Fix poetry index and speedcopy update [\#2589](https://github.com/pypeclub/OpenPype/pull/2589) - Webpublisher: Fix - subset names from processed .psd used wrong value for task [\#2586](https://github.com/pypeclub/OpenPype/pull/2586) - `vrscene` creator Deadline webservice URL handling [\#2580](https://github.com/pypeclub/OpenPype/pull/2580) - global: track name was failing if duplicated root word in name [\#2568](https://github.com/pypeclub/OpenPype/pull/2568) - Validate Maya Rig produces no cycle errors [\#2484](https://github.com/pypeclub/OpenPype/pull/2484) **Merged pull requests:** - Bump pillow from 8.4.0 to 9.0.0 [\#2595](https://github.com/pypeclub/OpenPype/pull/2595) - Webpublisher: Skip version collect [\#2591](https://github.com/pypeclub/OpenPype/pull/2591) - build\(deps\): bump pillow from 8.4.0 to 9.0.0 [\#2523](https://github.com/pypeclub/OpenPype/pull/2523) ## [3.8.0](https://github.com/pypeclub/OpenPype/tree/3.8.0) (2022-01-24) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.7.0...3.8.0) ### 📖 Documentation - Variable in docs renamed to proper name [\#2546](https://github.com/pypeclub/OpenPype/pull/2546) **🆕 New features** - Flame: extracting segments with trans-coding [\#2547](https://github.com/pypeclub/OpenPype/pull/2547) - Maya : V-Ray Proxy - load all ABC files via proxy [\#2544](https://github.com/pypeclub/OpenPype/pull/2544) - Maya to Unreal: Extended static mesh workflow [\#2537](https://github.com/pypeclub/OpenPype/pull/2537) - Flame: collecting publishable instances [\#2519](https://github.com/pypeclub/OpenPype/pull/2519) - Flame: create publishable clips [\#2495](https://github.com/pypeclub/OpenPype/pull/2495) - Flame: OpenTimelineIO Export Modul [\#2398](https://github.com/pypeclub/OpenPype/pull/2398) **🚀 Enhancements** - Webpublisher: Moved error at the beginning of the log [\#2559](https://github.com/pypeclub/OpenPype/pull/2559) - Ftrack: Use ApplicationManager to get DJV path [\#2558](https://github.com/pypeclub/OpenPype/pull/2558) - Webpublisher: Added endpoint to reprocess batch through UI [\#2555](https://github.com/pypeclub/OpenPype/pull/2555) - Settings: PathInput strip passed string [\#2550](https://github.com/pypeclub/OpenPype/pull/2550) - Global: Exctract Review anatomy fill data with output name [\#2548](https://github.com/pypeclub/OpenPype/pull/2548) - Cosmetics: Clean up some cosmetics / typos [\#2542](https://github.com/pypeclub/OpenPype/pull/2542) - General: Validate if current process OpenPype version is requested version [\#2529](https://github.com/pypeclub/OpenPype/pull/2529) - General: Be able to use anatomy data in ffmpeg output arguments [\#2525](https://github.com/pypeclub/OpenPype/pull/2525) - Expose toggle publish plug-in settings for Maya Look Shading Engine Naming [\#2521](https://github.com/pypeclub/OpenPype/pull/2521) - Photoshop: Move implementation to OpenPype [\#2510](https://github.com/pypeclub/OpenPype/pull/2510) - TimersManager: Move module one hierarchy higher [\#2501](https://github.com/pypeclub/OpenPype/pull/2501) - Slack: notifications are sent with Openpype logo and bot name [\#2499](https://github.com/pypeclub/OpenPype/pull/2499) - Slack: Add review to notification message [\#2498](https://github.com/pypeclub/OpenPype/pull/2498) - Ftrack: Event handlers settings [\#2496](https://github.com/pypeclub/OpenPype/pull/2496) - Tools: Fix style and modality of errors in loader and creator [\#2489](https://github.com/pypeclub/OpenPype/pull/2489) - Maya: Collect 'fps' animation data only for "review" instances [\#2486](https://github.com/pypeclub/OpenPype/pull/2486) - Project Manager: Remove project button cleanup [\#2482](https://github.com/pypeclub/OpenPype/pull/2482) - Tools: Be able to change models of tasks and assets widgets [\#2475](https://github.com/pypeclub/OpenPype/pull/2475) - Publish pype: Reduce publish process defering [\#2464](https://github.com/pypeclub/OpenPype/pull/2464) - Maya: Improve speed of Collect History logic [\#2460](https://github.com/pypeclub/OpenPype/pull/2460) - Maya: Validate Rig Controllers - fix Error: in script editor [\#2459](https://github.com/pypeclub/OpenPype/pull/2459) - Maya: Validate NGONs simplify and speed-up [\#2458](https://github.com/pypeclub/OpenPype/pull/2458) - Maya: Optimize Validate Locked Normals speed for dense polymeshes [\#2457](https://github.com/pypeclub/OpenPype/pull/2457) - Maya: Refactor missing \_get\_reference\_node method [\#2455](https://github.com/pypeclub/OpenPype/pull/2455) - Houdini: Remove broken unique name counter [\#2450](https://github.com/pypeclub/OpenPype/pull/2450) - Maya: Improve lib.polyConstraint performance when Select tool is not the active tool context [\#2447](https://github.com/pypeclub/OpenPype/pull/2447) - General: Validate third party before build [\#2425](https://github.com/pypeclub/OpenPype/pull/2425) - Maya : add option to not group reference in ReferenceLoader [\#2383](https://github.com/pypeclub/OpenPype/pull/2383) **🐛 Bug fixes** - AfterEffects: Fix - removed obsolete import [\#2577](https://github.com/pypeclub/OpenPype/pull/2577) - General: OpenPype version updates [\#2575](https://github.com/pypeclub/OpenPype/pull/2575) - Ftrack: Delete action revision [\#2563](https://github.com/pypeclub/OpenPype/pull/2563) - Webpublisher: ftrack shows incorrect user names [\#2560](https://github.com/pypeclub/OpenPype/pull/2560) - General: Do not validate version if build does not support it [\#2557](https://github.com/pypeclub/OpenPype/pull/2557) - Webpublisher: Fixed progress reporting [\#2553](https://github.com/pypeclub/OpenPype/pull/2553) - Fix Maya AssProxyLoader version switch [\#2551](https://github.com/pypeclub/OpenPype/pull/2551) - General: Fix install thread in igniter [\#2549](https://github.com/pypeclub/OpenPype/pull/2549) - Houdini: vdbcache family preserve frame numbers on publish integration + enable validate version for Houdini [\#2535](https://github.com/pypeclub/OpenPype/pull/2535) - Maya: Fix Load VDB to V-Ray [\#2533](https://github.com/pypeclub/OpenPype/pull/2533) - Maya: ReferenceLoader fix not unique group name error for attach to root [\#2532](https://github.com/pypeclub/OpenPype/pull/2532) - Maya: namespaced context go back to original namespace when started from inside a namespace [\#2531](https://github.com/pypeclub/OpenPype/pull/2531) - Fix create zip tool - path argument [\#2522](https://github.com/pypeclub/OpenPype/pull/2522) - Maya: Fix Extract Look with space in names [\#2518](https://github.com/pypeclub/OpenPype/pull/2518) - Fix published frame content for sequence starting with 0 [\#2513](https://github.com/pypeclub/OpenPype/pull/2513) - Maya: reset empty string attributes correctly to "" instead of "None" [\#2506](https://github.com/pypeclub/OpenPype/pull/2506) - Improve FusionPreLaunch hook errors [\#2505](https://github.com/pypeclub/OpenPype/pull/2505) - General: Settings work if OpenPypeVersion is available [\#2494](https://github.com/pypeclub/OpenPype/pull/2494) - General: PYTHONPATH may break OpenPype dependencies [\#2493](https://github.com/pypeclub/OpenPype/pull/2493) - General: Modules import function output fix [\#2492](https://github.com/pypeclub/OpenPype/pull/2492) - AE: fix hiding of alert window below Publish [\#2491](https://github.com/pypeclub/OpenPype/pull/2491) - Workfiles tool: Files widget show files on first show [\#2488](https://github.com/pypeclub/OpenPype/pull/2488) - General: Custom template paths filter fix [\#2483](https://github.com/pypeclub/OpenPype/pull/2483) - Loader: Remove always on top flag in tray [\#2480](https://github.com/pypeclub/OpenPype/pull/2480) - General: Anatomy does not return root envs as unicode [\#2465](https://github.com/pypeclub/OpenPype/pull/2465) - Maya: Validate Shape Zero do not keep fixed geometry vertices selected/active after repair [\#2456](https://github.com/pypeclub/OpenPype/pull/2456) **Merged pull requests:** - AfterEffects: Move implementation to OpenPype [\#2543](https://github.com/pypeclub/OpenPype/pull/2543) - Maya: Remove Maya Look Assigner check on startup [\#2540](https://github.com/pypeclub/OpenPype/pull/2540) - build\(deps\): bump shelljs from 0.8.4 to 0.8.5 in /website [\#2538](https://github.com/pypeclub/OpenPype/pull/2538) - build\(deps\): bump follow-redirects from 1.14.4 to 1.14.7 in /website [\#2534](https://github.com/pypeclub/OpenPype/pull/2534) - Nuke: Merge avalon's implementation into OpenPype [\#2514](https://github.com/pypeclub/OpenPype/pull/2514) - Maya: Vray fix proxies look assignment [\#2392](https://github.com/pypeclub/OpenPype/pull/2392) - Bump algoliasearch-helper from 3.4.4 to 3.6.2 in /website [\#2297](https://github.com/pypeclub/OpenPype/pull/2297) ## [3.7.0](https://github.com/pypeclub/OpenPype/tree/3.7.0) (2022-01-04) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.4...3.7.0) **Deprecated:** - General: Default modules hierarchy n2 [\#2368](https://github.com/pypeclub/OpenPype/pull/2368) ### 📖 Documentation - docs\[website\]: Add Ellipse Studio \(logo\) as an OpenPype contributor [\#2324](https://github.com/pypeclub/OpenPype/pull/2324) **🆕 New features** - Settings UI use OpenPype styles [\#2296](https://github.com/pypeclub/OpenPype/pull/2296) - Store typed version dependencies for workfiles [\#2192](https://github.com/pypeclub/OpenPype/pull/2192) - OpenPypeV3: add key task type, task shortname and user to path templating construction [\#2157](https://github.com/pypeclub/OpenPype/pull/2157) - Nuke: Alembic model workflow [\#2140](https://github.com/pypeclub/OpenPype/pull/2140) - TVPaint: Load workfile from published. [\#1980](https://github.com/pypeclub/OpenPype/pull/1980) **🚀 Enhancements** - General: Workdir extra folders [\#2462](https://github.com/pypeclub/OpenPype/pull/2462) - Photoshop: New style validations for New publisher [\#2429](https://github.com/pypeclub/OpenPype/pull/2429) - General: Environment variables groups [\#2424](https://github.com/pypeclub/OpenPype/pull/2424) - Unreal: Dynamic menu created in Python [\#2422](https://github.com/pypeclub/OpenPype/pull/2422) - Settings UI: Hyperlinks to settings [\#2420](https://github.com/pypeclub/OpenPype/pull/2420) - Modules: JobQueue module moved one hierarchy level higher [\#2419](https://github.com/pypeclub/OpenPype/pull/2419) - TimersManager: Start timer post launch hook [\#2418](https://github.com/pypeclub/OpenPype/pull/2418) - General: Run applications as separate processes under linux [\#2408](https://github.com/pypeclub/OpenPype/pull/2408) - Ftrack: Check existence of object type on recreation [\#2404](https://github.com/pypeclub/OpenPype/pull/2404) - Enhancement: Global cleanup plugin that explicitly remove paths from context [\#2402](https://github.com/pypeclub/OpenPype/pull/2402) - General: MongoDB ability to specify replica set groups [\#2401](https://github.com/pypeclub/OpenPype/pull/2401) - Flame: moving `utility_scripts` to api folder also with `scripts` [\#2385](https://github.com/pypeclub/OpenPype/pull/2385) - Centos 7 dependency compatibility [\#2384](https://github.com/pypeclub/OpenPype/pull/2384) - Enhancement: Settings: Use project settings values from another project [\#2382](https://github.com/pypeclub/OpenPype/pull/2382) - Blender 3: Support auto install for new blender version [\#2377](https://github.com/pypeclub/OpenPype/pull/2377) - Maya add render image path to settings [\#2375](https://github.com/pypeclub/OpenPype/pull/2375) - Settings: Webpublisher in hosts enum [\#2367](https://github.com/pypeclub/OpenPype/pull/2367) - Hiero: python3 compatibility [\#2365](https://github.com/pypeclub/OpenPype/pull/2365) - Burnins: Be able recognize mxf OPAtom format [\#2361](https://github.com/pypeclub/OpenPype/pull/2361) - Maya: Add is\_static\_image\_plane and is\_in\_all\_views option in imagePlaneLoader [\#2356](https://github.com/pypeclub/OpenPype/pull/2356) - Local settings: Copyable studio paths [\#2349](https://github.com/pypeclub/OpenPype/pull/2349) - Assets Widget: Clear model on project change [\#2345](https://github.com/pypeclub/OpenPype/pull/2345) - General: OpenPype default modules hierarchy [\#2338](https://github.com/pypeclub/OpenPype/pull/2338) - TVPaint: Move implementation to OpenPype [\#2336](https://github.com/pypeclub/OpenPype/pull/2336) - General: FFprobe error exception contain original error message [\#2328](https://github.com/pypeclub/OpenPype/pull/2328) - Resolve: Add experimental button to menu [\#2325](https://github.com/pypeclub/OpenPype/pull/2325) - Hiero: Add experimental tools action [\#2323](https://github.com/pypeclub/OpenPype/pull/2323) - Input links: Cleanup and unification of differences [\#2322](https://github.com/pypeclub/OpenPype/pull/2322) - General: Don't validate vendor bin with executing them [\#2317](https://github.com/pypeclub/OpenPype/pull/2317) - General: Multilayer EXRs support [\#2315](https://github.com/pypeclub/OpenPype/pull/2315) - General: Run process log stderr as info log level [\#2309](https://github.com/pypeclub/OpenPype/pull/2309) - General: Reduce vendor imports [\#2305](https://github.com/pypeclub/OpenPype/pull/2305) - Tools: Cleanup of unused classes [\#2304](https://github.com/pypeclub/OpenPype/pull/2304) - Project Manager: Added ability to delete project [\#2298](https://github.com/pypeclub/OpenPype/pull/2298) - Ftrack: Synchronize input links [\#2287](https://github.com/pypeclub/OpenPype/pull/2287) - StandalonePublisher: Remove unused plugin ExtractHarmonyZip [\#2277](https://github.com/pypeclub/OpenPype/pull/2277) - Ftrack: Support multiple reviews [\#2271](https://github.com/pypeclub/OpenPype/pull/2271) - Ftrack: Remove unused clean component plugin [\#2269](https://github.com/pypeclub/OpenPype/pull/2269) - Royal Render: Support for rr channels in separate dirs [\#2268](https://github.com/pypeclub/OpenPype/pull/2268) - Houdini: Add experimental tools action [\#2267](https://github.com/pypeclub/OpenPype/pull/2267) - Nuke: extract baked review videos presets [\#2248](https://github.com/pypeclub/OpenPype/pull/2248) - TVPaint: Workers rendering [\#2209](https://github.com/pypeclub/OpenPype/pull/2209) - OpenPypeV3: Add key parent asset to path templating construction [\#2186](https://github.com/pypeclub/OpenPype/pull/2186) **🐛 Bug fixes** - TVPaint: Create render layer dialog is in front [\#2471](https://github.com/pypeclub/OpenPype/pull/2471) - Short Pyblish plugin path [\#2428](https://github.com/pypeclub/OpenPype/pull/2428) - PS: Introduced settings for invalid characters to use in ValidateNaming plugin [\#2417](https://github.com/pypeclub/OpenPype/pull/2417) - Settings UI: Breadcrumbs path does not create new entities [\#2416](https://github.com/pypeclub/OpenPype/pull/2416) - AfterEffects: Variant 2022 is in defaults but missing in schemas [\#2412](https://github.com/pypeclub/OpenPype/pull/2412) - Nuke: baking representations was not additive [\#2406](https://github.com/pypeclub/OpenPype/pull/2406) - General: Fix access to environments from default settings [\#2403](https://github.com/pypeclub/OpenPype/pull/2403) - Fix: Placeholder Input color set fix [\#2399](https://github.com/pypeclub/OpenPype/pull/2399) - Settings: Fix state change of wrapper label [\#2396](https://github.com/pypeclub/OpenPype/pull/2396) - Flame: fix ftrack publisher [\#2381](https://github.com/pypeclub/OpenPype/pull/2381) - hiero: solve custom ocio path [\#2379](https://github.com/pypeclub/OpenPype/pull/2379) - hiero: fix workio and flatten [\#2378](https://github.com/pypeclub/OpenPype/pull/2378) - Nuke: fixing menu re-drawing during context change [\#2374](https://github.com/pypeclub/OpenPype/pull/2374) - Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) - Nuke: fixing node name based on switched asset name [\#2369](https://github.com/pypeclub/OpenPype/pull/2369) - JobQueue: Fix loading of settings [\#2362](https://github.com/pypeclub/OpenPype/pull/2362) - Tools: Placeholder color [\#2359](https://github.com/pypeclub/OpenPype/pull/2359) - Launcher: Minimize button on MacOs [\#2355](https://github.com/pypeclub/OpenPype/pull/2355) - StandalonePublisher: Fix import of constant [\#2354](https://github.com/pypeclub/OpenPype/pull/2354) - Houdini: Fix HDA creation [\#2350](https://github.com/pypeclub/OpenPype/pull/2350) - Adobe products show issue [\#2347](https://github.com/pypeclub/OpenPype/pull/2347) - Maya Look Assigner: Fix Python 3 compatibility [\#2343](https://github.com/pypeclub/OpenPype/pull/2343) - Remove wrongly used host for hook [\#2342](https://github.com/pypeclub/OpenPype/pull/2342) - Tools: Use Qt context on tools show [\#2340](https://github.com/pypeclub/OpenPype/pull/2340) - Flame: Fix default argument value in custom dictionary [\#2339](https://github.com/pypeclub/OpenPype/pull/2339) - Timers Manager: Disable auto stop timer on linux platform [\#2334](https://github.com/pypeclub/OpenPype/pull/2334) - nuke: bake preset single input exception [\#2331](https://github.com/pypeclub/OpenPype/pull/2331) - Hiero: fixing multiple templates at a hierarchy parent [\#2330](https://github.com/pypeclub/OpenPype/pull/2330) - Fix - provider icons are pulled from a folder [\#2326](https://github.com/pypeclub/OpenPype/pull/2326) - InputLinks: Typo in "inputLinks" key [\#2314](https://github.com/pypeclub/OpenPype/pull/2314) - Deadline timeout and logging [\#2312](https://github.com/pypeclub/OpenPype/pull/2312) - nuke: do not multiply representation on class method [\#2311](https://github.com/pypeclub/OpenPype/pull/2311) - Workfiles tool: Fix task formatting [\#2306](https://github.com/pypeclub/OpenPype/pull/2306) - Delivery: Fix delivery paths created on windows [\#2302](https://github.com/pypeclub/OpenPype/pull/2302) - Maya: Deadline - fix limit groups [\#2295](https://github.com/pypeclub/OpenPype/pull/2295) - Royal Render: Fix plugin order and OpenPype auto-detection [\#2291](https://github.com/pypeclub/OpenPype/pull/2291) - New Publisher: Fix mapping of indexes [\#2285](https://github.com/pypeclub/OpenPype/pull/2285) - Alternate site for site sync doesnt work for sequences [\#2284](https://github.com/pypeclub/OpenPype/pull/2284) - FFmpeg: Execute ffprobe using list of arguments instead of string command [\#2281](https://github.com/pypeclub/OpenPype/pull/2281) - Nuke: Anatomy fill data use task as dictionary [\#2278](https://github.com/pypeclub/OpenPype/pull/2278) - Bug: fix variable name \_asset\_id in workfiles application [\#2274](https://github.com/pypeclub/OpenPype/pull/2274) - Version handling fixes [\#2272](https://github.com/pypeclub/OpenPype/pull/2272) **Merged pull requests:** - Maya: Replaced PATH usage with vendored oiio path for maketx utility [\#2405](https://github.com/pypeclub/OpenPype/pull/2405) - \[Fix\]\[MAYA\] Handle message type attribute within CollectLook [\#2394](https://github.com/pypeclub/OpenPype/pull/2394) - Add validator to check correct version of extension for PS and AE [\#2387](https://github.com/pypeclub/OpenPype/pull/2387) - Maya: configurable model top level validation [\#2321](https://github.com/pypeclub/OpenPype/pull/2321) - Create test publish class for After Effects [\#2270](https://github.com/pypeclub/OpenPype/pull/2270) ## [3.6.4](https://github.com/pypeclub/OpenPype/tree/3.6.4) (2021-11-23) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.3...3.6.4) **🐛 Bug fixes** - Nuke: inventory update removes all loaded read nodes [\#2294](https://github.com/pypeclub/OpenPype/pull/2294) ## [3.6.3](https://github.com/pypeclub/OpenPype/tree/3.6.3) (2021-11-19) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.2...3.6.3) **🐛 Bug fixes** - Deadline: Fix publish targets [\#2280](https://github.com/pypeclub/OpenPype/pull/2280) ## [3.6.2](https://github.com/pypeclub/OpenPype/tree/3.6.2) (2021-11-18) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.1...3.6.2) **🚀 Enhancements** - Tools: Assets widget [\#2265](https://github.com/pypeclub/OpenPype/pull/2265) - SceneInventory: Choose loader in asset switcher [\#2262](https://github.com/pypeclub/OpenPype/pull/2262) - Style: New fonts in OpenPype style [\#2256](https://github.com/pypeclub/OpenPype/pull/2256) - Tools: SceneInventory in OpenPype [\#2255](https://github.com/pypeclub/OpenPype/pull/2255) - Tools: Tasks widget [\#2251](https://github.com/pypeclub/OpenPype/pull/2251) - Tools: Creator in OpenPype [\#2244](https://github.com/pypeclub/OpenPype/pull/2244) - Added endpoint for configured extensions [\#2221](https://github.com/pypeclub/OpenPype/pull/2221) **🐛 Bug fixes** - Tools: Parenting of tools in Nuke and Hiero [\#2266](https://github.com/pypeclub/OpenPype/pull/2266) - limiting validator to specific editorial hosts [\#2264](https://github.com/pypeclub/OpenPype/pull/2264) - Tools: Select Context dialog attribute fix [\#2261](https://github.com/pypeclub/OpenPype/pull/2261) - Maya: Render publishing fails on linux [\#2260](https://github.com/pypeclub/OpenPype/pull/2260) - LookAssigner: Fix tool reopen [\#2259](https://github.com/pypeclub/OpenPype/pull/2259) - Standalone: editorial not publishing thumbnails on all subsets [\#2258](https://github.com/pypeclub/OpenPype/pull/2258) - Burnins: Support mxf metadata [\#2247](https://github.com/pypeclub/OpenPype/pull/2247) - Maya: Support for configurable AOV separator characters [\#2197](https://github.com/pypeclub/OpenPype/pull/2197) - Maya: texture colorspace modes in looks [\#2195](https://github.com/pypeclub/OpenPype/pull/2195) ## [3.6.1](https://github.com/pypeclub/OpenPype/tree/3.6.1) (2021-11-16) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.0...3.6.1) **🐛 Bug fixes** - Loader doesn't allow changing of version before loading [\#2254](https://github.com/pypeclub/OpenPype/pull/2254) ## [3.6.0](https://github.com/pypeclub/OpenPype/tree/3.6.0) (2021-11-15) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.5.0...3.6.0) ### 📖 Documentation - Add alternative sites for Site Sync [\#2206](https://github.com/pypeclub/OpenPype/pull/2206) - Add command line way of running site sync server [\#2188](https://github.com/pypeclub/OpenPype/pull/2188) **🆕 New features** - Add validate active site button to sync queue on a project [\#2176](https://github.com/pypeclub/OpenPype/pull/2176) - Maya : Colorspace configuration [\#2170](https://github.com/pypeclub/OpenPype/pull/2170) - Blender: Added support for audio [\#2168](https://github.com/pypeclub/OpenPype/pull/2168) - Flame: a host basic integration [\#2165](https://github.com/pypeclub/OpenPype/pull/2165) - Houdini: simple HDA workflow [\#2072](https://github.com/pypeclub/OpenPype/pull/2072) - Basic Royal Render Integration ✨ [\#2061](https://github.com/pypeclub/OpenPype/pull/2061) - Camera handling between Blender and Unreal [\#1988](https://github.com/pypeclub/OpenPype/pull/1988) - switch PyQt5 for PySide2 [\#1744](https://github.com/pypeclub/OpenPype/pull/1744) **🚀 Enhancements** - Tools: Subset manager in OpenPype [\#2243](https://github.com/pypeclub/OpenPype/pull/2243) - General: Skip module directories without init file [\#2239](https://github.com/pypeclub/OpenPype/pull/2239) - General: Static interfaces [\#2238](https://github.com/pypeclub/OpenPype/pull/2238) - Style: Fix transparent image in style [\#2235](https://github.com/pypeclub/OpenPype/pull/2235) - Add a "following workfile versioning" option on publish [\#2225](https://github.com/pypeclub/OpenPype/pull/2225) - Modules: Module can add cli commands [\#2224](https://github.com/pypeclub/OpenPype/pull/2224) - Webpublisher: Separate webpublisher logic [\#2222](https://github.com/pypeclub/OpenPype/pull/2222) - Add both side availability on Site Sync sites to Loader [\#2220](https://github.com/pypeclub/OpenPype/pull/2220) - Tools: Center loader and library loader on show [\#2219](https://github.com/pypeclub/OpenPype/pull/2219) - Maya : Validate shape zero [\#2212](https://github.com/pypeclub/OpenPype/pull/2212) - Maya : validate unique names [\#2211](https://github.com/pypeclub/OpenPype/pull/2211) - Tools: OpenPype stylesheet in workfiles tool [\#2208](https://github.com/pypeclub/OpenPype/pull/2208) - Ftrack: Replace Queue with deque in event handlers logic [\#2204](https://github.com/pypeclub/OpenPype/pull/2204) - Tools: New select context dialog [\#2200](https://github.com/pypeclub/OpenPype/pull/2200) - Maya : Validate mesh ngons [\#2199](https://github.com/pypeclub/OpenPype/pull/2199) - Dirmap in Nuke [\#2198](https://github.com/pypeclub/OpenPype/pull/2198) - Delivery: Check 'frame' key in template for sequence delivery [\#2196](https://github.com/pypeclub/OpenPype/pull/2196) - Settings: Site sync project settings improvement [\#2193](https://github.com/pypeclub/OpenPype/pull/2193) - Usage of tools code [\#2185](https://github.com/pypeclub/OpenPype/pull/2185) - Settings: Dictionary based on project roots [\#2184](https://github.com/pypeclub/OpenPype/pull/2184) - Subset name: Be able to pass asset document to get subset name [\#2179](https://github.com/pypeclub/OpenPype/pull/2179) - Tools: Experimental tools [\#2167](https://github.com/pypeclub/OpenPype/pull/2167) - Loader: Refactor and use OpenPype stylesheets [\#2166](https://github.com/pypeclub/OpenPype/pull/2166) - Add loader for linked smart objects in photoshop [\#2149](https://github.com/pypeclub/OpenPype/pull/2149) - Burnins: DNxHD profiles handling [\#2142](https://github.com/pypeclub/OpenPype/pull/2142) - Tools: Single access point for host tools [\#2139](https://github.com/pypeclub/OpenPype/pull/2139) **🐛 Bug fixes** - Ftrack: Sync project ftrack id cache issue [\#2250](https://github.com/pypeclub/OpenPype/pull/2250) - Ftrack: Session creation and Prepare project [\#2245](https://github.com/pypeclub/OpenPype/pull/2245) - Added queue for studio processing in PS [\#2237](https://github.com/pypeclub/OpenPype/pull/2237) - Python 2: Unicode to string conversion [\#2236](https://github.com/pypeclub/OpenPype/pull/2236) - Fix - enum for color coding in PS [\#2234](https://github.com/pypeclub/OpenPype/pull/2234) - Pyblish Tool: Fix targets handling [\#2232](https://github.com/pypeclub/OpenPype/pull/2232) - Ftrack: Base event fix of 'get\_project\_from\_entity' method [\#2214](https://github.com/pypeclub/OpenPype/pull/2214) - Maya : multiple subsets review broken [\#2210](https://github.com/pypeclub/OpenPype/pull/2210) - Fix - different command used for Linux and Mac OS [\#2207](https://github.com/pypeclub/OpenPype/pull/2207) - Tools: Workfiles tool don't use avalon widgets [\#2205](https://github.com/pypeclub/OpenPype/pull/2205) - Ftrack: Fill missing ftrack id on mongo project [\#2203](https://github.com/pypeclub/OpenPype/pull/2203) - Project Manager: Fix copying of tasks [\#2191](https://github.com/pypeclub/OpenPype/pull/2191) - StandalonePublisher: Source validator don't expect representations [\#2190](https://github.com/pypeclub/OpenPype/pull/2190) - Blender: Fix trying to pack an image when the shader node has no texture [\#2183](https://github.com/pypeclub/OpenPype/pull/2183) - Maya: review viewport settings [\#2177](https://github.com/pypeclub/OpenPype/pull/2177) - MacOS: Launching of applications may cause Permissions error [\#2175](https://github.com/pypeclub/OpenPype/pull/2175) - Maya: Aspect ratio [\#2174](https://github.com/pypeclub/OpenPype/pull/2174) - Blender: Fix 'Deselect All' with object not in 'Object Mode' [\#2163](https://github.com/pypeclub/OpenPype/pull/2163) - Tools: Stylesheets are applied after tool show [\#2161](https://github.com/pypeclub/OpenPype/pull/2161) - Maya: Collect render - fix UNC path support 🐛 [\#2158](https://github.com/pypeclub/OpenPype/pull/2158) - Maya: Fix hotbox broken by scriptsmenu [\#2151](https://github.com/pypeclub/OpenPype/pull/2151) - Ftrack: Ignore save warnings exception in Prepare project action [\#2150](https://github.com/pypeclub/OpenPype/pull/2150) - Loader thumbnails with smooth edges [\#2147](https://github.com/pypeclub/OpenPype/pull/2147) - Added validator for source files for Standalone Publisher [\#2138](https://github.com/pypeclub/OpenPype/pull/2138) **Merged pull requests:** - Bump pillow from 8.2.0 to 8.3.2 [\#2162](https://github.com/pypeclub/OpenPype/pull/2162) - Bump axios from 0.21.1 to 0.21.4 in /website [\#2059](https://github.com/pypeclub/OpenPype/pull/2059) ## [3.5.0](https://github.com/pypeclub/OpenPype/tree/3.5.0) (2021-10-17) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.4.1...3.5.0) **Deprecated:** - Maya: Change mayaAscii family to mayaScene [\#2106](https://github.com/pypeclub/OpenPype/pull/2106) **🆕 New features** - Added project and task into context change message in Maya [\#2131](https://github.com/pypeclub/OpenPype/pull/2131) - Add ExtractBurnin to photoshop review [\#2124](https://github.com/pypeclub/OpenPype/pull/2124) - PYPE-1218 - changed namespace to contain subset name in Maya [\#2114](https://github.com/pypeclub/OpenPype/pull/2114) - Added running configurable disk mapping command before start of OP [\#2091](https://github.com/pypeclub/OpenPype/pull/2091) - SFTP provider [\#2073](https://github.com/pypeclub/OpenPype/pull/2073) - Maya: Validate setdress top group [\#2068](https://github.com/pypeclub/OpenPype/pull/2068) - Maya: Enable publishing render attrib sets \(e.g. V-Ray Displacement\) with model [\#1955](https://github.com/pypeclub/OpenPype/pull/1955) **🚀 Enhancements** - Maya: make rig validators configurable in settings [\#2137](https://github.com/pypeclub/OpenPype/pull/2137) - Settings: Updated readme for entity types in settings [\#2132](https://github.com/pypeclub/OpenPype/pull/2132) - Nuke: unified clip loader [\#2128](https://github.com/pypeclub/OpenPype/pull/2128) - Settings UI: Project model refreshing and sorting [\#2104](https://github.com/pypeclub/OpenPype/pull/2104) - Create Read From Rendered - Disable Relative paths by default [\#2093](https://github.com/pypeclub/OpenPype/pull/2093) - Added choosing different dirmap mapping if workfile synched locally [\#2088](https://github.com/pypeclub/OpenPype/pull/2088) - General: Remove IdleManager module [\#2084](https://github.com/pypeclub/OpenPype/pull/2084) - Tray UI: Message box about missing settings defaults [\#2080](https://github.com/pypeclub/OpenPype/pull/2080) - Tray UI: Show menu where first click happened [\#2079](https://github.com/pypeclub/OpenPype/pull/2079) - Global: add global validators to settings [\#2078](https://github.com/pypeclub/OpenPype/pull/2078) - Use CRF for burnin when available [\#2070](https://github.com/pypeclub/OpenPype/pull/2070) - Project manager: Filter first item after selection of project [\#2069](https://github.com/pypeclub/OpenPype/pull/2069) - Nuke: Adding `still` image family workflow [\#2064](https://github.com/pypeclub/OpenPype/pull/2064) - Maya: validate authorized loaded plugins [\#2062](https://github.com/pypeclub/OpenPype/pull/2062) - Tools: add support for pyenv on windows [\#2051](https://github.com/pypeclub/OpenPype/pull/2051) - SyncServer: Dropbox Provider [\#1979](https://github.com/pypeclub/OpenPype/pull/1979) - Burnin: Get data from context with defined keys. [\#1897](https://github.com/pypeclub/OpenPype/pull/1897) - Timers manager: Get task time [\#1896](https://github.com/pypeclub/OpenPype/pull/1896) - TVPaint: Option to stop timer on application exit. [\#1887](https://github.com/pypeclub/OpenPype/pull/1887) **🐛 Bug fixes** - Maya: fix model publishing [\#2130](https://github.com/pypeclub/OpenPype/pull/2130) - Fix - oiiotool wasn't recognized even if present [\#2129](https://github.com/pypeclub/OpenPype/pull/2129) - General: Disk mapping group [\#2120](https://github.com/pypeclub/OpenPype/pull/2120) - Hiero: publishing effect first time makes wrong resources path [\#2115](https://github.com/pypeclub/OpenPype/pull/2115) - Add startup script for Houdini Core. [\#2110](https://github.com/pypeclub/OpenPype/pull/2110) - TVPaint: Behavior name of loop also accept repeat [\#2109](https://github.com/pypeclub/OpenPype/pull/2109) - Ftrack: Project settings save custom attributes skip unknown attributes [\#2103](https://github.com/pypeclub/OpenPype/pull/2103) - Blender: Fix NoneType error when animation\_data is missing for a rig [\#2101](https://github.com/pypeclub/OpenPype/pull/2101) - Fix broken import in sftp provider [\#2100](https://github.com/pypeclub/OpenPype/pull/2100) - Global: Fix docstring on publish plugin extract review [\#2097](https://github.com/pypeclub/OpenPype/pull/2097) - Delivery Action Files Sequence fix [\#2096](https://github.com/pypeclub/OpenPype/pull/2096) - General: Cloud mongo ca certificate issue [\#2095](https://github.com/pypeclub/OpenPype/pull/2095) - TVPaint: Creator use context from workfile [\#2087](https://github.com/pypeclub/OpenPype/pull/2087) - Blender: fix texture missing when publishing blend files [\#2085](https://github.com/pypeclub/OpenPype/pull/2085) - General: Startup validations oiio tool path fix on linux [\#2083](https://github.com/pypeclub/OpenPype/pull/2083) - Deadline: Collect deadline server does not check existence of deadline key [\#2082](https://github.com/pypeclub/OpenPype/pull/2082) - Blender: fixed Curves with modifiers in Rigs [\#2081](https://github.com/pypeclub/OpenPype/pull/2081) - Nuke UI scaling [\#2077](https://github.com/pypeclub/OpenPype/pull/2077) - Maya: Fix multi-camera renders [\#2065](https://github.com/pypeclub/OpenPype/pull/2065) - Fix Sync Queue when project disabled [\#2063](https://github.com/pypeclub/OpenPype/pull/2063) **Merged pull requests:** - Bump pywin32 from 300 to 301 [\#2086](https://github.com/pypeclub/OpenPype/pull/2086) ## [3.4.1](https://github.com/pypeclub/OpenPype/tree/3.4.1) (2021-09-23) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.4.0...3.4.1) **🆕 New features** - Settings: Flag project as deactivated and hide from tools' view [\#2008](https://github.com/pypeclub/OpenPype/pull/2008) **🚀 Enhancements** - General: Startup validations [\#2054](https://github.com/pypeclub/OpenPype/pull/2054) - Nuke: proxy mode validator [\#2052](https://github.com/pypeclub/OpenPype/pull/2052) - Ftrack: Removed ftrack interface [\#2049](https://github.com/pypeclub/OpenPype/pull/2049) - Settings UI: Deffered set value on entity [\#2044](https://github.com/pypeclub/OpenPype/pull/2044) - Loader: Families filtering [\#2043](https://github.com/pypeclub/OpenPype/pull/2043) - Settings UI: Project view enhancements [\#2042](https://github.com/pypeclub/OpenPype/pull/2042) - Settings for Nuke IncrementScriptVersion [\#2039](https://github.com/pypeclub/OpenPype/pull/2039) - Loader & Library loader: Use tools from OpenPype [\#2038](https://github.com/pypeclub/OpenPype/pull/2038) - Adding predefined project folders creation in PM [\#2030](https://github.com/pypeclub/OpenPype/pull/2030) - WebserverModule: Removed interface of webserver module [\#2028](https://github.com/pypeclub/OpenPype/pull/2028) - TimersManager: Removed interface of timers manager [\#2024](https://github.com/pypeclub/OpenPype/pull/2024) - Feature Maya import asset from scene inventory [\#2018](https://github.com/pypeclub/OpenPype/pull/2018) **🐛 Bug fixes** - Timers manger: Typo fix [\#2058](https://github.com/pypeclub/OpenPype/pull/2058) - Hiero: Editorial fixes [\#2057](https://github.com/pypeclub/OpenPype/pull/2057) - Differentiate jpg sequences from thumbnail [\#2056](https://github.com/pypeclub/OpenPype/pull/2056) - FFmpeg: Split command to list does not work [\#2046](https://github.com/pypeclub/OpenPype/pull/2046) - Removed shell flag in subprocess call [\#2045](https://github.com/pypeclub/OpenPype/pull/2045) **Merged pull requests:** - Bump prismjs from 1.24.0 to 1.25.0 in /website [\#2050](https://github.com/pypeclub/OpenPype/pull/2050) ## [3.4.0](https://github.com/pypeclub/OpenPype/tree/3.4.0) (2021-09-17) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.3.1...3.4.0) ### 📖 Documentation - Documentation: Ftrack launch argsuments update [\#2014](https://github.com/pypeclub/OpenPype/pull/2014) - Nuke Quick Start / Tutorial [\#1952](https://github.com/pypeclub/OpenPype/pull/1952) - Houdini: add Camera, Point Cache, Composite, Redshift ROP and VDB Cache support [\#1821](https://github.com/pypeclub/OpenPype/pull/1821) **🆕 New features** - Nuke: Compatibility with Nuke 13 [\#2003](https://github.com/pypeclub/OpenPype/pull/2003) - Maya: Add Xgen family support [\#1947](https://github.com/pypeclub/OpenPype/pull/1947) - Feature/webpublisher backend [\#1876](https://github.com/pypeclub/OpenPype/pull/1876) - Blender: Improved assets handling [\#1615](https://github.com/pypeclub/OpenPype/pull/1615) **🚀 Enhancements** - Added possibility to configure of synchronization of workfile version… [\#2041](https://github.com/pypeclub/OpenPype/pull/2041) - General: Task types in profiles [\#2036](https://github.com/pypeclub/OpenPype/pull/2036) - Console interpreter: Handle invalid sizes on initialization [\#2022](https://github.com/pypeclub/OpenPype/pull/2022) - Ftrack: Show OpenPype versions in event server status [\#2019](https://github.com/pypeclub/OpenPype/pull/2019) - General: Staging icon [\#2017](https://github.com/pypeclub/OpenPype/pull/2017) - Ftrack: Sync to avalon actions have jobs [\#2015](https://github.com/pypeclub/OpenPype/pull/2015) - Modules: Connect method is not required [\#2009](https://github.com/pypeclub/OpenPype/pull/2009) - Settings UI: Number with configurable steps [\#2001](https://github.com/pypeclub/OpenPype/pull/2001) - Moving project folder structure creation out of ftrack module \#1989 [\#1996](https://github.com/pypeclub/OpenPype/pull/1996) - Configurable items for providers without Settings [\#1987](https://github.com/pypeclub/OpenPype/pull/1987) - Global: Example addons [\#1986](https://github.com/pypeclub/OpenPype/pull/1986) - Standalone Publisher: Extract harmony zip handle workfile template [\#1982](https://github.com/pypeclub/OpenPype/pull/1982) - Settings UI: Number sliders [\#1978](https://github.com/pypeclub/OpenPype/pull/1978) - Workfiles: Support more workfile templates [\#1966](https://github.com/pypeclub/OpenPype/pull/1966) - Launcher: Fix crashes on action click [\#1964](https://github.com/pypeclub/OpenPype/pull/1964) - Settings: Minor fixes in UI and missing default values [\#1963](https://github.com/pypeclub/OpenPype/pull/1963) - Blender: Toggle system console works on windows [\#1962](https://github.com/pypeclub/OpenPype/pull/1962) - Global: Settings defined by Addons/Modules [\#1959](https://github.com/pypeclub/OpenPype/pull/1959) - CI: change release numbering triggers [\#1954](https://github.com/pypeclub/OpenPype/pull/1954) - Global: Avalon Host name collector [\#1949](https://github.com/pypeclub/OpenPype/pull/1949) - Global: Define hosts in CollectSceneVersion [\#1948](https://github.com/pypeclub/OpenPype/pull/1948) - Add face sets to exported alembics [\#1942](https://github.com/pypeclub/OpenPype/pull/1942) - OpenPype: Add version validation and `--headless` mode and update progress 🔄 [\#1939](https://github.com/pypeclub/OpenPype/pull/1939) - \#1894 - adds host to template\_name\_profiles for filtering [\#1915](https://github.com/pypeclub/OpenPype/pull/1915) - Environments: Tool environments in alphabetical order [\#1910](https://github.com/pypeclub/OpenPype/pull/1910) - Disregard publishing time. [\#1888](https://github.com/pypeclub/OpenPype/pull/1888) - Dynamic modules [\#1872](https://github.com/pypeclub/OpenPype/pull/1872) **🐛 Bug fixes** - Workfiles tool: Task selection [\#2040](https://github.com/pypeclub/OpenPype/pull/2040) - Ftrack: Delete old versions missing settings key [\#2037](https://github.com/pypeclub/OpenPype/pull/2037) - Nuke: typo on a button [\#2034](https://github.com/pypeclub/OpenPype/pull/2034) - Hiero: Fix "none" named tags [\#2033](https://github.com/pypeclub/OpenPype/pull/2033) - FFmpeg: Subprocess arguments as list [\#2032](https://github.com/pypeclub/OpenPype/pull/2032) - General: Fix Python 2 breaking line [\#2016](https://github.com/pypeclub/OpenPype/pull/2016) - Bugfix/webpublisher task type [\#2006](https://github.com/pypeclub/OpenPype/pull/2006) - Nuke thumbnails generated from middle of the sequence [\#1992](https://github.com/pypeclub/OpenPype/pull/1992) - Nuke: last version from path gets correct version [\#1990](https://github.com/pypeclub/OpenPype/pull/1990) - nuke, resolve, hiero: precollector order lest then 0.5 [\#1984](https://github.com/pypeclub/OpenPype/pull/1984) - Last workfile with multiple work templates [\#1981](https://github.com/pypeclub/OpenPype/pull/1981) - Collectors order [\#1977](https://github.com/pypeclub/OpenPype/pull/1977) - Stop timer was within validator order range. [\#1975](https://github.com/pypeclub/OpenPype/pull/1975) - Ftrack: arrow submodule has https url source [\#1974](https://github.com/pypeclub/OpenPype/pull/1974) - Ftrack: Fix hosts attribute in collect ftrack username [\#1972](https://github.com/pypeclub/OpenPype/pull/1972) - Deadline: Houdini plugins in different hierarchy [\#1970](https://github.com/pypeclub/OpenPype/pull/1970) - Removed deprecated submodules [\#1967](https://github.com/pypeclub/OpenPype/pull/1967) - Global: ExtractJpeg can handle filepaths with spaces [\#1961](https://github.com/pypeclub/OpenPype/pull/1961) - Resolve path when adding to zip [\#1960](https://github.com/pypeclub/OpenPype/pull/1960) **Merged pull requests:** - Bump url-parse from 1.5.1 to 1.5.3 in /website [\#1958](https://github.com/pypeclub/OpenPype/pull/1958) - Bump path-parse from 1.0.6 to 1.0.7 in /website [\#1933](https://github.com/pypeclub/OpenPype/pull/1933) ## [3.3.1](https://github.com/pypeclub/OpenPype/tree/3.3.1) (2021-08-20) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.3.0...3.3.1) **🐛 Bug fixes** - TVPaint: Fixed rendered frame indexes [\#1946](https://github.com/pypeclub/OpenPype/pull/1946) - Maya: Menu actions fix [\#1945](https://github.com/pypeclub/OpenPype/pull/1945) - standalone: editorial shared object problem [\#1941](https://github.com/pypeclub/OpenPype/pull/1941) - Bugfix nuke deadline app name [\#1928](https://github.com/pypeclub/OpenPype/pull/1928) ## [3.3.0](https://github.com/pypeclub/OpenPype/tree/3.3.0) (2021-08-17) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.2.0...3.3.0) ### 📖 Documentation - Standalone Publish of textures family [\#1834](https://github.com/pypeclub/OpenPype/pull/1834) **🆕 New features** - Settings UI: Breadcrumbs in settings [\#1932](https://github.com/pypeclub/OpenPype/pull/1932) - Maya: Scene patching 🩹on submission to Deadline [\#1923](https://github.com/pypeclub/OpenPype/pull/1923) - Feature AE local render [\#1901](https://github.com/pypeclub/OpenPype/pull/1901) **🚀 Enhancements** - Python console interpreter [\#1940](https://github.com/pypeclub/OpenPype/pull/1940) - Global: Updated logos and Default settings [\#1927](https://github.com/pypeclub/OpenPype/pull/1927) - Check for missing ✨ Python when using `pyenv` [\#1925](https://github.com/pypeclub/OpenPype/pull/1925) - Settings: Default values for enum [\#1920](https://github.com/pypeclub/OpenPype/pull/1920) - Settings UI: Modifiable dict view enhance [\#1919](https://github.com/pypeclub/OpenPype/pull/1919) - submodules: avalon-core update [\#1911](https://github.com/pypeclub/OpenPype/pull/1911) - Ftrack: Where I run action enhancement [\#1900](https://github.com/pypeclub/OpenPype/pull/1900) - Ftrack: Private project server actions [\#1899](https://github.com/pypeclub/OpenPype/pull/1899) - Support nested studio plugins paths. [\#1898](https://github.com/pypeclub/OpenPype/pull/1898) - Settings: global validators with options [\#1892](https://github.com/pypeclub/OpenPype/pull/1892) - Settings: Conditional dict enum positioning [\#1891](https://github.com/pypeclub/OpenPype/pull/1891) - Expose stop timer through rest api. [\#1886](https://github.com/pypeclub/OpenPype/pull/1886) - TVPaint: Increment workfile [\#1885](https://github.com/pypeclub/OpenPype/pull/1885) - Allow Multiple Notes to run on tasks. [\#1882](https://github.com/pypeclub/OpenPype/pull/1882) - Prepare for pyside2 [\#1869](https://github.com/pypeclub/OpenPype/pull/1869) - Filter hosts in settings host-enum [\#1868](https://github.com/pypeclub/OpenPype/pull/1868) - Local actions with process identifier [\#1867](https://github.com/pypeclub/OpenPype/pull/1867) - Workfile tool start at host launch support [\#1865](https://github.com/pypeclub/OpenPype/pull/1865) - Anatomy schema validation [\#1864](https://github.com/pypeclub/OpenPype/pull/1864) - Ftrack prepare project structure [\#1861](https://github.com/pypeclub/OpenPype/pull/1861) - Maya: support for configurable `dirmap` 🗺️ [\#1859](https://github.com/pypeclub/OpenPype/pull/1859) - Independent general environments [\#1853](https://github.com/pypeclub/OpenPype/pull/1853) - TVPaint Start Frame [\#1844](https://github.com/pypeclub/OpenPype/pull/1844) - Ftrack push attributes action adds traceback to job [\#1843](https://github.com/pypeclub/OpenPype/pull/1843) - Prepare project action enhance [\#1838](https://github.com/pypeclub/OpenPype/pull/1838) - nuke: settings create missing default subsets [\#1829](https://github.com/pypeclub/OpenPype/pull/1829) - Update poetry lock [\#1823](https://github.com/pypeclub/OpenPype/pull/1823) - Settings: settings for plugins [\#1819](https://github.com/pypeclub/OpenPype/pull/1819) - Settings list can use template or schema as object type [\#1815](https://github.com/pypeclub/OpenPype/pull/1815) - Maya: Deadline custom settings [\#1797](https://github.com/pypeclub/OpenPype/pull/1797) - Maya: Shader name validation [\#1762](https://github.com/pypeclub/OpenPype/pull/1762) **🐛 Bug fixes** - Fix - ftrack family was added incorrectly in some cases [\#1935](https://github.com/pypeclub/OpenPype/pull/1935) - Fix - Deadline publish on Linux started Tray instead of headless publishing [\#1930](https://github.com/pypeclub/OpenPype/pull/1930) - Maya: Validate Model Name - repair accident deletion in settings defaults [\#1929](https://github.com/pypeclub/OpenPype/pull/1929) - Nuke: submit to farm failed due `ftrack` family remove [\#1926](https://github.com/pypeclub/OpenPype/pull/1926) - Fix - validate takes repre\["files"\] as list all the time [\#1922](https://github.com/pypeclub/OpenPype/pull/1922) - standalone: validator asset parents [\#1917](https://github.com/pypeclub/OpenPype/pull/1917) - Nuke: update video file crassing [\#1916](https://github.com/pypeclub/OpenPype/pull/1916) - Fix - texture validators for workfiles triggers only for textures workfiles [\#1914](https://github.com/pypeclub/OpenPype/pull/1914) - Settings UI: List order works as expected [\#1906](https://github.com/pypeclub/OpenPype/pull/1906) - Hiero: loaded clip was not set colorspace from version data [\#1904](https://github.com/pypeclub/OpenPype/pull/1904) - Pyblish UI: Fix collecting stage processing [\#1903](https://github.com/pypeclub/OpenPype/pull/1903) - Burnins: Use input's bitrate in h624 [\#1902](https://github.com/pypeclub/OpenPype/pull/1902) - Bug: fixed python detection [\#1893](https://github.com/pypeclub/OpenPype/pull/1893) - global: integrate name missing default template [\#1890](https://github.com/pypeclub/OpenPype/pull/1890) - publisher: editorial plugins fixes [\#1889](https://github.com/pypeclub/OpenPype/pull/1889) - Normalize path returned from Workfiles. [\#1880](https://github.com/pypeclub/OpenPype/pull/1880) - Workfiles tool event arguments fix [\#1862](https://github.com/pypeclub/OpenPype/pull/1862) - imageio: fix grouping [\#1856](https://github.com/pypeclub/OpenPype/pull/1856) - Maya: don't add reference members as connections to the container set 📦 [\#1855](https://github.com/pypeclub/OpenPype/pull/1855) - publisher: missing version in subset prop [\#1849](https://github.com/pypeclub/OpenPype/pull/1849) - Ftrack type error fix in sync to avalon event handler [\#1845](https://github.com/pypeclub/OpenPype/pull/1845) - Nuke: updating effects subset fail [\#1841](https://github.com/pypeclub/OpenPype/pull/1841) - nuke: write render node skipped with crop [\#1836](https://github.com/pypeclub/OpenPype/pull/1836) - Project folder structure overrides [\#1813](https://github.com/pypeclub/OpenPype/pull/1813) - Maya: fix yeti settings path in extractor [\#1809](https://github.com/pypeclub/OpenPype/pull/1809) - Failsafe for cross project containers. [\#1806](https://github.com/pypeclub/OpenPype/pull/1806) - Houdini colector formatting keys fix [\#1802](https://github.com/pypeclub/OpenPype/pull/1802) - Settings error dialog on show [\#1798](https://github.com/pypeclub/OpenPype/pull/1798) - Application launch stdout/stderr in GUI build [\#1684](https://github.com/pypeclub/OpenPype/pull/1684) - Nuke: re-use instance nodes output path [\#1577](https://github.com/pypeclub/OpenPype/pull/1577) **Merged pull requests:** - Fix - make AE workfile publish to Ftrack configurable [\#1937](https://github.com/pypeclub/OpenPype/pull/1937) - Add support for multiple Deadline ☠️➖ servers [\#1905](https://github.com/pypeclub/OpenPype/pull/1905) - Maya: add support for `RedshiftNormalMap` node, fix `tx` linear space 🚀 [\#1863](https://github.com/pypeclub/OpenPype/pull/1863) - Maya: expected files -\> render products ⚙️ overhaul [\#1812](https://github.com/pypeclub/OpenPype/pull/1812) - PS, AE - send actual context when another webserver is running [\#1811](https://github.com/pypeclub/OpenPype/pull/1811) ## [3.2.0](https://github.com/pypeclub/OpenPype/tree/3.2.0) (2021-07-13) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.4...3.2.0) ### 📖 Documentation - Fix: staging and `--use-version` option [\#1786](https://github.com/pypeclub/OpenPype/pull/1786) - Subset template and TVPaint subset template docs [\#1717](https://github.com/pypeclub/OpenPype/pull/1717) - Overscan color extract review [\#1701](https://github.com/pypeclub/OpenPype/pull/1701) **🚀 Enhancements** - Nuke: ftrack family plugin settings preset [\#1805](https://github.com/pypeclub/OpenPype/pull/1805) - Standalone publisher last project [\#1799](https://github.com/pypeclub/OpenPype/pull/1799) - Ftrack Multiple notes as server action [\#1795](https://github.com/pypeclub/OpenPype/pull/1795) - Settings conditional dict [\#1777](https://github.com/pypeclub/OpenPype/pull/1777) - Settings application use python 2 only where needed [\#1776](https://github.com/pypeclub/OpenPype/pull/1776) - Settings UI copy/paste [\#1769](https://github.com/pypeclub/OpenPype/pull/1769) - Workfile tool widths [\#1766](https://github.com/pypeclub/OpenPype/pull/1766) - Push hierarchical attributes care about task parent changes [\#1763](https://github.com/pypeclub/OpenPype/pull/1763) - Application executables with environment variables [\#1757](https://github.com/pypeclub/OpenPype/pull/1757) - Deadline: Nuke submission additional attributes [\#1756](https://github.com/pypeclub/OpenPype/pull/1756) - Settings schema without prefill [\#1753](https://github.com/pypeclub/OpenPype/pull/1753) - Settings Hosts enum [\#1739](https://github.com/pypeclub/OpenPype/pull/1739) - Validate containers settings [\#1736](https://github.com/pypeclub/OpenPype/pull/1736) - PS - added loader from sequence [\#1726](https://github.com/pypeclub/OpenPype/pull/1726) - Autoupdate launcher [\#1725](https://github.com/pypeclub/OpenPype/pull/1725) - Toggle Ftrack upload in StandalonePublisher [\#1708](https://github.com/pypeclub/OpenPype/pull/1708) - Nuke: Prerender Frame Range by default [\#1699](https://github.com/pypeclub/OpenPype/pull/1699) - Smoother edges of color triangle [\#1695](https://github.com/pypeclub/OpenPype/pull/1695) **🐛 Bug fixes** - nuke: fixing wrong name of family folder when `used existing frames` [\#1803](https://github.com/pypeclub/OpenPype/pull/1803) - Collect ftrack family bugs [\#1801](https://github.com/pypeclub/OpenPype/pull/1801) - Invitee email can be None which break the Ftrack commit. [\#1788](https://github.com/pypeclub/OpenPype/pull/1788) - Otio unrelated error on import [\#1782](https://github.com/pypeclub/OpenPype/pull/1782) - FFprobe streams order [\#1775](https://github.com/pypeclub/OpenPype/pull/1775) - Fix - single file files are str only, cast it to list to count properly [\#1772](https://github.com/pypeclub/OpenPype/pull/1772) - Environments in app executable for MacOS [\#1768](https://github.com/pypeclub/OpenPype/pull/1768) - Project specific environments [\#1767](https://github.com/pypeclub/OpenPype/pull/1767) - Settings UI with refresh button [\#1764](https://github.com/pypeclub/OpenPype/pull/1764) - Standalone publisher thumbnail extractor fix [\#1761](https://github.com/pypeclub/OpenPype/pull/1761) - Anatomy others templates don't cause crash [\#1758](https://github.com/pypeclub/OpenPype/pull/1758) - Backend acre module commit update [\#1745](https://github.com/pypeclub/OpenPype/pull/1745) - hiero: precollect instances failing when audio selected [\#1743](https://github.com/pypeclub/OpenPype/pull/1743) - Hiero: creator instance error [\#1742](https://github.com/pypeclub/OpenPype/pull/1742) - Nuke: fixing render creator for no selection format failing [\#1741](https://github.com/pypeclub/OpenPype/pull/1741) - StandalonePublisher: failing collector for editorial [\#1738](https://github.com/pypeclub/OpenPype/pull/1738) - Local settings UI crash on missing defaults [\#1737](https://github.com/pypeclub/OpenPype/pull/1737) - TVPaint white background on thumbnail [\#1735](https://github.com/pypeclub/OpenPype/pull/1735) - Ftrack missing custom attribute message [\#1734](https://github.com/pypeclub/OpenPype/pull/1734) - Launcher project changes [\#1733](https://github.com/pypeclub/OpenPype/pull/1733) - Ftrack sync status [\#1732](https://github.com/pypeclub/OpenPype/pull/1732) - TVPaint use layer name for default variant [\#1724](https://github.com/pypeclub/OpenPype/pull/1724) - Default subset template for TVPaint review and workfile families [\#1716](https://github.com/pypeclub/OpenPype/pull/1716) - Maya: Extract review hotfix [\#1714](https://github.com/pypeclub/OpenPype/pull/1714) - Settings: Imageio improving granularity [\#1711](https://github.com/pypeclub/OpenPype/pull/1711) - Application without executables [\#1679](https://github.com/pypeclub/OpenPype/pull/1679) - Unreal: launching on Linux [\#1672](https://github.com/pypeclub/OpenPype/pull/1672) **Merged pull requests:** - Bump prismjs from 1.23.0 to 1.24.0 in /website [\#1773](https://github.com/pypeclub/OpenPype/pull/1773) - TVPaint ftrack family [\#1755](https://github.com/pypeclub/OpenPype/pull/1755) ## [2.18.4](https://github.com/pypeclub/OpenPype/tree/2.18.4) (2021-06-24) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.3...2.18.4) ## [2.18.3](https://github.com/pypeclub/OpenPype/tree/2.18.3) (2021-06-23) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.2...2.18.3) ## [2.18.2](https://github.com/pypeclub/OpenPype/tree/2.18.2) (2021-06-16) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.1.0...2.18.2) ## [3.1.0](https://github.com/pypeclub/OpenPype/tree/3.1.0) (2021-06-15) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.0.0...3.1.0) ### 📖 Documentation - Feature Slack integration [\#1657](https://github.com/pypeclub/OpenPype/pull/1657) **🚀 Enhancements** - Log Viewer with OpenPype style [\#1703](https://github.com/pypeclub/OpenPype/pull/1703) - Scrolling in OpenPype info widget [\#1702](https://github.com/pypeclub/OpenPype/pull/1702) - OpenPype style in modules [\#1694](https://github.com/pypeclub/OpenPype/pull/1694) - Sort applications and tools alphabetically in Settings UI [\#1689](https://github.com/pypeclub/OpenPype/pull/1689) - \#683 - Validate Frame Range in Standalone Publisher [\#1683](https://github.com/pypeclub/OpenPype/pull/1683) - Hiero: old container versions identify with red color [\#1682](https://github.com/pypeclub/OpenPype/pull/1682) - Project Manger: Default name column width [\#1669](https://github.com/pypeclub/OpenPype/pull/1669) - Remove outline in stylesheet [\#1667](https://github.com/pypeclub/OpenPype/pull/1667) - TVPaint: Creator take layer name as default value for subset variant [\#1663](https://github.com/pypeclub/OpenPype/pull/1663) - TVPaint custom subset template [\#1662](https://github.com/pypeclub/OpenPype/pull/1662) - Editorial: conform assets validator [\#1659](https://github.com/pypeclub/OpenPype/pull/1659) - Nuke - Publish simplification [\#1653](https://github.com/pypeclub/OpenPype/pull/1653) - \#1333 - added tooltip hints to Pyblish buttons [\#1649](https://github.com/pypeclub/OpenPype/pull/1649) **🐛 Bug fixes** - Nuke: broken publishing rendered frames [\#1707](https://github.com/pypeclub/OpenPype/pull/1707) - Standalone publisher Thumbnail export args [\#1705](https://github.com/pypeclub/OpenPype/pull/1705) - Bad zip can break OpenPype start [\#1691](https://github.com/pypeclub/OpenPype/pull/1691) - Hiero: published whole edit mov [\#1687](https://github.com/pypeclub/OpenPype/pull/1687) - Ftrack subprocess handle of stdout/stderr [\#1675](https://github.com/pypeclub/OpenPype/pull/1675) - Settings list race condifiton and mutable dict list conversion [\#1671](https://github.com/pypeclub/OpenPype/pull/1671) - Mac launch arguments fix [\#1660](https://github.com/pypeclub/OpenPype/pull/1660) - Fix missing dbm python module [\#1652](https://github.com/pypeclub/OpenPype/pull/1652) - Transparent branches in view on Mac [\#1648](https://github.com/pypeclub/OpenPype/pull/1648) - Add asset on task item [\#1646](https://github.com/pypeclub/OpenPype/pull/1646) - Project manager save and queue [\#1645](https://github.com/pypeclub/OpenPype/pull/1645) - New project anatomy values [\#1644](https://github.com/pypeclub/OpenPype/pull/1644) - Farm publishing: check if published items do exist [\#1573](https://github.com/pypeclub/OpenPype/pull/1573) **Merged pull requests:** - Bump normalize-url from 4.5.0 to 4.5.1 in /website [\#1686](https://github.com/pypeclub/OpenPype/pull/1686) ## [3.0.0](https://github.com/pypeclub/openpype/tree/3.0.0) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.18.1...3.0.0) ### Configuration - Studio Settings GUI: no more json configuration files. - OpenPype Modules can be turned on and off. - Easy to add Application versions. - Per Project Environment and plugin management. - Robust profile system for creating reviewables and burnins, with filtering based on Application, Task and data family. - Configurable publish plugins. - Options to make any validator or extractor, optional or disabled. - Color Management is now unified under anatomy settings. - Subset naming and grouping is fully configurable. - All project attributes can now be set directly in OpenPype settings. - Studio Setting can be locked to prevent unwanted artist changes. - You can now add per project and per task type templates for workfile initialization in most hosts. - Too many other individual configurable option to list in this changelog :) ### Local Settings - Local Settings GUI where users can change certain option on individual basis. - Application executables. - Project roots. - Project site sync settings. ### Build, Installation and Deployments - No requirements on artist machine. - Fully distributed workflow possible. - Self-contained installation. - Available on all three major platforms. - Automatic artist OpenPype updates. - Studio OpenPype repository for updates distribution. - Robust Build system. - Safe studio update versioning with staging and production options. - MacOS build generates .app and .dmg installer. - Windows build with installer creation script. ### Misc - System and diagnostic info tool in the tray. - Launching application from Launcher indicates activity. - All project roots are now named. Single root project are now achieved by having a single named root in the project anatomy. - Every project root is cast into environment variable as well, so it can be used in DCC instead of absolute path (depends on DCC support for env vars). - Basic support for task types, on top of task names. - Timer now change automatically when the context is switched inside running application. - 'Master" versions have been renamed to "Hero". - Extract Burnins now supports file sequences and color settings. - Extract Review support overscan cropping, better letterboxes and background colour fill. - Delivery tool for copying and renaming any published assets in bulk. - Harmony, Photoshop and After Effects now connect directly with OpenPype tray instead of spawning their own terminal. ### Project Manager GUI - Create Projects. - Create Shots and Assets. - Create Tasks and assign task types. - Fill required asset attributes. - Validations for duplicated or unsupported names. - Archive Assets. - Move Asset within hierarchy. ### Site Sync (beta) - Synchronization of published files between workstations and central storage. - Ability to add arbitrary storage providers to the Site Sync system. - Default setup includes Disk and Google Drive providers as examples. - Access to availability information from Loader and Scene Manager. - Sync queue GUI with filtering, error and status reporting. - Site sync can be configured on a per-project basis. - Bulk upload and download from the loader. ### Ftrack - Actions have customisable roles. - Settings on all actions are updated live and don't need openpype restart. - Ftrack module can now be turned off completely. - It is enough to specify ftrack server name and the URL will be formed correctly. So instead of mystudio.ftrackapp.com, it's possible to use simply: "mystudio". ### Editorial - Fully OTIO based editorial publishing. - Completely re-done Hiero publishing to be a lot simpler and faster. - Consistent conforming from Resolve, Hiero and Standalone Publisher. ### Backend - OpenPype and Avalon now always share the same database (in 2.x is was possible to split them). - Major codebase refactoring to allow for better CI, versioning and control of individual integrations. - OTIO is bundled with build. - OIIO is bundled with build. - FFMPEG is bundled with build. - Rest API and host WebSocket servers have been unified into a single local webserver. - Maya look assigner has been integrated into the main codebase. - Publish GUI has been integrated into the main codebase. - Studio and Project settings overrides are now stored in Mongo. - Too many other backend fixes and tweaks to list :), you can see full changelog on github for those. - OpenPype uses Poetry to manage it's virtual environment when running from code. - all applications can be marked as python 2 or 3 compatible to make the switch a bit easier. ### Pull Requests since 3.0.0-rc.6 **Implemented enhancements:** - settings: task types enum entity [\#1605](https://github.com/pypeclub/OpenPype/issues/1605) - Settings: ignore keys in referenced schema [\#1600](https://github.com/pypeclub/OpenPype/issues/1600) - Maya: support for frame steps and frame lists [\#1585](https://github.com/pypeclub/OpenPype/issues/1585) - TVPaint: Publish workfile. [\#1548](https://github.com/pypeclub/OpenPype/issues/1548) - Loader: Current Asset Button [\#1448](https://github.com/pypeclub/OpenPype/issues/1448) - Hiero: publish with retiming [\#1377](https://github.com/pypeclub/OpenPype/issues/1377) - Ask user to restart after changing global environments in settings [\#910](https://github.com/pypeclub/OpenPype/issues/910) - add option to define paht to workfile template [\#895](https://github.com/pypeclub/OpenPype/issues/895) - Harmony: move server console to system tray [\#676](https://github.com/pypeclub/OpenPype/issues/676) - Standalone style [\#1630](https://github.com/pypeclub/OpenPype/pull/1630) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Faster hierarchical values push [\#1627](https://github.com/pypeclub/OpenPype/pull/1627) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Launcher tool style [\#1624](https://github.com/pypeclub/OpenPype/pull/1624) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Loader and Library loader enhancements [\#1623](https://github.com/pypeclub/OpenPype/pull/1623) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Tray style [\#1622](https://github.com/pypeclub/OpenPype/pull/1622) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Maya schemas cleanup [\#1610](https://github.com/pypeclub/OpenPype/pull/1610) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Settings: ignore keys in referenced schema [\#1608](https://github.com/pypeclub/OpenPype/pull/1608) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - settings: task types enum entity [\#1606](https://github.com/pypeclub/OpenPype/pull/1606) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Openpype style [\#1604](https://github.com/pypeclub/OpenPype/pull/1604) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - TVPaint: Publish workfile. [\#1597](https://github.com/pypeclub/OpenPype/pull/1597) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Nuke: add option to define path to workfile template [\#1571](https://github.com/pypeclub/OpenPype/pull/1571) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Crop overscan in Extract Review [\#1569](https://github.com/pypeclub/OpenPype/pull/1569) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Unreal and Blender: Material Workflow [\#1562](https://github.com/pypeclub/OpenPype/pull/1562) ([simonebarbieri](https://github.com/simonebarbieri)) - Harmony: move server console to system tray [\#1560](https://github.com/pypeclub/OpenPype/pull/1560) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Ask user to restart after changing global environments in settings [\#1550](https://github.com/pypeclub/OpenPype/pull/1550) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Hiero: publish with retiming [\#1545](https://github.com/pypeclub/OpenPype/pull/1545) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) **Fixed bugs:** - Library loader load asset documents on OpenPype start [\#1603](https://github.com/pypeclub/OpenPype/issues/1603) - Resolve: unable to load the same footage twice [\#1317](https://github.com/pypeclub/OpenPype/issues/1317) - Resolve: unable to load footage [\#1316](https://github.com/pypeclub/OpenPype/issues/1316) - Add required Python 2 modules [\#1291](https://github.com/pypeclub/OpenPype/issues/1291) - GUi scaling with hires displays [\#705](https://github.com/pypeclub/OpenPype/issues/705) - Maya: non unicode string in publish validation [\#673](https://github.com/pypeclub/OpenPype/issues/673) - Nuke: Rendered Frame validation is triggered by multiple collections [\#156](https://github.com/pypeclub/OpenPype/issues/156) - avalon-core debugging failing [\#80](https://github.com/pypeclub/OpenPype/issues/80) - Only check arnold shading group if arnold is used [\#72](https://github.com/pypeclub/OpenPype/issues/72) - Sync server Qt layout fix [\#1621](https://github.com/pypeclub/OpenPype/pull/1621) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Console Listener on Python 2 fix [\#1620](https://github.com/pypeclub/OpenPype/pull/1620) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Bug: Initialize blessed term only in console mode [\#1619](https://github.com/pypeclub/OpenPype/pull/1619) ([antirotor](https://github.com/antirotor)) - Settings template skip paths support wrappers [\#1618](https://github.com/pypeclub/OpenPype/pull/1618) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Maya capture 'isolate\_view' fix + minor corrections [\#1617](https://github.com/pypeclub/OpenPype/pull/1617) ([2-REC](https://github.com/2-REC)) - MacOs Fix launch of standalone publisher [\#1616](https://github.com/pypeclub/OpenPype/pull/1616) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - 'Delivery action' report fix + typos [\#1612](https://github.com/pypeclub/OpenPype/pull/1612) ([2-REC](https://github.com/2-REC)) - List append fix in mutable dict settings [\#1599](https://github.com/pypeclub/OpenPype/pull/1599) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Documentation: Maya: fix review [\#1598](https://github.com/pypeclub/OpenPype/pull/1598) ([antirotor](https://github.com/antirotor)) - Bugfix: Set certifi CA bundle for all platforms [\#1596](https://github.com/pypeclub/OpenPype/pull/1596) ([antirotor](https://github.com/antirotor)) **Merged pull requests:** - Bump dns-packet from 1.3.1 to 1.3.4 in /website [\#1611](https://github.com/pypeclub/OpenPype/pull/1611) ([dependabot[bot]](https://github.com/apps/dependabot)) - Maya: Render workflow fixes [\#1607](https://github.com/pypeclub/OpenPype/pull/1607) ([antirotor](https://github.com/antirotor)) - Maya: support for frame steps and frame lists [\#1586](https://github.com/pypeclub/OpenPype/pull/1586) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - 3.0.0 - curated changelog [\#1284](https://github.com/pypeclub/OpenPype/pull/1284) ([mkolar](https://github.com/mkolar)) ## [2.18.1](https://github.com/pypeclub/openpype/tree/2.18.1) (2021-06-03) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.18.0...2.18.1) **Enhancements:** - Faster hierarchical values push [\#1626](https://github.com/pypeclub/OpenPype/pull/1626) - Feature Delivery in library loader [\#1549](https://github.com/pypeclub/OpenPype/pull/1549) - Hiero: Initial frame publish support. [\#1172](https://github.com/pypeclub/OpenPype/pull/1172) **Fixed bugs:** - Maya capture 'isolate\_view' fix + minor corrections [\#1614](https://github.com/pypeclub/OpenPype/pull/1614) - 'Delivery action' report fix +typos [\#1613](https://github.com/pypeclub/OpenPype/pull/1613) - Delivery in LibraryLoader - fixed sequence issue [\#1590](https://github.com/pypeclub/OpenPype/pull/1590) - FFmpeg filters in quote marks [\#1588](https://github.com/pypeclub/OpenPype/pull/1588) - Ftrack delete action cause circular error [\#1581](https://github.com/pypeclub/OpenPype/pull/1581) - Fix Maya playblast. [\#1566](https://github.com/pypeclub/OpenPype/pull/1566) - More failsafes prevent errored runs. [\#1554](https://github.com/pypeclub/OpenPype/pull/1554) - Celaction publishing [\#1539](https://github.com/pypeclub/OpenPype/pull/1539) - celaction: app not starting [\#1533](https://github.com/pypeclub/OpenPype/pull/1533) **Merged pull requests:** - Maya: Render workflow fixes - 2.0 backport [\#1609](https://github.com/pypeclub/OpenPype/pull/1609) - Maya Hardware support [\#1553](https://github.com/pypeclub/OpenPype/pull/1553) ## [CI/3.0.0-rc.6](https://github.com/pypeclub/openpype/tree/CI/3.0.0-rc.6) (2021-05-27) [Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.5...CI/3.0.0-rc.6) **Implemented enhancements:** - Hiero: publish color and transformation soft-effects [\#1376](https://github.com/pypeclub/OpenPype/issues/1376) - Get rid of `AVALON\_HIERARCHY` and `hiearchy` key on asset [\#432](https://github.com/pypeclub/OpenPype/issues/432) - Sync to avalon do not store hierarchy key [\#1582](https://github.com/pypeclub/OpenPype/pull/1582) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Tools: launcher scripts for project manager [\#1557](https://github.com/pypeclub/OpenPype/pull/1557) ([antirotor](https://github.com/antirotor)) - Simple tvpaint publish [\#1555](https://github.com/pypeclub/OpenPype/pull/1555) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Feature Delivery in library loader [\#1546](https://github.com/pypeclub/OpenPype/pull/1546) ([kalisp](https://github.com/kalisp)) - Documentation: Dev and system build documentation [\#1543](https://github.com/pypeclub/OpenPype/pull/1543) ([antirotor](https://github.com/antirotor)) - Color entity [\#1542](https://github.com/pypeclub/OpenPype/pull/1542) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Extract review bg color [\#1534](https://github.com/pypeclub/OpenPype/pull/1534) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - TVPaint loader settings [\#1530](https://github.com/pypeclub/OpenPype/pull/1530) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Blender can initialize differente user script paths [\#1528](https://github.com/pypeclub/OpenPype/pull/1528) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Blender and Unreal: Improved Animation Workflow [\#1514](https://github.com/pypeclub/OpenPype/pull/1514) ([simonebarbieri](https://github.com/simonebarbieri)) - Hiero: publish color and transformation soft-effects [\#1511](https://github.com/pypeclub/OpenPype/pull/1511) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) **Fixed bugs:** - OpenPype specific version issues [\#1583](https://github.com/pypeclub/OpenPype/issues/1583) - Ftrack login server can't work without stderr [\#1576](https://github.com/pypeclub/OpenPype/issues/1576) - Mac application launch [\#1575](https://github.com/pypeclub/OpenPype/issues/1575) - Settings are not propagated to Nuke write nodes [\#1538](https://github.com/pypeclub/OpenPype/issues/1538) - Subset names settings not applied for publishing [\#1537](https://github.com/pypeclub/OpenPype/issues/1537) - Nuke: callback at start not setting colorspace [\#1412](https://github.com/pypeclub/OpenPype/issues/1412) - Pype 3: Missing icon for Settings [\#1272](https://github.com/pypeclub/OpenPype/issues/1272) - Blender: cannot initialize Avalon if BLENDER\_USER\_SCRIPTS is already used [\#1050](https://github.com/pypeclub/OpenPype/issues/1050) - Ftrack delete action cause circular error [\#206](https://github.com/pypeclub/OpenPype/issues/206) - Build: stop cleaning of pyc files in build directory [\#1592](https://github.com/pypeclub/OpenPype/pull/1592) ([antirotor](https://github.com/antirotor)) - Ftrack login server can't work without stderr [\#1591](https://github.com/pypeclub/OpenPype/pull/1591) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - FFmpeg filters in quote marks [\#1589](https://github.com/pypeclub/OpenPype/pull/1589) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - OpenPype specific version issues [\#1584](https://github.com/pypeclub/OpenPype/pull/1584) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Mac application launch [\#1580](https://github.com/pypeclub/OpenPype/pull/1580) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Ftrack delete action cause circular error [\#1579](https://github.com/pypeclub/OpenPype/pull/1579) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Hiero: publishing issues [\#1578](https://github.com/pypeclub/OpenPype/pull/1578) ([jezscha](https://github.com/jezscha)) - Nuke: callback at start not setting colorspace [\#1561](https://github.com/pypeclub/OpenPype/pull/1561) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Bugfix PS subset and quick review [\#1541](https://github.com/pypeclub/OpenPype/pull/1541) ([kalisp](https://github.com/kalisp)) - Settings are not propagated to Nuke write nodes [\#1540](https://github.com/pypeclub/OpenPype/pull/1540) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - OpenPype: Powershell scripts polishing [\#1536](https://github.com/pypeclub/OpenPype/pull/1536) ([antirotor](https://github.com/antirotor)) - Host name collecting fix [\#1535](https://github.com/pypeclub/OpenPype/pull/1535) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Handle duplicated task names in project manager [\#1531](https://github.com/pypeclub/OpenPype/pull/1531) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Validate is file attribute in settings schema [\#1529](https://github.com/pypeclub/OpenPype/pull/1529) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) **Merged pull requests:** - Bump postcss from 8.2.8 to 8.3.0 in /website [\#1593](https://github.com/pypeclub/OpenPype/pull/1593) ([dependabot[bot]](https://github.com/apps/dependabot)) - User installation documentation [\#1532](https://github.com/pypeclub/OpenPype/pull/1532) ([64qam](https://github.com/64qam)) ## [CI/3.0.0-rc.5](https://github.com/pypeclub/openpype/tree/CI/3.0.0-rc.5) (2021-05-19) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.18.0...CI/3.0.0-rc.5) **Implemented enhancements:** - OpenPype: Build - Add progress bars [\#1524](https://github.com/pypeclub/OpenPype/pull/1524) ([antirotor](https://github.com/antirotor)) - Default environments per host imlementation [\#1522](https://github.com/pypeclub/OpenPype/pull/1522) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - OpenPype: use `semver` module for version resolution [\#1513](https://github.com/pypeclub/OpenPype/pull/1513) ([antirotor](https://github.com/antirotor)) - Feature Aftereffects setting cleanup documentation [\#1510](https://github.com/pypeclub/OpenPype/pull/1510) ([kalisp](https://github.com/kalisp)) - Feature Sync server settings enhancement [\#1501](https://github.com/pypeclub/OpenPype/pull/1501) ([kalisp](https://github.com/kalisp)) - Project manager [\#1396](https://github.com/pypeclub/OpenPype/pull/1396) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) **Fixed bugs:** - Unified schema definition [\#874](https://github.com/pypeclub/OpenPype/issues/874) - Maya: fix look assignment [\#1526](https://github.com/pypeclub/OpenPype/pull/1526) ([antirotor](https://github.com/antirotor)) - Bugfix Sync server local site issues [\#1523](https://github.com/pypeclub/OpenPype/pull/1523) ([kalisp](https://github.com/kalisp)) - Store as list dictionary check initial value with right type [\#1520](https://github.com/pypeclub/OpenPype/pull/1520) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Maya: wrong collection of playblasted frames [\#1515](https://github.com/pypeclub/OpenPype/pull/1515) ([mkolar](https://github.com/mkolar)) - Convert pyblish logs to string at the moment of logging [\#1512](https://github.com/pypeclub/OpenPype/pull/1512) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - 3.0 | nuke: fixing start\_at with option gui [\#1509](https://github.com/pypeclub/OpenPype/pull/1509) ([jezscha](https://github.com/jezscha)) - Tests: fix pype -\> openpype to make tests work again [\#1508](https://github.com/pypeclub/OpenPype/pull/1508) ([antirotor](https://github.com/antirotor)) **Merged pull requests:** - OpenPype: disable submodule update with `--no-submodule-update` [\#1525](https://github.com/pypeclub/OpenPype/pull/1525) ([antirotor](https://github.com/antirotor)) - Ftrack without autosync in Pype 3 [\#1519](https://github.com/pypeclub/OpenPype/pull/1519) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Feature Harmony setting cleanup documentation [\#1506](https://github.com/pypeclub/OpenPype/pull/1506) ([kalisp](https://github.com/kalisp)) - Sync Server beginning of documentation [\#1471](https://github.com/pypeclub/OpenPype/pull/1471) ([kalisp](https://github.com/kalisp)) - Blender: publish layout json [\#1348](https://github.com/pypeclub/OpenPype/pull/1348) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) ## [2.18.0](https://github.com/pypeclub/openpype/tree/2.18.0) (2021-05-18) [Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.4...2.18.0) **Implemented enhancements:** - Default environments per host imlementation [\#1405](https://github.com/pypeclub/OpenPype/issues/1405) - Blender: publish layout json [\#1346](https://github.com/pypeclub/OpenPype/issues/1346) - Ftrack without autosync in Pype 3 [\#1128](https://github.com/pypeclub/OpenPype/issues/1128) - Launcher: started action indicator [\#1102](https://github.com/pypeclub/OpenPype/issues/1102) - Launch arguments of applications [\#1094](https://github.com/pypeclub/OpenPype/issues/1094) - Publish: instance info [\#724](https://github.com/pypeclub/OpenPype/issues/724) - Review: ability to control review length [\#482](https://github.com/pypeclub/OpenPype/issues/482) - Colorized recognition of creator result [\#394](https://github.com/pypeclub/OpenPype/issues/394) - event assign user to started task [\#49](https://github.com/pypeclub/OpenPype/issues/49) - rebuild containers from reference in maya [\#55](https://github.com/pypeclub/OpenPype/issues/55) - nuke Load metadata [\#66](https://github.com/pypeclub/OpenPype/issues/66) - Maya: Safer handling of expected render output names [\#1496](https://github.com/pypeclub/OpenPype/pull/1496) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - TVPaint: Increment workfile version on successfull publish. [\#1489](https://github.com/pypeclub/OpenPype/pull/1489) ([tokejepsen](https://github.com/tokejepsen)) - Use SubsetLoader and multiple contexts for delete\_old\_versions [\#1484](https://github.com/pypeclub/OpenPype/pull/1484) ([tokejepsen](https://github.com/tokejepsen)) - Maya: Use of multiple deadline servers [\#1483](https://github.com/pypeclub/OpenPype/pull/1483) ([antirotor](https://github.com/antirotor)) **Fixed bugs:** - Igniter version resolution doesn't consider it's own version [\#1505](https://github.com/pypeclub/OpenPype/issues/1505) - Maya: Safer handling of expected render output names [\#1159](https://github.com/pypeclub/OpenPype/issues/1159) - Harmony: Invalid render output from non-conventionally named instance [\#871](https://github.com/pypeclub/OpenPype/issues/871) - Existing subsets hints in creator [\#1503](https://github.com/pypeclub/OpenPype/pull/1503) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - nuke: space in node name breaking process [\#1494](https://github.com/pypeclub/OpenPype/pull/1494) ([jezscha](https://github.com/jezscha)) - Maya: wrong collection of playblasted frames [\#1517](https://github.com/pypeclub/OpenPype/pull/1517) ([mkolar](https://github.com/mkolar)) - Existing subsets hints in creator [\#1502](https://github.com/pypeclub/OpenPype/pull/1502) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Use instance frame start instead of timeline. [\#1486](https://github.com/pypeclub/OpenPype/pull/1486) ([tokejepsen](https://github.com/tokejepsen)) - Maya: Redshift - set proper start frame on proxy [\#1480](https://github.com/pypeclub/OpenPype/pull/1480) ([antirotor](https://github.com/antirotor)) **Closed issues:** - Nuke: wrong "star at" value on render load [\#1352](https://github.com/pypeclub/OpenPype/issues/1352) - DV Resolve - loading/updating - image video [\#915](https://github.com/pypeclub/OpenPype/issues/915) **Merged pull requests:** - nuke: fixing start\_at with option gui [\#1507](https://github.com/pypeclub/OpenPype/pull/1507) ([jezscha](https://github.com/jezscha)) ## [CI/3.0.0-rc.4](https://github.com/pypeclub/openpype/tree/CI/3.0.0-rc.4) (2021-05-12) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.3...CI/3.0.0-rc.4) **Implemented enhancements:** - Resolve: documentation [\#1490](https://github.com/pypeclub/OpenPype/issues/1490) - Hiero: audio to review [\#1378](https://github.com/pypeclub/OpenPype/issues/1378) - nks color clips after publish [\#44](https://github.com/pypeclub/OpenPype/issues/44) - Store data from modifiable dict as list [\#1504](https://github.com/pypeclub/OpenPype/pull/1504) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Use SubsetLoader and multiple contexts for delete\_old\_versions [\#1497](https://github.com/pypeclub/OpenPype/pull/1497) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Hiero: publish audio and add to review [\#1493](https://github.com/pypeclub/OpenPype/pull/1493) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Resolve: documentation [\#1491](https://github.com/pypeclub/OpenPype/pull/1491) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Change integratenew template profiles setting [\#1487](https://github.com/pypeclub/OpenPype/pull/1487) ([kalisp](https://github.com/kalisp)) - Settings tool cleanup [\#1477](https://github.com/pypeclub/OpenPype/pull/1477) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Sorted Applications and Tools in Custom attribute [\#1476](https://github.com/pypeclub/OpenPype/pull/1476) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - PS - group all published instances [\#1416](https://github.com/pypeclub/OpenPype/pull/1416) ([kalisp](https://github.com/kalisp)) - OpenPype: Support for Docker [\#1289](https://github.com/pypeclub/OpenPype/pull/1289) ([antirotor](https://github.com/antirotor)) **Fixed bugs:** - Harmony: palettes publishing [\#1439](https://github.com/pypeclub/OpenPype/issues/1439) - Photoshop: validation for already created images [\#1435](https://github.com/pypeclub/OpenPype/issues/1435) - Nuke Extracts Thumbnail from frame out of shot range [\#963](https://github.com/pypeclub/OpenPype/issues/963) - Instance in same Context repairing [\#390](https://github.com/pypeclub/OpenPype/issues/390) - User Inactivity - Start timers sets wrong time [\#91](https://github.com/pypeclub/OpenPype/issues/91) - Use instance frame start instead of timeline [\#1499](https://github.com/pypeclub/OpenPype/pull/1499) ([mkolar](https://github.com/mkolar)) - Various smaller fixes [\#1498](https://github.com/pypeclub/OpenPype/pull/1498) ([mkolar](https://github.com/mkolar)) - nuke: space in node name breaking process [\#1495](https://github.com/pypeclub/OpenPype/pull/1495) ([jezscha](https://github.com/jezscha)) - Codec determination in extract burnin [\#1492](https://github.com/pypeclub/OpenPype/pull/1492) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Undefined constant in subprocess module [\#1485](https://github.com/pypeclub/OpenPype/pull/1485) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - List entity catch add/remove item changes properly [\#1482](https://github.com/pypeclub/OpenPype/pull/1482) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Resolve: additional fixes of publishing workflow [\#1481](https://github.com/pypeclub/OpenPype/pull/1481) ([jezscha](https://github.com/jezscha)) - Photoshop: validation for already created images [\#1436](https://github.com/pypeclub/OpenPype/pull/1436) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) **Merged pull requests:** - Maya: Support for looks on VRay Proxies [\#1443](https://github.com/pypeclub/OpenPype/pull/1443) ([antirotor](https://github.com/antirotor)) ## [2.17.3](https://github.com/pypeclub/openpype/tree/2.17.3) (2021-05-06) [Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.3...2.17.3) **Fixed bugs:** - Nuke: workfile version synced to db version always [\#1479](https://github.com/pypeclub/OpenPype/pull/1479) ([jezscha](https://github.com/jezscha)) ## [CI/3.0.0-rc.3](https://github.com/pypeclub/openpype/tree/CI/3.0.0-rc.3) (2021-05-05) [Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.2...CI/3.0.0-rc.3) **Implemented enhancements:** - Path entity with placeholder [\#1473](https://github.com/pypeclub/OpenPype/pull/1473) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Burnin custom font filepath [\#1472](https://github.com/pypeclub/OpenPype/pull/1472) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Poetry: Move to OpenPype [\#1449](https://github.com/pypeclub/OpenPype/pull/1449) ([antirotor](https://github.com/antirotor)) **Fixed bugs:** - Mac SSL path needs to be relative to pype\_root [\#1469](https://github.com/pypeclub/OpenPype/issues/1469) - Resolve: fix loading clips to timeline [\#1421](https://github.com/pypeclub/OpenPype/issues/1421) - Wrong handling of slashes when loading on mac [\#1411](https://github.com/pypeclub/OpenPype/issues/1411) - Nuke openpype3 [\#1342](https://github.com/pypeclub/OpenPype/issues/1342) - Houdini launcher [\#1171](https://github.com/pypeclub/OpenPype/issues/1171) - Fix SyncServer get\_enabled\_projects should handle global state [\#1475](https://github.com/pypeclub/OpenPype/pull/1475) ([kalisp](https://github.com/kalisp)) - Igniter buttons enable/disable fix [\#1474](https://github.com/pypeclub/OpenPype/pull/1474) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Mac SSL path needs to be relative to pype\_root [\#1470](https://github.com/pypeclub/OpenPype/pull/1470) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Resolve: 17 compatibility issues and load image sequences [\#1422](https://github.com/pypeclub/OpenPype/pull/1422) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) ## [CI/3.0.0-rc.2](https://github.com/pypeclub/openpype/tree/CI/3.0.0-rc.2) (2021-05-04) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.2...CI/3.0.0-rc.2) **Implemented enhancements:** - Extract burnins with sequences [\#1467](https://github.com/pypeclub/OpenPype/pull/1467) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Extract burnins with color setting [\#1466](https://github.com/pypeclub/OpenPype/pull/1466) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) **Fixed bugs:** - Fix groups check in Python 2 [\#1468](https://github.com/pypeclub/OpenPype/pull/1468) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) ## [2.17.2](https://github.com/pypeclub/openpype/tree/2.17.2) (2021-05-04) [Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.1...2.17.2) **Implemented enhancements:** - Forward/Backward compatible apps and tools with OpenPype 3 [\#1463](https://github.com/pypeclub/OpenPype/pull/1463) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) ## [CI/3.0.0-rc.1](https://github.com/pypeclub/openpype/tree/CI/3.0.0-rc.1) (2021-05-04) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.1...CI/3.0.0-rc.1) **Implemented enhancements:** - Only show studio settings to admins [\#1406](https://github.com/pypeclub/OpenPype/issues/1406) - Ftrack specific settings save warning messages [\#1458](https://github.com/pypeclub/OpenPype/pull/1458) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Faster settings actions [\#1446](https://github.com/pypeclub/OpenPype/pull/1446) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Feature/sync server priority [\#1444](https://github.com/pypeclub/OpenPype/pull/1444) ([kalisp](https://github.com/kalisp)) - Faster settings UI loading [\#1442](https://github.com/pypeclub/OpenPype/pull/1442) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Igniter re-write [\#1441](https://github.com/pypeclub/OpenPype/pull/1441) ([mkolar](https://github.com/mkolar)) - Wrap openpype build into installers [\#1419](https://github.com/pypeclub/OpenPype/pull/1419) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Extract review first documentation [\#1404](https://github.com/pypeclub/OpenPype/pull/1404) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Blender PySide2 install guide [\#1403](https://github.com/pypeclub/OpenPype/pull/1403) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Nuke: deadline submission with gpu [\#1394](https://github.com/pypeclub/OpenPype/pull/1394) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Igniter: Reverse item filter for OpenPype version [\#1349](https://github.com/pypeclub/OpenPype/pull/1349) ([antirotor](https://github.com/antirotor)) **Fixed bugs:** - OpenPype Mongo URL definition [\#1450](https://github.com/pypeclub/OpenPype/issues/1450) - Various typos and smaller fixes [\#1464](https://github.com/pypeclub/OpenPype/pull/1464) ([mkolar](https://github.com/mkolar)) - Validation of dynamic items in settings [\#1462](https://github.com/pypeclub/OpenPype/pull/1462) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - List can handle new items correctly [\#1459](https://github.com/pypeclub/OpenPype/pull/1459) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Settings actions process fix [\#1457](https://github.com/pypeclub/OpenPype/pull/1457) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Add to overrides actions fix [\#1456](https://github.com/pypeclub/OpenPype/pull/1456) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - OpenPype Mongo URL definition [\#1455](https://github.com/pypeclub/OpenPype/pull/1455) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Global settings save/load out of system settings [\#1447](https://github.com/pypeclub/OpenPype/pull/1447) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Keep metadata on remove overrides [\#1445](https://github.com/pypeclub/OpenPype/pull/1445) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Nuke: fixing undo for loaded mov and sequence [\#1432](https://github.com/pypeclub/OpenPype/pull/1432) ([jezscha](https://github.com/jezscha)) - ExtractReview skip empty strings from settings [\#1431](https://github.com/pypeclub/OpenPype/pull/1431) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Bugfix Sync server tweaks [\#1430](https://github.com/pypeclub/OpenPype/pull/1430) ([kalisp](https://github.com/kalisp)) - Hiero: missing thumbnail in review [\#1429](https://github.com/pypeclub/OpenPype/pull/1429) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Bugfix Maya in deadline for OpenPype [\#1428](https://github.com/pypeclub/OpenPype/pull/1428) ([kalisp](https://github.com/kalisp)) - AE - validation for duration was 1 frame shorter [\#1427](https://github.com/pypeclub/OpenPype/pull/1427) ([kalisp](https://github.com/kalisp)) - Houdini menu filename [\#1418](https://github.com/pypeclub/OpenPype/pull/1418) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Fix Avalon plugins attribute overrides [\#1413](https://github.com/pypeclub/OpenPype/pull/1413) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Nuke: submit to Deadline fails [\#1409](https://github.com/pypeclub/OpenPype/pull/1409) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Validate MongoDB Url on start [\#1407](https://github.com/pypeclub/OpenPype/pull/1407) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Nuke: fix set colorspace with new settings [\#1386](https://github.com/pypeclub/OpenPype/pull/1386) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - MacOs build and install issues [\#1380](https://github.com/pypeclub/OpenPype/pull/1380) ([mkolar](https://github.com/mkolar)) **Closed issues:** - test [\#1452](https://github.com/pypeclub/OpenPype/issues/1452) **Merged pull requests:** - TVPaint frame range definition [\#1425](https://github.com/pypeclub/OpenPype/pull/1425) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Only show studio settings to admins [\#1420](https://github.com/pypeclub/OpenPype/pull/1420) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - TVPaint documentation [\#1305](https://github.com/pypeclub/OpenPype/pull/1305) ([64qam](https://github.com/64qam)) ## [2.17.1](https://github.com/pypeclub/openpype/tree/2.17.1) (2021-04-30) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.0...2.17.1) **Enhancements:** - Nuke: deadline submission with gpu [\#1414](https://github.com/pypeclub/OpenPype/pull/1414) - TVPaint frame range definition [\#1424](https://github.com/pypeclub/OpenPype/pull/1424) - PS - group all published instances [\#1415](https://github.com/pypeclub/OpenPype/pull/1415) - Add task name to context pop up. [\#1383](https://github.com/pypeclub/OpenPype/pull/1383) - Enhance review letterbox feature. [\#1371](https://github.com/pypeclub/OpenPype/pull/1371) **Fixed bugs:** - Houdini menu filename [\#1417](https://github.com/pypeclub/OpenPype/pull/1417) - AE - validation for duration was 1 frame shorter [\#1426](https://github.com/pypeclub/OpenPype/pull/1426) **Merged pull requests:** - Maya: Vray - problem getting all file nodes for look publishing [\#1399](https://github.com/pypeclub/OpenPype/pull/1399) - Maya: Support for Redshift proxies [\#1360](https://github.com/pypeclub/OpenPype/pull/1360) ## [2.17.0](https://github.com/pypeclub/openpype/tree/2.17.0) (2021-04-20) [Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-beta.2...2.17.0) **Enhancements:** - Forward compatible ftrack group [\#1243](https://github.com/pypeclub/OpenPype/pull/1243) - Settings in mongo as dict [\#1221](https://github.com/pypeclub/OpenPype/pull/1221) - Maya: Make tx option configurable with presets [\#1328](https://github.com/pypeclub/OpenPype/pull/1328) - TVPaint asset name validation [\#1302](https://github.com/pypeclub/OpenPype/pull/1302) - TV Paint: Set initial project settings. [\#1299](https://github.com/pypeclub/OpenPype/pull/1299) - TV Paint: Validate mark in and out. [\#1298](https://github.com/pypeclub/OpenPype/pull/1298) - Validate project settings [\#1297](https://github.com/pypeclub/OpenPype/pull/1297) - After Effects: added SubsetManager [\#1234](https://github.com/pypeclub/OpenPype/pull/1234) - Show error message in pyblish UI [\#1206](https://github.com/pypeclub/OpenPype/pull/1206) **Fixed bugs:** - Hiero: fixing source frame from correct object [\#1362](https://github.com/pypeclub/OpenPype/pull/1362) - Nuke: fix colourspace, prerenders and nuke panes opening [\#1308](https://github.com/pypeclub/OpenPype/pull/1308) - AE remove orphaned instance from workfile - fix self.stub [\#1282](https://github.com/pypeclub/OpenPype/pull/1282) - Nuke: deadline submission with search replaced env values from preset [\#1194](https://github.com/pypeclub/OpenPype/pull/1194) - Ftrack custom attributes in bulks [\#1312](https://github.com/pypeclub/OpenPype/pull/1312) - Ftrack optional pypclub role [\#1303](https://github.com/pypeclub/OpenPype/pull/1303) - After Effects: remove orphaned instances [\#1275](https://github.com/pypeclub/OpenPype/pull/1275) - Avalon schema names [\#1242](https://github.com/pypeclub/OpenPype/pull/1242) - Handle duplication of Task name [\#1226](https://github.com/pypeclub/OpenPype/pull/1226) - Modified path of plugin loads for Harmony and TVPaint [\#1217](https://github.com/pypeclub/OpenPype/pull/1217) - Regex checks in profiles filtering [\#1214](https://github.com/pypeclub/OpenPype/pull/1214) - Bulk mov strict task [\#1204](https://github.com/pypeclub/OpenPype/pull/1204) - Update custom ftrack session attributes [\#1202](https://github.com/pypeclub/OpenPype/pull/1202) - Nuke: write node colorspace ignore `default\(\)` label [\#1199](https://github.com/pypeclub/OpenPype/pull/1199) - Nuke: reverse search to make it more versatile [\#1178](https://github.com/pypeclub/OpenPype/pull/1178) ## [2.16.0](https://github.com/pypeclub/pype/tree/2.16.0) (2021-03-22) [Full Changelog](https://github.com/pypeclub/pype/compare/2.15.3...2.16.0) **Enhancements:** - Nuke: deadline submit limit group filter [\#1167](https://github.com/pypeclub/pype/pull/1167) - Maya: support for Deadline Group and Limit Groups - backport 2.x [\#1156](https://github.com/pypeclub/pype/pull/1156) - Maya: fixes for Redshift support [\#1152](https://github.com/pypeclub/pype/pull/1152) - Nuke: adding preset for a Read node name to all img and mov Loaders [\#1146](https://github.com/pypeclub/pype/pull/1146) - nuke deadline submit with environ var from presets overrides [\#1142](https://github.com/pypeclub/pype/pull/1142) - Change timers after task change [\#1138](https://github.com/pypeclub/pype/pull/1138) - Nuke: shortcuts for Pype menu [\#1127](https://github.com/pypeclub/pype/pull/1127) - Nuke: workfile template [\#1124](https://github.com/pypeclub/pype/pull/1124) - Sites local settings by site name [\#1117](https://github.com/pypeclub/pype/pull/1117) - Reset loader's asset selection on context change [\#1106](https://github.com/pypeclub/pype/pull/1106) - Bulk mov render publishing [\#1101](https://github.com/pypeclub/pype/pull/1101) - Photoshop: mark publishable instances [\#1093](https://github.com/pypeclub/pype/pull/1093) - Added ability to define BG color for extract review [\#1088](https://github.com/pypeclub/pype/pull/1088) - TVPaint extractor enhancement [\#1080](https://github.com/pypeclub/pype/pull/1080) - Photoshop: added support for .psb in workfiles [\#1078](https://github.com/pypeclub/pype/pull/1078) - Optionally add task to subset name [\#1072](https://github.com/pypeclub/pype/pull/1072) - Only extend clip range when collecting. [\#1008](https://github.com/pypeclub/pype/pull/1008) - Collect audio for farm reviews. [\#1073](https://github.com/pypeclub/pype/pull/1073) **Fixed bugs:** - Fix path spaces in jpeg extractor [\#1174](https://github.com/pypeclub/pype/pull/1174) - Maya: Bugfix: superclass for CreateCameraRig [\#1166](https://github.com/pypeclub/pype/pull/1166) - Maya: Submit to Deadline - fix typo in condition [\#1163](https://github.com/pypeclub/pype/pull/1163) - Avoid dot in repre extension [\#1125](https://github.com/pypeclub/pype/pull/1125) - Fix versions variable usage in standalone publisher [\#1090](https://github.com/pypeclub/pype/pull/1090) - Collect instance data fix subset query [\#1082](https://github.com/pypeclub/pype/pull/1082) - Fix getting the camera name. [\#1067](https://github.com/pypeclub/pype/pull/1067) - Nuke: Ensure "NUKE\_TEMP\_DIR" is not part of the Deadline job environment. [\#1064](https://github.com/pypeclub/pype/pull/1064) ## [2.15.3](https://github.com/pypeclub/pype/tree/2.15.3) (2021-02-26) [Full Changelog](https://github.com/pypeclub/pype/compare/2.15.2...2.15.3) **Enhancements:** - Maya: speedup renderable camera collection [\#1053](https://github.com/pypeclub/pype/pull/1053) - Harmony - add regex search to filter allowed task names for collectin… [\#1047](https://github.com/pypeclub/pype/pull/1047) **Fixed bugs:** - Ftrack integrate hierarchy fix [\#1085](https://github.com/pypeclub/pype/pull/1085) - Explicit subset filter in anatomy instance data [\#1059](https://github.com/pypeclub/pype/pull/1059) - TVPaint frame offset [\#1057](https://github.com/pypeclub/pype/pull/1057) - Auto fix unicode strings [\#1046](https://github.com/pypeclub/pype/pull/1046) ## [2.15.2](https://github.com/pypeclub/pype/tree/2.15.2) (2021-02-19) [Full Changelog](https://github.com/pypeclub/pype/compare/2.15.1...2.15.2) **Enhancements:** - Maya: Vray scene publishing [\#1013](https://github.com/pypeclub/pype/pull/1013) **Fixed bugs:** - Fix entity move under project [\#1040](https://github.com/pypeclub/pype/pull/1040) - smaller nuke fixes from production [\#1036](https://github.com/pypeclub/pype/pull/1036) - TVPaint thumbnail extract fix [\#1031](https://github.com/pypeclub/pype/pull/1031) ## [2.15.1](https://github.com/pypeclub/pype/tree/2.15.1) (2021-02-12) [Full Changelog](https://github.com/pypeclub/pype/compare/2.15.0...2.15.1) **Enhancements:** - Delete version as loader action [\#1011](https://github.com/pypeclub/pype/pull/1011) - Delete old versions [\#445](https://github.com/pypeclub/pype/pull/445) **Fixed bugs:** - PS - remove obsolete functions from pywin32 [\#1006](https://github.com/pypeclub/pype/pull/1006) - Clone description of review session objects. [\#922](https://github.com/pypeclub/pype/pull/922) ## [2.15.0](https://github.com/pypeclub/pype/tree/2.15.0) (2021-02-09) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.6...2.15.0) **Enhancements:** - Resolve - loading and updating clips [\#932](https://github.com/pypeclub/pype/pull/932) - Release/2.15.0 [\#926](https://github.com/pypeclub/pype/pull/926) - Photoshop: add option for template.psd and prelaunch hook [\#894](https://github.com/pypeclub/pype/pull/894) - Nuke: deadline presets [\#993](https://github.com/pypeclub/pype/pull/993) - Maya: Alembic only set attributes that exists. [\#986](https://github.com/pypeclub/pype/pull/986) - Harmony: render local and handle fixes [\#981](https://github.com/pypeclub/pype/pull/981) - PSD Bulk export of ANIM group [\#965](https://github.com/pypeclub/pype/pull/965) - AE - added prelaunch hook for opening last or workfile from template [\#944](https://github.com/pypeclub/pype/pull/944) - PS - safer handling of loading of workfile [\#941](https://github.com/pypeclub/pype/pull/941) - Maya: Handling Arnold referenced AOVs [\#938](https://github.com/pypeclub/pype/pull/938) - TVPaint: switch layer IDs for layer names during identification [\#903](https://github.com/pypeclub/pype/pull/903) - TVPaint audio/sound loader [\#893](https://github.com/pypeclub/pype/pull/893) - Clone review session with children. [\#891](https://github.com/pypeclub/pype/pull/891) - Simple compositing data packager for freelancers [\#884](https://github.com/pypeclub/pype/pull/884) - Harmony deadline submission [\#881](https://github.com/pypeclub/pype/pull/881) - Maya: Optionally hide image planes from reviews. [\#840](https://github.com/pypeclub/pype/pull/840) - Maya: handle referenced AOVs for Vray [\#824](https://github.com/pypeclub/pype/pull/824) - DWAA/DWAB support on windows [\#795](https://github.com/pypeclub/pype/pull/795) - Unreal: animation, layout and setdress updates [\#695](https://github.com/pypeclub/pype/pull/695) **Fixed bugs:** - Maya: Looks - disable hardlinks [\#995](https://github.com/pypeclub/pype/pull/995) - Fix Ftrack custom attribute update [\#982](https://github.com/pypeclub/pype/pull/982) - Prores ks in burnin script [\#960](https://github.com/pypeclub/pype/pull/960) - terminal.py crash on import [\#839](https://github.com/pypeclub/pype/pull/839) - Extract review handle bizarre pixel aspect ratio [\#990](https://github.com/pypeclub/pype/pull/990) - Nuke: add nuke related env var to sumbission [\#988](https://github.com/pypeclub/pype/pull/988) - Nuke: missing preset's variable [\#984](https://github.com/pypeclub/pype/pull/984) - Get creator by name fix [\#979](https://github.com/pypeclub/pype/pull/979) - Fix update of project's tasks on Ftrack sync [\#972](https://github.com/pypeclub/pype/pull/972) - nuke: wrong frame offset in mov loader [\#971](https://github.com/pypeclub/pype/pull/971) - Create project structure action fix multiroot [\#967](https://github.com/pypeclub/pype/pull/967) - PS: remove pywin installation from hook [\#964](https://github.com/pypeclub/pype/pull/964) - Prores ks in burnin script [\#959](https://github.com/pypeclub/pype/pull/959) - Subset family is now stored in subset document [\#956](https://github.com/pypeclub/pype/pull/956) - DJV new version arguments [\#954](https://github.com/pypeclub/pype/pull/954) - TV Paint: Fix single frame Sequence [\#953](https://github.com/pypeclub/pype/pull/953) - nuke: missing `file` knob update [\#933](https://github.com/pypeclub/pype/pull/933) - Photoshop: Create from single layer was failing [\#920](https://github.com/pypeclub/pype/pull/920) - Nuke: baking mov with correct colorspace inherited from write [\#909](https://github.com/pypeclub/pype/pull/909) - Launcher fix actions discover [\#896](https://github.com/pypeclub/pype/pull/896) - Get the correct file path for the updated mov. [\#889](https://github.com/pypeclub/pype/pull/889) - Maya: Deadline submitter - shared data access violation [\#831](https://github.com/pypeclub/pype/pull/831) - Maya: Take into account vray master AOV switch [\#822](https://github.com/pypeclub/pype/pull/822) **Merged pull requests:** - Refactor blender to 3.0 format [\#934](https://github.com/pypeclub/pype/pull/934) ## [2.14.6](https://github.com/pypeclub/pype/tree/2.14.6) (2021-01-15) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.5...2.14.6) **Fixed bugs:** - Nuke: improving of hashing path [\#885](https://github.com/pypeclub/pype/pull/885) **Merged pull requests:** - Hiero: cut videos with correct secons [\#892](https://github.com/pypeclub/pype/pull/892) - Faster sync to avalon preparation [\#869](https://github.com/pypeclub/pype/pull/869) ## [2.14.5](https://github.com/pypeclub/pype/tree/2.14.5) (2021-01-06) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.4...2.14.5) **Merged pull requests:** - Pype logger refactor [\#866](https://github.com/pypeclub/pype/pull/866) ## [2.14.4](https://github.com/pypeclub/pype/tree/2.14.4) (2020-12-18) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.3...2.14.4) **Merged pull requests:** - Fix - AE - added explicit cast to int [\#837](https://github.com/pypeclub/pype/pull/837) ## [2.14.3](https://github.com/pypeclub/pype/tree/2.14.3) (2020-12-16) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.2...2.14.3) **Fixed bugs:** - TVPaint repair invalid metadata [\#809](https://github.com/pypeclub/pype/pull/809) - Feature/push hier value to nonhier action [\#807](https://github.com/pypeclub/pype/pull/807) - Harmony: fix palette and image sequence loader [\#806](https://github.com/pypeclub/pype/pull/806) **Merged pull requests:** - respecting space in path [\#823](https://github.com/pypeclub/pype/pull/823) ## [2.14.2](https://github.com/pypeclub/pype/tree/2.14.2) (2020-12-04) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.1...2.14.2) **Enhancements:** - Collapsible wrapper in settings [\#767](https://github.com/pypeclub/pype/pull/767) **Fixed bugs:** - Harmony: template extraction and palettes thumbnails on mac [\#768](https://github.com/pypeclub/pype/pull/768) - TVPaint store context to workfile metadata \(764\) [\#766](https://github.com/pypeclub/pype/pull/766) - Extract review audio cut fix [\#763](https://github.com/pypeclub/pype/pull/763) **Merged pull requests:** - AE: fix publish after background load [\#781](https://github.com/pypeclub/pype/pull/781) - TVPaint store members key [\#769](https://github.com/pypeclub/pype/pull/769) ## [2.14.1](https://github.com/pypeclub/pype/tree/2.14.1) (2020-11-27) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.0...2.14.1) **Enhancements:** - Settings required keys in modifiable dict [\#770](https://github.com/pypeclub/pype/pull/770) - Extract review may not add audio to output [\#761](https://github.com/pypeclub/pype/pull/761) **Fixed bugs:** - After Effects: frame range, file format and render source scene fixes [\#760](https://github.com/pypeclub/pype/pull/760) - Hiero: trimming review with clip event number [\#754](https://github.com/pypeclub/pype/pull/754) - TVPaint: fix updating of loaded subsets [\#752](https://github.com/pypeclub/pype/pull/752) - Maya: Vray handling of default aov [\#748](https://github.com/pypeclub/pype/pull/748) - Maya: multiple renderable cameras in layer didn't work [\#744](https://github.com/pypeclub/pype/pull/744) - Ftrack integrate custom attributes fix [\#742](https://github.com/pypeclub/pype/pull/742) ## [2.14.0](https://github.com/pypeclub/pype/tree/2.14.0) (2020-11-23) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.7...2.14.0) **Enhancements:** - Render publish plugins abstraction [\#687](https://github.com/pypeclub/pype/pull/687) - Shot asset build trigger status [\#736](https://github.com/pypeclub/pype/pull/736) - Maya: add camera rig publishing option [\#721](https://github.com/pypeclub/pype/pull/721) - Sort instances by label in pyblish gui [\#719](https://github.com/pypeclub/pype/pull/719) - Synchronize ftrack hierarchical and shot attributes [\#716](https://github.com/pypeclub/pype/pull/716) - 686 standalonepublisher editorial from image sequences [\#699](https://github.com/pypeclub/pype/pull/699) - Ask user to select non-default camera from scene or create a new. [\#678](https://github.com/pypeclub/pype/pull/678) - TVPaint: image loader with options [\#675](https://github.com/pypeclub/pype/pull/675) - Maya: Camera name can be added to burnins. [\#674](https://github.com/pypeclub/pype/pull/674) - After Effects: base integration with loaders [\#667](https://github.com/pypeclub/pype/pull/667) - Harmony: Javascript refactoring and overall stability improvements [\#666](https://github.com/pypeclub/pype/pull/666) **Fixed bugs:** - Bugfix Hiero Review / Plate representation publish [\#743](https://github.com/pypeclub/pype/pull/743) - Asset fetch second fix [\#726](https://github.com/pypeclub/pype/pull/726) - TVPaint extract review fix [\#740](https://github.com/pypeclub/pype/pull/740) - After Effects: Review were not being sent to ftrack [\#738](https://github.com/pypeclub/pype/pull/738) - Maya: vray proxy was not loading [\#722](https://github.com/pypeclub/pype/pull/722) - Maya: Vray expected file fixes [\#682](https://github.com/pypeclub/pype/pull/682) - Missing audio on farm submission. [\#639](https://github.com/pypeclub/pype/pull/639) **Deprecated:** - Removed artist view from pyblish gui [\#717](https://github.com/pypeclub/pype/pull/717) - Maya: disable legacy override check for cameras [\#715](https://github.com/pypeclub/pype/pull/715) **Merged pull requests:** - Application manager [\#728](https://github.com/pypeclub/pype/pull/728) - Feature \#664 3.0 lib refactor [\#706](https://github.com/pypeclub/pype/pull/706) - Lib from illicit part 2 [\#700](https://github.com/pypeclub/pype/pull/700) - 3.0 lib refactor - path tools [\#697](https://github.com/pypeclub/pype/pull/697) ## [2.13.7](https://github.com/pypeclub/pype/tree/2.13.7) (2020-11-19) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.6...2.13.7) **Fixed bugs:** - Standalone Publisher: getting fps from context instead of nonexistent entity [\#729](https://github.com/pypeclub/pype/pull/729) ## [2.13.6](https://github.com/pypeclub/pype/tree/2.13.6) (2020-11-15) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.5...2.13.6) **Fixed bugs:** - Maya workfile version wasn't syncing with renders properly [\#711](https://github.com/pypeclub/pype/pull/711) - Maya: Fix for publishing multiple cameras with review from the same scene [\#710](https://github.com/pypeclub/pype/pull/710) ## [2.13.5](https://github.com/pypeclub/pype/tree/2.13.5) (2020-11-12) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.4...2.13.5) **Enhancements:** - 3.0 lib refactor [\#664](https://github.com/pypeclub/pype/issues/664) **Fixed bugs:** - Wrong thumbnail file was picked when publishing sequence in standalone publisher [\#703](https://github.com/pypeclub/pype/pull/703) - Fix: Burnin data pass and FFmpeg tool check [\#701](https://github.com/pypeclub/pype/pull/701) ## [2.13.4](https://github.com/pypeclub/pype/tree/2.13.4) (2020-11-09) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.3...2.13.4) **Enhancements:** - AfterEffects integration with Websocket [\#663](https://github.com/pypeclub/pype/issues/663) **Fixed bugs:** - Photoshop uhiding hidden layers [\#688](https://github.com/pypeclub/pype/issues/688) - \#688 - Fix publishing hidden layers [\#692](https://github.com/pypeclub/pype/pull/692) **Closed issues:** - Nuke Favorite directories "shot dir" "project dir" - not working [\#684](https://github.com/pypeclub/pype/issues/684) **Merged pull requests:** - Nuke Favorite directories "shot dir" "project dir" - not working \#684 [\#685](https://github.com/pypeclub/pype/pull/685) ## [2.13.3](https://github.com/pypeclub/pype/tree/2.13.3) (2020-11-03) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.2...2.13.3) **Enhancements:** - TV paint base integration [\#612](https://github.com/pypeclub/pype/issues/612) **Fixed bugs:** - Fix ffmpeg executable path with spaces [\#680](https://github.com/pypeclub/pype/pull/680) - Hotfix: Added default version number [\#679](https://github.com/pypeclub/pype/pull/679) ## [2.13.2](https://github.com/pypeclub/pype/tree/2.13.2) (2020-10-28) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.1...2.13.2) **Fixed bugs:** - Nuke: wrong conditions when fixing legacy write nodes [\#665](https://github.com/pypeclub/pype/pull/665) ## [2.13.1](https://github.com/pypeclub/pype/tree/2.13.1) (2020-10-23) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.0...2.13.1) **Enhancements:** - move maya look assigner to pype menu [\#292](https://github.com/pypeclub/pype/issues/292) **Fixed bugs:** - Layer name is not propagating to metadata in Photoshop [\#654](https://github.com/pypeclub/pype/issues/654) - Loader in Photoshop fails with "can't set attribute" [\#650](https://github.com/pypeclub/pype/issues/650) - Nuke Load mp4 wrong frame range [\#661](https://github.com/pypeclub/pype/issues/661) - Hiero: Review video file adding one frame to the end [\#659](https://github.com/pypeclub/pype/issues/659) ## [2.13.0](https://github.com/pypeclub/pype/tree/2.13.0) (2020-10-18) [Full Changelog](https://github.com/pypeclub/pype/compare/2.12.5...2.13.0) **Enhancements:** - Deadline Output Folder [\#636](https://github.com/pypeclub/pype/issues/636) - Nuke Camera Loader [\#565](https://github.com/pypeclub/pype/issues/565) - Deadline publish job shows publishing output folder [\#649](https://github.com/pypeclub/pype/pull/649) - Get latest version in lib [\#642](https://github.com/pypeclub/pype/pull/642) - Improved publishing of multiple representation from SP [\#638](https://github.com/pypeclub/pype/pull/638) - Launch TvPaint shot work file from within Ftrack [\#631](https://github.com/pypeclub/pype/pull/631) - Add mp4 support for RV action. [\#628](https://github.com/pypeclub/pype/pull/628) - Maya: allow renders to have version synced with workfile [\#618](https://github.com/pypeclub/pype/pull/618) - Renaming nukestudio host folder to hiero [\#617](https://github.com/pypeclub/pype/pull/617) - Harmony: More efficient publishing [\#615](https://github.com/pypeclub/pype/pull/615) - Ftrack server action improvement [\#608](https://github.com/pypeclub/pype/pull/608) - Deadline user defaults to pype username if present [\#607](https://github.com/pypeclub/pype/pull/607) - Standalone publisher now has icon [\#606](https://github.com/pypeclub/pype/pull/606) - Nuke render write targeting knob improvement [\#603](https://github.com/pypeclub/pype/pull/603) - Animated pyblish gui [\#602](https://github.com/pypeclub/pype/pull/602) - Maya: Deadline - make use of asset dependencies optional [\#591](https://github.com/pypeclub/pype/pull/591) - Nuke: Publishing, loading and updating alembic cameras [\#575](https://github.com/pypeclub/pype/pull/575) - Maya: add look assigner to pype menu even if scriptsmenu is not available [\#573](https://github.com/pypeclub/pype/pull/573) - Store task types in the database [\#572](https://github.com/pypeclub/pype/pull/572) - Maya: Tiled EXRs to scanline EXRs render option [\#512](https://github.com/pypeclub/pype/pull/512) - Fusion basic integration [\#452](https://github.com/pypeclub/pype/pull/452) **Fixed bugs:** - Burnin script did not propagate ffmpeg output [\#640](https://github.com/pypeclub/pype/issues/640) - Pyblish-pype spacer in terminal wasn't transparent [\#646](https://github.com/pypeclub/pype/pull/646) - Lib subprocess without logger [\#645](https://github.com/pypeclub/pype/pull/645) - Nuke: prevent crash if we only have single frame in sequence [\#644](https://github.com/pypeclub/pype/pull/644) - Burnin script logs better output [\#641](https://github.com/pypeclub/pype/pull/641) - Missing audio on farm submission. [\#639](https://github.com/pypeclub/pype/pull/639) - review from imagesequence error [\#633](https://github.com/pypeclub/pype/pull/633) - Hiero: wrong order of fps clip instance data collecting [\#627](https://github.com/pypeclub/pype/pull/627) - Add source for review instances. [\#625](https://github.com/pypeclub/pype/pull/625) - Task processing in event sync [\#623](https://github.com/pypeclub/pype/pull/623) - sync to avalon doesn t remove renamed task [\#619](https://github.com/pypeclub/pype/pull/619) - Intent publish setting wasn't working with default value [\#562](https://github.com/pypeclub/pype/pull/562) - Maya: Updating a look where the shader name changed, leaves the geo without a shader [\#514](https://github.com/pypeclub/pype/pull/514) **Merged pull requests:** - Avalon module without Qt [\#581](https://github.com/pypeclub/pype/pull/581) - Ftrack module without Qt [\#577](https://github.com/pypeclub/pype/pull/577) ## [2.12.5](https://github.com/pypeclub/pype/tree/2.12.5) (2020-10-14) [Full Changelog](https://github.com/pypeclub/pype/compare/2.12.4...2.12.5) **Enhancements:** - Launch TvPaint shot work file from within Ftrack [\#629](https://github.com/pypeclub/pype/issues/629) **Merged pull requests:** - Harmony: Disable application launch logic [\#637](https://github.com/pypeclub/pype/pull/637) ## [2.12.4](https://github.com/pypeclub/pype/tree/2.12.4) (2020-10-08) [Full Changelog](https://github.com/pypeclub/pype/compare/2.12.3...2.12.4) **Enhancements:** - convert nukestudio to hiero host [\#616](https://github.com/pypeclub/pype/issues/616) - Fusion basic integration [\#451](https://github.com/pypeclub/pype/issues/451) **Fixed bugs:** - Sync to avalon doesn't remove renamed task [\#605](https://github.com/pypeclub/pype/issues/605) - NukeStudio: FPS collecting into clip instances [\#624](https://github.com/pypeclub/pype/pull/624) **Merged pull requests:** - NukeStudio: small fixes [\#622](https://github.com/pypeclub/pype/pull/622) - NukeStudio: broken order of plugins [\#620](https://github.com/pypeclub/pype/pull/620) ## [2.12.3](https://github.com/pypeclub/pype/tree/2.12.3) (2020-10-06) [Full Changelog](https://github.com/pypeclub/pype/compare/2.12.2...2.12.3) **Enhancements:** - Nuke Publish Camera [\#567](https://github.com/pypeclub/pype/issues/567) - Harmony: open xstage file no matter of its name [\#526](https://github.com/pypeclub/pype/issues/526) - Stop integration of unwanted data [\#387](https://github.com/pypeclub/pype/issues/387) - Move avalon-launcher functionality to pype [\#229](https://github.com/pypeclub/pype/issues/229) - avalon workfiles api [\#214](https://github.com/pypeclub/pype/issues/214) - Store task types [\#180](https://github.com/pypeclub/pype/issues/180) - Avalon Mongo Connection split [\#136](https://github.com/pypeclub/pype/issues/136) - nk camera workflow [\#71](https://github.com/pypeclub/pype/issues/71) - Hiero integration added [\#590](https://github.com/pypeclub/pype/pull/590) - Anatomy instance data collection is substantially faster for many instances [\#560](https://github.com/pypeclub/pype/pull/560) **Fixed bugs:** - test issue [\#596](https://github.com/pypeclub/pype/issues/596) - Harmony: empty scene contamination [\#583](https://github.com/pypeclub/pype/issues/583) - Edit publishing in SP doesn't respect shot selection for publishing [\#542](https://github.com/pypeclub/pype/issues/542) - Pathlib breaks compatibility with python2 hosts [\#281](https://github.com/pypeclub/pype/issues/281) - Updating a look where the shader name changed leaves the geo without a shader [\#237](https://github.com/pypeclub/pype/issues/237) - Better error handling [\#84](https://github.com/pypeclub/pype/issues/84) - Harmony: function signature [\#609](https://github.com/pypeclub/pype/pull/609) - Nuke: gizmo publishing error [\#594](https://github.com/pypeclub/pype/pull/594) - Harmony: fix clashing namespace of called js functions [\#584](https://github.com/pypeclub/pype/pull/584) - Maya: fix maya scene type preset exception [\#569](https://github.com/pypeclub/pype/pull/569) **Closed issues:** - Nuke Gizmo publishing [\#597](https://github.com/pypeclub/pype/issues/597) - nuke gizmo publishing error [\#592](https://github.com/pypeclub/pype/issues/592) - Publish EDL [\#579](https://github.com/pypeclub/pype/issues/579) - Publish render from SP [\#576](https://github.com/pypeclub/pype/issues/576) - rename ftrack custom attribute group to `pype` [\#184](https://github.com/pypeclub/pype/issues/184) **Merged pull requests:** - Audio file existence check [\#614](https://github.com/pypeclub/pype/pull/614) - NKS small fixes [\#587](https://github.com/pypeclub/pype/pull/587) - Standalone publisher editorial plugins interfering [\#580](https://github.com/pypeclub/pype/pull/580) ## [2.12.2](https://github.com/pypeclub/pype/tree/2.12.2) (2020-09-25) [Full Changelog](https://github.com/pypeclub/pype/compare/2.12.1...2.12.2) **Enhancements:** - pype config GUI [\#241](https://github.com/pypeclub/pype/issues/241) **Fixed bugs:** - Harmony: Saving heavy scenes will crash [\#507](https://github.com/pypeclub/pype/issues/507) - Extract review a representation name with `\*\_burnin` [\#388](https://github.com/pypeclub/pype/issues/388) - Hierarchy data was not considering active isntances [\#551](https://github.com/pypeclub/pype/pull/551) ## [2.12.1](https://github.com/pypeclub/pype/tree/2.12.1) (2020-09-15) [Full Changelog](https://github.com/pypeclub/pype/compare/2.12.0...2.12.1) **Fixed bugs:** - Pype: changelog.md is outdated [\#503](https://github.com/pypeclub/pype/issues/503) - dependency security alert ! [\#484](https://github.com/pypeclub/pype/issues/484) - Maya: RenderSetup is missing update [\#106](https://github.com/pypeclub/pype/issues/106) - \ extract effects creates new instance [\#78](https://github.com/pypeclub/pype/issues/78) ## [2.12.0](https://github.com/pypeclub/pype/tree/2.12.0) (2020-09-10) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.8...2.12.0) **Enhancements:** - Less mongo connections [\#509](https://github.com/pypeclub/pype/pull/509) - Nuke: adding image loader [\#499](https://github.com/pypeclub/pype/pull/499) - Move launcher window to top if launcher action is clicked [\#450](https://github.com/pypeclub/pype/pull/450) - Maya: better tile rendering support in Pype [\#446](https://github.com/pypeclub/pype/pull/446) - Implementation of non QML launcher [\#443](https://github.com/pypeclub/pype/pull/443) - Optional skip review on renders. [\#441](https://github.com/pypeclub/pype/pull/441) - Ftrack: Option to push status from task to latest version [\#440](https://github.com/pypeclub/pype/pull/440) - Properly containerize image plane loads. [\#434](https://github.com/pypeclub/pype/pull/434) - Option to keep the review files. [\#426](https://github.com/pypeclub/pype/pull/426) - Isolate view on instance members. [\#425](https://github.com/pypeclub/pype/pull/425) - Maya: Publishing of tile renderings on Deadline [\#398](https://github.com/pypeclub/pype/pull/398) - Feature/little bit better logging gui [\#383](https://github.com/pypeclub/pype/pull/383) **Fixed bugs:** - Maya: Fix tile order for Draft Tile Assembler [\#511](https://github.com/pypeclub/pype/pull/511) - Remove extra dash [\#501](https://github.com/pypeclub/pype/pull/501) - Fix: strip dot from repre names in single frame renders [\#498](https://github.com/pypeclub/pype/pull/498) - Better handling of destination during integrating [\#485](https://github.com/pypeclub/pype/pull/485) - Fix: allow thumbnail creation for single frame renders [\#460](https://github.com/pypeclub/pype/pull/460) - added missing argument to launch\_application in ftrack app handler [\#453](https://github.com/pypeclub/pype/pull/453) - Burnins: Copy bit rate of input video to match quality. [\#448](https://github.com/pypeclub/pype/pull/448) - Standalone publisher is now independent from tray [\#442](https://github.com/pypeclub/pype/pull/442) - Bugfix/empty enumerator attributes [\#436](https://github.com/pypeclub/pype/pull/436) - Fixed wrong order of "other" category collapssing in publisher [\#435](https://github.com/pypeclub/pype/pull/435) - Multiple reviews where being overwritten to one. [\#424](https://github.com/pypeclub/pype/pull/424) - Cleanup plugin fail on instances without staging dir [\#420](https://github.com/pypeclub/pype/pull/420) - deprecated -intra parameter in ffmpeg to new `-g` [\#417](https://github.com/pypeclub/pype/pull/417) - Delivery action can now work with entered path [\#397](https://github.com/pypeclub/pype/pull/397) **Merged pull requests:** - Review on instance.data [\#473](https://github.com/pypeclub/pype/pull/473) ## [2.11.8](https://github.com/pypeclub/pype/tree/2.11.8) (2020-08-27) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.7...2.11.8) **Enhancements:** - DWAA support for Maya [\#382](https://github.com/pypeclub/pype/issues/382) - Isolate View on Playblast [\#367](https://github.com/pypeclub/pype/issues/367) - Maya: Tile rendering [\#297](https://github.com/pypeclub/pype/issues/297) - single pype instance running [\#47](https://github.com/pypeclub/pype/issues/47) - PYPE-649: projects don't guarantee backwards compatible environment [\#8](https://github.com/pypeclub/pype/issues/8) - PYPE-663: separate venv for each deployed version [\#7](https://github.com/pypeclub/pype/issues/7) **Fixed bugs:** - pyblish pype - other group is collapsed before plugins are done [\#431](https://github.com/pypeclub/pype/issues/431) - Alpha white edges in harmony on PNGs [\#412](https://github.com/pypeclub/pype/issues/412) - harmony image loader picks wrong representations [\#404](https://github.com/pypeclub/pype/issues/404) - Clockify crash when response contain symbol not allowed by UTF-8 [\#81](https://github.com/pypeclub/pype/issues/81) ## [2.11.7](https://github.com/pypeclub/pype/tree/2.11.7) (2020-08-21) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.6...2.11.7) **Fixed bugs:** - Clean Up Baked Movie [\#369](https://github.com/pypeclub/pype/issues/369) - celaction last workfile [\#459](https://github.com/pypeclub/pype/pull/459) ## [2.11.6](https://github.com/pypeclub/pype/tree/2.11.6) (2020-08-18) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.5...2.11.6) **Enhancements:** - publisher app [\#56](https://github.com/pypeclub/pype/issues/56) ## [2.11.5](https://github.com/pypeclub/pype/tree/2.11.5) (2020-08-13) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.4...2.11.5) **Enhancements:** - Switch from master to equivalent [\#220](https://github.com/pypeclub/pype/issues/220) - Standalone publisher now only groups sequence if the extension is known [\#439](https://github.com/pypeclub/pype/pull/439) **Fixed bugs:** - Logs have been disable for editorial by default to speed up publishing [\#433](https://github.com/pypeclub/pype/pull/433) - additional fixes for celaction [\#430](https://github.com/pypeclub/pype/pull/430) - Harmony: invalid variable scope in validate scene settings [\#428](https://github.com/pypeclub/pype/pull/428) - new representation name for audio was not accepted [\#427](https://github.com/pypeclub/pype/pull/427) ## [2.11.4](https://github.com/pypeclub/pype/tree/2.11.4) (2020-08-10) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.3...2.11.4) **Enhancements:** - WebSocket server [\#135](https://github.com/pypeclub/pype/issues/135) - standalonepublisher: editorial family features expansion \[master branch\] [\#411](https://github.com/pypeclub/pype/pull/411) ## [2.11.3](https://github.com/pypeclub/pype/tree/2.11.3) (2020-08-04) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.2...2.11.3) **Fixed bugs:** - Harmony: publishing performance issues [\#408](https://github.com/pypeclub/pype/pull/408) ## [2.11.2](https://github.com/pypeclub/pype/tree/2.11.2) (2020-07-31) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.1...2.11.2) **Fixed bugs:** - Ftrack to Avalon bug [\#406](https://github.com/pypeclub/pype/issues/406) ## [2.11.1](https://github.com/pypeclub/pype/tree/2.11.1) (2020-07-29) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.0...2.11.1) **Merged pull requests:** - Celaction: metadata json folder fixes on path [\#393](https://github.com/pypeclub/pype/pull/393) - CelAction - version up method taken fro pype.lib [\#391](https://github.com/pypeclub/pype/pull/391) ## 2.11.0 ## _**release date:** 27 July 2020_ **new:** - _(blender)_ namespace support [\#341](https://github.com/pypeclub/pype/pull/341) - _(blender)_ start end frames [\#330](https://github.com/pypeclub/pype/pull/330) - _(blender)_ camera asset [\#322](https://github.com/pypeclub/pype/pull/322) - _(pype)_ toggle instances per family in pyblish GUI [\#320](https://github.com/pypeclub/pype/pull/320) - _(pype)_ current release version is now shown in the tray menu [#379](https://github.com/pypeclub/pype/pull/379) **improved:** - _(resolve)_ tagging for publish [\#239](https://github.com/pypeclub/pype/issues/239) - _(pype)_ Support publishing a subset of shots with standalone editorial [\#336](https://github.com/pypeclub/pype/pull/336) - _(harmony)_ Basic support for palettes [\#324](https://github.com/pypeclub/pype/pull/324) - _(photoshop)_ Flag outdated containers on startup and publish. [\#309](https://github.com/pypeclub/pype/pull/309) - _(harmony)_ Flag Outdated containers [\#302](https://github.com/pypeclub/pype/pull/302) - _(photoshop)_ Publish review [\#298](https://github.com/pypeclub/pype/pull/298) - _(pype)_ Optional Last workfile launch [\#365](https://github.com/pypeclub/pype/pull/365) **fixed:** - _(premiere)_ workflow fixes [\#346](https://github.com/pypeclub/pype/pull/346) - _(pype)_ pype-setup does not work with space in path [\#327](https://github.com/pypeclub/pype/issues/327) - _(ftrack)_ Ftrack delete action cause circular error [\#206](https://github.com/pypeclub/pype/issues/206) - _(nuke)_ Priority was forced to 50 [\#345](https://github.com/pypeclub/pype/pull/345) - _(nuke)_ Fix ValidateNukeWriteKnobs [\#340](https://github.com/pypeclub/pype/pull/340) - _(maya)_ If camera attributes are connected, we can ignore them. [\#339](https://github.com/pypeclub/pype/pull/339) - _(pype)_ stop appending of tools environment to existing env [\#337](https://github.com/pypeclub/pype/pull/337) - _(ftrack)_ Ftrack timeout needs to look at AVALON\_TIMEOUT [\#325](https://github.com/pypeclub/pype/pull/325) - _(harmony)_ Only zip files are supported. [\#310](https://github.com/pypeclub/pype/pull/310) - _(pype)_ hotfix/Fix event server mongo uri [\#305](https://github.com/pypeclub/pype/pull/305) - _(photoshop)_ Subset was not named or validated correctly. [\#304](https://github.com/pypeclub/pype/pull/304) ## 2.10.0 ## _**release date:** 17 June 2020_ **new:** - _(harmony)_ **Toon Boom Harmony** has been greatly extended to support rigging, scene build, animation and rendering workflows. [#270](https://github.com/pypeclub/pype/issues/270) [#271](https://github.com/pypeclub/pype/issues/271) [#190](https://github.com/pypeclub/pype/issues/190) [#191](https://github.com/pypeclub/pype/issues/191) [#172](https://github.com/pypeclub/pype/issues/172) [#168](https://github.com/pypeclub/pype/issues/168) - _(pype)_ Added support for rudimentary **edl publishing** into individual shots. [#265](https://github.com/pypeclub/pype/issues/265) - _(celaction)_ Simple **Celaction** integration has been added with support for workfiles and rendering. [#255](https://github.com/pypeclub/pype/issues/255) - _(maya)_ Support for multiple job types when submitting to the farm. We can now render Maya or Standalone render jobs for Vray and Arnold (limited support for arnold) [#204](https://github.com/pypeclub/pype/issues/204) - _(photoshop)_ Added initial support for Photoshop [#232](https://github.com/pypeclub/pype/issues/232) **improved:** - _(blender)_ Updated support for rigs and added support Layout family [#233](https://github.com/pypeclub/pype/issues/233) [#226](https://github.com/pypeclub/pype/issues/226) - _(premiere)_ It is now possible to choose different storage root for workfiles of different task types. [#255](https://github.com/pypeclub/pype/issues/255) - _(maya)_ Support for unmerged AOVs in Redshift multipart EXRs [#197](https://github.com/pypeclub/pype/issues/197) - _(pype)_ Pype repository has been refactored in preparation for 3.0 release [#169](https://github.com/pypeclub/pype/issues/169) - _(deadline)_ All file dependencies are now passed to deadline from maya to prevent premature start of rendering if caches or textures haven't been coppied over yet. [#195](https://github.com/pypeclub/pype/issues/195) - _(nuke)_ Script validation can now be made optional. [#194](https://github.com/pypeclub/pype/issues/194) - _(pype)_ Publishing can now be stopped at any time. [#194](https://github.com/pypeclub/pype/issues/194) **fix:** - _(pype)_ Pyblish-lite has been integrated into pype repository, plus various publishing GUI fixes. [#274](https://github.com/pypeclub/pype/issues/274) [#275](https://github.com/pypeclub/pype/issues/275) [#268](https://github.com/pypeclub/pype/issues/268) [#227](https://github.com/pypeclub/pype/issues/227) [#238](https://github.com/pypeclub/pype/issues/238) - _(maya)_ Alembic extractor was getting wrong frame range type in certain scenarios [#254](https://github.com/pypeclub/pype/issues/254) - _(maya)_ Attaching a render to subset in maya was not passing validation in certain scenarios [#256](https://github.com/pypeclub/pype/issues/256) - _(ftrack)_ Various small fixes to ftrack sync [#263](https://github.com/pypeclub/pype/issues/263) [#259](https://github.com/pypeclub/pype/issues/259) - _(maya)_ Look extraction is now able to skp invalid connections in shaders [#207](https://github.com/pypeclub/pype/issues/207) ## 2.9.0 ## _**release date:** 25 May 2020_ **new:** - _(pype)_ Support for **Multiroot projects**. You can now store project data on multiple physical or virtual storages and target individual publishes to these locations. For instance render can be stored on a faster storage than the rest of the project. [#145](https://github.com/pypeclub/pype/issues/145), [#38](https://github.com/pypeclub/pype/issues/38) - _(harmony)_ Basic implementation of **Toon Boom Harmony** has been added. [#142](https://github.com/pypeclub/pype/issues/142) - _(pype)_ OSX support is in public beta now. There are issues to be expected, but the main implementation should be functional. [#141](https://github.com/pypeclub/pype/issues/141) **improved:** - _(pype)_ **Review extractor** has been completely rebuilt. It now supports granular filtering so you can create **multiple outputs** for different tasks, families or hosts. [#103](https://github.com/pypeclub/pype/issues/103), [#166](https://github.com/pypeclub/pype/issues/166), [#165](https://github.com/pypeclub/pype/issues/165) - _(pype)_ **Burnin** generation had been extended to **support same multi-output filtering** as review extractor [#103](https://github.com/pypeclub/pype/issues/103) - _(pype)_ Publishing file templates can now be specified in config for each individual family [#114](https://github.com/pypeclub/pype/issues/114) - _(pype)_ Studio specific plugins can now be appended to pype standard publishing plugins. [#112](https://github.com/pypeclub/pype/issues/112) - _(nukestudio)_ Reviewable clips no longer need to be previously cut, exported and re-imported to timeline. **Pype can now dynamically cut reviewable quicktimes** from continuous offline footage during publishing. [#23](https://github.com/pypeclub/pype/issues/23) - _(deadline)_ Deadline can now correctly differentiate between staging and production pype. [#154](https://github.com/pypeclub/pype/issues/154) - _(deadline)_ `PYPE_PYTHON_EXE` env variable can now be used to direct publishing to explicit python installation. [#120](https://github.com/pypeclub/pype/issues/120) - _(nuke)_ Nuke now check for new version of loaded data on file open. [#140](https://github.com/pypeclub/pype/issues/140) - _(nuke)_ frame range and limit checkboxes are now exposed on write node. [#119](https://github.com/pypeclub/pype/issues/119) **fix:** - _(nukestudio)_ Project Location was using backslashes which was breaking nukestudio native exporting in certains configurations [#82](https://github.com/pypeclub/pype/issues/82) - _(nukestudio)_ Duplicity in hierarchy tags was prone to throwing publishing error [#130](https://github.com/pypeclub/pype/issues/130), [#144](https://github.com/pypeclub/pype/issues/144) - _(ftrack)_ multiple stability improvements [#157](https://github.com/pypeclub/pype/issues/157), [#159](https://github.com/pypeclub/pype/issues/159), [#128](https://github.com/pypeclub/pype/issues/128), [#118](https://github.com/pypeclub/pype/issues/118), [#127](https://github.com/pypeclub/pype/issues/127) - _(deadline)_ multipart EXRs were stopping review publishing on the farm. They are still not supported for automatic review generation, but the publish will go through correctly without the quicktime. [#155](https://github.com/pypeclub/pype/issues/155) - _(deadline)_ If deadline is non-responsive it will no longer freeze host when publishing [#149](https://github.com/pypeclub/pype/issues/149) - _(deadline)_ Sometimes deadline was trying to launch render before all the source data was coppied over. [#137](https://github.com/pypeclub/pype/issues/137) _(harmony)_ Basic implementation of **Toon Boom Harmony** has been added. [#142](https://github.com/pypeclub/pype/issues/142) - _(nuke)_ Filepath knob wasn't updated properly. [#131](https://github.com/pypeclub/pype/issues/131) - _(maya)_ When extracting animation, the "Write Color Set" options on the instance were not respected. [#108](https://github.com/pypeclub/pype/issues/108) - _(maya)_ Attribute overrides for AOV only worked for the legacy render layers. Now it works for new render setup as well [#132](https://github.com/pypeclub/pype/issues/132) - _(maya)_ Stability and usability improvements in yeti workflow [#104](https://github.com/pypeclub/pype/issues/104) ## 2.8.0 ## _**release date:** 20 April 2020_ **new:** - _(pype)_ Option to generate slates from json templates. [PYPE-628] [#26](https://github.com/pypeclub/pype/issues/26) - _(pype)_ It is now possible to automate loading of published subsets into any scene. Documentation will follow :). [PYPE-611] [#24](https://github.com/pypeclub/pype/issues/24) **fix:** - _(maya)_ Some Redshift render tokens could break publishing. [PYPE-778] [#33](https://github.com/pypeclub/pype/issues/33) - _(maya)_ Publish was not preserving maya file extension. [#39](https://github.com/pypeclub/pype/issues/39) - _(maya)_ Rig output validator was failing on nodes without shapes. [#40](https://github.com/pypeclub/pype/issues/40) - _(maya)_ Yeti caches can now be properly versioned up in the scene inventory. [#40](https://github.com/pypeclub/pype/issues/40) - _(nuke)_ Build first workfiles was not accepting jpeg sequences. [#34](https://github.com/pypeclub/pype/issues/34) - _(deadline)_ Trying to generate ffmpeg review from multipart EXRs no longer crashes publishing. [PYPE-781] - _(deadline)_ Render publishing is more stable in multiplatform environments. [PYPE-775] ## 2.7.0 ## _**release date:** 30 March 2020_ **new:** - _(maya)_ Artist can now choose to load multiple references of the same subset at once [PYPE-646, PYPS-81] - _(nuke)_ Option to use named OCIO colorspaces for review colour baking. [PYPS-82] - _(pype)_ Pype can now work with `master` versions for publishing and loading. These are non-versioned publishes that are overwritten with the latest version during publish. These are now supported in all the GUIs, but their publishing is deactivated by default. [PYPE-653] - _(blender)_ Added support for basic blender workflow. We currently support `rig`, `model` and `animation` families. [PYPE-768] - _(pype)_ Source timecode can now be used in burn-ins. [PYPE-777] - _(pype)_ Review outputs profiles can now specify delivery resolution different than project setting [PYPE-759] - _(nuke)_ Bookmark to current context is now added automatically to all nuke browser windows. [PYPE-712] **change:** - _(maya)_ It is now possible to publish camera without. baking. Keep in mind that unbaked cameras can't be guaranteed to work in other hosts. [PYPE-595] - _(maya)_ All the renders from maya are now grouped in the loader by their Layer name. [PYPE-482] - _(nuke/hiero)_ Any publishes from nuke and hiero can now be versioned independently of the workfile. [PYPE-728] **fix:** - _(nuke)_ Mixed slashes caused issues in ocio config path. - _(pype)_ Intent field in pyblish GUI was passing label instead of value to ftrack. [PYPE-733] - _(nuke)_ Publishing of pre-renders was inconsistent. [PYPE-766] - _(maya)_ Handles and frame ranges were inconsistent in various places during publishing. - _(nuke)_ Nuke was crashing if it ran into certain missing knobs. For example DPX output missing `autocrop` [PYPE-774] - _(deadline)_ Project overrides were not working properly with farm render publishing. - _(hiero)_ Problems with single frame plates publishing. - _(maya)_ Redshift RenderPass token were breaking render publishing. [PYPE-778] - _(nuke)_ Build first workfile was not accepting jpeg sequences. - _(maya)_ Multipart (Multilayer) EXRs were breaking review publishing due to FFMPEG incompatiblity [PYPE-781] ## 2.6.0 ## _**release date:** 9 March 2020_ **change:** - _(maya)_ render publishing has been simplified and made more robust. Render setup layers are now automatically added to publishing subsets and `render globals` family has been replaced with simple `render` [PYPE-570] - _(avalon)_ change context and workfiles apps, have been merged into one, that allows both actions to be performed at the same time. [PYPE-747] - _(pype)_ thumbnails are now automatically propagate to asset from the last published subset in the loader - _(ftrack)_ publishing comment and intent are now being published to ftrack note as well as describtion. [PYPE-727] - _(pype)_ when overriding existing version new old representations are now overriden, instead of the new ones just being appended. (to allow this behaviour, the version validator need to be disabled. [PYPE-690]) - _(pype)_ burnin preset has been significantly simplified. It now doesn't require passing function to each field, but only need the actual text template. to use this, all the current burnin PRESETS MUST BE UPDATED for all the projects. - _(ftrack)_ credentials are now stored on a per server basis, so it's possible to switch between ftrack servers without having to log in and out. [PYPE-723] **new:** - _(pype)_ production and development deployments now have different colour of the tray icon. Orange for Dev and Green for production [PYPE-718] - _(maya)_ renders can now be attached to a publishable subset rather than creating their own subset. For example it is possible to create a reviewable `look` or `model` render and have it correctly attached as a representation of the subsets [PYPE-451] - _(maya)_ after saving current scene into a new context (as a new shot for instance), all the scene publishing subsets data gets re-generated automatically to match the new context [PYPE-532] - _(pype)_ we now support project specific publish, load and create plugins [PYPE-740] - _(ftrack)_ new action that allow archiving/deleting old published versions. User can keep how many of the latest version to keep when the action is ran. [PYPE-748, PYPE-715] - _(ftrack)_ it is now possible to monitor and restart ftrack event server using ftrack action. [PYPE-658] - _(pype)_ validator that prevent accidental overwrites of previously published versions. [PYPE-680] - _(avalon)_ avalon core updated to version 5.6.0 - _(maya)_ added validator to make sure that relative paths are used when publishing arnold standins. - _(nukestudio)_ it is now possible to extract and publish audio family from clip in nuke studio [PYPE-682] **fix**: - _(maya)_ maya set framerange button was ignoring handles [PYPE-719] - _(ftrack)_ sync to avalon was sometime crashing when ran on empty project - _(nukestudio)_ publishing same shots after they've been previously archived/deleted would result in a crash. [PYPE-737] - _(nuke)_ slate workflow was breaking in certain scenarios. [PYPE-730] - _(pype)_ rendering publish workflow has been significantly improved to prevent error resulting from implicit render collection. [PYPE-665, PYPE-746] - _(pype)_ launching application on a non-synced project resulted in obscure [PYPE-528] - _(pype)_ missing keys in burnins no longer result in an error. [PYPE-706] - _(ftrack)_ create folder structure action was sometimes failing for project managers due to wrong permissions. - _(Nukestudio)_ using `source` in the start frame tag could result in wrong frame range calculation - _(ftrack)_ sync to avalon action and event have been improved by catching more edge cases and provessing them properly. ## 2.5.0 ## _**release date:** 11 Feb 2020_ **change:** - _(pype)_ added many logs for easier debugging - _(pype)_ review presets can now be separated between 2d and 3d renders [PYPE-693] - _(pype)_ anatomy module has been greatly improved to allow for more dynamic pulblishing and faster debugging [PYPE-685] - _(pype)_ avalon schemas have been moved from `pype-config` to `pype` repository, for simplification. [PYPE-670] - _(ftrack)_ updated to latest ftrack API - _(ftrack)_ publishing comments now appear in ftrack also as a note on version with customisable category [PYPE-645] - _(ftrack)_ delete asset/subset action had been improved. It is now able to remove multiple entities and descendants of the selected entities [PYPE-361, PYPS-72] - _(workfiles)_ added date field to workfiles app [PYPE-603] - _(maya)_ old deprecated loader have been removed in favour of a single unified reference loader (old scenes will upgrade automatically to the new loader upon opening) [PYPE-633, PYPE-697] - _(avalon)_ core updated to 5.5.15 [PYPE-671] - _(nuke)_ library loader is now available in nuke [PYPE-698] **new:** - _(pype)_ added pype render wrapper to allow rendering on mixed platform farms. [PYPE-634] - _(pype)_ added `pype launch` command. It let's admin run applications with dynamically built environment based on the given context. [PYPE-634] - _(pype)_ added support for extracting review sequences with burnins [PYPE-657] - _(publish)_ users can now set intent next to a comment when publishing. This will then be reflected on an attribute in ftrack. [PYPE-632] - _(burnin)_ timecode can now be added to burnin - _(burnin)_ datetime keys can now be added to burnin and anatomy [PYPE-651] - _(burnin)_ anatomy templates can now be used in burnins. [PYPE=626] - _(nuke)_ new validator for render resolution - _(nuke)_ support for attach slate to nuke renders [PYPE-630] - _(nuke)_ png sequences were added to loaders - _(maya)_ added maya 2020 compatibility [PYPE-677] - _(maya)_ ability to publish and load .ASS standin sequences [PYPS-54] - _(pype)_ thumbnails can now be published and are visible in the loader. `AVALON_THUMBNAIL_ROOT` environment variable needs to be set for this to work [PYPE-573, PYPE-132] - _(blender)_ base implementation of blender was added with publishing and loading of .blend files [PYPE-612] - _(ftrack)_ new action for preparing deliveries [PYPE-639] **fix**: - _(burnin)_ more robust way of finding ffmpeg for burnins. - _(pype)_ improved UNC paths remapping when sending to farm. - _(pype)_ float frames sometimes made their way to representation context in database, breaking loaders [PYPE-668] - _(pype)_ `pype install --force` was failing sometimes [PYPE-600] - _(pype)_ padding in published files got calculated wrongly sometimes. It is now instead being always read from project anatomy. [PYPE-667] - _(publish)_ comment publishing was failing in certain situations - _(ftrack)_ multiple edge case scenario fixes in auto sync and sync-to-avalon action - _(ftrack)_ sync to avalon now works on empty projects - _(ftrack)_ thumbnail update event was failing when deleting entities [PYPE-561] - _(nuke)_ loader applies proper colorspaces from Presets - _(nuke)_ publishing handles didn't always work correctly [PYPE-686] - _(maya)_ assembly publishing and loading wasn't working correctly ## 2.4.0 ## _**release date:** 9 Dec 2019_ **change:** - _(ftrack)_ version to status ftrack event can now be configured from Presets - based on preset `presets/ftracc/ftrack_config.json["status_version_to_task"]` - _(ftrack)_ sync to avalon event has been completely re-written. It now supports most of the project management situations on ftrack including moving, renaming and deleting entities, updating attributes and working with tasks. - _(ftrack)_ sync to avalon action has been also re-writen. It is now much faster (up to 100 times depending on a project structure), has much better logging and reporting on encountered problems, and is able to handle much more complex situations. - _(ftrack)_ sync to avalon trigger by checking `auto-sync` toggle on ftrack [PYPE-504] - _(pype)_ various new features in the REST api - _(pype)_ new visual identity used across pype - _(pype)_ started moving all requirements to pip installation rather than vendorising them in pype repository. Due to a few yet unreleased packages, this means that pype can temporarily be only installed in the offline mode. **new:** - _(nuke)_ support for publishing gizmos and loading them as viewer processes - _(nuke)_ support for publishing nuke nodes from backdrops and loading them back - _(pype)_ burnins can now work with start and end frames as keys - use keys `{frame_start}`, `{frame_end}` and `{current_frame}` in burnin preset to use them. [PYPS-44,PYPS-73, PYPE-602] - _(pype)_ option to filter logs by user and level in loggin GUI - _(pype)_ image family added to standalone publisher [PYPE-574] - _(pype)_ matchmove family added to standalone publisher [PYPE-574] - _(nuke)_ validator for comparing arbitrary knobs with values from presets - _(maya)_ option to force maya to copy textures in the new look publish rather than hardlinking them - _(pype)_ comments from pyblish GUI are now being added to ftrack version - _(maya)_ validator for checking outdated containers in the scene - _(maya)_ option to publish and load arnold standin sequence [PYPE-579, PYPS-54] **fix**: - _(pype)_ burnins were not respecting codec of the input video - _(nuke)_ lot's of various nuke and nuke studio fixes across the board [PYPS-45] - _(pype)_ workfiles app is not launching with the start of the app by default [PYPE-569] - _(ftrack)_ ftrack integration during publishing was failing under certain situations [PYPS-66] - _(pype)_ minor fixes in REST api - _(ftrack)_ status change event was crashing when the target status was missing [PYPS-68] - _(ftrack)_ actions will try to reconnect if they fail for some reason - _(maya)_ problems with fps mapping when using float FPS values - _(deadline)_ overall improvements to deadline publishing - _(setup)_ environment variables are now remapped on the fly based on the platform pype is running on. This fixes many issues in mixed platform environments. ## 2.3.6 # _**release date:** 27 Nov 2019_ **hotfix**: - _(ftrack)_ was hiding important debug logo - _(nuke)_ crashes during workfile publishing - _(ftrack)_ event server crashes because of signal problems - _(muster)_ problems with muster render submissions - _(ftrack)_ thumbnail update event syntax errors ## 2.3.0 ## _release date: 6 Oct 2019_ **new**: - _(maya)_ support for yeti rigs and yeti caches - _(maya)_ validator for comparing arbitrary attributes against ftrack - _(pype)_ burnins can now show current date and time - _(muster)_ pools can now be set in render globals in maya - _(pype)_ Rest API has been implemented in beta stage - _(nuke)_ LUT loader has been added - _(pype)_ rudimentary user module has been added as preparation for user management - _(pype)_ a simple logging GUI has been added to pype tray - _(nuke)_ nuke can now bake input process into mov - _(maya)_ imported models now have selection handle displayed by defaulting - _(avalon)_ it's is now possible to load multiple assets at once using loader - _(maya)_ added ability to automatically connect yeti rig to a mesh upon loading **changed**: - _(ftrack)_ event server now runs two parallel processes and is able to keep queue of events to process. - _(nuke)_ task name is now added to all rendered subsets - _(pype)_ adding more families to standalone publisher - _(pype)_ standalone publisher now uses pyblish-lite - _(pype)_ standalone publisher can now create review quicktimes - _(ftrack)_ queries to ftrack were sped up - _(ftrack)_ multiple ftrack action have been deprecated - _(avalon)_ avalon upstream has been updated to 5.5.0 - _(nukestudio)_ published transforms can now be animated - **fix**: - _(maya)_ fps popup button didn't work in some cases - _(maya)_ geometry instances and references in maya were losing shader assignments - _(muster)_ muster rendering templates were not working correctly - _(maya)_ arnold tx texture conversion wasn't respecting colorspace set by the artist - _(pype)_ problems with avalon db sync - _(maya)_ ftrack was rounding FPS making it inconsistent - _(pype)_ wrong icon names in Creator - _(maya)_ scene inventory wasn't showing anything if representation was removed from database after it's been loaded to the scene - _(nukestudio)_ multiple bugs squashed - _(loader)_ loader was taking long time to show all the loading action when first launcher in maya ## 2.2.0 ## _release date: 8 Sept 2019_ **new**: - _(pype)_ add customisable workflow for creating quicktimes from renders or playblasts - _(nuke)_ option to choose deadline chunk size on write nodes - _(nukestudio)_ added option to publish soft effects (subTrackItems) from NukeStudio as subsets including LUT files. these can then be loaded in nuke or NukeStudio - _(nuke)_ option to build nuke script from previously published latest versions of plate and render subsets. - _(nuke)_ nuke writes now have deadline tab. - _(ftrack)_ Prepare Project action can now be used for creating the base folder structure on disk and in ftrack, setting up all the initial project attributes and it automatically prepares `pype_project_config` folder for the given project. - _(clockify)_ Added support for time tracking in clockify. This currently in addition to ftrack time logs, but does not completely replace them. - _(pype)_ any attributes in Creator and Loader plugins can now be customised using pype preset system **changed**: - nukestudio now uses workio API for workfiles - _(maya)_ "FIX FPS" prompt in maya now appears in the middle of the screen - _(muster)_ can now be configured with custom templates - _(pype)_ global publishing plugins can now be configured using presets as well as host specific ones **fix**: - wrong version retrieval from path in certain scenarios - nuke reset resolution wasn't working in certain scenarios ## 2.1.0 ## _release date: 6 Aug 2019_ A large cleanup release. Most of the change are under the hood. **new**: - _(pype)_ add customisable workflow for creating quicktimes from renders or playblasts - _(pype)_ Added configurable option to add burnins to any generated quicktimes - _(ftrack)_ Action that identifies what machines pype is running on. - _(system)_ unify subprocess calls - _(maya)_ add audio to review quicktimes - _(nuke)_ add crop before write node to prevent overscan problems in ffmpeg - **Nuke Studio** publishing and workfiles support - **Muster** render manager support - _(nuke)_ Framerange, FPS and Resolution are set automatically at startup - _(maya)_ Ability to load published sequences as image planes - _(system)_ Ftrack event that sets asset folder permissions based on task assignees in ftrack. - _(maya)_ Pyblish plugin that allow validation of maya attributes - _(system)_ added better startup logging to tray debug, including basic connection information - _(avalon)_ option to group published subsets to groups in the loader - _(avalon)_ loader family filters are working now **changed**: - change multiple key attributes to unify their behaviour across the pipeline - `frameRate` to `fps` - `startFrame` to `frameStart` - `endFrame` to `frameEnd` - `fstart` to `frameStart` - `fend` to `frameEnd` - `handle_start` to `handleStart` - `handle_end` to `handleEnd` - `resolution_width` to `resolutionWidth` - `resolution_height` to `resolutionHeight` - `pixel_aspect` to `pixelAspect` - _(nuke)_ write nodes are now created inside group with only some attributes editable by the artist - rendered frames are now deleted from temporary location after their publishing is finished. - _(ftrack)_ RV action can now be launched from any entity - after publishing only refresh button is now available in pyblish UI - added context instance pyblish-lite so that artist knows if context plugin fails - _(avalon)_ allow opening selected files using enter key - _(avalon)_ core updated to v5.2.9 with our forked changes on top **fix**: - faster hierarchy retrieval from db - _(nuke)_ A lot of stability enhancements - _(nuke studio)_ A lot of stability enhancements - _(nuke)_ now only renders a single write node on farm - _(ftrack)_ pype would crash when launcher project level task - work directory was sometimes not being created correctly - major pype.lib cleanup. Removing of unused functions, merging those that were doing the same and general house cleaning. - _(avalon)_ subsets in maya 2019 weren't behaving correctly in the outliner \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at info@pype.club. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: CONTRIBUTING.md ================================================ ## How to contribute to OpenPype OpenPype has reached the end of its life and is now in a limited maintenance mode (read more at https://community.ynput.io/t/openpype-end-of-life-timeline/877). As such we're no longer accepting contributions unless they are also ported to AYON at the same time. ## Getting my PR merged during this period - Each OpenPype PR MUST have a corresponding AYON PR in github. Without AYON compatibility features will not be merged! Luckily most of the code is compatible, albeit sometimes in a different place after refactor. Porting from OpenPype to AYON should be really easy. - Please keep the corresponding OpenPype and AYON PR names the same so they can be easily identified. Inside each PR, put a link to the corresponding PR from the other product. OpenPype PRs should point to AYON PR and vice versa. AYON repository structure is a lot more granular compared to OpenPype. If you're unsure what repository your AYON equivalent PR should target, feel free to make OpenPype PR first and ask. ================================================ FILE: Dockerfile ================================================ # Build Pype docker image FROM ubuntu:focal AS builder ARG OPENPYPE_PYTHON_VERSION=3.9.12 ARG BUILD_DATE ARG VERSION LABEL maintainer="info@openpype.io" LABEL description="Docker Image to build and run OpenPype under Ubuntu 20.04" LABEL org.opencontainers.image.name="pypeclub/openpype" LABEL org.opencontainers.image.title="OpenPype Docker Image" LABEL org.opencontainers.image.url="https://openpype.io/" LABEL org.opencontainers.image.source="https://github.com/pypeclub/OpenPype" LABEL org.opencontainers.image.documentation="https://openpype.io/docs/system_introduction" LABEL org.opencontainers.image.created=$BUILD_DATE LABEL org.opencontainers.image.version=$VERSION USER root ARG DEBIAN_FRONTEND=noninteractive # update base RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ bash \ git \ cmake \ make \ curl \ wget \ build-essential \ checkinstall \ libssl-dev \ zlib1g-dev \ libbz2-dev \ libreadline-dev \ libsqlite3-dev \ llvm \ libncursesw5-dev \ xz-utils \ tk-dev \ libxml2-dev \ libxmlsec1-dev \ libffi-dev \ liblzma-dev \ patchelf SHELL ["/bin/bash", "-c"] RUN mkdir /opt/openpype # download and install pyenv RUN curl https://pyenv.run | bash \ && echo 'export PATH="$HOME/.pyenv/bin:$PATH"'>> $HOME/init_pyenv.sh \ && echo 'eval "$(pyenv init -)"' >> $HOME/init_pyenv.sh \ && echo 'eval "$(pyenv virtualenv-init -)"' >> $HOME/init_pyenv.sh \ && echo 'eval "$(pyenv init --path)"' >> $HOME/init_pyenv.sh # install python with pyenv RUN source $HOME/init_pyenv.sh \ && pyenv install ${OPENPYPE_PYTHON_VERSION} COPY . /opt/openpype/ RUN chmod +x /opt/openpype/tools/create_env.sh && chmod +x /opt/openpype/tools/build.sh WORKDIR /opt/openpype # set local python version RUN cd /opt/openpype \ && source $HOME/init_pyenv.sh \ && pyenv local ${OPENPYPE_PYTHON_VERSION} # fetch third party tools/libraries RUN source $HOME/init_pyenv.sh \ && ./tools/create_env.sh \ && ./tools/fetch_thirdparty_libs.sh # build openpype RUN source $HOME/init_pyenv.sh \ && bash ./tools/build.sh ================================================ FILE: Dockerfile.centos7 ================================================ # Build Pype docker image FROM centos:7 AS builder ARG OPENPYPE_PYTHON_VERSION=3.9.12 LABEL org.opencontainers.image.name="pypeclub/openpype" LABEL org.opencontainers.image.title="OpenPype Docker Image" LABEL org.opencontainers.image.url="https://openpype.io/" LABEL org.opencontainers.image.source="https://github.com/pypeclub/pype" LABEL org.opencontainers.image.documentation="https://openpype.io/docs/system_introduction" LABEL org.opencontainers.image.created=$BUILD_DATE LABEL org.opencontainers.image.version=$VERSION USER root # update base RUN yum -y install deltarpm \ && yum -y update \ && yum clean all # add tools we need RUN yum -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm \ && yum -y install centos-release-scl \ && yum -y install \ bash \ which \ git \ make \ devtoolset-7 \ cmake \ curl \ wget \ gcc \ zlib-devel \ pcre-devel \ perl-core \ bzip2 \ bzip2-devel \ readline-devel \ sqlite sqlite-devel \ openssl-devel \ openssl-libs \ openssl11-devel \ openssl11-libs \ tk-devel libffi-devel \ patchelf \ automake \ autoconf \ patch \ ncurses \ ncurses-devel \ qt5-qtbase-devel \ xcb-util-wm \ xcb-util-renderutil \ && yum clean all # we need to build our own patchelf WORKDIR /temp-patchelf RUN git clone -b 0.17.0 --single-branch https://github.com/NixOS/patchelf.git . \ && source scl_source enable devtoolset-7 \ && ./bootstrap.sh \ && ./configure \ && make \ && make install RUN mkdir /opt/openpype # RUN useradd -m pype # RUN chown pype /opt/openpype # USER pype RUN curl https://pyenv.run | bash # ENV PYTHON_CONFIGURE_OPTS --enable-shared RUN echo 'export PATH="$HOME/.pyenv/bin:$PATH"'>> $HOME/.bashrc \ && echo 'eval "$(pyenv init -)"' >> $HOME/.bashrc \ && echo 'eval "$(pyenv virtualenv-init -)"' >> $HOME/.bashrc \ && echo 'eval "$(pyenv init --path)"' >> $HOME/.bashrc RUN source $HOME/.bashrc \ && export CPPFLAGS="-I/usr/include/openssl11" \ && export LDFLAGS="-L/usr/lib64/openssl11 -lssl -lcrypto" \ && export PATH=/usr/local/openssl/bin:$PATH \ && export LD_LIBRARY_PATH=/usr/local/openssl/lib:$LD_LIBRARY_PATH \ && pyenv install ${OPENPYPE_PYTHON_VERSION} COPY . /opt/openpype/ RUN rm -rf /openpype/.poetry || echo "No Poetry installed yet." # USER root # RUN chown -R pype /opt/openpype RUN chmod +x /opt/openpype/tools/create_env.sh && chmod +x /opt/openpype/tools/build.sh # USER pype WORKDIR /opt/openpype RUN cd /opt/openpype \ && source $HOME/.bashrc \ && pyenv local ${OPENPYPE_PYTHON_VERSION} RUN source $HOME/.bashrc \ && ./tools/create_env.sh RUN source $HOME/.bashrc \ && ./tools/fetch_thirdparty_libs.sh RUN echo 'export PYTHONPATH="/opt/openpype/vendor/python:$PYTHONPATH"'>> $HOME/.bashrc RUN source $HOME/.bashrc \ && bash ./tools/build.sh RUN cp /usr/lib64/libffi* ./build/exe.linux-x86_64-3.9/lib \ && cp /usr/lib64/openssl11/libssl* ./build/exe.linux-x86_64-3.9/lib \ && cp /usr/lib64/openssl11/libcrypto* ./build/exe.linux-x86_64-3.9/lib \ && ln -sr ./build/exe.linux-x86_64-3.9/lib/libssl.so ./build/exe.linux-x86_64-3.9/lib/libssl.1.1.so \ && ln -sr ./build/exe.linux-x86_64-3.9/lib/libcrypto.so ./build/exe.linux-x86_64-3.9/lib/libcrypto.1.1.so \ && cp /root/.pyenv/versions/${OPENPYPE_PYTHON_VERSION}/lib/libpython* ./build/exe.linux-x86_64-3.9/lib \ && cp /usr/lib64/libxcb* ./build/exe.linux-x86_64-3.9/vendor/python/PySide2/Qt/lib RUN cd /opt/openpype \ rm -rf ./vendor/bin ================================================ FILE: Dockerfile.debian ================================================ # Build Pype docker image FROM debian:bullseye AS builder ARG OPENPYPE_PYTHON_VERSION=3.9.12 ARG BUILD_DATE ARG VERSION LABEL maintainer="info@openpype.io" LABEL description="Docker Image to build and run OpenPype under Ubuntu 20.04" LABEL org.opencontainers.image.name="pypeclub/openpype" LABEL org.opencontainers.image.title="OpenPype Docker Image" LABEL org.opencontainers.image.url="https://openpype.io/" LABEL org.opencontainers.image.source="https://github.com/pypeclub/OpenPype" LABEL org.opencontainers.image.documentation="https://openpype.io/docs/system_introduction" LABEL org.opencontainers.image.created=$BUILD_DATE LABEL org.opencontainers.image.version=$VERSION USER root ARG DEBIAN_FRONTEND=noninteractive # update base RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ bash \ git \ cmake \ make \ curl \ wget \ build-essential \ libssl-dev \ zlib1g-dev \ libbz2-dev \ libreadline-dev \ libsqlite3-dev \ llvm \ libncursesw5-dev \ xz-utils \ tk-dev \ libxml2-dev \ libxmlsec1-dev \ libffi-dev \ liblzma-dev \ patchelf SHELL ["/bin/bash", "-c"] RUN mkdir /opt/openpype # download and install pyenv RUN curl https://pyenv.run | bash \ && echo 'export PATH="$HOME/.pyenv/bin:$PATH"'>> $HOME/init_pyenv.sh \ && echo 'eval "$(pyenv init -)"' >> $HOME/init_pyenv.sh \ && echo 'eval "$(pyenv virtualenv-init -)"' >> $HOME/init_pyenv.sh \ && echo 'eval "$(pyenv init --path)"' >> $HOME/init_pyenv.sh # install python with pyenv RUN source $HOME/init_pyenv.sh \ && pyenv install ${OPENPYPE_PYTHON_VERSION} COPY . /opt/openpype/ RUN chmod +x /opt/openpype/tools/create_env.sh && chmod +x /opt/openpype/tools/build.sh WORKDIR /opt/openpype # set local python version RUN cd /opt/openpype \ && source $HOME/init_pyenv.sh \ && pyenv local ${OPENPYPE_PYTHON_VERSION} # fetch third party tools/libraries RUN source $HOME/init_pyenv.sh \ && ./tools/create_env.sh \ && ./tools/fetch_thirdparty_libs.sh # build openpype RUN source $HOME/init_pyenv.sh \ && bash ./tools/build.sh ================================================ FILE: HISTORY.md ================================================ # Changelog ## [3.15.0](https://github.com/ynput/OpenPype/tree/3.15.0) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.14.10...3.15.0) **Deprecated:** - General: Fill default values of new publish template profiles [\#4245](https://github.com/ynput/OpenPype/pull/4245) ### 📖 Documentation - documentation: Split tools into separate entries [\#4342](https://github.com/ynput/OpenPype/pull/4342) - Documentation: Fix harmony docs [\#4301](https://github.com/ynput/OpenPype/pull/4301) - Remove staging logic set by OpenPype version [\#3979](https://github.com/ynput/OpenPype/pull/3979) **🆕 New features** - General: Push to studio library [\#4284](https://github.com/ynput/OpenPype/pull/4284) - Colorspace Management and Distribution [\#4195](https://github.com/ynput/OpenPype/pull/4195) - Nuke: refactor to latest publisher workfow [\#4006](https://github.com/ynput/OpenPype/pull/4006) - Update to Python 3.9 [\#3546](https://github.com/ynput/OpenPype/pull/3546) **🚀 Enhancements** - Unreal: Don't use mongo queries in 'ExistingLayoutLoader' [\#4356](https://github.com/ynput/OpenPype/pull/4356) - General: Loader and Creator plugins can be disabled [\#4310](https://github.com/ynput/OpenPype/pull/4310) - General: Unbind poetry version [\#4306](https://github.com/ynput/OpenPype/pull/4306) - General: Enhanced enum def items [\#4295](https://github.com/ynput/OpenPype/pull/4295) - Git: add pre-commit hooks [\#4289](https://github.com/ynput/OpenPype/pull/4289) - Tray Publisher: Improve Online family functionality [\#4263](https://github.com/ynput/OpenPype/pull/4263) - General: Update MacOs to PySide6 [\#4255](https://github.com/ynput/OpenPype/pull/4255) - Build: update to Gazu in toml [\#4208](https://github.com/ynput/OpenPype/pull/4208) - Global: adding imageio to settings [\#4158](https://github.com/ynput/OpenPype/pull/4158) - Blender: added project settings for validator no colons in name [\#4149](https://github.com/ynput/OpenPype/pull/4149) - Dockerfile for Debian Bullseye [\#4108](https://github.com/ynput/OpenPype/pull/4108) - AfterEffects: publish multiple compositions [\#4092](https://github.com/ynput/OpenPype/pull/4092) - AfterEffects: make new publisher default [\#4056](https://github.com/ynput/OpenPype/pull/4056) - Photoshop: make new publisher default [\#4051](https://github.com/ynput/OpenPype/pull/4051) - Feature/multiverse [\#4046](https://github.com/ynput/OpenPype/pull/4046) - Tests: add support for deadline for automatic tests [\#3989](https://github.com/ynput/OpenPype/pull/3989) - Add version to shortcut name [\#3906](https://github.com/ynput/OpenPype/pull/3906) - TrayPublisher: Removed from experimental tools [\#3667](https://github.com/ynput/OpenPype/pull/3667) **🐛 Bug fixes** - change 3.7 to 3.9 in folder name [\#4354](https://github.com/ynput/OpenPype/pull/4354) - PushToProject: Fix hierarchy of project change [\#4350](https://github.com/ynput/OpenPype/pull/4350) - Fix photoshop workfile save-as [\#4347](https://github.com/ynput/OpenPype/pull/4347) - Nuke Input process node sourcing improvements [\#4341](https://github.com/ynput/OpenPype/pull/4341) - New publisher: Some validation plugin tweaks [\#4339](https://github.com/ynput/OpenPype/pull/4339) - Harmony: fix unable to change workfile on Mac [\#4334](https://github.com/ynput/OpenPype/pull/4334) - Global: fixing in-place source publishing for editorial [\#4333](https://github.com/ynput/OpenPype/pull/4333) - General: Use class constants of QMessageBox [\#4332](https://github.com/ynput/OpenPype/pull/4332) - TVPaint: Fix plugin for TVPaint 11.7 [\#4328](https://github.com/ynput/OpenPype/pull/4328) - Exctract OTIO review has improved quality [\#4325](https://github.com/ynput/OpenPype/pull/4325) - Ftrack: fix typos causing bugs in sync [\#4322](https://github.com/ynput/OpenPype/pull/4322) - General: Python 2 compatibility of instance collector [\#4320](https://github.com/ynput/OpenPype/pull/4320) - Slack: user groups speedup [\#4318](https://github.com/ynput/OpenPype/pull/4318) - Maya: Bug - Multiverse extractor executed on plain animation family [\#4315](https://github.com/ynput/OpenPype/pull/4315) - Fix run\_documentation.ps1 [\#4312](https://github.com/ynput/OpenPype/pull/4312) - Nuke: new creators fixes [\#4308](https://github.com/ynput/OpenPype/pull/4308) - General: missing comment on standalone and tray publisher [\#4303](https://github.com/ynput/OpenPype/pull/4303) - AfterEffects: Fix for audio from mp4 layer [\#4296](https://github.com/ynput/OpenPype/pull/4296) - General: Update gazu in poetry lock [\#4247](https://github.com/ynput/OpenPype/pull/4247) - Bug: Fixing version detection and filtering in Igniter [\#3914](https://github.com/ynput/OpenPype/pull/3914) - Bug: Create missing version dir [\#3903](https://github.com/ynput/OpenPype/pull/3903) **🔀 Refactored code** - Remove redundant export\_alembic method. [\#4293](https://github.com/ynput/OpenPype/pull/4293) - Igniter: Use qtpy modules instead of Qt [\#4237](https://github.com/ynput/OpenPype/pull/4237) **Merged pull requests:** - Sort families by alphabetical order in the Create plugin [\#4346](https://github.com/ynput/OpenPype/pull/4346) - Global: Validate unique subsets [\#4336](https://github.com/ynput/OpenPype/pull/4336) - Maya: Collect instances preserve handles even if frameStart + frameEnd matches context [\#3437](https://github.com/ynput/OpenPype/pull/3437) ## [3.14.10](https://github.com/ynput/OpenPype/tree/3.14.10) [Full Changelog](https://github.com/ynput/OpenPype/compare/3.14.9...3.14.10) **🆕 New features** - Global | Nuke: Creator placeholders in workfile template builder [\#4266](https://github.com/ynput/OpenPype/pull/4266) - Slack: Added dynamic message [\#4265](https://github.com/ynput/OpenPype/pull/4265) - Blender: Workfile Loader [\#4234](https://github.com/ynput/OpenPype/pull/4234) - Unreal: Publishing and Loading for UAssets [\#4198](https://github.com/ynput/OpenPype/pull/4198) - Publish: register publishes without copying them [\#4157](https://github.com/ynput/OpenPype/pull/4157) **🚀 Enhancements** - General: Added install method with docstring to HostBase [\#4298](https://github.com/ynput/OpenPype/pull/4298) - Traypublisher: simple editorial multiple edl [\#4248](https://github.com/ynput/OpenPype/pull/4248) - General: Extend 'IPluginPaths' to have more available methods [\#4214](https://github.com/ynput/OpenPype/pull/4214) - Refactorization of folder coloring [\#4211](https://github.com/ynput/OpenPype/pull/4211) - Flame - loading multilayer with controlled layer names [\#4204](https://github.com/ynput/OpenPype/pull/4204) **🐛 Bug fixes** - Unreal: fix missing `maintained_selection` call [\#4300](https://github.com/ynput/OpenPype/pull/4300) - Ftrack: Fix receive of host ip on MacOs [\#4288](https://github.com/ynput/OpenPype/pull/4288) - SiteSync: sftp connection failing when shouldnt be tested [\#4278](https://github.com/ynput/OpenPype/pull/4278) - Deadline: fix default value for passing mongo url [\#4275](https://github.com/ynput/OpenPype/pull/4275) - Scene Manager: Fix variable name [\#4268](https://github.com/ynput/OpenPype/pull/4268) - Slack: notification fails because of missing published path [\#4264](https://github.com/ynput/OpenPype/pull/4264) - hiero: creator gui with min max [\#4257](https://github.com/ynput/OpenPype/pull/4257) - NiceCheckbox: Fix checker positioning in Python 2 [\#4253](https://github.com/ynput/OpenPype/pull/4253) - Publisher: Fix 'CreatorType' not equal for Python 2 DCCs [\#4249](https://github.com/ynput/OpenPype/pull/4249) - Deadline: fix dependencies [\#4242](https://github.com/ynput/OpenPype/pull/4242) - Houdini: hotfix instance data access [\#4236](https://github.com/ynput/OpenPype/pull/4236) - bugfix/image plane load error [\#4222](https://github.com/ynput/OpenPype/pull/4222) - Hiero: thumbnail from multilayer exr [\#4209](https://github.com/ynput/OpenPype/pull/4209) **🔀 Refactored code** - Resolve: Use qtpy in Resolve [\#4254](https://github.com/ynput/OpenPype/pull/4254) - Houdini: Use qtpy in Houdini [\#4252](https://github.com/ynput/OpenPype/pull/4252) - Max: Use qtpy in Max [\#4251](https://github.com/ynput/OpenPype/pull/4251) - Maya: Use qtpy in Maya [\#4250](https://github.com/ynput/OpenPype/pull/4250) - Hiero: Use qtpy in Hiero [\#4240](https://github.com/ynput/OpenPype/pull/4240) - Nuke: Use qtpy in Nuke [\#4239](https://github.com/ynput/OpenPype/pull/4239) - Flame: Use qtpy in flame [\#4238](https://github.com/ynput/OpenPype/pull/4238) - General: Legacy io not used in global plugins [\#4134](https://github.com/ynput/OpenPype/pull/4134) **Merged pull requests:** - Bump json5 from 1.0.1 to 1.0.2 in /website [\#4292](https://github.com/ynput/OpenPype/pull/4292) - Maya: Fix validate frame range repair + fix create render with deadline disabled [\#4279](https://github.com/ynput/OpenPype/pull/4279) ## [3.14.9](https://github.com/pypeclub/OpenPype/tree/3.14.9) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.8...3.14.9) ### 📖 Documentation - Documentation: Testing on Deadline [\#4185](https://github.com/pypeclub/OpenPype/pull/4185) - Consistent Python version [\#4160](https://github.com/pypeclub/OpenPype/pull/4160) **🆕 New features** - Feature/op 4397 gl tf extractor for maya [\#4192](https://github.com/pypeclub/OpenPype/pull/4192) - Maya: Extractor for Unreal SkeletalMesh [\#4174](https://github.com/pypeclub/OpenPype/pull/4174) - 3dsmax: integration [\#4168](https://github.com/pypeclub/OpenPype/pull/4168) - Blender: Extract Alembic Animations [\#4128](https://github.com/pypeclub/OpenPype/pull/4128) - Unreal: Load Alembic Animations [\#4127](https://github.com/pypeclub/OpenPype/pull/4127) **🚀 Enhancements** - Houdini: Use new interface class name for publish host [\#4220](https://github.com/pypeclub/OpenPype/pull/4220) - General: Default command for headless mode is interactive [\#4203](https://github.com/pypeclub/OpenPype/pull/4203) - Maya: Enhanced ASS publishing [\#4196](https://github.com/pypeclub/OpenPype/pull/4196) - Feature/op 3924 implement ass extractor [\#4188](https://github.com/pypeclub/OpenPype/pull/4188) - File transactions: Source path is destination path [\#4184](https://github.com/pypeclub/OpenPype/pull/4184) - Deadline: improve environment processing [\#4182](https://github.com/pypeclub/OpenPype/pull/4182) - General: Comment per instance in Publisher [\#4178](https://github.com/pypeclub/OpenPype/pull/4178) - Ensure Mongo database directory exists in Windows. [\#4166](https://github.com/pypeclub/OpenPype/pull/4166) - Note about unrestricted execution on Windows. [\#4161](https://github.com/pypeclub/OpenPype/pull/4161) - Maya: Enable thumbnail transparency on extraction. [\#4147](https://github.com/pypeclub/OpenPype/pull/4147) - Maya: Disable viewport Pan/Zoom on playblast extraction. [\#4146](https://github.com/pypeclub/OpenPype/pull/4146) - Maya: Optional viewport refresh on pointcache extraction [\#4144](https://github.com/pypeclub/OpenPype/pull/4144) - CelAction: refactory integration to current openpype [\#4140](https://github.com/pypeclub/OpenPype/pull/4140) - Maya: create and publish bounding box geometry [\#4131](https://github.com/pypeclub/OpenPype/pull/4131) - Changed the UOpenPypePublishInstance to use the UDataAsset class [\#4124](https://github.com/pypeclub/OpenPype/pull/4124) - General: Collection Audio speed up [\#4110](https://github.com/pypeclub/OpenPype/pull/4110) - Maya: keep existing AOVs when creating render instance [\#4087](https://github.com/pypeclub/OpenPype/pull/4087) - General: Oiio conversion multipart fix [\#4060](https://github.com/pypeclub/OpenPype/pull/4060) **🐛 Bug fixes** - Publisher: Signal type issues in Python 2 DCCs [\#4230](https://github.com/pypeclub/OpenPype/pull/4230) - Blender: Fix Layout Family Versioning [\#4228](https://github.com/pypeclub/OpenPype/pull/4228) - Blender: Fix Create Camera "Use selection" [\#4226](https://github.com/pypeclub/OpenPype/pull/4226) - TrayPublisher - join needs list [\#4224](https://github.com/pypeclub/OpenPype/pull/4224) - General: Event callbacks pass event to callbacks as expected [\#4210](https://github.com/pypeclub/OpenPype/pull/4210) - Build:Revert .toml update of Gazu [\#4207](https://github.com/pypeclub/OpenPype/pull/4207) - Nuke: fixed imageio node overrides subset filter [\#4202](https://github.com/pypeclub/OpenPype/pull/4202) - Maya: pointcache [\#4201](https://github.com/pypeclub/OpenPype/pull/4201) - Unreal: Support for Unreal Engine 5.1 [\#4199](https://github.com/pypeclub/OpenPype/pull/4199) - General: Integrate thumbnail looks for thumbnail to multiple places [\#4181](https://github.com/pypeclub/OpenPype/pull/4181) - Various minor bugfixes [\#4172](https://github.com/pypeclub/OpenPype/pull/4172) - Nuke/Hiero: Remove tkinter library paths before launch [\#4171](https://github.com/pypeclub/OpenPype/pull/4171) - Flame: vertical alignment of layers [\#4169](https://github.com/pypeclub/OpenPype/pull/4169) - Nuke: correct detection of viewer and display [\#4165](https://github.com/pypeclub/OpenPype/pull/4165) - Settings UI: Don't create QApplication if already exists [\#4156](https://github.com/pypeclub/OpenPype/pull/4156) - General: Extract review handle start offset of sequences [\#4152](https://github.com/pypeclub/OpenPype/pull/4152) - Maya: Maintain time connections on Alembic update. [\#4143](https://github.com/pypeclub/OpenPype/pull/4143) **🔀 Refactored code** - General: Use qtpy in modules and hosts UIs which are running in OpenPype process [\#4225](https://github.com/pypeclub/OpenPype/pull/4225) - Tools: Use qtpy instead of Qt in standalone tools [\#4223](https://github.com/pypeclub/OpenPype/pull/4223) - General: Use qtpy in settings UI [\#4215](https://github.com/pypeclub/OpenPype/pull/4215) **Merged pull requests:** - layout publish more than one container issue [\#4098](https://github.com/pypeclub/OpenPype/pull/4098) ## [3.14.8](https://github.com/pypeclub/OpenPype/tree/3.14.8) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.7...3.14.8) **🚀 Enhancements** - General: Refactored extract hierarchy plugin [\#4139](https://github.com/pypeclub/OpenPype/pull/4139) - General: Find executable enhancement [\#4137](https://github.com/pypeclub/OpenPype/pull/4137) - Ftrack: Reset session before instance processing [\#4129](https://github.com/pypeclub/OpenPype/pull/4129) - Ftrack: Editorial asset sync issue [\#4126](https://github.com/pypeclub/OpenPype/pull/4126) - Deadline: Build version resolving [\#4115](https://github.com/pypeclub/OpenPype/pull/4115) - Houdini: New Publisher [\#3046](https://github.com/pypeclub/OpenPype/pull/3046) - Fix: Standalone Publish Directories [\#4148](https://github.com/pypeclub/OpenPype/pull/4148) **🐛 Bug fixes** - Ftrack: Fix occational double parents issue [\#4153](https://github.com/pypeclub/OpenPype/pull/4153) - General: Maketx executable issue [\#4136](https://github.com/pypeclub/OpenPype/pull/4136) - Maya: Looks - add all connections [\#4135](https://github.com/pypeclub/OpenPype/pull/4135) - General: Fix variable check in collect anatomy instance data [\#4117](https://github.com/pypeclub/OpenPype/pull/4117) ## [3.14.7](https://github.com/pypeclub/OpenPype/tree/3.14.7) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.6...3.14.7) **🆕 New features** - Hiero: loading effect family to timeline [\#4055](https://github.com/pypeclub/OpenPype/pull/4055) **🚀 Enhancements** - Photoshop: bug with pop-up window on Instance Creator [\#4121](https://github.com/pypeclub/OpenPype/pull/4121) - Publisher: Open on specific tab [\#4120](https://github.com/pypeclub/OpenPype/pull/4120) - Publisher: Hide unknown publish values [\#4116](https://github.com/pypeclub/OpenPype/pull/4116) - Ftrack: Event server status give more information about version locations [\#4112](https://github.com/pypeclub/OpenPype/pull/4112) - General: Allow higher numbers in frames and clips [\#4101](https://github.com/pypeclub/OpenPype/pull/4101) - Publisher: Settings for validate frame range [\#4097](https://github.com/pypeclub/OpenPype/pull/4097) - Publisher: Ignore escape button [\#4090](https://github.com/pypeclub/OpenPype/pull/4090) - Flame: Loading clip with native colorspace resolved from mapping [\#4079](https://github.com/pypeclub/OpenPype/pull/4079) - General: Extract review single frame output [\#4064](https://github.com/pypeclub/OpenPype/pull/4064) - Publisher: Prepared common function for instance data cache [\#4063](https://github.com/pypeclub/OpenPype/pull/4063) - Publisher: Easy access to publish page from create page [\#4058](https://github.com/pypeclub/OpenPype/pull/4058) - General/TVPaint: Attribute defs dialog [\#4052](https://github.com/pypeclub/OpenPype/pull/4052) - Publisher: Better reset defer [\#4048](https://github.com/pypeclub/OpenPype/pull/4048) - Publisher: Add thumbnail sources [\#4042](https://github.com/pypeclub/OpenPype/pull/4042) **🐛 Bug fixes** - General: Move default settings for template name [\#4119](https://github.com/pypeclub/OpenPype/pull/4119) - Slack: notification fail in new tray publisher [\#4118](https://github.com/pypeclub/OpenPype/pull/4118) - Nuke: loaded nodes set to first tab [\#4114](https://github.com/pypeclub/OpenPype/pull/4114) - Nuke: load image first frame [\#4113](https://github.com/pypeclub/OpenPype/pull/4113) - Files Widget: Ignore case sensitivity of extensions [\#4096](https://github.com/pypeclub/OpenPype/pull/4096) - Webpublisher: extension is lowercased in Setting and in uploaded files [\#4095](https://github.com/pypeclub/OpenPype/pull/4095) - Publish Report Viewer: Fix small bugs [\#4086](https://github.com/pypeclub/OpenPype/pull/4086) - Igniter: fix regex to match semver better [\#4085](https://github.com/pypeclub/OpenPype/pull/4085) - Maya: aov filtering [\#4083](https://github.com/pypeclub/OpenPype/pull/4083) - Flame/Flare: Loading to multiple batches [\#4080](https://github.com/pypeclub/OpenPype/pull/4080) - hiero: creator from settings with set maximum [\#4077](https://github.com/pypeclub/OpenPype/pull/4077) - Nuke: resolve hashes in file name only for frame token [\#4074](https://github.com/pypeclub/OpenPype/pull/4074) - Publisher: Fix cache of asset docs [\#4070](https://github.com/pypeclub/OpenPype/pull/4070) - Webpublisher: cleanup wp extract thumbnail [\#4067](https://github.com/pypeclub/OpenPype/pull/4067) - Settings UI: Locked setting can't bypass lock [\#4066](https://github.com/pypeclub/OpenPype/pull/4066) - Loader: Fix comparison of repre name [\#4053](https://github.com/pypeclub/OpenPype/pull/4053) - Deadline: Extract environment subprocess failure [\#4050](https://github.com/pypeclub/OpenPype/pull/4050) **🔀 Refactored code** - General: Collect entities plugin minor changes [\#4089](https://github.com/pypeclub/OpenPype/pull/4089) - General: Direct interfaces import [\#4065](https://github.com/pypeclub/OpenPype/pull/4065) **Merged pull requests:** - Bump loader-utils from 1.4.1 to 1.4.2 in /website [\#4100](https://github.com/pypeclub/OpenPype/pull/4100) - Online family for Tray Publisher [\#4093](https://github.com/pypeclub/OpenPype/pull/4093) - Bump loader-utils from 1.4.0 to 1.4.1 in /website [\#4081](https://github.com/pypeclub/OpenPype/pull/4081) - remove underscore from subset name [\#4059](https://github.com/pypeclub/OpenPype/pull/4059) - Alembic Loader as Arnold Standin [\#4047](https://github.com/pypeclub/OpenPype/pull/4047) ## [3.14.6](https://github.com/pypeclub/OpenPype/tree/3.14.6) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.5...3.14.6) ### 📖 Documentation - Documentation: Minor updates to dev\_requirements.md [\#4025](https://github.com/pypeclub/OpenPype/pull/4025) **🆕 New features** - Nuke: add 13.2 variant [\#4041](https://github.com/pypeclub/OpenPype/pull/4041) **🚀 Enhancements** - Publish Report Viewer: Store reports locally on machine [\#4040](https://github.com/pypeclub/OpenPype/pull/4040) - General: More specific error in burnins script [\#4026](https://github.com/pypeclub/OpenPype/pull/4026) - General: Extract review does not crash with old settings overrides [\#4023](https://github.com/pypeclub/OpenPype/pull/4023) - Publisher: Convertors for legacy instances [\#4020](https://github.com/pypeclub/OpenPype/pull/4020) - workflows: adding milestone creator and assigner [\#4018](https://github.com/pypeclub/OpenPype/pull/4018) - Publisher: Catch creator errors [\#4015](https://github.com/pypeclub/OpenPype/pull/4015) **🐛 Bug fixes** - Hiero - effect collection fixes [\#4038](https://github.com/pypeclub/OpenPype/pull/4038) - Nuke - loader clip correct hash conversion in path [\#4037](https://github.com/pypeclub/OpenPype/pull/4037) - Maya: Soft fail when applying capture preset [\#4034](https://github.com/pypeclub/OpenPype/pull/4034) - Igniter: handle missing directory [\#4032](https://github.com/pypeclub/OpenPype/pull/4032) - StandalonePublisher: Fix thumbnail publishing [\#4029](https://github.com/pypeclub/OpenPype/pull/4029) - Experimental Tools: Fix publisher import [\#4027](https://github.com/pypeclub/OpenPype/pull/4027) - Houdini: fix wrong path in ASS loader [\#4016](https://github.com/pypeclub/OpenPype/pull/4016) **🔀 Refactored code** - General: Import lib functions from lib [\#4017](https://github.com/pypeclub/OpenPype/pull/4017) ## [3.14.5](https://github.com/pypeclub/OpenPype/tree/3.14.5) (2022-10-24) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.4...3.14.5) **🚀 Enhancements** - Maya: add OBJ extractor to model family [\#4021](https://github.com/pypeclub/OpenPype/pull/4021) - Publish report viewer tool [\#4010](https://github.com/pypeclub/OpenPype/pull/4010) - Nuke | Global: adding custom tags representation filtering [\#4009](https://github.com/pypeclub/OpenPype/pull/4009) - Publisher: Create context has shared data for collection phase [\#3995](https://github.com/pypeclub/OpenPype/pull/3995) - Resolve: updating to v18 compatibility [\#3986](https://github.com/pypeclub/OpenPype/pull/3986) **🐛 Bug fixes** - TrayPublisher: Fix missing argument [\#4019](https://github.com/pypeclub/OpenPype/pull/4019) - General: Fix python 2 compatibility of ffmpeg and oiio tools discovery [\#4011](https://github.com/pypeclub/OpenPype/pull/4011) **🔀 Refactored code** - Maya: Removed unused imports [\#4008](https://github.com/pypeclub/OpenPype/pull/4008) - Unreal: Fix import of moved function [\#4007](https://github.com/pypeclub/OpenPype/pull/4007) - Houdini: Change import of RepairAction [\#4005](https://github.com/pypeclub/OpenPype/pull/4005) - Nuke/Hiero: Refactor openpype.api imports [\#4000](https://github.com/pypeclub/OpenPype/pull/4000) - TVPaint: Defined with HostBase [\#3994](https://github.com/pypeclub/OpenPype/pull/3994) **Merged pull requests:** - Unreal: Remove redundant Creator stub [\#4012](https://github.com/pypeclub/OpenPype/pull/4012) - Unreal: add `uproject` extension to Unreal project template [\#4004](https://github.com/pypeclub/OpenPype/pull/4004) - Unreal: fix order of includes [\#4002](https://github.com/pypeclub/OpenPype/pull/4002) - Fusion: Implement backwards compatibility \(+/- Fusion 17.2\) [\#3958](https://github.com/pypeclub/OpenPype/pull/3958) ## [3.14.4](https://github.com/pypeclub/OpenPype/tree/3.14.4) (2022-10-19) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.3...3.14.4) **🆕 New features** - Webpublisher: use max next published version number for all items in batch [\#3961](https://github.com/pypeclub/OpenPype/pull/3961) - General: Control Thumbnail integration via explicit configuration profiles [\#3951](https://github.com/pypeclub/OpenPype/pull/3951) **🚀 Enhancements** - Publisher: Multiselection in card view [\#3993](https://github.com/pypeclub/OpenPype/pull/3993) - TrayPublisher: Original Basename cause crash too early [\#3990](https://github.com/pypeclub/OpenPype/pull/3990) - Tray Publisher: add `originalBasename` data to simple creators [\#3988](https://github.com/pypeclub/OpenPype/pull/3988) - General: Custom paths to ffmpeg and OpenImageIO tools [\#3982](https://github.com/pypeclub/OpenPype/pull/3982) - Integrate: Preserve existing subset group if instance does not set it for new version [\#3976](https://github.com/pypeclub/OpenPype/pull/3976) - Publisher: Prepare publisher controller for remote publishing [\#3972](https://github.com/pypeclub/OpenPype/pull/3972) - Maya: new style dataclasses in maya deadline submitter plugin [\#3968](https://github.com/pypeclub/OpenPype/pull/3968) - Maya: Define preffered Qt bindings for Qt.py and qtpy [\#3963](https://github.com/pypeclub/OpenPype/pull/3963) - Settings: Move imageio from project anatomy to project settings \[pypeclub\] [\#3959](https://github.com/pypeclub/OpenPype/pull/3959) - TrayPublisher: Extract thumbnail for other families [\#3952](https://github.com/pypeclub/OpenPype/pull/3952) - Publisher: Pass instance to subset name method on update [\#3949](https://github.com/pypeclub/OpenPype/pull/3949) - General: Set root environments before DCC launch [\#3947](https://github.com/pypeclub/OpenPype/pull/3947) - Refactor: changed legacy way to update database for Hero version integrate [\#3941](https://github.com/pypeclub/OpenPype/pull/3941) - Maya: Moved plugin from global to maya [\#3939](https://github.com/pypeclub/OpenPype/pull/3939) - Publisher: Create dialog is part of main window [\#3936](https://github.com/pypeclub/OpenPype/pull/3936) - Fusion: Implement Alembic and FBX mesh loader [\#3927](https://github.com/pypeclub/OpenPype/pull/3927) **🐛 Bug fixes** - TrayPublisher: Disable sequences in batch mov creator [\#3996](https://github.com/pypeclub/OpenPype/pull/3996) - Fix - tags might be missing on representation [\#3985](https://github.com/pypeclub/OpenPype/pull/3985) - Resolve: Fix usage of functions from lib [\#3983](https://github.com/pypeclub/OpenPype/pull/3983) - Maya: remove invalid prefix token for non-multipart outputs [\#3981](https://github.com/pypeclub/OpenPype/pull/3981) - Ftrack: Fix schema cache for Python 2 [\#3980](https://github.com/pypeclub/OpenPype/pull/3980) - Maya: add object to attr.s declaration [\#3973](https://github.com/pypeclub/OpenPype/pull/3973) - Maya: Deadline OutputFilePath hack regression for Renderman [\#3950](https://github.com/pypeclub/OpenPype/pull/3950) - Houdini: Fix validate workfile paths for non-parm file references [\#3948](https://github.com/pypeclub/OpenPype/pull/3948) - Photoshop: missed sync published version of workfile with workfile [\#3946](https://github.com/pypeclub/OpenPype/pull/3946) - Maya: Set default value for RenderSetupIncludeLights option [\#3944](https://github.com/pypeclub/OpenPype/pull/3944) - Maya: fix regression of Renderman Deadline hack [\#3943](https://github.com/pypeclub/OpenPype/pull/3943) - Kitsu: 2 fixes, nb\_frames and Shot type error [\#3940](https://github.com/pypeclub/OpenPype/pull/3940) - Tray: Change order of attribute changes [\#3938](https://github.com/pypeclub/OpenPype/pull/3938) - AttributeDefs: Fix crashing multivalue of files widget [\#3937](https://github.com/pypeclub/OpenPype/pull/3937) - General: Fix links query on hero version [\#3900](https://github.com/pypeclub/OpenPype/pull/3900) - Publisher: Files Drag n Drop cleanup [\#3888](https://github.com/pypeclub/OpenPype/pull/3888) **🔀 Refactored code** - Flame: Import lib functions from lib [\#3992](https://github.com/pypeclub/OpenPype/pull/3992) - General: Fix deprecated warning in legacy creator [\#3978](https://github.com/pypeclub/OpenPype/pull/3978) - Blender: Remove openpype api imports [\#3977](https://github.com/pypeclub/OpenPype/pull/3977) - General: Use direct import of resources [\#3964](https://github.com/pypeclub/OpenPype/pull/3964) - General: Direct settings imports [\#3934](https://github.com/pypeclub/OpenPype/pull/3934) - General: import 'Logger' from 'openpype.lib' [\#3926](https://github.com/pypeclub/OpenPype/pull/3926) - General: Remove deprecated functions from lib [\#3907](https://github.com/pypeclub/OpenPype/pull/3907) **Merged pull requests:** - Maya + Yeti: Load Yeti Cache fix frame number recognition [\#3942](https://github.com/pypeclub/OpenPype/pull/3942) - Fusion: Implement callbacks to Fusion's event system thread [\#3928](https://github.com/pypeclub/OpenPype/pull/3928) - Photoshop: create single frame image in Ftrack as review [\#3908](https://github.com/pypeclub/OpenPype/pull/3908) ## [3.14.3](https://github.com/pypeclub/OpenPype/tree/3.14.3) (2022-10-03) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.2...3.14.3) **🚀 Enhancements** - Publisher: Enhancement proposals [\#3897](https://github.com/pypeclub/OpenPype/pull/3897) **🐛 Bug fixes** - Maya: Fix Render single camera validator [\#3929](https://github.com/pypeclub/OpenPype/pull/3929) - Flame: loading multilayer exr to batch/reel is working [\#3901](https://github.com/pypeclub/OpenPype/pull/3901) - Hiero: Fix inventory check on launch [\#3895](https://github.com/pypeclub/OpenPype/pull/3895) - WebPublisher: Fix import after refactor [\#3891](https://github.com/pypeclub/OpenPype/pull/3891) **🔀 Refactored code** - Maya: Remove unused 'openpype.api' imports in plugins [\#3925](https://github.com/pypeclub/OpenPype/pull/3925) - Resolve: Use new Extractor location [\#3918](https://github.com/pypeclub/OpenPype/pull/3918) - Unreal: Use new Extractor location [\#3917](https://github.com/pypeclub/OpenPype/pull/3917) - Flame: Use new Extractor location [\#3916](https://github.com/pypeclub/OpenPype/pull/3916) - Houdini: Use new Extractor location [\#3894](https://github.com/pypeclub/OpenPype/pull/3894) - Harmony: Use new Extractor location [\#3893](https://github.com/pypeclub/OpenPype/pull/3893) **Merged pull requests:** - Maya: Fix Scene Inventory possibly starting off-screen due to maya preferences [\#3923](https://github.com/pypeclub/OpenPype/pull/3923) ## [3.14.2](https://github.com/pypeclub/OpenPype/tree/3.14.2) (2022-09-12) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.1...3.14.2) ### 📖 Documentation - Documentation: Anatomy templates [\#3618](https://github.com/pypeclub/OpenPype/pull/3618) **🆕 New features** - Nuke: Build workfile by template [\#3763](https://github.com/pypeclub/OpenPype/pull/3763) - Houdini: Publishing workfiles [\#3697](https://github.com/pypeclub/OpenPype/pull/3697) - Global: making collect audio plugin global [\#3679](https://github.com/pypeclub/OpenPype/pull/3679) **🚀 Enhancements** - Flame: Adding Creator's retimed shot and handles switch [\#3826](https://github.com/pypeclub/OpenPype/pull/3826) - Flame: OpenPype submenu to batch and media manager [\#3825](https://github.com/pypeclub/OpenPype/pull/3825) - General: Better pixmap scaling [\#3809](https://github.com/pypeclub/OpenPype/pull/3809) - Photoshop: attempt to speed up ExtractImage [\#3793](https://github.com/pypeclub/OpenPype/pull/3793) - SyncServer: Added cli commands for sync server [\#3765](https://github.com/pypeclub/OpenPype/pull/3765) - Kitsu: Drop 'entities root' setting. [\#3739](https://github.com/pypeclub/OpenPype/pull/3739) - git: update gitignore [\#3722](https://github.com/pypeclub/OpenPype/pull/3722) - Blender: Publisher collect workfile representation [\#3670](https://github.com/pypeclub/OpenPype/pull/3670) - Maya: move set render settings menu entry [\#3669](https://github.com/pypeclub/OpenPype/pull/3669) - Scene Inventory: Maya add actions to select from or to scene [\#3659](https://github.com/pypeclub/OpenPype/pull/3659) - Scene Inventory: Add subsetGroup column [\#3658](https://github.com/pypeclub/OpenPype/pull/3658) **🐛 Bug fixes** - General: Fix Pattern access in client code [\#3828](https://github.com/pypeclub/OpenPype/pull/3828) - Launcher: Skip opening last work file works for groups [\#3822](https://github.com/pypeclub/OpenPype/pull/3822) - Maya: Publishing data key change [\#3811](https://github.com/pypeclub/OpenPype/pull/3811) - Igniter: Fix status handling when version is already installed [\#3804](https://github.com/pypeclub/OpenPype/pull/3804) - Resolve: Addon import is Python 2 compatible [\#3798](https://github.com/pypeclub/OpenPype/pull/3798) - Hiero: retimed clip publishing is working [\#3792](https://github.com/pypeclub/OpenPype/pull/3792) - nuke: validate write node is not failing due wrong type [\#3780](https://github.com/pypeclub/OpenPype/pull/3780) - Fix - changed format of version string in pyproject.toml [\#3777](https://github.com/pypeclub/OpenPype/pull/3777) - Ftrack status fix typo prgoress -\> progress [\#3761](https://github.com/pypeclub/OpenPype/pull/3761) - Fix version resolution [\#3757](https://github.com/pypeclub/OpenPype/pull/3757) - Maya: `containerise` dont skip empty values [\#3674](https://github.com/pypeclub/OpenPype/pull/3674) **🔀 Refactored code** - Photoshop: Use new Extractor location [\#3789](https://github.com/pypeclub/OpenPype/pull/3789) - Blender: Use new Extractor location [\#3787](https://github.com/pypeclub/OpenPype/pull/3787) - AfterEffects: Use new Extractor location [\#3784](https://github.com/pypeclub/OpenPype/pull/3784) - General: Remove unused teshost [\#3773](https://github.com/pypeclub/OpenPype/pull/3773) - General: Copied 'Extractor' plugin to publish pipeline [\#3771](https://github.com/pypeclub/OpenPype/pull/3771) - General: Move queries of asset and representation links [\#3770](https://github.com/pypeclub/OpenPype/pull/3770) - General: Move create project folders to pipeline [\#3768](https://github.com/pypeclub/OpenPype/pull/3768) - General: Create project function moved to client code [\#3766](https://github.com/pypeclub/OpenPype/pull/3766) - Maya: Refactor submit deadline to use AbstractSubmitDeadline [\#3759](https://github.com/pypeclub/OpenPype/pull/3759) - General: Change publish template settings location [\#3755](https://github.com/pypeclub/OpenPype/pull/3755) - General: Move hostdirname functionality into host [\#3749](https://github.com/pypeclub/OpenPype/pull/3749) - General: Move publish utils to pipeline [\#3745](https://github.com/pypeclub/OpenPype/pull/3745) - Houdini: Define houdini as addon [\#3735](https://github.com/pypeclub/OpenPype/pull/3735) - Fusion: Defined fusion as addon [\#3733](https://github.com/pypeclub/OpenPype/pull/3733) - Flame: Defined flame as addon [\#3732](https://github.com/pypeclub/OpenPype/pull/3732) - Resolve: Define resolve as addon [\#3727](https://github.com/pypeclub/OpenPype/pull/3727) **Merged pull requests:** - Standalone Publisher: Ignore empty labels, then still use name like other asset models [\#3779](https://github.com/pypeclub/OpenPype/pull/3779) - Kitsu - sync\_all\_project - add list ignore\_projects [\#3776](https://github.com/pypeclub/OpenPype/pull/3776) ## [3.14.1](https://github.com/pypeclub/OpenPype/tree/3.14.1) (2022-08-30) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.0...3.14.1) ### 📖 Documentation - Documentation: Few updates [\#3698](https://github.com/pypeclub/OpenPype/pull/3698) - Documentation: Settings development [\#3660](https://github.com/pypeclub/OpenPype/pull/3660) **🆕 New features** - Webpublisher:change create flatten image into tri state [\#3678](https://github.com/pypeclub/OpenPype/pull/3678) - Blender: validators code correction with settings and defaults [\#3662](https://github.com/pypeclub/OpenPype/pull/3662) **🚀 Enhancements** - General: Thumbnail can use project roots [\#3750](https://github.com/pypeclub/OpenPype/pull/3750) - Settings: Remove settings lock on tray exit [\#3720](https://github.com/pypeclub/OpenPype/pull/3720) - General: Added helper getters to modules manager [\#3712](https://github.com/pypeclub/OpenPype/pull/3712) - Unreal: Define unreal as module and use host class [\#3701](https://github.com/pypeclub/OpenPype/pull/3701) - Settings: Lock settings UI session [\#3700](https://github.com/pypeclub/OpenPype/pull/3700) - General: Benevolent context label collector [\#3686](https://github.com/pypeclub/OpenPype/pull/3686) - Ftrack: Store ftrack entities on hierarchy integration to instances [\#3677](https://github.com/pypeclub/OpenPype/pull/3677) - Ftrack: More logs related to auto sync value change [\#3671](https://github.com/pypeclub/OpenPype/pull/3671) - Blender: ops refresh manager after process events [\#3663](https://github.com/pypeclub/OpenPype/pull/3663) **🐛 Bug fixes** - Maya: Fix typo in getPanel argument `with_focus` -\> `withFocus` [\#3753](https://github.com/pypeclub/OpenPype/pull/3753) - General: Smaller fixes of imports [\#3748](https://github.com/pypeclub/OpenPype/pull/3748) - General: Logger tweaks [\#3741](https://github.com/pypeclub/OpenPype/pull/3741) - Nuke: missing job dependency if multiple bake streams [\#3737](https://github.com/pypeclub/OpenPype/pull/3737) - Nuke: color-space settings from anatomy is working [\#3721](https://github.com/pypeclub/OpenPype/pull/3721) - Settings: Fix studio default anatomy save [\#3716](https://github.com/pypeclub/OpenPype/pull/3716) - Maya: Use project name instead of project code [\#3709](https://github.com/pypeclub/OpenPype/pull/3709) - Settings: Fix project overrides save [\#3708](https://github.com/pypeclub/OpenPype/pull/3708) - Workfiles tool: Fix published workfile filtering [\#3704](https://github.com/pypeclub/OpenPype/pull/3704) - PS, AE: Provide default variant value for workfile subset [\#3703](https://github.com/pypeclub/OpenPype/pull/3703) - RoyalRender: handle host name that is not set [\#3695](https://github.com/pypeclub/OpenPype/pull/3695) - Flame: retime is working on clip publishing [\#3684](https://github.com/pypeclub/OpenPype/pull/3684) - Webpublisher: added check for empty context [\#3682](https://github.com/pypeclub/OpenPype/pull/3682) **🔀 Refactored code** - General: Move delivery logic to pipeline [\#3751](https://github.com/pypeclub/OpenPype/pull/3751) - General: Host addons cleanup [\#3744](https://github.com/pypeclub/OpenPype/pull/3744) - Webpublisher: Webpublisher is used as addon [\#3740](https://github.com/pypeclub/OpenPype/pull/3740) - Photoshop: Defined photoshop as addon [\#3736](https://github.com/pypeclub/OpenPype/pull/3736) - Harmony: Defined harmony as addon [\#3734](https://github.com/pypeclub/OpenPype/pull/3734) - General: Module interfaces cleanup [\#3731](https://github.com/pypeclub/OpenPype/pull/3731) - AfterEffects: Move AE functions from general lib [\#3730](https://github.com/pypeclub/OpenPype/pull/3730) - Blender: Define blender as module [\#3729](https://github.com/pypeclub/OpenPype/pull/3729) - AfterEffects: Define AfterEffects as module [\#3728](https://github.com/pypeclub/OpenPype/pull/3728) - General: Replace PypeLogger with Logger [\#3725](https://github.com/pypeclub/OpenPype/pull/3725) - Nuke: Define nuke as module [\#3724](https://github.com/pypeclub/OpenPype/pull/3724) - General: Move subset name functionality [\#3723](https://github.com/pypeclub/OpenPype/pull/3723) - General: Move creators plugin getter [\#3714](https://github.com/pypeclub/OpenPype/pull/3714) - General: Move constants from lib to client [\#3713](https://github.com/pypeclub/OpenPype/pull/3713) - Loader: Subset groups using client operations [\#3710](https://github.com/pypeclub/OpenPype/pull/3710) - TVPaint: Defined as module [\#3707](https://github.com/pypeclub/OpenPype/pull/3707) - StandalonePublisher: Define StandalonePublisher as module [\#3706](https://github.com/pypeclub/OpenPype/pull/3706) - TrayPublisher: Define TrayPublisher as module [\#3705](https://github.com/pypeclub/OpenPype/pull/3705) - General: Move context specific functions to context tools [\#3702](https://github.com/pypeclub/OpenPype/pull/3702) **Merged pull requests:** - Hiero: Define hiero as module [\#3717](https://github.com/pypeclub/OpenPype/pull/3717) - Deadline: better logging for DL webservice failures [\#3694](https://github.com/pypeclub/OpenPype/pull/3694) - Photoshop: resize saved images in ExtractReview for ffmpeg [\#3676](https://github.com/pypeclub/OpenPype/pull/3676) - Nuke: Validation refactory to new publisher [\#3567](https://github.com/pypeclub/OpenPype/pull/3567) ## [3.14.0](https://github.com/pypeclub/OpenPype/tree/3.14.0) (2022-08-18) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.13.0...3.14.0) **🆕 New features** - Maya: Build workfile by template [\#3578](https://github.com/pypeclub/OpenPype/pull/3578) - Maya: Implementation of JSON layout for Unreal workflow [\#3353](https://github.com/pypeclub/OpenPype/pull/3353) - Maya: Build workfile by template [\#3315](https://github.com/pypeclub/OpenPype/pull/3315) **🚀 Enhancements** - Ftrack: Addiotional component metadata [\#3685](https://github.com/pypeclub/OpenPype/pull/3685) - Ftrack: Set task status on farm publishing [\#3680](https://github.com/pypeclub/OpenPype/pull/3680) - Ftrack: Set task status on task creation in integrate hierarchy [\#3675](https://github.com/pypeclub/OpenPype/pull/3675) - Maya: Disable rendering of all lights for render instances submitted through Deadline. [\#3661](https://github.com/pypeclub/OpenPype/pull/3661) - General: Optimized OCIO configs [\#3650](https://github.com/pypeclub/OpenPype/pull/3650) **🐛 Bug fixes** - General: Switch from hero version to versioned works [\#3691](https://github.com/pypeclub/OpenPype/pull/3691) - General: Fix finding of last version [\#3656](https://github.com/pypeclub/OpenPype/pull/3656) - General: Extract Review can scale with pixel aspect ratio [\#3644](https://github.com/pypeclub/OpenPype/pull/3644) - Maya: Refactor moved usage of CreateRender settings [\#3643](https://github.com/pypeclub/OpenPype/pull/3643) - General: Hero version representations have full context [\#3638](https://github.com/pypeclub/OpenPype/pull/3638) - Nuke: color settings for render write node is working now [\#3632](https://github.com/pypeclub/OpenPype/pull/3632) - Maya: FBX support for update in reference loader [\#3631](https://github.com/pypeclub/OpenPype/pull/3631) **🔀 Refactored code** - General: Use client projects getter [\#3673](https://github.com/pypeclub/OpenPype/pull/3673) - Resolve: Match folder structure to other hosts [\#3653](https://github.com/pypeclub/OpenPype/pull/3653) - Maya: Hosts as modules [\#3647](https://github.com/pypeclub/OpenPype/pull/3647) - TimersManager: Plugins are in timers manager module [\#3639](https://github.com/pypeclub/OpenPype/pull/3639) - General: Move workfiles functions into pipeline [\#3637](https://github.com/pypeclub/OpenPype/pull/3637) - General: Workfiles builder using query functions [\#3598](https://github.com/pypeclub/OpenPype/pull/3598) **Merged pull requests:** - Deadline: Global job pre load is not Pype 2 compatible [\#3666](https://github.com/pypeclub/OpenPype/pull/3666) - Maya: Remove unused get current renderer logic [\#3645](https://github.com/pypeclub/OpenPype/pull/3645) - Kitsu|Fix: Movie project type fails & first loop children names [\#3636](https://github.com/pypeclub/OpenPype/pull/3636) - fix the bug of failing to extract look when UDIMs format used in AiImage [\#3628](https://github.com/pypeclub/OpenPype/pull/3628) ## [3.13.0](https://github.com/pypeclub/OpenPype/tree/3.13.0) (2022-08-09) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.12.2...3.13.0) **🆕 New features** - Support for mutliple installed versions - 3.13 [\#3605](https://github.com/pypeclub/OpenPype/pull/3605) - Traypublisher: simple editorial publishing [\#3492](https://github.com/pypeclub/OpenPype/pull/3492) **🚀 Enhancements** - Editorial: Mix audio use side file for ffmpeg filters [\#3630](https://github.com/pypeclub/OpenPype/pull/3630) - Ftrack: Comment template can contain optional keys [\#3615](https://github.com/pypeclub/OpenPype/pull/3615) - Ftrack: Add more metadata to ftrack components [\#3612](https://github.com/pypeclub/OpenPype/pull/3612) - General: Add context to pyblish context [\#3594](https://github.com/pypeclub/OpenPype/pull/3594) - Kitsu: Shot&Sequence name with prefix over appends [\#3593](https://github.com/pypeclub/OpenPype/pull/3593) - Photoshop: implemented {layer} placeholder in subset template [\#3591](https://github.com/pypeclub/OpenPype/pull/3591) - General: Python module appdirs from git [\#3589](https://github.com/pypeclub/OpenPype/pull/3589) - Ftrack: Update ftrack api to 2.3.3 [\#3588](https://github.com/pypeclub/OpenPype/pull/3588) - General: New Integrator small fixes [\#3583](https://github.com/pypeclub/OpenPype/pull/3583) - Maya: Render Creator has configurable options. [\#3097](https://github.com/pypeclub/OpenPype/pull/3097) **🐛 Bug fixes** - Maya: fix aov separator in Redshift [\#3625](https://github.com/pypeclub/OpenPype/pull/3625) - Fix for multi-version build on Mac [\#3622](https://github.com/pypeclub/OpenPype/pull/3622) - Ftrack: Sync hierarchical attributes can handle new created entities [\#3621](https://github.com/pypeclub/OpenPype/pull/3621) - General: Extract review aspect ratio scale is calculated by ffmpeg [\#3620](https://github.com/pypeclub/OpenPype/pull/3620) - Maya: Fix types of default settings [\#3617](https://github.com/pypeclub/OpenPype/pull/3617) - Integrator: Don't force to have dot before frame [\#3611](https://github.com/pypeclub/OpenPype/pull/3611) - AfterEffects: refactored integrate doesnt work formulti frame publishes [\#3610](https://github.com/pypeclub/OpenPype/pull/3610) - Maya look data contents fails with custom attribute on group [\#3607](https://github.com/pypeclub/OpenPype/pull/3607) - TrayPublisher: Fix wrong conflict merge [\#3600](https://github.com/pypeclub/OpenPype/pull/3600) - Bugfix: Add OCIO as submodule to prepare for handling `maketx` color space conversion. [\#3590](https://github.com/pypeclub/OpenPype/pull/3590) - Fix general settings environment variables resolution [\#3587](https://github.com/pypeclub/OpenPype/pull/3587) - Editorial publishing workflow improvements [\#3580](https://github.com/pypeclub/OpenPype/pull/3580) - General: Update imports in start script [\#3579](https://github.com/pypeclub/OpenPype/pull/3579) - Nuke: render family integration consistency [\#3576](https://github.com/pypeclub/OpenPype/pull/3576) - Ftrack: Handle missing published path in integrator [\#3570](https://github.com/pypeclub/OpenPype/pull/3570) - Nuke: publish existing frames with slate with correct range [\#3555](https://github.com/pypeclub/OpenPype/pull/3555) **🔀 Refactored code** - General: Plugin settings handled by plugins [\#3623](https://github.com/pypeclub/OpenPype/pull/3623) - General: Naive implementation of document create, update, delete [\#3601](https://github.com/pypeclub/OpenPype/pull/3601) - General: Use query functions in general code [\#3596](https://github.com/pypeclub/OpenPype/pull/3596) - General: Separate extraction of template data into more functions [\#3574](https://github.com/pypeclub/OpenPype/pull/3574) - General: Lib cleanup [\#3571](https://github.com/pypeclub/OpenPype/pull/3571) **Merged pull requests:** - Webpublisher: timeout for PS studio processing [\#3619](https://github.com/pypeclub/OpenPype/pull/3619) - Core: translated validate\_containers.py into New publisher style [\#3614](https://github.com/pypeclub/OpenPype/pull/3614) - Enable write color sets on animation publish automatically [\#3582](https://github.com/pypeclub/OpenPype/pull/3582) ## [3.12.2](https://github.com/pypeclub/OpenPype/tree/3.12.2) (2022-07-27) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.12.1...3.12.2) ### 📖 Documentation - Update website with more studios [\#3554](https://github.com/pypeclub/OpenPype/pull/3554) - Documentation: Update publishing dev docs [\#3549](https://github.com/pypeclub/OpenPype/pull/3549) **🚀 Enhancements** - General: Global thumbnail extractor is ready for more cases [\#3561](https://github.com/pypeclub/OpenPype/pull/3561) - Maya: add additional validators to Settings [\#3540](https://github.com/pypeclub/OpenPype/pull/3540) - General: Interactive console in cli [\#3526](https://github.com/pypeclub/OpenPype/pull/3526) - Ftrack: Automatic daily review session creation can define trigger hour [\#3516](https://github.com/pypeclub/OpenPype/pull/3516) - Ftrack: add source into Note [\#3509](https://github.com/pypeclub/OpenPype/pull/3509) - Ftrack: Trigger custom ftrack topic of project structure creation [\#3506](https://github.com/pypeclub/OpenPype/pull/3506) - Settings UI: Add extract to file action on project view [\#3505](https://github.com/pypeclub/OpenPype/pull/3505) - Add pack and unpack convenience scripts [\#3502](https://github.com/pypeclub/OpenPype/pull/3502) - General: Event system [\#3499](https://github.com/pypeclub/OpenPype/pull/3499) - NewPublisher: Keep plugins with mismatch target in report [\#3498](https://github.com/pypeclub/OpenPype/pull/3498) - Nuke: load clip with options from settings [\#3497](https://github.com/pypeclub/OpenPype/pull/3497) - TrayPublisher: implemented render\_mov\_batch [\#3486](https://github.com/pypeclub/OpenPype/pull/3486) - Migrate basic families to the new Tray Publisher [\#3469](https://github.com/pypeclub/OpenPype/pull/3469) - Enhance powershell build scripts [\#1827](https://github.com/pypeclub/OpenPype/pull/1827) **🐛 Bug fixes** - Maya: fix Review image plane attribute [\#3569](https://github.com/pypeclub/OpenPype/pull/3569) - Maya: Fix animated attributes \(ie. overscan\) on loaded cameras breaking review publishing. [\#3562](https://github.com/pypeclub/OpenPype/pull/3562) - NewPublisher: Python 2 compatible html escape [\#3559](https://github.com/pypeclub/OpenPype/pull/3559) - Remove invalid submodules from `/vendor` [\#3557](https://github.com/pypeclub/OpenPype/pull/3557) - General: Remove hosts filter on integrator plugins [\#3556](https://github.com/pypeclub/OpenPype/pull/3556) - Settings: Clean default values of environments [\#3550](https://github.com/pypeclub/OpenPype/pull/3550) - Module interfaces: Fix import error [\#3547](https://github.com/pypeclub/OpenPype/pull/3547) - Workfiles tool: Show of tool and it's flags [\#3539](https://github.com/pypeclub/OpenPype/pull/3539) - General: Create workfile documents works again [\#3538](https://github.com/pypeclub/OpenPype/pull/3538) - Additional fixes for powershell scripts [\#3525](https://github.com/pypeclub/OpenPype/pull/3525) - Maya: Added wrapper around cmds.setAttr [\#3523](https://github.com/pypeclub/OpenPype/pull/3523) - Nuke: double slate [\#3521](https://github.com/pypeclub/OpenPype/pull/3521) - General: Fix hash of centos oiio archive [\#3519](https://github.com/pypeclub/OpenPype/pull/3519) - Maya: Renderman display output fix [\#3514](https://github.com/pypeclub/OpenPype/pull/3514) - TrayPublisher: Simple creation enhancements and fixes [\#3513](https://github.com/pypeclub/OpenPype/pull/3513) - NewPublisher: Publish attributes are properly collected [\#3510](https://github.com/pypeclub/OpenPype/pull/3510) - TrayPublisher: Make sure host name is filled [\#3504](https://github.com/pypeclub/OpenPype/pull/3504) - NewPublisher: Groups work and enum multivalue [\#3501](https://github.com/pypeclub/OpenPype/pull/3501) **🔀 Refactored code** - General: Use query functions in integrator [\#3563](https://github.com/pypeclub/OpenPype/pull/3563) - General: Mongo core connection moved to client [\#3531](https://github.com/pypeclub/OpenPype/pull/3531) - Refactor Integrate Asset [\#3530](https://github.com/pypeclub/OpenPype/pull/3530) - General: Client docstrings cleanup [\#3529](https://github.com/pypeclub/OpenPype/pull/3529) - General: Move load related functions into pipeline [\#3527](https://github.com/pypeclub/OpenPype/pull/3527) - General: Get current context document functions [\#3522](https://github.com/pypeclub/OpenPype/pull/3522) - Kitsu: Use query function from client [\#3496](https://github.com/pypeclub/OpenPype/pull/3496) - TimersManager: Use query functions [\#3495](https://github.com/pypeclub/OpenPype/pull/3495) - Deadline: Use query functions [\#3466](https://github.com/pypeclub/OpenPype/pull/3466) - Refactor Integrate Asset [\#2898](https://github.com/pypeclub/OpenPype/pull/2898) **Merged pull requests:** - Maya: fix active pane loss [\#3566](https://github.com/pypeclub/OpenPype/pull/3566) ## [3.12.1](https://github.com/pypeclub/OpenPype/tree/3.12.1) (2022-07-13) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.12.0...3.12.1) ### 📖 Documentation - Docs: Added minimal permissions for MongoDB [\#3441](https://github.com/pypeclub/OpenPype/pull/3441) **🆕 New features** - Maya: Add VDB to Arnold loader [\#3433](https://github.com/pypeclub/OpenPype/pull/3433) **🚀 Enhancements** - TrayPublisher: Added more options for grouping of instances [\#3494](https://github.com/pypeclub/OpenPype/pull/3494) - NewPublisher: Align creator attributes from top to bottom [\#3487](https://github.com/pypeclub/OpenPype/pull/3487) - NewPublisher: Added ability to use label of instance [\#3484](https://github.com/pypeclub/OpenPype/pull/3484) - General: Creator Plugins have access to project [\#3476](https://github.com/pypeclub/OpenPype/pull/3476) - General: Better arguments order in creator init [\#3475](https://github.com/pypeclub/OpenPype/pull/3475) - Ftrack: Trigger custom ftrack events on project creation and preparation [\#3465](https://github.com/pypeclub/OpenPype/pull/3465) - Windows installer: Clean old files and add version subfolder [\#3445](https://github.com/pypeclub/OpenPype/pull/3445) - Blender: Bugfix - Set fps properly on open [\#3426](https://github.com/pypeclub/OpenPype/pull/3426) - Hiero: Add custom scripts menu [\#3425](https://github.com/pypeclub/OpenPype/pull/3425) - Blender: pre pyside install for all platforms [\#3400](https://github.com/pypeclub/OpenPype/pull/3400) - Maya: Add additional playblast options to review Extractor. [\#3384](https://github.com/pypeclub/OpenPype/pull/3384) - Maya: Ability to set resolution for playblasts from asset, and override through review instance. [\#3360](https://github.com/pypeclub/OpenPype/pull/3360) - Maya: Redshift Volume Loader Implement update, remove, switch + fix vdb sequence support [\#3197](https://github.com/pypeclub/OpenPype/pull/3197) - Maya: Implement `iter_visible_nodes_in_range` for extracting Alembics [\#3100](https://github.com/pypeclub/OpenPype/pull/3100) **🐛 Bug fixes** - TrayPublisher: Keep use instance label in list view [\#3493](https://github.com/pypeclub/OpenPype/pull/3493) - General: Extract review use first frame of input sequence [\#3491](https://github.com/pypeclub/OpenPype/pull/3491) - General: Fix Plist loading for application launch [\#3485](https://github.com/pypeclub/OpenPype/pull/3485) - Nuke: Workfile tools open on start [\#3479](https://github.com/pypeclub/OpenPype/pull/3479) - New Publisher: Disabled context change allows creation [\#3478](https://github.com/pypeclub/OpenPype/pull/3478) - General: thumbnail extractor fix [\#3474](https://github.com/pypeclub/OpenPype/pull/3474) - Kitsu: bugfix with sync-service ans publish plugins [\#3473](https://github.com/pypeclub/OpenPype/pull/3473) - Flame: solved problem with multi-selected loading [\#3470](https://github.com/pypeclub/OpenPype/pull/3470) - General: Fix query function in update logic [\#3468](https://github.com/pypeclub/OpenPype/pull/3468) - Resolve: removed few bugs [\#3464](https://github.com/pypeclub/OpenPype/pull/3464) - General: Delete old versions is safer when ftrack is disabled [\#3462](https://github.com/pypeclub/OpenPype/pull/3462) - Nuke: fixing metadata slate TC difference [\#3455](https://github.com/pypeclub/OpenPype/pull/3455) - Nuke: prerender reviewable fails [\#3450](https://github.com/pypeclub/OpenPype/pull/3450) - Maya: fix hashing in Python 3 for tile rendering [\#3447](https://github.com/pypeclub/OpenPype/pull/3447) - LogViewer: Escape html characters in log message [\#3443](https://github.com/pypeclub/OpenPype/pull/3443) - Nuke: Slate frame is integrated [\#3427](https://github.com/pypeclub/OpenPype/pull/3427) - Maya: Camera extra data - additional fix for \#3304 [\#3386](https://github.com/pypeclub/OpenPype/pull/3386) - Maya: Handle excluding `model` family from frame range validator. [\#3370](https://github.com/pypeclub/OpenPype/pull/3370) **🔀 Refactored code** - Maya: Merge animation + pointcache extractor logic [\#3461](https://github.com/pypeclub/OpenPype/pull/3461) - Maya: Re-use `maintained_time` from lib [\#3460](https://github.com/pypeclub/OpenPype/pull/3460) - General: Use query functions in global plugins [\#3459](https://github.com/pypeclub/OpenPype/pull/3459) - Clockify: Use query functions in clockify actions [\#3458](https://github.com/pypeclub/OpenPype/pull/3458) - General: Use query functions in rest api calls [\#3457](https://github.com/pypeclub/OpenPype/pull/3457) - General: Use query functions in openpype lib functions [\#3454](https://github.com/pypeclub/OpenPype/pull/3454) - General: Use query functions in load utils [\#3446](https://github.com/pypeclub/OpenPype/pull/3446) - General: Move publish plugin and publish render abstractions [\#3442](https://github.com/pypeclub/OpenPype/pull/3442) - General: Use Anatomy after move to pipeline [\#3436](https://github.com/pypeclub/OpenPype/pull/3436) - General: Anatomy moved to pipeline [\#3435](https://github.com/pypeclub/OpenPype/pull/3435) - Fusion: Use client query functions [\#3380](https://github.com/pypeclub/OpenPype/pull/3380) - Resolve: Use client query functions [\#3379](https://github.com/pypeclub/OpenPype/pull/3379) - General: Host implementation defined with class [\#3337](https://github.com/pypeclub/OpenPype/pull/3337) ## [3.12.0](https://github.com/pypeclub/OpenPype/tree/3.12.0) (2022-06-28) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.11.1...3.12.0) ### 📖 Documentation - Fix typo in documentation: pyenv on mac [\#3417](https://github.com/pypeclub/OpenPype/pull/3417) - Linux: update OIIO package [\#3401](https://github.com/pypeclub/OpenPype/pull/3401) **🆕 New features** - Shotgrid: Add production beta of shotgrid integration [\#2921](https://github.com/pypeclub/OpenPype/pull/2921) **🚀 Enhancements** - Webserver: Added CORS middleware [\#3422](https://github.com/pypeclub/OpenPype/pull/3422) - Attribute Defs UI: Files widget show what is allowed to drop in [\#3411](https://github.com/pypeclub/OpenPype/pull/3411) - General: Add ability to change user value for templates [\#3366](https://github.com/pypeclub/OpenPype/pull/3366) - Hosts: More options for in-host callbacks [\#3357](https://github.com/pypeclub/OpenPype/pull/3357) - Multiverse: expose some settings to GUI [\#3350](https://github.com/pypeclub/OpenPype/pull/3350) - Maya: Allow more data to be published along camera 🎥 [\#3304](https://github.com/pypeclub/OpenPype/pull/3304) - Add root keys and project keys to create starting folder [\#2755](https://github.com/pypeclub/OpenPype/pull/2755) **🐛 Bug fixes** - NewPublisher: Fix subset name change on change of creator plugin [\#3420](https://github.com/pypeclub/OpenPype/pull/3420) - Bug: fix invalid avalon import [\#3418](https://github.com/pypeclub/OpenPype/pull/3418) - Nuke: Fix keyword argument in query function [\#3414](https://github.com/pypeclub/OpenPype/pull/3414) - Houdini: fix loading and updating vbd/bgeo sequences [\#3408](https://github.com/pypeclub/OpenPype/pull/3408) - Nuke: Collect representation files based on Write [\#3407](https://github.com/pypeclub/OpenPype/pull/3407) - General: Filter representations before integration start [\#3398](https://github.com/pypeclub/OpenPype/pull/3398) - Maya: look collector typo [\#3392](https://github.com/pypeclub/OpenPype/pull/3392) - TVPaint: Make sure exit code is set to not None [\#3382](https://github.com/pypeclub/OpenPype/pull/3382) - Maya: vray device aspect ratio fix [\#3381](https://github.com/pypeclub/OpenPype/pull/3381) - Flame: bunch of publishing issues [\#3377](https://github.com/pypeclub/OpenPype/pull/3377) - Harmony: added unc path to zifile command in Harmony [\#3372](https://github.com/pypeclub/OpenPype/pull/3372) - Standalone: settings improvements [\#3355](https://github.com/pypeclub/OpenPype/pull/3355) - Nuke: Load full model hierarchy by default [\#3328](https://github.com/pypeclub/OpenPype/pull/3328) - Nuke: multiple baking streams with correct slate [\#3245](https://github.com/pypeclub/OpenPype/pull/3245) - Maya: fix image prefix warning in validator [\#3128](https://github.com/pypeclub/OpenPype/pull/3128) **🔀 Refactored code** - Unreal: Use client query functions [\#3421](https://github.com/pypeclub/OpenPype/pull/3421) - General: Move editorial lib to pipeline [\#3419](https://github.com/pypeclub/OpenPype/pull/3419) - Kitsu: renaming to plural func sync\_all\_projects [\#3397](https://github.com/pypeclub/OpenPype/pull/3397) - Houdini: Use client query functions [\#3395](https://github.com/pypeclub/OpenPype/pull/3395) - Hiero: Use client query functions [\#3393](https://github.com/pypeclub/OpenPype/pull/3393) - Nuke: Use client query functions [\#3391](https://github.com/pypeclub/OpenPype/pull/3391) - Maya: Use client query functions [\#3385](https://github.com/pypeclub/OpenPype/pull/3385) - Harmony: Use client query functions [\#3378](https://github.com/pypeclub/OpenPype/pull/3378) - Celaction: Use client query functions [\#3376](https://github.com/pypeclub/OpenPype/pull/3376) - Photoshop: Use client query functions [\#3375](https://github.com/pypeclub/OpenPype/pull/3375) - AfterEffects: Use client query functions [\#3374](https://github.com/pypeclub/OpenPype/pull/3374) - TVPaint: Use client query functions [\#3340](https://github.com/pypeclub/OpenPype/pull/3340) - Ftrack: Use client query functions [\#3339](https://github.com/pypeclub/OpenPype/pull/3339) - Standalone Publisher: Use client query functions [\#3330](https://github.com/pypeclub/OpenPype/pull/3330) **Merged pull requests:** - Sync Queue: Added far future value for null values for dates [\#3371](https://github.com/pypeclub/OpenPype/pull/3371) - Maya - added support for single frame playblast review [\#3369](https://github.com/pypeclub/OpenPype/pull/3369) - Houdini: Implement Redshift Proxy Export [\#3196](https://github.com/pypeclub/OpenPype/pull/3196) ## [3.11.1](https://github.com/pypeclub/OpenPype/tree/3.11.1) (2022-06-20) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.11.0...3.11.1) **🆕 New features** - Flame: custom export temp folder [\#3346](https://github.com/pypeclub/OpenPype/pull/3346) - Nuke: removing third-party plugins [\#3344](https://github.com/pypeclub/OpenPype/pull/3344) **🚀 Enhancements** - Pyblish Pype: Hiding/Close issues [\#3367](https://github.com/pypeclub/OpenPype/pull/3367) - Ftrack: Removed requirement of pypeclub role from default settings [\#3354](https://github.com/pypeclub/OpenPype/pull/3354) - Kitsu: Prevent crash on missing frames information [\#3352](https://github.com/pypeclub/OpenPype/pull/3352) - Ftrack: Open browser from tray [\#3320](https://github.com/pypeclub/OpenPype/pull/3320) - Enhancement: More control over thumbnail processing. [\#3259](https://github.com/pypeclub/OpenPype/pull/3259) **🐛 Bug fixes** - Nuke: bake streams with slate on farm [\#3368](https://github.com/pypeclub/OpenPype/pull/3368) - Harmony: audio validator has wrong logic [\#3364](https://github.com/pypeclub/OpenPype/pull/3364) - Nuke: Fix missing variable in extract thumbnail [\#3363](https://github.com/pypeclub/OpenPype/pull/3363) - Nuke: Fix precollect writes [\#3361](https://github.com/pypeclub/OpenPype/pull/3361) - AE- fix validate\_scene\_settings and renderLocal [\#3358](https://github.com/pypeclub/OpenPype/pull/3358) - deadline: fixing misidentification of revieables [\#3356](https://github.com/pypeclub/OpenPype/pull/3356) - General: Create only one thumbnail per instance [\#3351](https://github.com/pypeclub/OpenPype/pull/3351) - nuke: adding extract thumbnail settings 3.10 [\#3347](https://github.com/pypeclub/OpenPype/pull/3347) - General: Fix last version function [\#3345](https://github.com/pypeclub/OpenPype/pull/3345) - Deadline: added OPENPYPE\_MONGO to filter [\#3336](https://github.com/pypeclub/OpenPype/pull/3336) - Nuke: fixing farm publishing if review is disabled [\#3306](https://github.com/pypeclub/OpenPype/pull/3306) - Maya: Fix Yeti errors on Create, Publish and Load [\#3198](https://github.com/pypeclub/OpenPype/pull/3198) **🔀 Refactored code** - Webpublisher: Use client query functions [\#3333](https://github.com/pypeclub/OpenPype/pull/3333) ## [3.11.0](https://github.com/pypeclub/OpenPype/tree/3.11.0) (2022-06-17) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.10.0...3.11.0) ### 📖 Documentation - Documentation: Add app key to template documentation [\#3299](https://github.com/pypeclub/OpenPype/pull/3299) - doc: adding royal render and multiverse to the web site [\#3285](https://github.com/pypeclub/OpenPype/pull/3285) - Module: Kitsu module [\#2650](https://github.com/pypeclub/OpenPype/pull/2650) **🆕 New features** - Multiverse: fixed composition write, full docs, cosmetics [\#3178](https://github.com/pypeclub/OpenPype/pull/3178) **🚀 Enhancements** - Settings: Settings can be extracted from UI [\#3323](https://github.com/pypeclub/OpenPype/pull/3323) - updated poetry installation source [\#3316](https://github.com/pypeclub/OpenPype/pull/3316) - Ftrack: Action to easily create daily review session [\#3310](https://github.com/pypeclub/OpenPype/pull/3310) - TVPaint: Extractor use mark in/out range to render [\#3309](https://github.com/pypeclub/OpenPype/pull/3309) - Ftrack: Delivery action can work on ReviewSessions [\#3307](https://github.com/pypeclub/OpenPype/pull/3307) - Maya: Look assigner UI improvements [\#3298](https://github.com/pypeclub/OpenPype/pull/3298) - Ftrack: Action to transfer values of hierarchical attributes [\#3284](https://github.com/pypeclub/OpenPype/pull/3284) - Maya: better handling of legacy review subsets names [\#3269](https://github.com/pypeclub/OpenPype/pull/3269) - General: Updated windows oiio tool [\#3268](https://github.com/pypeclub/OpenPype/pull/3268) - Unreal: add support for skeletalMesh and staticMesh to loaders [\#3267](https://github.com/pypeclub/OpenPype/pull/3267) - Maya: reference loaders could store placeholder in referenced url [\#3264](https://github.com/pypeclub/OpenPype/pull/3264) - TVPaint: Init file for TVPaint worker also handle guideline images [\#3250](https://github.com/pypeclub/OpenPype/pull/3250) - Nuke: Change default icon path in settings [\#3247](https://github.com/pypeclub/OpenPype/pull/3247) - Maya: publishing of animation and pointcache on a farm [\#3225](https://github.com/pypeclub/OpenPype/pull/3225) - Maya: Look assigner UI improvements [\#3208](https://github.com/pypeclub/OpenPype/pull/3208) - Nuke: add pointcache and animation to loader [\#3186](https://github.com/pypeclub/OpenPype/pull/3186) - Nuke: Add a gizmo menu [\#3172](https://github.com/pypeclub/OpenPype/pull/3172) - Support for Unreal 5 [\#3122](https://github.com/pypeclub/OpenPype/pull/3122) **🐛 Bug fixes** - General: Handle empty source key on instance [\#3342](https://github.com/pypeclub/OpenPype/pull/3342) - Houdini: Fix Houdini VDB manage update wrong file attribute name [\#3322](https://github.com/pypeclub/OpenPype/pull/3322) - Nuke: anatomy compatibility issue hacks [\#3321](https://github.com/pypeclub/OpenPype/pull/3321) - hiero: otio p3 compatibility issue - metadata on effect use update 3.11 [\#3314](https://github.com/pypeclub/OpenPype/pull/3314) - General: Vendorized modules for Python 2 and update poetry lock [\#3305](https://github.com/pypeclub/OpenPype/pull/3305) - Fix - added local targets to install host [\#3303](https://github.com/pypeclub/OpenPype/pull/3303) - Settings: Add missing default settings for nuke gizmo [\#3301](https://github.com/pypeclub/OpenPype/pull/3301) - Maya: Fix swaped width and height in reviews [\#3300](https://github.com/pypeclub/OpenPype/pull/3300) - Maya: point cache publish handles Maya instances [\#3297](https://github.com/pypeclub/OpenPype/pull/3297) - Global: extract review slate issues [\#3286](https://github.com/pypeclub/OpenPype/pull/3286) - Webpublisher: return only active projects in ProjectsEndpoint [\#3281](https://github.com/pypeclub/OpenPype/pull/3281) - Hiero: add support for task tags 3.10.x [\#3279](https://github.com/pypeclub/OpenPype/pull/3279) - General: Fix Oiio tool path resolving [\#3278](https://github.com/pypeclub/OpenPype/pull/3278) - Maya: Fix udim support for e.g. uppercase \ tag [\#3266](https://github.com/pypeclub/OpenPype/pull/3266) - Nuke: bake reformat was failing on string type [\#3261](https://github.com/pypeclub/OpenPype/pull/3261) - Maya: hotfix Pxr multitexture in looks [\#3260](https://github.com/pypeclub/OpenPype/pull/3260) - Unreal: Fix Camera Loading if Layout is missing [\#3255](https://github.com/pypeclub/OpenPype/pull/3255) - Unreal: Fixed Animation loading in UE5 [\#3240](https://github.com/pypeclub/OpenPype/pull/3240) - Unreal: Fixed Render creation in UE5 [\#3239](https://github.com/pypeclub/OpenPype/pull/3239) - Unreal: Fixed Camera loading in UE5 [\#3238](https://github.com/pypeclub/OpenPype/pull/3238) - Flame: debugging [\#3224](https://github.com/pypeclub/OpenPype/pull/3224) - add silent audio to slate [\#3162](https://github.com/pypeclub/OpenPype/pull/3162) - Add timecode to slate [\#2929](https://github.com/pypeclub/OpenPype/pull/2929) **🔀 Refactored code** - Blender: Use client query functions [\#3331](https://github.com/pypeclub/OpenPype/pull/3331) - General: Define query functions [\#3288](https://github.com/pypeclub/OpenPype/pull/3288) **Merged pull requests:** - Maya: add pointcache family to gpu cache loader [\#3318](https://github.com/pypeclub/OpenPype/pull/3318) - Maya look: skip empty file attributes [\#3274](https://github.com/pypeclub/OpenPype/pull/3274) ## [3.10.0](https://github.com/pypeclub/OpenPype/tree/3.10.0) (2022-05-26) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.8...3.10.0) ### 📖 Documentation - Docs: add all-contributors config and initial list [\#3094](https://github.com/pypeclub/OpenPype/pull/3094) - Nuke docs with videos [\#3052](https://github.com/pypeclub/OpenPype/pull/3052) **🆕 New features** - General: OpenPype modules publish plugins are registered in host [\#3180](https://github.com/pypeclub/OpenPype/pull/3180) - General: Creator plugins from addons can be registered [\#3179](https://github.com/pypeclub/OpenPype/pull/3179) - Ftrack: Single image reviewable [\#3157](https://github.com/pypeclub/OpenPype/pull/3157) - Nuke: Expose write attributes to settings [\#3123](https://github.com/pypeclub/OpenPype/pull/3123) - Hiero: Initial frame publish support [\#3106](https://github.com/pypeclub/OpenPype/pull/3106) - Unreal: Render Publishing [\#2917](https://github.com/pypeclub/OpenPype/pull/2917) - AfterEffects: Implemented New Publisher [\#2838](https://github.com/pypeclub/OpenPype/pull/2838) - Unreal: Rendering implementation [\#2410](https://github.com/pypeclub/OpenPype/pull/2410) **🚀 Enhancements** - Maya: FBX camera export [\#3253](https://github.com/pypeclub/OpenPype/pull/3253) - General: updating common vendor `scriptmenu` to 1.5.2 [\#3246](https://github.com/pypeclub/OpenPype/pull/3246) - Project Manager: Allow to paste Tasks into multiple assets at the same time [\#3226](https://github.com/pypeclub/OpenPype/pull/3226) - Project manager: Sped up project load [\#3216](https://github.com/pypeclub/OpenPype/pull/3216) - Loader UI: Speed issues of loader with sync server [\#3199](https://github.com/pypeclub/OpenPype/pull/3199) - Looks: add basic support for Renderman [\#3190](https://github.com/pypeclub/OpenPype/pull/3190) - Maya: added clean\_import option to Import loader [\#3181](https://github.com/pypeclub/OpenPype/pull/3181) - Add the scripts menu definition to nuke [\#3168](https://github.com/pypeclub/OpenPype/pull/3168) - Maya: add maya 2023 to default applications [\#3167](https://github.com/pypeclub/OpenPype/pull/3167) - Compressed bgeo publishing in SAP and Houdini loader [\#3153](https://github.com/pypeclub/OpenPype/pull/3153) - General: Add 'dataclasses' to required python modules [\#3149](https://github.com/pypeclub/OpenPype/pull/3149) - Hooks: Tweak logging grammar [\#3147](https://github.com/pypeclub/OpenPype/pull/3147) - Nuke: settings for reformat node in CreateWriteRender node [\#3143](https://github.com/pypeclub/OpenPype/pull/3143) - Houdini: Add loader for alembic through Alembic Archive node [\#3140](https://github.com/pypeclub/OpenPype/pull/3140) - Publisher: UI Modifications and fixes [\#3139](https://github.com/pypeclub/OpenPype/pull/3139) - General: Simplified OP modules/addons import [\#3137](https://github.com/pypeclub/OpenPype/pull/3137) - Terminal: Tweak coloring of TrayModuleManager logging enabled states [\#3133](https://github.com/pypeclub/OpenPype/pull/3133) - General: Cleanup some Loader docstrings [\#3131](https://github.com/pypeclub/OpenPype/pull/3131) - Nuke: render instance with subset name filtered overrides [\#3117](https://github.com/pypeclub/OpenPype/pull/3117) - Unreal: Layout and Camera update and remove functions reimplemented and improvements [\#3116](https://github.com/pypeclub/OpenPype/pull/3116) - Settings: Remove environment groups from settings [\#3115](https://github.com/pypeclub/OpenPype/pull/3115) - TVPaint: Match renderlayer key with other hosts [\#3110](https://github.com/pypeclub/OpenPype/pull/3110) - Ftrack: AssetVersion status on publish [\#3108](https://github.com/pypeclub/OpenPype/pull/3108) - Tray publisher: Simple families from settings [\#3105](https://github.com/pypeclub/OpenPype/pull/3105) - Local Settings UI: Overlay messages on save and reset [\#3104](https://github.com/pypeclub/OpenPype/pull/3104) - General: Remove repos related logic [\#3087](https://github.com/pypeclub/OpenPype/pull/3087) - Standalone publisher: add support for bgeo and vdb [\#3080](https://github.com/pypeclub/OpenPype/pull/3080) - Houdini: Fix FPS + outdated content pop-ups [\#3079](https://github.com/pypeclub/OpenPype/pull/3079) - General: Add global log verbose arguments [\#3070](https://github.com/pypeclub/OpenPype/pull/3070) - Flame: extract presets distribution [\#3063](https://github.com/pypeclub/OpenPype/pull/3063) - Update collect\_render.py [\#3055](https://github.com/pypeclub/OpenPype/pull/3055) - SiteSync: Added compute\_resource\_sync\_sites to sync\_server\_module [\#2983](https://github.com/pypeclub/OpenPype/pull/2983) - Maya: Implement Hardware Renderer 2.0 support for Render Products [\#2611](https://github.com/pypeclub/OpenPype/pull/2611) **🐛 Bug fixes** - nuke: use framerange issue [\#3254](https://github.com/pypeclub/OpenPype/pull/3254) - Ftrack: Chunk sizes for queries has minimal condition [\#3244](https://github.com/pypeclub/OpenPype/pull/3244) - Maya: renderman displays needs to be filtered [\#3242](https://github.com/pypeclub/OpenPype/pull/3242) - Ftrack: Validate that the user exists on ftrack [\#3237](https://github.com/pypeclub/OpenPype/pull/3237) - Maya: Fix support for multiple resolutions [\#3236](https://github.com/pypeclub/OpenPype/pull/3236) - TVPaint: Look for more groups than 12 [\#3228](https://github.com/pypeclub/OpenPype/pull/3228) - Hiero: debugging frame range and other 3.10 [\#3222](https://github.com/pypeclub/OpenPype/pull/3222) - Project Manager: Fix persistent editors on project change [\#3218](https://github.com/pypeclub/OpenPype/pull/3218) - Deadline: instance data overwrite fix [\#3214](https://github.com/pypeclub/OpenPype/pull/3214) - Ftrack: Push hierarchical attributes action works [\#3210](https://github.com/pypeclub/OpenPype/pull/3210) - Standalone Publisher: Always create new representation for thumbnail [\#3203](https://github.com/pypeclub/OpenPype/pull/3203) - Photoshop: skip collector when automatic testing [\#3202](https://github.com/pypeclub/OpenPype/pull/3202) - Nuke: render/workfile version sync doesn't work on farm [\#3185](https://github.com/pypeclub/OpenPype/pull/3185) - Ftrack: Review image only if there are no mp4 reviews [\#3183](https://github.com/pypeclub/OpenPype/pull/3183) - Ftrack: Locations deepcopy issue [\#3177](https://github.com/pypeclub/OpenPype/pull/3177) - General: Avoid creating multiple thumbnails [\#3176](https://github.com/pypeclub/OpenPype/pull/3176) - General/Hiero: better clip duration calculation [\#3169](https://github.com/pypeclub/OpenPype/pull/3169) - General: Oiio conversion for ffmpeg checks for invalid characters [\#3166](https://github.com/pypeclub/OpenPype/pull/3166) - Fix for attaching render to subset [\#3164](https://github.com/pypeclub/OpenPype/pull/3164) - Harmony: fixed missing task name in render instance [\#3163](https://github.com/pypeclub/OpenPype/pull/3163) - Ftrack: Action delete old versions formatting works [\#3152](https://github.com/pypeclub/OpenPype/pull/3152) - Deadline: fix the output directory [\#3144](https://github.com/pypeclub/OpenPype/pull/3144) - General: New Session schema [\#3141](https://github.com/pypeclub/OpenPype/pull/3141) - General: Missing version on headless mode crash properly [\#3136](https://github.com/pypeclub/OpenPype/pull/3136) - TVPaint: Composite layers in reversed order [\#3135](https://github.com/pypeclub/OpenPype/pull/3135) - Nuke: fixing default settings for workfile builder loaders [\#3120](https://github.com/pypeclub/OpenPype/pull/3120) - Nuke: fix anatomy imageio regex default [\#3119](https://github.com/pypeclub/OpenPype/pull/3119) - General: Python 3 compatibility in queries [\#3112](https://github.com/pypeclub/OpenPype/pull/3112) - General: TemplateResult can be copied [\#3099](https://github.com/pypeclub/OpenPype/pull/3099) - General: Collect loaded versions skips not existing representations [\#3095](https://github.com/pypeclub/OpenPype/pull/3095) - RoyalRender Control Submission - AVALON\_APP\_NAME default [\#3091](https://github.com/pypeclub/OpenPype/pull/3091) - Ftrack: Update Create Folders action [\#3089](https://github.com/pypeclub/OpenPype/pull/3089) - Maya: Collect Render fix any render cameras check [\#3088](https://github.com/pypeclub/OpenPype/pull/3088) - Project Manager: Avoid unnecessary updates of asset documents [\#3083](https://github.com/pypeclub/OpenPype/pull/3083) - Standalone publisher: Fix plugins install [\#3077](https://github.com/pypeclub/OpenPype/pull/3077) - General: Extract review sequence is not converted with same names [\#3076](https://github.com/pypeclub/OpenPype/pull/3076) - Webpublisher: Use variant value [\#3068](https://github.com/pypeclub/OpenPype/pull/3068) - Nuke: Add aov matching even for remainder and prerender [\#3060](https://github.com/pypeclub/OpenPype/pull/3060) - Fix support for Renderman in Maya [\#3006](https://github.com/pypeclub/OpenPype/pull/3006) **🔀 Refactored code** - Avalon repo removed from Jobs workflow [\#3193](https://github.com/pypeclub/OpenPype/pull/3193) - General: Remove remaining imports from avalon [\#3130](https://github.com/pypeclub/OpenPype/pull/3130) - General: Move mongo db logic and remove avalon repository [\#3066](https://github.com/pypeclub/OpenPype/pull/3066) - General: Move host install [\#3009](https://github.com/pypeclub/OpenPype/pull/3009) **Merged pull requests:** - Harmony: message length in 21.1 [\#3257](https://github.com/pypeclub/OpenPype/pull/3257) - Harmony: 21.1 fix [\#3249](https://github.com/pypeclub/OpenPype/pull/3249) - Maya: added jpg to filter for Image Plane Loader [\#3223](https://github.com/pypeclub/OpenPype/pull/3223) - Webpublisher: replace space by underscore in subset names [\#3160](https://github.com/pypeclub/OpenPype/pull/3160) - StandalonePublisher: removed Extract Background plugins [\#3093](https://github.com/pypeclub/OpenPype/pull/3093) - Nuke: added suspend\_publish knob [\#3078](https://github.com/pypeclub/OpenPype/pull/3078) - Bump async from 2.6.3 to 2.6.4 in /website [\#3065](https://github.com/pypeclub/OpenPype/pull/3065) - SiteSync: Download all workfile inputs [\#2966](https://github.com/pypeclub/OpenPype/pull/2966) - Photoshop: New Publisher [\#2933](https://github.com/pypeclub/OpenPype/pull/2933) - Bump pillow from 9.0.0 to 9.0.1 [\#2880](https://github.com/pypeclub/OpenPype/pull/2880) - AfterEffects: Allow configuration of default variant via Settings [\#2856](https://github.com/pypeclub/OpenPype/pull/2856) ## [3.9.8](https://github.com/pypeclub/OpenPype/tree/3.9.8) (2022-05-19) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.7...3.9.8) ## [3.9.7](https://github.com/pypeclub/OpenPype/tree/3.9.7) (2022-05-11) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.6...3.9.7) ## [3.9.6](https://github.com/pypeclub/OpenPype/tree/3.9.6) (2022-05-03) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.5...3.9.6) ## [3.9.5](https://github.com/pypeclub/OpenPype/tree/3.9.5) (2022-04-25) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.4...3.9.5) ## [3.9.4](https://github.com/pypeclub/OpenPype/tree/3.9.4) (2022-04-15) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.3...3.9.4) ### 📖 Documentation - Documentation: more info about Tasks [\#3062](https://github.com/pypeclub/OpenPype/pull/3062) - Documentation: Python requirements to 3.7.9 [\#3035](https://github.com/pypeclub/OpenPype/pull/3035) - Website Docs: Remove unused pages [\#2974](https://github.com/pypeclub/OpenPype/pull/2974) **🆕 New features** - General: Local overrides for environment variables [\#3045](https://github.com/pypeclub/OpenPype/pull/3045) - Flame: Flare integration preparation [\#2928](https://github.com/pypeclub/OpenPype/pull/2928) **🚀 Enhancements** - TVPaint: Added init file for worker to triggers missing sound file dialog [\#3053](https://github.com/pypeclub/OpenPype/pull/3053) - Ftrack: Custom attributes can be filled in slate values [\#3036](https://github.com/pypeclub/OpenPype/pull/3036) - Resolve environment variable in google drive credential path [\#3008](https://github.com/pypeclub/OpenPype/pull/3008) **🐛 Bug fixes** - GitHub: Updated push-protected action in github workflow [\#3064](https://github.com/pypeclub/OpenPype/pull/3064) - Nuke: Typos in imports from Nuke implementation [\#3061](https://github.com/pypeclub/OpenPype/pull/3061) - Hotfix: fixing deadline job publishing [\#3059](https://github.com/pypeclub/OpenPype/pull/3059) - General: Extract Review handle invalid characters for ffmpeg [\#3050](https://github.com/pypeclub/OpenPype/pull/3050) - Slate Review: Support to keep format on slate concatenation [\#3049](https://github.com/pypeclub/OpenPype/pull/3049) - Webpublisher: fix processing of workfile [\#3048](https://github.com/pypeclub/OpenPype/pull/3048) - Ftrack: Integrate ftrack api fix [\#3044](https://github.com/pypeclub/OpenPype/pull/3044) - Webpublisher - removed wrong hardcoded family [\#3043](https://github.com/pypeclub/OpenPype/pull/3043) - LibraryLoader: Use current project for asset query in families filter [\#3042](https://github.com/pypeclub/OpenPype/pull/3042) - SiteSync: Providers ignore that site is disabled [\#3041](https://github.com/pypeclub/OpenPype/pull/3041) - Unreal: Creator import fixes [\#3040](https://github.com/pypeclub/OpenPype/pull/3040) - SiteSync: fix transitive alternate sites, fix dropdown in Local Settings [\#3018](https://github.com/pypeclub/OpenPype/pull/3018) - Maya: invalid review flag on rendered AOVs [\#2915](https://github.com/pypeclub/OpenPype/pull/2915) **Merged pull requests:** - Deadline: reworked pools assignment [\#3051](https://github.com/pypeclub/OpenPype/pull/3051) - Houdini: Avoid ImportError on `hdefereval` when Houdini runs without UI [\#2987](https://github.com/pypeclub/OpenPype/pull/2987) ## [3.9.3](https://github.com/pypeclub/OpenPype/tree/3.9.3) (2022-04-07) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.2...3.9.3) ### 📖 Documentation - Documentation: Added mention of adding My Drive as a root [\#2999](https://github.com/pypeclub/OpenPype/pull/2999) - Website Docs: Manager Ftrack fix broken links [\#2979](https://github.com/pypeclub/OpenPype/pull/2979) - Docs: Added MongoDB requirements [\#2951](https://github.com/pypeclub/OpenPype/pull/2951) - Documentation: New publisher develop docs [\#2896](https://github.com/pypeclub/OpenPype/pull/2896) **🆕 New features** - Ftrack: Add description integrator [\#3027](https://github.com/pypeclub/OpenPype/pull/3027) - nuke: bypass baking [\#2992](https://github.com/pypeclub/OpenPype/pull/2992) - Publishing textures for Unreal [\#2988](https://github.com/pypeclub/OpenPype/pull/2988) - Maya to Unreal: Static and Skeletal Meshes [\#2978](https://github.com/pypeclub/OpenPype/pull/2978) - Multiverse: Initial Support [\#2908](https://github.com/pypeclub/OpenPype/pull/2908) **🚀 Enhancements** - General: default workfile subset name for workfile [\#3011](https://github.com/pypeclub/OpenPype/pull/3011) - Ftrack: Add more options for note text of integrate ftrack note [\#3025](https://github.com/pypeclub/OpenPype/pull/3025) - Console Interpreter: Changed how console splitter size are reused on show [\#3016](https://github.com/pypeclub/OpenPype/pull/3016) - Deadline: Use more suitable name for sequence review logic [\#3015](https://github.com/pypeclub/OpenPype/pull/3015) - Nuke: add concurrency attr to deadline job [\#3005](https://github.com/pypeclub/OpenPype/pull/3005) - Photoshop: create image without instance [\#3001](https://github.com/pypeclub/OpenPype/pull/3001) - TVPaint: Render scene family [\#3000](https://github.com/pypeclub/OpenPype/pull/3000) - Deadline: priority configurable in Maya jobs [\#2995](https://github.com/pypeclub/OpenPype/pull/2995) - Nuke: ReviewDataMov Read RAW attribute [\#2985](https://github.com/pypeclub/OpenPype/pull/2985) - General: `METADATA_KEYS` constant as `frozenset` for optimal immutable lookup [\#2980](https://github.com/pypeclub/OpenPype/pull/2980) - General: Tools with host filters [\#2975](https://github.com/pypeclub/OpenPype/pull/2975) - Hero versions: Use custom templates [\#2967](https://github.com/pypeclub/OpenPype/pull/2967) - Slack: Added configurable maximum file size of review upload to Slack [\#2945](https://github.com/pypeclub/OpenPype/pull/2945) - NewPublisher: Prepared implementation of optional pyblish plugin [\#2943](https://github.com/pypeclub/OpenPype/pull/2943) - TVPaint: Extractor to convert PNG into EXR [\#2942](https://github.com/pypeclub/OpenPype/pull/2942) - Workfiles tool: Save as published workfiles [\#2937](https://github.com/pypeclub/OpenPype/pull/2937) - Workfiles: Open published workfiles [\#2925](https://github.com/pypeclub/OpenPype/pull/2925) - General: Default modules loaded dynamically [\#2923](https://github.com/pypeclub/OpenPype/pull/2923) - CI: change the version bump logic [\#2919](https://github.com/pypeclub/OpenPype/pull/2919) - Deadline: Add headless argument [\#2916](https://github.com/pypeclub/OpenPype/pull/2916) - Nuke: Add no-audio Tag [\#2911](https://github.com/pypeclub/OpenPype/pull/2911) - Ftrack: Fill workfile in custom attribute [\#2906](https://github.com/pypeclub/OpenPype/pull/2906) - Nuke: improving readability [\#2903](https://github.com/pypeclub/OpenPype/pull/2903) - Settings UI: Add simple tooltips for settings entities [\#2901](https://github.com/pypeclub/OpenPype/pull/2901) **🐛 Bug fixes** - General: Fix validate asset docs plug-in filename and class name [\#3029](https://github.com/pypeclub/OpenPype/pull/3029) - Deadline: Fixed default value of use sequence for review [\#3033](https://github.com/pypeclub/OpenPype/pull/3033) - Settings UI: Version column can be extended so version are visible [\#3032](https://github.com/pypeclub/OpenPype/pull/3032) - General: Fix import after movements [\#3028](https://github.com/pypeclub/OpenPype/pull/3028) - Harmony: Added creating subset name for workfile from template [\#3024](https://github.com/pypeclub/OpenPype/pull/3024) - AfterEffects: Added creating subset name for workfile from template [\#3023](https://github.com/pypeclub/OpenPype/pull/3023) - General: Add example addons to ignored [\#3022](https://github.com/pypeclub/OpenPype/pull/3022) - Maya: Remove missing import [\#3017](https://github.com/pypeclub/OpenPype/pull/3017) - Ftrack: multiple reviewable componets [\#3012](https://github.com/pypeclub/OpenPype/pull/3012) - Tray publisher: Fixes after code movement [\#3010](https://github.com/pypeclub/OpenPype/pull/3010) - Hosts: Remove path existence checks in 'add\_implementation\_envs' [\#3004](https://github.com/pypeclub/OpenPype/pull/3004) - Nuke: fixing unicode type detection in effect loaders [\#3002](https://github.com/pypeclub/OpenPype/pull/3002) - Fix - remove doubled dot in workfile created from template [\#2998](https://github.com/pypeclub/OpenPype/pull/2998) - Nuke: removing redundant Ftrack asset when farm publishing [\#2996](https://github.com/pypeclub/OpenPype/pull/2996) - PS: fix renaming subset incorrectly in PS [\#2991](https://github.com/pypeclub/OpenPype/pull/2991) - Fix: Disable setuptools auto discovery [\#2990](https://github.com/pypeclub/OpenPype/pull/2990) - AEL: fix opening existing workfile if no scene opened [\#2989](https://github.com/pypeclub/OpenPype/pull/2989) - Maya: Don't do hardlinks on windows for look publishing [\#2986](https://github.com/pypeclub/OpenPype/pull/2986) - Settings UI: Fix version completer on linux [\#2981](https://github.com/pypeclub/OpenPype/pull/2981) - Photoshop: Fix creation of subset names in PS review and workfile [\#2969](https://github.com/pypeclub/OpenPype/pull/2969) - Slack: Added default for review\_upload\_limit for Slack [\#2965](https://github.com/pypeclub/OpenPype/pull/2965) - General: OIIO conversion for ffmeg can handle sequences [\#2958](https://github.com/pypeclub/OpenPype/pull/2958) - Settings: Conditional dictionary avoid invalid logs [\#2956](https://github.com/pypeclub/OpenPype/pull/2956) - General: Smaller fixes and typos [\#2950](https://github.com/pypeclub/OpenPype/pull/2950) - LogViewer: Don't refresh on initialization [\#2949](https://github.com/pypeclub/OpenPype/pull/2949) - nuke: python3 compatibility issue with `iteritems` [\#2948](https://github.com/pypeclub/OpenPype/pull/2948) - General: anatomy data with correct task short key [\#2947](https://github.com/pypeclub/OpenPype/pull/2947) - SceneInventory: Fix imports in UI [\#2944](https://github.com/pypeclub/OpenPype/pull/2944) - Slack: add generic exception [\#2941](https://github.com/pypeclub/OpenPype/pull/2941) - General: Python specific vendor paths on env injection [\#2939](https://github.com/pypeclub/OpenPype/pull/2939) - General: More fail safe delete old versions [\#2936](https://github.com/pypeclub/OpenPype/pull/2936) - Settings UI: Collapsed of collapsible wrapper works as expected [\#2934](https://github.com/pypeclub/OpenPype/pull/2934) - Maya: Do not pass `set` to maya commands \(fixes support for older maya versions\) [\#2932](https://github.com/pypeclub/OpenPype/pull/2932) - General: Don't print log record on OSError [\#2926](https://github.com/pypeclub/OpenPype/pull/2926) - Hiero: Fix import of 'register\_event\_callback' [\#2924](https://github.com/pypeclub/OpenPype/pull/2924) - Flame: centos related debugging [\#2922](https://github.com/pypeclub/OpenPype/pull/2922) - Ftrack: Missing Ftrack id after editorial publish [\#2905](https://github.com/pypeclub/OpenPype/pull/2905) - AfterEffects: Fix rendering for single frame in DL [\#2875](https://github.com/pypeclub/OpenPype/pull/2875) **🔀 Refactored code** - General: Move plugins register and discover [\#2935](https://github.com/pypeclub/OpenPype/pull/2935) - General: Move Attribute Definitions from pipeline [\#2931](https://github.com/pypeclub/OpenPype/pull/2931) - General: Removed silo references and terminal splash [\#2927](https://github.com/pypeclub/OpenPype/pull/2927) - General: Move pipeline constants to OpenPype [\#2918](https://github.com/pypeclub/OpenPype/pull/2918) - General: Move formatting and workfile functions [\#2914](https://github.com/pypeclub/OpenPype/pull/2914) - General: Move remaining plugins from avalon [\#2912](https://github.com/pypeclub/OpenPype/pull/2912) **Merged pull requests:** - Maya: Allow to select invalid camera contents if no cameras found [\#3030](https://github.com/pypeclub/OpenPype/pull/3030) - Bump paramiko from 2.9.2 to 2.10.1 [\#2973](https://github.com/pypeclub/OpenPype/pull/2973) - Bump minimist from 1.2.5 to 1.2.6 in /website [\#2954](https://github.com/pypeclub/OpenPype/pull/2954) - Bump node-forge from 1.2.1 to 1.3.0 in /website [\#2953](https://github.com/pypeclub/OpenPype/pull/2953) - Maya - added transparency into review creator [\#2952](https://github.com/pypeclub/OpenPype/pull/2952) ## [3.9.2](https://github.com/pypeclub/OpenPype/tree/3.9.2) (2022-04-04) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.1...3.9.2) ## [3.9.1](https://github.com/pypeclub/OpenPype/tree/3.9.1) (2022-03-18) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.0...3.9.1) **🚀 Enhancements** - General: Change how OPENPYPE\_DEBUG value is handled [\#2907](https://github.com/pypeclub/OpenPype/pull/2907) - nuke: imageio adding ocio config version 1.2 [\#2897](https://github.com/pypeclub/OpenPype/pull/2897) - Flame: support for comment with xml attribute overrides [\#2892](https://github.com/pypeclub/OpenPype/pull/2892) - Nuke: ExtractReviewSlate can handle more codes and profiles [\#2879](https://github.com/pypeclub/OpenPype/pull/2879) - Flame: sequence used for reference video [\#2869](https://github.com/pypeclub/OpenPype/pull/2869) **🐛 Bug fixes** - General: Fix use of Anatomy roots [\#2904](https://github.com/pypeclub/OpenPype/pull/2904) - Fixing gap detection in extract review [\#2902](https://github.com/pypeclub/OpenPype/pull/2902) - Pyblish Pype - ensure current state is correct when entering new group order [\#2899](https://github.com/pypeclub/OpenPype/pull/2899) - SceneInventory: Fix import of load function [\#2894](https://github.com/pypeclub/OpenPype/pull/2894) - Harmony - fixed creator issue [\#2891](https://github.com/pypeclub/OpenPype/pull/2891) - General: Remove forgotten use of avalon Creator [\#2885](https://github.com/pypeclub/OpenPype/pull/2885) - General: Avoid circular import [\#2884](https://github.com/pypeclub/OpenPype/pull/2884) - Fixes for attaching loaded containers \(\#2837\) [\#2874](https://github.com/pypeclub/OpenPype/pull/2874) - Maya: Deformer node ids validation plugin [\#2826](https://github.com/pypeclub/OpenPype/pull/2826) - Flame Babypublisher optimalization [\#2806](https://github.com/pypeclub/OpenPype/pull/2806) - hotfix: OIIO tool path - add extension on windows [\#2618](https://github.com/pypeclub/OpenPype/pull/2618) **🔀 Refactored code** - General: Reduce style usage to OpenPype repository [\#2889](https://github.com/pypeclub/OpenPype/pull/2889) - General: Move loader logic from avalon to openpype [\#2886](https://github.com/pypeclub/OpenPype/pull/2886) ## [3.9.0](https://github.com/pypeclub/OpenPype/tree/3.9.0) (2022-03-14) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...3.9.0) **Deprecated:** - Houdini: Remove unused code [\#2779](https://github.com/pypeclub/OpenPype/pull/2779) - Loader: Remove default family states for hosts from code [\#2706](https://github.com/pypeclub/OpenPype/pull/2706) - AssetCreator: Remove the tool [\#2845](https://github.com/pypeclub/OpenPype/pull/2845) ### 📖 Documentation - Documentation: fixed broken links [\#2799](https://github.com/pypeclub/OpenPype/pull/2799) - Documentation: broken link fix [\#2785](https://github.com/pypeclub/OpenPype/pull/2785) - Documentation: link fixes [\#2772](https://github.com/pypeclub/OpenPype/pull/2772) - Update docusaurus to latest version [\#2760](https://github.com/pypeclub/OpenPype/pull/2760) - Various testing updates [\#2726](https://github.com/pypeclub/OpenPype/pull/2726) - documentation: add example to `repack-version` command [\#2669](https://github.com/pypeclub/OpenPype/pull/2669) - Update docusaurus [\#2639](https://github.com/pypeclub/OpenPype/pull/2639) - Documentation: Fixed relative links [\#2621](https://github.com/pypeclub/OpenPype/pull/2621) - Documentation: Change Photoshop & AfterEffects plugin path [\#2878](https://github.com/pypeclub/OpenPype/pull/2878) **🆕 New features** - Flame: loading clips to reels [\#2622](https://github.com/pypeclub/OpenPype/pull/2622) - General: Store settings by OpenPype version [\#2570](https://github.com/pypeclub/OpenPype/pull/2570) **🚀 Enhancements** - New: Validation exceptions [\#2841](https://github.com/pypeclub/OpenPype/pull/2841) - General: Set context environments for non host applications [\#2803](https://github.com/pypeclub/OpenPype/pull/2803) - Houdini: Remove duplicate ValidateOutputNode plug-in [\#2780](https://github.com/pypeclub/OpenPype/pull/2780) - Tray publisher: New Tray Publisher host \(beta\) [\#2778](https://github.com/pypeclub/OpenPype/pull/2778) - Slack: Added regex for filtering on subset names [\#2775](https://github.com/pypeclub/OpenPype/pull/2775) - Houdini: Implement Reset Frame Range [\#2770](https://github.com/pypeclub/OpenPype/pull/2770) - Pyblish Pype: Remove redundant new line in installed fonts printing [\#2758](https://github.com/pypeclub/OpenPype/pull/2758) - Flame: use Shot Name on segment for asset name [\#2751](https://github.com/pypeclub/OpenPype/pull/2751) - Flame: adding validator source clip [\#2746](https://github.com/pypeclub/OpenPype/pull/2746) - Work Files: Preserve subversion comment of current filename by default [\#2734](https://github.com/pypeclub/OpenPype/pull/2734) - Maya: set Deadline job/batch name to original source workfile name instead of published workfile [\#2733](https://github.com/pypeclub/OpenPype/pull/2733) - Ftrack: Disable ftrack module by default [\#2732](https://github.com/pypeclub/OpenPype/pull/2732) - Project Manager: Disable add task, add asset and save button when not in a project [\#2727](https://github.com/pypeclub/OpenPype/pull/2727) - dropbox handle big file [\#2718](https://github.com/pypeclub/OpenPype/pull/2718) - Fusion Move PR: Minor tweaks to Fusion integration [\#2716](https://github.com/pypeclub/OpenPype/pull/2716) - RoyalRender: Minor enhancements [\#2700](https://github.com/pypeclub/OpenPype/pull/2700) - Nuke: prerender with review knob [\#2691](https://github.com/pypeclub/OpenPype/pull/2691) - Maya configurable unit validator [\#2680](https://github.com/pypeclub/OpenPype/pull/2680) - General: Add settings for CleanUpFarm and disable the plugin by default [\#2679](https://github.com/pypeclub/OpenPype/pull/2679) - Project Manager: Only allow scroll wheel edits when spinbox is active [\#2678](https://github.com/pypeclub/OpenPype/pull/2678) - Ftrack: Sync description to assets [\#2670](https://github.com/pypeclub/OpenPype/pull/2670) - Houdini: Moved to OpenPype [\#2658](https://github.com/pypeclub/OpenPype/pull/2658) - Maya: Move implementation to OpenPype [\#2649](https://github.com/pypeclub/OpenPype/pull/2649) - General: FFmpeg conversion also check attribute string length [\#2635](https://github.com/pypeclub/OpenPype/pull/2635) - Houdini: Load Arnold .ass procedurals into Houdini [\#2606](https://github.com/pypeclub/OpenPype/pull/2606) - Deadline: Simplify GlobalJobPreLoad logic [\#2605](https://github.com/pypeclub/OpenPype/pull/2605) - Houdini: Implement Arnold .ass standin extraction from Houdini \(also support .ass.gz\) [\#2603](https://github.com/pypeclub/OpenPype/pull/2603) - New Publisher: New features and preparations for new standalone publisher [\#2556](https://github.com/pypeclub/OpenPype/pull/2556) - Fix Maya 2022 Python 3 compatibility [\#2445](https://github.com/pypeclub/OpenPype/pull/2445) - TVPaint: Use new publisher exceptions in validators [\#2435](https://github.com/pypeclub/OpenPype/pull/2435) - Harmony: Added new style validations for New Publisher [\#2434](https://github.com/pypeclub/OpenPype/pull/2434) - Aftereffects: New style validations for New publisher [\#2430](https://github.com/pypeclub/OpenPype/pull/2430) - Farm publishing: New cleanup plugin for Maya renders on farm [\#2390](https://github.com/pypeclub/OpenPype/pull/2390) - General: Subset name filtering in ExtractReview outpus [\#2872](https://github.com/pypeclub/OpenPype/pull/2872) - NewPublisher: Descriptions and Icons in creator dialog [\#2867](https://github.com/pypeclub/OpenPype/pull/2867) - NewPublisher: Changing task on publishing instance [\#2863](https://github.com/pypeclub/OpenPype/pull/2863) - TrayPublisher: Choose project widget is more clear [\#2859](https://github.com/pypeclub/OpenPype/pull/2859) - Maya: add loaded containers to published instance [\#2837](https://github.com/pypeclub/OpenPype/pull/2837) - Ftrack: Can sync fps as string [\#2836](https://github.com/pypeclub/OpenPype/pull/2836) - General: Custom function for find executable [\#2822](https://github.com/pypeclub/OpenPype/pull/2822) - General: Color dialog UI fixes [\#2817](https://github.com/pypeclub/OpenPype/pull/2817) - global: letter box calculated on output as last process [\#2812](https://github.com/pypeclub/OpenPype/pull/2812) - Nuke: adding Reformat to baking mov plugin [\#2811](https://github.com/pypeclub/OpenPype/pull/2811) - Manager: Update all to latest button [\#2805](https://github.com/pypeclub/OpenPype/pull/2805) - Houdini: Move Houdini Save Current File to beginning of ExtractorOrder [\#2747](https://github.com/pypeclub/OpenPype/pull/2747) - Global: adding studio name/code to anatomy template formatting data [\#2630](https://github.com/pypeclub/OpenPype/pull/2630) **🐛 Bug fixes** - Settings UI: Search case sensitivity [\#2810](https://github.com/pypeclub/OpenPype/pull/2810) - resolve: fixing fusion module loading [\#2802](https://github.com/pypeclub/OpenPype/pull/2802) - Ftrack: Unset task ids from asset versions before tasks are removed [\#2800](https://github.com/pypeclub/OpenPype/pull/2800) - Slack: fail gracefully if slack exception [\#2798](https://github.com/pypeclub/OpenPype/pull/2798) - Flame: Fix version string in default settings [\#2783](https://github.com/pypeclub/OpenPype/pull/2783) - After Effects: Fix typo in name `afftereffects` -\> `aftereffects` [\#2768](https://github.com/pypeclub/OpenPype/pull/2768) - Houdini: Fix open last workfile [\#2767](https://github.com/pypeclub/OpenPype/pull/2767) - Avoid renaming udim indexes [\#2765](https://github.com/pypeclub/OpenPype/pull/2765) - Maya: Fix `unique_namespace` when in an namespace that is empty [\#2759](https://github.com/pypeclub/OpenPype/pull/2759) - Loader UI: Fix right click in representation widget [\#2757](https://github.com/pypeclub/OpenPype/pull/2757) - Harmony: Rendering in Deadline didn't work in other machines than submitter [\#2754](https://github.com/pypeclub/OpenPype/pull/2754) - Aftereffects 2022 and Deadline [\#2748](https://github.com/pypeclub/OpenPype/pull/2748) - Flame: bunch of bugs [\#2745](https://github.com/pypeclub/OpenPype/pull/2745) - Maya: Save current scene on workfile publish [\#2744](https://github.com/pypeclub/OpenPype/pull/2744) - Version Up: Preserve parts of filename after version number \(like subversion\) on version\_up [\#2741](https://github.com/pypeclub/OpenPype/pull/2741) - Loader UI: Multiple asset selection and underline colors fixed [\#2731](https://github.com/pypeclub/OpenPype/pull/2731) - General: Fix loading of unused chars in xml format [\#2729](https://github.com/pypeclub/OpenPype/pull/2729) - TVPaint: Set objectName with members [\#2725](https://github.com/pypeclub/OpenPype/pull/2725) - General: Don't use 'objectName' from loaded references [\#2715](https://github.com/pypeclub/OpenPype/pull/2715) - Settings: Studio Project anatomy is queried using right keys [\#2711](https://github.com/pypeclub/OpenPype/pull/2711) - Local Settings: Additional applications don't break UI [\#2710](https://github.com/pypeclub/OpenPype/pull/2710) - Maya: Remove some unused code [\#2709](https://github.com/pypeclub/OpenPype/pull/2709) - Houdini: Fix refactor of Houdini host move for CreateArnoldAss [\#2704](https://github.com/pypeclub/OpenPype/pull/2704) - LookAssigner: Fix imports after moving code to OpenPype repository [\#2701](https://github.com/pypeclub/OpenPype/pull/2701) - Multiple hosts: unify menu style across hosts [\#2693](https://github.com/pypeclub/OpenPype/pull/2693) - Maya Redshift fixes [\#2692](https://github.com/pypeclub/OpenPype/pull/2692) - Maya: fix fps validation popup [\#2685](https://github.com/pypeclub/OpenPype/pull/2685) - Houdini Explicitly collect correct frame name even in case of single frame render when `frameStart` is provided [\#2676](https://github.com/pypeclub/OpenPype/pull/2676) - hiero: fix effect collector name and order [\#2673](https://github.com/pypeclub/OpenPype/pull/2673) - Maya: Fix menu callbacks [\#2671](https://github.com/pypeclub/OpenPype/pull/2671) - hiero: removing obsolete unsupported plugin [\#2667](https://github.com/pypeclub/OpenPype/pull/2667) - Launcher: Fix access to 'data' attribute on actions [\#2659](https://github.com/pypeclub/OpenPype/pull/2659) - Maya `vrscene` loader fixes [\#2633](https://github.com/pypeclub/OpenPype/pull/2633) - Houdini: fix usd family in loader and integrators [\#2631](https://github.com/pypeclub/OpenPype/pull/2631) - Maya: Add only reference node to look family container like with other families [\#2508](https://github.com/pypeclub/OpenPype/pull/2508) - General: Missing time function [\#2877](https://github.com/pypeclub/OpenPype/pull/2877) - Deadline: Fix plugin name for tile assemble [\#2868](https://github.com/pypeclub/OpenPype/pull/2868) - Nuke: gizmo precollect fix [\#2866](https://github.com/pypeclub/OpenPype/pull/2866) - General: Fix hardlink for windows [\#2864](https://github.com/pypeclub/OpenPype/pull/2864) - General: ffmpeg was crashing on slate merge [\#2860](https://github.com/pypeclub/OpenPype/pull/2860) - WebPublisher: Video file was published with one too many frame [\#2858](https://github.com/pypeclub/OpenPype/pull/2858) - New Publisher: Error dialog got right styles [\#2857](https://github.com/pypeclub/OpenPype/pull/2857) - General: Fix getattr clalback on dynamic modules [\#2855](https://github.com/pypeclub/OpenPype/pull/2855) - Nuke: slate resolution to input video resolution [\#2853](https://github.com/pypeclub/OpenPype/pull/2853) - WebPublisher: Fix username stored in DB [\#2852](https://github.com/pypeclub/OpenPype/pull/2852) - WebPublisher: Fix wrong number of frames for video file [\#2851](https://github.com/pypeclub/OpenPype/pull/2851) - Nuke: Fix family test in validate\_write\_legacy to work with stillImage [\#2847](https://github.com/pypeclub/OpenPype/pull/2847) - Nuke: fix multiple baking profile farm publishing [\#2842](https://github.com/pypeclub/OpenPype/pull/2842) - Blender: Fixed parameters for FBX export of the camera [\#2840](https://github.com/pypeclub/OpenPype/pull/2840) - Maya: Stop creation of reviews for Cryptomattes [\#2832](https://github.com/pypeclub/OpenPype/pull/2832) - Deadline: Remove recreated event [\#2828](https://github.com/pypeclub/OpenPype/pull/2828) - Deadline: Added missing events folder [\#2827](https://github.com/pypeclub/OpenPype/pull/2827) - Settings: Missing document with OP versions may break start of OpenPype [\#2825](https://github.com/pypeclub/OpenPype/pull/2825) - Deadline: more detailed temp file name for environment json [\#2824](https://github.com/pypeclub/OpenPype/pull/2824) - General: Host name was formed from obsolete code [\#2821](https://github.com/pypeclub/OpenPype/pull/2821) - Settings UI: Fix "Apply from" action [\#2820](https://github.com/pypeclub/OpenPype/pull/2820) - Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819) - Nuke: Use AVALON\_APP to get value for "app" key [\#2818](https://github.com/pypeclub/OpenPype/pull/2818) - StandalonePublisher: use dynamic groups in subset names [\#2816](https://github.com/pypeclub/OpenPype/pull/2816) **🔀 Refactored code** - Ftrack: Moved module one hierarchy level higher [\#2792](https://github.com/pypeclub/OpenPype/pull/2792) - SyncServer: Moved module one hierarchy level higher [\#2791](https://github.com/pypeclub/OpenPype/pull/2791) - Royal render: Move module one hierarchy level higher [\#2790](https://github.com/pypeclub/OpenPype/pull/2790) - Deadline: Move module one hierarchy level higher [\#2789](https://github.com/pypeclub/OpenPype/pull/2789) - Refactor: move webserver tool to openpype [\#2876](https://github.com/pypeclub/OpenPype/pull/2876) - General: Move create logic from avalon to OpenPype [\#2854](https://github.com/pypeclub/OpenPype/pull/2854) - General: Add vendors from avalon [\#2848](https://github.com/pypeclub/OpenPype/pull/2848) - General: Basic event system [\#2846](https://github.com/pypeclub/OpenPype/pull/2846) - General: Move change context functions [\#2839](https://github.com/pypeclub/OpenPype/pull/2839) - Tools: Don't use avalon tools code [\#2829](https://github.com/pypeclub/OpenPype/pull/2829) - Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823) - General: Extract template formatting from anatomy [\#2766](https://github.com/pypeclub/OpenPype/pull/2766) **Merged pull requests:** - Fusion: Moved implementation into OpenPype [\#2713](https://github.com/pypeclub/OpenPype/pull/2713) - TVPaint: Plugin build without dependencies [\#2705](https://github.com/pypeclub/OpenPype/pull/2705) - Webpublisher: Photoshop create a beauty png [\#2689](https://github.com/pypeclub/OpenPype/pull/2689) - Ftrack: Hierarchical attributes are queried properly [\#2682](https://github.com/pypeclub/OpenPype/pull/2682) - Maya: Add Validate Frame Range settings [\#2661](https://github.com/pypeclub/OpenPype/pull/2661) - Harmony: move to Openpype [\#2657](https://github.com/pypeclub/OpenPype/pull/2657) - Maya: cleanup duplicate rendersetup code [\#2642](https://github.com/pypeclub/OpenPype/pull/2642) - Deadline: Be able to pass Mongo url to job [\#2616](https://github.com/pypeclub/OpenPype/pull/2616) ## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.1...3.8.2) ### 📖 Documentation - Cosmetics: Fix common typos in openpype/website [\#2617](https://github.com/pypeclub/OpenPype/pull/2617) **🚀 Enhancements** - TVPaint: Image loaders also work on review family [\#2638](https://github.com/pypeclub/OpenPype/pull/2638) - General: Project backup tools [\#2629](https://github.com/pypeclub/OpenPype/pull/2629) - nuke: adding clear button to write nodes [\#2627](https://github.com/pypeclub/OpenPype/pull/2627) - Ftrack: Family to Asset type mapping is in settings [\#2602](https://github.com/pypeclub/OpenPype/pull/2602) - Nuke: load color space from representation data [\#2576](https://github.com/pypeclub/OpenPype/pull/2576) **🐛 Bug fixes** - Fix pulling of cx\_freeze 6.10 [\#2628](https://github.com/pypeclub/OpenPype/pull/2628) - Global: fix broken otio review extractor [\#2590](https://github.com/pypeclub/OpenPype/pull/2590) **Merged pull requests:** - WebPublisher: fix instance duplicates [\#2641](https://github.com/pypeclub/OpenPype/pull/2641) - Fix - safer pulling of task name for webpublishing from PS [\#2613](https://github.com/pypeclub/OpenPype/pull/2613) ## [3.8.1](https://github.com/pypeclub/OpenPype/tree/3.8.1) (2022-02-01) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.0...3.8.1) **🚀 Enhancements** - Webpublisher: Thumbnail extractor [\#2600](https://github.com/pypeclub/OpenPype/pull/2600) - Loader: Allow to toggle default family filters between "include" or "exclude" filtering [\#2541](https://github.com/pypeclub/OpenPype/pull/2541) - Launcher: Added context menu to to skip opening last workfile [\#2536](https://github.com/pypeclub/OpenPype/pull/2536) - Unreal: JSON Layout Loading support [\#2066](https://github.com/pypeclub/OpenPype/pull/2066) **🐛 Bug fixes** - Release/3.8.0 [\#2619](https://github.com/pypeclub/OpenPype/pull/2619) - Settings: Enum does not store empty string if has single item to select [\#2615](https://github.com/pypeclub/OpenPype/pull/2615) - switch distutils to sysconfig for `get_platform()` [\#2594](https://github.com/pypeclub/OpenPype/pull/2594) - Fix poetry index and speedcopy update [\#2589](https://github.com/pypeclub/OpenPype/pull/2589) - Webpublisher: Fix - subset names from processed .psd used wrong value for task [\#2586](https://github.com/pypeclub/OpenPype/pull/2586) - `vrscene` creator Deadline webservice URL handling [\#2580](https://github.com/pypeclub/OpenPype/pull/2580) - global: track name was failing if duplicated root word in name [\#2568](https://github.com/pypeclub/OpenPype/pull/2568) - Validate Maya Rig produces no cycle errors [\#2484](https://github.com/pypeclub/OpenPype/pull/2484) **Merged pull requests:** - Bump pillow from 8.4.0 to 9.0.0 [\#2595](https://github.com/pypeclub/OpenPype/pull/2595) - Webpublisher: Skip version collect [\#2591](https://github.com/pypeclub/OpenPype/pull/2591) - build\(deps\): bump pillow from 8.4.0 to 9.0.0 [\#2523](https://github.com/pypeclub/OpenPype/pull/2523) ## [3.8.0](https://github.com/pypeclub/OpenPype/tree/3.8.0) (2022-01-24) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.7.0...3.8.0) ### 📖 Documentation - Variable in docs renamed to proper name [\#2546](https://github.com/pypeclub/OpenPype/pull/2546) **🆕 New features** - Flame: extracting segments with trans-coding [\#2547](https://github.com/pypeclub/OpenPype/pull/2547) - Maya : V-Ray Proxy - load all ABC files via proxy [\#2544](https://github.com/pypeclub/OpenPype/pull/2544) - Maya to Unreal: Extended static mesh workflow [\#2537](https://github.com/pypeclub/OpenPype/pull/2537) - Flame: collecting publishable instances [\#2519](https://github.com/pypeclub/OpenPype/pull/2519) - Flame: create publishable clips [\#2495](https://github.com/pypeclub/OpenPype/pull/2495) - Flame: OpenTimelineIO Export Modul [\#2398](https://github.com/pypeclub/OpenPype/pull/2398) **🚀 Enhancements** - Webpublisher: Moved error at the beginning of the log [\#2559](https://github.com/pypeclub/OpenPype/pull/2559) - Ftrack: Use ApplicationManager to get DJV path [\#2558](https://github.com/pypeclub/OpenPype/pull/2558) - Webpublisher: Added endpoint to reprocess batch through UI [\#2555](https://github.com/pypeclub/OpenPype/pull/2555) - Settings: PathInput strip passed string [\#2550](https://github.com/pypeclub/OpenPype/pull/2550) - Global: Exctract Review anatomy fill data with output name [\#2548](https://github.com/pypeclub/OpenPype/pull/2548) - Cosmetics: Clean up some cosmetics / typos [\#2542](https://github.com/pypeclub/OpenPype/pull/2542) - General: Validate if current process OpenPype version is requested version [\#2529](https://github.com/pypeclub/OpenPype/pull/2529) - General: Be able to use anatomy data in ffmpeg output arguments [\#2525](https://github.com/pypeclub/OpenPype/pull/2525) - Expose toggle publish plug-in settings for Maya Look Shading Engine Naming [\#2521](https://github.com/pypeclub/OpenPype/pull/2521) - Photoshop: Move implementation to OpenPype [\#2510](https://github.com/pypeclub/OpenPype/pull/2510) - TimersManager: Move module one hierarchy higher [\#2501](https://github.com/pypeclub/OpenPype/pull/2501) - Slack: notifications are sent with Openpype logo and bot name [\#2499](https://github.com/pypeclub/OpenPype/pull/2499) - Slack: Add review to notification message [\#2498](https://github.com/pypeclub/OpenPype/pull/2498) - Ftrack: Event handlers settings [\#2496](https://github.com/pypeclub/OpenPype/pull/2496) - Tools: Fix style and modality of errors in loader and creator [\#2489](https://github.com/pypeclub/OpenPype/pull/2489) - Maya: Collect 'fps' animation data only for "review" instances [\#2486](https://github.com/pypeclub/OpenPype/pull/2486) - Project Manager: Remove project button cleanup [\#2482](https://github.com/pypeclub/OpenPype/pull/2482) - Tools: Be able to change models of tasks and assets widgets [\#2475](https://github.com/pypeclub/OpenPype/pull/2475) - Publish pype: Reduce publish process defering [\#2464](https://github.com/pypeclub/OpenPype/pull/2464) - Maya: Improve speed of Collect History logic [\#2460](https://github.com/pypeclub/OpenPype/pull/2460) - Maya: Validate Rig Controllers - fix Error: in script editor [\#2459](https://github.com/pypeclub/OpenPype/pull/2459) - Maya: Validate NGONs simplify and speed-up [\#2458](https://github.com/pypeclub/OpenPype/pull/2458) - Maya: Optimize Validate Locked Normals speed for dense polymeshes [\#2457](https://github.com/pypeclub/OpenPype/pull/2457) - Maya: Refactor missing \_get\_reference\_node method [\#2455](https://github.com/pypeclub/OpenPype/pull/2455) - Houdini: Remove broken unique name counter [\#2450](https://github.com/pypeclub/OpenPype/pull/2450) - Maya: Improve lib.polyConstraint performance when Select tool is not the active tool context [\#2447](https://github.com/pypeclub/OpenPype/pull/2447) - General: Validate third party before build [\#2425](https://github.com/pypeclub/OpenPype/pull/2425) - Maya : add option to not group reference in ReferenceLoader [\#2383](https://github.com/pypeclub/OpenPype/pull/2383) **🐛 Bug fixes** - AfterEffects: Fix - removed obsolete import [\#2577](https://github.com/pypeclub/OpenPype/pull/2577) - General: OpenPype version updates [\#2575](https://github.com/pypeclub/OpenPype/pull/2575) - Ftrack: Delete action revision [\#2563](https://github.com/pypeclub/OpenPype/pull/2563) - Webpublisher: ftrack shows incorrect user names [\#2560](https://github.com/pypeclub/OpenPype/pull/2560) - General: Do not validate version if build does not support it [\#2557](https://github.com/pypeclub/OpenPype/pull/2557) - Webpublisher: Fixed progress reporting [\#2553](https://github.com/pypeclub/OpenPype/pull/2553) - Fix Maya AssProxyLoader version switch [\#2551](https://github.com/pypeclub/OpenPype/pull/2551) - General: Fix install thread in igniter [\#2549](https://github.com/pypeclub/OpenPype/pull/2549) - Houdini: vdbcache family preserve frame numbers on publish integration + enable validate version for Houdini [\#2535](https://github.com/pypeclub/OpenPype/pull/2535) - Maya: Fix Load VDB to V-Ray [\#2533](https://github.com/pypeclub/OpenPype/pull/2533) - Maya: ReferenceLoader fix not unique group name error for attach to root [\#2532](https://github.com/pypeclub/OpenPype/pull/2532) - Maya: namespaced context go back to original namespace when started from inside a namespace [\#2531](https://github.com/pypeclub/OpenPype/pull/2531) - Fix create zip tool - path argument [\#2522](https://github.com/pypeclub/OpenPype/pull/2522) - Maya: Fix Extract Look with space in names [\#2518](https://github.com/pypeclub/OpenPype/pull/2518) - Fix published frame content for sequence starting with 0 [\#2513](https://github.com/pypeclub/OpenPype/pull/2513) - Maya: reset empty string attributes correctly to "" instead of "None" [\#2506](https://github.com/pypeclub/OpenPype/pull/2506) - Improve FusionPreLaunch hook errors [\#2505](https://github.com/pypeclub/OpenPype/pull/2505) - General: Settings work if OpenPypeVersion is available [\#2494](https://github.com/pypeclub/OpenPype/pull/2494) - General: PYTHONPATH may break OpenPype dependencies [\#2493](https://github.com/pypeclub/OpenPype/pull/2493) - General: Modules import function output fix [\#2492](https://github.com/pypeclub/OpenPype/pull/2492) - AE: fix hiding of alert window below Publish [\#2491](https://github.com/pypeclub/OpenPype/pull/2491) - Workfiles tool: Files widget show files on first show [\#2488](https://github.com/pypeclub/OpenPype/pull/2488) - General: Custom template paths filter fix [\#2483](https://github.com/pypeclub/OpenPype/pull/2483) - Loader: Remove always on top flag in tray [\#2480](https://github.com/pypeclub/OpenPype/pull/2480) - General: Anatomy does not return root envs as unicode [\#2465](https://github.com/pypeclub/OpenPype/pull/2465) - Maya: Validate Shape Zero do not keep fixed geometry vertices selected/active after repair [\#2456](https://github.com/pypeclub/OpenPype/pull/2456) **Merged pull requests:** - AfterEffects: Move implementation to OpenPype [\#2543](https://github.com/pypeclub/OpenPype/pull/2543) - Maya: Remove Maya Look Assigner check on startup [\#2540](https://github.com/pypeclub/OpenPype/pull/2540) - build\(deps\): bump shelljs from 0.8.4 to 0.8.5 in /website [\#2538](https://github.com/pypeclub/OpenPype/pull/2538) - build\(deps\): bump follow-redirects from 1.14.4 to 1.14.7 in /website [\#2534](https://github.com/pypeclub/OpenPype/pull/2534) - Nuke: Merge avalon's implementation into OpenPype [\#2514](https://github.com/pypeclub/OpenPype/pull/2514) - Maya: Vray fix proxies look assignment [\#2392](https://github.com/pypeclub/OpenPype/pull/2392) - Bump algoliasearch-helper from 3.4.4 to 3.6.2 in /website [\#2297](https://github.com/pypeclub/OpenPype/pull/2297) ## [3.7.0](https://github.com/pypeclub/OpenPype/tree/3.7.0) (2022-01-04) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.4...3.7.0) **Deprecated:** - General: Default modules hierarchy n2 [\#2368](https://github.com/pypeclub/OpenPype/pull/2368) ### 📖 Documentation - docs\[website\]: Add Ellipse Studio \(logo\) as an OpenPype contributor [\#2324](https://github.com/pypeclub/OpenPype/pull/2324) **🆕 New features** - Settings UI use OpenPype styles [\#2296](https://github.com/pypeclub/OpenPype/pull/2296) - Store typed version dependencies for workfiles [\#2192](https://github.com/pypeclub/OpenPype/pull/2192) - OpenPypeV3: add key task type, task shortname and user to path templating construction [\#2157](https://github.com/pypeclub/OpenPype/pull/2157) - Nuke: Alembic model workflow [\#2140](https://github.com/pypeclub/OpenPype/pull/2140) - TVPaint: Load workfile from published. [\#1980](https://github.com/pypeclub/OpenPype/pull/1980) **🚀 Enhancements** - General: Workdir extra folders [\#2462](https://github.com/pypeclub/OpenPype/pull/2462) - Photoshop: New style validations for New publisher [\#2429](https://github.com/pypeclub/OpenPype/pull/2429) - General: Environment variables groups [\#2424](https://github.com/pypeclub/OpenPype/pull/2424) - Unreal: Dynamic menu created in Python [\#2422](https://github.com/pypeclub/OpenPype/pull/2422) - Settings UI: Hyperlinks to settings [\#2420](https://github.com/pypeclub/OpenPype/pull/2420) - Modules: JobQueue module moved one hierarchy level higher [\#2419](https://github.com/pypeclub/OpenPype/pull/2419) - TimersManager: Start timer post launch hook [\#2418](https://github.com/pypeclub/OpenPype/pull/2418) - General: Run applications as separate processes under linux [\#2408](https://github.com/pypeclub/OpenPype/pull/2408) - Ftrack: Check existence of object type on recreation [\#2404](https://github.com/pypeclub/OpenPype/pull/2404) - Enhancement: Global cleanup plugin that explicitly remove paths from context [\#2402](https://github.com/pypeclub/OpenPype/pull/2402) - General: MongoDB ability to specify replica set groups [\#2401](https://github.com/pypeclub/OpenPype/pull/2401) - Flame: moving `utility_scripts` to api folder also with `scripts` [\#2385](https://github.com/pypeclub/OpenPype/pull/2385) - Centos 7 dependency compatibility [\#2384](https://github.com/pypeclub/OpenPype/pull/2384) - Enhancement: Settings: Use project settings values from another project [\#2382](https://github.com/pypeclub/OpenPype/pull/2382) - Blender 3: Support auto install for new blender version [\#2377](https://github.com/pypeclub/OpenPype/pull/2377) - Maya add render image path to settings [\#2375](https://github.com/pypeclub/OpenPype/pull/2375) - Settings: Webpublisher in hosts enum [\#2367](https://github.com/pypeclub/OpenPype/pull/2367) - Hiero: python3 compatibility [\#2365](https://github.com/pypeclub/OpenPype/pull/2365) - Burnins: Be able recognize mxf OPAtom format [\#2361](https://github.com/pypeclub/OpenPype/pull/2361) - Maya: Add is\_static\_image\_plane and is\_in\_all\_views option in imagePlaneLoader [\#2356](https://github.com/pypeclub/OpenPype/pull/2356) - Local settings: Copyable studio paths [\#2349](https://github.com/pypeclub/OpenPype/pull/2349) - Assets Widget: Clear model on project change [\#2345](https://github.com/pypeclub/OpenPype/pull/2345) - General: OpenPype default modules hierarchy [\#2338](https://github.com/pypeclub/OpenPype/pull/2338) - TVPaint: Move implementation to OpenPype [\#2336](https://github.com/pypeclub/OpenPype/pull/2336) - General: FFprobe error exception contain original error message [\#2328](https://github.com/pypeclub/OpenPype/pull/2328) - Resolve: Add experimental button to menu [\#2325](https://github.com/pypeclub/OpenPype/pull/2325) - Hiero: Add experimental tools action [\#2323](https://github.com/pypeclub/OpenPype/pull/2323) - Input links: Cleanup and unification of differences [\#2322](https://github.com/pypeclub/OpenPype/pull/2322) - General: Don't validate vendor bin with executing them [\#2317](https://github.com/pypeclub/OpenPype/pull/2317) - General: Multilayer EXRs support [\#2315](https://github.com/pypeclub/OpenPype/pull/2315) - General: Run process log stderr as info log level [\#2309](https://github.com/pypeclub/OpenPype/pull/2309) - General: Reduce vendor imports [\#2305](https://github.com/pypeclub/OpenPype/pull/2305) - Tools: Cleanup of unused classes [\#2304](https://github.com/pypeclub/OpenPype/pull/2304) - Project Manager: Added ability to delete project [\#2298](https://github.com/pypeclub/OpenPype/pull/2298) - Ftrack: Synchronize input links [\#2287](https://github.com/pypeclub/OpenPype/pull/2287) - StandalonePublisher: Remove unused plugin ExtractHarmonyZip [\#2277](https://github.com/pypeclub/OpenPype/pull/2277) - Ftrack: Support multiple reviews [\#2271](https://github.com/pypeclub/OpenPype/pull/2271) - Ftrack: Remove unused clean component plugin [\#2269](https://github.com/pypeclub/OpenPype/pull/2269) - Royal Render: Support for rr channels in separate dirs [\#2268](https://github.com/pypeclub/OpenPype/pull/2268) - Houdini: Add experimental tools action [\#2267](https://github.com/pypeclub/OpenPype/pull/2267) - Nuke: extract baked review videos presets [\#2248](https://github.com/pypeclub/OpenPype/pull/2248) - TVPaint: Workers rendering [\#2209](https://github.com/pypeclub/OpenPype/pull/2209) - OpenPypeV3: Add key parent asset to path templating construction [\#2186](https://github.com/pypeclub/OpenPype/pull/2186) **🐛 Bug fixes** - TVPaint: Create render layer dialog is in front [\#2471](https://github.com/pypeclub/OpenPype/pull/2471) - Short Pyblish plugin path [\#2428](https://github.com/pypeclub/OpenPype/pull/2428) - PS: Introduced settings for invalid characters to use in ValidateNaming plugin [\#2417](https://github.com/pypeclub/OpenPype/pull/2417) - Settings UI: Breadcrumbs path does not create new entities [\#2416](https://github.com/pypeclub/OpenPype/pull/2416) - AfterEffects: Variant 2022 is in defaults but missing in schemas [\#2412](https://github.com/pypeclub/OpenPype/pull/2412) - Nuke: baking representations was not additive [\#2406](https://github.com/pypeclub/OpenPype/pull/2406) - General: Fix access to environments from default settings [\#2403](https://github.com/pypeclub/OpenPype/pull/2403) - Fix: Placeholder Input color set fix [\#2399](https://github.com/pypeclub/OpenPype/pull/2399) - Settings: Fix state change of wrapper label [\#2396](https://github.com/pypeclub/OpenPype/pull/2396) - Flame: fix ftrack publisher [\#2381](https://github.com/pypeclub/OpenPype/pull/2381) - hiero: solve custom ocio path [\#2379](https://github.com/pypeclub/OpenPype/pull/2379) - hiero: fix workio and flatten [\#2378](https://github.com/pypeclub/OpenPype/pull/2378) - Nuke: fixing menu re-drawing during context change [\#2374](https://github.com/pypeclub/OpenPype/pull/2374) - Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) - Nuke: fixing node name based on switched asset name [\#2369](https://github.com/pypeclub/OpenPype/pull/2369) - JobQueue: Fix loading of settings [\#2362](https://github.com/pypeclub/OpenPype/pull/2362) - Tools: Placeholder color [\#2359](https://github.com/pypeclub/OpenPype/pull/2359) - Launcher: Minimize button on MacOs [\#2355](https://github.com/pypeclub/OpenPype/pull/2355) - StandalonePublisher: Fix import of constant [\#2354](https://github.com/pypeclub/OpenPype/pull/2354) - Houdini: Fix HDA creation [\#2350](https://github.com/pypeclub/OpenPype/pull/2350) - Adobe products show issue [\#2347](https://github.com/pypeclub/OpenPype/pull/2347) - Maya Look Assigner: Fix Python 3 compatibility [\#2343](https://github.com/pypeclub/OpenPype/pull/2343) - Remove wrongly used host for hook [\#2342](https://github.com/pypeclub/OpenPype/pull/2342) - Tools: Use Qt context on tools show [\#2340](https://github.com/pypeclub/OpenPype/pull/2340) - Flame: Fix default argument value in custom dictionary [\#2339](https://github.com/pypeclub/OpenPype/pull/2339) - Timers Manager: Disable auto stop timer on linux platform [\#2334](https://github.com/pypeclub/OpenPype/pull/2334) - nuke: bake preset single input exception [\#2331](https://github.com/pypeclub/OpenPype/pull/2331) - Hiero: fixing multiple templates at a hierarchy parent [\#2330](https://github.com/pypeclub/OpenPype/pull/2330) - Fix - provider icons are pulled from a folder [\#2326](https://github.com/pypeclub/OpenPype/pull/2326) - InputLinks: Typo in "inputLinks" key [\#2314](https://github.com/pypeclub/OpenPype/pull/2314) - Deadline timeout and logging [\#2312](https://github.com/pypeclub/OpenPype/pull/2312) - nuke: do not multiply representation on class method [\#2311](https://github.com/pypeclub/OpenPype/pull/2311) - Workfiles tool: Fix task formatting [\#2306](https://github.com/pypeclub/OpenPype/pull/2306) - Delivery: Fix delivery paths created on windows [\#2302](https://github.com/pypeclub/OpenPype/pull/2302) - Maya: Deadline - fix limit groups [\#2295](https://github.com/pypeclub/OpenPype/pull/2295) - Royal Render: Fix plugin order and OpenPype auto-detection [\#2291](https://github.com/pypeclub/OpenPype/pull/2291) - New Publisher: Fix mapping of indexes [\#2285](https://github.com/pypeclub/OpenPype/pull/2285) - Alternate site for site sync doesnt work for sequences [\#2284](https://github.com/pypeclub/OpenPype/pull/2284) - FFmpeg: Execute ffprobe using list of arguments instead of string command [\#2281](https://github.com/pypeclub/OpenPype/pull/2281) - Nuke: Anatomy fill data use task as dictionary [\#2278](https://github.com/pypeclub/OpenPype/pull/2278) - Bug: fix variable name \_asset\_id in workfiles application [\#2274](https://github.com/pypeclub/OpenPype/pull/2274) - Version handling fixes [\#2272](https://github.com/pypeclub/OpenPype/pull/2272) **Merged pull requests:** - Maya: Replaced PATH usage with vendored oiio path for maketx utility [\#2405](https://github.com/pypeclub/OpenPype/pull/2405) - \[Fix\]\[MAYA\] Handle message type attribute within CollectLook [\#2394](https://github.com/pypeclub/OpenPype/pull/2394) - Add validator to check correct version of extension for PS and AE [\#2387](https://github.com/pypeclub/OpenPype/pull/2387) - Maya: configurable model top level validation [\#2321](https://github.com/pypeclub/OpenPype/pull/2321) - Create test publish class for After Effects [\#2270](https://github.com/pypeclub/OpenPype/pull/2270) ## [3.6.4](https://github.com/pypeclub/OpenPype/tree/3.6.4) (2021-11-23) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.3...3.6.4) **🐛 Bug fixes** - Nuke: inventory update removes all loaded read nodes [\#2294](https://github.com/pypeclub/OpenPype/pull/2294) ## [3.6.3](https://github.com/pypeclub/OpenPype/tree/3.6.3) (2021-11-19) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.2...3.6.3) **🐛 Bug fixes** - Deadline: Fix publish targets [\#2280](https://github.com/pypeclub/OpenPype/pull/2280) ## [3.6.2](https://github.com/pypeclub/OpenPype/tree/3.6.2) (2021-11-18) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.1...3.6.2) **🚀 Enhancements** - Tools: Assets widget [\#2265](https://github.com/pypeclub/OpenPype/pull/2265) - SceneInventory: Choose loader in asset switcher [\#2262](https://github.com/pypeclub/OpenPype/pull/2262) - Style: New fonts in OpenPype style [\#2256](https://github.com/pypeclub/OpenPype/pull/2256) - Tools: SceneInventory in OpenPype [\#2255](https://github.com/pypeclub/OpenPype/pull/2255) - Tools: Tasks widget [\#2251](https://github.com/pypeclub/OpenPype/pull/2251) - Tools: Creator in OpenPype [\#2244](https://github.com/pypeclub/OpenPype/pull/2244) - Added endpoint for configured extensions [\#2221](https://github.com/pypeclub/OpenPype/pull/2221) **🐛 Bug fixes** - Tools: Parenting of tools in Nuke and Hiero [\#2266](https://github.com/pypeclub/OpenPype/pull/2266) - limiting validator to specific editorial hosts [\#2264](https://github.com/pypeclub/OpenPype/pull/2264) - Tools: Select Context dialog attribute fix [\#2261](https://github.com/pypeclub/OpenPype/pull/2261) - Maya: Render publishing fails on linux [\#2260](https://github.com/pypeclub/OpenPype/pull/2260) - LookAssigner: Fix tool reopen [\#2259](https://github.com/pypeclub/OpenPype/pull/2259) - Standalone: editorial not publishing thumbnails on all subsets [\#2258](https://github.com/pypeclub/OpenPype/pull/2258) - Burnins: Support mxf metadata [\#2247](https://github.com/pypeclub/OpenPype/pull/2247) - Maya: Support for configurable AOV separator characters [\#2197](https://github.com/pypeclub/OpenPype/pull/2197) - Maya: texture colorspace modes in looks [\#2195](https://github.com/pypeclub/OpenPype/pull/2195) ## [3.6.1](https://github.com/pypeclub/OpenPype/tree/3.6.1) (2021-11-16) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.0...3.6.1) **🐛 Bug fixes** - Loader doesn't allow changing of version before loading [\#2254](https://github.com/pypeclub/OpenPype/pull/2254) ## [3.6.0](https://github.com/pypeclub/OpenPype/tree/3.6.0) (2021-11-15) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.5.0...3.6.0) ### 📖 Documentation - Add alternative sites for Site Sync [\#2206](https://github.com/pypeclub/OpenPype/pull/2206) - Add command line way of running site sync server [\#2188](https://github.com/pypeclub/OpenPype/pull/2188) **🆕 New features** - Add validate active site button to sync queue on a project [\#2176](https://github.com/pypeclub/OpenPype/pull/2176) - Maya : Colorspace configuration [\#2170](https://github.com/pypeclub/OpenPype/pull/2170) - Blender: Added support for audio [\#2168](https://github.com/pypeclub/OpenPype/pull/2168) - Flame: a host basic integration [\#2165](https://github.com/pypeclub/OpenPype/pull/2165) - Houdini: simple HDA workflow [\#2072](https://github.com/pypeclub/OpenPype/pull/2072) - Basic Royal Render Integration ✨ [\#2061](https://github.com/pypeclub/OpenPype/pull/2061) - Camera handling between Blender and Unreal [\#1988](https://github.com/pypeclub/OpenPype/pull/1988) - switch PyQt5 for PySide2 [\#1744](https://github.com/pypeclub/OpenPype/pull/1744) **🚀 Enhancements** - Tools: Subset manager in OpenPype [\#2243](https://github.com/pypeclub/OpenPype/pull/2243) - General: Skip module directories without init file [\#2239](https://github.com/pypeclub/OpenPype/pull/2239) - General: Static interfaces [\#2238](https://github.com/pypeclub/OpenPype/pull/2238) - Style: Fix transparent image in style [\#2235](https://github.com/pypeclub/OpenPype/pull/2235) - Add a "following workfile versioning" option on publish [\#2225](https://github.com/pypeclub/OpenPype/pull/2225) - Modules: Module can add cli commands [\#2224](https://github.com/pypeclub/OpenPype/pull/2224) - Webpublisher: Separate webpublisher logic [\#2222](https://github.com/pypeclub/OpenPype/pull/2222) - Add both side availability on Site Sync sites to Loader [\#2220](https://github.com/pypeclub/OpenPype/pull/2220) - Tools: Center loader and library loader on show [\#2219](https://github.com/pypeclub/OpenPype/pull/2219) - Maya : Validate shape zero [\#2212](https://github.com/pypeclub/OpenPype/pull/2212) - Maya : validate unique names [\#2211](https://github.com/pypeclub/OpenPype/pull/2211) - Tools: OpenPype stylesheet in workfiles tool [\#2208](https://github.com/pypeclub/OpenPype/pull/2208) - Ftrack: Replace Queue with deque in event handlers logic [\#2204](https://github.com/pypeclub/OpenPype/pull/2204) - Tools: New select context dialog [\#2200](https://github.com/pypeclub/OpenPype/pull/2200) - Maya : Validate mesh ngons [\#2199](https://github.com/pypeclub/OpenPype/pull/2199) - Dirmap in Nuke [\#2198](https://github.com/pypeclub/OpenPype/pull/2198) - Delivery: Check 'frame' key in template for sequence delivery [\#2196](https://github.com/pypeclub/OpenPype/pull/2196) - Settings: Site sync project settings improvement [\#2193](https://github.com/pypeclub/OpenPype/pull/2193) - Usage of tools code [\#2185](https://github.com/pypeclub/OpenPype/pull/2185) - Settings: Dictionary based on project roots [\#2184](https://github.com/pypeclub/OpenPype/pull/2184) - Subset name: Be able to pass asset document to get subset name [\#2179](https://github.com/pypeclub/OpenPype/pull/2179) - Tools: Experimental tools [\#2167](https://github.com/pypeclub/OpenPype/pull/2167) - Loader: Refactor and use OpenPype stylesheets [\#2166](https://github.com/pypeclub/OpenPype/pull/2166) - Add loader for linked smart objects in photoshop [\#2149](https://github.com/pypeclub/OpenPype/pull/2149) - Burnins: DNxHD profiles handling [\#2142](https://github.com/pypeclub/OpenPype/pull/2142) - Tools: Single access point for host tools [\#2139](https://github.com/pypeclub/OpenPype/pull/2139) **🐛 Bug fixes** - Ftrack: Sync project ftrack id cache issue [\#2250](https://github.com/pypeclub/OpenPype/pull/2250) - Ftrack: Session creation and Prepare project [\#2245](https://github.com/pypeclub/OpenPype/pull/2245) - Added queue for studio processing in PS [\#2237](https://github.com/pypeclub/OpenPype/pull/2237) - Python 2: Unicode to string conversion [\#2236](https://github.com/pypeclub/OpenPype/pull/2236) - Fix - enum for color coding in PS [\#2234](https://github.com/pypeclub/OpenPype/pull/2234) - Pyblish Tool: Fix targets handling [\#2232](https://github.com/pypeclub/OpenPype/pull/2232) - Ftrack: Base event fix of 'get\_project\_from\_entity' method [\#2214](https://github.com/pypeclub/OpenPype/pull/2214) - Maya : multiple subsets review broken [\#2210](https://github.com/pypeclub/OpenPype/pull/2210) - Fix - different command used for Linux and Mac OS [\#2207](https://github.com/pypeclub/OpenPype/pull/2207) - Tools: Workfiles tool don't use avalon widgets [\#2205](https://github.com/pypeclub/OpenPype/pull/2205) - Ftrack: Fill missing ftrack id on mongo project [\#2203](https://github.com/pypeclub/OpenPype/pull/2203) - Project Manager: Fix copying of tasks [\#2191](https://github.com/pypeclub/OpenPype/pull/2191) - StandalonePublisher: Source validator don't expect representations [\#2190](https://github.com/pypeclub/OpenPype/pull/2190) - Blender: Fix trying to pack an image when the shader node has no texture [\#2183](https://github.com/pypeclub/OpenPype/pull/2183) - Maya: review viewport settings [\#2177](https://github.com/pypeclub/OpenPype/pull/2177) - MacOS: Launching of applications may cause Permissions error [\#2175](https://github.com/pypeclub/OpenPype/pull/2175) - Maya: Aspect ratio [\#2174](https://github.com/pypeclub/OpenPype/pull/2174) - Blender: Fix 'Deselect All' with object not in 'Object Mode' [\#2163](https://github.com/pypeclub/OpenPype/pull/2163) - Tools: Stylesheets are applied after tool show [\#2161](https://github.com/pypeclub/OpenPype/pull/2161) - Maya: Collect render - fix UNC path support 🐛 [\#2158](https://github.com/pypeclub/OpenPype/pull/2158) - Maya: Fix hotbox broken by scriptsmenu [\#2151](https://github.com/pypeclub/OpenPype/pull/2151) - Ftrack: Ignore save warnings exception in Prepare project action [\#2150](https://github.com/pypeclub/OpenPype/pull/2150) - Loader thumbnails with smooth edges [\#2147](https://github.com/pypeclub/OpenPype/pull/2147) - Added validator for source files for Standalone Publisher [\#2138](https://github.com/pypeclub/OpenPype/pull/2138) **Merged pull requests:** - Bump pillow from 8.2.0 to 8.3.2 [\#2162](https://github.com/pypeclub/OpenPype/pull/2162) - Bump axios from 0.21.1 to 0.21.4 in /website [\#2059](https://github.com/pypeclub/OpenPype/pull/2059) ## [3.5.0](https://github.com/pypeclub/OpenPype/tree/3.5.0) (2021-10-17) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.4.1...3.5.0) **Deprecated:** - Maya: Change mayaAscii family to mayaScene [\#2106](https://github.com/pypeclub/OpenPype/pull/2106) **🆕 New features** - Added project and task into context change message in Maya [\#2131](https://github.com/pypeclub/OpenPype/pull/2131) - Add ExtractBurnin to photoshop review [\#2124](https://github.com/pypeclub/OpenPype/pull/2124) - PYPE-1218 - changed namespace to contain subset name in Maya [\#2114](https://github.com/pypeclub/OpenPype/pull/2114) - Added running configurable disk mapping command before start of OP [\#2091](https://github.com/pypeclub/OpenPype/pull/2091) - SFTP provider [\#2073](https://github.com/pypeclub/OpenPype/pull/2073) - Maya: Validate setdress top group [\#2068](https://github.com/pypeclub/OpenPype/pull/2068) - Maya: Enable publishing render attrib sets \(e.g. V-Ray Displacement\) with model [\#1955](https://github.com/pypeclub/OpenPype/pull/1955) **🚀 Enhancements** - Maya: make rig validators configurable in settings [\#2137](https://github.com/pypeclub/OpenPype/pull/2137) - Settings: Updated readme for entity types in settings [\#2132](https://github.com/pypeclub/OpenPype/pull/2132) - Nuke: unified clip loader [\#2128](https://github.com/pypeclub/OpenPype/pull/2128) - Settings UI: Project model refreshing and sorting [\#2104](https://github.com/pypeclub/OpenPype/pull/2104) - Create Read From Rendered - Disable Relative paths by default [\#2093](https://github.com/pypeclub/OpenPype/pull/2093) - Added choosing different dirmap mapping if workfile synched locally [\#2088](https://github.com/pypeclub/OpenPype/pull/2088) - General: Remove IdleManager module [\#2084](https://github.com/pypeclub/OpenPype/pull/2084) - Tray UI: Message box about missing settings defaults [\#2080](https://github.com/pypeclub/OpenPype/pull/2080) - Tray UI: Show menu where first click happened [\#2079](https://github.com/pypeclub/OpenPype/pull/2079) - Global: add global validators to settings [\#2078](https://github.com/pypeclub/OpenPype/pull/2078) - Use CRF for burnin when available [\#2070](https://github.com/pypeclub/OpenPype/pull/2070) - Project manager: Filter first item after selection of project [\#2069](https://github.com/pypeclub/OpenPype/pull/2069) - Nuke: Adding `still` image family workflow [\#2064](https://github.com/pypeclub/OpenPype/pull/2064) - Maya: validate authorized loaded plugins [\#2062](https://github.com/pypeclub/OpenPype/pull/2062) - Tools: add support for pyenv on windows [\#2051](https://github.com/pypeclub/OpenPype/pull/2051) - SyncServer: Dropbox Provider [\#1979](https://github.com/pypeclub/OpenPype/pull/1979) - Burnin: Get data from context with defined keys. [\#1897](https://github.com/pypeclub/OpenPype/pull/1897) - Timers manager: Get task time [\#1896](https://github.com/pypeclub/OpenPype/pull/1896) - TVPaint: Option to stop timer on application exit. [\#1887](https://github.com/pypeclub/OpenPype/pull/1887) **🐛 Bug fixes** - Maya: fix model publishing [\#2130](https://github.com/pypeclub/OpenPype/pull/2130) - Fix - oiiotool wasn't recognized even if present [\#2129](https://github.com/pypeclub/OpenPype/pull/2129) - General: Disk mapping group [\#2120](https://github.com/pypeclub/OpenPype/pull/2120) - Hiero: publishing effect first time makes wrong resources path [\#2115](https://github.com/pypeclub/OpenPype/pull/2115) - Add startup script for Houdini Core. [\#2110](https://github.com/pypeclub/OpenPype/pull/2110) - TVPaint: Behavior name of loop also accept repeat [\#2109](https://github.com/pypeclub/OpenPype/pull/2109) - Ftrack: Project settings save custom attributes skip unknown attributes [\#2103](https://github.com/pypeclub/OpenPype/pull/2103) - Blender: Fix NoneType error when animation\_data is missing for a rig [\#2101](https://github.com/pypeclub/OpenPype/pull/2101) - Fix broken import in sftp provider [\#2100](https://github.com/pypeclub/OpenPype/pull/2100) - Global: Fix docstring on publish plugin extract review [\#2097](https://github.com/pypeclub/OpenPype/pull/2097) - Delivery Action Files Sequence fix [\#2096](https://github.com/pypeclub/OpenPype/pull/2096) - General: Cloud mongo ca certificate issue [\#2095](https://github.com/pypeclub/OpenPype/pull/2095) - TVPaint: Creator use context from workfile [\#2087](https://github.com/pypeclub/OpenPype/pull/2087) - Blender: fix texture missing when publishing blend files [\#2085](https://github.com/pypeclub/OpenPype/pull/2085) - General: Startup validations oiio tool path fix on linux [\#2083](https://github.com/pypeclub/OpenPype/pull/2083) - Deadline: Collect deadline server does not check existence of deadline key [\#2082](https://github.com/pypeclub/OpenPype/pull/2082) - Blender: fixed Curves with modifiers in Rigs [\#2081](https://github.com/pypeclub/OpenPype/pull/2081) - Nuke UI scaling [\#2077](https://github.com/pypeclub/OpenPype/pull/2077) - Maya: Fix multi-camera renders [\#2065](https://github.com/pypeclub/OpenPype/pull/2065) - Fix Sync Queue when project disabled [\#2063](https://github.com/pypeclub/OpenPype/pull/2063) **Merged pull requests:** - Bump pywin32 from 300 to 301 [\#2086](https://github.com/pypeclub/OpenPype/pull/2086) ## [3.4.1](https://github.com/pypeclub/OpenPype/tree/3.4.1) (2021-09-23) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.4.0...3.4.1) **🆕 New features** - Settings: Flag project as deactivated and hide from tools' view [\#2008](https://github.com/pypeclub/OpenPype/pull/2008) **🚀 Enhancements** - General: Startup validations [\#2054](https://github.com/pypeclub/OpenPype/pull/2054) - Nuke: proxy mode validator [\#2052](https://github.com/pypeclub/OpenPype/pull/2052) - Ftrack: Removed ftrack interface [\#2049](https://github.com/pypeclub/OpenPype/pull/2049) - Settings UI: Deffered set value on entity [\#2044](https://github.com/pypeclub/OpenPype/pull/2044) - Loader: Families filtering [\#2043](https://github.com/pypeclub/OpenPype/pull/2043) - Settings UI: Project view enhancements [\#2042](https://github.com/pypeclub/OpenPype/pull/2042) - Settings for Nuke IncrementScriptVersion [\#2039](https://github.com/pypeclub/OpenPype/pull/2039) - Loader & Library loader: Use tools from OpenPype [\#2038](https://github.com/pypeclub/OpenPype/pull/2038) - Adding predefined project folders creation in PM [\#2030](https://github.com/pypeclub/OpenPype/pull/2030) - WebserverModule: Removed interface of webserver module [\#2028](https://github.com/pypeclub/OpenPype/pull/2028) - TimersManager: Removed interface of timers manager [\#2024](https://github.com/pypeclub/OpenPype/pull/2024) - Feature Maya import asset from scene inventory [\#2018](https://github.com/pypeclub/OpenPype/pull/2018) **🐛 Bug fixes** - Timers manger: Typo fix [\#2058](https://github.com/pypeclub/OpenPype/pull/2058) - Hiero: Editorial fixes [\#2057](https://github.com/pypeclub/OpenPype/pull/2057) - Differentiate jpg sequences from thumbnail [\#2056](https://github.com/pypeclub/OpenPype/pull/2056) - FFmpeg: Split command to list does not work [\#2046](https://github.com/pypeclub/OpenPype/pull/2046) - Removed shell flag in subprocess call [\#2045](https://github.com/pypeclub/OpenPype/pull/2045) **Merged pull requests:** - Bump prismjs from 1.24.0 to 1.25.0 in /website [\#2050](https://github.com/pypeclub/OpenPype/pull/2050) ## [3.4.0](https://github.com/pypeclub/OpenPype/tree/3.4.0) (2021-09-17) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.3.1...3.4.0) ### 📖 Documentation - Documentation: Ftrack launch argsuments update [\#2014](https://github.com/pypeclub/OpenPype/pull/2014) - Nuke Quick Start / Tutorial [\#1952](https://github.com/pypeclub/OpenPype/pull/1952) - Houdini: add Camera, Point Cache, Composite, Redshift ROP and VDB Cache support [\#1821](https://github.com/pypeclub/OpenPype/pull/1821) **🆕 New features** - Nuke: Compatibility with Nuke 13 [\#2003](https://github.com/pypeclub/OpenPype/pull/2003) - Maya: Add Xgen family support [\#1947](https://github.com/pypeclub/OpenPype/pull/1947) - Feature/webpublisher backend [\#1876](https://github.com/pypeclub/OpenPype/pull/1876) - Blender: Improved assets handling [\#1615](https://github.com/pypeclub/OpenPype/pull/1615) **🚀 Enhancements** - Added possibility to configure of synchronization of workfile version… [\#2041](https://github.com/pypeclub/OpenPype/pull/2041) - General: Task types in profiles [\#2036](https://github.com/pypeclub/OpenPype/pull/2036) - Console interpreter: Handle invalid sizes on initialization [\#2022](https://github.com/pypeclub/OpenPype/pull/2022) - Ftrack: Show OpenPype versions in event server status [\#2019](https://github.com/pypeclub/OpenPype/pull/2019) - General: Staging icon [\#2017](https://github.com/pypeclub/OpenPype/pull/2017) - Ftrack: Sync to avalon actions have jobs [\#2015](https://github.com/pypeclub/OpenPype/pull/2015) - Modules: Connect method is not required [\#2009](https://github.com/pypeclub/OpenPype/pull/2009) - Settings UI: Number with configurable steps [\#2001](https://github.com/pypeclub/OpenPype/pull/2001) - Moving project folder structure creation out of ftrack module \#1989 [\#1996](https://github.com/pypeclub/OpenPype/pull/1996) - Configurable items for providers without Settings [\#1987](https://github.com/pypeclub/OpenPype/pull/1987) - Global: Example addons [\#1986](https://github.com/pypeclub/OpenPype/pull/1986) - Standalone Publisher: Extract harmony zip handle workfile template [\#1982](https://github.com/pypeclub/OpenPype/pull/1982) - Settings UI: Number sliders [\#1978](https://github.com/pypeclub/OpenPype/pull/1978) - Workfiles: Support more workfile templates [\#1966](https://github.com/pypeclub/OpenPype/pull/1966) - Launcher: Fix crashes on action click [\#1964](https://github.com/pypeclub/OpenPype/pull/1964) - Settings: Minor fixes in UI and missing default values [\#1963](https://github.com/pypeclub/OpenPype/pull/1963) - Blender: Toggle system console works on windows [\#1962](https://github.com/pypeclub/OpenPype/pull/1962) - Global: Settings defined by Addons/Modules [\#1959](https://github.com/pypeclub/OpenPype/pull/1959) - CI: change release numbering triggers [\#1954](https://github.com/pypeclub/OpenPype/pull/1954) - Global: Avalon Host name collector [\#1949](https://github.com/pypeclub/OpenPype/pull/1949) - Global: Define hosts in CollectSceneVersion [\#1948](https://github.com/pypeclub/OpenPype/pull/1948) - Add face sets to exported alembics [\#1942](https://github.com/pypeclub/OpenPype/pull/1942) - OpenPype: Add version validation and `--headless` mode and update progress 🔄 [\#1939](https://github.com/pypeclub/OpenPype/pull/1939) - \#1894 - adds host to template\_name\_profiles for filtering [\#1915](https://github.com/pypeclub/OpenPype/pull/1915) - Environments: Tool environments in alphabetical order [\#1910](https://github.com/pypeclub/OpenPype/pull/1910) - Disregard publishing time. [\#1888](https://github.com/pypeclub/OpenPype/pull/1888) - Dynamic modules [\#1872](https://github.com/pypeclub/OpenPype/pull/1872) **🐛 Bug fixes** - Workfiles tool: Task selection [\#2040](https://github.com/pypeclub/OpenPype/pull/2040) - Ftrack: Delete old versions missing settings key [\#2037](https://github.com/pypeclub/OpenPype/pull/2037) - Nuke: typo on a button [\#2034](https://github.com/pypeclub/OpenPype/pull/2034) - Hiero: Fix "none" named tags [\#2033](https://github.com/pypeclub/OpenPype/pull/2033) - FFmpeg: Subprocess arguments as list [\#2032](https://github.com/pypeclub/OpenPype/pull/2032) - General: Fix Python 2 breaking line [\#2016](https://github.com/pypeclub/OpenPype/pull/2016) - Bugfix/webpublisher task type [\#2006](https://github.com/pypeclub/OpenPype/pull/2006) - Nuke thumbnails generated from middle of the sequence [\#1992](https://github.com/pypeclub/OpenPype/pull/1992) - Nuke: last version from path gets correct version [\#1990](https://github.com/pypeclub/OpenPype/pull/1990) - nuke, resolve, hiero: precollector order lest then 0.5 [\#1984](https://github.com/pypeclub/OpenPype/pull/1984) - Last workfile with multiple work templates [\#1981](https://github.com/pypeclub/OpenPype/pull/1981) - Collectors order [\#1977](https://github.com/pypeclub/OpenPype/pull/1977) - Stop timer was within validator order range. [\#1975](https://github.com/pypeclub/OpenPype/pull/1975) - Ftrack: arrow submodule has https url source [\#1974](https://github.com/pypeclub/OpenPype/pull/1974) - Ftrack: Fix hosts attribute in collect ftrack username [\#1972](https://github.com/pypeclub/OpenPype/pull/1972) - Deadline: Houdini plugins in different hierarchy [\#1970](https://github.com/pypeclub/OpenPype/pull/1970) - Removed deprecated submodules [\#1967](https://github.com/pypeclub/OpenPype/pull/1967) - Global: ExtractJpeg can handle filepaths with spaces [\#1961](https://github.com/pypeclub/OpenPype/pull/1961) - Resolve path when adding to zip [\#1960](https://github.com/pypeclub/OpenPype/pull/1960) **Merged pull requests:** - Bump url-parse from 1.5.1 to 1.5.3 in /website [\#1958](https://github.com/pypeclub/OpenPype/pull/1958) - Bump path-parse from 1.0.6 to 1.0.7 in /website [\#1933](https://github.com/pypeclub/OpenPype/pull/1933) ## [3.3.1](https://github.com/pypeclub/OpenPype/tree/3.3.1) (2021-08-20) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.3.0...3.3.1) **🐛 Bug fixes** - TVPaint: Fixed rendered frame indexes [\#1946](https://github.com/pypeclub/OpenPype/pull/1946) - Maya: Menu actions fix [\#1945](https://github.com/pypeclub/OpenPype/pull/1945) - standalone: editorial shared object problem [\#1941](https://github.com/pypeclub/OpenPype/pull/1941) - Bugfix nuke deadline app name [\#1928](https://github.com/pypeclub/OpenPype/pull/1928) ## [3.3.0](https://github.com/pypeclub/OpenPype/tree/3.3.0) (2021-08-17) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.2.0...3.3.0) ### 📖 Documentation - Standalone Publish of textures family [\#1834](https://github.com/pypeclub/OpenPype/pull/1834) **🆕 New features** - Settings UI: Breadcrumbs in settings [\#1932](https://github.com/pypeclub/OpenPype/pull/1932) - Maya: Scene patching 🩹on submission to Deadline [\#1923](https://github.com/pypeclub/OpenPype/pull/1923) - Feature AE local render [\#1901](https://github.com/pypeclub/OpenPype/pull/1901) **🚀 Enhancements** - Python console interpreter [\#1940](https://github.com/pypeclub/OpenPype/pull/1940) - Global: Updated logos and Default settings [\#1927](https://github.com/pypeclub/OpenPype/pull/1927) - Check for missing ✨ Python when using `pyenv` [\#1925](https://github.com/pypeclub/OpenPype/pull/1925) - Settings: Default values for enum [\#1920](https://github.com/pypeclub/OpenPype/pull/1920) - Settings UI: Modifiable dict view enhance [\#1919](https://github.com/pypeclub/OpenPype/pull/1919) - submodules: avalon-core update [\#1911](https://github.com/pypeclub/OpenPype/pull/1911) - Ftrack: Where I run action enhancement [\#1900](https://github.com/pypeclub/OpenPype/pull/1900) - Ftrack: Private project server actions [\#1899](https://github.com/pypeclub/OpenPype/pull/1899) - Support nested studio plugins paths. [\#1898](https://github.com/pypeclub/OpenPype/pull/1898) - Settings: global validators with options [\#1892](https://github.com/pypeclub/OpenPype/pull/1892) - Settings: Conditional dict enum positioning [\#1891](https://github.com/pypeclub/OpenPype/pull/1891) - Expose stop timer through rest api. [\#1886](https://github.com/pypeclub/OpenPype/pull/1886) - TVPaint: Increment workfile [\#1885](https://github.com/pypeclub/OpenPype/pull/1885) - Allow Multiple Notes to run on tasks. [\#1882](https://github.com/pypeclub/OpenPype/pull/1882) - Prepare for pyside2 [\#1869](https://github.com/pypeclub/OpenPype/pull/1869) - Filter hosts in settings host-enum [\#1868](https://github.com/pypeclub/OpenPype/pull/1868) - Local actions with process identifier [\#1867](https://github.com/pypeclub/OpenPype/pull/1867) - Workfile tool start at host launch support [\#1865](https://github.com/pypeclub/OpenPype/pull/1865) - Anatomy schema validation [\#1864](https://github.com/pypeclub/OpenPype/pull/1864) - Ftrack prepare project structure [\#1861](https://github.com/pypeclub/OpenPype/pull/1861) - Maya: support for configurable `dirmap` 🗺️ [\#1859](https://github.com/pypeclub/OpenPype/pull/1859) - Independent general environments [\#1853](https://github.com/pypeclub/OpenPype/pull/1853) - TVPaint Start Frame [\#1844](https://github.com/pypeclub/OpenPype/pull/1844) - Ftrack push attributes action adds traceback to job [\#1843](https://github.com/pypeclub/OpenPype/pull/1843) - Prepare project action enhance [\#1838](https://github.com/pypeclub/OpenPype/pull/1838) - nuke: settings create missing default subsets [\#1829](https://github.com/pypeclub/OpenPype/pull/1829) - Update poetry lock [\#1823](https://github.com/pypeclub/OpenPype/pull/1823) - Settings: settings for plugins [\#1819](https://github.com/pypeclub/OpenPype/pull/1819) - Settings list can use template or schema as object type [\#1815](https://github.com/pypeclub/OpenPype/pull/1815) - Maya: Deadline custom settings [\#1797](https://github.com/pypeclub/OpenPype/pull/1797) - Maya: Shader name validation [\#1762](https://github.com/pypeclub/OpenPype/pull/1762) **🐛 Bug fixes** - Fix - ftrack family was added incorrectly in some cases [\#1935](https://github.com/pypeclub/OpenPype/pull/1935) - Fix - Deadline publish on Linux started Tray instead of headless publishing [\#1930](https://github.com/pypeclub/OpenPype/pull/1930) - Maya: Validate Model Name - repair accident deletion in settings defaults [\#1929](https://github.com/pypeclub/OpenPype/pull/1929) - Nuke: submit to farm failed due `ftrack` family remove [\#1926](https://github.com/pypeclub/OpenPype/pull/1926) - Fix - validate takes repre\["files"\] as list all the time [\#1922](https://github.com/pypeclub/OpenPype/pull/1922) - standalone: validator asset parents [\#1917](https://github.com/pypeclub/OpenPype/pull/1917) - Nuke: update video file crassing [\#1916](https://github.com/pypeclub/OpenPype/pull/1916) - Fix - texture validators for workfiles triggers only for textures workfiles [\#1914](https://github.com/pypeclub/OpenPype/pull/1914) - Settings UI: List order works as expected [\#1906](https://github.com/pypeclub/OpenPype/pull/1906) - Hiero: loaded clip was not set colorspace from version data [\#1904](https://github.com/pypeclub/OpenPype/pull/1904) - Pyblish UI: Fix collecting stage processing [\#1903](https://github.com/pypeclub/OpenPype/pull/1903) - Burnins: Use input's bitrate in h624 [\#1902](https://github.com/pypeclub/OpenPype/pull/1902) - Bug: fixed python detection [\#1893](https://github.com/pypeclub/OpenPype/pull/1893) - global: integrate name missing default template [\#1890](https://github.com/pypeclub/OpenPype/pull/1890) - publisher: editorial plugins fixes [\#1889](https://github.com/pypeclub/OpenPype/pull/1889) - Normalize path returned from Workfiles. [\#1880](https://github.com/pypeclub/OpenPype/pull/1880) - Workfiles tool event arguments fix [\#1862](https://github.com/pypeclub/OpenPype/pull/1862) - imageio: fix grouping [\#1856](https://github.com/pypeclub/OpenPype/pull/1856) - Maya: don't add reference members as connections to the container set 📦 [\#1855](https://github.com/pypeclub/OpenPype/pull/1855) - publisher: missing version in subset prop [\#1849](https://github.com/pypeclub/OpenPype/pull/1849) - Ftrack type error fix in sync to avalon event handler [\#1845](https://github.com/pypeclub/OpenPype/pull/1845) - Nuke: updating effects subset fail [\#1841](https://github.com/pypeclub/OpenPype/pull/1841) - nuke: write render node skipped with crop [\#1836](https://github.com/pypeclub/OpenPype/pull/1836) - Project folder structure overrides [\#1813](https://github.com/pypeclub/OpenPype/pull/1813) - Maya: fix yeti settings path in extractor [\#1809](https://github.com/pypeclub/OpenPype/pull/1809) - Failsafe for cross project containers. [\#1806](https://github.com/pypeclub/OpenPype/pull/1806) - Houdini colector formatting keys fix [\#1802](https://github.com/pypeclub/OpenPype/pull/1802) - Settings error dialog on show [\#1798](https://github.com/pypeclub/OpenPype/pull/1798) - Application launch stdout/stderr in GUI build [\#1684](https://github.com/pypeclub/OpenPype/pull/1684) - Nuke: re-use instance nodes output path [\#1577](https://github.com/pypeclub/OpenPype/pull/1577) **Merged pull requests:** - Fix - make AE workfile publish to Ftrack configurable [\#1937](https://github.com/pypeclub/OpenPype/pull/1937) - Add support for multiple Deadline ☠️➖ servers [\#1905](https://github.com/pypeclub/OpenPype/pull/1905) - Maya: add support for `RedshiftNormalMap` node, fix `tx` linear space 🚀 [\#1863](https://github.com/pypeclub/OpenPype/pull/1863) - Maya: expected files -\> render products ⚙️ overhaul [\#1812](https://github.com/pypeclub/OpenPype/pull/1812) - PS, AE - send actual context when another webserver is running [\#1811](https://github.com/pypeclub/OpenPype/pull/1811) ## [3.2.0](https://github.com/pypeclub/OpenPype/tree/3.2.0) (2021-07-13) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.4...3.2.0) ### 📖 Documentation - Fix: staging and `--use-version` option [\#1786](https://github.com/pypeclub/OpenPype/pull/1786) - Subset template and TVPaint subset template docs [\#1717](https://github.com/pypeclub/OpenPype/pull/1717) - Overscan color extract review [\#1701](https://github.com/pypeclub/OpenPype/pull/1701) **🚀 Enhancements** - Nuke: ftrack family plugin settings preset [\#1805](https://github.com/pypeclub/OpenPype/pull/1805) - Standalone publisher last project [\#1799](https://github.com/pypeclub/OpenPype/pull/1799) - Ftrack Multiple notes as server action [\#1795](https://github.com/pypeclub/OpenPype/pull/1795) - Settings conditional dict [\#1777](https://github.com/pypeclub/OpenPype/pull/1777) - Settings application use python 2 only where needed [\#1776](https://github.com/pypeclub/OpenPype/pull/1776) - Settings UI copy/paste [\#1769](https://github.com/pypeclub/OpenPype/pull/1769) - Workfile tool widths [\#1766](https://github.com/pypeclub/OpenPype/pull/1766) - Push hierarchical attributes care about task parent changes [\#1763](https://github.com/pypeclub/OpenPype/pull/1763) - Application executables with environment variables [\#1757](https://github.com/pypeclub/OpenPype/pull/1757) - Deadline: Nuke submission additional attributes [\#1756](https://github.com/pypeclub/OpenPype/pull/1756) - Settings schema without prefill [\#1753](https://github.com/pypeclub/OpenPype/pull/1753) - Settings Hosts enum [\#1739](https://github.com/pypeclub/OpenPype/pull/1739) - Validate containers settings [\#1736](https://github.com/pypeclub/OpenPype/pull/1736) - PS - added loader from sequence [\#1726](https://github.com/pypeclub/OpenPype/pull/1726) - Autoupdate launcher [\#1725](https://github.com/pypeclub/OpenPype/pull/1725) - Toggle Ftrack upload in StandalonePublisher [\#1708](https://github.com/pypeclub/OpenPype/pull/1708) - Nuke: Prerender Frame Range by default [\#1699](https://github.com/pypeclub/OpenPype/pull/1699) - Smoother edges of color triangle [\#1695](https://github.com/pypeclub/OpenPype/pull/1695) **🐛 Bug fixes** - nuke: fixing wrong name of family folder when `used existing frames` [\#1803](https://github.com/pypeclub/OpenPype/pull/1803) - Collect ftrack family bugs [\#1801](https://github.com/pypeclub/OpenPype/pull/1801) - Invitee email can be None which break the Ftrack commit. [\#1788](https://github.com/pypeclub/OpenPype/pull/1788) - Otio unrelated error on import [\#1782](https://github.com/pypeclub/OpenPype/pull/1782) - FFprobe streams order [\#1775](https://github.com/pypeclub/OpenPype/pull/1775) - Fix - single file files are str only, cast it to list to count properly [\#1772](https://github.com/pypeclub/OpenPype/pull/1772) - Environments in app executable for MacOS [\#1768](https://github.com/pypeclub/OpenPype/pull/1768) - Project specific environments [\#1767](https://github.com/pypeclub/OpenPype/pull/1767) - Settings UI with refresh button [\#1764](https://github.com/pypeclub/OpenPype/pull/1764) - Standalone publisher thumbnail extractor fix [\#1761](https://github.com/pypeclub/OpenPype/pull/1761) - Anatomy others templates don't cause crash [\#1758](https://github.com/pypeclub/OpenPype/pull/1758) - Backend acre module commit update [\#1745](https://github.com/pypeclub/OpenPype/pull/1745) - hiero: precollect instances failing when audio selected [\#1743](https://github.com/pypeclub/OpenPype/pull/1743) - Hiero: creator instance error [\#1742](https://github.com/pypeclub/OpenPype/pull/1742) - Nuke: fixing render creator for no selection format failing [\#1741](https://github.com/pypeclub/OpenPype/pull/1741) - StandalonePublisher: failing collector for editorial [\#1738](https://github.com/pypeclub/OpenPype/pull/1738) - Local settings UI crash on missing defaults [\#1737](https://github.com/pypeclub/OpenPype/pull/1737) - TVPaint white background on thumbnail [\#1735](https://github.com/pypeclub/OpenPype/pull/1735) - Ftrack missing custom attribute message [\#1734](https://github.com/pypeclub/OpenPype/pull/1734) - Launcher project changes [\#1733](https://github.com/pypeclub/OpenPype/pull/1733) - Ftrack sync status [\#1732](https://github.com/pypeclub/OpenPype/pull/1732) - TVPaint use layer name for default variant [\#1724](https://github.com/pypeclub/OpenPype/pull/1724) - Default subset template for TVPaint review and workfile families [\#1716](https://github.com/pypeclub/OpenPype/pull/1716) - Maya: Extract review hotfix [\#1714](https://github.com/pypeclub/OpenPype/pull/1714) - Settings: Imageio improving granularity [\#1711](https://github.com/pypeclub/OpenPype/pull/1711) - Application without executables [\#1679](https://github.com/pypeclub/OpenPype/pull/1679) - Unreal: launching on Linux [\#1672](https://github.com/pypeclub/OpenPype/pull/1672) **Merged pull requests:** - Bump prismjs from 1.23.0 to 1.24.0 in /website [\#1773](https://github.com/pypeclub/OpenPype/pull/1773) - TVPaint ftrack family [\#1755](https://github.com/pypeclub/OpenPype/pull/1755) ## [2.18.4](https://github.com/pypeclub/OpenPype/tree/2.18.4) (2021-06-24) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.3...2.18.4) ## [2.18.3](https://github.com/pypeclub/OpenPype/tree/2.18.3) (2021-06-23) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.2...2.18.3) ## [2.18.2](https://github.com/pypeclub/OpenPype/tree/2.18.2) (2021-06-16) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.1.0...2.18.2) ## [3.1.0](https://github.com/pypeclub/OpenPype/tree/3.1.0) (2021-06-15) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.0.0...3.1.0) ### 📖 Documentation - Feature Slack integration [\#1657](https://github.com/pypeclub/OpenPype/pull/1657) **🚀 Enhancements** - Log Viewer with OpenPype style [\#1703](https://github.com/pypeclub/OpenPype/pull/1703) - Scrolling in OpenPype info widget [\#1702](https://github.com/pypeclub/OpenPype/pull/1702) - OpenPype style in modules [\#1694](https://github.com/pypeclub/OpenPype/pull/1694) - Sort applications and tools alphabetically in Settings UI [\#1689](https://github.com/pypeclub/OpenPype/pull/1689) - \#683 - Validate Frame Range in Standalone Publisher [\#1683](https://github.com/pypeclub/OpenPype/pull/1683) - Hiero: old container versions identify with red color [\#1682](https://github.com/pypeclub/OpenPype/pull/1682) - Project Manger: Default name column width [\#1669](https://github.com/pypeclub/OpenPype/pull/1669) - Remove outline in stylesheet [\#1667](https://github.com/pypeclub/OpenPype/pull/1667) - TVPaint: Creator take layer name as default value for subset variant [\#1663](https://github.com/pypeclub/OpenPype/pull/1663) - TVPaint custom subset template [\#1662](https://github.com/pypeclub/OpenPype/pull/1662) - Editorial: conform assets validator [\#1659](https://github.com/pypeclub/OpenPype/pull/1659) - Nuke - Publish simplification [\#1653](https://github.com/pypeclub/OpenPype/pull/1653) - \#1333 - added tooltip hints to Pyblish buttons [\#1649](https://github.com/pypeclub/OpenPype/pull/1649) **🐛 Bug fixes** - Nuke: broken publishing rendered frames [\#1707](https://github.com/pypeclub/OpenPype/pull/1707) - Standalone publisher Thumbnail export args [\#1705](https://github.com/pypeclub/OpenPype/pull/1705) - Bad zip can break OpenPype start [\#1691](https://github.com/pypeclub/OpenPype/pull/1691) - Hiero: published whole edit mov [\#1687](https://github.com/pypeclub/OpenPype/pull/1687) - Ftrack subprocess handle of stdout/stderr [\#1675](https://github.com/pypeclub/OpenPype/pull/1675) - Settings list race condifiton and mutable dict list conversion [\#1671](https://github.com/pypeclub/OpenPype/pull/1671) - Mac launch arguments fix [\#1660](https://github.com/pypeclub/OpenPype/pull/1660) - Fix missing dbm python module [\#1652](https://github.com/pypeclub/OpenPype/pull/1652) - Transparent branches in view on Mac [\#1648](https://github.com/pypeclub/OpenPype/pull/1648) - Add asset on task item [\#1646](https://github.com/pypeclub/OpenPype/pull/1646) - Project manager save and queue [\#1645](https://github.com/pypeclub/OpenPype/pull/1645) - New project anatomy values [\#1644](https://github.com/pypeclub/OpenPype/pull/1644) - Farm publishing: check if published items do exist [\#1573](https://github.com/pypeclub/OpenPype/pull/1573) **Merged pull requests:** - Bump normalize-url from 4.5.0 to 4.5.1 in /website [\#1686](https://github.com/pypeclub/OpenPype/pull/1686) ## [3.0.0](https://github.com/pypeclub/openpype/tree/3.0.0) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.18.1...3.0.0) ### Configuration - Studio Settings GUI: no more json configuration files. - OpenPype Modules can be turned on and off. - Easy to add Application versions. - Per Project Environment and plugin management. - Robust profile system for creating reviewables and burnins, with filtering based on Application, Task and data family. - Configurable publish plugins. - Options to make any validator or extractor, optional or disabled. - Color Management is now unified under anatomy settings. - Subset naming and grouping is fully configurable. - All project attributes can now be set directly in OpenPype settings. - Studio Setting can be locked to prevent unwanted artist changes. - You can now add per project and per task type templates for workfile initialization in most hosts. - Too many other individual configurable option to list in this changelog :) ### Local Settings - Local Settings GUI where users can change certain option on individual basis. - Application executables. - Project roots. - Project site sync settings. ### Build, Installation and Deployments - No requirements on artist machine. - Fully distributed workflow possible. - Self-contained installation. - Available on all three major platforms. - Automatic artist OpenPype updates. - Studio OpenPype repository for updates distribution. - Robust Build system. - Safe studio update versioning with staging and production options. - MacOS build generates .app and .dmg installer. - Windows build with installer creation script. ### Misc - System and diagnostic info tool in the tray. - Launching application from Launcher indicates activity. - All project roots are now named. Single root project are now achieved by having a single named root in the project anatomy. - Every project root is cast into environment variable as well, so it can be used in DCC instead of absolute path (depends on DCC support for env vars). - Basic support for task types, on top of task names. - Timer now change automatically when the context is switched inside running application. - 'Master" versions have been renamed to "Hero". - Extract Burnins now supports file sequences and color settings. - Extract Review support overscan cropping, better letterboxes and background colour fill. - Delivery tool for copying and renaming any published assets in bulk. - Harmony, Photoshop and After Effects now connect directly with OpenPype tray instead of spawning their own terminal. ### Project Manager GUI - Create Projects. - Create Shots and Assets. - Create Tasks and assign task types. - Fill required asset attributes. - Validations for duplicated or unsupported names. - Archive Assets. - Move Asset within hierarchy. ### Site Sync (beta) - Synchronization of published files between workstations and central storage. - Ability to add arbitrary storage providers to the Site Sync system. - Default setup includes Disk and Google Drive providers as examples. - Access to availability information from Loader and Scene Manager. - Sync queue GUI with filtering, error and status reporting. - Site sync can be configured on a per-project basis. - Bulk upload and download from the loader. ### Ftrack - Actions have customisable roles. - Settings on all actions are updated live and don't need openpype restart. - Ftrack module can now be turned off completely. - It is enough to specify ftrack server name and the URL will be formed correctly. So instead of mystudio.ftrackapp.com, it's possible to use simply: "mystudio". ### Editorial - Fully OTIO based editorial publishing. - Completely re-done Hiero publishing to be a lot simpler and faster. - Consistent conforming from Resolve, Hiero and Standalone Publisher. ### Backend - OpenPype and Avalon now always share the same database (in 2.x is was possible to split them). - Major codebase refactoring to allow for better CI, versioning and control of individual integrations. - OTIO is bundled with build. - OIIO is bundled with build. - FFMPEG is bundled with build. - Rest API and host WebSocket servers have been unified into a single local webserver. - Maya look assigner has been integrated into the main codebase. - Publish GUI has been integrated into the main codebase. - Studio and Project settings overrides are now stored in Mongo. - Too many other backend fixes and tweaks to list :), you can see full changelog on github for those. - OpenPype uses Poetry to manage it's virtual environment when running from code. - all applications can be marked as python 2 or 3 compatible to make the switch a bit easier. ### Pull Requests since 3.0.0-rc.6 **Implemented enhancements:** - settings: task types enum entity [\#1605](https://github.com/pypeclub/OpenPype/issues/1605) - Settings: ignore keys in referenced schema [\#1600](https://github.com/pypeclub/OpenPype/issues/1600) - Maya: support for frame steps and frame lists [\#1585](https://github.com/pypeclub/OpenPype/issues/1585) - TVPaint: Publish workfile. [\#1548](https://github.com/pypeclub/OpenPype/issues/1548) - Loader: Current Asset Button [\#1448](https://github.com/pypeclub/OpenPype/issues/1448) - Hiero: publish with retiming [\#1377](https://github.com/pypeclub/OpenPype/issues/1377) - Ask user to restart after changing global environments in settings [\#910](https://github.com/pypeclub/OpenPype/issues/910) - add option to define paht to workfile template [\#895](https://github.com/pypeclub/OpenPype/issues/895) - Harmony: move server console to system tray [\#676](https://github.com/pypeclub/OpenPype/issues/676) - Standalone style [\#1630](https://github.com/pypeclub/OpenPype/pull/1630) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Faster hierarchical values push [\#1627](https://github.com/pypeclub/OpenPype/pull/1627) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Launcher tool style [\#1624](https://github.com/pypeclub/OpenPype/pull/1624) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Loader and Library loader enhancements [\#1623](https://github.com/pypeclub/OpenPype/pull/1623) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Tray style [\#1622](https://github.com/pypeclub/OpenPype/pull/1622) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Maya schemas cleanup [\#1610](https://github.com/pypeclub/OpenPype/pull/1610) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Settings: ignore keys in referenced schema [\#1608](https://github.com/pypeclub/OpenPype/pull/1608) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - settings: task types enum entity [\#1606](https://github.com/pypeclub/OpenPype/pull/1606) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Openpype style [\#1604](https://github.com/pypeclub/OpenPype/pull/1604) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - TVPaint: Publish workfile. [\#1597](https://github.com/pypeclub/OpenPype/pull/1597) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Nuke: add option to define path to workfile template [\#1571](https://github.com/pypeclub/OpenPype/pull/1571) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Crop overscan in Extract Review [\#1569](https://github.com/pypeclub/OpenPype/pull/1569) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Unreal and Blender: Material Workflow [\#1562](https://github.com/pypeclub/OpenPype/pull/1562) ([simonebarbieri](https://github.com/simonebarbieri)) - Harmony: move server console to system tray [\#1560](https://github.com/pypeclub/OpenPype/pull/1560) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Ask user to restart after changing global environments in settings [\#1550](https://github.com/pypeclub/OpenPype/pull/1550) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Hiero: publish with retiming [\#1545](https://github.com/pypeclub/OpenPype/pull/1545) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) **Fixed bugs:** - Library loader load asset documents on OpenPype start [\#1603](https://github.com/pypeclub/OpenPype/issues/1603) - Resolve: unable to load the same footage twice [\#1317](https://github.com/pypeclub/OpenPype/issues/1317) - Resolve: unable to load footage [\#1316](https://github.com/pypeclub/OpenPype/issues/1316) - Add required Python 2 modules [\#1291](https://github.com/pypeclub/OpenPype/issues/1291) - GUi scaling with hires displays [\#705](https://github.com/pypeclub/OpenPype/issues/705) - Maya: non unicode string in publish validation [\#673](https://github.com/pypeclub/OpenPype/issues/673) - Nuke: Rendered Frame validation is triggered by multiple collections [\#156](https://github.com/pypeclub/OpenPype/issues/156) - avalon-core debugging failing [\#80](https://github.com/pypeclub/OpenPype/issues/80) - Only check arnold shading group if arnold is used [\#72](https://github.com/pypeclub/OpenPype/issues/72) - Sync server Qt layout fix [\#1621](https://github.com/pypeclub/OpenPype/pull/1621) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Console Listener on Python 2 fix [\#1620](https://github.com/pypeclub/OpenPype/pull/1620) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Bug: Initialize blessed term only in console mode [\#1619](https://github.com/pypeclub/OpenPype/pull/1619) ([antirotor](https://github.com/antirotor)) - Settings template skip paths support wrappers [\#1618](https://github.com/pypeclub/OpenPype/pull/1618) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Maya capture 'isolate\_view' fix + minor corrections [\#1617](https://github.com/pypeclub/OpenPype/pull/1617) ([2-REC](https://github.com/2-REC)) - MacOs Fix launch of standalone publisher [\#1616](https://github.com/pypeclub/OpenPype/pull/1616) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - 'Delivery action' report fix + typos [\#1612](https://github.com/pypeclub/OpenPype/pull/1612) ([2-REC](https://github.com/2-REC)) - List append fix in mutable dict settings [\#1599](https://github.com/pypeclub/OpenPype/pull/1599) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Documentation: Maya: fix review [\#1598](https://github.com/pypeclub/OpenPype/pull/1598) ([antirotor](https://github.com/antirotor)) - Bugfix: Set certifi CA bundle for all platforms [\#1596](https://github.com/pypeclub/OpenPype/pull/1596) ([antirotor](https://github.com/antirotor)) **Merged pull requests:** - Bump dns-packet from 1.3.1 to 1.3.4 in /website [\#1611](https://github.com/pypeclub/OpenPype/pull/1611) ([dependabot[bot]](https://github.com/apps/dependabot)) - Maya: Render workflow fixes [\#1607](https://github.com/pypeclub/OpenPype/pull/1607) ([antirotor](https://github.com/antirotor)) - Maya: support for frame steps and frame lists [\#1586](https://github.com/pypeclub/OpenPype/pull/1586) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - 3.0.0 - curated changelog [\#1284](https://github.com/pypeclub/OpenPype/pull/1284) ([mkolar](https://github.com/mkolar)) ## [2.18.1](https://github.com/pypeclub/openpype/tree/2.18.1) (2021-06-03) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.18.0...2.18.1) **Enhancements:** - Faster hierarchical values push [\#1626](https://github.com/pypeclub/OpenPype/pull/1626) - Feature Delivery in library loader [\#1549](https://github.com/pypeclub/OpenPype/pull/1549) - Hiero: Initial frame publish support. [\#1172](https://github.com/pypeclub/OpenPype/pull/1172) **Fixed bugs:** - Maya capture 'isolate\_view' fix + minor corrections [\#1614](https://github.com/pypeclub/OpenPype/pull/1614) - 'Delivery action' report fix +typos [\#1613](https://github.com/pypeclub/OpenPype/pull/1613) - Delivery in LibraryLoader - fixed sequence issue [\#1590](https://github.com/pypeclub/OpenPype/pull/1590) - FFmpeg filters in quote marks [\#1588](https://github.com/pypeclub/OpenPype/pull/1588) - Ftrack delete action cause circular error [\#1581](https://github.com/pypeclub/OpenPype/pull/1581) - Fix Maya playblast. [\#1566](https://github.com/pypeclub/OpenPype/pull/1566) - More failsafes prevent errored runs. [\#1554](https://github.com/pypeclub/OpenPype/pull/1554) - Celaction publishing [\#1539](https://github.com/pypeclub/OpenPype/pull/1539) - celaction: app not starting [\#1533](https://github.com/pypeclub/OpenPype/pull/1533) **Merged pull requests:** - Maya: Render workflow fixes - 2.0 backport [\#1609](https://github.com/pypeclub/OpenPype/pull/1609) - Maya Hardware support [\#1553](https://github.com/pypeclub/OpenPype/pull/1553) ## [CI/3.0.0-rc.6](https://github.com/pypeclub/openpype/tree/CI/3.0.0-rc.6) (2021-05-27) [Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.5...CI/3.0.0-rc.6) **Implemented enhancements:** - Hiero: publish color and transformation soft-effects [\#1376](https://github.com/pypeclub/OpenPype/issues/1376) - Get rid of `AVALON\_HIERARCHY` and `hiearchy` key on asset [\#432](https://github.com/pypeclub/OpenPype/issues/432) - Sync to avalon do not store hierarchy key [\#1582](https://github.com/pypeclub/OpenPype/pull/1582) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Tools: launcher scripts for project manager [\#1557](https://github.com/pypeclub/OpenPype/pull/1557) ([antirotor](https://github.com/antirotor)) - Simple tvpaint publish [\#1555](https://github.com/pypeclub/OpenPype/pull/1555) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Feature Delivery in library loader [\#1546](https://github.com/pypeclub/OpenPype/pull/1546) ([kalisp](https://github.com/kalisp)) - Documentation: Dev and system build documentation [\#1543](https://github.com/pypeclub/OpenPype/pull/1543) ([antirotor](https://github.com/antirotor)) - Color entity [\#1542](https://github.com/pypeclub/OpenPype/pull/1542) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Extract review bg color [\#1534](https://github.com/pypeclub/OpenPype/pull/1534) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - TVPaint loader settings [\#1530](https://github.com/pypeclub/OpenPype/pull/1530) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Blender can initialize differente user script paths [\#1528](https://github.com/pypeclub/OpenPype/pull/1528) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Blender and Unreal: Improved Animation Workflow [\#1514](https://github.com/pypeclub/OpenPype/pull/1514) ([simonebarbieri](https://github.com/simonebarbieri)) - Hiero: publish color and transformation soft-effects [\#1511](https://github.com/pypeclub/OpenPype/pull/1511) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) **Fixed bugs:** - OpenPype specific version issues [\#1583](https://github.com/pypeclub/OpenPype/issues/1583) - Ftrack login server can't work without stderr [\#1576](https://github.com/pypeclub/OpenPype/issues/1576) - Mac application launch [\#1575](https://github.com/pypeclub/OpenPype/issues/1575) - Settings are not propagated to Nuke write nodes [\#1538](https://github.com/pypeclub/OpenPype/issues/1538) - Subset names settings not applied for publishing [\#1537](https://github.com/pypeclub/OpenPype/issues/1537) - Nuke: callback at start not setting colorspace [\#1412](https://github.com/pypeclub/OpenPype/issues/1412) - Pype 3: Missing icon for Settings [\#1272](https://github.com/pypeclub/OpenPype/issues/1272) - Blender: cannot initialize Avalon if BLENDER\_USER\_SCRIPTS is already used [\#1050](https://github.com/pypeclub/OpenPype/issues/1050) - Ftrack delete action cause circular error [\#206](https://github.com/pypeclub/OpenPype/issues/206) - Build: stop cleaning of pyc files in build directory [\#1592](https://github.com/pypeclub/OpenPype/pull/1592) ([antirotor](https://github.com/antirotor)) - Ftrack login server can't work without stderr [\#1591](https://github.com/pypeclub/OpenPype/pull/1591) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - FFmpeg filters in quote marks [\#1589](https://github.com/pypeclub/OpenPype/pull/1589) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - OpenPype specific version issues [\#1584](https://github.com/pypeclub/OpenPype/pull/1584) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Mac application launch [\#1580](https://github.com/pypeclub/OpenPype/pull/1580) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Ftrack delete action cause circular error [\#1579](https://github.com/pypeclub/OpenPype/pull/1579) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Hiero: publishing issues [\#1578](https://github.com/pypeclub/OpenPype/pull/1578) ([jezscha](https://github.com/jezscha)) - Nuke: callback at start not setting colorspace [\#1561](https://github.com/pypeclub/OpenPype/pull/1561) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Bugfix PS subset and quick review [\#1541](https://github.com/pypeclub/OpenPype/pull/1541) ([kalisp](https://github.com/kalisp)) - Settings are not propagated to Nuke write nodes [\#1540](https://github.com/pypeclub/OpenPype/pull/1540) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - OpenPype: Powershell scripts polishing [\#1536](https://github.com/pypeclub/OpenPype/pull/1536) ([antirotor](https://github.com/antirotor)) - Host name collecting fix [\#1535](https://github.com/pypeclub/OpenPype/pull/1535) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Handle duplicated task names in project manager [\#1531](https://github.com/pypeclub/OpenPype/pull/1531) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Validate is file attribute in settings schema [\#1529](https://github.com/pypeclub/OpenPype/pull/1529) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) **Merged pull requests:** - Bump postcss from 8.2.8 to 8.3.0 in /website [\#1593](https://github.com/pypeclub/OpenPype/pull/1593) ([dependabot[bot]](https://github.com/apps/dependabot)) - User installation documentation [\#1532](https://github.com/pypeclub/OpenPype/pull/1532) ([64qam](https://github.com/64qam)) ## [CI/3.0.0-rc.5](https://github.com/pypeclub/openpype/tree/CI/3.0.0-rc.5) (2021-05-19) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.18.0...CI/3.0.0-rc.5) **Implemented enhancements:** - OpenPype: Build - Add progress bars [\#1524](https://github.com/pypeclub/OpenPype/pull/1524) ([antirotor](https://github.com/antirotor)) - Default environments per host imlementation [\#1522](https://github.com/pypeclub/OpenPype/pull/1522) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - OpenPype: use `semver` module for version resolution [\#1513](https://github.com/pypeclub/OpenPype/pull/1513) ([antirotor](https://github.com/antirotor)) - Feature Aftereffects setting cleanup documentation [\#1510](https://github.com/pypeclub/OpenPype/pull/1510) ([kalisp](https://github.com/kalisp)) - Feature Sync server settings enhancement [\#1501](https://github.com/pypeclub/OpenPype/pull/1501) ([kalisp](https://github.com/kalisp)) - Project manager [\#1396](https://github.com/pypeclub/OpenPype/pull/1396) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) **Fixed bugs:** - Unified schema definition [\#874](https://github.com/pypeclub/OpenPype/issues/874) - Maya: fix look assignment [\#1526](https://github.com/pypeclub/OpenPype/pull/1526) ([antirotor](https://github.com/antirotor)) - Bugfix Sync server local site issues [\#1523](https://github.com/pypeclub/OpenPype/pull/1523) ([kalisp](https://github.com/kalisp)) - Store as list dictionary check initial value with right type [\#1520](https://github.com/pypeclub/OpenPype/pull/1520) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Maya: wrong collection of playblasted frames [\#1515](https://github.com/pypeclub/OpenPype/pull/1515) ([mkolar](https://github.com/mkolar)) - Convert pyblish logs to string at the moment of logging [\#1512](https://github.com/pypeclub/OpenPype/pull/1512) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - 3.0 | nuke: fixing start\_at with option gui [\#1509](https://github.com/pypeclub/OpenPype/pull/1509) ([jezscha](https://github.com/jezscha)) - Tests: fix pype -\> openpype to make tests work again [\#1508](https://github.com/pypeclub/OpenPype/pull/1508) ([antirotor](https://github.com/antirotor)) **Merged pull requests:** - OpenPype: disable submodule update with `--no-submodule-update` [\#1525](https://github.com/pypeclub/OpenPype/pull/1525) ([antirotor](https://github.com/antirotor)) - Ftrack without autosync in Pype 3 [\#1519](https://github.com/pypeclub/OpenPype/pull/1519) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Feature Harmony setting cleanup documentation [\#1506](https://github.com/pypeclub/OpenPype/pull/1506) ([kalisp](https://github.com/kalisp)) - Sync Server beginning of documentation [\#1471](https://github.com/pypeclub/OpenPype/pull/1471) ([kalisp](https://github.com/kalisp)) - Blender: publish layout json [\#1348](https://github.com/pypeclub/OpenPype/pull/1348) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) ## [2.18.0](https://github.com/pypeclub/openpype/tree/2.18.0) (2021-05-18) [Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.4...2.18.0) **Implemented enhancements:** - Default environments per host imlementation [\#1405](https://github.com/pypeclub/OpenPype/issues/1405) - Blender: publish layout json [\#1346](https://github.com/pypeclub/OpenPype/issues/1346) - Ftrack without autosync in Pype 3 [\#1128](https://github.com/pypeclub/OpenPype/issues/1128) - Launcher: started action indicator [\#1102](https://github.com/pypeclub/OpenPype/issues/1102) - Launch arguments of applications [\#1094](https://github.com/pypeclub/OpenPype/issues/1094) - Publish: instance info [\#724](https://github.com/pypeclub/OpenPype/issues/724) - Review: ability to control review length [\#482](https://github.com/pypeclub/OpenPype/issues/482) - Colorized recognition of creator result [\#394](https://github.com/pypeclub/OpenPype/issues/394) - event assign user to started task [\#49](https://github.com/pypeclub/OpenPype/issues/49) - rebuild containers from reference in maya [\#55](https://github.com/pypeclub/OpenPype/issues/55) - nuke Load metadata [\#66](https://github.com/pypeclub/OpenPype/issues/66) - Maya: Safer handling of expected render output names [\#1496](https://github.com/pypeclub/OpenPype/pull/1496) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - TVPaint: Increment workfile version on successfull publish. [\#1489](https://github.com/pypeclub/OpenPype/pull/1489) ([tokejepsen](https://github.com/tokejepsen)) - Use SubsetLoader and multiple contexts for delete\_old\_versions [\#1484](https://github.com/pypeclub/OpenPype/pull/1484) ([tokejepsen](https://github.com/tokejepsen)) - Maya: Use of multiple deadline servers [\#1483](https://github.com/pypeclub/OpenPype/pull/1483) ([antirotor](https://github.com/antirotor)) **Fixed bugs:** - Igniter version resolution doesn't consider it's own version [\#1505](https://github.com/pypeclub/OpenPype/issues/1505) - Maya: Safer handling of expected render output names [\#1159](https://github.com/pypeclub/OpenPype/issues/1159) - Harmony: Invalid render output from non-conventionally named instance [\#871](https://github.com/pypeclub/OpenPype/issues/871) - Existing subsets hints in creator [\#1503](https://github.com/pypeclub/OpenPype/pull/1503) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - nuke: space in node name breaking process [\#1494](https://github.com/pypeclub/OpenPype/pull/1494) ([jezscha](https://github.com/jezscha)) - Maya: wrong collection of playblasted frames [\#1517](https://github.com/pypeclub/OpenPype/pull/1517) ([mkolar](https://github.com/mkolar)) - Existing subsets hints in creator [\#1502](https://github.com/pypeclub/OpenPype/pull/1502) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Use instance frame start instead of timeline. [\#1486](https://github.com/pypeclub/OpenPype/pull/1486) ([tokejepsen](https://github.com/tokejepsen)) - Maya: Redshift - set proper start frame on proxy [\#1480](https://github.com/pypeclub/OpenPype/pull/1480) ([antirotor](https://github.com/antirotor)) **Closed issues:** - Nuke: wrong "star at" value on render load [\#1352](https://github.com/pypeclub/OpenPype/issues/1352) - DV Resolve - loading/updating - image video [\#915](https://github.com/pypeclub/OpenPype/issues/915) **Merged pull requests:** - nuke: fixing start\_at with option gui [\#1507](https://github.com/pypeclub/OpenPype/pull/1507) ([jezscha](https://github.com/jezscha)) ## [CI/3.0.0-rc.4](https://github.com/pypeclub/openpype/tree/CI/3.0.0-rc.4) (2021-05-12) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.3...CI/3.0.0-rc.4) **Implemented enhancements:** - Resolve: documentation [\#1490](https://github.com/pypeclub/OpenPype/issues/1490) - Hiero: audio to review [\#1378](https://github.com/pypeclub/OpenPype/issues/1378) - nks color clips after publish [\#44](https://github.com/pypeclub/OpenPype/issues/44) - Store data from modifiable dict as list [\#1504](https://github.com/pypeclub/OpenPype/pull/1504) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Use SubsetLoader and multiple contexts for delete\_old\_versions [\#1497](https://github.com/pypeclub/OpenPype/pull/1497) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Hiero: publish audio and add to review [\#1493](https://github.com/pypeclub/OpenPype/pull/1493) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Resolve: documentation [\#1491](https://github.com/pypeclub/OpenPype/pull/1491) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Change integratenew template profiles setting [\#1487](https://github.com/pypeclub/OpenPype/pull/1487) ([kalisp](https://github.com/kalisp)) - Settings tool cleanup [\#1477](https://github.com/pypeclub/OpenPype/pull/1477) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Sorted Applications and Tools in Custom attribute [\#1476](https://github.com/pypeclub/OpenPype/pull/1476) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - PS - group all published instances [\#1416](https://github.com/pypeclub/OpenPype/pull/1416) ([kalisp](https://github.com/kalisp)) - OpenPype: Support for Docker [\#1289](https://github.com/pypeclub/OpenPype/pull/1289) ([antirotor](https://github.com/antirotor)) **Fixed bugs:** - Harmony: palettes publishing [\#1439](https://github.com/pypeclub/OpenPype/issues/1439) - Photoshop: validation for already created images [\#1435](https://github.com/pypeclub/OpenPype/issues/1435) - Nuke Extracts Thumbnail from frame out of shot range [\#963](https://github.com/pypeclub/OpenPype/issues/963) - Instance in same Context repairing [\#390](https://github.com/pypeclub/OpenPype/issues/390) - User Inactivity - Start timers sets wrong time [\#91](https://github.com/pypeclub/OpenPype/issues/91) - Use instance frame start instead of timeline [\#1499](https://github.com/pypeclub/OpenPype/pull/1499) ([mkolar](https://github.com/mkolar)) - Various smaller fixes [\#1498](https://github.com/pypeclub/OpenPype/pull/1498) ([mkolar](https://github.com/mkolar)) - nuke: space in node name breaking process [\#1495](https://github.com/pypeclub/OpenPype/pull/1495) ([jezscha](https://github.com/jezscha)) - Codec determination in extract burnin [\#1492](https://github.com/pypeclub/OpenPype/pull/1492) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Undefined constant in subprocess module [\#1485](https://github.com/pypeclub/OpenPype/pull/1485) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - List entity catch add/remove item changes properly [\#1482](https://github.com/pypeclub/OpenPype/pull/1482) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Resolve: additional fixes of publishing workflow [\#1481](https://github.com/pypeclub/OpenPype/pull/1481) ([jezscha](https://github.com/jezscha)) - Photoshop: validation for already created images [\#1436](https://github.com/pypeclub/OpenPype/pull/1436) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) **Merged pull requests:** - Maya: Support for looks on VRay Proxies [\#1443](https://github.com/pypeclub/OpenPype/pull/1443) ([antirotor](https://github.com/antirotor)) ## [2.17.3](https://github.com/pypeclub/openpype/tree/2.17.3) (2021-05-06) [Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.3...2.17.3) **Fixed bugs:** - Nuke: workfile version synced to db version always [\#1479](https://github.com/pypeclub/OpenPype/pull/1479) ([jezscha](https://github.com/jezscha)) ## [CI/3.0.0-rc.3](https://github.com/pypeclub/openpype/tree/CI/3.0.0-rc.3) (2021-05-05) [Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.2...CI/3.0.0-rc.3) **Implemented enhancements:** - Path entity with placeholder [\#1473](https://github.com/pypeclub/OpenPype/pull/1473) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Burnin custom font filepath [\#1472](https://github.com/pypeclub/OpenPype/pull/1472) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Poetry: Move to OpenPype [\#1449](https://github.com/pypeclub/OpenPype/pull/1449) ([antirotor](https://github.com/antirotor)) **Fixed bugs:** - Mac SSL path needs to be relative to pype\_root [\#1469](https://github.com/pypeclub/OpenPype/issues/1469) - Resolve: fix loading clips to timeline [\#1421](https://github.com/pypeclub/OpenPype/issues/1421) - Wrong handling of slashes when loading on mac [\#1411](https://github.com/pypeclub/OpenPype/issues/1411) - Nuke openpype3 [\#1342](https://github.com/pypeclub/OpenPype/issues/1342) - Houdini launcher [\#1171](https://github.com/pypeclub/OpenPype/issues/1171) - Fix SyncServer get\_enabled\_projects should handle global state [\#1475](https://github.com/pypeclub/OpenPype/pull/1475) ([kalisp](https://github.com/kalisp)) - Igniter buttons enable/disable fix [\#1474](https://github.com/pypeclub/OpenPype/pull/1474) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Mac SSL path needs to be relative to pype\_root [\#1470](https://github.com/pypeclub/OpenPype/pull/1470) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Resolve: 17 compatibility issues and load image sequences [\#1422](https://github.com/pypeclub/OpenPype/pull/1422) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) ## [CI/3.0.0-rc.2](https://github.com/pypeclub/openpype/tree/CI/3.0.0-rc.2) (2021-05-04) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.2...CI/3.0.0-rc.2) **Implemented enhancements:** - Extract burnins with sequences [\#1467](https://github.com/pypeclub/OpenPype/pull/1467) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Extract burnins with color setting [\#1466](https://github.com/pypeclub/OpenPype/pull/1466) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) **Fixed bugs:** - Fix groups check in Python 2 [\#1468](https://github.com/pypeclub/OpenPype/pull/1468) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) ## [2.17.2](https://github.com/pypeclub/openpype/tree/2.17.2) (2021-05-04) [Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.1...2.17.2) **Implemented enhancements:** - Forward/Backward compatible apps and tools with OpenPype 3 [\#1463](https://github.com/pypeclub/OpenPype/pull/1463) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) ## [CI/3.0.0-rc.1](https://github.com/pypeclub/openpype/tree/CI/3.0.0-rc.1) (2021-05-04) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.1...CI/3.0.0-rc.1) **Implemented enhancements:** - Only show studio settings to admins [\#1406](https://github.com/pypeclub/OpenPype/issues/1406) - Ftrack specific settings save warning messages [\#1458](https://github.com/pypeclub/OpenPype/pull/1458) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Faster settings actions [\#1446](https://github.com/pypeclub/OpenPype/pull/1446) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Feature/sync server priority [\#1444](https://github.com/pypeclub/OpenPype/pull/1444) ([kalisp](https://github.com/kalisp)) - Faster settings UI loading [\#1442](https://github.com/pypeclub/OpenPype/pull/1442) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Igniter re-write [\#1441](https://github.com/pypeclub/OpenPype/pull/1441) ([mkolar](https://github.com/mkolar)) - Wrap openpype build into installers [\#1419](https://github.com/pypeclub/OpenPype/pull/1419) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Extract review first documentation [\#1404](https://github.com/pypeclub/OpenPype/pull/1404) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Blender PySide2 install guide [\#1403](https://github.com/pypeclub/OpenPype/pull/1403) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Nuke: deadline submission with gpu [\#1394](https://github.com/pypeclub/OpenPype/pull/1394) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Igniter: Reverse item filter for OpenPype version [\#1349](https://github.com/pypeclub/OpenPype/pull/1349) ([antirotor](https://github.com/antirotor)) **Fixed bugs:** - OpenPype Mongo URL definition [\#1450](https://github.com/pypeclub/OpenPype/issues/1450) - Various typos and smaller fixes [\#1464](https://github.com/pypeclub/OpenPype/pull/1464) ([mkolar](https://github.com/mkolar)) - Validation of dynamic items in settings [\#1462](https://github.com/pypeclub/OpenPype/pull/1462) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - List can handle new items correctly [\#1459](https://github.com/pypeclub/OpenPype/pull/1459) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Settings actions process fix [\#1457](https://github.com/pypeclub/OpenPype/pull/1457) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Add to overrides actions fix [\#1456](https://github.com/pypeclub/OpenPype/pull/1456) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - OpenPype Mongo URL definition [\#1455](https://github.com/pypeclub/OpenPype/pull/1455) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Global settings save/load out of system settings [\#1447](https://github.com/pypeclub/OpenPype/pull/1447) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Keep metadata on remove overrides [\#1445](https://github.com/pypeclub/OpenPype/pull/1445) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Nuke: fixing undo for loaded mov and sequence [\#1432](https://github.com/pypeclub/OpenPype/pull/1432) ([jezscha](https://github.com/jezscha)) - ExtractReview skip empty strings from settings [\#1431](https://github.com/pypeclub/OpenPype/pull/1431) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Bugfix Sync server tweaks [\#1430](https://github.com/pypeclub/OpenPype/pull/1430) ([kalisp](https://github.com/kalisp)) - Hiero: missing thumbnail in review [\#1429](https://github.com/pypeclub/OpenPype/pull/1429) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Bugfix Maya in deadline for OpenPype [\#1428](https://github.com/pypeclub/OpenPype/pull/1428) ([kalisp](https://github.com/kalisp)) - AE - validation for duration was 1 frame shorter [\#1427](https://github.com/pypeclub/OpenPype/pull/1427) ([kalisp](https://github.com/kalisp)) - Houdini menu filename [\#1418](https://github.com/pypeclub/OpenPype/pull/1418) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Fix Avalon plugins attribute overrides [\#1413](https://github.com/pypeclub/OpenPype/pull/1413) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Nuke: submit to Deadline fails [\#1409](https://github.com/pypeclub/OpenPype/pull/1409) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - Validate MongoDB Url on start [\#1407](https://github.com/pypeclub/OpenPype/pull/1407) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Nuke: fix set colorspace with new settings [\#1386](https://github.com/pypeclub/OpenPype/pull/1386) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - MacOs build and install issues [\#1380](https://github.com/pypeclub/OpenPype/pull/1380) ([mkolar](https://github.com/mkolar)) **Closed issues:** - test [\#1452](https://github.com/pypeclub/OpenPype/issues/1452) **Merged pull requests:** - TVPaint frame range definition [\#1425](https://github.com/pypeclub/OpenPype/pull/1425) ([iLLiCiTiT](https://github.com/iLLiCiTiT)) - Only show studio settings to admins [\#1420](https://github.com/pypeclub/OpenPype/pull/1420) ([create-issue-branch[bot]](https://github.com/apps/create-issue-branch)) - TVPaint documentation [\#1305](https://github.com/pypeclub/OpenPype/pull/1305) ([64qam](https://github.com/64qam)) ## [2.17.1](https://github.com/pypeclub/openpype/tree/2.17.1) (2021-04-30) [Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.0...2.17.1) **Enhancements:** - Nuke: deadline submission with gpu [\#1414](https://github.com/pypeclub/OpenPype/pull/1414) - TVPaint frame range definition [\#1424](https://github.com/pypeclub/OpenPype/pull/1424) - PS - group all published instances [\#1415](https://github.com/pypeclub/OpenPype/pull/1415) - Add task name to context pop up. [\#1383](https://github.com/pypeclub/OpenPype/pull/1383) - Enhance review letterbox feature. [\#1371](https://github.com/pypeclub/OpenPype/pull/1371) **Fixed bugs:** - Houdini menu filename [\#1417](https://github.com/pypeclub/OpenPype/pull/1417) - AE - validation for duration was 1 frame shorter [\#1426](https://github.com/pypeclub/OpenPype/pull/1426) **Merged pull requests:** - Maya: Vray - problem getting all file nodes for look publishing [\#1399](https://github.com/pypeclub/OpenPype/pull/1399) - Maya: Support for Redshift proxies [\#1360](https://github.com/pypeclub/OpenPype/pull/1360) ## [2.17.0](https://github.com/pypeclub/openpype/tree/2.17.0) (2021-04-20) [Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-beta.2...2.17.0) **Enhancements:** - Forward compatible ftrack group [\#1243](https://github.com/pypeclub/OpenPype/pull/1243) - Settings in mongo as dict [\#1221](https://github.com/pypeclub/OpenPype/pull/1221) - Maya: Make tx option configurable with presets [\#1328](https://github.com/pypeclub/OpenPype/pull/1328) - TVPaint asset name validation [\#1302](https://github.com/pypeclub/OpenPype/pull/1302) - TV Paint: Set initial project settings. [\#1299](https://github.com/pypeclub/OpenPype/pull/1299) - TV Paint: Validate mark in and out. [\#1298](https://github.com/pypeclub/OpenPype/pull/1298) - Validate project settings [\#1297](https://github.com/pypeclub/OpenPype/pull/1297) - After Effects: added SubsetManager [\#1234](https://github.com/pypeclub/OpenPype/pull/1234) - Show error message in pyblish UI [\#1206](https://github.com/pypeclub/OpenPype/pull/1206) **Fixed bugs:** - Hiero: fixing source frame from correct object [\#1362](https://github.com/pypeclub/OpenPype/pull/1362) - Nuke: fix colourspace, prerenders and nuke panes opening [\#1308](https://github.com/pypeclub/OpenPype/pull/1308) - AE remove orphaned instance from workfile - fix self.stub [\#1282](https://github.com/pypeclub/OpenPype/pull/1282) - Nuke: deadline submission with search replaced env values from preset [\#1194](https://github.com/pypeclub/OpenPype/pull/1194) - Ftrack custom attributes in bulks [\#1312](https://github.com/pypeclub/OpenPype/pull/1312) - Ftrack optional pypclub role [\#1303](https://github.com/pypeclub/OpenPype/pull/1303) - After Effects: remove orphaned instances [\#1275](https://github.com/pypeclub/OpenPype/pull/1275) - Avalon schema names [\#1242](https://github.com/pypeclub/OpenPype/pull/1242) - Handle duplication of Task name [\#1226](https://github.com/pypeclub/OpenPype/pull/1226) - Modified path of plugin loads for Harmony and TVPaint [\#1217](https://github.com/pypeclub/OpenPype/pull/1217) - Regex checks in profiles filtering [\#1214](https://github.com/pypeclub/OpenPype/pull/1214) - Bulk mov strict task [\#1204](https://github.com/pypeclub/OpenPype/pull/1204) - Update custom ftrack session attributes [\#1202](https://github.com/pypeclub/OpenPype/pull/1202) - Nuke: write node colorspace ignore `default\(\)` label [\#1199](https://github.com/pypeclub/OpenPype/pull/1199) - Nuke: reverse search to make it more versatile [\#1178](https://github.com/pypeclub/OpenPype/pull/1178) ## [2.16.0](https://github.com/pypeclub/pype/tree/2.16.0) (2021-03-22) [Full Changelog](https://github.com/pypeclub/pype/compare/2.15.3...2.16.0) **Enhancements:** - Nuke: deadline submit limit group filter [\#1167](https://github.com/pypeclub/pype/pull/1167) - Maya: support for Deadline Group and Limit Groups - backport 2.x [\#1156](https://github.com/pypeclub/pype/pull/1156) - Maya: fixes for Redshift support [\#1152](https://github.com/pypeclub/pype/pull/1152) - Nuke: adding preset for a Read node name to all img and mov Loaders [\#1146](https://github.com/pypeclub/pype/pull/1146) - nuke deadline submit with environ var from presets overrides [\#1142](https://github.com/pypeclub/pype/pull/1142) - Change timers after task change [\#1138](https://github.com/pypeclub/pype/pull/1138) - Nuke: shortcuts for Pype menu [\#1127](https://github.com/pypeclub/pype/pull/1127) - Nuke: workfile template [\#1124](https://github.com/pypeclub/pype/pull/1124) - Sites local settings by site name [\#1117](https://github.com/pypeclub/pype/pull/1117) - Reset loader's asset selection on context change [\#1106](https://github.com/pypeclub/pype/pull/1106) - Bulk mov render publishing [\#1101](https://github.com/pypeclub/pype/pull/1101) - Photoshop: mark publishable instances [\#1093](https://github.com/pypeclub/pype/pull/1093) - Added ability to define BG color for extract review [\#1088](https://github.com/pypeclub/pype/pull/1088) - TVPaint extractor enhancement [\#1080](https://github.com/pypeclub/pype/pull/1080) - Photoshop: added support for .psb in workfiles [\#1078](https://github.com/pypeclub/pype/pull/1078) - Optionally add task to subset name [\#1072](https://github.com/pypeclub/pype/pull/1072) - Only extend clip range when collecting. [\#1008](https://github.com/pypeclub/pype/pull/1008) - Collect audio for farm reviews. [\#1073](https://github.com/pypeclub/pype/pull/1073) **Fixed bugs:** - Fix path spaces in jpeg extractor [\#1174](https://github.com/pypeclub/pype/pull/1174) - Maya: Bugfix: superclass for CreateCameraRig [\#1166](https://github.com/pypeclub/pype/pull/1166) - Maya: Submit to Deadline - fix typo in condition [\#1163](https://github.com/pypeclub/pype/pull/1163) - Avoid dot in repre extension [\#1125](https://github.com/pypeclub/pype/pull/1125) - Fix versions variable usage in standalone publisher [\#1090](https://github.com/pypeclub/pype/pull/1090) - Collect instance data fix subset query [\#1082](https://github.com/pypeclub/pype/pull/1082) - Fix getting the camera name. [\#1067](https://github.com/pypeclub/pype/pull/1067) - Nuke: Ensure "NUKE\_TEMP\_DIR" is not part of the Deadline job environment. [\#1064](https://github.com/pypeclub/pype/pull/1064) ## [2.15.3](https://github.com/pypeclub/pype/tree/2.15.3) (2021-02-26) [Full Changelog](https://github.com/pypeclub/pype/compare/2.15.2...2.15.3) **Enhancements:** - Maya: speedup renderable camera collection [\#1053](https://github.com/pypeclub/pype/pull/1053) - Harmony - add regex search to filter allowed task names for collectin… [\#1047](https://github.com/pypeclub/pype/pull/1047) **Fixed bugs:** - Ftrack integrate hierarchy fix [\#1085](https://github.com/pypeclub/pype/pull/1085) - Explicit subset filter in anatomy instance data [\#1059](https://github.com/pypeclub/pype/pull/1059) - TVPaint frame offset [\#1057](https://github.com/pypeclub/pype/pull/1057) - Auto fix unicode strings [\#1046](https://github.com/pypeclub/pype/pull/1046) ## [2.15.2](https://github.com/pypeclub/pype/tree/2.15.2) (2021-02-19) [Full Changelog](https://github.com/pypeclub/pype/compare/2.15.1...2.15.2) **Enhancements:** - Maya: Vray scene publishing [\#1013](https://github.com/pypeclub/pype/pull/1013) **Fixed bugs:** - Fix entity move under project [\#1040](https://github.com/pypeclub/pype/pull/1040) - smaller nuke fixes from production [\#1036](https://github.com/pypeclub/pype/pull/1036) - TVPaint thumbnail extract fix [\#1031](https://github.com/pypeclub/pype/pull/1031) ## [2.15.1](https://github.com/pypeclub/pype/tree/2.15.1) (2021-02-12) [Full Changelog](https://github.com/pypeclub/pype/compare/2.15.0...2.15.1) **Enhancements:** - Delete version as loader action [\#1011](https://github.com/pypeclub/pype/pull/1011) - Delete old versions [\#445](https://github.com/pypeclub/pype/pull/445) **Fixed bugs:** - PS - remove obsolete functions from pywin32 [\#1006](https://github.com/pypeclub/pype/pull/1006) - Clone description of review session objects. [\#922](https://github.com/pypeclub/pype/pull/922) ## [2.15.0](https://github.com/pypeclub/pype/tree/2.15.0) (2021-02-09) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.6...2.15.0) **Enhancements:** - Resolve - loading and updating clips [\#932](https://github.com/pypeclub/pype/pull/932) - Release/2.15.0 [\#926](https://github.com/pypeclub/pype/pull/926) - Photoshop: add option for template.psd and prelaunch hook [\#894](https://github.com/pypeclub/pype/pull/894) - Nuke: deadline presets [\#993](https://github.com/pypeclub/pype/pull/993) - Maya: Alembic only set attributes that exists. [\#986](https://github.com/pypeclub/pype/pull/986) - Harmony: render local and handle fixes [\#981](https://github.com/pypeclub/pype/pull/981) - PSD Bulk export of ANIM group [\#965](https://github.com/pypeclub/pype/pull/965) - AE - added prelaunch hook for opening last or workfile from template [\#944](https://github.com/pypeclub/pype/pull/944) - PS - safer handling of loading of workfile [\#941](https://github.com/pypeclub/pype/pull/941) - Maya: Handling Arnold referenced AOVs [\#938](https://github.com/pypeclub/pype/pull/938) - TVPaint: switch layer IDs for layer names during identification [\#903](https://github.com/pypeclub/pype/pull/903) - TVPaint audio/sound loader [\#893](https://github.com/pypeclub/pype/pull/893) - Clone review session with children. [\#891](https://github.com/pypeclub/pype/pull/891) - Simple compositing data packager for freelancers [\#884](https://github.com/pypeclub/pype/pull/884) - Harmony deadline submission [\#881](https://github.com/pypeclub/pype/pull/881) - Maya: Optionally hide image planes from reviews. [\#840](https://github.com/pypeclub/pype/pull/840) - Maya: handle referenced AOVs for Vray [\#824](https://github.com/pypeclub/pype/pull/824) - DWAA/DWAB support on windows [\#795](https://github.com/pypeclub/pype/pull/795) - Unreal: animation, layout and setdress updates [\#695](https://github.com/pypeclub/pype/pull/695) **Fixed bugs:** - Maya: Looks - disable hardlinks [\#995](https://github.com/pypeclub/pype/pull/995) - Fix Ftrack custom attribute update [\#982](https://github.com/pypeclub/pype/pull/982) - Prores ks in burnin script [\#960](https://github.com/pypeclub/pype/pull/960) - terminal.py crash on import [\#839](https://github.com/pypeclub/pype/pull/839) - Extract review handle bizarre pixel aspect ratio [\#990](https://github.com/pypeclub/pype/pull/990) - Nuke: add nuke related env var to sumbission [\#988](https://github.com/pypeclub/pype/pull/988) - Nuke: missing preset's variable [\#984](https://github.com/pypeclub/pype/pull/984) - Get creator by name fix [\#979](https://github.com/pypeclub/pype/pull/979) - Fix update of project's tasks on Ftrack sync [\#972](https://github.com/pypeclub/pype/pull/972) - nuke: wrong frame offset in mov loader [\#971](https://github.com/pypeclub/pype/pull/971) - Create project structure action fix multiroot [\#967](https://github.com/pypeclub/pype/pull/967) - PS: remove pywin installation from hook [\#964](https://github.com/pypeclub/pype/pull/964) - Prores ks in burnin script [\#959](https://github.com/pypeclub/pype/pull/959) - Subset family is now stored in subset document [\#956](https://github.com/pypeclub/pype/pull/956) - DJV new version arguments [\#954](https://github.com/pypeclub/pype/pull/954) - TV Paint: Fix single frame Sequence [\#953](https://github.com/pypeclub/pype/pull/953) - nuke: missing `file` knob update [\#933](https://github.com/pypeclub/pype/pull/933) - Photoshop: Create from single layer was failing [\#920](https://github.com/pypeclub/pype/pull/920) - Nuke: baking mov with correct colorspace inherited from write [\#909](https://github.com/pypeclub/pype/pull/909) - Launcher fix actions discover [\#896](https://github.com/pypeclub/pype/pull/896) - Get the correct file path for the updated mov. [\#889](https://github.com/pypeclub/pype/pull/889) - Maya: Deadline submitter - shared data access violation [\#831](https://github.com/pypeclub/pype/pull/831) - Maya: Take into account vray master AOV switch [\#822](https://github.com/pypeclub/pype/pull/822) **Merged pull requests:** - Refactor blender to 3.0 format [\#934](https://github.com/pypeclub/pype/pull/934) ## [2.14.6](https://github.com/pypeclub/pype/tree/2.14.6) (2021-01-15) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.5...2.14.6) **Fixed bugs:** - Nuke: improving of hashing path [\#885](https://github.com/pypeclub/pype/pull/885) **Merged pull requests:** - Hiero: cut videos with correct secons [\#892](https://github.com/pypeclub/pype/pull/892) - Faster sync to avalon preparation [\#869](https://github.com/pypeclub/pype/pull/869) ## [2.14.5](https://github.com/pypeclub/pype/tree/2.14.5) (2021-01-06) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.4...2.14.5) **Merged pull requests:** - Pype logger refactor [\#866](https://github.com/pypeclub/pype/pull/866) ## [2.14.4](https://github.com/pypeclub/pype/tree/2.14.4) (2020-12-18) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.3...2.14.4) **Merged pull requests:** - Fix - AE - added explicit cast to int [\#837](https://github.com/pypeclub/pype/pull/837) ## [2.14.3](https://github.com/pypeclub/pype/tree/2.14.3) (2020-12-16) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.2...2.14.3) **Fixed bugs:** - TVPaint repair invalid metadata [\#809](https://github.com/pypeclub/pype/pull/809) - Feature/push hier value to nonhier action [\#807](https://github.com/pypeclub/pype/pull/807) - Harmony: fix palette and image sequence loader [\#806](https://github.com/pypeclub/pype/pull/806) **Merged pull requests:** - respecting space in path [\#823](https://github.com/pypeclub/pype/pull/823) ## [2.14.2](https://github.com/pypeclub/pype/tree/2.14.2) (2020-12-04) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.1...2.14.2) **Enhancements:** - Collapsible wrapper in settings [\#767](https://github.com/pypeclub/pype/pull/767) **Fixed bugs:** - Harmony: template extraction and palettes thumbnails on mac [\#768](https://github.com/pypeclub/pype/pull/768) - TVPaint store context to workfile metadata \(764\) [\#766](https://github.com/pypeclub/pype/pull/766) - Extract review audio cut fix [\#763](https://github.com/pypeclub/pype/pull/763) **Merged pull requests:** - AE: fix publish after background load [\#781](https://github.com/pypeclub/pype/pull/781) - TVPaint store members key [\#769](https://github.com/pypeclub/pype/pull/769) ## [2.14.1](https://github.com/pypeclub/pype/tree/2.14.1) (2020-11-27) [Full Changelog](https://github.com/pypeclub/pype/compare/2.14.0...2.14.1) **Enhancements:** - Settings required keys in modifiable dict [\#770](https://github.com/pypeclub/pype/pull/770) - Extract review may not add audio to output [\#761](https://github.com/pypeclub/pype/pull/761) **Fixed bugs:** - After Effects: frame range, file format and render source scene fixes [\#760](https://github.com/pypeclub/pype/pull/760) - Hiero: trimming review with clip event number [\#754](https://github.com/pypeclub/pype/pull/754) - TVPaint: fix updating of loaded subsets [\#752](https://github.com/pypeclub/pype/pull/752) - Maya: Vray handling of default aov [\#748](https://github.com/pypeclub/pype/pull/748) - Maya: multiple renderable cameras in layer didn't work [\#744](https://github.com/pypeclub/pype/pull/744) - Ftrack integrate custom attributes fix [\#742](https://github.com/pypeclub/pype/pull/742) ## [2.14.0](https://github.com/pypeclub/pype/tree/2.14.0) (2020-11-23) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.7...2.14.0) **Enhancements:** - Render publish plugins abstraction [\#687](https://github.com/pypeclub/pype/pull/687) - Shot asset build trigger status [\#736](https://github.com/pypeclub/pype/pull/736) - Maya: add camera rig publishing option [\#721](https://github.com/pypeclub/pype/pull/721) - Sort instances by label in pyblish gui [\#719](https://github.com/pypeclub/pype/pull/719) - Synchronize ftrack hierarchical and shot attributes [\#716](https://github.com/pypeclub/pype/pull/716) - 686 standalonepublisher editorial from image sequences [\#699](https://github.com/pypeclub/pype/pull/699) - Ask user to select non-default camera from scene or create a new. [\#678](https://github.com/pypeclub/pype/pull/678) - TVPaint: image loader with options [\#675](https://github.com/pypeclub/pype/pull/675) - Maya: Camera name can be added to burnins. [\#674](https://github.com/pypeclub/pype/pull/674) - After Effects: base integration with loaders [\#667](https://github.com/pypeclub/pype/pull/667) - Harmony: Javascript refactoring and overall stability improvements [\#666](https://github.com/pypeclub/pype/pull/666) **Fixed bugs:** - Bugfix Hiero Review / Plate representation publish [\#743](https://github.com/pypeclub/pype/pull/743) - Asset fetch second fix [\#726](https://github.com/pypeclub/pype/pull/726) - TVPaint extract review fix [\#740](https://github.com/pypeclub/pype/pull/740) - After Effects: Review were not being sent to ftrack [\#738](https://github.com/pypeclub/pype/pull/738) - Maya: vray proxy was not loading [\#722](https://github.com/pypeclub/pype/pull/722) - Maya: Vray expected file fixes [\#682](https://github.com/pypeclub/pype/pull/682) - Missing audio on farm submission. [\#639](https://github.com/pypeclub/pype/pull/639) **Deprecated:** - Removed artist view from pyblish gui [\#717](https://github.com/pypeclub/pype/pull/717) - Maya: disable legacy override check for cameras [\#715](https://github.com/pypeclub/pype/pull/715) **Merged pull requests:** - Application manager [\#728](https://github.com/pypeclub/pype/pull/728) - Feature \#664 3.0 lib refactor [\#706](https://github.com/pypeclub/pype/pull/706) - Lib from illicit part 2 [\#700](https://github.com/pypeclub/pype/pull/700) - 3.0 lib refactor - path tools [\#697](https://github.com/pypeclub/pype/pull/697) ## [2.13.7](https://github.com/pypeclub/pype/tree/2.13.7) (2020-11-19) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.6...2.13.7) **Fixed bugs:** - Standalone Publisher: getting fps from context instead of nonexistent entity [\#729](https://github.com/pypeclub/pype/pull/729) ## [2.13.6](https://github.com/pypeclub/pype/tree/2.13.6) (2020-11-15) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.5...2.13.6) **Fixed bugs:** - Maya workfile version wasn't syncing with renders properly [\#711](https://github.com/pypeclub/pype/pull/711) - Maya: Fix for publishing multiple cameras with review from the same scene [\#710](https://github.com/pypeclub/pype/pull/710) ## [2.13.5](https://github.com/pypeclub/pype/tree/2.13.5) (2020-11-12) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.4...2.13.5) **Enhancements:** - 3.0 lib refactor [\#664](https://github.com/pypeclub/pype/issues/664) **Fixed bugs:** - Wrong thumbnail file was picked when publishing sequence in standalone publisher [\#703](https://github.com/pypeclub/pype/pull/703) - Fix: Burnin data pass and FFmpeg tool check [\#701](https://github.com/pypeclub/pype/pull/701) ## [2.13.4](https://github.com/pypeclub/pype/tree/2.13.4) (2020-11-09) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.3...2.13.4) **Enhancements:** - AfterEffects integration with Websocket [\#663](https://github.com/pypeclub/pype/issues/663) **Fixed bugs:** - Photoshop uhiding hidden layers [\#688](https://github.com/pypeclub/pype/issues/688) - \#688 - Fix publishing hidden layers [\#692](https://github.com/pypeclub/pype/pull/692) **Closed issues:** - Nuke Favorite directories "shot dir" "project dir" - not working [\#684](https://github.com/pypeclub/pype/issues/684) **Merged pull requests:** - Nuke Favorite directories "shot dir" "project dir" - not working \#684 [\#685](https://github.com/pypeclub/pype/pull/685) ## [2.13.3](https://github.com/pypeclub/pype/tree/2.13.3) (2020-11-03) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.2...2.13.3) **Enhancements:** - TV paint base integration [\#612](https://github.com/pypeclub/pype/issues/612) **Fixed bugs:** - Fix ffmpeg executable path with spaces [\#680](https://github.com/pypeclub/pype/pull/680) - Hotfix: Added default version number [\#679](https://github.com/pypeclub/pype/pull/679) ## [2.13.2](https://github.com/pypeclub/pype/tree/2.13.2) (2020-10-28) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.1...2.13.2) **Fixed bugs:** - Nuke: wrong conditions when fixing legacy write nodes [\#665](https://github.com/pypeclub/pype/pull/665) ## [2.13.1](https://github.com/pypeclub/pype/tree/2.13.1) (2020-10-23) [Full Changelog](https://github.com/pypeclub/pype/compare/2.13.0...2.13.1) **Enhancements:** - move maya look assigner to pype menu [\#292](https://github.com/pypeclub/pype/issues/292) **Fixed bugs:** - Layer name is not propagating to metadata in Photoshop [\#654](https://github.com/pypeclub/pype/issues/654) - Loader in Photoshop fails with "can't set attribute" [\#650](https://github.com/pypeclub/pype/issues/650) - Nuke Load mp4 wrong frame range [\#661](https://github.com/pypeclub/pype/issues/661) - Hiero: Review video file adding one frame to the end [\#659](https://github.com/pypeclub/pype/issues/659) ## [2.13.0](https://github.com/pypeclub/pype/tree/2.13.0) (2020-10-18) [Full Changelog](https://github.com/pypeclub/pype/compare/2.12.5...2.13.0) **Enhancements:** - Deadline Output Folder [\#636](https://github.com/pypeclub/pype/issues/636) - Nuke Camera Loader [\#565](https://github.com/pypeclub/pype/issues/565) - Deadline publish job shows publishing output folder [\#649](https://github.com/pypeclub/pype/pull/649) - Get latest version in lib [\#642](https://github.com/pypeclub/pype/pull/642) - Improved publishing of multiple representation from SP [\#638](https://github.com/pypeclub/pype/pull/638) - Launch TvPaint shot work file from within Ftrack [\#631](https://github.com/pypeclub/pype/pull/631) - Add mp4 support for RV action. [\#628](https://github.com/pypeclub/pype/pull/628) - Maya: allow renders to have version synced with workfile [\#618](https://github.com/pypeclub/pype/pull/618) - Renaming nukestudio host folder to hiero [\#617](https://github.com/pypeclub/pype/pull/617) - Harmony: More efficient publishing [\#615](https://github.com/pypeclub/pype/pull/615) - Ftrack server action improvement [\#608](https://github.com/pypeclub/pype/pull/608) - Deadline user defaults to pype username if present [\#607](https://github.com/pypeclub/pype/pull/607) - Standalone publisher now has icon [\#606](https://github.com/pypeclub/pype/pull/606) - Nuke render write targeting knob improvement [\#603](https://github.com/pypeclub/pype/pull/603) - Animated pyblish gui [\#602](https://github.com/pypeclub/pype/pull/602) - Maya: Deadline - make use of asset dependencies optional [\#591](https://github.com/pypeclub/pype/pull/591) - Nuke: Publishing, loading and updating alembic cameras [\#575](https://github.com/pypeclub/pype/pull/575) - Maya: add look assigner to pype menu even if scriptsmenu is not available [\#573](https://github.com/pypeclub/pype/pull/573) - Store task types in the database [\#572](https://github.com/pypeclub/pype/pull/572) - Maya: Tiled EXRs to scanline EXRs render option [\#512](https://github.com/pypeclub/pype/pull/512) - Fusion basic integration [\#452](https://github.com/pypeclub/pype/pull/452) **Fixed bugs:** - Burnin script did not propagate ffmpeg output [\#640](https://github.com/pypeclub/pype/issues/640) - Pyblish-pype spacer in terminal wasn't transparent [\#646](https://github.com/pypeclub/pype/pull/646) - Lib subprocess without logger [\#645](https://github.com/pypeclub/pype/pull/645) - Nuke: prevent crash if we only have single frame in sequence [\#644](https://github.com/pypeclub/pype/pull/644) - Burnin script logs better output [\#641](https://github.com/pypeclub/pype/pull/641) - Missing audio on farm submission. [\#639](https://github.com/pypeclub/pype/pull/639) - review from imagesequence error [\#633](https://github.com/pypeclub/pype/pull/633) - Hiero: wrong order of fps clip instance data collecting [\#627](https://github.com/pypeclub/pype/pull/627) - Add source for review instances. [\#625](https://github.com/pypeclub/pype/pull/625) - Task processing in event sync [\#623](https://github.com/pypeclub/pype/pull/623) - sync to avalon doesn t remove renamed task [\#619](https://github.com/pypeclub/pype/pull/619) - Intent publish setting wasn't working with default value [\#562](https://github.com/pypeclub/pype/pull/562) - Maya: Updating a look where the shader name changed, leaves the geo without a shader [\#514](https://github.com/pypeclub/pype/pull/514) **Merged pull requests:** - Avalon module without Qt [\#581](https://github.com/pypeclub/pype/pull/581) - Ftrack module without Qt [\#577](https://github.com/pypeclub/pype/pull/577) ## [2.12.5](https://github.com/pypeclub/pype/tree/2.12.5) (2020-10-14) [Full Changelog](https://github.com/pypeclub/pype/compare/2.12.4...2.12.5) **Enhancements:** - Launch TvPaint shot work file from within Ftrack [\#629](https://github.com/pypeclub/pype/issues/629) **Merged pull requests:** - Harmony: Disable application launch logic [\#637](https://github.com/pypeclub/pype/pull/637) ## [2.12.4](https://github.com/pypeclub/pype/tree/2.12.4) (2020-10-08) [Full Changelog](https://github.com/pypeclub/pype/compare/2.12.3...2.12.4) **Enhancements:** - convert nukestudio to hiero host [\#616](https://github.com/pypeclub/pype/issues/616) - Fusion basic integration [\#451](https://github.com/pypeclub/pype/issues/451) **Fixed bugs:** - Sync to avalon doesn't remove renamed task [\#605](https://github.com/pypeclub/pype/issues/605) - NukeStudio: FPS collecting into clip instances [\#624](https://github.com/pypeclub/pype/pull/624) **Merged pull requests:** - NukeStudio: small fixes [\#622](https://github.com/pypeclub/pype/pull/622) - NukeStudio: broken order of plugins [\#620](https://github.com/pypeclub/pype/pull/620) ## [2.12.3](https://github.com/pypeclub/pype/tree/2.12.3) (2020-10-06) [Full Changelog](https://github.com/pypeclub/pype/compare/2.12.2...2.12.3) **Enhancements:** - Nuke Publish Camera [\#567](https://github.com/pypeclub/pype/issues/567) - Harmony: open xstage file no matter of its name [\#526](https://github.com/pypeclub/pype/issues/526) - Stop integration of unwanted data [\#387](https://github.com/pypeclub/pype/issues/387) - Move avalon-launcher functionality to pype [\#229](https://github.com/pypeclub/pype/issues/229) - avalon workfiles api [\#214](https://github.com/pypeclub/pype/issues/214) - Store task types [\#180](https://github.com/pypeclub/pype/issues/180) - Avalon Mongo Connection split [\#136](https://github.com/pypeclub/pype/issues/136) - nk camera workflow [\#71](https://github.com/pypeclub/pype/issues/71) - Hiero integration added [\#590](https://github.com/pypeclub/pype/pull/590) - Anatomy instance data collection is substantially faster for many instances [\#560](https://github.com/pypeclub/pype/pull/560) **Fixed bugs:** - test issue [\#596](https://github.com/pypeclub/pype/issues/596) - Harmony: empty scene contamination [\#583](https://github.com/pypeclub/pype/issues/583) - Edit publishing in SP doesn't respect shot selection for publishing [\#542](https://github.com/pypeclub/pype/issues/542) - Pathlib breaks compatibility with python2 hosts [\#281](https://github.com/pypeclub/pype/issues/281) - Updating a look where the shader name changed leaves the geo without a shader [\#237](https://github.com/pypeclub/pype/issues/237) - Better error handling [\#84](https://github.com/pypeclub/pype/issues/84) - Harmony: function signature [\#609](https://github.com/pypeclub/pype/pull/609) - Nuke: gizmo publishing error [\#594](https://github.com/pypeclub/pype/pull/594) - Harmony: fix clashing namespace of called js functions [\#584](https://github.com/pypeclub/pype/pull/584) - Maya: fix maya scene type preset exception [\#569](https://github.com/pypeclub/pype/pull/569) **Closed issues:** - Nuke Gizmo publishing [\#597](https://github.com/pypeclub/pype/issues/597) - nuke gizmo publishing error [\#592](https://github.com/pypeclub/pype/issues/592) - Publish EDL [\#579](https://github.com/pypeclub/pype/issues/579) - Publish render from SP [\#576](https://github.com/pypeclub/pype/issues/576) - rename ftrack custom attribute group to `pype` [\#184](https://github.com/pypeclub/pype/issues/184) **Merged pull requests:** - Audio file existence check [\#614](https://github.com/pypeclub/pype/pull/614) - NKS small fixes [\#587](https://github.com/pypeclub/pype/pull/587) - Standalone publisher editorial plugins interfering [\#580](https://github.com/pypeclub/pype/pull/580) ## [2.12.2](https://github.com/pypeclub/pype/tree/2.12.2) (2020-09-25) [Full Changelog](https://github.com/pypeclub/pype/compare/2.12.1...2.12.2) **Enhancements:** - pype config GUI [\#241](https://github.com/pypeclub/pype/issues/241) **Fixed bugs:** - Harmony: Saving heavy scenes will crash [\#507](https://github.com/pypeclub/pype/issues/507) - Extract review a representation name with `\*\_burnin` [\#388](https://github.com/pypeclub/pype/issues/388) - Hierarchy data was not considering active isntances [\#551](https://github.com/pypeclub/pype/pull/551) ## [2.12.1](https://github.com/pypeclub/pype/tree/2.12.1) (2020-09-15) [Full Changelog](https://github.com/pypeclub/pype/compare/2.12.0...2.12.1) **Fixed bugs:** - Pype: changelog.md is outdated [\#503](https://github.com/pypeclub/pype/issues/503) - dependency security alert ! [\#484](https://github.com/pypeclub/pype/issues/484) - Maya: RenderSetup is missing update [\#106](https://github.com/pypeclub/pype/issues/106) - \ extract effects creates new instance [\#78](https://github.com/pypeclub/pype/issues/78) ## [2.12.0](https://github.com/pypeclub/pype/tree/2.12.0) (2020-09-10) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.8...2.12.0) **Enhancements:** - Less mongo connections [\#509](https://github.com/pypeclub/pype/pull/509) - Nuke: adding image loader [\#499](https://github.com/pypeclub/pype/pull/499) - Move launcher window to top if launcher action is clicked [\#450](https://github.com/pypeclub/pype/pull/450) - Maya: better tile rendering support in Pype [\#446](https://github.com/pypeclub/pype/pull/446) - Implementation of non QML launcher [\#443](https://github.com/pypeclub/pype/pull/443) - Optional skip review on renders. [\#441](https://github.com/pypeclub/pype/pull/441) - Ftrack: Option to push status from task to latest version [\#440](https://github.com/pypeclub/pype/pull/440) - Properly containerize image plane loads. [\#434](https://github.com/pypeclub/pype/pull/434) - Option to keep the review files. [\#426](https://github.com/pypeclub/pype/pull/426) - Isolate view on instance members. [\#425](https://github.com/pypeclub/pype/pull/425) - Maya: Publishing of tile renderings on Deadline [\#398](https://github.com/pypeclub/pype/pull/398) - Feature/little bit better logging gui [\#383](https://github.com/pypeclub/pype/pull/383) **Fixed bugs:** - Maya: Fix tile order for Draft Tile Assembler [\#511](https://github.com/pypeclub/pype/pull/511) - Remove extra dash [\#501](https://github.com/pypeclub/pype/pull/501) - Fix: strip dot from repre names in single frame renders [\#498](https://github.com/pypeclub/pype/pull/498) - Better handling of destination during integrating [\#485](https://github.com/pypeclub/pype/pull/485) - Fix: allow thumbnail creation for single frame renders [\#460](https://github.com/pypeclub/pype/pull/460) - added missing argument to launch\_application in ftrack app handler [\#453](https://github.com/pypeclub/pype/pull/453) - Burnins: Copy bit rate of input video to match quality. [\#448](https://github.com/pypeclub/pype/pull/448) - Standalone publisher is now independent from tray [\#442](https://github.com/pypeclub/pype/pull/442) - Bugfix/empty enumerator attributes [\#436](https://github.com/pypeclub/pype/pull/436) - Fixed wrong order of "other" category collapssing in publisher [\#435](https://github.com/pypeclub/pype/pull/435) - Multiple reviews where being overwritten to one. [\#424](https://github.com/pypeclub/pype/pull/424) - Cleanup plugin fail on instances without staging dir [\#420](https://github.com/pypeclub/pype/pull/420) - deprecated -intra parameter in ffmpeg to new `-g` [\#417](https://github.com/pypeclub/pype/pull/417) - Delivery action can now work with entered path [\#397](https://github.com/pypeclub/pype/pull/397) **Merged pull requests:** - Review on instance.data [\#473](https://github.com/pypeclub/pype/pull/473) ## [2.11.8](https://github.com/pypeclub/pype/tree/2.11.8) (2020-08-27) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.7...2.11.8) **Enhancements:** - DWAA support for Maya [\#382](https://github.com/pypeclub/pype/issues/382) - Isolate View on Playblast [\#367](https://github.com/pypeclub/pype/issues/367) - Maya: Tile rendering [\#297](https://github.com/pypeclub/pype/issues/297) - single pype instance running [\#47](https://github.com/pypeclub/pype/issues/47) - PYPE-649: projects don't guarantee backwards compatible environment [\#8](https://github.com/pypeclub/pype/issues/8) - PYPE-663: separate venv for each deployed version [\#7](https://github.com/pypeclub/pype/issues/7) **Fixed bugs:** - pyblish pype - other group is collapsed before plugins are done [\#431](https://github.com/pypeclub/pype/issues/431) - Alpha white edges in harmony on PNGs [\#412](https://github.com/pypeclub/pype/issues/412) - harmony image loader picks wrong representations [\#404](https://github.com/pypeclub/pype/issues/404) - Clockify crash when response contain symbol not allowed by UTF-8 [\#81](https://github.com/pypeclub/pype/issues/81) ## [2.11.7](https://github.com/pypeclub/pype/tree/2.11.7) (2020-08-21) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.6...2.11.7) **Fixed bugs:** - Clean Up Baked Movie [\#369](https://github.com/pypeclub/pype/issues/369) - celaction last workfile [\#459](https://github.com/pypeclub/pype/pull/459) ## [2.11.6](https://github.com/pypeclub/pype/tree/2.11.6) (2020-08-18) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.5...2.11.6) **Enhancements:** - publisher app [\#56](https://github.com/pypeclub/pype/issues/56) ## [2.11.5](https://github.com/pypeclub/pype/tree/2.11.5) (2020-08-13) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.4...2.11.5) **Enhancements:** - Switch from master to equivalent [\#220](https://github.com/pypeclub/pype/issues/220) - Standalone publisher now only groups sequence if the extension is known [\#439](https://github.com/pypeclub/pype/pull/439) **Fixed bugs:** - Logs have been disable for editorial by default to speed up publishing [\#433](https://github.com/pypeclub/pype/pull/433) - additional fixes for celaction [\#430](https://github.com/pypeclub/pype/pull/430) - Harmony: invalid variable scope in validate scene settings [\#428](https://github.com/pypeclub/pype/pull/428) - new representation name for audio was not accepted [\#427](https://github.com/pypeclub/pype/pull/427) ## [2.11.4](https://github.com/pypeclub/pype/tree/2.11.4) (2020-08-10) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.3...2.11.4) **Enhancements:** - WebSocket server [\#135](https://github.com/pypeclub/pype/issues/135) - standalonepublisher: editorial family features expansion \[master branch\] [\#411](https://github.com/pypeclub/pype/pull/411) ## [2.11.3](https://github.com/pypeclub/pype/tree/2.11.3) (2020-08-04) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.2...2.11.3) **Fixed bugs:** - Harmony: publishing performance issues [\#408](https://github.com/pypeclub/pype/pull/408) ## [2.11.2](https://github.com/pypeclub/pype/tree/2.11.2) (2020-07-31) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.1...2.11.2) **Fixed bugs:** - Ftrack to Avalon bug [\#406](https://github.com/pypeclub/pype/issues/406) ## [2.11.1](https://github.com/pypeclub/pype/tree/2.11.1) (2020-07-29) [Full Changelog](https://github.com/pypeclub/pype/compare/2.11.0...2.11.1) **Merged pull requests:** - Celaction: metadata json folder fixes on path [\#393](https://github.com/pypeclub/pype/pull/393) - CelAction - version up method taken fro pype.lib [\#391](https://github.com/pypeclub/pype/pull/391) ## 2.11.0 ## _**release date:** 27 July 2020_ **new:** - _(blender)_ namespace support [\#341](https://github.com/pypeclub/pype/pull/341) - _(blender)_ start end frames [\#330](https://github.com/pypeclub/pype/pull/330) - _(blender)_ camera asset [\#322](https://github.com/pypeclub/pype/pull/322) - _(pype)_ toggle instances per family in pyblish GUI [\#320](https://github.com/pypeclub/pype/pull/320) - _(pype)_ current release version is now shown in the tray menu [#379](https://github.com/pypeclub/pype/pull/379) **improved:** - _(resolve)_ tagging for publish [\#239](https://github.com/pypeclub/pype/issues/239) - _(pype)_ Support publishing a subset of shots with standalone editorial [\#336](https://github.com/pypeclub/pype/pull/336) - _(harmony)_ Basic support for palettes [\#324](https://github.com/pypeclub/pype/pull/324) - _(photoshop)_ Flag outdated containers on startup and publish. [\#309](https://github.com/pypeclub/pype/pull/309) - _(harmony)_ Flag Outdated containers [\#302](https://github.com/pypeclub/pype/pull/302) - _(photoshop)_ Publish review [\#298](https://github.com/pypeclub/pype/pull/298) - _(pype)_ Optional Last workfile launch [\#365](https://github.com/pypeclub/pype/pull/365) **fixed:** - _(premiere)_ workflow fixes [\#346](https://github.com/pypeclub/pype/pull/346) - _(pype)_ pype-setup does not work with space in path [\#327](https://github.com/pypeclub/pype/issues/327) - _(ftrack)_ Ftrack delete action cause circular error [\#206](https://github.com/pypeclub/pype/issues/206) - _(nuke)_ Priority was forced to 50 [\#345](https://github.com/pypeclub/pype/pull/345) - _(nuke)_ Fix ValidateNukeWriteKnobs [\#340](https://github.com/pypeclub/pype/pull/340) - _(maya)_ If camera attributes are connected, we can ignore them. [\#339](https://github.com/pypeclub/pype/pull/339) - _(pype)_ stop appending of tools environment to existing env [\#337](https://github.com/pypeclub/pype/pull/337) - _(ftrack)_ Ftrack timeout needs to look at AVALON\_TIMEOUT [\#325](https://github.com/pypeclub/pype/pull/325) - _(harmony)_ Only zip files are supported. [\#310](https://github.com/pypeclub/pype/pull/310) - _(pype)_ hotfix/Fix event server mongo uri [\#305](https://github.com/pypeclub/pype/pull/305) - _(photoshop)_ Subset was not named or validated correctly. [\#304](https://github.com/pypeclub/pype/pull/304) ## 2.10.0 ## _**release date:** 17 June 2020_ **new:** - _(harmony)_ **Toon Boom Harmony** has been greatly extended to support rigging, scene build, animation and rendering workflows. [#270](https://github.com/pypeclub/pype/issues/270) [#271](https://github.com/pypeclub/pype/issues/271) [#190](https://github.com/pypeclub/pype/issues/190) [#191](https://github.com/pypeclub/pype/issues/191) [#172](https://github.com/pypeclub/pype/issues/172) [#168](https://github.com/pypeclub/pype/issues/168) - _(pype)_ Added support for rudimentary **edl publishing** into individual shots. [#265](https://github.com/pypeclub/pype/issues/265) - _(celaction)_ Simple **Celaction** integration has been added with support for workfiles and rendering. [#255](https://github.com/pypeclub/pype/issues/255) - _(maya)_ Support for multiple job types when submitting to the farm. We can now render Maya or Standalone render jobs for Vray and Arnold (limited support for arnold) [#204](https://github.com/pypeclub/pype/issues/204) - _(photoshop)_ Added initial support for Photoshop [#232](https://github.com/pypeclub/pype/issues/232) **improved:** - _(blender)_ Updated support for rigs and added support Layout family [#233](https://github.com/pypeclub/pype/issues/233) [#226](https://github.com/pypeclub/pype/issues/226) - _(premiere)_ It is now possible to choose different storage root for workfiles of different task types. [#255](https://github.com/pypeclub/pype/issues/255) - _(maya)_ Support for unmerged AOVs in Redshift multipart EXRs [#197](https://github.com/pypeclub/pype/issues/197) - _(pype)_ Pype repository has been refactored in preparation for 3.0 release [#169](https://github.com/pypeclub/pype/issues/169) - _(deadline)_ All file dependencies are now passed to deadline from maya to prevent premature start of rendering if caches or textures haven't been coppied over yet. [#195](https://github.com/pypeclub/pype/issues/195) - _(nuke)_ Script validation can now be made optional. [#194](https://github.com/pypeclub/pype/issues/194) - _(pype)_ Publishing can now be stopped at any time. [#194](https://github.com/pypeclub/pype/issues/194) **fix:** - _(pype)_ Pyblish-lite has been integrated into pype repository, plus various publishing GUI fixes. [#274](https://github.com/pypeclub/pype/issues/274) [#275](https://github.com/pypeclub/pype/issues/275) [#268](https://github.com/pypeclub/pype/issues/268) [#227](https://github.com/pypeclub/pype/issues/227) [#238](https://github.com/pypeclub/pype/issues/238) - _(maya)_ Alembic extractor was getting wrong frame range type in certain scenarios [#254](https://github.com/pypeclub/pype/issues/254) - _(maya)_ Attaching a render to subset in maya was not passing validation in certain scenarios [#256](https://github.com/pypeclub/pype/issues/256) - _(ftrack)_ Various small fixes to ftrack sync [#263](https://github.com/pypeclub/pype/issues/263) [#259](https://github.com/pypeclub/pype/issues/259) - _(maya)_ Look extraction is now able to skp invalid connections in shaders [#207](https://github.com/pypeclub/pype/issues/207) ## 2.9.0 ## _**release date:** 25 May 2020_ **new:** - _(pype)_ Support for **Multiroot projects**. You can now store project data on multiple physical or virtual storages and target individual publishes to these locations. For instance render can be stored on a faster storage than the rest of the project. [#145](https://github.com/pypeclub/pype/issues/145), [#38](https://github.com/pypeclub/pype/issues/38) - _(harmony)_ Basic implementation of **Toon Boom Harmony** has been added. [#142](https://github.com/pypeclub/pype/issues/142) - _(pype)_ OSX support is in public beta now. There are issues to be expected, but the main implementation should be functional. [#141](https://github.com/pypeclub/pype/issues/141) **improved:** - _(pype)_ **Review extractor** has been completely rebuilt. It now supports granular filtering so you can create **multiple outputs** for different tasks, families or hosts. [#103](https://github.com/pypeclub/pype/issues/103), [#166](https://github.com/pypeclub/pype/issues/166), [#165](https://github.com/pypeclub/pype/issues/165) - _(pype)_ **Burnin** generation had been extended to **support same multi-output filtering** as review extractor [#103](https://github.com/pypeclub/pype/issues/103) - _(pype)_ Publishing file templates can now be specified in config for each individual family [#114](https://github.com/pypeclub/pype/issues/114) - _(pype)_ Studio specific plugins can now be appended to pype standard publishing plugins. [#112](https://github.com/pypeclub/pype/issues/112) - _(nukestudio)_ Reviewable clips no longer need to be previously cut, exported and re-imported to timeline. **Pype can now dynamically cut reviewable quicktimes** from continuous offline footage during publishing. [#23](https://github.com/pypeclub/pype/issues/23) - _(deadline)_ Deadline can now correctly differentiate between staging and production pype. [#154](https://github.com/pypeclub/pype/issues/154) - _(deadline)_ `PYPE_PYTHON_EXE` env variable can now be used to direct publishing to explicit python installation. [#120](https://github.com/pypeclub/pype/issues/120) - _(nuke)_ Nuke now check for new version of loaded data on file open. [#140](https://github.com/pypeclub/pype/issues/140) - _(nuke)_ frame range and limit checkboxes are now exposed on write node. [#119](https://github.com/pypeclub/pype/issues/119) **fix:** - _(nukestudio)_ Project Location was using backslashes which was breaking nukestudio native exporting in certains configurations [#82](https://github.com/pypeclub/pype/issues/82) - _(nukestudio)_ Duplicity in hierarchy tags was prone to throwing publishing error [#130](https://github.com/pypeclub/pype/issues/130), [#144](https://github.com/pypeclub/pype/issues/144) - _(ftrack)_ multiple stability improvements [#157](https://github.com/pypeclub/pype/issues/157), [#159](https://github.com/pypeclub/pype/issues/159), [#128](https://github.com/pypeclub/pype/issues/128), [#118](https://github.com/pypeclub/pype/issues/118), [#127](https://github.com/pypeclub/pype/issues/127) - _(deadline)_ multipart EXRs were stopping review publishing on the farm. They are still not supported for automatic review generation, but the publish will go through correctly without the quicktime. [#155](https://github.com/pypeclub/pype/issues/155) - _(deadline)_ If deadline is non-responsive it will no longer freeze host when publishing [#149](https://github.com/pypeclub/pype/issues/149) - _(deadline)_ Sometimes deadline was trying to launch render before all the source data was coppied over. [#137](https://github.com/pypeclub/pype/issues/137) _(harmony)_ Basic implementation of **Toon Boom Harmony** has been added. [#142](https://github.com/pypeclub/pype/issues/142) - _(nuke)_ Filepath knob wasn't updated properly. [#131](https://github.com/pypeclub/pype/issues/131) - _(maya)_ When extracting animation, the "Write Color Set" options on the instance were not respected. [#108](https://github.com/pypeclub/pype/issues/108) - _(maya)_ Attribute overrides for AOV only worked for the legacy render layers. Now it works for new render setup as well [#132](https://github.com/pypeclub/pype/issues/132) - _(maya)_ Stability and usability improvements in yeti workflow [#104](https://github.com/pypeclub/pype/issues/104) ## 2.8.0 ## _**release date:** 20 April 2020_ **new:** - _(pype)_ Option to generate slates from json templates. [PYPE-628] [#26](https://github.com/pypeclub/pype/issues/26) - _(pype)_ It is now possible to automate loading of published subsets into any scene. Documentation will follow :). [PYPE-611] [#24](https://github.com/pypeclub/pype/issues/24) **fix:** - _(maya)_ Some Redshift render tokens could break publishing. [PYPE-778] [#33](https://github.com/pypeclub/pype/issues/33) - _(maya)_ Publish was not preserving maya file extension. [#39](https://github.com/pypeclub/pype/issues/39) - _(maya)_ Rig output validator was failing on nodes without shapes. [#40](https://github.com/pypeclub/pype/issues/40) - _(maya)_ Yeti caches can now be properly versioned up in the scene inventory. [#40](https://github.com/pypeclub/pype/issues/40) - _(nuke)_ Build first workfiles was not accepting jpeg sequences. [#34](https://github.com/pypeclub/pype/issues/34) - _(deadline)_ Trying to generate ffmpeg review from multipart EXRs no longer crashes publishing. [PYPE-781] - _(deadline)_ Render publishing is more stable in multiplatform environments. [PYPE-775] ## 2.7.0 ## _**release date:** 30 March 2020_ **new:** - _(maya)_ Artist can now choose to load multiple references of the same subset at once [PYPE-646, PYPS-81] - _(nuke)_ Option to use named OCIO colorspaces for review colour baking. [PYPS-82] - _(pype)_ Pype can now work with `master` versions for publishing and loading. These are non-versioned publishes that are overwritten with the latest version during publish. These are now supported in all the GUIs, but their publishing is deactivated by default. [PYPE-653] - _(blender)_ Added support for basic blender workflow. We currently support `rig`, `model` and `animation` families. [PYPE-768] - _(pype)_ Source timecode can now be used in burn-ins. [PYPE-777] - _(pype)_ Review outputs profiles can now specify delivery resolution different than project setting [PYPE-759] - _(nuke)_ Bookmark to current context is now added automatically to all nuke browser windows. [PYPE-712] **change:** - _(maya)_ It is now possible to publish camera without. baking. Keep in mind that unbaked cameras can't be guaranteed to work in other hosts. [PYPE-595] - _(maya)_ All the renders from maya are now grouped in the loader by their Layer name. [PYPE-482] - _(nuke/hiero)_ Any publishes from nuke and hiero can now be versioned independently of the workfile. [PYPE-728] **fix:** - _(nuke)_ Mixed slashes caused issues in ocio config path. - _(pype)_ Intent field in pyblish GUI was passing label instead of value to ftrack. [PYPE-733] - _(nuke)_ Publishing of pre-renders was inconsistent. [PYPE-766] - _(maya)_ Handles and frame ranges were inconsistent in various places during publishing. - _(nuke)_ Nuke was crashing if it ran into certain missing knobs. For example DPX output missing `autocrop` [PYPE-774] - _(deadline)_ Project overrides were not working properly with farm render publishing. - _(hiero)_ Problems with single frame plates publishing. - _(maya)_ Redshift RenderPass token were breaking render publishing. [PYPE-778] - _(nuke)_ Build first workfile was not accepting jpeg sequences. - _(maya)_ Multipart (Multilayer) EXRs were breaking review publishing due to FFMPEG incompatiblity [PYPE-781] ## 2.6.0 ## _**release date:** 9 March 2020_ **change:** - _(maya)_ render publishing has been simplified and made more robust. Render setup layers are now automatically added to publishing subsets and `render globals` family has been replaced with simple `render` [PYPE-570] - _(avalon)_ change context and workfiles apps, have been merged into one, that allows both actions to be performed at the same time. [PYPE-747] - _(pype)_ thumbnails are now automatically propagate to asset from the last published subset in the loader - _(ftrack)_ publishing comment and intent are now being published to ftrack note as well as describtion. [PYPE-727] - _(pype)_ when overriding existing version new old representations are now overriden, instead of the new ones just being appended. (to allow this behaviour, the version validator need to be disabled. [PYPE-690]) - _(pype)_ burnin preset has been significantly simplified. It now doesn't require passing function to each field, but only need the actual text template. to use this, all the current burnin PRESETS MUST BE UPDATED for all the projects. - _(ftrack)_ credentials are now stored on a per server basis, so it's possible to switch between ftrack servers without having to log in and out. [PYPE-723] **new:** - _(pype)_ production and development deployments now have different colour of the tray icon. Orange for Dev and Green for production [PYPE-718] - _(maya)_ renders can now be attached to a publishable subset rather than creating their own subset. For example it is possible to create a reviewable `look` or `model` render and have it correctly attached as a representation of the subsets [PYPE-451] - _(maya)_ after saving current scene into a new context (as a new shot for instance), all the scene publishing subsets data gets re-generated automatically to match the new context [PYPE-532] - _(pype)_ we now support project specific publish, load and create plugins [PYPE-740] - _(ftrack)_ new action that allow archiving/deleting old published versions. User can keep how many of the latest version to keep when the action is ran. [PYPE-748, PYPE-715] - _(ftrack)_ it is now possible to monitor and restart ftrack event server using ftrack action. [PYPE-658] - _(pype)_ validator that prevent accidental overwrites of previously published versions. [PYPE-680] - _(avalon)_ avalon core updated to version 5.6.0 - _(maya)_ added validator to make sure that relative paths are used when publishing arnold standins. - _(nukestudio)_ it is now possible to extract and publish audio family from clip in nuke studio [PYPE-682] **fix**: - _(maya)_ maya set framerange button was ignoring handles [PYPE-719] - _(ftrack)_ sync to avalon was sometime crashing when ran on empty project - _(nukestudio)_ publishing same shots after they've been previously archived/deleted would result in a crash. [PYPE-737] - _(nuke)_ slate workflow was breaking in certain scenarios. [PYPE-730] - _(pype)_ rendering publish workflow has been significantly improved to prevent error resulting from implicit render collection. [PYPE-665, PYPE-746] - _(pype)_ launching application on a non-synced project resulted in obscure [PYPE-528] - _(pype)_ missing keys in burnins no longer result in an error. [PYPE-706] - _(ftrack)_ create folder structure action was sometimes failing for project managers due to wrong permissions. - _(Nukestudio)_ using `source` in the start frame tag could result in wrong frame range calculation - _(ftrack)_ sync to avalon action and event have been improved by catching more edge cases and provessing them properly. ## 2.5.0 ## _**release date:** 11 Feb 2020_ **change:** - _(pype)_ added many logs for easier debugging - _(pype)_ review presets can now be separated between 2d and 3d renders [PYPE-693] - _(pype)_ anatomy module has been greatly improved to allow for more dynamic pulblishing and faster debugging [PYPE-685] - _(pype)_ avalon schemas have been moved from `pype-config` to `pype` repository, for simplification. [PYPE-670] - _(ftrack)_ updated to latest ftrack API - _(ftrack)_ publishing comments now appear in ftrack also as a note on version with customisable category [PYPE-645] - _(ftrack)_ delete asset/subset action had been improved. It is now able to remove multiple entities and descendants of the selected entities [PYPE-361, PYPS-72] - _(workfiles)_ added date field to workfiles app [PYPE-603] - _(maya)_ old deprecated loader have been removed in favour of a single unified reference loader (old scenes will upgrade automatically to the new loader upon opening) [PYPE-633, PYPE-697] - _(avalon)_ core updated to 5.5.15 [PYPE-671] - _(nuke)_ library loader is now available in nuke [PYPE-698] **new:** - _(pype)_ added pype render wrapper to allow rendering on mixed platform farms. [PYPE-634] - _(pype)_ added `pype launch` command. It let's admin run applications with dynamically built environment based on the given context. [PYPE-634] - _(pype)_ added support for extracting review sequences with burnins [PYPE-657] - _(publish)_ users can now set intent next to a comment when publishing. This will then be reflected on an attribute in ftrack. [PYPE-632] - _(burnin)_ timecode can now be added to burnin - _(burnin)_ datetime keys can now be added to burnin and anatomy [PYPE-651] - _(burnin)_ anatomy templates can now be used in burnins. [PYPE=626] - _(nuke)_ new validator for render resolution - _(nuke)_ support for attach slate to nuke renders [PYPE-630] - _(nuke)_ png sequences were added to loaders - _(maya)_ added maya 2020 compatibility [PYPE-677] - _(maya)_ ability to publish and load .ASS standin sequences [PYPS-54] - _(pype)_ thumbnails can now be published and are visible in the loader. `AVALON_THUMBNAIL_ROOT` environment variable needs to be set for this to work [PYPE-573, PYPE-132] - _(blender)_ base implementation of blender was added with publishing and loading of .blend files [PYPE-612] - _(ftrack)_ new action for preparing deliveries [PYPE-639] **fix**: - _(burnin)_ more robust way of finding ffmpeg for burnins. - _(pype)_ improved UNC paths remapping when sending to farm. - _(pype)_ float frames sometimes made their way to representation context in database, breaking loaders [PYPE-668] - _(pype)_ `pype install --force` was failing sometimes [PYPE-600] - _(pype)_ padding in published files got calculated wrongly sometimes. It is now instead being always read from project anatomy. [PYPE-667] - _(publish)_ comment publishing was failing in certain situations - _(ftrack)_ multiple edge case scenario fixes in auto sync and sync-to-avalon action - _(ftrack)_ sync to avalon now works on empty projects - _(ftrack)_ thumbnail update event was failing when deleting entities [PYPE-561] - _(nuke)_ loader applies proper colorspaces from Presets - _(nuke)_ publishing handles didn't always work correctly [PYPE-686] - _(maya)_ assembly publishing and loading wasn't working correctly ## 2.4.0 ## _**release date:** 9 Dec 2019_ **change:** - _(ftrack)_ version to status ftrack event can now be configured from Presets - based on preset `presets/ftracc/ftrack_config.json["status_version_to_task"]` - _(ftrack)_ sync to avalon event has been completely re-written. It now supports most of the project management situations on ftrack including moving, renaming and deleting entities, updating attributes and working with tasks. - _(ftrack)_ sync to avalon action has been also re-writen. It is now much faster (up to 100 times depending on a project structure), has much better logging and reporting on encountered problems, and is able to handle much more complex situations. - _(ftrack)_ sync to avalon trigger by checking `auto-sync` toggle on ftrack [PYPE-504] - _(pype)_ various new features in the REST api - _(pype)_ new visual identity used across pype - _(pype)_ started moving all requirements to pip installation rather than vendorising them in pype repository. Due to a few yet unreleased packages, this means that pype can temporarily be only installed in the offline mode. **new:** - _(nuke)_ support for publishing gizmos and loading them as viewer processes - _(nuke)_ support for publishing nuke nodes from backdrops and loading them back - _(pype)_ burnins can now work with start and end frames as keys - use keys `{frame_start}`, `{frame_end}` and `{current_frame}` in burnin preset to use them. [PYPS-44,PYPS-73, PYPE-602] - _(pype)_ option to filter logs by user and level in loggin GUI - _(pype)_ image family added to standalone publisher [PYPE-574] - _(pype)_ matchmove family added to standalone publisher [PYPE-574] - _(nuke)_ validator for comparing arbitrary knobs with values from presets - _(maya)_ option to force maya to copy textures in the new look publish rather than hardlinking them - _(pype)_ comments from pyblish GUI are now being added to ftrack version - _(maya)_ validator for checking outdated containers in the scene - _(maya)_ option to publish and load arnold standin sequence [PYPE-579, PYPS-54] **fix**: - _(pype)_ burnins were not respecting codec of the input video - _(nuke)_ lot's of various nuke and nuke studio fixes across the board [PYPS-45] - _(pype)_ workfiles app is not launching with the start of the app by default [PYPE-569] - _(ftrack)_ ftrack integration during publishing was failing under certain situations [PYPS-66] - _(pype)_ minor fixes in REST api - _(ftrack)_ status change event was crashing when the target status was missing [PYPS-68] - _(ftrack)_ actions will try to reconnect if they fail for some reason - _(maya)_ problems with fps mapping when using float FPS values - _(deadline)_ overall improvements to deadline publishing - _(setup)_ environment variables are now remapped on the fly based on the platform pype is running on. This fixes many issues in mixed platform environments. ## 2.3.6 # _**release date:** 27 Nov 2019_ **hotfix**: - _(ftrack)_ was hiding important debug logo - _(nuke)_ crashes during workfile publishing - _(ftrack)_ event server crashes because of signal problems - _(muster)_ problems with muster render submissions - _(ftrack)_ thumbnail update event syntax errors ## 2.3.0 ## _release date: 6 Oct 2019_ **new**: - _(maya)_ support for yeti rigs and yeti caches - _(maya)_ validator for comparing arbitrary attributes against ftrack - _(pype)_ burnins can now show current date and time - _(muster)_ pools can now be set in render globals in maya - _(pype)_ Rest API has been implemented in beta stage - _(nuke)_ LUT loader has been added - _(pype)_ rudimentary user module has been added as preparation for user management - _(pype)_ a simple logging GUI has been added to pype tray - _(nuke)_ nuke can now bake input process into mov - _(maya)_ imported models now have selection handle displayed by defaulting - _(avalon)_ it's is now possible to load multiple assets at once using loader - _(maya)_ added ability to automatically connect yeti rig to a mesh upon loading **changed**: - _(ftrack)_ event server now runs two parallel processes and is able to keep queue of events to process. - _(nuke)_ task name is now added to all rendered subsets - _(pype)_ adding more families to standalone publisher - _(pype)_ standalone publisher now uses pyblish-lite - _(pype)_ standalone publisher can now create review quicktimes - _(ftrack)_ queries to ftrack were sped up - _(ftrack)_ multiple ftrack action have been deprecated - _(avalon)_ avalon upstream has been updated to 5.5.0 - _(nukestudio)_ published transforms can now be animated - **fix**: - _(maya)_ fps popup button didn't work in some cases - _(maya)_ geometry instances and references in maya were losing shader assignments - _(muster)_ muster rendering templates were not working correctly - _(maya)_ arnold tx texture conversion wasn't respecting colorspace set by the artist - _(pype)_ problems with avalon db sync - _(maya)_ ftrack was rounding FPS making it inconsistent - _(pype)_ wrong icon names in Creator - _(maya)_ scene inventory wasn't showing anything if representation was removed from database after it's been loaded to the scene - _(nukestudio)_ multiple bugs squashed - _(loader)_ loader was taking long time to show all the loading action when first launcher in maya ## 2.2.0 ## _release date: 8 Sept 2019_ **new**: - _(pype)_ add customisable workflow for creating quicktimes from renders or playblasts - _(nuke)_ option to choose deadline chunk size on write nodes - _(nukestudio)_ added option to publish soft effects (subTrackItems) from NukeStudio as subsets including LUT files. these can then be loaded in nuke or NukeStudio - _(nuke)_ option to build nuke script from previously published latest versions of plate and render subsets. - _(nuke)_ nuke writes now have deadline tab. - _(ftrack)_ Prepare Project action can now be used for creating the base folder structure on disk and in ftrack, setting up all the initial project attributes and it automatically prepares `pype_project_config` folder for the given project. - _(clockify)_ Added support for time tracking in clockify. This currently in addition to ftrack time logs, but does not completely replace them. - _(pype)_ any attributes in Creator and Loader plugins can now be customised using pype preset system **changed**: - nukestudio now uses workio API for workfiles - _(maya)_ "FIX FPS" prompt in maya now appears in the middle of the screen - _(muster)_ can now be configured with custom templates - _(pype)_ global publishing plugins can now be configured using presets as well as host specific ones **fix**: - wrong version retrieval from path in certain scenarios - nuke reset resolution wasn't working in certain scenarios ## 2.1.0 ## _release date: 6 Aug 2019_ A large cleanup release. Most of the change are under the hood. **new**: - _(pype)_ add customisable workflow for creating quicktimes from renders or playblasts - _(pype)_ Added configurable option to add burnins to any generated quicktimes - _(ftrack)_ Action that identifies what machines pype is running on. - _(system)_ unify subprocess calls - _(maya)_ add audio to review quicktimes - _(nuke)_ add crop before write node to prevent overscan problems in ffmpeg - **Nuke Studio** publishing and workfiles support - **Muster** render manager support - _(nuke)_ Framerange, FPS and Resolution are set automatically at startup - _(maya)_ Ability to load published sequences as image planes - _(system)_ Ftrack event that sets asset folder permissions based on task assignees in ftrack. - _(maya)_ Pyblish plugin that allow validation of maya attributes - _(system)_ added better startup logging to tray debug, including basic connection information - _(avalon)_ option to group published subsets to groups in the loader - _(avalon)_ loader family filters are working now **changed**: - change multiple key attributes to unify their behaviour across the pipeline - `frameRate` to `fps` - `startFrame` to `frameStart` - `endFrame` to `frameEnd` - `fstart` to `frameStart` - `fend` to `frameEnd` - `handle_start` to `handleStart` - `handle_end` to `handleEnd` - `resolution_width` to `resolutionWidth` - `resolution_height` to `resolutionHeight` - `pixel_aspect` to `pixelAspect` - _(nuke)_ write nodes are now created inside group with only some attributes editable by the artist - rendered frames are now deleted from temporary location after their publishing is finished. - _(ftrack)_ RV action can now be launched from any entity - after publishing only refresh button is now available in pyblish UI - added context instance pyblish-lite so that artist knows if context plugin fails - _(avalon)_ allow opening selected files using enter key - _(avalon)_ core updated to v5.2.9 with our forked changes on top **fix**: - faster hierarchy retrieval from db - _(nuke)_ A lot of stability enhancements - _(nuke studio)_ A lot of stability enhancements - _(nuke)_ now only renders a single write node on farm - _(ftrack)_ pype would crash when launcher project level task - work directory was sometimes not being created correctly - major pype.lib cleanup. Removing of unused functions, merging those that were doing the same and general house cleaning. - _(avalon)_ subsets in maya 2019 weren't behaving correctly in the outliner \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Orbi Tools s.r.o. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ [![All Contributors](https://img.shields.io/badge/all_contributors-28-orange.svg?style=flat-square)](#contributors-) OpenPype ======== [![documentation](https://github.com/pypeclub/pype/actions/workflows/documentation.yml/badge.svg)](https://github.com/pypeclub/pype/actions/workflows/documentation.yml) ![GitHub VFX Platform](https://img.shields.io/badge/vfx%20platform-2022-lightgrey?labelColor=303846) ## Important Notice! OpenPype as a standalone product has reach end of it's life and this repository has now been archived in favour of [ayon-core](https://github.com/ynput/ayon-core). You can read more details about the end of life process here https://community.ynput.io/t/openpype-end-of-life-timeline/877 Thank you all for years of amazing contributions and see you in AYON! ``` Please refer to https://github.com/ynput/OpenPype/blob/develop/CONTRIBUTING.md for more information about the current PR process. ``` Introduction ------------ Open-source pipeline for visual effects and animation built on top of the [Avalon](https://getavalon.github.io/) framework, expanding it with extra features and integrations. OpenPype connects your DCCs, asset database, project management and time tracking into a single system. It has a tight integration with [ftrack](https://www.ftrack.com/en/), but can also run independently or be integrated into a different project management solution. OpenPype provides a robust platform for your studio, without the worry of a vendor lock. You will always have full access to the source-code and your project database will run locally or in the cloud of your choice. To get all the information about the project, go to [OpenPype.io](http://openpype.io) Requirements ------------ We aim to closely follow [**VFX Reference Platform**](https://vfxplatform.com/) OpenPype is written in Python 3 with specific elements still running in Python2 until all DCCs are fully updated. To see the list of those, that are not quite there yet, go to [VFX Python3 tracker](https://vfxpy.com/) The main things you will need to run and build OpenPype are: - **Terminal** in your OS - PowerShell 5.0+ (Windows) - Bash (Linux) - [**Python 3.9.6**](#python) or higher - [**MongoDB**](#database) (needed only for local development) It can be built and ran on all common platforms. We develop and test on the following: - **Windows** 10 - **Linux** - **Ubuntu** 20.04 LTS - **Centos** 7 - **Mac OSX** - **10.15** Catalina - **11.1** Big Sur (using Rosetta2) For more details on requirements visit [requirements documentation](https://openpype.io/docs/dev_requirements) Building OpenPype ----------------- To build OpenPype you currently need [Python 3.9](https://www.python.org/downloads/) as we are following [vfx platform](https://vfxplatform.com). Because of some Linux distros comes with newer Python version already, you need to install **3.9** version and make use of it. You can use perhaps [pyenv](https://github.com/pyenv/pyenv) for this on Linux. **Note**: We do not support 3.9.0 because of [this bug](https://github.com/python/cpython/pull/22670). Please, use higher versions of 3.9.x. ### Windows You will need [Python >= 3.9.1](https://www.python.org/downloads/) and [git](https://git-scm.com/downloads). More tools might be needed for installing dependencies (for example for **OpenTimelineIO**) - mostly development tools like [CMake](https://cmake.org/) and [Visual Studio](https://visualstudio.microsoft.com/cs/downloads/) #### Clone repository: ```sh git clone --recurse-submodules git@github.com:ynput/OpenPype.git ``` #### To build OpenPype: 1) Run `.\tools\create_env.ps1` to create virtual environment in `.\venv`. 2) Run `.\tools\fetch_thirdparty_libs.ps1` to download third-party dependencies like ffmpeg and oiio. Those will be included in build. 3) Run `.\tools\build.ps1` to build OpenPype executables in `.\build\`. To create distributable OpenPype versions, run `./tools/create_zip.ps1` - that will create zip file with name `openpype-vx.x.x.zip` parsed from current OpenPype repository and copy it to user data dir, or you can specify `--path /path/to/zip` to force it there. You can then point **Igniter** - OpenPype setup tool - to directory containing this zip and it will install it on current computer. OpenPype is build using [CX_Freeze](https://cx-freeze.readthedocs.io/en/latest) to freeze itself and all dependencies. ### macOS You will need [Python >= 3.9](https://www.python.org/downloads/) and [git](https://git-scm.com/downloads). You'll need also other tools to build some OpenPype dependencies like [CMake](https://cmake.org/) and **XCode Command Line Tools** (or some other build system). Easy way of installing everything necessary is to use [Homebrew](https://brew.sh): 1) Install **Homebrew**: ```sh /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" ``` 2) Install **cmake**: ```sh brew install cmake ``` 3) Install [pyenv](https://github.com/pyenv/pyenv): ```sh brew install pyenv echo 'eval "$(pyenv init -)"' >> ~/.zshrc pyenv init exec "$SHELL" PATH=$(pyenv root)/shims:$PATH ``` 4) Pull in required Python version 3.9.x: ```sh # install Python build dependences brew install openssl readline sqlite3 xz zlib # replace with up-to-date 3.9.x version pyenv install 3.9.6 ``` 5) Set local Python version: ```sh # switch to OpenPype source directory pyenv local 3.9.6 ``` #### To build OpenPype: 1) Run `.\tools\create_env.sh` to create virtual environment in `.\venv` 2) Run `.\tools\fetch_thirdparty_libs.sh` to download third-party dependencies like ffmpeg and oiio. Those will be included in build. 3) Run `.\tools\build.sh` to build OpenPype executables in `.\build\` ### Linux #### Docker Easiest way to build OpenPype on Linux is using [Docker](https://www.docker.com/). Just run: ```sh sudo ./tools/docker_build.sh ``` This will by default use Debian as base image. If you need to make Centos 7 compatible build, please run: ```sh sudo ./tools/docker_build.sh centos7 ``` If all is successful, you'll find built OpenPype in `./build/` folder. Docker build can be also started from Windows machine, just use `./tools/docker_build.ps1` instead of shell script. This could be used even for building linux build (with argument `centos7` or `debian`) #### Manual build You will need [Python >= 3.9](https://www.python.org/downloads/) and [git](https://git-scm.com/downloads). You'll also need [curl](https://curl.se) on systems that doesn't have one preinstalled. To build Python related stuff, you need Python header files installed (`python3-dev` on Ubuntu for example). You'll need also other tools to build some OpenPype dependencies like [CMake](https://cmake.org/). Python 3 should be part of all modern distributions. You can use your package manager to install **git** and **cmake**.
Details for Ubuntu Install git, cmake and curl ```sh sudo apt install build-essential checkinstall sudo apt install git cmake curl ``` #### Note: In case you run in error about `xcb` when running OpenPype, you'll need also additional libraries for Qt5: ```sh sudo apt install qt5-default ``` or if you are on Ubuntu > 20.04, there is no `qt5-default` packages so you need to install its content individually: ```sh sudo apt-get install qtbase5-dev qtchooser qt5-qmake qtbase5-dev-tools ```
Details for Centos Install git, cmake and curl ```sh sudo yum install qit cmake ``` #### Note: In case you run in error about `xcb` when running OpenPype, you'll need also additional libraries for Qt5: ```sh sudo yum install qt5-qtbase-devel ```
Use pyenv to install Python version for OpenPype build You will need **bzip2**, **readline**, **sqlite3** and other libraries. For more details about Python build environments see: https://github.com/pyenv/pyenv/wiki#suggested-build-environment **For Ubuntu:** ```sh sudo apt-get update; sudo apt-get install --no-install-recommends make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev ``` **For Centos:** ```sh yum install gcc zlib-devel bzip2 bzip2-devel readline-devel sqlite sqlite-devel openssl-devel tk-devel libffi-devel ``` **install pyenv** ```sh curl https://pyenv.run | bash # you can add those to ~/.bashrc export PATH="$HOME/.pyenv/bin:$PATH" eval "$(pyenv init -)" eval "$(pyenv virtualenv-init -)" # reload shell exec $SHELL # install Python 3.9.x pyenv install -v 3.9.6 # change path to OpenPype 3 cd /path/to/openpype-3 # set local python version pyenv local 3.9.6 ```
#### To build OpenPype: 1) Run `.\tools\create_env.sh` to create virtual environment in `.\venv` 2) Run `.\tools\build.sh` to build OpenPype executables in `.\build\` Running OpenPype ---------------- OpenPype can by executed either from live sources (this repository) or from *"frozen code"* - executables that can be build using steps described above. If OpenPype is executed from live sources, it will use OpenPype version included in them. If it is executed from frozen code it will try to find latest OpenPype version installed locally on current computer and if it is not found, it will ask for its location. On that location OpenPype can be either in directories or zip files. OpenPype will try to find latest version and install it to user data directory (on Windows to `%LOCALAPPDATA%\pypeclub\openpype`, on Linux `~/.local/share/openpype` and on macOS in `~/Library/Application Support/openpype`). ### From sources OpenPype can be run directly from sources by activating virtual environment: ```sh poetry run python start.py tray ``` This will use current OpenPype version with sources. You can override this with `--use-version=x.x.x` and then OpenPype will try to find locally installed specified version (present in user data directory). ### From frozen code You need to build OpenPype first. This will produce two executables - `openpype_gui(.exe)` and `openpype_console(.exe)`. First one will act as GUI application and will not create console (useful in production environments). The second one will create console and will write output there - useful for headless application and debugging purposes. If you need OpenPype version installed, just run `./tools/create_zip(.ps1|.sh)` without arguments and it will create zip file that OpenPype can use. Building documentation ---------------------- To build API documentation, run `.\tools\make_docs(.ps1|.sh)`. It will create html documentation from current sources in `.\docs\build`. **Note that it needs existing virtual environment.** Running tests ------------- To run tests, execute `.\tools\run_tests(.ps1|.sh)`. **Note that it needs existing virtual environment.** Developer tools --------------- In case you wish to add your own tools to `.\tools` folder without git tracking, it is possible by adding it with `dev_*` suffix (example: `dev_clear_pyc(.ps1|.sh)`). ## Contributors ✨ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
Milan Kolar
Milan Kolar

💻 📖 🚇 💼 🖋 🔍 🚧 📆 👀 🧑‍🏫 💬
Jakub Ježek
Jakub Ježek

💻 📖 🚇 🖋 👀 🚧 🧑‍🏫 📆 💬
Ondřej Samohel
Ondřej Samohel

💻 📖 🚇 🖋 👀 🚧 🧑‍🏫 📆 💬
Jakub Trllo
Jakub Trllo

💻 📖 🚇 👀 🚧 💬
Petr Kalis
Petr Kalis

💻 📖 🚇 👀 🚧 💬
64qam
64qam

💻 👀 📖 🚇 📆 🚧 🖋 📓
Roy Nieterau
Roy Nieterau

💻 📖 👀 🧑‍🏫 💬
Toke Jepsen
Toke Jepsen

💻 📖 👀 🧑‍🏫 💬
Jiri Sindelar
Jiri Sindelar

💻 👀 📖 🖋 📓
Simone Barbieri
Simone Barbieri

💻 📖
karimmozilla
karimmozilla

💻
Allan I. A.
Allan I. A.

💻
murphy
murphy

💻 👀 📓 📖 📆
Wijnand Koreman
Wijnand Koreman

💻
Bo Zhou
Bo Zhou

💻
Clément Hector
Clément Hector

💻 👀
David Lai
David Lai

💻 👀
Derek
Derek

💻 📖
Gábor Marinov
Gábor Marinov

💻 📖
icyvapor
icyvapor

💻 📖
Jérôme LORRAIN
Jérôme LORRAIN

💻
David Morris-Oliveros
David Morris-Oliveros

💻
BenoitConnan
BenoitConnan

💻
Malthaldar
Malthaldar

💻
Sven Neve
Sven Neve

💻
zafrs
zafrs

💻
Félix David
Félix David

💻 📖
Alexey Bogomolov
Alexey Bogomolov

💻
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! ================================================ FILE: app_launcher.py ================================================ """Launch process that is not child process of python or OpenPype. This is written for linux distributions where process tree may affect what is when closed or blocked to be closed. """ import os import sys import subprocess import json def main(input_json_path): """Read launch arguments from json file and launch the process. Expected that json contains "args" key with string or list of strings. Arguments are converted to string using `list2cmdline`. At the end is added `&` which will cause that launched process is detached and running as "background" process. ## Notes @iLLiCiT: This should be possible to do with 'disown' or double forking but I didn't find a way how to do it properly. Disown didn't work as expected for me and double forking killed parent process which is unexpected too. """ with open(input_json_path, "r") as stream: data = json.load(stream) # Change environment variables env = data.get("env") or {} for key, value in env.items(): os.environ[key] = value # Prepare launch arguments args = data["args"] if isinstance(args, list): args = subprocess.list2cmdline(args) # Run the command as background process shell_cmd = args + " &" os.system(shell_cmd) sys.exit(0) if __name__ == "__main__": # Expect that last argument is path to a json with launch args information main(sys.argv[-1]) ================================================ FILE: conftest.py ================================================ # -*- coding: utf-8 -*- """Conftest.""" ... ================================================ FILE: docs/Makefile ================================================ # Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) ================================================ FILE: docs/README.md ================================================ API Documentation ================= This documents the way how to build and modify API documentation using Sphinx and AutoAPI. Ground for documentation should be directly in sources - in docstrings and markdowns. Sphinx and AutoAPI will crawl over them and generate RST files that are in turn used to generate HTML documentation. For docstrings we prefer "Napoleon" or "Google" style docstrings, but RST is also acceptable mainly in cases where you need to use Sphinx directives. Using only docstrings is not really viable as some documentation should be done on higher level - like overview of some modules/functionality and so on. This should be done directly in RST files and committed to repository. Configuration ------------- Configuration is done in `/docs/source/conf.py`. The most important settings are: - `autodoc_mock_imports`: add modules that can't be actually imported by Sphinx in running environment, like `nuke`, `maya`, etc. - `autoapi_ignore`: add directories that shouldn't be processed by **AutoAPI**, like vendor dirs, etc. - `html_theme_options`: you can use these options to influence how the html theme of the generated files will look. - `myst_gfm_only`: are Myst parser option for Markdown setting what flavour of Markdown should be used. How to build it --------------- You can run: ```sh cd .\docs make.bat html ``` on linux/macOS: ```sh cd ./docs make html ``` This will go over our code and generate **.rst** files in `/docs/source/autoapi` and from those it will generate full html documentation in `/docs/build/html`. During the build you may see tons of red errors that are pointing to our issues: 1) **Wrong imports** - Invalid import are usually wrong relative imports (too deep) or circular imports. 2) **Invalid docstrings** - Docstrings to be processed into documentation needs to follow some syntax - this can be checked by running `pydocstyle` that is already included with OpenPype 3) **Invalid markdown/rst files** - Markdown/RST files can be included inside RST files using `.. include::` directive. But they have to be properly formatted. Editing RST templates --------------------- Everything starts with `/docs/source/index.rst` - this file should be properly edited, Right now it just includes `readme.rst` that in turn include and parse main `README.md`. This is entrypoint to API documentation. All templates generated by AutoAPI are in `/docs/source/autoapi`. They should be eventually committed to repository and edited too. Steps for enhancing API documentation ------------------------------------- 1) Run `/docs/make.bat html` 2) Read the red errors/warnings - fix it in the code 3) Run `/docs/make.bat html` - again until there are no red lines 4) Edit RST files and add some meaningful content there Resources ========= - [ReStructuredText on Wikipedia](https://en.wikipedia.org/wiki/ReStructuredText) - [RST Quick Reference](https://docutils.sourceforge.io/docs/user/rst/quickref.html) - [Sphinx AutoAPI Documentation](https://sphinx-autoapi.readthedocs.io/en/latest/) - [Example of Google Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) - [Sphinx Directives](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html) ================================================ FILE: docs/make.bat ================================================ @ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=..\.poetry\bin\poetry run sphinx-build ) set SOURCEDIR=source set BUILDDIR=build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd ================================================ FILE: docs/source/_static/README.md ================================================ ================================================ FILE: docs/source/_templates/autoapi/index.rst ================================================ API Reference ============= This page contains auto-generated API reference documentation [#f1]_. .. toctree:: :titlesonly: {% for page in pages %} {% if page.top_level_object and page.display %} {{ page.include_path }} {% endif %} {% endfor %} .. [#f1] Created with `sphinx-autoapi `_ ================================================ FILE: docs/source/_templates/autoapi/python/attribute.rst ================================================ {% extends "python/data.rst" %} ================================================ FILE: docs/source/_templates/autoapi/python/class.rst ================================================ {% if obj.display %} .. py:{{ obj.type }}:: {{ obj.short_name }}{% if obj.args %}({{ obj.args }}){% endif %} {% for (args, return_annotation) in obj.overloads %} {{ " " * (obj.type | length) }} {{ obj.short_name }}{% if args %}({{ args }}){% endif %} {% endfor %} {% if obj.bases %} {% if "show-inheritance" in autoapi_options %} Bases: {% for base in obj.bases %}{{ base|link_objs }}{% if not loop.last %}, {% endif %}{% endfor %} {% endif %} {% if "show-inheritance-diagram" in autoapi_options and obj.bases != ["object"] %} .. autoapi-inheritance-diagram:: {{ obj.obj["full_name"] }} :parts: 1 {% if "private-members" in autoapi_options %} :private-bases: {% endif %} {% endif %} {% endif %} {% if obj.docstring %} {{ obj.docstring|indent(3) }} {% endif %} {% if "inherited-members" in autoapi_options %} {% set visible_classes = obj.classes|selectattr("display")|list %} {% else %} {% set visible_classes = obj.classes|rejectattr("inherited")|selectattr("display")|list %} {% endif %} {% for klass in visible_classes %} {{ klass.render()|indent(3) }} {% endfor %} {% if "inherited-members" in autoapi_options %} {% set visible_properties = obj.properties|selectattr("display")|list %} {% else %} {% set visible_properties = obj.properties|rejectattr("inherited")|selectattr("display")|list %} {% endif %} {% for property in visible_properties %} {{ property.render()|indent(3) }} {% endfor %} {% if "inherited-members" in autoapi_options %} {% set visible_attributes = obj.attributes|selectattr("display")|list %} {% else %} {% set visible_attributes = obj.attributes|rejectattr("inherited")|selectattr("display")|list %} {% endif %} {% for attribute in visible_attributes %} {{ attribute.render()|indent(3) }} {% endfor %} {% if "inherited-members" in autoapi_options %} {% set visible_methods = obj.methods|selectattr("display")|list %} {% else %} {% set visible_methods = obj.methods|rejectattr("inherited")|selectattr("display")|list %} {% endif %} {% for method in visible_methods %} {{ method.render()|indent(3) }} {% endfor %} {% endif %} ================================================ FILE: docs/source/_templates/autoapi/python/data.rst ================================================ {% if obj.display %} .. py:{{ obj.type }}:: {{ obj.name }} {%- if obj.annotation is not none %} :type: {%- if obj.annotation %} {{ obj.annotation }}{%- endif %} {%- endif %} {%- if obj.value is not none %} :value: {% if obj.value is string and obj.value.splitlines()|count > 1 -%} Multiline-String .. raw:: html
Show Value .. code-block:: python """{{ obj.value|indent(width=8,blank=true) }}""" .. raw:: html
{%- else -%} {%- if obj.value is string -%} {{ "%r" % obj.value|string|truncate(100) }} {%- else -%} {{ obj.value|string|truncate(100) }} {%- endif -%} {%- endif %} {%- endif %} {{ obj.docstring|indent(3) }} {% endif %} ================================================ FILE: docs/source/_templates/autoapi/python/exception.rst ================================================ {% extends "python/class.rst" %} ================================================ FILE: docs/source/_templates/autoapi/python/function.rst ================================================ {% if obj.display %} .. py:function:: {{ obj.short_name }}({{ obj.args }}){% if obj.return_annotation is not none %} -> {{ obj.return_annotation }}{% endif %} {% for (args, return_annotation) in obj.overloads %} {{ obj.short_name }}({{ args }}){% if return_annotation is not none %} -> {{ return_annotation }}{% endif %} {% endfor %} {% for property in obj.properties %} :{{ property }}: {% endfor %} {% if obj.docstring %} {{ obj.docstring|indent(3) }} {% endif %} {% endif %} ================================================ FILE: docs/source/_templates/autoapi/python/method.rst ================================================ {%- if obj.display %} .. py:method:: {{ obj.short_name }}({{ obj.args }}){% if obj.return_annotation is not none %} -> {{ obj.return_annotation }}{% endif %} {% for (args, return_annotation) in obj.overloads %} {{ obj.short_name }}({{ args }}){% if return_annotation is not none %} -> {{ return_annotation }}{% endif %} {% endfor %} {% if obj.properties %} {% for property in obj.properties %} :{{ property }}: {% endfor %} {% else %} {% endif %} {% if obj.docstring %} {{ obj.docstring|indent(3) }} {% endif %} {% endif %} ================================================ FILE: docs/source/_templates/autoapi/python/module.rst ================================================ {% if not obj.display %} :orphan: {% endif %} :py:mod:`{{ obj.name }}` =========={{ "=" * obj.name|length }} .. py:module:: {{ obj.name }} {% if obj.docstring %} .. autoapi-nested-parse:: {{ obj.docstring|indent(3) }} {% endif %} {% block subpackages %} {% set visible_subpackages = obj.subpackages|selectattr("display")|list %} {% if visible_subpackages %} Subpackages ----------- .. toctree:: :titlesonly: :maxdepth: 3 {% for subpackage in visible_subpackages %} {{ subpackage.short_name }}/index.rst {% endfor %} {% endif %} {% endblock %} {% block submodules %} {% set visible_submodules = obj.submodules|selectattr("display")|list %} {% if visible_submodules %} Submodules ---------- .. toctree:: :titlesonly: :maxdepth: 1 {% for submodule in visible_submodules %} {{ submodule.short_name }}/index.rst {% endfor %} {% endif %} {% endblock %} {% block content %} {% if obj.all is not none %} {% set visible_children = obj.children|selectattr("short_name", "in", obj.all)|list %} {% elif obj.type is equalto("package") %} {% set visible_children = obj.children|selectattr("display")|list %} {% else %} {% set visible_children = obj.children|selectattr("display")|rejectattr("imported")|list %} {% endif %} {% if visible_children %} {{ obj.type|title }} Contents {{ "-" * obj.type|length }}--------- {% set visible_classes = visible_children|selectattr("type", "equalto", "class")|list %} {% set visible_functions = visible_children|selectattr("type", "equalto", "function")|list %} {% set visible_attributes = visible_children|selectattr("type", "equalto", "data")|list %} {% if "show-module-summary" in autoapi_options and (visible_classes or visible_functions) %} {% block classes scoped %} {% if visible_classes %} Classes ~~~~~~~ .. autoapisummary:: {% for klass in visible_classes %} {{ klass.id }} {% endfor %} {% endif %} {% endblock %} {% block functions scoped %} {% if visible_functions %} Functions ~~~~~~~~~ .. autoapisummary:: {% for function in visible_functions %} {{ function.id }} {% endfor %} {% endif %} {% endblock %} {% block attributes scoped %} {% if visible_attributes %} Attributes ~~~~~~~~~~ .. autoapisummary:: {% for attribute in visible_attributes %} {{ attribute.id }} {% endfor %} {% endif %} {% endblock %} {% endif %} {% for obj_item in visible_children %} {{ obj_item.render()|indent(0) }} {% endfor %} {% endif %} {% endblock %} ================================================ FILE: docs/source/_templates/autoapi/python/package.rst ================================================ {% extends "python/module.rst" %} ================================================ FILE: docs/source/_templates/autoapi/python/property.rst ================================================ {%- if obj.display %} .. py:property:: {{ obj.short_name }} {% if obj.annotation %} :type: {{ obj.annotation }} {% endif %} {% if obj.properties %} {% for property in obj.properties %} :{{ property }}: {% endfor %} {% endif %} {% if obj.docstring %} {{ obj.docstring|indent(3) }} {% endif %} {% endif %} ================================================ FILE: docs/source/conf.py ================================================ # -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # # This file does only contain a selection of the most common options. For a # full list see the documentation: # http://www.sphinx-doc.org/en/master/config # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys import os import sys import revitron_sphinx_theme openpype_root = os.path.abspath('../..') sys.path.insert(0, openpype_root) # app = QApplication([]) """ repos = os.listdir(os.path.abspath("../../repos")) repos = [os.path.join(openpype_root, "repos", repo) for repo in repos] for repo in repos: sys.path.append(repo) """ todo_include_todos = True autodoc_mock_imports = ["maya", "pymel", "nuke", "nukestudio", "nukescripts", "hiero", "bpy", "fusion", "houdini", "hou", "unreal", "__builtin__", "resolve", "pysync", "DaVinciResolveScript"] # -- Project information ----------------------------------------------------- project = 'OpenPype' copyright = '2023 Ynput' author = 'Ynput' # The short X.Y version version = '' # The full version, including alpha/beta/rc tags release = '' # -- General configuration --------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.mathjax', 'sphinx.ext.autosummary', 'revitron_sphinx_theme', 'autoapi.extension', 'myst_parser' ] ############################## # Autoapi settings ############################## autoapi_dirs = ['../../openpype', '../../igniter'] # bypass modules with a lot of python2 content for now autoapi_ignore = [ "*vendor*", "*schemas*", "*startup/*", "*/website*", "*openpype/hooks*", "*openpype/style*", "openpype/tests*", # to many levels of relative import: "*/modules/sync_server/*" ] autoapi_keep_files = True autoapi_options = [ 'members', 'undoc-members', 'show-inheritance', 'show-module-summary' ] autoapi_add_toctree_entry = True autoapi_template_dir = '_templates/autoapi' # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = ['.rst', '.md'] # The master toctree document. master_doc = 'index' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "English" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [ "openpype.hosts.resolve.*", "openpype.tools.*" ] # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'friendly' # -- Options for autodoc ----------------------------------------------------- autodoc_default_flags = ['members'] autosummary_generate = True # -- Options for HTML output ------------------------------------------------- # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'revitron_sphinx_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = { 'collapse_navigation': True, 'sticky_navigation': True, 'navigation_depth': 4, 'includehidden': True, 'titles_only': False, 'github_url': '', } html_logo = '_static/AYON_tight_G.svg' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # The default sidebars (for documents that don't match any pattern) are # defined by theme itself. Builtin themes are using these templates by # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', # 'searchbox.html']``. # # html_sidebars = {} # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. htmlhelp_basename = 'pypedoc' # -- Options for LaTeX output ------------------------------------------------ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'openpype.tex', 'OpenPype Documentation', 'Ynput', 'manual'), ] # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'openpype', 'OpenPype Documentation', [author], 1) ] # -- Options for Texinfo output ---------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'OpenPype', 'OpenPype Documentation', author, 'OpenPype', 'Pipeline for studios', 'Miscellaneous'), ] # -- Options for Epub output ------------------------------------------------- # Bibliographic Dublin Core info. epub_title = project # The unique identifier of the text. This can be a ISBN number # or the project homepage. # # epub_identifier = '' # A unique identification for the text. # # epub_uid = '' # A list of files that should not be packed into the epub file. epub_exclude_files = ['search.html'] # -- Extension configuration ------------------------------------------------- # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { 'https://docs.python.org/3/': None } myst_gfm_only = True ================================================ FILE: docs/source/index.rst ================================================ .. openpype documentation master file, created by sphinx-quickstart on Mon May 13 17:18:23 2019. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to OpenPype's API documentation! ======================================== .. toctree:: Readme Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` ================================================ FILE: docs/source/readme.rst ================================================ =============== OpenPype Readme =============== .. include:: ../../README.md :parser: myst_parser.sphinx_ ================================================ FILE: igniter/Poppins/OFL.txt ================================================ Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL ----------------------------------------------------------- SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ----------------------------------------------------------- PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. DEFINITIONS "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. "Reserved Font Name" refers to any names specified as such after the copyright statement(s). "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). "Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. TERMINATION This license becomes null and void if any of the above conditions are not met. DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. ================================================ FILE: igniter/__init__.py ================================================ # -*- coding: utf-8 -*- """Open install dialog.""" import os import sys os.chdir(os.path.dirname(__file__)) # for override sys.path in Deadline from .bootstrap_repos import ( BootstrapRepos, OpenPypeVersion ) from .version import __version__ as version # Store OpenPypeVersion to 'sys.modules' # - this makes it available in OpenPype processes without modifying # 'sys.path' or 'PYTHONPATH' if "OpenPypeVersion" not in sys.modules: sys.modules["OpenPypeVersion"] = OpenPypeVersion def _get_qt_app(): from qtpy import QtWidgets, QtCore app = QtWidgets.QApplication.instance() if app is not None: return app for attr_name in ( "AA_EnableHighDpiScaling", "AA_UseHighDpiPixmaps", ): attr = getattr(QtCore.Qt, attr_name, None) if attr is not None: QtWidgets.QApplication.setAttribute(attr) policy = os.getenv("QT_SCALE_FACTOR_ROUNDING_POLICY") if ( hasattr(QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy") and not policy ): QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough ) return QtWidgets.QApplication(sys.argv) def open_dialog(): """Show Igniter dialog.""" if os.getenv("OPENPYPE_HEADLESS_MODE"): print("!!! Can't open dialog in headless mode. Exiting.") sys.exit(1) from .install_dialog import InstallDialog app = _get_qt_app() d = InstallDialog() d.open() app.exec_() return d.result() def open_update_window(openpype_version): """Open update window.""" if os.getenv("OPENPYPE_HEADLESS_MODE"): print("!!! Can't open dialog in headless mode. Exiting.") sys.exit(1) from .update_window import UpdateWindow app = _get_qt_app() d = UpdateWindow(version=openpype_version) d.open() app.exec_() version_path = d.get_version_path() return version_path def show_message_dialog(title, message): """Show dialog with a message and title to user.""" if os.getenv("OPENPYPE_HEADLESS_MODE"): print("!!! Can't open dialog in headless mode. Exiting.") sys.exit(1) from .message_dialog import MessageDialog app = _get_qt_app() dialog = MessageDialog(title, message) dialog.open() app.exec_() __all__ = [ "BootstrapRepos", "open_dialog", "open_update_window", "show_message_dialog", "version" ] ================================================ FILE: igniter/__main__.py ================================================ # -*- coding: utf-8 -*- """Open install dialog.""" import sys from qtpy import QtWidgets from .install_dialog import InstallDialog RESULT = 0 def get_result(res: int): """Sets result returned from dialog.""" global RESULT RESULT = res app = QtWidgets.QApplication(sys.argv) d = InstallDialog() d.finished.connect(get_result) d.open() app.exec() sys.exit(RESULT) ================================================ FILE: igniter/bootstrap_repos.py ================================================ # -*- coding: utf-8 -*- """Bootstrap OpenPype repositories.""" from __future__ import annotations import logging as log import os import re import shutil import sys import tempfile from pathlib import Path from typing import Union, Callable, List, Tuple import hashlib import platform from zipfile import ZipFile, BadZipFile from appdirs import user_data_dir from speedcopy import copyfile import semver from .user_settings import ( OpenPypeSecureRegistry, OpenPypeSettingsRegistry ) from .tools import ( get_openpype_global_settings, get_openpype_path_from_settings, get_expected_studio_version_str, get_local_openpype_path_from_settings ) LOG_INFO = 0 LOG_WARNING = 1 LOG_ERROR = 3 def sanitize_long_path(path): """Sanitize long paths (260 characters) when on Windows. Long paths are not capatible with ZipFile or reading a file, so we can shorten the path to use. Args: path (str): path to either directory or file. Returns: str: sanitized path """ if platform.system().lower() != "windows": return path path = os.path.abspath(path) if path.startswith("\\\\"): path = "\\\\?\\UNC\\" + path[2:] else: path = "\\\\?\\" + path return path def sha256sum(filename): """Calculate sha256 for content of the file. Args: filename (str): Path to file. Returns: str: hex encoded sha256 """ h = hashlib.sha256() b = bytearray(128 * 1024) mv = memoryview(b) with open(filename, 'rb', buffering=0) as f: for n in iter(lambda: f.readinto(mv), 0): h.update(mv[:n]) return h.hexdigest() class ZipFileLongPaths(ZipFile): def _extract_member(self, member, targetpath, pwd): return ZipFile._extract_member( self, member, sanitize_long_path(targetpath), pwd ) class OpenPypeVersion(semver.VersionInfo): """Class for storing information about OpenPype version. Attributes: path (str): path to OpenPype """ path = None _local_openpype_path = None # this should match any string complying with https://semver.org/ _VERSION_REGEX = re.compile(r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P[a-zA-Z\d\-.]*))?(?:\+(?P[a-zA-Z\d\-.]*))?") # noqa: E501 _installed_version = None def __init__(self, *args, **kwargs): """Create OpenPype version. .. deprecated:: 3.0.0-rc.2 `client` and `variant` are removed. Args: major (int): version when you make incompatible API changes. minor (int): version when you add functionality in a backwards-compatible manner. patch (int): version when you make backwards-compatible bug fixes. prerelease (str): an optional prerelease string build (str): an optional build string version (str): if set, it will be parsed and will override parameters like `major`, `minor` and so on. path (Path): path to version location. """ self.path = None if "version" in kwargs.keys(): if not kwargs.get("version"): raise ValueError("Invalid version specified") v = OpenPypeVersion.parse(kwargs.get("version")) kwargs["major"] = v.major kwargs["minor"] = v.minor kwargs["patch"] = v.patch kwargs["prerelease"] = v.prerelease kwargs["build"] = v.build kwargs.pop("version") if kwargs.get("path"): if isinstance(kwargs.get("path"), str): self.path = Path(kwargs.get("path")) elif isinstance(kwargs.get("path"), Path): self.path = kwargs.get("path") else: raise TypeError("Path must be str or Path") kwargs.pop("path") if "path" in kwargs.keys(): kwargs.pop("path") super().__init__(*args, **kwargs) def __repr__(self): return f"<{self.__class__.__name__}: {str(self)} - path={self.path}>" def __lt__(self, other: OpenPypeVersion): result = super().__lt__(other) # prefer path over no path if self == other and not self.path and other.path: return True if self == other and self.path and other.path and \ other.path.is_dir() and self.path.is_file(): return True if self.finalize_version() == other.finalize_version() and \ self.prerelease == other.prerelease: return True return result def get_main_version(self) -> str: """Return main version component. This returns x.x.x part of version from possibly more complex one like x.x.x-foo-bar. .. deprecated:: 3.0.0-rc.2 use `finalize_version()` instead. Returns: str: main version component """ return str(self.finalize_version()) @staticmethod def version_in_str(string: str) -> Union[None, OpenPypeVersion]: """Find OpenPype version in given string. Args: string (str): string to search. Returns: OpenPypeVersion: of detected or None. """ # strip .zip ext if present string = re.sub(r"\.zip$", "", string, flags=re.IGNORECASE) m = re.search(OpenPypeVersion._VERSION_REGEX, string) if not m: return None version = OpenPypeVersion.parse(string[m.start():m.end()]) return version def __hash__(self): return hash(self.path) if self.path else hash(str(self)) @staticmethod def is_version_in_dir( dir_item: Path, version: OpenPypeVersion) -> Tuple[bool, str]: """Test if path item is OpenPype version matching detected version. If item is directory that might (based on it's name) contain OpenPype version, check if it really does contain OpenPype and that their versions matches. Args: dir_item (Path): Directory to test. version (OpenPypeVersion): OpenPype version detected from name. Returns: Tuple: State and reason, True if it is valid OpenPype version, False otherwise. """ try: # add one 'openpype' level as inside dir there should # be many other repositories. version_str = OpenPypeVersion.get_version_string_from_directory( dir_item) # noqa: E501 version_check = OpenPypeVersion(version=version_str) except ValueError: return False, f"cannot determine version from {dir_item}" version_main = version_check.get_main_version() detected_main = version.get_main_version() if version_main != detected_main: return False, (f"dir version ({version}) and " f"its content version ({version_check}) " "doesn't match. Skipping.") return True, "Versions match" @staticmethod def is_version_in_zip( zip_item: Path, version: OpenPypeVersion) -> Tuple[bool, str]: """Test if zip path is OpenPype version matching detected version. Open zip file, look inside and parse version from OpenPype inside it. If there is none, or it is different from version specified in file name, skip it. Args: zip_item (Path): Zip file to test. version (OpenPypeVersion): Pype version detected from name. Returns: Tuple: State and reason, True if it is valid OpenPype version, False otherwise. """ # skip non-zip files if zip_item.suffix.lower() != ".zip": return False, "Not a zip" try: with ZipFile(zip_item, "r") as zip_file: with zip_file.open( "openpype/version.py") as version_file: zip_version = {} exec(version_file.read(), zip_version) try: version_check = OpenPypeVersion( version=zip_version["__version__"]) except ValueError as e: return False, str(e) version_main = version_check.get_main_version() # # noqa: E501 detected_main = version.get_main_version() # noqa: E501 if version_main != detected_main: return False, (f"zip version ({version}) " f"and its content version " f"({version_check}) " "doesn't match. Skipping.") except BadZipFile: return False, f"{zip_item} is not a zip file" except KeyError: return False, "Zip does not contain OpenPype" return True, "Versions match" @staticmethod def get_version_string_from_directory(repo_dir: Path) -> Union[str, None]: """Get version of OpenPype in given directory. Note: in frozen OpenPype installed in user data dir, this must point one level deeper as it is: `openpype-version-v3.0.0/openpype/version.py` Args: repo_dir (Path): Path to OpenPype repo. Returns: str: version string. None: if OpenPype is not found. """ # try to find version version_file = Path(repo_dir) / "openpype" / "version.py" if not version_file.exists(): return None version = {} with version_file.open("r") as fp: exec(fp.read(), version) return version['__version__'] @classmethod def get_openpype_path(cls): """Path to openpype zip directory. Path can be set through environment variable 'OPENPYPE_PATH' which is set during start of OpenPype if is not available. """ return os.getenv("OPENPYPE_PATH") @classmethod def get_local_openpype_path(cls): """Path to unzipped versions. By default it should be user appdata, but could be overridden by settings. """ if cls._local_openpype_path: return cls._local_openpype_path settings = get_openpype_global_settings(os.environ["OPENPYPE_MONGO"]) data_dir = get_local_openpype_path_from_settings(settings) if not data_dir: data_dir = Path(user_data_dir("openpype", "pypeclub")) cls._local_openpype_path = data_dir return data_dir @classmethod def openpype_path_is_set(cls): """Path to OpenPype zip directory is set.""" if cls.get_openpype_path(): return True return False @classmethod def openpype_path_is_accessible(cls): """Path to OpenPype zip directory is accessible. Exists for this machine. """ # First check if is set if not cls.openpype_path_is_set(): return False # Validate existence if Path(cls.get_openpype_path()).exists(): return True return False @classmethod def get_local_versions(cls) -> List: """Get all versions available on this machine. Returns: list: of compatible versions available on the machine. """ dir_to_search = cls.get_local_openpype_path() versions = cls.get_versions_from_directory(dir_to_search) return list(sorted(set(versions))) @classmethod def get_remote_versions(cls) -> List: """Get all versions available in OpenPype Path. Returns: list of OpenPypeVersions: Versions found in OpenPype path. """ # Return all local versions if arguments are set to None dir_to_search = None if cls.openpype_path_is_accessible(): dir_to_search = Path(cls.get_openpype_path()) else: registry = OpenPypeSettingsRegistry() try: registry_dir = Path(str(registry.get_item("openPypePath"))) if registry_dir.exists(): dir_to_search = registry_dir except ValueError: # nothing found in registry, we'll use data dir pass if not dir_to_search: return [] versions = cls.get_versions_from_directory(dir_to_search) return list(sorted(set(versions))) @staticmethod def get_versions_from_directory( openpype_dir: Path) -> List: """Get all detected OpenPype versions in directory. Args: openpype_dir (Path): Directory to scan. Returns: list of OpenPypeVersion Throws: ValueError: if invalid path is specified. """ openpype_versions = [] if not openpype_dir.exists() and not openpype_dir.is_dir(): return openpype_versions # iterate over directory in first level and find all that might # contain OpenPype. for item in openpype_dir.iterdir(): # if the item is directory with major.minor version, dive deeper if item.is_dir() and re.match(r"^\d+\.\d+$", item.name): _versions = OpenPypeVersion.get_versions_from_directory( item) if _versions: openpype_versions += _versions # if file exists, strip extension, in case of dir don't. name = item.name if item.is_dir() else item.stem result = OpenPypeVersion.version_in_str(name) if result: detected_version: OpenPypeVersion detected_version = result if item.is_dir() and not OpenPypeVersion.is_version_in_dir( item, detected_version )[0]: continue if item.is_file() and not OpenPypeVersion.is_version_in_zip( item, detected_version )[0]: continue detected_version.path = item openpype_versions.append(detected_version) return sorted(openpype_versions) @staticmethod def get_installed_version_str() -> str: """Get version of local OpenPype.""" version = {} path = Path(os.environ["OPENPYPE_ROOT"]) / "openpype" / "version.py" with open(path, "r") as fp: exec(fp.read(), version) return version["__version__"] @classmethod def get_installed_version(cls): """Get version of OpenPype inside build.""" if cls._installed_version is None: installed_version_str = cls.get_installed_version_str() if installed_version_str: cls._installed_version = OpenPypeVersion( version=installed_version_str, path=Path(os.environ["OPENPYPE_ROOT"]) ) return cls._installed_version @staticmethod def get_latest_version( local: bool = None, remote: bool = None ) -> Union[OpenPypeVersion, None]: """Get the latest available version. The version does not contain information about path and source. This is utility version to get the latest version from all found. Arguments 'local' and 'remote' define if local and remote repository versions are used. All versions are used if both are not set (or set to 'None'). If only one of them is set to 'True' the other is disabled. It is possible to set both to 'True' (same as both set to None) and to 'False' in that case only build version can be used. Args: local (bool, optional): List local versions if True. remote (bool, optional): List remote versions if True. Returns: Latest OpenPypeVersion or None """ if local is None and remote is None: local = True remote = True elif local is None and not remote: local = True elif remote is None and not local: remote = True installed_version = OpenPypeVersion.get_installed_version() local_versions = OpenPypeVersion.get_local_versions() if local else [] remote_versions = OpenPypeVersion.get_remote_versions() if remote else [] # noqa: E501 all_versions = local_versions + remote_versions + [installed_version] all_versions.sort() return all_versions[-1] @classmethod def get_expected_studio_version(cls, staging=False, global_settings=None): """Expected OpenPype version that should be used at the moment. If version is not defined in settings the latest found version is used. Using precached global settings is needed for usage inside OpenPype. Args: staging (bool): Staging version or production version. global_settings (dict): Optional precached global settings. Returns: OpenPypeVersion: Version that should be used. """ result = get_expected_studio_version_str(staging, global_settings) if not result: return None return OpenPypeVersion(version=result) def is_compatible(self, version: OpenPypeVersion): """Test build compatibility. This will simply compare major and minor versions (ignoring patch and the rest). Args: version (OpenPypeVersion): Version to check compatibility with. Returns: bool: if the version is compatible """ return self.major == version.major and self.minor == version.minor class BootstrapRepos: """Class for bootstrapping local OpenPype installation. Attributes: data_dir (Path): local OpenPype installation directory. registry (OpenPypeSettingsRegistry): OpenPype registry object. zip_filter (list): List of files to exclude from zip openpype_filter (list): list of top level directories to include in zip in OpenPype repository. """ def __init__(self, progress_callback: Callable = None, message=None): """Constructor. Args: progress_callback (callable): Optional callback method to report progress. message (QtCore.Signal, optional): Signal to report messages back. """ # vendor and app used to construct user data dir self._message = message self._log = log.getLogger(str(__class__)) self.set_data_dir(None) self.secure_registry = OpenPypeSecureRegistry("mongodb") self.registry = OpenPypeSettingsRegistry() self.zip_filter = [".pyc", "__pycache__"] self.openpype_filter = [ "openpype", "LICENSE" ] # dummy progress reporter def empty_progress(x: int): """Progress callback dummy.""" return x if not progress_callback: progress_callback = empty_progress self._progress_callback = progress_callback def set_data_dir(self, data_dir): if not data_dir: self.data_dir = Path(user_data_dir("openpype", "pypeclub")) else: self._print(f"overriding local folder: {data_dir}") self.data_dir = data_dir @staticmethod def get_version_path_from_list( version: str, version_list: list) -> Union[Path, None]: """Get path for specific version in list of OpenPype versions. Args: version (str): Version string to look for (1.2.4-nightly.1+test) version_list (list of OpenPypeVersion): list of version to search. Returns: Path: Path to given version. """ for v in version_list: if str(v) == version: return v.path return None @staticmethod def get_version(repo_dir: Path) -> Union[str, None]: """Get version of OpenPype in given directory. Note: in frozen OpenPype installed in user data dir, this must point one level deeper as it is: `openpype-version-v3.0.0/openpype/version.py` Args: repo_dir (Path): Path to OpenPype repo. Returns: str: version string. None: if OpenPype is not found. """ # try to find version version_file = Path(repo_dir) / "openpype" / "version.py" if not version_file.exists(): return None version = {} with version_file.open("r") as fp: exec(fp.read(), version) return version['__version__'] def create_version_from_live_code( self, repo_dir: Path = None) -> Union[OpenPypeVersion, None]: """Copy zip created from OpenPype repositories to user data dir. This detects OpenPype version either in local "live" OpenPype repository or in user provided path. Then it will zip it in temporary directory, and finally it will move it to destination which is user data directory. Existing files will be replaced. Args: repo_dir (Path, optional): Path to OpenPype repository. Returns: Path: path of installed repository file. """ # if repo dir is not set, we detect local "live" OpenPype repository # version and use it as a source. Otherwise, repo_dir is user # entered location. if repo_dir: version = self.get_version(repo_dir) else: installed_version = OpenPypeVersion.get_installed_version() version = str(installed_version) repo_dir = installed_version.path if not version: self._print("OpenPype not found.", LOG_ERROR) return # create destination directory destination = self.data_dir / f"{installed_version.major}.{installed_version.minor}" # noqa if not destination.exists(): destination.mkdir(parents=True) # create zip inside temporary directory. with tempfile.TemporaryDirectory() as temp_dir: temp_zip = \ Path(temp_dir) / f"openpype-v{version}.zip" self._print(f"creating zip: {temp_zip}") self._create_openpype_zip(temp_zip, repo_dir) if not os.path.exists(temp_zip): self._print("make archive failed.", LOG_ERROR) return None destination = self._move_zip_to_data_dir(temp_zip) return OpenPypeVersion(version=version, path=Path(destination)) def _move_zip_to_data_dir(self, zip_file) -> Union[None, Path]: """Move zip with OpenPype version to user data directory. Args: zip_file (Path): Path to zip file. Returns: None if move fails. Path to moved zip on success. """ version = OpenPypeVersion.version_in_str(zip_file.name) destination_dir = self.data_dir / f"{version.major}.{version.minor}" if not destination_dir.exists(): destination_dir.mkdir(parents=True) destination = destination_dir / zip_file.name if destination.exists(): self._print( f"Destination file {destination} exists, removing.", LOG_WARNING) try: destination.unlink() except Exception as e: self._print(str(e), LOG_ERROR, exc_info=True) return None if not destination_dir.exists(): destination_dir.mkdir(parents=True) elif not destination_dir.is_dir(): self._print( "Destination exists but is not directory.", LOG_ERROR) return None try: shutil.move(zip_file.as_posix(), destination_dir.as_posix()) except shutil.Error as e: self._print(str(e), LOG_ERROR, exc_info=True) return None return destination def _filter_dir(self, path: Path, path_filter: List) -> List[Path]: """Recursively crawl over path and filter.""" result = [] for item in path.iterdir(): if item.name in path_filter: continue if item.name.startswith('.'): continue if item.is_dir(): result.extend(self._filter_dir(item, path_filter)) else: result.append(item) return result def create_version_from_frozen_code(self) -> Union[None, OpenPypeVersion]: """Create OpenPype version from *frozen* code distributed by installer. This should be real edge case for those wanting to try out OpenPype without setting up whole infrastructure but is strongly discouraged in studio setup as this use local version independent of others that can be out of date. Returns: :class:`OpenPypeVersion` zip file to be installed. """ frozen_root = Path(sys.executable).parent openpype_list = [] for f in self.openpype_filter: if (frozen_root / f).is_dir(): openpype_list += self._filter_dir( frozen_root / f, self.zip_filter) else: openpype_list.append(frozen_root / f) version = self.get_version(frozen_root) # create zip inside temporary directory. with tempfile.TemporaryDirectory() as temp_dir: temp_zip = \ Path(temp_dir) / f"openpype-v{version}.zip" self._print(f"creating zip: {temp_zip}") with ZipFile(temp_zip, "w") as zip_file: progress = 0 openpype_inc = 98.0 / float(len(openpype_list)) file: Path for file in openpype_list: progress += openpype_inc self._progress_callback(int(progress)) arc_name = file.relative_to(frozen_root.parent) # we need to replace first part of path which starts with # something like `exe.win/linux....` with `openpype` as # this is expected by OpenPype in zip archive. arc_name = Path().joinpath(*arc_name.parts[1:]) zip_file.write(file, arc_name) destination = self._move_zip_to_data_dir(temp_zip) return OpenPypeVersion(version=version, path=destination) def _create_openpype_zip(self, zip_path: Path, openpype_path: Path) -> None: """Pack repositories and OpenPype into zip. We are using :mod:`ZipFile` instead :meth:`shutil.make_archive` because we need to decide what file and directories to include in zip and what not. They are determined by :attr:`zip_filter` on file level and :attr:`openpype_filter` on top level directory in OpenPype repository. Args: zip_path (Path): Path to zip file. openpype_path (Path): Path to OpenPype sources. """ # get filtered list of file in Pype repository # openpype_list = self._filter_dir(openpype_path, self.zip_filter) openpype_list = [] for f in self.openpype_filter: if (openpype_path / f).is_dir(): openpype_list += self._filter_dir( openpype_path / f, self.zip_filter) else: openpype_list.append(openpype_path / f) openpype_files = len(openpype_list) openpype_inc = 98.0 / float(openpype_files) with ZipFile(zip_path, "w") as zip_file: progress = 0 openpype_root = openpype_path.resolve() # generate list of filtered paths dir_filter = [openpype_root / f for f in self.openpype_filter] checksums = [] file: Path for file in openpype_list: progress += openpype_inc self._progress_callback(int(progress)) # if file resides in filtered path, skip it is_inside = None df: Path for df in dir_filter: try: is_inside = file.resolve().relative_to(df) except ValueError: pass if not is_inside: continue processed_path = file self._print(f"- processing {processed_path}") checksums.append( ( sha256sum(sanitize_long_path(file.as_posix())), file.resolve().relative_to(openpype_root) ) ) zip_file.write( file, file.resolve().relative_to(openpype_root)) checksums_str = "" for c in checksums: file_str = c[1] if platform.system().lower() == "windows": file_str = c[1].as_posix().replace("\\", "/") checksums_str += "{}:{}\n".format(c[0], file_str) zip_file.writestr("checksums", checksums_str) # test if zip is ok zip_file.testzip() self._progress_callback(100) def validate_openpype_version(self, path: Path) -> tuple: """Validate version directory or zip file. This will load `checksums` file if present, calculate checksums of existing files in given path and compare. It will also compare lists of files together for missing files. Args: path (Path): Path to OpenPype version to validate. Returns: tuple(bool, str): with version validity as first item and string with reason as second. """ if os.getenv("OPENPYPE_DONT_VALIDATE_VERSION"): return True, "Disabled validation" if not path.exists(): return False, "Path doesn't exist" if path.is_file(): return self._validate_zip(path) return self._validate_dir(path) @staticmethod def _validate_zip(path: Path) -> tuple: """Validate content of zip file.""" with ZipFile(path, "r") as zip_file: # read checksums try: checksums_data = str(zip_file.read("checksums")) except IOError: # FIXME: This should be set to False sometimes in the future return True, "Cannot read checksums for archive." # split it to the list of tuples checksums = [ tuple(line.split(":")) for line in checksums_data.split("\n") if line ] # get list of files in zip minus `checksums` file itself # and turn in to set to compare against list of files # from checksum file. If difference exists, something is # wrong files_in_zip = set(zip_file.namelist()) files_in_zip.remove("checksums") files_in_checksum = {file[1] for file in checksums} diff = files_in_zip.difference(files_in_checksum) if diff: return False, f"Missing files {diff}" # calculate and compare checksums in the zip file for file_checksum, file_name in checksums: if platform.system().lower() == "windows": file_name = file_name.replace("/", "\\") h = hashlib.sha256() try: h.update(zip_file.read(file_name)) except FileNotFoundError: return False, f"Missing file [ {file_name} ]" if h.hexdigest() != file_checksum: return False, f"Invalid checksum on {file_name}" return True, "All ok" @staticmethod def _validate_dir(path: Path) -> tuple: """Validate checksums in a given path. Args: path (Path): path to folder to validate. Returns: tuple(bool, str): returns status and reason as a bool and str in a tuple. """ checksums_file = Path(path / "checksums") if not checksums_file.exists(): # FIXME: This should be set to False sometimes in the future return True, "Cannot read checksums for archive." checksums_data = checksums_file.read_text() checksums = [ tuple(line.split(":")) for line in checksums_data.split("\n") if line ] # compare file list against list of files from checksum file. # If difference exists, something is wrong and we invalidate directly files_in_dir = set( file.relative_to(path).as_posix() for file in path.iterdir() if file.is_file() ) files_in_dir.remove("checksums") files_in_checksum = {file[1] for file in checksums} diff = files_in_dir.difference(files_in_checksum) if diff: return False, f"Missing files {diff}" # calculate and compare checksums for file_checksum, file_name in checksums: if platform.system().lower() == "windows": file_name = file_name.replace("/", "\\") try: current = sha256sum( sanitize_long_path((path / file_name).as_posix()) ) except FileNotFoundError: return False, f"Missing file [ {file_name} ]" if file_checksum != current: return False, f"Invalid checksum on {file_name}" return True, "All ok" @staticmethod def add_paths_from_archive(archive: Path) -> None: """Add first-level directory and 'repos' as paths to :mod:`sys.path`. This will enable Python to import OpenPype and modules in `repos` submodule directory in zip file. Adding to both `sys.path` and `PYTHONPATH`, skipping duplicates. Args: archive (Path): path to archive. .. deprecated:: 3.0 we don't use zip archives directly """ if not archive.is_file() and not archive.exists(): raise ValueError("Archive is not file.") archive_path = str(archive) sys.path.insert(0, archive_path) pythonpath = os.getenv("PYTHONPATH", "") python_paths = pythonpath.split(os.pathsep) python_paths.insert(0, archive_path) os.environ["PYTHONPATH"] = os.pathsep.join(python_paths) @staticmethod def add_paths_from_directory(directory: Path) -> None: """Add repos first level directories as paths to :mod:`sys.path`. This works the same as :meth:`add_paths_from_archive` but in specified directory. Adding to both `sys.path` and `PYTHONPATH`, skipping duplicates. Args: directory (Path): path to directory. """ sys.path.insert(0, directory.as_posix()) @staticmethod def find_openpype_version( version: Union[str, OpenPypeVersion] ) -> Union[OpenPypeVersion, None]: """Find location of specified OpenPype version. Args: version (Union[str, OpenPypeVersion): Version to find. Returns: requested OpenPypeVersion. """ installed_version = OpenPypeVersion.get_installed_version() if isinstance(version, str): version = OpenPypeVersion(version=version) if installed_version == version: return installed_version local_versions = OpenPypeVersion.get_local_versions() zip_version = None for local_version in local_versions: if local_version == version: if local_version.path.suffix.lower() == ".zip": zip_version = local_version else: return local_version if zip_version is not None: return zip_version remote_versions = OpenPypeVersion.get_remote_versions() return next( ( remote_version for remote_version in remote_versions if remote_version == version ), None) @staticmethod def find_latest_openpype_version() -> Union[OpenPypeVersion, None]: """Find the latest available OpenPype version in all location. Returns: Latest OpenPype version on None if nothing was found. """ installed_version = OpenPypeVersion.get_installed_version() local_versions = OpenPypeVersion.get_local_versions() remote_versions = OpenPypeVersion.get_remote_versions() all_versions = local_versions + remote_versions + [installed_version] if not all_versions: return None all_versions.sort() latest_version = all_versions[-1] if latest_version == installed_version: return latest_version if not latest_version.path.is_dir(): for version in local_versions: if version == latest_version and version.path.is_dir(): latest_version = version break return latest_version def find_openpype( self, openpype_path: Union[Path, str] = None, include_zips: bool = False ) -> Union[List[OpenPypeVersion], None]: """Get ordered dict of detected OpenPype version. Resolution order for OpenPype is following: 1) First we test for ``OPENPYPE_PATH`` environment variable 2) We try to find ``openPypePath`` in registry setting 3) We use user data directory Args: openpype_path (Path or str, optional): Try to find OpenPype on the given path or url. include_zips (bool, optional): If set True it will try to find OpenPype in zip files in given directory. Returns: dict of Path: Dictionary of detected OpenPype version. Key is version, value is path to zip file. None: if OpenPype is not found. Todo: implement git/url support as OpenPype location, so it would be possible to enter git url, OpenPype would check it out and if it is ok install it as normal version. """ if openpype_path and not isinstance(openpype_path, Path): raise NotImplementedError( ("Finding OpenPype in non-filesystem locations is" " not implemented yet.")) # if checks bellow for OPENPYPE_PATH and registry fails, use data_dir # DEPRECATED: lookup in root of this folder is deprecated in favour # of major.minor sub-folders. dirs_to_search = [self.data_dir] if openpype_path: dirs_to_search = [openpype_path] elif os.getenv("OPENPYPE_PATH") \ and Path(os.getenv("OPENPYPE_PATH")).exists(): # first try OPENPYPE_PATH and if that is not available, # try registry. dirs_to_search = [Path(os.getenv("OPENPYPE_PATH"))] else: try: registry_dir = Path( str(self.registry.get_item("openPypePath"))) if registry_dir.exists(): dirs_to_search = [registry_dir] except ValueError: # nothing found in registry, we'll use data dir pass openpype_versions = [] for dir_to_search in dirs_to_search: try: openpype_versions += self.get_openpype_versions( dir_to_search) except ValueError: # location is invalid, skip it pass if not include_zips: openpype_versions = [ v for v in openpype_versions if v.path.suffix != ".zip" ] # remove duplicates openpype_versions = sorted(list(set(openpype_versions))) return openpype_versions def process_entered_location(self, location: str) -> Union[Path, None]: """Process user entered location string. It decides if location string is mongodb url or path. If it is mongodb url, it will connect and load ``OPENPYPE_PATH`` from there and use it as path to OpenPype. In it is _not_ mongodb url, it is assumed we have a path, this is tested and zip file is produced and installed using :meth:`create_version_from_live_code`. Args: location (str): User entered location. Returns: Path: to OpenPype zip produced from this location. None: Zipping failed. """ openpype_path = None # try to get OpenPype path from mongo. if location.startswith("mongodb"): global_settings = get_openpype_global_settings(location) openpype_path = get_openpype_path_from_settings(global_settings) if not openpype_path: self._print("cannot find OPENPYPE_PATH in settings.") return None # if not successful, consider location to be fs path. if not openpype_path: openpype_path = Path(location) # test if this path does exist. if not openpype_path.exists(): self._print(f"{openpype_path} doesn't exists.") return None # test if entered path isn't user data dir if self.data_dir == openpype_path: self._print("cannot point to user data dir", LOG_ERROR) return None # find openpype zip files in location. There can be # either "live" OpenPype repository, or multiple zip files or even # multiple OpenPype version directories. This process looks into zip # files and directories and tries to parse `version.py` file. versions = self.find_openpype(openpype_path, include_zips=True) if versions: self._print(f"found OpenPype in [ {openpype_path} ]") self._print(f"latest version found is [ {versions[-1]} ]") return self.install_version(versions[-1]) # if we got here, it means that location is "live" # OpenPype repository. We'll create zip from it and move it to user # data dir. live_openpype = self.create_version_from_live_code(openpype_path) if not live_openpype.path.exists(): self._print(f"installing zip {live_openpype} failed.", LOG_ERROR) return None # install it return self.install_version(live_openpype) def _print(self, message: str, level: int = LOG_INFO, exc_info: bool = False): """Helper function passing logs to UI and to logger. Supporting 3 levels of logs defined with `LOG_INFO`, `LOG_WARNING` and `LOG_ERROR` constants. Args: message (str): Message to log. level (int, optional): Log level to use. exc_info (bool, optional): Exception info object to pass to logger. """ if self._message: self._message.emit(message, level == LOG_ERROR) if level == LOG_WARNING: self._log.warning(message, exc_info=exc_info) return if level == LOG_ERROR: self._log.error(message, exc_info=exc_info) return self._log.info(message, exc_info=exc_info) def extract_openpype(self, version: OpenPypeVersion) -> Union[Path, None]: """Extract zipped OpenPype version to user data directory. Args: version (OpenPypeVersion): Version of OpenPype. Returns: Path: path to extracted version. None: if something failed. """ if not version.path: raise ValueError( f"version {version} is not associated with any file") destination = self.data_dir / f"{version.major}.{version.minor}" / version.path.stem # noqa if destination.exists() and destination.is_dir(): try: shutil.rmtree(destination) except OSError as e: msg = f"!!! Cannot remove already existing {destination}" self._print(msg, LOG_ERROR, exc_info=True) raise e destination.mkdir(parents=True) # extract zip there self._print("Extracting zip to destination ...") with ZipFileLongPaths(version.path, "r") as zip_ref: zip_ref.extractall(destination) self._print(f"Installed as {version.path.stem}") return destination def is_inside_user_data(self, path: Path) -> bool: """Test if version is located in user data dir. Args: path (Path) Path to test. Returns: True if path is inside user data dir. """ is_inside = False try: is_inside = path.resolve().relative_to( self.data_dir) except ValueError: # if relative path cannot be calculated, OpenPype version is not # inside user data dir pass return is_inside def install_version(self, openpype_version: OpenPypeVersion, force: bool = False) -> Path: """Install OpenPype version to user data directory. Args: openpype_version (OpenPypeVersion): OpenPype version to install. force (bool, optional): Force overwrite existing version. Returns: Path: Path to installed OpenPype. Raises: OpenPypeVersionExists: If not forced and this version already exist in user data directory. OpenPypeVersionInvalid: If version to install is invalid. OpenPypeVersionIOError: If copying or zipping fail. """ if self.is_inside_user_data(openpype_version.path) and not openpype_version.path.is_file(): # noqa raise OpenPypeVersionExists( "OpenPype already inside user data dir") # determine destination directory name # for zip file strip suffix, in case of dir use whole dir name if openpype_version.path.is_dir(): dir_name = openpype_version.path.name else: dir_name = openpype_version.path.stem destination = self.data_dir / f"{openpype_version.major}.{openpype_version.minor}" / dir_name # noqa # test if destination directory already exist, if so lets delete it. if destination.exists() and force: self._print("removing existing directory") try: shutil.rmtree(destination) except OSError as e: self._print( f"cannot remove already existing {destination}", LOG_ERROR, exc_info=True) raise OpenPypeVersionIOError( f"cannot remove existing {destination}") from e elif destination.exists() and not force: self._print("destination directory already exists") raise OpenPypeVersionExists(f"{destination} already exist.") else: # create destination parent directories even if they don't exist. destination.mkdir(parents=True) remove_source_file = False # version is directory if openpype_version.path.is_dir(): # create zip inside temporary directory. self._print("Creating zip from directory ...") self._progress_callback(0) with tempfile.TemporaryDirectory() as temp_dir: temp_zip = \ Path(temp_dir) / f"openpype-v{openpype_version}.zip" self._print(f"creating zip: {temp_zip}") self._create_openpype_zip(temp_zip, openpype_version.path) if not os.path.exists(temp_zip): self._print("make archive failed.", LOG_ERROR) raise OpenPypeVersionIOError("Zip creation failed.") # set zip as version source openpype_version.path = temp_zip if self.is_inside_user_data(openpype_version.path): raise OpenPypeVersionInvalid( "Version is in user data dir.") openpype_version.path = self._copy_zip( openpype_version.path, destination) elif openpype_version.path.is_file(): # check if file is zip (by extension) if openpype_version.path.suffix.lower() != ".zip": raise OpenPypeVersionInvalid("Invalid file format") if not self.is_inside_user_data(openpype_version.path): self._progress_callback(35) openpype_version.path = self._copy_zip( openpype_version.path, destination) # Mark zip to be deleted when done remove_source_file = True # extract zip there self._print("extracting zip to destination ...") with ZipFileLongPaths(openpype_version.path, "r") as zip_ref: self._progress_callback(75) zip_ref.extractall(destination) self._progress_callback(100) # Remove zip file copied to local app data if remove_source_file: os.remove(openpype_version.path) return destination def _copy_zip(self, source: Path, destination: Path) -> Path: try: # copy file to destination self._print("Copying zip to destination ...") _destination_zip = destination.parent / source.name # noqa: E501 copyfile( source.as_posix(), _destination_zip.as_posix()) except OSError as e: self._print( "cannot copy version to user data directory", LOG_ERROR, exc_info=True) raise OpenPypeVersionIOError(( f"can't copy version {source.as_posix()} " f"to destination {destination.parent.as_posix()}")) from e return _destination_zip def _is_openpype_in_dir(self, dir_item: Path, detected_version: OpenPypeVersion) -> bool: """Test if path item is OpenPype version matching detected version. If item is directory that might (based on it's name) contain OpenPype version, check if it really does contain OpenPype and that their versions matches. Args: dir_item (Path): Directory to test. detected_version (OpenPypeVersion): OpenPype version detected from name. Returns: True if it is valid OpenPype version, False otherwise. """ try: # add one 'openpype' level as inside dir there should # be many other repositories. version_str = BootstrapRepos.get_version(dir_item) version_check = OpenPypeVersion(version=version_str) except ValueError: self._print( f"cannot determine version from {dir_item}", True) return False version_main = version_check.get_main_version() detected_main = detected_version.get_main_version() if version_main != detected_main: self._print( (f"dir version ({detected_version}) and " f"its content version ({version_check}) " "doesn't match. Skipping.")) return False return True def _is_openpype_in_zip(self, zip_item: Path, detected_version: OpenPypeVersion) -> bool: """Test if zip path is OpenPype version matching detected version. Open zip file, look inside and parse version from OpenPype inside it. If there is none, or it is different from version specified in file name, skip it. Args: zip_item (Path): Zip file to test. detected_version (OpenPypeVersion): Pype version detected from name. Returns: True if it is valid OpenPype version, False otherwise. """ # skip non-zip files if zip_item.suffix.lower() != ".zip": return False try: with ZipFile(zip_item, "r") as zip_file: with zip_file.open( "openpype/version.py") as version_file: zip_version = {} exec(version_file.read(), zip_version) try: version_check = OpenPypeVersion( version=zip_version["__version__"]) except ValueError as e: self._print(str(e), True) return False version_main = version_check.get_main_version() # noqa: E501 detected_main = detected_version.get_main_version() # noqa: E501 if version_main != detected_main: self._print( (f"zip version ({detected_version}) " f"and its content version " f"({version_check}) " "doesn't match. Skipping."), True) return False except BadZipFile: self._print(f"{zip_item} is not a zip file", True) return False except KeyError: self._print("Zip does not contain OpenPype", True) return False return True def get_openpype_versions(self, openpype_dir: Path) -> list: """Get all detected OpenPype versions in directory. Args: openpype_dir (Path): Directory to scan. Returns: list of OpenPypeVersion Throws: ValueError: if invalid path is specified. """ if not openpype_dir.exists() and not openpype_dir.is_dir(): raise ValueError(f"specified directory {openpype_dir} is invalid") openpype_versions = [] # iterate over directory in first level and find all that might # contain OpenPype. for item in openpype_dir.iterdir(): # if the item is directory with major.minor version, dive deeper if item.is_dir() and re.match(r"^\d+\.\d+$", item.name): _versions = self.get_openpype_versions(item) if _versions: openpype_versions += _versions # if it is file, strip extension, in case of dir don't. name = item.name if item.is_dir() else item.stem result = OpenPypeVersion.version_in_str(name) if result: detected_version: OpenPypeVersion detected_version = result if item.is_dir() and not self._is_openpype_in_dir( item, detected_version ): continue if item.is_file() and not self._is_openpype_in_zip( item, detected_version ): continue detected_version.path = item openpype_versions.append(detected_version) return sorted(openpype_versions) class OpenPypeVersionExists(Exception): """Exception for handling existing OpenPype version.""" pass class OpenPypeVersionInvalid(Exception): """Exception for handling invalid OpenPype version.""" pass class OpenPypeVersionIOError(Exception): """Exception for handling IO errors in OpenPype version.""" pass ================================================ FILE: igniter/install_dialog.py ================================================ # -*- coding: utf-8 -*- """Show dialog for choosing central pype repository.""" import os import sys import re import collections from qtpy import QtCore, QtGui, QtWidgets from .install_thread import InstallThread from .tools import ( validate_mongo_connection, get_openpype_icon_path ) from .nice_progress_bar import NiceProgressBar from .user_settings import OpenPypeSecureRegistry from .tools import load_stylesheet from .version import __version__ class ButtonWithOptions(QtWidgets.QFrame): option_clicked = QtCore.Signal(str) def __init__(self, commands, parent=None): super(ButtonWithOptions, self).__init__(parent) self.setObjectName("ButtonWithOptions") options_btn = QtWidgets.QToolButton(self) options_btn.setArrowType(QtCore.Qt.DownArrow) options_btn.setIconSize(QtCore.QSize(12, 12)) default = None default_label = None options_menu = QtWidgets.QMenu(self) for option, option_label in commands.items(): if default is None: default = option default_label = option_label continue action = QtWidgets.QAction(option_label, options_menu) action.setData(option) options_menu.addAction(action) main_btn = QtWidgets.QPushButton(default_label, self) main_btn.setFlat(True) main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(1) main_layout.addWidget(main_btn, 1, QtCore.Qt.AlignVCenter) main_layout.addWidget(options_btn, 0, QtCore.Qt.AlignVCenter) main_btn.clicked.connect(self._on_main_button) options_btn.clicked.connect(self._on_options_click) options_menu.triggered.connect(self._on_trigger) self.main_btn = main_btn self.options_btn = options_btn self.options_menu = options_menu options_btn.setEnabled(not options_menu.isEmpty()) self._default_value = default def resizeEvent(self, event): super(ButtonWithOptions, self).resizeEvent(event) self.options_btn.setFixedHeight(self.main_btn.height()) def _on_options_click(self): pos = self.main_btn.rect().bottomLeft() point = self.main_btn.mapToGlobal(pos) self.options_menu.popup(point) def _on_trigger(self, action): self.option_clicked.emit(action.data()) def _on_main_button(self): self.option_clicked.emit(self._default_value) class ConsoleWidget(QtWidgets.QWidget): def __init__(self, parent=None): super(ConsoleWidget, self).__init__(parent) # style for normal and error console text default_console_style = QtGui.QTextCharFormat() error_console_style = QtGui.QTextCharFormat() default_console_style.setForeground( QtGui.QColor.fromRgb(72, 200, 150) ) error_console_style.setForeground( QtGui.QColor.fromRgb(184, 54, 19) ) label = QtWidgets.QLabel("Console:", self) console_output = QtWidgets.QPlainTextEdit(self) console_output.setMinimumSize(QtCore.QSize(300, 200)) console_output.setReadOnly(True) console_output.setCurrentCharFormat(default_console_style) console_output.setObjectName("Console") main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(label, 0) main_layout.addWidget(console_output, 1) self.default_console_style = default_console_style self.error_console_style = error_console_style self.label = label self.console_output = console_output self.hide_console() def hide_console(self): self.label.setVisible(False) self.console_output.setVisible(False) self.updateGeometry() def show_console(self): self.label.setVisible(True) self.console_output.setVisible(True) self.updateGeometry() def update_console(self, msg: str, error: bool = False) -> None: if not error: self.console_output.setCurrentCharFormat( self.default_console_style ) else: self.console_output.setCurrentCharFormat( self.error_console_style ) self.console_output.appendPlainText(msg) class MongoUrlInput(QtWidgets.QLineEdit): """Widget to input mongodb URL.""" def set_valid(self): """Set valid state on mongo url input.""" self.setProperty("state", "valid") self.style().polish(self) def remove_state(self): """Set invalid state on mongo url input.""" self.setProperty("state", "") self.style().polish(self) def set_invalid(self): """Set invalid state on mongo url input.""" self.setProperty("state", "invalid") self.style().polish(self) class InstallDialog(QtWidgets.QDialog): """Main Igniter dialog window.""" mongo_url_regex = re.compile(r"^(mongodb|mongodb\+srv)://.*?") _width = 500 _height = 200 commands = collections.OrderedDict([ ("run", "Start"), ("run_from_code", "Run from code") ]) def __init__(self, parent=None): super(InstallDialog, self).__init__(parent) self.setWindowTitle( f"OpenPype Igniter {__version__}" ) self.setWindowFlags( QtCore.Qt.WindowCloseButtonHint | QtCore.Qt.WindowMinimizeButtonHint ) current_dir = os.path.dirname(os.path.abspath(__file__)) roboto_font_path = os.path.join(current_dir, "RobotoMono-Regular.ttf") poppins_font_path = os.path.join(current_dir, "Poppins") # Install roboto font QtGui.QFontDatabase.addApplicationFont(roboto_font_path) for filename in os.listdir(poppins_font_path): if os.path.splitext(filename)[1] == ".ttf": QtGui.QFontDatabase.addApplicationFont(filename) # Load logo icon_path = get_openpype_icon_path() pixmap_openpype_logo = QtGui.QPixmap(icon_path) # Set logo as icon of window self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo)) secure_registry = OpenPypeSecureRegistry("mongodb") mongo_url = "" try: mongo_url = ( os.getenv("OPENPYPE_MONGO", "") or secure_registry.get_item("openPypeMongo") ) except ValueError: pass self.mongo_url = mongo_url self._pixmap_openpype_logo = pixmap_openpype_logo self._secure_registry = secure_registry self._controls_disabled = False self._install_thread = None self.resize(QtCore.QSize(self._width, self._height)) self._init_ui() # Set stylesheet self.setStyleSheet(load_stylesheet()) # Trigger Mongo URL validation self._mongo_input.setText(self.mongo_url) def _init_ui(self): # basic visual style - dark background, light text # Main info # -------------------------------------------------------------------- main_label = QtWidgets.QLabel("Welcome to OpenPype", self) main_label.setWordWrap(True) main_label.setObjectName("MainLabel") # Mongo box | OK button # -------------------------------------------------------------------- mongo_input = MongoUrlInput(self) mongo_input.setPlaceholderText( "Enter your database Address. Example: mongodb://192.168.1.10:2707" ) mongo_messages_widget = QtWidgets.QWidget(self) mongo_connection_msg = QtWidgets.QLabel(mongo_messages_widget) mongo_connection_msg.setVisible(True) mongo_connection_msg.setTextInteractionFlags( QtCore.Qt.TextSelectableByMouse ) mongo_messages_layout = QtWidgets.QVBoxLayout(mongo_messages_widget) mongo_messages_layout.setContentsMargins(0, 0, 0, 0) mongo_messages_layout.addWidget(mongo_connection_msg) # Progress bar # -------------------------------------------------------------------- progress_bar = NiceProgressBar(self) progress_bar.setAlignment(QtCore.Qt.AlignCenter) progress_bar.setTextVisible(False) # Console # -------------------------------------------------------------------- console_widget = ConsoleWidget(self) # Bottom button bar # -------------------------------------------------------------------- bottom_widget = QtWidgets.QWidget(self) btns_widget = QtWidgets.QWidget(bottom_widget) openpype_logo_label = QtWidgets.QLabel("openpype logo", bottom_widget) openpype_logo_label.setPixmap(self._pixmap_openpype_logo) run_button = ButtonWithOptions( self.commands, btns_widget ) run_button.setMinimumSize(64, 24) run_button.setToolTip("Run OpenPype") # install button - - - - - - - - - - - - - - - - - - - - - - - - - - - exit_button = QtWidgets.QPushButton("Exit", btns_widget) exit_button.setObjectName("ExitBtn") exit_button.setFlat(True) exit_button.setMinimumSize(64, 24) exit_button.setToolTip("Exit") btns_layout = QtWidgets.QHBoxLayout(btns_widget) btns_layout.setContentsMargins(0, 0, 0, 0) btns_layout.addWidget(run_button, 0) btns_layout.addWidget(exit_button, 0) bottom_layout = QtWidgets.QHBoxLayout(bottom_widget) bottom_layout.setContentsMargins(0, 0, 0, 0) bottom_layout.setAlignment(QtCore.Qt.AlignHCenter) bottom_layout.addWidget(openpype_logo_label, 0) bottom_layout.addStretch(1) bottom_layout.addWidget(btns_widget, 0) # add all to main main = QtWidgets.QVBoxLayout(self) main.addSpacing(15) main.addWidget(main_label, 0) main.addSpacing(15) main.addWidget(mongo_input, 0) main.addWidget(mongo_messages_widget, 0) main.addWidget(progress_bar, 0) main.addSpacing(15) main.addWidget(console_widget, 1) main.addWidget(bottom_widget, 0) run_button.option_clicked.connect(self._on_run_btn_click) exit_button.clicked.connect(self._on_exit_clicked) mongo_input.textChanged.connect(self._on_mongo_url_change) self._console_widget = console_widget self.main_label = main_label self._mongo_input = mongo_input self._mongo_connection_msg = mongo_connection_msg self._run_button = run_button self._exit_button = exit_button self._progress_bar = progress_bar def _on_run_btn_click(self, option): # Disable buttons self._disable_buttons() # Set progress to any value self._update_progress(1) self._progress_bar.repaint() # Add label to show that is connecting to mongo self.set_invalid_mongo_connection(self.mongo_url, True) # Process events to repaint changes QtWidgets.QApplication.processEvents() if not self.validate_url(): self._enable_buttons() self._update_progress(0) # Update any messages self._mongo_input.setText(self.mongo_url) return if option == "run": self._run_openpype() elif option == "run_from_code": self._run_openpype_from_code() else: raise AssertionError("BUG: Unknown variant \"{}\"".format(option)) def _run_openpype_from_code(self): os.environ["OPENPYPE_MONGO"] = self.mongo_url try: self._secure_registry.set_item("openPypeMongo", self.mongo_url) except ValueError: print("Couldn't save Mongo URL to keyring") self.done(2) def _run_openpype(self): """Start install process. This will once again validate entered path and mongo if ok, start working thread that will do actual job. """ # Check if install thread is not already running if self._install_thread and self._install_thread.isRunning(): return self._mongo_input.set_valid() install_thread = InstallThread(self) install_thread.message.connect(self.update_console) install_thread.progress.connect(self._update_progress) install_thread.finished.connect(self._installation_finished) install_thread.set_mongo(self.mongo_url) self._install_thread = install_thread install_thread.start() def _installation_finished(self): # TODO we should find out why status can be set to 'None'? # - 'InstallThread.run' should handle all cases so not sure where # that come from status = self._install_thread.result() if status is not None and status >= 0: self._update_progress(100) QtWidgets.QApplication.processEvents() self.done(3) else: self._enable_buttons() self._show_console() def _update_progress(self, progress: int): self._progress_bar.setValue(progress) text_visible = self._progress_bar.isTextVisible() if progress == 0: if text_visible: self._progress_bar.setTextVisible(False) elif not text_visible: self._progress_bar.setTextVisible(True) def _on_exit_clicked(self): self.reject() def _on_mongo_url_change(self, new_value): # Strip the value new_value = new_value.strip() # Store new mongo url to variable self.mongo_url = new_value msg = None # Change style of input if not new_value: self._mongo_input.remove_state() elif not self.mongo_url_regex.match(new_value): self._mongo_input.set_invalid() msg = ( "Mongo URL should start with" " \"mongodb://\" or \"mongodb+srv://\"" ) else: self._mongo_input.set_valid() self.set_invalid_mongo_url(msg) def validate_url(self): """Validate if entered url is ok. Returns: True if url is valid monogo string. """ if self.mongo_url == "": return False is_valid, reason_str = validate_mongo_connection(self.mongo_url) if not is_valid: self.set_invalid_mongo_connection(self.mongo_url) self._mongo_input.set_invalid() self.update_console(f"!!! {reason_str}", True) return False self.set_invalid_mongo_connection(None) self._mongo_input.set_valid() return True def set_invalid_mongo_url(self, reason): if reason is None: self._mongo_connection_msg.setText("") else: self._mongo_connection_msg.setText("- {}".format(reason)) def set_invalid_mongo_connection(self, mongo_url, connecting=False): if mongo_url is None: self.set_invalid_mongo_url(mongo_url) return if connecting: msg = "Connecting to: {}".format(mongo_url) else: msg = "Can't connect to: {}".format(mongo_url) self.set_invalid_mongo_url(msg) def update_console(self, msg: str, error: bool = False) -> None: """Display message in console. Args: msg (str): message. error (bool): if True, print it red. """ self._console_widget.update_console(msg, error) def _show_console(self): self._console_widget.show_console() self.updateGeometry() def _disable_buttons(self): """Disable buttons so user interaction doesn't interfere.""" self._exit_button.setEnabled(False) self._run_button.setEnabled(False) self._controls_disabled = True def _enable_buttons(self): """Enable buttons after operation is complete.""" self._exit_button.setEnabled(True) self._run_button.setEnabled(True) self._controls_disabled = False def closeEvent(self, event): # noqa """Prevent closing if window when controls are disabled.""" if self._controls_disabled: return event.ignore() return super(InstallDialog, self).closeEvent(event) if __name__ == "__main__": app = QtWidgets.QApplication(sys.argv) d = InstallDialog() d.show() sys.exit(app.exec_()) ================================================ FILE: igniter/install_thread.py ================================================ # -*- coding: utf-8 -*- """Working thread for installer.""" import os import sys from pathlib import Path from qtpy import QtCore from .bootstrap_repos import ( BootstrapRepos, OpenPypeVersionInvalid, OpenPypeVersionIOError, OpenPypeVersionExists, OpenPypeVersion ) from .tools import ( get_openpype_global_settings, get_local_openpype_path_from_settings, validate_mongo_connection ) class InstallThread(QtCore.QThread): """Install Worker thread. This class takes care of finding OpenPype version on user entered path (or loading this path from database). If nothing is entered by user, OpenPype will create its zip files from repositories that comes with it. If path contains plain repositories, they are zipped and installed to user data dir. """ progress = QtCore.Signal(int) message = QtCore.Signal((str, bool)) def __init__(self, parent=None,): self._mongo = None self._result = None super().__init__(parent) def result(self): """Result of finished installation.""" return self._result def _set_result(self, value): if self._result is not None: raise AssertionError("BUG: Result was set more than once!") self._result = value def run(self): """Thread entry point. Using :class:`BootstrapRepos` to either install OpenPype as zip files or copy them from location specified by user or retrieved from database. """ self.message.emit("Installing OpenPype ...", False) # find local version of OpenPype bs = BootstrapRepos( progress_callback=self.set_progress, message=self.message) local_version = OpenPypeVersion.get_installed_version_str() # user did not entered url if self._mongo: self.message.emit("Saving mongo connection string ...", False) bs.secure_registry.set_item("openPypeMongo", self._mongo) elif os.getenv("OPENPYPE_MONGO"): self._mongo = os.getenv("OPENPYPE_MONGO") else: # try to get it from settings registry try: self._mongo = bs.secure_registry.get_item( "openPypeMongo") except ValueError: self.message.emit( "!!! We need MongoDB URL to proceed.", True) self._set_result(-1) return os.environ["OPENPYPE_MONGO"] = self._mongo if not validate_mongo_connection(self._mongo): self.message.emit(f"Cannot connect to {self._mongo}", True) self._set_result(-1) return global_settings = get_openpype_global_settings(self._mongo) data_dir = get_local_openpype_path_from_settings(global_settings) bs.set_data_dir(data_dir) self.message.emit( f"Detecting installed OpenPype versions in {bs.data_dir}", False) detected = bs.find_openpype(include_zips=True) if not detected and getattr(sys, 'frozen', False): self.message.emit("None detected.", True) self.message.emit(("We will use OpenPype coming with " "installer."), False) openpype_version = bs.create_version_from_frozen_code() if not openpype_version: self.message.emit( f"!!! Install failed - {openpype_version}", True) self._set_result(-1) return self.message.emit(f"Using: {openpype_version}", False) bs.install_version(openpype_version) self.message.emit(f"Installed as {openpype_version}", False) self.progress.emit(100) self._set_result(1) return if detected and not OpenPypeVersion.get_installed_version().is_compatible(detected[-1]): # noqa: E501 self.message.emit(( f"Latest detected version {detected[-1]} " "is not compatible with the currently running " f"{local_version}" ), True) self.message.emit(( "Filtering detected versions to compatible ones..." ), False) # filter results to get only compatible versions detected = [ version for version in detected if version.is_compatible( OpenPypeVersion.get_installed_version()) ] if detected: if OpenPypeVersion( version=local_version, path=Path()) < detected[-1]: self.message.emit(( f"Latest installed version {detected[-1]} is newer " f"then currently running {local_version}" ), False) self.message.emit("Skipping OpenPype install ...", False) if detected[-1].path.suffix.lower() == ".zip": bs.extract_openpype(detected[-1]) self._set_result(0) return if OpenPypeVersion(version=local_version).get_main_version() == detected[-1].get_main_version(): # noqa: E501 self.message.emit(( f"Latest installed version is the same as " f"currently running {local_version}" ), False) self.message.emit("Skipping OpenPype install ...", False) self._set_result(0) return self.message.emit(( "All installed versions are older then " f"currently running one {local_version}" ), False) self.message.emit("None detected.", False) self.message.emit( f"We will use local OpenPype version {local_version}", False) local_openpype = bs.create_version_from_live_code() if not local_openpype: self.message.emit( f"!!! Install failed - {local_openpype}", True) self._set_result(-1) return try: bs.install_version(local_openpype) except (OpenPypeVersionExists, OpenPypeVersionInvalid, OpenPypeVersionIOError) as e: self.message.emit(f"Installed failed: ", True) self.message.emit(str(e), True) self._set_result(-1) return self.message.emit(f"Installed as {local_openpype}", False) self.progress.emit(100) self._set_result(1) return self.progress.emit(100) self._set_result(1) return def set_path(self, path: str) -> None: """Helper to set path. Args: path (str): Path to set. """ self._path = path def set_mongo(self, mongo: str) -> None: """Helper to set mongo url. Args: mongo (str): Mongodb url. """ self._mongo = mongo def set_progress(self, progress: int) -> None: """Helper to set progress bar. Args: progress (int): Progress in percents. """ self.progress.emit(progress) ================================================ FILE: igniter/message_dialog.py ================================================ from qtpy import QtWidgets, QtGui from .tools import ( load_stylesheet, get_openpype_icon_path ) class MessageDialog(QtWidgets.QDialog): """Simple message dialog with title, message and OK button.""" def __init__(self, title, message): super(MessageDialog, self).__init__() # Set logo as icon of window icon_path = get_openpype_icon_path() pixmap_openpype_logo = QtGui.QPixmap(icon_path) self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo)) # Set title self.setWindowTitle(title) # Set message label_widget = QtWidgets.QLabel(message, self) ok_btn = QtWidgets.QPushButton("OK", self) btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) btns_layout.addWidget(ok_btn, 0) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(label_widget, 1) layout.addLayout(btns_layout, 0) ok_btn.clicked.connect(self._on_ok_clicked) self._label_widget = label_widget self._ok_btn = ok_btn def _on_ok_clicked(self): self.close() def showEvent(self, event): super(MessageDialog, self).showEvent(event) self.setStyleSheet(load_stylesheet()) ================================================ FILE: igniter/nice_progress_bar.py ================================================ from qtpy import QtWidgets class NiceProgressBar(QtWidgets.QProgressBar): def __init__(self, parent=None): super(NiceProgressBar, self).__init__(parent) self._real_value = 0 def setValue(self, value): self._real_value = value if value != 0 and value < 11: value = 11 super(NiceProgressBar, self).setValue(value) def value(self): return self._real_value def text(self): return "{} %".format(self._real_value) ================================================ FILE: igniter/splash.txt ================================================ * .* * .* * . * .* * . . * .* .* .* * . . * .* .* .* * . _. /** \ * \* * * . __. ---* \ \* \ * \* * . \___. /* * \ \ * \ \* \ * \* . |____. /* * \|\ * \ \ * \ \ * \ \* \/. _/_____. /* * / \ * \ \ * \ \ * \ \__* \/__. __________. --*-- ___* \ \ \/_* \ \ __* \ \ \_* \ \____\* \/____/. \____________ . /* ___ \* \ \ \/_\ * \ \ _____* \ \ \___/* \ \____\ * \/____/ . |___________ . /* ___ \ * \|\ \/_\ \ * \ \ _____/ * \ \ \___/ * \ \____\ / * \/____/ \. _/__________ . /* ___ \ * / \ \/_\ \ * \ \ _____/ * \ \ \___/ ---* \ \____\ / \__* \/____/ \/__. ____________ . --*-- ___ \ * \ \ \/_\ \ * \ \ _____/ * \ \ \___/ ---- * \ \____\ / \____\* \/____/ \/____/. ____________ /\ ___ \ . \ \ \/_\ \ * \ \ _____/ * \ \ \___/ ---- * \ \____\ / \____\ . \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ . \ \ _____/ * \ \ \___/ ---- * \ \____\ / \____\ . \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ . \ \ \___/ ---- * \ \____\ / \____\ . \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ \ \ \___/ ---- * \ \____\ / \____\ \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ \ \ \___/ ---- . \ \____\ / \____\ \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ _ \ \ \___/ ---- \ \____\ / \____\ \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ \ \ \___/ ---- \ \____\ / \____\ \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ \ \ \___/ ---- \ \ \____\ / \____\ \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ \ \ \___/ ---- \ \ \____\ / \____\ \ \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ \ \ \___/ ---- \ \ \____\ / \____\ __\ \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ \ \ \___/ ---- \ \ \____\ / \____\ \__\ \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ \ \ \___/ ---- \ \ \ \____\ / \____\ \__\ \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ \ \ \___/ ---- \ \ \ \____\ / \____\ \__\ \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___. \ \ \___/ ---- \ \\ \ \____\ / \____\ \__\, \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ . \ \ \___/ ---- \ \\ \ \____\ / \____\ \__\\, \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ _. \ \ \___/ ---- \ \\\ \ \____\ / \____\ \__\\\ \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ __. \ \ \___/ ---- \ \\ \ \ \____\ / \____\ \__\\_/. \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___. \ \ \___/ ---- \ \\ \\ \ \____\ / \____\ \__\\__\. \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ . \ \ \___/ ---- \ \\ \\ \ \____\ / \____\ \__\\__\\. \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ _. \ \ \___/ ---- \ \\ \\\ \ \____\ / \____\ \__\\__\\. \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ __. \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\_. \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ __. \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__. \/____/ \/____/ ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ * ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ O* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ .oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ ..oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . .oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . p.oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . Py.oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . PYp.oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . PYPe.oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . PYPE .oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . PYPE c.oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . PYPE C1.oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . PYPE ClU.oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . PYPE CluB.oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . PYPE Club .oO* ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . PYPE Club . .. ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . PYPE Club . .. ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . PYPE Club . . ____________ /\ ___ \ \ \ \/_\ \ \ \ _____/ ___ ___ ___ \ \ \___/ ---- \ \\ \\ \ \ \____\ / \____\ \__\\__\\__\ \/____/ \/____/ . PYPE Club . ================================================ FILE: igniter/stylesheet.css ================================================ *{ font-size: 10pt; font-family: "Poppins"; } QWidget { color: #bfccd6; background-color: #282C34; border-radius: 0px; } QMenu { border: 1px solid #555555; background-color: #21252B; } QMenu::item { padding: 5px 10px 5px 10px; border-left: 5px solid #313741;; } QMenu::item:selected { border-left-color: rgb(84, 209, 178); background-color: #222d37; } QLineEdit, QPlainTextEdit { border: 1px solid #464b54; border-radius: 3px; background-color: #21252B; padding: 0.5em; } QLineEdit[state="valid"] { background-color: rgb(19, 19, 19); color: rgb(64, 230, 132); border-color: rgb(32, 64, 32); } QLineEdit[state="invalid"] { background-color: rgb(32, 19, 19); color: rgb(255, 69, 0); border-color: rgb(64, 32, 32); } QLabel { background: transparent; color: #969b9e; } QLabel:hover {color: #b8c1c5;} QPushButton { border: 1px solid #aaaaaa; border-radius: 3px; padding: 5px; } QPushButton:hover { background-color: #333840; border: 1px solid #fff; color: #fff; } QTableView { border: 1px solid #444; gridline-color: #6c6c6c; background-color: #201F1F; alternate-background-color:#21252B; } QTableView::item:pressed, QListView::item:pressed, QTreeView::item:pressed { background: #78879b; color: #FFFFFF; } QTableView::item:selected:active, QTreeView::item:selected:active, QListView::item:selected:active { background: #3d8ec9; } QProgressBar { border: 1px solid grey; border-radius: 10px; color: #222222; font-weight: bold; } QProgressBar:horizontal { height: 20px; } QProgressBar::chunk { border-radius: 10px; background-color: qlineargradient( x1: 0, y1: 0.5, x2: 1, y2: 0.5, stop: 0 rgb(72, 200, 150), stop: 1 rgb(82, 172, 215) ); } QScrollBar:horizontal { height: 15px; margin: 3px 15px 3px 15px; border: 1px transparent #21252B; border-radius: 4px; background-color: #21252B; } QScrollBar::handle:horizontal { background-color: #4B5362; min-width: 5px; border-radius: 4px; } QScrollBar::add-line:horizontal { margin: 0px 3px 0px 3px; border-image: url(:/qss_icons/rc/right_arrow_disabled.png); width: 10px; height: 10px; subcontrol-position: right; subcontrol-origin: margin; } QScrollBar::sub-line:horizontal { margin: 0px 3px 0px 3px; border-image: url(:/qss_icons/rc/left_arrow_disabled.png); height: 10px; width: 10px; subcontrol-position: left; subcontrol-origin: margin; } QScrollBar::add-line:horizontal:hover,QScrollBar::add-line:horizontal:on { border-image: url(:/qss_icons/rc/right_arrow.png); height: 10px; width: 10px; subcontrol-position: right; subcontrol-origin: margin; } QScrollBar::sub-line:horizontal:hover, QScrollBar::sub-line:horizontal:on { border-image: url(:/qss_icons/rc/left_arrow.png); height: 10px; width: 10px; subcontrol-position: left; subcontrol-origin: margin; } QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal { background: none; } QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: none; } QScrollBar:vertical { background-color: #21252B; width: 15px; margin: 15px 3px 15px 3px; border: 1px transparent #21252B; border-radius: 4px; } QScrollBar::handle:vertical { background-color: #4B5362; min-height: 5px; border-radius: 4px; } QScrollBar::sub-line:vertical { margin: 3px 0px 3px 0px; border-image: url(:/qss_icons/rc/up_arrow_disabled.png); height: 10px; width: 10px; subcontrol-position: top; subcontrol-origin: margin; } QScrollBar::add-line:vertical { margin: 3px 0px 3px 0px; border-image: url(:/qss_icons/rc/down_arrow_disabled.png); height: 10px; width: 10px; subcontrol-position: bottom; subcontrol-origin: margin; } QScrollBar::sub-line:vertical:hover,QScrollBar::sub-line:vertical:on { border-image: url(:/qss_icons/rc/up_arrow.png); height: 10px; width: 10px; subcontrol-position: top; subcontrol-origin: margin; } QScrollBar::add-line:vertical:hover, QScrollBar::add-line:vertical:on { border-image: url(:/qss_icons/rc/down_arrow.png); height: 10px; width: 10px; subcontrol-position: bottom; subcontrol-origin: margin; } QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical { background: none; } QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; } #MainLabel { color: rgb(200, 200, 200); font-size: 12pt; } #Console { background-color: #21252B; color: rgb(72, 200, 150); font-family: "Roboto Mono"; font-size: 8pt; } #ExitBtn { /* `border` must be set to background of flat button is painted .*/ border: none; color: rgb(39, 39, 39); background-color: #828a97; padding: 0.5em; font-weight: 400; } #ExitBtn:hover{ background-color: #b2bece } #ExitBtn:disabled { background-color: rgba(185, 185, 185, 31); color: rgba(64, 64, 64, 63); } #ButtonWithOptions QPushButton{ border-top-right-radius: 0px; border-bottom-right-radius: 0px; border: none; background-color: rgb(84, 209, 178); color: rgb(39, 39, 39); font-weight: 400; padding: 0.5em; } #ButtonWithOptions QPushButton:hover{ background-color: rgb(85, 224, 189) } #ButtonWithOptions QPushButton:disabled { background-color: rgba(72, 200, 150, 31); color: rgba(64, 64, 64, 63); } #ButtonWithOptions QToolButton{ border: none; border-top-left-radius: 0px; border-bottom-left-radius: 0px; border-top-right-radius: 3px; border-bottom-right-radius: 3px; background-color: rgb(84, 209, 178); color: rgb(39, 39, 39); } #ButtonWithOptions QToolButton:hover{ background-color: rgb(85, 224, 189) } #ButtonWithOptions QToolButton:disabled { background-color: rgba(72, 200, 150, 31); color: rgba(64, 64, 64, 63); } ================================================ FILE: igniter/terminal_splash.py ================================================ # -*- coding: utf-8 -*- """OpenPype terminal animation.""" import blessed from pathlib import Path from time import sleep NO_TERMINAL = False try: term = blessed.Terminal() except AttributeError: # this happens when blessed cannot find proper terminal. # If so, skip printing ascii art animation. NO_TERMINAL = True def play_animation(): """Play ASCII art OpenPype animation.""" if NO_TERMINAL: return print(term.home + term.clear) frame_size = 7 splash_file = Path(__file__).parent / "splash.txt" with splash_file.open("r") as sf: animation = sf.readlines() animation_length = int(len(animation) / frame_size) current_frame = 0 for _ in range(animation_length): frame = "".join( scanline for y, scanline in enumerate( animation[current_frame : current_frame + frame_size] ) ) with term.location(0, 0): # term.aquamarine3_bold(frame) print(f"{term.bold}{term.aquamarine3}{frame}{term.normal}") sleep(0.02) current_frame += frame_size print(term.move_y(7)) ================================================ FILE: igniter/tools.py ================================================ # -*- coding: utf-8 -*- """Tools used in **Igniter** GUI.""" import os from typing import Union from urllib.parse import urlparse, parse_qs from pathlib import Path import platform import certifi from pymongo import MongoClient from pymongo.errors import ( ServerSelectionTimeoutError, InvalidURI, ConfigurationError, OperationFailure ) class OpenPypeVersionNotFound(Exception): """OpenPype version was not found in remote and local repository.""" pass class OpenPypeVersionIncompatible(Exception): """OpenPype version is not compatible with the installed one (build).""" pass def should_add_certificate_path_to_mongo_url(mongo_url): """Check if should add ca certificate to mongo url. Since 30.9.2021 cloud mongo requires newer certificates that are not available on most of workstation. This adds path to certifi certificate which is valid for it. To add the certificate path url must have scheme 'mongodb+srv' or has 'ssl=true' or 'tls=true' in url query. """ parsed = urlparse(mongo_url) query = parse_qs(parsed.query) lowered_query_keys = set(key.lower() for key in query.keys()) add_certificate = False # Check if url 'ssl' or 'tls' are set to 'true' for key in ("ssl", "tls"): if key in query and "true" in query[key]: add_certificate = True break # Check if url contains 'mongodb+srv' if not add_certificate and parsed.scheme == "mongodb+srv": add_certificate = True # Check if url does already contain certificate path if add_certificate and "tlscafile" in lowered_query_keys: add_certificate = False return add_certificate def validate_mongo_connection(cnx: str) -> (bool, str): """Check if provided mongodb URL is valid. Args: cnx (str): URL to validate. Returns: (bool, str): True if ok, False if not and reason in str. """ parsed = urlparse(cnx) if parsed.scheme not in ["mongodb", "mongodb+srv"]: return False, "Not mongodb schema" kwargs = { "serverSelectionTimeoutMS": os.environ.get("AVALON_TIMEOUT", 2000) } # Add certificate path if should be required if should_add_certificate_path_to_mongo_url(cnx): kwargs["tlsCAFile"] = certifi.where() try: client = MongoClient(cnx, **kwargs) client.server_info() with client.start_session(): pass client.close() except ServerSelectionTimeoutError as e: return False, f"Cannot connect to server {cnx} - {e}" except ValueError: return False, f"Invalid port specified {parsed.port}" except (ConfigurationError, OperationFailure, InvalidURI) as exc: return False, str(exc) else: return True, "Connection is successful" def validate_mongo_string(mongo: str) -> (bool, str): """Validate string if it is mongo url acceptable by **Igniter**.. Args: mongo (str): String to validate. Returns: (bool, str): True if valid, False if not and in second part of tuple the reason why it failed. """ if not mongo: return True, "empty string" return validate_mongo_connection(mongo) def validate_path_string(path: str) -> (bool, str): """Validate string if it is path to OpenPype repository. Args: path (str): Path to validate. Returns: (bool, str): True if valid, False if not and in second part of tuple the reason why it failed. """ if not path: return False, "empty string" if not Path(path).exists(): return False, "path doesn't exists" if not Path(path).is_dir(): return False, "path is not directory" return True, "valid path" def get_openpype_global_settings(url: str) -> dict: """Load global settings from Mongo database. We are loading data from database `openpype` and collection `settings`. There we expect document type `global_settings`. Args: url (str): MongoDB url. Returns: dict: With settings data. Empty dictionary is returned if not found. """ kwargs = {} if should_add_certificate_path_to_mongo_url(url): kwargs["tlsCAFile"] = certifi.where() try: # Create mongo connection client = MongoClient(url, **kwargs) # Access settings collection openpype_db = os.environ.get("OPENPYPE_DATABASE_NAME") or "openpype" col = client[openpype_db]["settings"] # Query global settings global_settings = col.find_one({"type": "global_settings"}) or {} # Close Mongo connection client.close() except Exception: # TODO log traceback or message return {} return global_settings.get("data") or {} def get_openpype_path_from_settings(settings: dict) -> Union[str, None]: """Get OpenPype path from global settings. Args: settings (dict): mongodb url. Returns: path to OpenPype or None if not found """ paths = ( settings .get("openpype_path", {}) .get(platform.system().lower()) ) or [] # For cases when `openpype_path` is a single path if paths and isinstance(paths, str): paths = [paths] return next((path for path in paths if os.path.exists(path)), None) def get_local_openpype_path_from_settings(settings: dict) -> Union[str, None]: """Get OpenPype local path from global settings. Used to download and unzip OP versions. Args: settings (dict): settings from DB. Returns: path to OpenPype or None if not found """ path = ( settings .get("local_openpype_path", {}) .get(platform.system().lower()) ) if path: return Path(path) return None def get_expected_studio_version_str( staging=False, global_settings=None ) -> str: """Version that should be currently used in studio. Args: staging (bool): Get current version for staging. global_settings (dict): Optional precached global settings. Returns: str: OpenPype version which should be used. Empty string means latest. """ mongo_url = os.environ.get("OPENPYPE_MONGO") if global_settings is None: global_settings = get_openpype_global_settings(mongo_url) key = "staging_version" if staging else "production_version" return global_settings.get(key) or "" def load_stylesheet() -> str: """Load css style sheet. Returns: str: content of the stylesheet """ stylesheet_path = Path(__file__).parent.resolve() / "stylesheet.css" return stylesheet_path.read_text() def get_openpype_icon_path() -> str: """Path to OpenPype icon png file.""" return os.path.join( os.path.dirname(os.path.abspath(__file__)), "openpype_icon.png" ) ================================================ FILE: igniter/update_thread.py ================================================ # -*- coding: utf-8 -*- """Working thread for update.""" from qtpy import QtCore from .bootstrap_repos import ( BootstrapRepos, OpenPypeVersion ) class UpdateThread(QtCore.QThread): """Install Worker thread. This class takes care of finding OpenPype version on user entered path (or loading this path from database). If nothing is entered by user, OpenPype will create its zip files from repositories that comes with it. If path contains plain repositories, they are zipped and installed to user data dir. """ progress = QtCore.Signal(int) message = QtCore.Signal((str, bool)) def __init__(self, parent=None): self._result = None self._openpype_version = None super().__init__(parent) def set_version(self, openpype_version: OpenPypeVersion): self._openpype_version = openpype_version def result(self): """Result of finished installation.""" return self._result def _set_result(self, value): if self._result is not None: raise AssertionError("BUG: Result was set more than once!") self._result = value def run(self): """Thread entry point. Using :class:`BootstrapRepos` to either install OpenPype as zip files or copy them from location specified by user or retrieved from database. """ bs = BootstrapRepos( progress_callback=self.set_progress, message=self.message) bs.set_data_dir(OpenPypeVersion.get_local_openpype_path()) version_path = bs.install_version(self._openpype_version) self._set_result(version_path) def set_progress(self, progress: int) -> None: """Helper to set progress bar. Args: progress (int): Progress in percents. """ self.progress.emit(progress) ================================================ FILE: igniter/update_window.py ================================================ # -*- coding: utf-8 -*- """Progress window to show when OpenPype is updating/installing locally.""" import os from qtpy import QtCore, QtGui, QtWidgets from .update_thread import UpdateThread from .bootstrap_repos import OpenPypeVersion from .nice_progress_bar import NiceProgressBar from .tools import load_stylesheet class UpdateWindow(QtWidgets.QDialog): """OpenPype update window.""" _width = 500 _height = 100 def __init__(self, version: OpenPypeVersion, parent=None): super(UpdateWindow, self).__init__(parent) self._openpype_version = version self._result_version_path = None self.setWindowTitle( f"OpenPype is updating ..." ) self.setModal(True) self.setWindowFlags( QtCore.Qt.WindowMinimizeButtonHint ) current_dir = os.path.dirname(os.path.abspath(__file__)) roboto_font_path = os.path.join(current_dir, "RobotoMono-Regular.ttf") poppins_font_path = os.path.join(current_dir, "Poppins") icon_path = os.path.join(current_dir, "openpype_icon.png") # Install roboto font QtGui.QFontDatabase.addApplicationFont(roboto_font_path) for filename in os.listdir(poppins_font_path): if os.path.splitext(filename)[1] == ".ttf": QtGui.QFontDatabase.addApplicationFont(filename) # Load logo pixmap_openpype_logo = QtGui.QPixmap(icon_path) # Set logo as icon of window self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo)) self._pixmap_openpype_logo = pixmap_openpype_logo self._update_thread = None self._init_ui() # Set stylesheet self.setStyleSheet(load_stylesheet()) self._run_update() def _init_ui(self): # Main info # -------------------------------------------------------------------- main_label = QtWidgets.QLabel( f"OpenPype is updating to {self._openpype_version}", self) main_label.setWordWrap(True) main_label.setObjectName("MainLabel") # Progress bar # -------------------------------------------------------------------- progress_bar = NiceProgressBar(self) progress_bar.setAlignment(QtCore.Qt.AlignCenter) progress_bar.setTextVisible(False) # add all to main main = QtWidgets.QVBoxLayout(self) main.addSpacing(15) main.addWidget(main_label, 0) main.addSpacing(15) main.addWidget(progress_bar, 0) main.addSpacing(15) self._progress_bar = progress_bar def showEvent(self, event): super().showEvent(event) current_size = self.size() new_size = QtCore.QSize( max(current_size.width(), self._width), max(current_size.height(), self._height) ) if current_size != new_size: self.resize(new_size) def _run_update(self): """Start install process. This will once again validate entered path and mongo if ok, start working thread that will do actual job. """ # Check if install thread is not already running if self._update_thread and self._update_thread.isRunning(): return self._progress_bar.setRange(0, 0) update_thread = UpdateThread(self) update_thread.set_version(self._openpype_version) update_thread.message.connect(self.update_console) update_thread.progress.connect(self._update_progress) update_thread.finished.connect(self._installation_finished) self._update_thread = update_thread update_thread.start() def get_version_path(self): return self._result_version_path def _installation_finished(self): status = self._update_thread.result() self._result_version_path = status self._progress_bar.setRange(0, 1) self._update_progress(100) QtWidgets.QApplication.processEvents() self.done(0) def _update_progress(self, progress: int): # not updating progress as we are not able to determine it # correctly now. Progress bar is set to un-deterministic mode # until we are able to get progress in better way. """ self._progress_bar.setRange(0, 0) self._progress_bar.setValue(progress) text_visible = self._progress_bar.isTextVisible() if progress == 0: if text_visible: self._progress_bar.setTextVisible(False) elif not text_visible: self._progress_bar.setTextVisible(True) """ return def update_console(self, msg: str, error: bool = False) -> None: """Display message in console. Args: msg (str): message. error (bool): if True, print it red. """ print(msg) ================================================ FILE: igniter/user_settings.py ================================================ # -*- coding: utf-8 -*- """Package to deal with saving and retrieving user specific settings.""" import os from datetime import datetime from abc import ABCMeta, abstractmethod import json # disable lru cache in Python 2 try: from functools import lru_cache except ImportError: def lru_cache(maxsize): def max_size(func): def wrapper(*args, **kwargs): value = func(*args, **kwargs) return value return wrapper return max_size # ConfigParser was renamed in python3 to configparser try: import configparser except ImportError: import ConfigParser as configparser import platform import six import appdirs _PLACEHOLDER = object() class OpenPypeSecureRegistry: """Store information using keyring. Registry should be used for private data that should be available only for user. All passed registry names will have added prefix `OpenPype/` to easier identify which data were created by OpenPype. Args: name(str): Name of registry used as identifier for data. """ def __init__(self, name): try: import keyring except Exception: raise NotImplementedError( "Python module `keyring` is not available." ) # hack for cx_freeze and Windows keyring backend if platform.system().lower() == "windows": from keyring.backends import Windows keyring.set_keyring(Windows.WinVaultKeyring()) # Force "OpenPype" prefix self._name = "/".join(("OpenPype", name)) def set_item(self, name, value): # type: (str, str) -> None """Set sensitive item into system's keyring. This uses `Keyring module`_ to save sensitive stuff into system's keyring. Args: name (str): Name of the item. value (str): Value of the item. .. _Keyring module: https://github.com/jaraco/keyring """ import keyring keyring.set_password(self._name, name, value) @lru_cache(maxsize=32) def get_item(self, name, default=_PLACEHOLDER): """Get value of sensitive item from system's keyring. See also `Keyring module`_ Args: name (str): Name of the item. default (Any): Default value if item is not available. Returns: value (str): Value of the item. Raises: ValueError: If item doesn't exist and default is not defined. .. _Keyring module: https://github.com/jaraco/keyring """ import keyring value = keyring.get_password(self._name, name) if value: return value if default is not _PLACEHOLDER: return default # NOTE Should raise `KeyError` raise ValueError( "Item {}:{} does not exist in keyring.".format(self._name, name) ) def delete_item(self, name): # type: (str) -> None """Delete value stored in system's keyring. See also `Keyring module`_ Args: name (str): Name of the item to be deleted. .. _Keyring module: https://github.com/jaraco/keyring """ import keyring self.get_item.cache_clear() keyring.delete_password(self._name, name) @six.add_metaclass(ABCMeta) class ASettingRegistry(): """Abstract class defining structure of **SettingRegistry** class. It is implementing methods to store secure items into keyring, otherwise mechanism for storing common items must be implemented in abstract methods. Attributes: _name (str): Registry names. """ def __init__(self, name): # type: (str) -> ASettingRegistry super(ASettingRegistry, self).__init__() self._name = name self._items = {} def set_item(self, name, value): # type: (str, str) -> None """Set item to settings registry. Args: name (str): Name of the item. value (str): Value of the item. """ self._set_item(name, value) @abstractmethod def _set_item(self, name, value): # type: (str, str) -> None # Implement it pass def __setitem__(self, name, value): self._items[name] = value self._set_item(name, value) def get_item(self, name): # type: (str) -> str """Get item from settings registry. Args: name (str): Name of the item. Returns: value (str): Value of the item. Raises: ValueError: If item doesn't exist. """ return self._get_item(name) @abstractmethod def _get_item(self, name): # type: (str) -> str # Implement it pass def __getitem__(self, name): return self._get_item(name) def delete_item(self, name): # type: (str) -> None """Delete item from settings registry. Args: name (str): Name of the item. """ self._delete_item(name) @abstractmethod def _delete_item(self, name): # type: (str) -> None """Delete item from settings. Note: see :meth:`openpype.lib.user_settings.ARegistrySettings.delete_item` """ pass def __delitem__(self, name): del self._items[name] self._delete_item(name) class IniSettingRegistry(ASettingRegistry): """Class using :mod:`configparser`. This class is using :mod:`configparser` (ini) files to store items. """ def __init__(self, name, path): # type: (str, str) -> IniSettingRegistry super(IniSettingRegistry, self).__init__(name) # get registry file version = os.getenv("OPENPYPE_VERSION", "N/A") self._registry_file = os.path.join(path, "{}.ini".format(name)) if not os.path.exists(self._registry_file): with open(self._registry_file, mode="w") as cfg: print("# Settings registry", cfg) print("# Generated by OpenPype {}".format(version), cfg) now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") print("# {}".format(now), cfg) def set_item_section( self, section, name, value): # type: (str, str, str) -> None """Set item to specific section of ini registry. If section doesn't exists, it is created. Args: section (str): Name of section. name (str): Name of the item. value (str): Value of the item. """ value = str(value) config = configparser.ConfigParser() config.read(self._registry_file) if not config.has_section(section): config.add_section(section) current = config[section] current[name] = value with open(self._registry_file, mode="w") as cfg: config.write(cfg) def _set_item(self, name, value): # type: (str, str) -> None self.set_item_section("MAIN", name, value) def set_item(self, name, value): # type: (str, str) -> None """Set item to settings ini file. This saves item to ``DEFAULT`` section of ini as each item there must reside in some section. Args: name (str): Name of the item. value (str): Value of the item. """ # this does the some, overridden just for different docstring. # we cast value to str as ini options values must be strings. super(IniSettingRegistry, self).set_item(name, str(value)) def get_item(self, name): # type: (str) -> str """Gets item from settings ini file. This gets settings from ``DEFAULT`` section of ini file as each item there must reside in some section. Args: name (str): Name of the item. Returns: str: Value of item. Raises: ValueError: If value doesn't exist. """ return super(IniSettingRegistry, self).get_item(name) @lru_cache(maxsize=32) def get_item_from_section(self, section, name): # type: (str, str) -> str """Get item from section of ini file. This will read ini file and try to get item value from specified section. If that section or item doesn't exist, :exc:`ValueError` is risen. Args: section (str): Name of ini section. name (str): Name of the item. Returns: str: Item value. Raises: ValueError: If value doesn't exist. """ config = configparser.ConfigParser() config.read(self._registry_file) try: value = config[section][name] except KeyError: raise ValueError( "Registry doesn't contain value {}:{}".format(section, name)) return value def _get_item(self, name): # type: (str) -> str return self.get_item_from_section("MAIN", name) def delete_item_from_section(self, section, name): # type: (str, str) -> None """Delete item from section in ini file. Args: section (str): Section name. name (str): Name of the item. Raises: ValueError: If item doesn't exist. """ self.get_item_from_section.cache_clear() config = configparser.ConfigParser() config.read(self._registry_file) try: _ = config[section][name] except KeyError: raise ValueError( "Registry doesn't contain value {}:{}".format(section, name)) config.remove_option(section, name) # if section is empty, delete it if len(config[section].keys()) == 0: config.remove_section(section) with open(self._registry_file, mode="w") as cfg: config.write(cfg) def _delete_item(self, name): """Delete item from default section. Note: See :meth:`~openpype.lib.IniSettingsRegistry.delete_item_from_section` """ self.delete_item_from_section("MAIN", name) class JSONSettingRegistry(ASettingRegistry): """Class using json file as storage.""" def __init__(self, name, path): # type: (str, str) -> JSONSettingRegistry super(JSONSettingRegistry, self).__init__(name) #: str: name of registry file self._registry_file = os.path.join(path, "{}.json".format(name)) now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") header = { "__metadata__": { "openpype-version": os.getenv("OPENPYPE_VERSION", "N/A"), "generated": now }, "registry": {} } if not os.path.exists(os.path.dirname(self._registry_file)): os.makedirs(os.path.dirname(self._registry_file), exist_ok=True) if not os.path.exists(self._registry_file): with open(self._registry_file, mode="w") as cfg: json.dump(header, cfg, indent=4) @lru_cache(maxsize=32) def _get_item(self, name): # type: (str) -> object """Get item value from registry json. Note: See :meth:`openpype.lib.JSONSettingRegistry.get_item` """ with open(self._registry_file, mode="r") as cfg: data = json.load(cfg) try: value = data["registry"][name] except KeyError: raise ValueError( "Registry doesn't contain value {}".format(name)) return value def get_item(self, name): # type: (str) -> object """Get item value from registry json. Args: name (str): Name of the item. Returns: value of the item Raises: ValueError: If item is not found in registry file. """ return self._get_item(name) def _set_item(self, name, value): # type: (str, object) -> None """Set item value to registry json. Note: See :meth:`openpype.lib.JSONSettingRegistry.set_item` """ with open(self._registry_file, "r+") as cfg: data = json.load(cfg) data["registry"][name] = value cfg.truncate(0) cfg.seek(0) json.dump(data, cfg, indent=4) def set_item(self, name, value): # type: (str, object) -> None """Set item and its value into json registry file. Args: name (str): name of the item. value (Any): value of the item. """ self._set_item(name, value) def _delete_item(self, name): # type: (str) -> None self._get_item.cache_clear() with open(self._registry_file, "r+") as cfg: data = json.load(cfg) del data["registry"][name] cfg.truncate(0) cfg.seek(0) json.dump(data, cfg, indent=4) class OpenPypeSettingsRegistry(JSONSettingRegistry): """Class handling OpenPype general settings registry. Attributes: vendor (str): Name used for path construction. product (str): Additional name used for path construction. """ def __init__(self, name=None): self.vendor = "pypeclub" self.product = "openpype" if not name: name = "openpype_settings" path = appdirs.user_data_dir(self.product, self.vendor) super(OpenPypeSettingsRegistry, self).__init__(name, path) ================================================ FILE: igniter/version.py ================================================ # -*- coding: utf-8 -*- """Definition of Igniter version.""" __version__ = "1.0.2" ================================================ FILE: inno_setup.iss ================================================ ; Script generated by the Inno Setup Script Wizard. ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "OpenPype" #define Build GetEnv("BUILD_DIR") #define AppVer GetEnv("BUILD_VERSION") [Setup] ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) AppId={{B9E9DF6A-5BDA-42DD-9F35-C09D564C4D93} AppName={#MyAppName} AppVersion={#AppVer} AppVerName={#MyAppName} version {#AppVer} AppPublisher=Ynput s.r.o AppPublisherURL=https://ynput.io AppSupportURL=https://ynput.io AppUpdatesURL=https://ynput.io DefaultDirName={autopf}\{#MyAppName}\{#AppVer} UsePreviousAppDir=no DisableProgramGroupPage=yes OutputBaseFilename={#MyAppName}-{#AppVer}-install AllowCancelDuringInstall=yes ; Uncomment the following line to run in non administrative install mode (install for current user only.) ;PrivilegesRequired=lowest PrivilegesRequiredOverridesAllowed=dialog SetupIconFile=igniter\openpype.ico OutputDir=build\ Compression=lzma2 SolidCompression=yes WizardStyle=modern [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" [InstallDelete] ; clean everything in previous installation folder Type: filesandordirs; Name: "{app}\*" [Files] Source: "build\{#build}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs ; NOTE: Don't use "Flags: ignoreversion" on any shared system files [Icons] Name: "{autoprograms}\{#MyAppName} {#AppVer}"; Filename: "{app}\openpype_gui.exe" Name: "{autodesktop}\{#MyAppName} {#AppVer}"; Filename: "{app}\openpype_gui.exe"; Tasks: desktopicon [Run] Filename: "{app}\openpype_gui.exe"; Description: "{cm:LaunchProgram,OpenPype}"; Flags: nowait postinstall skipifsilent ================================================ FILE: openpype/__init__.py ================================================ import os PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) PLUGINS_DIR = os.path.join(PACKAGE_DIR, "plugins") AYON_SERVER_ENABLED = os.environ.get("USE_AYON_SERVER") == "1" ================================================ FILE: openpype/__main__.py ================================================ # -*- coding: utf-8 -*- """Main entry point for Pype command.""" from . import cli import sys import traceback if __name__ == '__main__': try: cli.main(obj={}, prog_name="pype") except Exception: exc_info = sys.exc_info() print("!!! Pype crashed:") traceback.print_exception(*exc_info) sys.exit(1) ================================================ FILE: openpype/addons/README.md ================================================ This directory is for storing external addons that needs to be included in the pipeline when distributed. The directory is ignored by Git, but included in the zip and installation files. ================================================ FILE: openpype/cli.py ================================================ # -*- coding: utf-8 -*- """Package for handling pype command line arguments.""" import os import sys import code import click from openpype import AYON_SERVER_ENABLED from .pype_commands import PypeCommands class AliasedGroup(click.Group): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._aliases = {} def set_alias(self, src_name, dst_name): self._aliases[dst_name] = src_name def get_command(self, ctx, cmd_name): if cmd_name in self._aliases: cmd_name = self._aliases[cmd_name] return super().get_command(ctx, cmd_name) @click.group(cls=AliasedGroup, invoke_without_command=True) @click.pass_context @click.option("--use-version", expose_value=False, help="use specified version") @click.option("--use-staging", is_flag=True, expose_value=False, help="use staging variants") @click.option("--list-versions", is_flag=True, expose_value=False, help="list all detected versions.") @click.option("--validate-version", expose_value=False, help="validate given version integrity") @click.option("--debug", is_flag=True, expose_value=False, help="Enable debug") @click.option("--verbose", expose_value=False, help=("Change OpenPype log level (debug - critical or 0-50)")) @click.option("--automatic-tests", is_flag=True, expose_value=False, help=("Run in automatic tests mode")) def main(ctx): """Pype is main command serving as entry point to pipeline system. It wraps different commands together. """ if ctx.invoked_subcommand is None: # Print help if headless mode is used if AYON_SERVER_ENABLED: is_headless = os.getenv("AYON_HEADLESS_MODE") == "1" else: is_headless = os.getenv("OPENPYPE_HEADLESS_MODE") == "1" if is_headless: print(ctx.get_help()) sys.exit(0) else: ctx.invoke(tray) @main.command() @click.option("-d", "--dev", is_flag=True, help="Settings in Dev mode") def settings(dev): """Show Pype Settings UI.""" if AYON_SERVER_ENABLED: raise RuntimeError("AYON does not support 'settings' command.") PypeCommands().launch_settings_gui(dev) @main.command() def tray(): """Launch pype tray. Default action of pype command is to launch tray widget to control basic aspects of pype. See documentation for more information. """ PypeCommands().launch_tray() @PypeCommands.add_modules @main.group(help="Run command line arguments of OpenPype addons") @click.pass_context def module(ctx): """Addon specific commands created dynamically. These commands are generated dynamically by currently loaded addons. """ pass # Add 'addon' as alias for module main.set_alias("module", "addon") @main.command() @click.option("--ftrack-url", envvar="FTRACK_SERVER", help="Ftrack server url") @click.option("--ftrack-user", envvar="FTRACK_API_USER", help="Ftrack api user") @click.option("--ftrack-api-key", envvar="FTRACK_API_KEY", help="Ftrack api key") @click.option("--legacy", is_flag=True, help="run event server without mongo storing") @click.option("--clockify-api-key", envvar="CLOCKIFY_API_KEY", help="Clockify API key.") @click.option("--clockify-workspace", envvar="CLOCKIFY_WORKSPACE", help="Clockify workspace") def eventserver(ftrack_url, ftrack_user, ftrack_api_key, legacy, clockify_api_key, clockify_workspace): """Launch ftrack event server. This should be ideally used by system service (such us systemd or upstart on linux and window service). """ if AYON_SERVER_ENABLED: raise RuntimeError("AYON does not support 'eventserver' command.") PypeCommands().launch_eventservercli( ftrack_url, ftrack_user, ftrack_api_key, legacy, clockify_api_key, clockify_workspace ) @main.command() @click.option("-h", "--host", help="Host", default=None) @click.option("-p", "--port", help="Port", default=None) @click.option("-e", "--executable", help="Executable") @click.option("-u", "--upload_dir", help="Upload dir") def webpublisherwebserver(executable, upload_dir, host=None, port=None): """Starts webserver for communication with Webpublish FR via command line OP must be congigured on a machine, eg. OPENPYPE_MONGO filled AND FTRACK_BOT_API_KEY provided with api key from Ftrack. Expect "pype.club" user created on Ftrack. """ if AYON_SERVER_ENABLED: raise RuntimeError( "AYON does not support 'webpublisherwebserver' command." ) PypeCommands().launch_webpublisher_webservercli( upload_dir=upload_dir, executable=executable, host=host, port=port ) @main.command() @click.argument("output_json_path") @click.option("--project", help="Project name", default=None) @click.option("--asset", help="Asset name", default=None) @click.option("--task", help="Task name", default=None) @click.option("--app", help="Application name", default=None) @click.option( "--envgroup", help="Environment group (e.g. \"farm\")", default=None ) def extractenvironments(output_json_path, project, asset, task, app, envgroup): """Extract environment variables for entered context to a json file. Entered output filepath will be created if does not exists. All context options must be passed otherwise only pype's global environments will be extracted. Context options are "project", "asset", "task", "app" """ PypeCommands.extractenvironments( output_json_path, project, asset, task, app, envgroup ) @main.command() @click.argument("paths", nargs=-1) @click.option("-t", "--targets", help="Targets module", default=None, multiple=True) @click.option("-g", "--gui", is_flag=True, help="Show Publish UI", default=False) def publish(paths, targets, gui): """Start CLI publishing. Publish collects json from paths provided as an argument. More than one path is allowed. """ PypeCommands.publish(list(paths), targets, gui) @main.command(context_settings={"ignore_unknown_options": True}) def projectmanager(): if AYON_SERVER_ENABLED: raise RuntimeError("AYON does not support 'projectmanager' command.") PypeCommands().launch_project_manager() @main.command(context_settings={"ignore_unknown_options": True}) def publish_report_viewer(): from openpype.tools.publisher.publish_report_viewer import main sys.exit(main()) @main.command() @click.argument("output_path") @click.option("--project", help="Define project context") @click.option("--asset", help="Define asset in project (project must be set)") @click.option( "--strict", is_flag=True, help="Full context must be set otherwise dialog can't be closed." ) def contextselection( output_path, project, asset, strict ): """Show Qt dialog to select context. Context is project name, asset name and task name. The result is stored into json file which path is passed in first argument. """ PypeCommands.contextselection( output_path, project, asset, strict ) @main.command( context_settings=dict( ignore_unknown_options=True, allow_extra_args=True)) @click.argument("script", required=True, type=click.Path(exists=True)) def run(script): """Run python script in Pype context.""" import runpy if not script: print("Error: missing path to script file.") else: args = sys.argv args.remove("run") args.remove(script) sys.argv = args args_string = " ".join(args[1:]) print(f"... running: {script} {args_string}") runpy.run_path(script, run_name="__main__", ) @main.command() @click.argument("folder", nargs=-1) @click.option("-m", "--mark", help="Run tests marked by", default=None) @click.option("-p", "--pyargs", help="Run tests from package", default=None) @click.option("-t", "--test_data_folder", help="Unzipped directory path of test file", default=None) @click.option("-s", "--persist", help="Persist test DB and published files after test end", default=None) @click.option("-a", "--app_variant", help="Provide specific app variant for test, empty for latest", default=None) @click.option("--app_group", help="Provide specific app group for test, empty for default", default=None) @click.option("-t", "--timeout", help="Provide specific timeout value for test case", default=None) @click.option("-so", "--setup_only", help="Only create dbs, do not run tests", default=None) @click.option("--mongo_url", help="MongoDB for testing.", default=None) @click.option("--dump_databases", help="Dump all databases to data folder.", default=None) def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant, timeout, setup_only, mongo_url, app_group, dump_databases): """Run all automatic tests after proper initialization via start.py""" PypeCommands().run_tests(folder, mark, pyargs, test_data_folder, persist, app_variant, timeout, setup_only, mongo_url, app_group, dump_databases) @main.command(help="DEPRECATED - run sync server") @click.pass_context @click.option("-a", "--active_site", required=True, help="Name of active site") def syncserver(ctx, active_site): """Run sync site server in background. Deprecated: This command is deprecated and will be removed in future versions. Use '~/openpype_console module sync_server syncservice' instead. Details: Some Site Sync use cases need to expose site to another one. For example if majority of artists work in studio, they are not using SS at all, but if you want to expose published assets to 'studio' site to SFTP for only a couple of artists, some background process must mark published assets to live on multiple sites (they might be physically in same location - mounted shared disk). Process mimics OP Tray with specific 'active_site' name, all configuration for this "dummy" user comes from Setting or Local Settings (configured by starting OP Tray with env var OPENPYPE_LOCAL_ID set to 'active_site'. """ if AYON_SERVER_ENABLED: raise RuntimeError("AYON does not support 'syncserver' command.") from openpype.modules.sync_server.sync_server_module import ( syncservice) ctx.invoke(syncservice, active_site=active_site) @main.command() @click.argument("directory") def repack_version(directory): """Repack OpenPype version from directory. This command will re-create zip file from specified directory, recalculating file checksums. It will try to use version detected in directory name. """ if AYON_SERVER_ENABLED: raise RuntimeError("AYON does not support 'repack-version' command.") PypeCommands().repack_version(directory) @main.command() @click.option("--project", help="Project name") @click.option( "--dirpath", help="Directory where package is stored", default=None) @click.option( "--dbonly", help="Store only Database data", default=False, is_flag=True) def pack_project(project, dirpath, dbonly): """Create a package of project with all files and database dump.""" if AYON_SERVER_ENABLED: raise RuntimeError("AYON does not support 'pack-project' command.") PypeCommands().pack_project(project, dirpath, dbonly) @main.command() @click.option("--zipfile", help="Path to zip file") @click.option( "--root", help="Replace root which was stored in project", default=None ) @click.option( "--dbonly", help="Store only Database data", default=False, is_flag=True) def unpack_project(zipfile, root, dbonly): """Create a package of project with all files and database dump.""" if AYON_SERVER_ENABLED: raise RuntimeError("AYON does not support 'unpack-project' command.") PypeCommands().unpack_project(zipfile, root, dbonly) @main.command() def interactive(): """Interactive (Python like) console. Helpful command not only for development to directly work with python interpreter. Warning: Executable 'openpype_gui' on Windows won't work. """ if AYON_SERVER_ENABLED: version = os.environ["AYON_VERSION"] banner = ( f"AYON launcher {version}\nPython {sys.version} on {sys.platform}" ) else: from openpype.version import __version__ banner = ( f"OpenPype {__version__}\nPython {sys.version} on {sys.platform}" ) code.interact(banner) @main.command() @click.option("--build", help="Print only build version", is_flag=True, default=False) def version(build): """Print OpenPype version.""" if AYON_SERVER_ENABLED: print(os.environ["AYON_VERSION"]) return from openpype.version import __version__ from igniter.bootstrap_repos import BootstrapRepos, OpenPypeVersion from pathlib import Path if getattr(sys, 'frozen', False): local_version = BootstrapRepos.get_version( Path(os.getenv("OPENPYPE_ROOT"))) else: local_version = OpenPypeVersion.get_installed_version_str() if build: print(local_version) return print(f"{__version__} (booted: {local_version})") ================================================ FILE: openpype/client/__init__.py ================================================ from .mongo import ( OpenPypeMongoConnection, ) from .server.utils import get_ayon_server_api_connection from .entities import ( get_projects, get_project, get_whole_project, get_asset_by_id, get_asset_by_name, get_assets, get_archived_assets, get_asset_ids_with_subsets, get_subset_by_id, get_subset_by_name, get_subsets, get_subset_families, get_version_by_id, get_version_by_name, get_versions, get_hero_version_by_id, get_hero_version_by_subset_id, get_hero_versions, get_last_versions, get_last_version_by_subset_id, get_last_version_by_subset_name, get_output_link_versions, version_is_latest, get_representation_by_id, get_representation_by_name, get_representations, get_representation_parents, get_representations_parents, get_archived_representations, get_thumbnail, get_thumbnails, get_thumbnail_id_from_source, get_workfile_info, get_asset_name_identifier, ) from .entity_links import ( get_linked_asset_ids, get_linked_assets, get_linked_representation_id, ) from .operations import ( create_project, ) __all__ = ( "OpenPypeMongoConnection", "get_ayon_server_api_connection", "get_projects", "get_project", "get_whole_project", "get_asset_by_id", "get_asset_by_name", "get_assets", "get_archived_assets", "get_asset_ids_with_subsets", "get_subset_by_id", "get_subset_by_name", "get_subsets", "get_subset_families", "get_version_by_id", "get_version_by_name", "get_versions", "get_hero_version_by_id", "get_hero_version_by_subset_id", "get_hero_versions", "get_last_versions", "get_last_version_by_subset_id", "get_last_version_by_subset_name", "get_output_link_versions", "version_is_latest", "get_representation_by_id", "get_representation_by_name", "get_representations", "get_representation_parents", "get_representations_parents", "get_archived_representations", "get_thumbnail", "get_thumbnails", "get_thumbnail_id_from_source", "get_workfile_info", "get_linked_asset_ids", "get_linked_assets", "get_linked_representation_id", "create_project", "get_asset_name_identifier", ) ================================================ FILE: openpype/client/entities.py ================================================ from openpype import AYON_SERVER_ENABLED if not AYON_SERVER_ENABLED: from .mongo.entities import * else: from .server.entities import * def get_asset_name_identifier(asset_doc): """Get asset name identifier by asset document. This function is added because of AYON implementation where name identifier is not just a name but full path. Asset document must have "name" key, and "data.parents" when in AYON mode. Args: asset_doc (dict[str, Any]): Asset document. """ if not AYON_SERVER_ENABLED: return asset_doc["name"] parents = list(asset_doc["data"]["parents"]) parents.append(asset_doc["name"]) return "/" + "/".join(parents) ================================================ FILE: openpype/client/entity_links.py ================================================ from openpype import AYON_SERVER_ENABLED if not AYON_SERVER_ENABLED: from .mongo.entity_links import * else: from .server.entity_links import * ================================================ FILE: openpype/client/mongo/__init__.py ================================================ from .mongo import ( MongoEnvNotSet, get_default_components, should_add_certificate_path_to_mongo_url, validate_mongo_connection, OpenPypeMongoConnection, get_project_database, get_project_connection, load_json_file, replace_project_documents, store_project_documents, ) __all__ = ( "MongoEnvNotSet", "get_default_components", "should_add_certificate_path_to_mongo_url", "validate_mongo_connection", "OpenPypeMongoConnection", "get_project_database", "get_project_connection", "load_json_file", "replace_project_documents", "store_project_documents", ) ================================================ FILE: openpype/client/mongo/entities.py ================================================ """Unclear if these will have public functions like these. Goal is that most of functions here are called on (or with) an object that has project name as a context (e.g. on 'ProjectEntity'?). + We will need more specific functions doing very specific queries really fast. """ import re import collections import six from bson.objectid import ObjectId from .mongo import get_project_database, get_project_connection PatternType = type(re.compile("")) def _prepare_fields(fields, required_fields=None): if not fields: return None output = { field: True for field in fields } if "_id" not in output: output["_id"] = True if required_fields: for key in required_fields: output[key] = True return output def convert_id(in_id): """Helper function for conversion of id from string to ObjectId. Args: in_id (Union[str, ObjectId, Any]): Entity id that should be converted to right type for queries. Returns: Union[ObjectId, Any]: Converted ids to ObjectId or in type. """ if isinstance(in_id, six.string_types): return ObjectId(in_id) return in_id def convert_ids(in_ids): """Helper function for conversion of ids from string to ObjectId. Args: in_ids (Iterable[Union[str, ObjectId, Any]]): List of entity ids that should be converted to right type for queries. Returns: List[ObjectId]: Converted ids to ObjectId. """ _output = set() for in_id in in_ids: if in_id is not None: _output.add(convert_id(in_id)) return list(_output) def get_projects(active=True, inactive=False, fields=None): """Yield all project entity documents. Args: active (Optional[bool]): Include active projects. Defaults to True. inactive (Optional[bool]): Include inactive projects. Defaults to False. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Yields: dict: Project entity data which can be reduced to specified 'fields'. None is returned if project with specified filters was not found. """ mongodb = get_project_database() for project_name in mongodb.collection_names(): if project_name in ("system.indexes",): continue project_doc = get_project( project_name, active=active, inactive=inactive, fields=fields ) if project_doc is not None: yield project_doc def get_project(project_name, active=True, inactive=True, fields=None): """Return project entity document by project name. Args: project_name (str): Name of project. active (Optional[bool]): Allow active project. Defaults to True. inactive (Optional[bool]): Allow inactive project. Defaults to True. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Project entity data which can be reduced to specified 'fields'. None is returned if project with specified filters was not found. """ # Skip if both are disabled if not active and not inactive: return None query_filter = {"type": "project"} # Keep query untouched if both should be available if active and inactive: pass # Add filter to keep only active elif active: query_filter["$or"] = [ {"data.active": {"$exists": False}}, {"data.active": True}, ] # Add filter to keep only inactive elif inactive: query_filter["$or"] = [ {"data.active": {"$exists": False}}, {"data.active": False}, ] conn = get_project_connection(project_name) return conn.find_one(query_filter, _prepare_fields(fields)) def get_whole_project(project_name): """Receive all documents from project. Helper that can be used to get all document from whole project. For example for backups etc. Returns: Cursor: Query cursor as iterable which returns all documents from project collection. """ conn = get_project_connection(project_name) return conn.find({}) def get_asset_by_id(project_name, asset_id, fields=None): """Receive asset data by its id. Args: project_name (str): Name of project where to look for queried entities. asset_id (Union[str, ObjectId]): Asset's id. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Asset entity data which can be reduced to specified 'fields'. None is returned if asset with specified filters was not found. """ asset_id = convert_id(asset_id) if not asset_id: return None query_filter = {"type": "asset", "_id": asset_id} conn = get_project_connection(project_name) return conn.find_one(query_filter, _prepare_fields(fields)) def get_asset_by_name(project_name, asset_name, fields=None): """Receive asset data by its name. Args: project_name (str): Name of project where to look for queried entities. asset_name (str): Asset's name. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Asset entity data which can be reduced to specified 'fields'. None is returned if asset with specified filters was not found. """ if not asset_name: return None query_filter = {"type": "asset", "name": asset_name} conn = get_project_connection(project_name) return conn.find_one(query_filter, _prepare_fields(fields)) # NOTE this could be just public function? # - any better variable name instead of 'standard'? # - same approach can be used for rest of types def _get_assets( project_name, asset_ids=None, asset_names=None, parent_ids=None, standard=True, archived=False, fields=None ): """Assets for specified project by passed filters. Passed filters (ids and names) are always combined so all conditions must match. To receive all assets from project just keep filters empty. Args: project_name (str): Name of project where to look for queried entities. asset_ids (Iterable[Union[str, ObjectId]]): Asset ids that should be found. asset_names (Iterable[str]): Name assets that should be found. parent_ids (Iterable[Union[str, ObjectId]]): Parent asset ids. standard (bool): Query standard assets (type 'asset'). archived (bool): Query archived assets (type 'archived_asset'). fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Cursor: Query cursor as iterable which returns asset documents matching passed filters. """ asset_types = [] if standard: asset_types.append("asset") if archived: asset_types.append("archived_asset") if not asset_types: return [] if len(asset_types) == 1: query_filter = {"type": asset_types[0]} else: query_filter = {"type": {"$in": asset_types}} if asset_ids is not None: asset_ids = convert_ids(asset_ids) if not asset_ids: return [] query_filter["_id"] = {"$in": asset_ids} if asset_names is not None: if not asset_names: return [] query_filter["name"] = {"$in": list(asset_names)} if parent_ids is not None: parent_ids = convert_ids(parent_ids) if not parent_ids: return [] query_filter["data.visualParent"] = {"$in": parent_ids} conn = get_project_connection(project_name) return conn.find(query_filter, _prepare_fields(fields)) def get_assets( project_name, asset_ids=None, asset_names=None, parent_ids=None, archived=False, fields=None ): """Assets for specified project by passed filters. Passed filters (ids and names) are always combined so all conditions must match. To receive all assets from project just keep filters empty. Args: project_name (str): Name of project where to look for queried entities. asset_ids (Iterable[Union[str, ObjectId]]): Asset ids that should be found. asset_names (Iterable[str]): Name assets that should be found. parent_ids (Iterable[Union[str, ObjectId]]): Parent asset ids. archived (bool): Add also archived assets. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Cursor: Query cursor as iterable which returns asset documents matching passed filters. """ return _get_assets( project_name, asset_ids, asset_names, parent_ids, True, archived, fields ) def get_archived_assets( project_name, asset_ids=None, asset_names=None, parent_ids=None, fields=None ): """Archived assets for specified project by passed filters. Passed filters (ids and names) are always combined so all conditions must match. To receive all archived assets from project just keep filters empty. Args: project_name (str): Name of project where to look for queried entities. asset_ids (Iterable[Union[str, ObjectId]]): Asset ids that should be found. asset_names (Iterable[str]): Name assets that should be found. parent_ids (Iterable[Union[str, ObjectId]]): Parent asset ids. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Cursor: Query cursor as iterable which returns asset documents matching passed filters. """ return _get_assets( project_name, asset_ids, asset_names, parent_ids, False, True, fields ) def get_asset_ids_with_subsets(project_name, asset_ids=None): """Find out which assets have existing subsets. Args: project_name (str): Name of project where to look for queried entities. asset_ids (Iterable[Union[str, ObjectId]]): Look only for entered asset ids. Returns: Iterable[ObjectId]: Asset ids that have existing subsets. """ subset_query = { "type": "subset" } if asset_ids is not None: asset_ids = convert_ids(asset_ids) if not asset_ids: return [] subset_query["parent"] = {"$in": asset_ids} conn = get_project_connection(project_name) result = conn.aggregate([ { "$match": subset_query }, { "$group": { "_id": "$parent", "count": {"$sum": 1} } } ]) asset_ids_with_subsets = [] for item in result: asset_id = item["_id"] count = item["count"] if count > 0: asset_ids_with_subsets.append(asset_id) return asset_ids_with_subsets def get_subset_by_id(project_name, subset_id, fields=None): """Single subset entity data by its id. Args: project_name (str): Name of project where to look for queried entities. subset_id (Union[str, ObjectId]): Id of subset which should be found. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Subset entity data which can be reduced to specified 'fields'. None is returned if subset with specified filters was not found. """ subset_id = convert_id(subset_id) if not subset_id: return None query_filters = {"type": "subset", "_id": subset_id} conn = get_project_connection(project_name) return conn.find_one(query_filters, _prepare_fields(fields)) def get_subset_by_name(project_name, subset_name, asset_id, fields=None): """Single subset entity data by its name and its version id. Args: project_name (str): Name of project where to look for queried entities. subset_name (str): Name of subset. asset_id (Union[str, ObjectId]): Id of parent asset. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Subset entity data which can be reduced to specified 'fields'. None is returned if subset with specified filters was not found. """ if not subset_name: return None asset_id = convert_id(asset_id) if not asset_id: return None query_filters = { "type": "subset", "name": subset_name, "parent": asset_id } conn = get_project_connection(project_name) return conn.find_one(query_filters, _prepare_fields(fields)) def get_subsets( project_name, subset_ids=None, subset_names=None, asset_ids=None, names_by_asset_ids=None, archived=False, fields=None ): """Subset entities data from one project filtered by entered filters. Filters are additive (all conditions must pass to return subset). Args: project_name (str): Name of project where to look for queried entities. subset_ids (Iterable[Union[str, ObjectId]]): Subset ids that should be queried. Filter ignored if 'None' is passed. subset_names (Iterable[str]): Subset names that should be queried. Filter ignored if 'None' is passed. asset_ids (Iterable[Union[str, ObjectId]]): Asset ids under which should look for the subsets. Filter ignored if 'None' is passed. names_by_asset_ids (dict[ObjectId, List[str]]): Complex filtering using asset ids and list of subset names under the asset. archived (bool): Look for archived subsets too. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Cursor: Iterable cursor yielding all matching subsets. """ subset_types = ["subset"] if archived: subset_types.append("archived_subset") if len(subset_types) == 1: query_filter = {"type": subset_types[0]} else: query_filter = {"type": {"$in": subset_types}} if asset_ids is not None: asset_ids = convert_ids(asset_ids) if not asset_ids: return [] query_filter["parent"] = {"$in": asset_ids} if subset_ids is not None: subset_ids = convert_ids(subset_ids) if not subset_ids: return [] query_filter["_id"] = {"$in": subset_ids} if subset_names is not None: if not subset_names: return [] query_filter["name"] = {"$in": list(subset_names)} if names_by_asset_ids is not None: or_query = [] for asset_id, names in names_by_asset_ids.items(): if asset_id and names: or_query.append({ "parent": convert_id(asset_id), "name": {"$in": list(names)} }) if not or_query: return [] query_filter["$or"] = or_query conn = get_project_connection(project_name) return conn.find(query_filter, _prepare_fields(fields)) def get_subset_families(project_name, subset_ids=None): """Set of main families of subsets. Args: project_name (str): Name of project where to look for queried entities. subset_ids (Iterable[Union[str, ObjectId]]): Subset ids that should be queried. All subsets from project are used if 'None' is passed. Returns: set[str]: Main families of matching subsets. """ subset_filter = { "type": "subset" } if subset_ids is not None: if not subset_ids: return set() subset_filter["_id"] = {"$in": list(subset_ids)} conn = get_project_connection(project_name) result = list(conn.aggregate([ {"$match": subset_filter}, {"$project": { "family": {"$arrayElemAt": ["$data.families", 0]} }}, {"$group": { "_id": "family_group", "families": {"$addToSet": "$family"} }} ])) if result: return set(result[0]["families"]) return set() def get_version_by_id(project_name, version_id, fields=None): """Single version entity data by its id. Args: project_name (str): Name of project where to look for queried entities. version_id (Union[str, ObjectId]): Id of version which should be found. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Version entity data which can be reduced to specified 'fields'. None is returned if version with specified filters was not found. """ version_id = convert_id(version_id) if not version_id: return None query_filter = { "type": {"$in": ["version", "hero_version"]}, "_id": version_id } conn = get_project_connection(project_name) return conn.find_one(query_filter, _prepare_fields(fields)) def get_version_by_name(project_name, version, subset_id, fields=None): """Single version entity data by its name and subset id. Args: project_name (str): Name of project where to look for queried entities. version (int): name of version entity (its version). subset_id (Union[str, ObjectId]): Id of version which should be found. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Version entity data which can be reduced to specified 'fields'. None is returned if version with specified filters was not found. """ subset_id = convert_id(subset_id) if not subset_id: return None conn = get_project_connection(project_name) query_filter = { "type": "version", "parent": subset_id, "name": version } return conn.find_one(query_filter, _prepare_fields(fields)) def version_is_latest(project_name, version_id): """Is version the latest from its subset. Note: Hero versions are considered as latest. Todo: Maybe raise exception when version was not found? Args: project_name (str):Name of project where to look for queried entities. version_id (Union[str, ObjectId]): Version id which is checked. Returns: bool: True if is latest version from subset else False. """ version_id = convert_id(version_id) if not version_id: return False version_doc = get_version_by_id( project_name, version_id, fields=["_id", "type", "parent"] ) # What to do when version is not found? if not version_doc: return False if version_doc["type"] == "hero_version": return True last_version = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] ) return last_version["_id"] == version_id def _get_versions( project_name, subset_ids=None, version_ids=None, versions=None, standard=True, hero=False, fields=None ): version_types = [] if standard: version_types.append("version") if hero: version_types.append("hero_version") if not version_types: return [] elif len(version_types) == 1: query_filter = {"type": version_types[0]} else: query_filter = {"type": {"$in": version_types}} if subset_ids is not None: subset_ids = convert_ids(subset_ids) if not subset_ids: return [] query_filter["parent"] = {"$in": subset_ids} if version_ids is not None: version_ids = convert_ids(version_ids) if not version_ids: return [] query_filter["_id"] = {"$in": version_ids} if versions is not None: versions = list(versions) if not versions: return [] if len(versions) == 1: query_filter["name"] = versions[0] else: query_filter["name"] = {"$in": versions} conn = get_project_connection(project_name) return conn.find(query_filter, _prepare_fields(fields)) def get_versions( project_name, version_ids=None, subset_ids=None, versions=None, hero=False, fields=None ): """Version entities data from one project filtered by entered filters. Filters are additive (all conditions must pass to return subset). Args: project_name (str): Name of project where to look for queried entities. version_ids (Iterable[Union[str, ObjectId]]): Version ids that will be queried. Filter ignored if 'None' is passed. subset_ids (Iterable[str]): Subset ids that will be queried. Filter ignored if 'None' is passed. versions (Iterable[int]): Version names (as integers). Filter ignored if 'None' is passed. hero (bool): Look also for hero versions. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Cursor: Iterable cursor yielding all matching versions. """ return _get_versions( project_name, subset_ids, version_ids, versions, standard=True, hero=hero, fields=fields ) def get_hero_version_by_subset_id(project_name, subset_id, fields=None): """Hero version by subset id. Args: project_name (str): Name of project where to look for queried entities. subset_id (Union[str, ObjectId]): Subset id under which is hero version. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Hero version entity data which can be reduced to specified 'fields'. None is returned if hero version with specified filters was not found. """ subset_id = convert_id(subset_id) if not subset_id: return None versions = list(_get_versions( project_name, subset_ids=[subset_id], standard=False, hero=True, fields=fields )) if versions: return versions[0] return None def get_hero_version_by_id(project_name, version_id, fields=None): """Hero version by its id. Args: project_name (str): Name of project where to look for queried entities. version_id (Union[str, ObjectId]): Hero version id. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Hero version entity data which can be reduced to specified 'fields'. None is returned if hero version with specified filters was not found. """ version_id = convert_id(version_id) if not version_id: return None versions = list(_get_versions( project_name, version_ids=[version_id], standard=False, hero=True, fields=fields )) if versions: return versions[0] return None def get_hero_versions( project_name, subset_ids=None, version_ids=None, fields=None ): """Hero version entities data from one project filtered by entered filters. Args: project_name (str): Name of project where to look for queried entities. subset_ids (Iterable[Union[str, ObjectId]]): Subset ids for which should look for hero versions. Filter ignored if 'None' is passed. version_ids (Iterable[Union[str, ObjectId]]): Hero version ids. Filter ignored if 'None' is passed. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Cursor|list: Iterable yielding hero versions matching passed filters. """ return _get_versions( project_name, subset_ids, version_ids, standard=False, hero=True, fields=fields ) def get_output_link_versions(project_name, version_id, fields=None): """Versions where passed version was used as input. Question: Not 100% sure about the usage of the function so the name and docstring maybe does not match what it does? Args: project_name (str): Name of project where to look for queried entities. version_id (Union[str, ObjectId]): Version id which can be used as input link for other versions. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Iterable: Iterable cursor yielding versions that are used as input links for passed version. """ version_id = convert_id(version_id) if not version_id: return [] conn = get_project_connection(project_name) # Does make sense to look for hero versions? query_filter = { "type": "version", "data.inputLinks.id": version_id } return conn.find(query_filter, _prepare_fields(fields)) def get_last_versions(project_name, subset_ids, active=None, fields=None): """Latest versions for entered subset_ids. Args: project_name (str): Name of project where to look for queried entities. subset_ids (Iterable[Union[str, ObjectId]]): List of subset ids. active (Optional[bool]): If True only active versions are returned. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: dict[ObjectId, int]: Key is subset id and value is last version name. """ subset_ids = convert_ids(subset_ids) if not subset_ids: return {} if fields is not None: fields = list(fields) if not fields: return {} # Avoid double query if only name and _id are requested name_needed = False limit_query = False if fields: fields_s = set(fields) if "name" in fields_s: name_needed = True fields_s.remove("name") for field in ("_id", "parent"): if field in fields_s: fields_s.remove(field) limit_query = len(fields_s) == 0 group_item = { "_id": "$parent", "_version_id": {"$last": "$_id"} } # Add name if name is needed (only for limit query) if name_needed: group_item["name"] = {"$last": "$name"} aggregate_filter = { "type": "version", "parent": {"$in": subset_ids} } if active is False: aggregate_filter["data.active"] = active elif active is True: aggregate_filter["$or"] = [ {"data.active": {"$exists": 0}}, {"data.active": active}, ] aggregation_pipeline = [ # Find all versions of those subsets {"$match": aggregate_filter}, # Sorting versions all together {"$sort": {"name": 1}}, # Group them by "parent", but only take the last {"$group": group_item} ] conn = get_project_connection(project_name) aggregate_result = conn.aggregate(aggregation_pipeline) if limit_query: output = {} for item in aggregate_result: subset_id = item["_id"] item_data = {"_id": item["_version_id"], "parent": subset_id} if name_needed: item_data["name"] = item["name"] output[subset_id] = item_data return output version_ids = [ doc["_version_id"] for doc in aggregate_result ] fields = _prepare_fields(fields, ["parent"]) version_docs = get_versions( project_name, version_ids=version_ids, fields=fields ) return { version_doc["parent"]: version_doc for version_doc in version_docs } def get_last_version_by_subset_id(project_name, subset_id, fields=None): """Last version for passed subset id. Args: project_name (str): Name of project where to look for queried entities. subset_id (Union[str, ObjectId]): Id of version which should be found. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Version entity data which can be reduced to specified 'fields'. None is returned if version with specified filters was not found. """ subset_id = convert_id(subset_id) if not subset_id: return None last_versions = get_last_versions( project_name, subset_ids=[subset_id], fields=fields ) return last_versions.get(subset_id) def get_last_version_by_subset_name( project_name, subset_name, asset_id=None, asset_name=None, fields=None ): """Last version for passed subset name under asset id/name. It is required to pass 'asset_id' or 'asset_name'. Asset id is recommended if is available. Args: project_name (str): Name of project where to look for queried entities. subset_name (str): Name of subset. asset_id (Union[str, ObjectId]): Asset id which is parent of passed subset name. asset_name (str): Asset name which is parent of passed subset name. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Version entity data which can be reduced to specified 'fields'. None is returned if version with specified filters was not found. """ if not asset_id and not asset_name: return None if not asset_id: asset_doc = get_asset_by_name(project_name, asset_name, fields=["_id"]) if not asset_doc: return None asset_id = asset_doc["_id"] subset_doc = get_subset_by_name( project_name, subset_name, asset_id, fields=["_id"] ) if not subset_doc: return None return get_last_version_by_subset_id( project_name, subset_doc["_id"], fields=fields ) def get_representation_by_id(project_name, representation_id, fields=None): """Representation entity data by its id. Args: project_name (str): Name of project where to look for queried entities. representation_id (Union[str, ObjectId]): Representation id. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Representation entity data which can be reduced to specified 'fields'. None is returned if representation with specified filters was not found. """ if not representation_id: return None repre_types = ["representation", "archived_representation"] query_filter = { "type": {"$in": repre_types} } if representation_id is not None: query_filter["_id"] = convert_id(representation_id) conn = get_project_connection(project_name) return conn.find_one(query_filter, _prepare_fields(fields)) def get_representation_by_name( project_name, representation_name, version_id, fields=None ): """Representation entity data by its name and its version id. Args: project_name (str): Name of project where to look for queried entities. representation_name (str): Representation name. version_id (Union[str, ObjectId]): Id of parent version entity. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[dict[str, Any], None]: Representation entity data which can be reduced to specified 'fields'. None is returned if representation with specified filters was not found. """ version_id = convert_id(version_id) if not version_id or not representation_name: return None repre_types = ["representation", "archived_representations"] query_filter = { "type": {"$in": repre_types}, "name": representation_name, "parent": version_id } conn = get_project_connection(project_name) return conn.find_one(query_filter, _prepare_fields(fields)) def _flatten_dict(data): flatten_queue = collections.deque() flatten_queue.append(data) output = {} while flatten_queue: item = flatten_queue.popleft() for key, value in item.items(): if not isinstance(value, dict): output[key] = value continue tmp = {} for subkey, subvalue in value.items(): new_key = "{}.{}".format(key, subkey) tmp[new_key] = subvalue flatten_queue.append(tmp) return output def _regex_filters(filters): output = [] for key, value in filters.items(): regexes = [] a_values = [] if isinstance(value, PatternType): regexes.append(value) elif isinstance(value, (list, tuple, set)): for item in value: if isinstance(item, PatternType): regexes.append(item) else: a_values.append(item) else: a_values.append(value) key_filters = [] if len(a_values) == 1: key_filters.append({key: a_values[0]}) elif a_values: key_filters.append({key: {"$in": a_values}}) for regex in regexes: key_filters.append({key: {"$regex": regex}}) if len(key_filters) == 1: output.append(key_filters[0]) else: output.append({"$or": key_filters}) return output def _get_representations( project_name, representation_ids, representation_names, version_ids, context_filters, names_by_version_ids, standard, archived, fields ): default_output = [] repre_types = [] if standard: repre_types.append("representation") if archived: repre_types.append("archived_representation") if not repre_types: return default_output if len(repre_types) == 1: query_filter = {"type": repre_types[0]} else: query_filter = {"type": {"$in": repre_types}} if representation_ids is not None: representation_ids = convert_ids(representation_ids) if not representation_ids: return default_output query_filter["_id"] = {"$in": representation_ids} if representation_names is not None: if not representation_names: return default_output query_filter["name"] = {"$in": list(representation_names)} if version_ids is not None: version_ids = convert_ids(version_ids) if not version_ids: return default_output query_filter["parent"] = {"$in": version_ids} or_queries = [] if names_by_version_ids is not None: or_query = [] for version_id, names in names_by_version_ids.items(): if version_id and names: or_query.append({ "parent": convert_id(version_id), "name": {"$in": list(names)} }) if not or_query: return default_output or_queries.append(or_query) if context_filters is not None: if not context_filters: return [] _flatten_filters = _flatten_dict(context_filters) flatten_filters = {} for key, value in _flatten_filters.items(): if not key.startswith("context"): key = "context.{}".format(key) flatten_filters[key] = value for item in _regex_filters(flatten_filters): for key, value in item.items(): if key != "$or": query_filter[key] = value elif value: or_queries.append(value) if len(or_queries) == 1: query_filter["$or"] = or_queries[0] elif or_queries: and_query = [] for or_query in or_queries: if isinstance(or_query, list): or_query = {"$or": or_query} and_query.append(or_query) query_filter["$and"] = and_query conn = get_project_connection(project_name) return conn.find(query_filter, _prepare_fields(fields)) def get_representations( project_name, representation_ids=None, representation_names=None, version_ids=None, context_filters=None, names_by_version_ids=None, archived=False, standard=True, fields=None ): """Representation entities data from one project filtered by filters. Filters are additive (all conditions must pass to return subset). Args: project_name (str): Name of project where to look for queried entities. representation_ids (Iterable[Union[str, ObjectId]]): Representation ids used as filter. Filter ignored if 'None' is passed. representation_names (Iterable[str]): Representations names used as filter. Filter ignored if 'None' is passed. version_ids (Iterable[str]): Subset ids used as parent filter. Filter ignored if 'None' is passed. context_filters (Dict[str, List[str, PatternType]]): Filter by representation context fields. names_by_version_ids (dict[ObjectId, list[str]]): Complex filtering using version ids and list of names under the version. archived (bool): Output will also contain archived representations. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Cursor: Iterable cursor yielding all matching representations. """ return _get_representations( project_name=project_name, representation_ids=representation_ids, representation_names=representation_names, version_ids=version_ids, context_filters=context_filters, names_by_version_ids=names_by_version_ids, standard=standard, archived=archived, fields=fields ) def get_archived_representations( project_name, representation_ids=None, representation_names=None, version_ids=None, context_filters=None, names_by_version_ids=None, fields=None ): """Archived representation entities data from project with applied filters. Filters are additive (all conditions must pass to return subset). Args: project_name (str): Name of project where to look for queried entities. representation_ids (Iterable[Union[str, ObjectId]]): Representation ids used as filter. Filter ignored if 'None' is passed. representation_names (Iterable[str]): Representations names used as filter. Filter ignored if 'None' is passed. version_ids (Iterable[str]): Subset ids used as parent filter. Filter ignored if 'None' is passed. context_filters (Dict[str, List[str, PatternType]]): Filter by representation context fields. names_by_version_ids (dict[ObjectId, List[str]]): Complex filtering using version ids and list of names under the version. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Cursor: Iterable cursor yielding all matching representations. """ return _get_representations( project_name=project_name, representation_ids=representation_ids, representation_names=representation_names, version_ids=version_ids, context_filters=context_filters, names_by_version_ids=names_by_version_ids, standard=False, archived=True, fields=fields ) def get_representations_parents(project_name, representations): """Prepare parents of representation entities. Each item of returned dictionary contains version, subset, asset and project in that order. Args: project_name (str): Name of project where to look for queried entities. representations (List[dict]): Representation entities with at least '_id' and 'parent' keys. Returns: dict[ObjectId, tuple]: Parents by representation id. """ repre_docs_by_version_id = collections.defaultdict(list) version_docs_by_version_id = {} version_docs_by_subset_id = collections.defaultdict(list) subset_docs_by_subset_id = {} subset_docs_by_asset_id = collections.defaultdict(list) output = {} for repre_doc in representations: repre_id = repre_doc["_id"] version_id = repre_doc["parent"] output[repre_id] = (None, None, None, None) repre_docs_by_version_id[version_id].append(repre_doc) version_docs = get_versions( project_name, version_ids=repre_docs_by_version_id.keys(), hero=True ) for version_doc in version_docs: version_id = version_doc["_id"] subset_id = version_doc["parent"] version_docs_by_version_id[version_id] = version_doc version_docs_by_subset_id[subset_id].append(version_doc) subset_docs = get_subsets( project_name, subset_ids=version_docs_by_subset_id.keys() ) for subset_doc in subset_docs: subset_id = subset_doc["_id"] asset_id = subset_doc["parent"] subset_docs_by_subset_id[subset_id] = subset_doc subset_docs_by_asset_id[asset_id].append(subset_doc) asset_docs = get_assets( project_name, asset_ids=subset_docs_by_asset_id.keys() ) asset_docs_by_id = { asset_doc["_id"]: asset_doc for asset_doc in asset_docs } project_doc = get_project(project_name) for version_id, repre_docs in repre_docs_by_version_id.items(): asset_doc = None subset_doc = None version_doc = version_docs_by_version_id.get(version_id) if version_doc: subset_id = version_doc["parent"] subset_doc = subset_docs_by_subset_id.get(subset_id) if subset_doc: asset_id = subset_doc["parent"] asset_doc = asset_docs_by_id.get(asset_id) for repre_doc in repre_docs: repre_id = repre_doc["_id"] output[repre_id] = ( version_doc, subset_doc, asset_doc, project_doc ) return output def get_representation_parents(project_name, representation): """Prepare parents of representation entity. Each item of returned dictionary contains version, subset, asset and project in that order. Args: project_name (str): Name of project where to look for queried entities. representation (dict): Representation entities with at least '_id' and 'parent' keys. Returns: dict[ObjectId, tuple]: Parents by representation id. """ if not representation: return None repre_id = representation["_id"] parents_by_repre_id = get_representations_parents( project_name, [representation] ) return parents_by_repre_id[repre_id] def get_thumbnail_id_from_source(project_name, src_type, src_id): """Receive thumbnail id from source entity. Args: project_name (str): Name of project where to look for queried entities. src_type (str): Type of source entity ('asset', 'version'). src_id (Union[str, ObjectId]): Id of source entity. Returns: Union[ObjectId, None]: Thumbnail id assigned to entity. If Source entity does not have any thumbnail id assigned. """ if not src_type or not src_id: return None query_filter = {"_id": convert_id(src_id)} conn = get_project_connection(project_name) src_doc = conn.find_one(query_filter, {"data.thumbnail_id"}) if src_doc: return src_doc.get("data", {}).get("thumbnail_id") return None def get_thumbnails(project_name, thumbnail_ids, fields=None): """Receive thumbnails entity data. Thumbnail entity can be used to receive binary content of thumbnail based on its content and ThumbnailResolvers. Args: project_name (str): Name of project where to look for queried entities. thumbnail_ids (Iterable[Union[str, ObjectId]]): Ids of thumbnail entities. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: cursor: Cursor of queried documents. """ if thumbnail_ids: thumbnail_ids = convert_ids(thumbnail_ids) if not thumbnail_ids: return [] query_filter = { "type": "thumbnail", "_id": {"$in": thumbnail_ids} } conn = get_project_connection(project_name) return conn.find(query_filter, _prepare_fields(fields)) def get_thumbnail( project_name, thumbnail_id, entity_type, entity_id, fields=None ): """Receive thumbnail entity data. Args: project_name (str): Name of project where to look for queried entities. thumbnail_id (Union[str, ObjectId]): Id of thumbnail entity. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Thumbnail entity data which can be reduced to specified 'fields'.None is returned if thumbnail with specified filters was not found. """ if not thumbnail_id: return None query_filter = {"type": "thumbnail", "_id": convert_id(thumbnail_id)} conn = get_project_connection(project_name) return conn.find_one(query_filter, _prepare_fields(fields)) def get_workfile_info( project_name, asset_id, task_name, filename, fields=None ): """Document with workfile information. Warning: Query is based on filename and context which does not meant it will find always right and expected result. Information have limited usage and is not recommended to use it as source information about workfile. Args: project_name (str): Name of project where to look for queried entities. asset_id (Union[str, ObjectId]): Id of asset entity. task_name (str): Task name on asset. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: Union[Dict, None]: Workfile entity data which can be reduced to specified 'fields'.None is returned if workfile with specified filters was not found. """ if not asset_id or not task_name or not filename: return None query_filter = { "type": "workfile", "parent": convert_id(asset_id), "task_name": task_name, "filename": filename } conn = get_project_connection(project_name) return conn.find_one(query_filter, _prepare_fields(fields)) """ ## Custom data storage: - Settings - OP settings overrides and local settings - Logging - logs from Logger - Webpublisher - jobs - Ftrack - events - Maya - Shaders - openpype/hosts/maya/api/shader_definition_editor.py - openpype/hosts/maya/plugins/publish/validate_model_name.py ## Global publish plugins - openpype/plugins/publish/extract_hierarchy_avalon.py Create: - asset Update: - asset ## Lib - openpype/lib/avalon_context.py Update: - workfile data - openpype/lib/project_backpack.py Update: - project """ ================================================ FILE: openpype/client/mongo/entity_links.py ================================================ from .mongo import get_project_connection from .entities import ( get_assets, get_asset_by_id, get_version_by_id, get_representation_by_id, convert_id, ) def get_linked_asset_ids(project_name, asset_doc=None, asset_id=None): """Extract linked asset ids from asset document. One of asset document or asset id must be passed. Note: Asset links now works only from asset to assets. Args: asset_doc (dict): Asset document from DB. Returns: List[Union[ObjectId, str]]: Asset ids of input links. """ output = [] if not asset_doc and not asset_id: return output if not asset_doc: asset_doc = get_asset_by_id( project_name, asset_id, fields=["data.inputLinks"] ) input_links = asset_doc["data"].get("inputLinks") if not input_links: return output for item in input_links: # Backwards compatibility for "_id" key which was replaced with # "id" if "_id" in item: link_id = item["_id"] else: link_id = item["id"] output.append(link_id) return output def get_linked_assets( project_name, asset_doc=None, asset_id=None, fields=None ): """Return linked assets based on passed asset document. One of asset document or asset id must be passed. Args: project_name (str): Name of project where to look for queried entities. asset_doc (Dict[str, Any]): Asset document from database. asset_id (Union[ObjectId, str]): Asset id. Can be used instead of asset document. fields (Iterable[str]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: List[Dict[str, Any]]: Asset documents of input links for passed asset doc. """ if not asset_doc: if not asset_id: return [] asset_doc = get_asset_by_id( project_name, asset_id, fields=["data.inputLinks"] ) if not asset_doc: return [] link_ids = get_linked_asset_ids(project_name, asset_doc=asset_doc) if not link_ids: return [] return list(get_assets(project_name, asset_ids=link_ids, fields=fields)) def get_linked_representation_id( project_name, repre_doc=None, repre_id=None, link_type=None, max_depth=None ): """Returns list of linked ids of particular type (if provided). One of representation document or representation id must be passed. Note: Representation links now works only from representation through version back to representations. Args: project_name (str): Name of project where look for links. repre_doc (Dict[str, Any]): Representation document. repre_id (Union[ObjectId, str]): Representation id. link_type (str): Type of link (e.g. 'reference', ...). max_depth (int): Limit recursion level. Default: 0 Returns: List[ObjectId] Linked representation ids. """ if repre_doc: repre_id = repre_doc["_id"] if repre_id: repre_id = convert_id(repre_id) if not repre_id and not repre_doc: return [] version_id = None if repre_doc: version_id = repre_doc.get("parent") if not version_id: repre_doc = get_representation_by_id( project_name, repre_id, fields=["parent"] ) version_id = repre_doc["parent"] if not version_id: return [] version_doc = get_version_by_id( project_name, version_id, fields=["type", "version_id"] ) if version_doc["type"] == "hero_version": version_id = version_doc["version_id"] if max_depth is None: max_depth = 0 match = { "_id": version_id, # Links are not stored to hero versions at this moment so filter # is limited to just versions "type": "version" } graph_lookup = { "from": project_name, "startWith": "$data.inputLinks.id", "connectFromField": "data.inputLinks.id", "connectToField": "_id", "as": "outputs_recursive", "depthField": "depth" } if max_depth != 0: # We offset by -1 since 0 basically means no recursion # but the recursion only happens after the initial lookup # for outputs. graph_lookup["maxDepth"] = max_depth - 1 query_pipeline = [ # Match {"$match": match}, # Recursive graph lookup for inputs {"$graphLookup": graph_lookup} ] conn = get_project_connection(project_name) result = conn.aggregate(query_pipeline) referenced_version_ids = _process_referenced_pipeline_result( result, link_type ) if not referenced_version_ids: return [] ref_ids = conn.distinct( "_id", filter={ "parent": {"$in": list(referenced_version_ids)}, "type": "representation" } ) return list(ref_ids) def _process_referenced_pipeline_result(result, link_type): """Filters result from pipeline for particular link_type. Pipeline cannot use link_type directly in a query. Returns: (list) """ referenced_version_ids = set() correctly_linked_ids = set() for item in result: input_links = item.get("data", {}).get("inputLinks") if not input_links: continue _filter_input_links( input_links, link_type, correctly_linked_ids ) # outputs_recursive in random order, sort by depth outputs_recursive = item.get("outputs_recursive") if not outputs_recursive: continue for output in sorted(outputs_recursive, key=lambda o: o["depth"]): # Leaf if output["_id"] not in correctly_linked_ids: continue _filter_input_links( output.get("data", {}).get("inputLinks"), link_type, correctly_linked_ids ) referenced_version_ids.add(output["_id"]) return referenced_version_ids def _filter_input_links(input_links, link_type, correctly_linked_ids): if not input_links: # to handle hero versions return for input_link in input_links: if link_type and input_link["type"] != link_type: continue link_id = input_link.get("id") or input_link.get("_id") if link_id is not None: correctly_linked_ids.add(link_id) ================================================ FILE: openpype/client/mongo/mongo.py ================================================ import os import sys import time import logging import pymongo import certifi from bson.json_util import ( loads, dumps, CANONICAL_JSON_OPTIONS ) from openpype import AYON_SERVER_ENABLED if sys.version_info[0] == 2: from urlparse import urlparse, parse_qs else: from urllib.parse import urlparse, parse_qs class MongoEnvNotSet(Exception): pass def documents_to_json(docs): """Convert documents to json string. Args: Union[list[dict[str, Any]], dict[str, Any]]: Document/s to convert to json string. Returns: str: Json string with mongo documents. """ return dumps(docs, json_options=CANONICAL_JSON_OPTIONS) def load_json_file(filepath): """Load mongo documents from a json file. Args: filepath (str): Path to a json file. Returns: Union[dict[str, Any], list[dict[str, Any]]]: Loaded content from a json file. """ if not os.path.exists(filepath): raise ValueError("Path {} was not found".format(filepath)) with open(filepath, "r") as stream: content = stream.read() return loads("".join(content)) def get_project_database_name(): """Name of database name where projects are available. Returns: str: Name of database name where projects are. """ return os.environ.get("AVALON_DB") or "avalon" def _decompose_url(url): """Decompose mongo url to basic components. Used for creation of MongoHandler which expect mongo url components as separated kwargs. Components are at the end not used as we're setting connection directly this is just a dumb components for MongoHandler validation pass. """ # Use first url from passed url # - this is because it is possible to pass multiple urls for multiple # replica sets which would crash on urlparse otherwise # - please don't use comma in username of password url = url.split(",")[0] components = { "scheme": None, "host": None, "port": None, "username": None, "password": None, "auth_db": None } result = urlparse(url) if result.scheme is None: _url = "mongodb://{}".format(url) result = urlparse(_url) components["scheme"] = result.scheme components["host"] = result.hostname try: components["port"] = result.port except ValueError: raise RuntimeError("invalid port specified") components["username"] = result.username components["password"] = result.password try: components["auth_db"] = parse_qs(result.query)['authSource'][0] except KeyError: # no auth db provided, mongo will use the one we are connecting to pass return components def get_default_components(): mongo_url = os.environ.get("OPENPYPE_MONGO") if mongo_url is None: raise MongoEnvNotSet( "URL for Mongo logging connection is not set." ) return _decompose_url(mongo_url) def should_add_certificate_path_to_mongo_url(mongo_url): """Check if should add ca certificate to mongo url. Since 30.9.2021 cloud mongo requires newer certificates that are not available on most of workstation. This adds path to certifi certificate which is valid for it. To add the certificate path url must have scheme 'mongodb+srv' or has 'ssl=true' or 'tls=true' in url query. """ parsed = urlparse(mongo_url) query = parse_qs(parsed.query) lowered_query_keys = set(key.lower() for key in query.keys()) add_certificate = False # Check if url 'ssl' or 'tls' are set to 'true' for key in ("ssl", "tls"): if key in query and "true" in query[key]: add_certificate = True break # Check if url contains 'mongodb+srv' if not add_certificate and parsed.scheme == "mongodb+srv": add_certificate = True # Check if url does already contain certificate path if add_certificate and "tlscafile" in lowered_query_keys: add_certificate = False return add_certificate def validate_mongo_connection(mongo_uri): """Check if provided mongodb URL is valid. Args: mongo_uri (str): URL to validate. Raises: ValueError: When port in mongo uri is not valid. pymongo.errors.InvalidURI: If passed mongo is invalid. pymongo.errors.ServerSelectionTimeoutError: If connection timeout passed so probably couldn't connect to mongo server. """ client = OpenPypeMongoConnection.create_connection( mongo_uri, retry_attempts=1 ) client.close() class OpenPypeMongoConnection: """Singleton MongoDB connection. Keeps MongoDB connections by url. """ mongo_clients = {} log = logging.getLogger("OpenPypeMongoConnection") @staticmethod def get_default_mongo_url(): return os.environ["OPENPYPE_MONGO"] @classmethod def get_mongo_client(cls, mongo_url=None): if mongo_url is None: mongo_url = cls.get_default_mongo_url() connection = cls.mongo_clients.get(mongo_url) if connection: # Naive validation of existing connection try: connection.server_info() with connection.start_session(): pass except Exception: connection = None if not connection: cls.log.debug("Creating mongo connection to {}".format(mongo_url)) connection = cls.create_connection(mongo_url) cls.mongo_clients[mongo_url] = connection return connection @classmethod def create_connection(cls, mongo_url, timeout=None, retry_attempts=None): if AYON_SERVER_ENABLED: raise RuntimeError("Created mongo connection in AYON mode") parsed = urlparse(mongo_url) # Force validation of scheme if parsed.scheme not in ["mongodb", "mongodb+srv"]: raise pymongo.errors.InvalidURI(( "Invalid URI scheme:" " URI must begin with 'mongodb://' or 'mongodb+srv://'" )) if timeout is None: timeout = int(os.environ.get("AVALON_TIMEOUT") or 1000) kwargs = { "serverSelectionTimeoutMS": timeout } if should_add_certificate_path_to_mongo_url(mongo_url): kwargs["tlsCAFile"] = certifi.where() mongo_client = pymongo.MongoClient(mongo_url, **kwargs) if retry_attempts is None: retry_attempts = 3 elif not retry_attempts: retry_attempts = 1 last_exc = None valid = False t1 = time.time() for attempt in range(1, retry_attempts + 1): try: mongo_client.server_info() with mongo_client.start_session(): pass valid = True break except Exception as exc: last_exc = exc if attempt < retry_attempts: cls.log.warning( "Attempt {} failed. Retrying... ".format(attempt) ) time.sleep(1) if not valid: raise last_exc cls.log.info("Connected to {}, delay {:.3f}s".format( mongo_url, time.time() - t1 )) return mongo_client # ------ Helper Mongo functions ------ # Functions can be helpful with custom tools to backup/restore mongo state. # Not meant as API functionality that should be used in production codebase! def get_collection_documents(database_name, collection_name, as_json=False): """Query all documents from a collection. Args: database_name (str): Name of database where to look for collection. collection_name (str): Name of collection where to look for collection. as_json (Optional[bool]): Output should be a json string. Default: 'False' Returns: Union[list[dict[str, Any]], str]: Queried documents. """ client = OpenPypeMongoConnection.get_mongo_client() output = list(client[database_name][collection_name].find({})) if as_json: output = documents_to_json(output) return output def store_collection(filepath, database_name, collection_name): """Store collection documents to a json file. Args: filepath (str): Path to a json file where documents will be stored. database_name (str): Name of database where to look for collection. collection_name (str): Name of collection to store. """ # Make sure directory for output file exists dirpath = os.path.dirname(filepath) if not os.path.isdir(dirpath): os.makedirs(dirpath) content = get_collection_documents(database_name, collection_name, True) with open(filepath, "w") as stream: stream.write(content) def replace_collection_documents(docs, database_name, collection_name): """Replace all documents in a collection with passed documents. Warnings: All existing documents in collection will be removed if there are any. Args: docs (list[dict[str, Any]]): New documents. database_name (str): Name of database where to look for collection. collection_name (str): Name of collection where new documents are uploaded. """ client = OpenPypeMongoConnection.get_mongo_client() database = client[database_name] if collection_name in database.list_collection_names(): database.drop_collection(collection_name) col = database[collection_name] col.insert_many(docs) def restore_collection(filepath, database_name, collection_name): """Restore/replace collection from a json filepath. Warnings: All existing documents in collection will be removed if there are any. Args: filepath (str): Path to a json with documents. database_name (str): Name of database where to look for collection. collection_name (str): Name of collection where new documents are uploaded. """ docs = load_json_file(filepath) replace_collection_documents(docs, database_name, collection_name) def get_project_database(database_name=None): """Database object where project collections are. Args: database_name (Optional[str]): Custom name of database. Returns: pymongo.database.Database: Collection related to passed project. """ if not database_name: database_name = get_project_database_name() return OpenPypeMongoConnection.get_mongo_client()[database_name] def get_project_connection(project_name, database_name=None): """Direct access to mongo collection. We're trying to avoid using direct access to mongo. This should be used only for Create, Update and Remove operations until there are implemented api calls for that. Args: project_name (str): Project name for which collection should be returned. database_name (Optional[str]): Custom name of database. Returns: pymongo.collection.Collection: Collection related to passed project. """ if not project_name: raise ValueError("Invalid project name {}".format(str(project_name))) return get_project_database(database_name)[project_name] def get_project_documents(project_name, database_name=None): """Query all documents from project collection. Args: project_name (str): Name of project. database_name (Optional[str]): Name of mongo database where to look for project. Returns: list[dict[str, Any]]: Documents in project collection. """ if not database_name: database_name = get_project_database_name() return get_collection_documents(database_name, project_name) def store_project_documents(project_name, filepath, database_name=None): """Store project documents to a file as json string. Args: project_name (str): Name of project to store. filepath (str): Path to a json file where output will be stored. database_name (Optional[str]): Name of mongo database where to look for project. """ if not database_name: database_name = get_project_database_name() store_collection(filepath, database_name, project_name) def replace_project_documents(project_name, docs, database_name=None): """Replace documents in mongo with passed documents. Warnings: Existing project collection is removed if exists in mongo. Args: project_name (str): Name of project. docs (list[dict[str, Any]]): Documents to restore. database_name (Optional[str]): Name of mongo database where project collection will be created. """ if not database_name: database_name = get_project_database_name() replace_collection_documents(docs, database_name, project_name) def restore_project_documents(project_name, filepath, database_name=None): """Replace documents in mongo with passed documents. Warnings: Existing project collection is removed if exists in mongo. Args: project_name (str): Name of project. filepath (str): File to json file with project documents. database_name (Optional[str]): Name of mongo database where project collection will be created. """ if not database_name: database_name = get_project_database_name() restore_collection(filepath, database_name, project_name) ================================================ FILE: openpype/client/mongo/operations.py ================================================ import re import copy import collections from bson.objectid import ObjectId from pymongo import DeleteOne, InsertOne, UpdateOne from openpype.client.operations_base import ( REMOVED_VALUE, CreateOperation, UpdateOperation, DeleteOperation, BaseOperationsSession ) from .mongo import get_project_connection from .entities import get_project PROJECT_NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" PROJECT_NAME_REGEX = re.compile( "^[{}]+$".format(PROJECT_NAME_ALLOWED_SYMBOLS) ) CURRENT_PROJECT_SCHEMA = "openpype:project-3.0" CURRENT_PROJECT_CONFIG_SCHEMA = "openpype:config-2.0" CURRENT_ASSET_DOC_SCHEMA = "openpype:asset-3.0" CURRENT_SUBSET_SCHEMA = "openpype:subset-3.0" CURRENT_VERSION_SCHEMA = "openpype:version-3.0" CURRENT_HERO_VERSION_SCHEMA = "openpype:hero_version-1.0" CURRENT_REPRESENTATION_SCHEMA = "openpype:representation-2.0" CURRENT_WORKFILE_INFO_SCHEMA = "openpype:workfile-1.0" CURRENT_THUMBNAIL_SCHEMA = "openpype:thumbnail-1.0" def _create_or_convert_to_mongo_id(mongo_id): if mongo_id is None: return ObjectId() return ObjectId(mongo_id) def new_project_document( project_name, project_code, config, data=None, entity_id=None ): """Create skeleton data of project document. Args: project_name (str): Name of project. Used as identifier of a project. project_code (str): Shorter version of projet without spaces and special characters (in most of cases). Should be also considered as unique name across projects. config (Dic[str, Any]): Project config consist of roots, templates, applications and other project Anatomy related data. data (Dict[str, Any]): Project data with information about it's attributes (e.g. 'fps' etc.) or integration specific keys. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of project document. """ if data is None: data = {} data["code"] = project_code return { "_id": _create_or_convert_to_mongo_id(entity_id), "name": project_name, "type": CURRENT_PROJECT_SCHEMA, "entity_data": data, "config": config } def new_asset_document( name, project_id, parent_id, parents, data=None, entity_id=None ): """Create skeleton data of asset document. Args: name (str): Is considered as unique identifier of asset in project. project_id (Union[str, ObjectId]): Id of project doument. parent_id (Union[str, ObjectId]): Id of parent asset. parents (List[str]): List of parent assets names. data (Dict[str, Any]): Asset document data. Empty dictionary is used if not passed. Value of 'parent_id' is used to fill 'visualParent'. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of asset document. """ if data is None: data = {} if parent_id is not None: parent_id = ObjectId(parent_id) data["visualParent"] = parent_id data["parents"] = parents return { "_id": _create_or_convert_to_mongo_id(entity_id), "type": "asset", "name": name, "parent": ObjectId(project_id), "data": data, "schema": CURRENT_ASSET_DOC_SCHEMA } def new_subset_document(name, family, asset_id, data=None, entity_id=None): """Create skeleton data of subset document. Args: name (str): Is considered as unique identifier of subset under asset. family (str): Subset's family. asset_id (Union[str, ObjectId]): Id of parent asset. data (Dict[str, Any]): Subset document data. Empty dictionary is used if not passed. Value of 'family' is used to fill 'family'. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of subset document. """ if data is None: data = {} data["family"] = family return { "_id": _create_or_convert_to_mongo_id(entity_id), "schema": CURRENT_SUBSET_SCHEMA, "type": "subset", "name": name, "data": data, "parent": asset_id } def new_version_doc(version, subset_id, data=None, entity_id=None): """Create skeleton data of version document. Args: version (int): Is considered as unique identifier of version under subset. subset_id (Union[str, ObjectId]): Id of parent subset. data (Dict[str, Any]): Version document data. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of version document. """ if data is None: data = {} return { "_id": _create_or_convert_to_mongo_id(entity_id), "schema": CURRENT_VERSION_SCHEMA, "type": "version", "name": int(version), "parent": subset_id, "data": data } def new_hero_version_doc(version_id, subset_id, data=None, entity_id=None): """Create skeleton data of hero version document. Args: version_id (ObjectId): Is considered as unique identifier of version under subset. subset_id (Union[str, ObjectId]): Id of parent subset. data (Dict[str, Any]): Version document data. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of version document. """ if data is None: data = {} return { "_id": _create_or_convert_to_mongo_id(entity_id), "schema": CURRENT_HERO_VERSION_SCHEMA, "type": "hero_version", "version_id": version_id, "parent": subset_id, "data": data } def new_representation_doc( name, version_id, context, data=None, entity_id=None ): """Create skeleton data of asset document. Args: version (int): Is considered as unique identifier of version under subset. version_id (Union[str, ObjectId]): Id of parent version. context (Dict[str, Any]): Representation context used for fill template of to query. data (Dict[str, Any]): Representation document data. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of version document. """ if data is None: data = {} return { "_id": _create_or_convert_to_mongo_id(entity_id), "schema": CURRENT_REPRESENTATION_SCHEMA, "type": "representation", "parent": version_id, "name": name, "data": data, # Imprint shortcut to context for performance reasons. "context": context } def new_thumbnail_doc(data=None, entity_id=None): """Create skeleton data of thumbnail document. Args: data (Dict[str, Any]): Thumbnail document data. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of thumbnail document. """ if data is None: data = {} return { "_id": _create_or_convert_to_mongo_id(entity_id), "type": "thumbnail", "schema": CURRENT_THUMBNAIL_SCHEMA, "data": data } def new_workfile_info_doc( filename, asset_id, task_name, files, data=None, entity_id=None ): """Create skeleton data of workfile info document. Workfile document is at this moment used primarily for artist notes. Args: filename (str): Filename of workfile. asset_id (Union[str, ObjectId]): Id of asset under which workfile live. task_name (str): Task under which was workfile created. files (List[str]): List of rootless filepaths related to workfile. data (Dict[str, Any]): Additional metadata. Returns: Dict[str, Any]: Skeleton of workfile info document. """ if not data: data = {} return { "_id": _create_or_convert_to_mongo_id(entity_id), "type": "workfile", "parent": ObjectId(asset_id), "task_name": task_name, "filename": filename, "data": data, "files": files } def _prepare_update_data(old_doc, new_doc, replace): changes = {} for key, value in new_doc.items(): if key not in old_doc or value != old_doc[key]: changes[key] = value if replace: for key in old_doc.keys(): if key not in new_doc: changes[key] = REMOVED_VALUE return changes def prepare_subset_update_data(old_doc, new_doc, replace=True): """Compare two subset documents and prepare update data. Based on compared values will create update data for 'MongoUpdateOperation'. Empty output means that documents are identical. Returns: Dict[str, Any]: Changes between old and new document. """ return _prepare_update_data(old_doc, new_doc, replace) def prepare_version_update_data(old_doc, new_doc, replace=True): """Compare two version documents and prepare update data. Based on compared values will create update data for 'MongoUpdateOperation'. Empty output means that documents are identical. Returns: Dict[str, Any]: Changes between old and new document. """ return _prepare_update_data(old_doc, new_doc, replace) def prepare_hero_version_update_data(old_doc, new_doc, replace=True): """Compare two hero version documents and prepare update data. Based on compared values will create update data for 'UpdateOperation'. Empty output means that documents are identical. Returns: Dict[str, Any]: Changes between old and new document. """ return _prepare_update_data(old_doc, new_doc, replace) def prepare_representation_update_data(old_doc, new_doc, replace=True): """Compare two representation documents and prepare update data. Based on compared values will create update data for 'MongoUpdateOperation'. Empty output means that documents are identical. Returns: Dict[str, Any]: Changes between old and new document. """ return _prepare_update_data(old_doc, new_doc, replace) def prepare_workfile_info_update_data(old_doc, new_doc, replace=True): """Compare two workfile info documents and prepare update data. Based on compared values will create update data for 'MongoUpdateOperation'. Empty output means that documents are identical. Returns: Dict[str, Any]: Changes between old and new document. """ return _prepare_update_data(old_doc, new_doc, replace) class MongoCreateOperation(CreateOperation): """Operation to create an entity. Args: project_name (str): On which project operation will happen. entity_type (str): Type of entity on which change happens. e.g. 'asset', 'representation' etc. data (Dict[str, Any]): Data of entity that will be created. """ operation_name = "create" def __init__(self, project_name, entity_type, data): super(MongoCreateOperation, self).__init__( project_name, entity_type, data ) if "_id" not in self._data: self._data["_id"] = ObjectId() else: self._data["_id"] = ObjectId(self._data["_id"]) @property def entity_id(self): return self._data["_id"] def to_mongo_operation(self): return InsertOne(copy.deepcopy(self._data)) class MongoUpdateOperation(UpdateOperation): """Operation to update an entity. Args: project_name (str): On which project operation will happen. entity_type (str): Type of entity on which change happens. e.g. 'asset', 'representation' etc. entity_id (Union[str, ObjectId]): Identifier of an entity. update_data (Dict[str, Any]): Key -> value changes that will be set in database. If value is set to 'REMOVED_VALUE' the key will be removed. Only first level of dictionary is checked (on purpose). """ operation_name = "update" def __init__(self, project_name, entity_type, entity_id, update_data): super(MongoUpdateOperation, self).__init__( project_name, entity_type, entity_id, update_data ) self._entity_id = ObjectId(self._entity_id) def to_mongo_operation(self): unset_data = {} set_data = {} for key, value in self._update_data.items(): if value is REMOVED_VALUE: unset_data[key] = None else: set_data[key] = value op_data = {} if unset_data: op_data["$unset"] = unset_data if set_data: op_data["$set"] = set_data if not op_data: return None return UpdateOne( {"_id": self.entity_id}, op_data ) class MongoDeleteOperation(DeleteOperation): """Operation to delete an entity. Args: project_name (str): On which project operation will happen. entity_type (str): Type of entity on which change happens. e.g. 'asset', 'representation' etc. entity_id (Union[str, ObjectId]): Entity id that will be removed. """ operation_name = "delete" def __init__(self, project_name, entity_type, entity_id): super(MongoDeleteOperation, self).__init__( project_name, entity_type, entity_id ) self._entity_id = ObjectId(self._entity_id) def to_mongo_operation(self): return DeleteOne({"_id": self.entity_id}) class MongoOperationsSession(BaseOperationsSession): """Session storing operations that should happen in an order. At this moment does not handle anything special can be sonsidered as stupid list of operations that will happen after each other. If creation of same entity is there multiple times it's handled in any way and document values are not validated. All operations must be related to single project. Args: project_name (str): Project name to which are operations related. """ def commit(self): """Commit session operations.""" operations, self._operations = self._operations, [] if not operations: return operations_by_project = collections.defaultdict(list) for operation in operations: operations_by_project[operation.project_name].append(operation) for project_name, operations in operations_by_project.items(): bulk_writes = [] for operation in operations: mongo_op = operation.to_mongo_operation() if mongo_op is not None: bulk_writes.append(mongo_op) if bulk_writes: collection = get_project_connection(project_name) collection.bulk_write(bulk_writes) def create_entity(self, project_name, entity_type, data): """Fast access to 'MongoCreateOperation'. Returns: MongoCreateOperation: Object of update operation. """ operation = MongoCreateOperation(project_name, entity_type, data) self.add(operation) return operation def update_entity(self, project_name, entity_type, entity_id, update_data): """Fast access to 'MongoUpdateOperation'. Returns: MongoUpdateOperation: Object of update operation. """ operation = MongoUpdateOperation( project_name, entity_type, entity_id, update_data ) self.add(operation) return operation def delete_entity(self, project_name, entity_type, entity_id): """Fast access to 'MongoDeleteOperation'. Returns: MongoDeleteOperation: Object of delete operation. """ operation = MongoDeleteOperation(project_name, entity_type, entity_id) self.add(operation) return operation def create_project( project_name, project_code, library_project=False, ): """Create project using OpenPype settings. This project creation function is not validating project document on creation. It is because project document is created blindly with only minimum required information about project which is it's name, code, type and schema. Entered project name must be unique and project must not exist yet. Note: This function is here to be OP v4 ready but in v3 has more logic to do. That's why inner imports are in the body. Args: project_name(str): New project name. Should be unique. project_code(str): Project's code should be unique too. library_project(bool): Project is library project. Raises: ValueError: When project name already exists in MongoDB. Returns: dict: Created project document. """ from openpype.settings import ProjectSettings, SaveWarningExc from openpype.pipeline.schema import validate if get_project(project_name, fields=["name"]): raise ValueError("Project with name \"{}\" already exists".format( project_name )) if not PROJECT_NAME_REGEX.match(project_name): raise ValueError(( "Project name \"{}\" contain invalid characters" ).format(project_name)) project_doc = { "type": "project", "name": project_name, "data": { "code": project_code, "library_project": library_project }, "schema": CURRENT_PROJECT_SCHEMA } op_session = MongoOperationsSession() # Insert document with basic data create_op = op_session.create_entity( project_name, project_doc["type"], project_doc ) op_session.commit() # Load ProjectSettings for the project and save it to store all attributes # and Anatomy try: project_settings_entity = ProjectSettings(project_name) project_settings_entity.save() except SaveWarningExc as exc: print(str(exc)) except Exception: op_session.delete_entity( project_name, project_doc["type"], create_op.entity_id ) op_session.commit() raise project_doc = get_project(project_name) try: # Validate created project document validate(project_doc) except Exception: # Remove project if is not valid op_session.delete_entity( project_name, project_doc["type"], create_op.entity_id ) op_session.commit() raise return project_doc ================================================ FILE: openpype/client/notes.md ================================================ # Client functionality ## Reason Preparation for OpenPype v4 server. Goal is to remove direct mongo calls in code to prepare a little bit for different source of data for code before. To start think about database calls less as mongo calls but more universally. To do so was implemented simple wrapper around database calls to not use pymongo specific code. Current goal is not to make universal database model which can be easily replaced with any different source of data but to make it close as possible. Current implementation of OpenPype is too tightly connected to pymongo and it's abilities so we're trying to get closer with long term changes that can be used even in current state. ## Queries Query functions don't use full potential of mongo queries like very specific queries based on subdictionaries or unknown structures. We try to avoid these calls as much as possible because they'll probably won't be available in future. If it's really necessary a new function can be added but only if it's reasonable for overall logic. All query functions were moved to `~/client/entities.py`. Each function has arguments with available filters and possible reduce of returned keys for each entity. ## Changes Changes are a little bit complicated. Mongo has many options how update can happen which had to be reduced also it would be at this stage complicated to validate values which are created or updated thus automation is at this point almost none. Changes can be made using operations available in `~/client/operations.py`. Each operation require project name and entity type, but may require operation specific data. ### Create Create operations expect already prepared document data, for that are prepared functions creating skeletal structures of documents (do not fill all required data), except `_id` all data should be right. Existence of entity is not validated so if the same creation operation is send n times it will create the entity n times which can cause issues. ### Update Update operation require entity id and keys that should be changed, update dictionary must have {"key": value}. If value should be set in nested dictionary the key must have also all subkeys joined with dot `.` (e.g. `{"data": {"fps": 25}}` -> `{"data.fps": 25}`). To simplify update dictionaries were prepared functions which does that for you, their name has template `prepare__update_data` - they work on comparison of previous document and new document. If there is missing function for requested entity type it is because we didn't need it yet and require implementation. ### Delete Delete operation need entity id. Entity will be deleted from mongo. ## What (probably) won't be replaced Some parts of code are still using direct mongo calls. In most of cases it is for very specific calls that are module specific or their usage will completely change in future. - Mongo calls that are not project specific (out of `avalon` collection) will be removed or will have to use different mechanism how the data are stored. At this moment it is related to OpenPype settings and logs, ftrack server events, some other data. - Sync server queries. They're complex and very specific for sync server module. Their replacement will require specific calls to OpenPype server in v4 thus their abstraction with wrapper is irrelevant and would complicate production in v3. - Project managers (ftrack, kitsu, shotgrid, embedded Project Manager, etc.). Project managers are creating, updating or removing assets in v3, but in v4 will create folders with different structure. Wrapping creation of assets would not help to prepare for v4 because of new data structures. The same can be said about editorial Extract Hierarchy Avalon plugin which create project structure. - Code parts that is marked as deprecated in v3 or will be deprecated in v4. - integrate asset legacy publish plugin - already is legacy kept for safety - integrate thumbnail - thumbnails will be stored in different way in v4 - input links - link will be stored in different way and will have different mechanism of linking. In v3 are links limited to same entity type "asset <-> asset" or "representation <-> representation". ## Known missing replacements - change subset group in loader tool - integrate subset group - query input links in openpype lib - create project in openpype lib - save/create workfile doc in openpype lib - integrate hero version ================================================ FILE: openpype/client/operations.py ================================================ from openpype import AYON_SERVER_ENABLED from .operations_base import REMOVED_VALUE if not AYON_SERVER_ENABLED: from .mongo.operations import * OperationsSession = MongoOperationsSession else: from ayon_api.server_api import ( PROJECT_NAME_ALLOWED_SYMBOLS, PROJECT_NAME_REGEX, ) from .server.operations import * from .mongo.operations import ( CURRENT_PROJECT_SCHEMA, CURRENT_PROJECT_CONFIG_SCHEMA, CURRENT_ASSET_DOC_SCHEMA, CURRENT_SUBSET_SCHEMA, CURRENT_VERSION_SCHEMA, CURRENT_HERO_VERSION_SCHEMA, CURRENT_REPRESENTATION_SCHEMA, CURRENT_WORKFILE_INFO_SCHEMA, CURRENT_THUMBNAIL_SCHEMA ) ================================================ FILE: openpype/client/operations_base.py ================================================ import uuid import copy from abc import ABCMeta, abstractmethod, abstractproperty import six REMOVED_VALUE = object() @six.add_metaclass(ABCMeta) class AbstractOperation(object): """Base operation class. Operation represent a call into database. The call can create, change or remove data. Args: project_name (str): On which project operation will happen. entity_type (str): Type of entity on which change happens. e.g. 'asset', 'representation' etc. """ def __init__(self, project_name, entity_type): self._project_name = project_name self._entity_type = entity_type self._id = str(uuid.uuid4()) @property def project_name(self): return self._project_name @property def id(self): """Identifier of operation.""" return self._id @property def entity_type(self): return self._entity_type @abstractproperty def operation_name(self): """Stringified type of operation.""" pass def to_data(self): """Convert operation to data that can be converted to json or others. Warning: Current state returns ObjectId objects which cannot be parsed by json. Returns: Dict[str, Any]: Description of operation. """ return { "id": self._id, "entity_type": self.entity_type, "project_name": self.project_name, "operation": self.operation_name } class CreateOperation(AbstractOperation): """Operation to create an entity. Args: project_name (str): On which project operation will happen. entity_type (str): Type of entity on which change happens. e.g. 'asset', 'representation' etc. data (Dict[str, Any]): Data of entity that will be created. """ operation_name = "create" def __init__(self, project_name, entity_type, data): super(CreateOperation, self).__init__(project_name, entity_type) if not data: data = {} else: data = copy.deepcopy(dict(data)) self._data = data def __setitem__(self, key, value): self.set_value(key, value) def __getitem__(self, key): return self.data[key] def set_value(self, key, value): self.data[key] = value def get(self, key, *args, **kwargs): return self.data.get(key, *args, **kwargs) @abstractproperty def entity_id(self): pass @property def data(self): return self._data def to_data(self): output = super(CreateOperation, self).to_data() output["data"] = copy.deepcopy(self.data) return output class UpdateOperation(AbstractOperation): """Operation to update an entity. Args: project_name (str): On which project operation will happen. entity_type (str): Type of entity on which change happens. e.g. 'asset', 'representation' etc. entity_id (Union[str, ObjectId]): Identifier of an entity. update_data (Dict[str, Any]): Key -> value changes that will be set in database. If value is set to 'REMOVED_VALUE' the key will be removed. Only first level of dictionary is checked (on purpose). """ operation_name = "update" def __init__(self, project_name, entity_type, entity_id, update_data): super(UpdateOperation, self).__init__(project_name, entity_type) self._entity_id = entity_id self._update_data = update_data @property def entity_id(self): return self._entity_id @property def update_data(self): return self._update_data def to_data(self): changes = {} for key, value in self._update_data.items(): if value is REMOVED_VALUE: value = None changes[key] = value output = super(UpdateOperation, self).to_data() output.update({ "entity_id": self.entity_id, "changes": changes }) return output class DeleteOperation(AbstractOperation): """Operation to delete an entity. Args: project_name (str): On which project operation will happen. entity_type (str): Type of entity on which change happens. e.g. 'asset', 'representation' etc. entity_id (Union[str, ObjectId]): Entity id that will be removed. """ operation_name = "delete" def __init__(self, project_name, entity_type, entity_id): super(DeleteOperation, self).__init__(project_name, entity_type) self._entity_id = entity_id @property def entity_id(self): return self._entity_id def to_data(self): output = super(DeleteOperation, self).to_data() output["entity_id"] = self.entity_id return output class BaseOperationsSession(object): """Session storing operations that should happen in an order. At this moment does not handle anything special can be considered as stupid list of operations that will happen after each other. If creation of same entity is there multiple times it's handled in any way and document values are not validated. """ def __init__(self): self._operations = [] def __len__(self): return len(self._operations) def add(self, operation): """Add operation to be processed. Args: operation (BaseOperation): Operation that should be processed. """ if not isinstance( operation, (CreateOperation, UpdateOperation, DeleteOperation) ): raise TypeError("Expected Operation object got {}".format( str(type(operation)) )) self._operations.append(operation) def append(self, operation): """Add operation to be processed. Args: operation (BaseOperation): Operation that should be processed. """ self.add(operation) def extend(self, operations): """Add operations to be processed. Args: operations (List[BaseOperation]): Operations that should be processed. """ for operation in operations: self.add(operation) def remove(self, operation): """Remove operation.""" self._operations.remove(operation) def clear(self): """Clear all registered operations.""" self._operations = [] def to_data(self): return [ operation.to_data() for operation in self._operations ] @abstractmethod def commit(self): """Commit session operations.""" pass def create_entity(self, project_name, entity_type, data): """Fast access to 'CreateOperation'. Returns: CreateOperation: Object of update operation. """ operation = CreateOperation(project_name, entity_type, data) self.add(operation) return operation def update_entity(self, project_name, entity_type, entity_id, update_data): """Fast access to 'UpdateOperation'. Returns: UpdateOperation: Object of update operation. """ operation = UpdateOperation( project_name, entity_type, entity_id, update_data ) self.add(operation) return operation def delete_entity(self, project_name, entity_type, entity_id): """Fast access to 'DeleteOperation'. Returns: DeleteOperation: Object of delete operation. """ operation = DeleteOperation(project_name, entity_type, entity_id) self.add(operation) return operation ================================================ FILE: openpype/client/server/__init__.py ================================================ ================================================ FILE: openpype/client/server/constants.py ================================================ # --- Folders --- DEFAULT_FOLDER_FIELDS = { "id", "name", "path", "parentId", "active", "parents", "thumbnailId" } REPRESENTATION_FILES_FIELDS = { "files.name", "files.hash", "files.id", "files.path", "files.size", } ================================================ FILE: openpype/client/server/conversion_utils.py ================================================ import os import arrow import collections import json import six from openpype.client.operations_base import REMOVED_VALUE from openpype.client.mongo.operations import ( CURRENT_PROJECT_SCHEMA, CURRENT_ASSET_DOC_SCHEMA, CURRENT_SUBSET_SCHEMA, CURRENT_VERSION_SCHEMA, CURRENT_HERO_VERSION_SCHEMA, CURRENT_REPRESENTATION_SCHEMA, CURRENT_WORKFILE_INFO_SCHEMA, ) from .constants import REPRESENTATION_FILES_FIELDS from .utils import create_entity_id, prepare_entity_changes # --- Project entity --- PROJECT_FIELDS_MAPPING_V3_V4 = { "_id": {"name"}, "name": {"name"}, "data": {"data", "code"}, "data.library_project": {"library"}, "data.code": {"code"}, "data.active": {"active"}, } # TODO this should not be hardcoded but received from server!!! # --- Folder entity --- FOLDER_FIELDS_MAPPING_V3_V4 = { "_id": {"id"}, "name": {"name"}, "label": {"label"}, "data": { "parentId", "parents", "active", "tasks", "thumbnailId" }, "data.visualParent": {"parentId"}, "data.parents": {"parents"}, "data.active": {"active"}, "data.thumbnail_id": {"thumbnailId"}, "data.entityType": {"folderType"} } # --- Subset entity --- SUBSET_FIELDS_MAPPING_V3_V4 = { "_id": {"id"}, "name": {"name"}, "data.active": {"active"}, "parent": {"folderId"} } # --- Version entity --- VERSION_FIELDS_MAPPING_V3_V4 = { "_id": {"id"}, "name": {"version"}, "parent": {"productId"} } # --- Representation entity --- REPRESENTATION_FIELDS_MAPPING_V3_V4 = { "_id": {"id"}, "name": {"name"}, "parent": {"versionId"}, "context": {"context"}, "files": {"files"}, } def project_fields_v3_to_v4(fields, con): """Convert project fields from v3 to v4 structure. Args: fields (Union[Iterable(str), None]): fields to be converted. Returns: Union[Set(str), None]: Converted fields to v4 fields. """ # TODO config fields # - config.apps # - config.groups if not fields: return None project_attribs = con.get_attributes_for_type("project") output = set() for field in fields: # If config is needed the rest api call must be used if field.startswith("config"): return None if field in PROJECT_FIELDS_MAPPING_V3_V4: output |= PROJECT_FIELDS_MAPPING_V3_V4[field] if field == "data": output |= { "attrib.{}".format(attr) for attr in project_attribs } elif field.startswith("data"): field_parts = field.split(".") field_parts.pop(0) data_key = ".".join(field_parts) if data_key in project_attribs: output.add("attrib.{}".format(data_key)) else: output.add("data") print("Requested specific key from data {}".format(data_key)) else: raise ValueError("Unknown field mapping for {}".format(field)) if "name" not in output: output.add("name") return output def _get_default_template_name(templates): default_template = None for name, template in templates.items(): if name == "default": return "default" if default_template is None: default_template = name return default_template def _template_replacements_to_v3(template): return ( template .replace("{product[name]}", "{subset}") .replace("{product[type]}", "{family}") ) def _convert_template_item(template_item): for key, value in tuple(template_item.items()): template_item[key] = _template_replacements_to_v3(value) # Change 'directory' to 'folder' if "directory" in template_item: template_item["folder"] = template_item.pop("directory") if ( "path" not in template_item and "file" in template_item and "folder" in template_item ): template_item["path"] = "/".join( (template_item["folder"], template_item["file"]) ) def _fill_template_category(templates, cat_templates, cat_key): default_template_name = _get_default_template_name(cat_templates) for template_name, cat_template in cat_templates.items(): _convert_template_item(cat_template) if template_name == default_template_name: templates[cat_key] = cat_template else: new_name = "{}_{}".format(cat_key, template_name) templates["others"][new_name] = cat_template def convert_v4_project_to_v3(project): """Convert Project entity data from v4 structure to v3 structure. Args: project (Dict[str, Any]): Project entity queried from v4 server. Returns: Dict[str, Any]: Project converted to v3 structure. """ if not project: return project project_name = project["name"] output = { "_id": project_name, "name": project_name, "schema": CURRENT_PROJECT_SCHEMA, "type": "project" } data = project.get("data") or {} attribs = project.get("attrib") or {} apps_attr = attribs.pop("applications", None) or [] applications = [ {"name": app_name} for app_name in apps_attr ] data.update(attribs) if "tools" in data: data["tools_env"] = data.pop("tools") data["entityType"] = "Project" config = {} project_config = project.get("config") if project_config: config["apps"] = applications config["roots"] = project_config["roots"] templates = project_config["templates"] templates["defaults"] = templates.pop("common", None) or {} others_templates = templates.pop("others", None) or {} new_others_templates = {} templates["others"] = new_others_templates for name, template in others_templates.items(): _convert_template_item(template) new_others_templates[name] = template staging_templates = templates.pop("staging", None) # Key 'staging_directories' is legacy key that changed # to 'staging_dir' _legacy_staging_templates = templates.pop("staging_directories", None) if staging_templates is None: staging_templates = _legacy_staging_templates if staging_templates is None: staging_templates = {} # Prefix all staging template names with 'staging_' prefix # and add them to 'others' for name, template in staging_templates.items(): _convert_template_item(template) new_name = "staging_{}".format(name) new_others_templates[new_name] = template for key in ( "work", "publish", "hero", ): cat_templates = templates.pop(key) _fill_template_category(templates, cat_templates, key) delivery_templates = templates.pop("delivery", None) or {} new_delivery_templates = {} for name, delivery_template in delivery_templates.items(): new_delivery_templates[name] = "/".join( (delivery_template["directory"], delivery_template["file"]) ) templates["delivery"] = new_delivery_templates config["templates"] = templates if "taskTypes" in project: task_types = project["taskTypes"] new_task_types = {} for task_type in task_types: name = task_type.pop("name") # Change 'shortName' to 'short_name' task_type["short_name"] = task_type.pop("shortName", None) new_task_types[name] = task_type config["tasks"] = new_task_types if config: output["config"] = config for data_key, key in ( ("library_project", "library"), ("code", "code"), ("active", "active") ): if key in project: data[data_key] = project[key] if "attrib" in project: for key, value in project["attrib"].items(): data[key] = value if data: output["data"] = data return output def folder_fields_v3_to_v4(fields, con): """Convert folder fields from v3 to v4 structure. Args: fields (Union[Iterable(str), None]): fields to be converted. Returns: Union[Set(str), None]: Converted fields to v4 fields. """ if not fields: return None folder_attributes = con.get_attributes_for_type("folder") output = set() for field in fields: if field in ("schema", "type", "parent"): continue if field in FOLDER_FIELDS_MAPPING_V3_V4: output |= FOLDER_FIELDS_MAPPING_V3_V4[field] if field == "data": output |= { "attrib.{}".format(attr) for attr in folder_attributes } elif field.startswith("data"): field_parts = field.split(".") field_parts.pop(0) data_key = ".".join(field_parts) if data_key == "label": output.add("name") elif data_key in ("icon", "color"): continue elif data_key.startswith("tasks"): output.add("tasks") elif data_key in folder_attributes: output.add("attrib.{}".format(data_key)) else: output.add("data") print("Requested specific key from data {}".format(data_key)) else: raise ValueError("Unknown field mapping for {}".format(field)) if "id" not in output: output.add("id") return output def convert_v4_tasks_to_v3(tasks): """Convert v4 task item to v3 task. Args: tasks (List[Dict[str, Any]]): Task entites. Returns: Dict[str, Dict[str, Any]]: Tasks in v3 variant ready for v3 asset. """ output = {} for task in tasks: task_name = task["name"] new_task = { "type": task["taskType"] } output[task_name] = new_task return output def convert_v4_folder_to_v3(folder, project_name): """Convert v4 folder to v3 asset. Args: folder (Dict[str, Any]): Folder entity data. project_name (str): Project name from which folder was queried. Returns: Dict[str, Any]: Converted v4 folder to v3 asset. """ output = { "_id": folder["id"], "parent": project_name, "type": "asset", "schema": CURRENT_ASSET_DOC_SCHEMA } output_data = folder.get("data") or {} if "name" in folder: output["name"] = folder["name"] output_data["label"] = folder["name"] if "folderType" in folder: output_data["entityType"] = folder["folderType"] for src_key, dst_key in ( ("parentId", "visualParent"), ("active", "active"), ("thumbnailId", "thumbnail_id"), ("parents", "parents"), ): if src_key in folder: output_data[dst_key] = folder[src_key] if "attrib" in folder: output_data.update(folder["attrib"]) if "tools" in output_data: output_data["tools_env"] = output_data.pop("tools") if "tasks" in folder: output_data["tasks"] = convert_v4_tasks_to_v3(folder["tasks"]) output["data"] = output_data return output def subset_fields_v3_to_v4(fields, con): """Convert subset fields from v3 to v4 structure. Args: fields (Union[Iterable(str), None]): fields to be converted. Returns: Union[Set(str), None]: Converted fields to v4 fields. """ if not fields: return None product_attributes = con.get_attributes_for_type("product") output = set() for field in fields: if field in ("schema", "type"): continue if field in SUBSET_FIELDS_MAPPING_V3_V4: output |= SUBSET_FIELDS_MAPPING_V3_V4[field] elif field == "data": output.add("productType") output.add("active") output |= { "attrib.{}".format(attr) for attr in product_attributes } elif field.startswith("data"): field_parts = field.split(".") field_parts.pop(0) data_key = ".".join(field_parts) if data_key in ("family", "families"): output.add("productType") elif data_key in product_attributes: output.add("attrib.{}".format(data_key)) else: output.add("data") print("Requested specific key from data {}".format(data_key)) else: raise ValueError("Unknown field mapping for {}".format(field)) if "id" not in output: output.add("id") return output def convert_v4_subset_to_v3(subset): output = { "_id": subset["id"], "type": "subset", "schema": CURRENT_SUBSET_SCHEMA } if "folderId" in subset: output["parent"] = subset["folderId"] output_data = subset.get("data") or {} if "name" in subset: output["name"] = subset["name"] if "active" in subset: output_data["active"] = subset["active"] if "attrib" in subset: attrib = subset["attrib"] if "productGroup" in attrib: attrib["subsetGroup"] = attrib.pop("productGroup") output_data.update(attrib) family = subset.get("productType") if family: output_data["family"] = family output_data["families"] = [family] output["data"] = output_data return output def version_fields_v3_to_v4(fields, con): """Convert version fields from v3 to v4 structure. Args: fields (Union[Iterable(str), None]): fields to be converted. Returns: Union[Set(str), None]: Converted fields to v4 fields. """ if not fields: return None version_attributes = con.get_attributes_for_type("version") output = set() for field in fields: if field in ("type", "schema", "version_id"): continue if field in VERSION_FIELDS_MAPPING_V3_V4: output |= VERSION_FIELDS_MAPPING_V3_V4[field] elif field == "data": output |= { "attrib.{}".format(attr) for attr in version_attributes } output |= { "author", "createdAt", "thumbnailId", } elif field.startswith("data"): field_parts = field.split(".") field_parts.pop(0) data_key = ".".join(field_parts) if data_key in version_attributes: output.add("attrib.{}".format(data_key)) elif data_key == "thumbnail_id": output.add("thumbnailId") elif data_key == "time": output.add("createdAt") elif data_key == "author": output.add("author") elif data_key in ("tags", ): continue else: output.add("data") print("Requested specific key from data {}".format(data_key)) else: raise ValueError("Unknown field mapping for {}".format(field)) if "id" not in output: output.add("id") return output def convert_v4_version_to_v3(version): """Convert v4 version entity to v4 version. Args: version (Dict[str, Any]): Queried v4 version entity. Returns: Dict[str, Any]: Conveted version entity to v3 structure. """ version_num = version["version"] if version_num < 0: output = { "_id": version["id"], "type": "hero_version", "schema": CURRENT_HERO_VERSION_SCHEMA, } if "productId" in version: output["parent"] = version["productId"] if "data" in version: output["data"] = version["data"] return output output = { "_id": version["id"], "type": "version", "name": version_num, "schema": CURRENT_VERSION_SCHEMA } if "productId" in version: output["parent"] = version["productId"] output_data = version.get("data") or {} if "attrib" in version: output_data.update(version["attrib"]) for src_key, dst_key in ( ("active", "active"), ("thumbnailId", "thumbnail_id"), ("author", "author") ): if src_key in version: output_data[dst_key] = version[src_key] if "createdAt" in version: created_at = arrow.get(version["createdAt"]).to("local") output_data["time"] = created_at.strftime("%Y%m%dT%H%M%SZ") output["data"] = output_data return output def representation_fields_v3_to_v4(fields, con): """Convert representation fields from v3 to v4 structure. Args: fields (Union[Iterable(str), None]): fields to be converted. Returns: Union[Set(str), None]: Converted fields to v4 fields. """ if not fields: return None representation_attributes = con.get_attributes_for_type("representation") output = set() for field in fields: if field in ("type", "schema"): continue if field in REPRESENTATION_FIELDS_MAPPING_V3_V4: output |= REPRESENTATION_FIELDS_MAPPING_V3_V4[field] elif field.startswith("context"): output.add("context") # TODO: 'files' can have specific attributes but the keys in v3 and v4 # are not the same (content is not the same) elif field.startswith("files"): output |= REPRESENTATION_FILES_FIELDS elif field.startswith("data"): output |= { "attrib.{}".format(attr) for attr in representation_attributes } else: raise ValueError("Unknown field mapping for {}".format(field)) if "id" not in output: output.add("id") return output def convert_v4_representation_to_v3(representation): """Convert v4 representation to v3 representation. Args: representation (Dict[str, Any]): Queried representation from v4 server. Returns: Dict[str, Any]: Converted representation to v3 structure. """ output = { "type": "representation", "schema": CURRENT_REPRESENTATION_SCHEMA, } if "id" in representation: output["_id"] = representation["id"] for v3_key, v4_key in ( ("name", "name"), ("parent", "versionId") ): if v4_key in representation: output[v3_key] = representation[v4_key] if "context" in representation: context = representation["context"] if isinstance(context, six.string_types): context = json.loads(context) if "asset" not in context and "folder" in context: _c_folder = context["folder"] context["asset"] = _c_folder["name"] elif "asset" in context and "folder" not in context: context["folder"] = {"name": context["asset"]} if "product" in context: _c_product = context.pop("product") context["family"] = _c_product["type"] context["subset"] = _c_product["name"] output["context"] = context if "files" in representation: files = representation["files"] new_files = [] # From GraphQl is list if isinstance(files, list): for file_info in files: file_info["_id"] = file_info["id"] new_files.append(file_info) # From RestPoint is dictionary elif isinstance(files, dict): for file_id, file_info in files: file_info["_id"] = file_id new_files.append(file_info) for file_info in new_files: if not file_info.get("sites"): file_info["sites"] = [{ "name": "studio" }] output["files"] = new_files if representation.get("active") is False: output["type"] = "archived_representation" output["old_id"] = output["_id"] output_data = representation.get("data") or {} if "attrib" in representation: output_data.update(representation["attrib"]) for key, data_key in ( ("active", "active"), ): if key in representation: output_data[data_key] = representation[key] if "template" in output_data: output_data["template"] = ( output_data["template"] .replace("{product[name]}", "{subset}") .replace("{product[type]}", "{family}") ) output["data"] = output_data return output def workfile_info_fields_v3_to_v4(fields): if not fields: return None new_fields = set() fields = set(fields) for v3_key, v4_key in ( ("_id", "id"), ("files", "path"), ("filename", "name"), ("data", "data"), ): if v3_key in fields: new_fields.add(v4_key) if "parent" in fields or "task_name" in fields: new_fields.add("taskId") return new_fields def convert_v4_workfile_info_to_v3(workfile_info, task): output = { "type": "workfile", "schema": CURRENT_WORKFILE_INFO_SCHEMA, } if "id" in workfile_info: output["_id"] = workfile_info["id"] if "path" in workfile_info: output["files"] = [workfile_info["path"]] if "name" in workfile_info: output["filename"] = workfile_info["name"] if "taskId" in workfile_info: output["task_name"] = task["name"] output["parent"] = task["folderId"] return output def convert_create_asset_to_v4(asset, project, con): folder_attributes = con.get_attributes_for_type("folder") asset_data = asset["data"] parent_id = asset_data["visualParent"] folder = { "name": asset["name"], "parentId": parent_id, } entity_id = asset.get("_id") if entity_id: folder["id"] = entity_id attribs = {} data = {} for key, value in asset_data.items(): if key in ( "visualParent", "thumbnail_id", "parents", "inputLinks", "avalon_mongo_id", ): continue if key not in folder_attributes: data[key] = value elif value is not None: attribs[key] = value if attribs: folder["attrib"] = attribs if data: folder["data"] = data return folder def convert_create_task_to_v4(task, project, con): if not project["taskTypes"]: raise ValueError( "Project \"{}\" does not have any task types".format( project["name"])) task_type = task["type"] if task_type not in project["taskTypes"]: task_type = tuple(project["taskTypes"].keys())[0] return { "name": task["name"], "taskType": task_type, "folderId": task["folderId"] } def convert_create_subset_to_v4(subset, con): product_attributes = con.get_attributes_for_type("product") subset_data = subset["data"] product_type = subset_data.get("family") if not product_type: product_type = subset_data["families"][0] converted_product = { "name": subset["name"], "productType": product_type, "folderId": subset["parent"], } entity_id = subset.get("_id") if entity_id: converted_product["id"] = entity_id attribs = {} data = {} if "subsetGroup" in subset_data: subset_data["productGroup"] = subset_data.pop("subsetGroup") for key, value in subset_data.items(): if key not in product_attributes: data[key] = value elif value is not None: attribs[key] = value if attribs: converted_product["attrib"] = attribs if data: converted_product["data"] = data return converted_product def convert_create_version_to_v4(version, con): version_attributes = con.get_attributes_for_type("version") converted_version = { "version": version["name"], "productId": version["parent"], } entity_id = version.get("_id") if entity_id: converted_version["id"] = entity_id version_data = version["data"] attribs = {} data = {} for key, value in version_data.items(): if key not in version_attributes: data[key] = value elif value is not None: attribs[key] = value if attribs: converted_version["attrib"] = attribs if data: converted_version["data"] = attribs return converted_version def convert_create_hero_version_to_v4(hero_version, project_name, con): if "version_id" in hero_version: version_id = hero_version["version_id"] version = con.get_version_by_id(project_name, version_id) version["version"] = - version["version"] for auto_key in ( "name", "createdAt", "updatedAt", "author", ): version.pop(auto_key, None) return version version_attributes = con.get_attributes_for_type("version") converted_version = { "version": hero_version["version"], "productId": hero_version["parent"], } entity_id = hero_version.get("_id") if entity_id: converted_version["id"] = entity_id version_data = hero_version["data"] attribs = {} data = {} for key, value in version_data.items(): if key not in version_attributes: data[key] = value elif value is not None: attribs[key] = value if attribs: converted_version["attrib"] = attribs if data: converted_version["data"] = attribs return converted_version def convert_create_representation_to_v4(representation, con): representation_attributes = con.get_attributes_for_type("representation") converted_representation = { "name": representation["name"], "versionId": representation["parent"], } entity_id = representation.get("_id") if entity_id: converted_representation["id"] = entity_id if representation.get("type") == "archived_representation": converted_representation["active"] = False new_files = [] for file_item in representation["files"]: new_file_item = { key: value for key, value in file_item.items() if key in ("hash", "path", "size") } new_file_item.update({ "id": create_entity_id(), "hash_type": "op3", "name": os.path.basename(new_file_item["path"]) }) new_files.append(new_file_item) converted_representation["files"] = new_files context = representation["context"] if "folder" not in context: context["folder"] = { "name": context.get("asset") } context["product"] = { "type": context.pop("family", None), "name": context.pop("subset", None), } attribs = {} data = { "context": context, } representation_data = representation["data"] representation_data["template"] = ( representation_data["template"] .replace("{subset}", "{product[name]}") .replace("{family}", "{product[type]}") ) for key, value in representation_data.items(): if key not in representation_attributes: data[key] = value elif value is not None: attribs[key] = value if attribs: converted_representation["attrib"] = attribs if data: converted_representation["data"] = data return converted_representation def convert_create_workfile_info_to_v4(data, project_name, con): folder_id = data["parent"] task_name = data["task_name"] task = con.get_task_by_name(project_name, folder_id, task_name) if not task: return None workfile_attributes = con.get_attributes_for_type("workfile") filename = data["filename"] possible_attribs = { "extension": os.path.splitext(filename)[-1] } attribs = {} for attr in workfile_attributes: if attr in possible_attribs: attribs[attr] = possible_attribs[attr] output = { "path": data["files"][0], "name": filename, "taskId": task["id"] } if "_id" in data: output["id"] = data["_id"] if attribs: output["attrib"] = attribs output_data = data.get("data") if output_data: output["data"] = output_data return output def _from_flat_dict(data): output = {} for key, value in data.items(): output_value = output subkeys = key.split(".") last_key = subkeys.pop(-1) for subkey in subkeys: if subkey not in output_value: output_value[subkey] = {} output_value = output_value[subkey] output_value[last_key] = value return output def _to_flat_dict(data): output = {} flat_queue = collections.deque() flat_queue.append(([], data)) while flat_queue: item = flat_queue.popleft() parent_keys, data = item for key, value in data.items(): keys = list(parent_keys) keys.append(key) if isinstance(value, dict): flat_queue.append((keys, value)) else: full_key = ".".join(keys) output[full_key] = value return output def convert_update_folder_to_v4(project_name, asset_id, update_data, con): new_update_data = {} folder_attributes = con.get_attributes_for_type("folder") full_update_data = _from_flat_dict(update_data) data = full_update_data.get("data") has_new_parent = False has_task_changes = False parent_id = None tasks = None new_data = {} attribs = full_update_data.pop("attrib", {}) if "type" in update_data: new_update_data["active"] = update_data["type"] == "asset" if data: if "thumbnail_id" in data: new_update_data["thumbnailId"] = data.pop("thumbnail_id") if "tasks" in data: tasks = data.pop("tasks") has_task_changes = True if "visualParent" in data: has_new_parent = True parent_id = data.pop("visualParent") for key, value in data.items(): if key in folder_attributes: attribs[key] = value else: new_data[key] = value if "name" in update_data: new_update_data["name"] = update_data["name"] if "type" in update_data: new_type = update_data["type"] if new_type == "asset": new_update_data["active"] = True elif new_type == "archived_asset": new_update_data["active"] = False if has_new_parent: new_update_data["parentId"] = parent_id if new_data: print("Folder has new data: {}".format(new_data)) new_update_data["data"] = new_data if attribs: new_update_data["attrib"] = attribs if has_task_changes: raise ValueError("Task changes of folder are not implemented") return _to_flat_dict(new_update_data) def convert_update_subset_to_v4(project_name, subset_id, update_data, con): new_update_data = {} product_attributes = con.get_attributes_for_type("product") full_update_data = _from_flat_dict(update_data) data = full_update_data.get("data") new_data = {} attribs = full_update_data.pop("attrib", {}) if data: if "family" in data: family = data.pop("family") new_update_data["productType"] = family if "families" in data: families = data.pop("families") if "productType" not in new_update_data: new_update_data["productType"] = families[0] if "subsetGroup" in data: data["productGroup"] = data.pop("subsetGroup") for key, value in data.items(): if key in product_attributes: if value is REMOVED_VALUE: value = None attribs[key] = value elif value is not REMOVED_VALUE: new_data[key] = value if "name" in update_data: new_update_data["name"] = update_data["name"] if "type" in update_data: new_type = update_data["type"] if new_type == "subset": new_update_data["active"] = True elif new_type == "archived_subset": new_update_data["active"] = False if "parent" in update_data: new_update_data["folderId"] = update_data["parent"] flat_data = _to_flat_dict(new_update_data) if attribs: flat_data["attrib"] = attribs if new_data: print("Subset has new data: {}".format(new_data)) flat_data["data"] = new_data return flat_data def convert_update_version_to_v4(project_name, version_id, update_data, con): new_update_data = {} version_attributes = con.get_attributes_for_type("version") full_update_data = _from_flat_dict(update_data) data = full_update_data.get("data") new_data = {} attribs = full_update_data.pop("attrib", {}) if data: if "author" in data: new_update_data["author"] = data.pop("author") if "thumbnail_id" in data: new_update_data["thumbnailId"] = data.pop("thumbnail_id") for key, value in data.items(): if key in version_attributes: if value is REMOVED_VALUE: value = None attribs[key] = value elif value is not REMOVED_VALUE: new_data[key] = value if "name" in update_data: new_update_data["version"] = update_data["name"] if "type" in update_data: new_type = update_data["type"] if new_type == "version": new_update_data["active"] = True elif new_type == "archived_version": new_update_data["active"] = False if "parent" in update_data: new_update_data["productId"] = update_data["parent"] flat_data = _to_flat_dict(new_update_data) if attribs: flat_data["attrib"] = attribs if new_data: print("Version has new data: {}".format(new_data)) flat_data["data"] = new_data return flat_data def convert_update_hero_version_to_v4( project_name, hero_version_id, update_data, con ): if "version_id" not in update_data: return None version_id = update_data["version_id"] hero_version = con.get_hero_version_by_id(project_name, hero_version_id) version = con.get_version_by_id(project_name, version_id) version["version"] = - version["version"] version["id"] = hero_version_id for auto_key in ( "name", "createdAt", "updatedAt", "author", ): version.pop(auto_key, None) return prepare_entity_changes(hero_version, version) def convert_update_representation_to_v4( project_name, repre_id, update_data, con ): new_update_data = {} folder_attributes = con.get_attributes_for_type("folder") full_update_data = _from_flat_dict(update_data) data = full_update_data.get("data") new_data = {} attribs = full_update_data.pop("attrib", {}) if data: for key, value in data.items(): if key in folder_attributes: attribs[key] = value else: new_data[key] = value if "template" in attribs: attribs["template"] = ( attribs["template"] .replace("{family}", "{product[type]}") .replace("{subset}", "{product[name]}") ) if "name" in update_data: new_update_data["name"] = update_data["name"] if "type" in update_data: new_type = update_data["type"] if new_type == "representation": new_update_data["active"] = True elif new_type == "archived_representation": new_update_data["active"] = False if "parent" in update_data: new_update_data["versionId"] = update_data["parent"] if "context" in update_data: context = update_data["context"] if "folder" not in context and "asset" in context: context["folder"] = {"name": context.pop("asset")} if "family" in context or "subset" in context: context["product"] = { "name": context.pop("subset"), "type": context.pop("family"), } new_data["context"] = context if "files" in update_data: new_files = update_data["files"] if isinstance(new_files, dict): new_files = list(new_files.values()) for item in new_files: for key in tuple(item.keys()): if key not in ("hash", "path", "size"): item.pop(key) item.update({ "id": create_entity_id(), "name": os.path.basename(item["path"]), "hash_type": "op3", }) new_update_data["files"] = new_files flat_data = _to_flat_dict(new_update_data) if attribs: flat_data["attrib"] = attribs if new_data: print("Representation has new data: {}".format(new_data)) flat_data["data"] = new_data return flat_data def convert_update_workfile_info_to_v4( project_name, workfile_id, update_data, con ): return { key: value for key, value in update_data.items() if key.startswith("data") } ================================================ FILE: openpype/client/server/entities.py ================================================ import collections from openpype.client.mongo.operations import CURRENT_THUMBNAIL_SCHEMA from .utils import get_ayon_server_api_connection from .openpype_comp import get_folders_with_tasks from .conversion_utils import ( project_fields_v3_to_v4, convert_v4_project_to_v3, folder_fields_v3_to_v4, convert_v4_folder_to_v3, subset_fields_v3_to_v4, convert_v4_subset_to_v3, version_fields_v3_to_v4, convert_v4_version_to_v3, representation_fields_v3_to_v4, convert_v4_representation_to_v3, workfile_info_fields_v3_to_v4, convert_v4_workfile_info_to_v3, ) def get_projects(active=True, inactive=False, library=None, fields=None): if not active and not inactive: return if active and inactive: active = None elif active: active = True elif inactive: active = False con = get_ayon_server_api_connection() fields = project_fields_v3_to_v4(fields, con) for project in con.get_projects(active, library, fields=fields): yield convert_v4_project_to_v3(project) def get_project(project_name, active=True, inactive=False, fields=None): # Skip if both are disabled con = get_ayon_server_api_connection() fields = project_fields_v3_to_v4(fields, con) return convert_v4_project_to_v3( con.get_project(project_name, fields=fields) ) def get_whole_project(*args, **kwargs): raise NotImplementedError("'get_whole_project' not implemented") def _get_subsets( project_name, subset_ids=None, subset_names=None, folder_ids=None, names_by_folder_ids=None, archived=False, fields=None ): # Convert fields and add minimum required fields con = get_ayon_server_api_connection() fields = subset_fields_v3_to_v4(fields, con) if fields is not None: for key in ( "id", "active" ): fields.add(key) active = True if archived: active = None for subset in con.get_products( project_name, product_ids=subset_ids, product_names=subset_names, folder_ids=folder_ids, names_by_folder_ids=names_by_folder_ids, active=active, fields=fields, ): yield convert_v4_subset_to_v3(subset) def _get_versions( project_name, version_ids=None, subset_ids=None, versions=None, hero=True, standard=True, latest=None, active=None, fields=None ): con = get_ayon_server_api_connection() fields = version_fields_v3_to_v4(fields, con) # Make sure 'productId' and 'version' are available when hero versions # are queried if fields and hero: fields = set(fields) fields |= {"productId", "version"} queried_versions = con.get_versions( project_name, version_ids=version_ids, product_ids=subset_ids, versions=versions, hero=hero, standard=standard, latest=latest, active=active, fields=fields ) version_entities = [] hero_versions = [] for version in queried_versions: if version["version"] < 0: hero_versions.append(version) else: version_entities.append(convert_v4_version_to_v3(version)) if hero_versions: subset_ids = set() versions_nums = set() for hero_version in hero_versions: versions_nums.add(abs(hero_version["version"])) subset_ids.add(hero_version["productId"]) hero_eq_versions = con.get_versions( project_name, product_ids=subset_ids, versions=versions_nums, hero=False, fields=["id", "version", "productId"] ) hero_eq_by_subset_id = collections.defaultdict(list) for version in hero_eq_versions: hero_eq_by_subset_id[version["productId"]].append(version) for hero_version in hero_versions: abs_version = abs(hero_version["version"]) subset_id = hero_version["productId"] version_id = None for version in hero_eq_by_subset_id.get(subset_id, []): if version["version"] == abs_version: version_id = version["id"] break conv_hero = convert_v4_version_to_v3(hero_version) conv_hero["version_id"] = version_id version_entities.append(conv_hero) return version_entities def get_asset_by_id(project_name, asset_id, fields=None): assets = get_assets( project_name, asset_ids=[asset_id], fields=fields ) for asset in assets: return asset return None def get_asset_by_name(project_name, asset_name, fields=None): assets = get_assets( project_name, asset_names=[asset_name], fields=fields ) for asset in assets: return asset return None def _folders_query(project_name, con, fields, **kwargs): if fields is None or "tasks" in fields: folders = get_folders_with_tasks( con, project_name, fields=fields, **kwargs ) else: folders = con.get_folders(project_name, fields=fields, **kwargs) for folder in folders: yield folder def get_assets( project_name, asset_ids=None, asset_names=None, parent_ids=None, archived=False, fields=None ): if not project_name: return active = True if archived: active = None con = get_ayon_server_api_connection() fields = folder_fields_v3_to_v4(fields, con) kwargs = dict( folder_ids=asset_ids, parent_ids=parent_ids, active=active, ) if not asset_names: for folder in _folders_query(project_name, con, fields, **kwargs): yield convert_v4_folder_to_v3(folder, project_name) return new_asset_names = set() folder_paths = set() for name in asset_names: if "/" in name: folder_paths.add(name) else: new_asset_names.add(name) yielded_ids = set() if folder_paths: for folder in _folders_query( project_name, con, fields, folder_paths=folder_paths, **kwargs ): yielded_ids.add(folder["id"]) yield convert_v4_folder_to_v3(folder, project_name) if not new_asset_names: return for folder in _folders_query( project_name, con, fields, folder_names=new_asset_names, **kwargs ): if folder["id"] not in yielded_ids: yielded_ids.add(folder["id"]) yield convert_v4_folder_to_v3(folder, project_name) def get_archived_assets( project_name, asset_ids=None, asset_names=None, parent_ids=None, fields=None ): return get_assets( project_name, asset_ids, asset_names, parent_ids, True, fields ) def get_asset_ids_with_subsets(project_name, asset_ids=None): con = get_ayon_server_api_connection() return con.get_folder_ids_with_products(project_name, asset_ids) def get_subset_by_id(project_name, subset_id, fields=None): subsets = get_subsets( project_name, subset_ids=[subset_id], fields=fields ) for subset in subsets: return subset return None def get_subset_by_name(project_name, subset_name, asset_id, fields=None): subsets = get_subsets( project_name, subset_names=[subset_name], asset_ids=[asset_id], fields=fields ) for subset in subsets: return subset return None def get_subsets( project_name, subset_ids=None, subset_names=None, asset_ids=None, names_by_asset_ids=None, archived=False, fields=None ): return _get_subsets( project_name, subset_ids, subset_names, asset_ids, names_by_asset_ids, archived, fields=fields ) def get_subset_families(project_name, subset_ids=None): con = get_ayon_server_api_connection() return con.get_product_type_names(project_name, subset_ids) def get_version_by_id(project_name, version_id, fields=None): versions = get_versions( project_name, version_ids=[version_id], fields=fields, hero=True ) for version in versions: return version return None def get_version_by_name(project_name, version, subset_id, fields=None): versions = get_versions( project_name, subset_ids=[subset_id], versions=[version], fields=fields ) for version in versions: return version return None def get_versions( project_name, version_ids=None, subset_ids=None, versions=None, hero=False, fields=None ): return _get_versions( project_name, version_ids, subset_ids, versions, hero=hero, standard=True, fields=fields ) def get_hero_version_by_id(project_name, version_id, fields=None): versions = get_hero_versions( project_name, version_ids=[version_id], fields=fields ) for version in versions: return version return None def get_hero_version_by_subset_id( project_name, subset_id, fields=None ): versions = get_hero_versions( project_name, subset_ids=[subset_id], fields=fields ) for version in versions: return version return None def get_hero_versions( project_name, subset_ids=None, version_ids=None, fields=None ): return _get_versions( project_name, version_ids=version_ids, subset_ids=subset_ids, hero=True, standard=False, fields=fields ) def get_last_versions(project_name, subset_ids, active=None, fields=None): if fields: fields = set(fields) fields.add("parent") versions = _get_versions( project_name, subset_ids=subset_ids, latest=True, hero=False, active=active, fields=fields ) return { version["parent"]: version for version in versions } def get_last_version_by_subset_id(project_name, subset_id, fields=None): versions = _get_versions( project_name, subset_ids=[subset_id], latest=True, hero=False, fields=fields ) if not versions: return None return versions[0] def get_last_version_by_subset_name( project_name, subset_name, asset_id=None, asset_name=None, fields=None ): if not asset_id and not asset_name: return None if not asset_id: asset = get_asset_by_name( project_name, asset_name, fields=["_id"] ) if not asset: return None asset_id = asset["_id"] subset = get_subset_by_name( project_name, subset_name, asset_id, fields=["_id"] ) if not subset: return None return get_last_version_by_subset_id( project_name, subset["_id"], fields=fields ) def get_output_link_versions(project_name, version_id, fields=None): if not version_id: return [] con = get_ayon_server_api_connection() version_links = con.get_version_links( project_name, version_id, link_direction="out") version_ids = { link["entityId"] for link in version_links if link["entityType"] == "version" } if not version_ids: return [] return get_versions(project_name, version_ids=version_ids, fields=fields) def version_is_latest(project_name, version_id): con = get_ayon_server_api_connection() return con.version_is_latest(project_name, version_id) def get_representation_by_id(project_name, representation_id, fields=None): representations = get_representations( project_name, representation_ids=[representation_id], fields=fields ) for representation in representations: return representation return None def get_representation_by_name( project_name, representation_name, version_id, fields=None ): representations = get_representations( project_name, representation_names=[representation_name], version_ids=[version_id], fields=fields ) for representation in representations: return representation return None def get_representations( project_name, representation_ids=None, representation_names=None, version_ids=None, context_filters=None, names_by_version_ids=None, archived=False, standard=True, fields=None ): if context_filters is not None: # TODO should we add the support? # - there was ability to fitler using regex raise ValueError("OP v4 can't filter by representation context.") if not archived and not standard: return if archived and not standard: active = False elif not archived and standard: active = True else: active = None con = get_ayon_server_api_connection() fields = representation_fields_v3_to_v4(fields, con) if fields and active is not None: fields.add("active") representations = con.get_representations( project_name, representation_ids=representation_ids, representation_names=representation_names, version_ids=version_ids, names_by_version_ids=names_by_version_ids, active=active, fields=fields ) for representation in representations: yield convert_v4_representation_to_v3(representation) def get_representation_parents(project_name, representation): if not representation: return None repre_id = representation["_id"] parents_by_repre_id = get_representations_parents( project_name, [representation] ) return parents_by_repre_id[repre_id] def get_representations_parents(project_name, representations): repre_ids = { repre["_id"] for repre in representations } con = get_ayon_server_api_connection() parents_by_repre_id = con.get_representations_parents(project_name, repre_ids) folder_ids = set() for parents in parents_by_repre_id .values(): folder_ids.add(parents[2]["id"]) tasks_by_folder_id = {} new_parents = {} for repre_id, parents in parents_by_repre_id .items(): version, subset, folder, project = parents folder_tasks = tasks_by_folder_id.get(folder["id"]) or {} folder["tasks"] = folder_tasks new_parents[repre_id] = ( convert_v4_version_to_v3(version), convert_v4_subset_to_v3(subset), convert_v4_folder_to_v3(folder, project_name), project ) return new_parents def get_archived_representations( project_name, representation_ids=None, representation_names=None, version_ids=None, context_filters=None, names_by_version_ids=None, fields=None ): return get_representations( project_name, representation_ids=representation_ids, representation_names=representation_names, version_ids=version_ids, context_filters=context_filters, names_by_version_ids=names_by_version_ids, archived=True, standard=False, fields=fields ) def get_thumbnail( project_name, thumbnail_id, entity_type, entity_id, fields=None ): """Receive thumbnail entity data. Args: project_name (str): Name of project where to look for queried entities. thumbnail_id (Union[str, ObjectId]): Id of thumbnail entity. entity_type (str): Type of entity for which the thumbnail should be received. entity_id (str): Id of entity for which the thumbnail should be received. fields (Iterable[str]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: None: If thumbnail with specified id was not found. Dict: Thumbnail entity data which can be reduced to specified 'fields'. """ if not thumbnail_id or not entity_type or not entity_id: return None if entity_type == "asset": entity_type = "folder" elif entity_type == "hero_version": entity_type = "version" return { "_id": thumbnail_id, "type": "thumbnail", "schema": CURRENT_THUMBNAIL_SCHEMA, "data": { "entity_type": entity_type, "entity_id": entity_id } } def get_thumbnails(project_name, thumbnail_contexts, fields=None): """Get thumbnail entities. Warning: This function is not OpenPype compatible. There is none usage of this function in codebase so there is nothing to convert. The previous implementation cannot be AYON compatible without entity types. """ thumbnail_items = set() for thumbnail_context in thumbnail_contexts: thumbnail_id, entity_type, entity_id = thumbnail_context thumbnail_item = get_thumbnail( project_name, thumbnail_id, entity_type, entity_id ) if thumbnail_item: thumbnail_items.add(thumbnail_item) return list(thumbnail_items) def get_thumbnail_id_from_source(project_name, src_type, src_id): """Receive thumbnail id from source entity. Args: project_name (str): Name of project where to look for queried entities. src_type (str): Type of source entity ('asset', 'version'). src_id (Union[str, ObjectId]): Id of source entity. Returns: ObjectId: Thumbnail id assigned to entity. None: If Source entity does not have any thumbnail id assigned. """ if not src_type or not src_id: return None if src_type == "version": version = get_version_by_id( project_name, src_id, fields=["data.thumbnail_id"] ) or {} return version.get("data", {}).get("thumbnail_id") if src_type == "asset": asset = get_asset_by_id( project_name, src_id, fields=["data.thumbnail_id"] ) or {} return asset.get("data", {}).get("thumbnail_id") return None def get_workfile_info( project_name, asset_id, task_name, filename, fields=None ): if not asset_id or not task_name or not filename: return None con = get_ayon_server_api_connection() task = con.get_task_by_name( project_name, asset_id, task_name, fields=["id", "name", "folderId"] ) if not task: return None fields = workfile_info_fields_v3_to_v4(fields) for workfile_info in con.get_workfiles_info( project_name, task_ids=[task["id"]], fields=fields ): if workfile_info["name"] == filename: return convert_v4_workfile_info_to_v3(workfile_info, task) return None ================================================ FILE: openpype/client/server/entity_links.py ================================================ from .utils import get_ayon_server_api_connection from .entities import get_assets, get_representation_by_id def get_linked_asset_ids(project_name, asset_doc=None, asset_id=None): """Extract linked asset ids from asset document. One of asset document or asset id must be passed. Note: Asset links now works only from asset to assets. Args: project_name (str): Project where to look for asset. asset_doc (dict): Asset document from DB. asset_id (str): Asset id to find its document. Returns: List[Union[ObjectId, str]]: Asset ids of input links. """ output = [] if not asset_doc and not asset_id: return output if not asset_id: asset_id = asset_doc["_id"] con = get_ayon_server_api_connection() links = con.get_folder_links(project_name, asset_id, link_direction="in") return [ link["entityId"] for link in links if link["entityType"] == "folder" ] def get_linked_assets( project_name, asset_doc=None, asset_id=None, fields=None ): """Return linked assets based on passed asset document. One of asset document or asset id must be passed. Args: project_name (str): Name of project where to look for queried entities. asset_doc (Dict[str, Any]): Asset document from database. asset_id (Union[ObjectId, str]): Asset id. Can be used instead of asset document. fields (Iterable[str]): Fields that should be returned. All fields are returned if 'None' is passed. Returns: List[Dict[str, Any]]: Asset documents of input links for passed asset doc. """ link_ids = get_linked_asset_ids(project_name, asset_doc, asset_id) if not link_ids: return [] return list(get_assets(project_name, asset_ids=link_ids, fields=fields)) def get_linked_representation_id( project_name, repre_doc=None, repre_id=None, link_type=None, max_depth=None ): """Returns list of linked ids of particular type (if provided). One of representation document or representation id must be passed. Note: Representation links now works only from representation through version back to representations. Todos: Missing depth query. Not sure how it did find more representations in depth, probably links to version? Args: project_name (str): Name of project where look for links. repre_doc (Dict[str, Any]): Representation document. repre_id (Union[ObjectId, str]): Representation id. link_type (str): Type of link (e.g. 'reference', ...). max_depth (int): Limit recursion level. Default: 0 Returns: List[ObjectId] Linked representation ids. """ if repre_doc: repre_id = repre_doc["_id"] if not repre_id and not repre_doc: return [] version_id = None if repre_doc: version_id = repre_doc.get("parent") if not version_id: repre_doc = get_representation_by_id( project_name, repre_id, fields=["parent"] ) if repre_doc: version_id = repre_doc["parent"] if not version_id: return [] if max_depth is None or max_depth == 0: max_depth = 1 link_types = None if link_type: link_types = [link_type] con = get_ayon_server_api_connection() # Store already found version ids to avoid recursion, and also to store # output -> Don't forget to remove 'version_id' at the end!!! linked_version_ids = {version_id} # Each loop of depth will reset this variable versions_to_check = {version_id} for _ in range(max_depth): if not versions_to_check: break versions_links = con.get_versions_links( project_name, versions_to_check, link_types=link_types, link_direction="out") versions_to_check = set() for links in versions_links.values(): for link in links: # Care only about version links if link["entityType"] != "version": continue entity_id = link["entityId"] # Skip already found linked version ids if entity_id in linked_version_ids: continue linked_version_ids.add(entity_id) versions_to_check.add(entity_id) linked_version_ids.remove(version_id) if not linked_version_ids: return [] con = get_ayon_server_api_connection() representations = con.get_representations( project_name, version_ids=linked_version_ids, fields=["id"]) return [ repre["id"] for repre in representations ] ================================================ FILE: openpype/client/server/openpype_comp.py ================================================ import collections import json import six from ayon_api.graphql import GraphQlQuery, FIELD_VALUE, fields_to_dict from .constants import DEFAULT_FOLDER_FIELDS def folders_tasks_graphql_query(fields): query = GraphQlQuery("FoldersQuery") project_name_var = query.add_variable("projectName", "String!") folder_ids_var = query.add_variable("folderIds", "[String!]") parent_folder_ids_var = query.add_variable("parentFolderIds", "[String!]") folder_paths_var = query.add_variable("folderPaths", "[String!]") folder_names_var = query.add_variable("folderNames", "[String!]") has_products_var = query.add_variable("folderHasProducts", "Boolean!") project_field = query.add_field("project") project_field.set_filter("name", project_name_var) folders_field = project_field.add_field_with_edges("folders") folders_field.set_filter("ids", folder_ids_var) folders_field.set_filter("parentIds", parent_folder_ids_var) folders_field.set_filter("names", folder_names_var) folders_field.set_filter("paths", folder_paths_var) folders_field.set_filter("hasProducts", has_products_var) fields = set(fields) fields.discard("tasks") tasks_field = folders_field.add_field_with_edges("tasks") tasks_field.add_field("name") tasks_field.add_field("taskType") nested_fields = fields_to_dict(fields) query_queue = collections.deque() for key, value in nested_fields.items(): query_queue.append((key, value, folders_field)) while query_queue: item = query_queue.popleft() key, value, parent = item field = parent.add_field(key) if value is FIELD_VALUE: continue for k, v in value.items(): query_queue.append((k, v, field)) return query def get_folders_with_tasks( con, project_name, folder_ids=None, folder_paths=None, folder_names=None, parent_ids=None, active=True, fields=None ): """Query folders with tasks from server. This is for v4 compatibility where tasks were stored on assets. This is an inefficient way how folders and tasks are queried so it was added only as compatibility function. Todos: Folder name won't be unique identifier, so we should add folder path filtering. Notes: Filter 'active' don't have direct filter in GraphQl. Args: con (ServerAPI): Connection to server. project_name (str): Name of project where folders are. folder_ids (Iterable[str]): Folder ids to filter. folder_paths (Iterable[str]): Folder paths used for filtering. folder_names (Iterable[str]): Folder names used for filtering. parent_ids (Iterable[str]): Ids of folder parents. Use 'None' if folder is direct child of project. active (Union[bool, None]): Filter active/inactive folders. Both are returned if is set to None. fields (Union[Iterable(str), None]): Fields to be queried for folder. All possible folder fields are returned if 'None' is passed. Yields: Dict[str, Any]: Queried folder entities. """ if not project_name: return filters = { "projectName": project_name } if folder_ids is not None: folder_ids = set(folder_ids) if not folder_ids: return filters["folderIds"] = list(folder_ids) if folder_paths is not None: folder_paths = set(folder_paths) if not folder_paths: return filters["folderPaths"] = list(folder_paths) if folder_names is not None: folder_names = set(folder_names) if not folder_names: return filters["folderNames"] = list(folder_names) if parent_ids is not None: parent_ids = set(parent_ids) if not parent_ids: return if None in parent_ids: # Replace 'None' with '"root"' which is used during GraphQl # query for parent ids filter for folders without folder # parent parent_ids.remove(None) parent_ids.add("root") if project_name in parent_ids: # Replace project name with '"root"' which is used during # GraphQl query for parent ids filter for folders without # folder parent parent_ids.remove(project_name) parent_ids.add("root") filters["parentFolderIds"] = list(parent_ids) if fields: fields = set(fields) else: fields = con.get_default_fields_for_type("folder") fields |= DEFAULT_FOLDER_FIELDS if active is not None: fields.add("active") query = folders_tasks_graphql_query(fields) for attr, filter_value in filters.items(): query.set_variable_value(attr, filter_value) parsed_data = query.query(con) folders = parsed_data["project"]["folders"] for folder in folders: if active is not None and folder["active"] is not active: continue folder_data = folder.get("data") if isinstance(folder_data, six.string_types): folder["data"] = json.loads(folder_data) yield folder ================================================ FILE: openpype/client/server/operations.py ================================================ import copy import json import collections import uuid import datetime from bson.objectid import ObjectId from openpype.client.operations_base import ( REMOVED_VALUE, CreateOperation, UpdateOperation, DeleteOperation, BaseOperationsSession ) from openpype.client.mongo.operations import ( CURRENT_THUMBNAIL_SCHEMA, CURRENT_REPRESENTATION_SCHEMA, CURRENT_HERO_VERSION_SCHEMA, CURRENT_VERSION_SCHEMA, CURRENT_SUBSET_SCHEMA, CURRENT_ASSET_DOC_SCHEMA, CURRENT_PROJECT_SCHEMA, ) from .conversion_utils import ( convert_create_asset_to_v4, convert_create_task_to_v4, convert_create_subset_to_v4, convert_create_version_to_v4, convert_create_hero_version_to_v4, convert_create_representation_to_v4, convert_create_workfile_info_to_v4, convert_update_folder_to_v4, convert_update_subset_to_v4, convert_update_version_to_v4, convert_update_hero_version_to_v4, convert_update_representation_to_v4, convert_update_workfile_info_to_v4, ) from .utils import create_entity_id, get_ayon_server_api_connection def _create_or_convert_to_id(entity_id=None): if entity_id is None: return create_entity_id() if isinstance(entity_id, ObjectId): raise TypeError("Type of 'ObjectId' is not supported anymore.") # Validate if can be converted to uuid uuid.UUID(entity_id) return entity_id def new_project_document( project_name, project_code, config, data=None, entity_id=None ): """Create skeleton data of project document. Args: project_name (str): Name of project. Used as identifier of a project. project_code (str): Shorter version of projet without spaces and special characters (in most of cases). Should be also considered as unique name across projects. config (Dic[str, Any]): Project config consist of roots, templates, applications and other project Anatomy related data. data (Dict[str, Any]): Project data with information about it's attributes (e.g. 'fps' etc.) or integration specific keys. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of project document. """ if data is None: data = {} data["code"] = project_code return { "_id": _create_or_convert_to_id(entity_id), "name": project_name, "type": CURRENT_PROJECT_SCHEMA, "entity_data": data, "config": config } def new_asset_document( name, project_id, parent_id, parents, data=None, entity_id=None ): """Create skeleton data of asset document. Args: name (str): Is considered as unique identifier of asset in project. project_id (Union[str, ObjectId]): Id of project doument. parent_id (Union[str, ObjectId]): Id of parent asset. parents (List[str]): List of parent assets names. data (Dict[str, Any]): Asset document data. Empty dictionary is used if not passed. Value of 'parent_id' is used to fill 'visualParent'. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of asset document. """ if data is None: data = {} if parent_id is not None: parent_id = _create_or_convert_to_id(parent_id) data["visualParent"] = parent_id data["parents"] = parents return { "_id": _create_or_convert_to_id(entity_id), "type": "asset", "name": name, # This will be ignored "parent": project_id, "data": data, "schema": CURRENT_ASSET_DOC_SCHEMA } def new_subset_document(name, family, asset_id, data=None, entity_id=None): """Create skeleton data of subset document. Args: name (str): Is considered as unique identifier of subset under asset. family (str): Subset's family. asset_id (Union[str, ObjectId]): Id of parent asset. data (Dict[str, Any]): Subset document data. Empty dictionary is used if not passed. Value of 'family' is used to fill 'family'. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of subset document. """ if data is None: data = {} data["family"] = family return { "_id": _create_or_convert_to_id(entity_id), "schema": CURRENT_SUBSET_SCHEMA, "type": "subset", "name": name, "data": data, "parent": _create_or_convert_to_id(asset_id) } def new_version_doc(version, subset_id, data=None, entity_id=None): """Create skeleton data of version document. Args: version (int): Is considered as unique identifier of version under subset. subset_id (Union[str, ObjectId]): Id of parent subset. data (Dict[str, Any]): Version document data. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of version document. """ if data is None: data = {} return { "_id": _create_or_convert_to_id(entity_id), "schema": CURRENT_VERSION_SCHEMA, "type": "version", "name": int(version), "parent": _create_or_convert_to_id(subset_id), "data": data } def new_hero_version_doc(subset_id, data, version=None, entity_id=None): """Create skeleton data of hero version document. Args: subset_id (Union[str, ObjectId]): Id of parent subset. data (Dict[str, Any]): Version document data. version (int): Version of source version. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of version document. """ if version is None: version = -1 elif version > 0: version = -version return { "_id": _create_or_convert_to_id(entity_id), "schema": CURRENT_HERO_VERSION_SCHEMA, "type": "hero_version", "version": version, "parent": _create_or_convert_to_id(subset_id), "data": data } def new_representation_doc( name, version_id, context, data=None, entity_id=None ): """Create skeleton data of representation document. Args: name (str): Representation name considered as unique identifier of representation under version. version_id (Union[str, ObjectId]): Id of parent version. context (Dict[str, Any]): Representation context used for fill template of to query. data (Dict[str, Any]): Representation document data. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of version document. """ if data is None: data = {} return { "_id": _create_or_convert_to_id(entity_id), "schema": CURRENT_REPRESENTATION_SCHEMA, "type": "representation", "parent": _create_or_convert_to_id(version_id), "name": name, "data": data, # Imprint shortcut to context for performance reasons. "context": context } def new_thumbnail_doc(data=None, entity_id=None): """Create skeleton data of thumbnail document. Args: data (Dict[str, Any]): Thumbnail document data. entity_id (Union[str, ObjectId]): Predefined id of document. New id is created if not passed. Returns: Dict[str, Any]: Skeleton of thumbnail document. """ if data is None: data = {} return { "_id": _create_or_convert_to_id(entity_id), "type": "thumbnail", "schema": CURRENT_THUMBNAIL_SCHEMA, "data": data } def new_workfile_info_doc( filename, asset_id, task_name, files, data=None, entity_id=None ): """Create skeleton data of workfile info document. Workfile document is at this moment used primarily for artist notes. Args: filename (str): Filename of workfile. asset_id (Union[str, ObjectId]): Id of asset under which workfile live. task_name (str): Task under which was workfile created. files (List[str]): List of rootless filepaths related to workfile. data (Dict[str, Any]): Additional metadata. Returns: Dict[str, Any]: Skeleton of workfile info document. """ if not data: data = {} return { "_id": _create_or_convert_to_id(entity_id), "type": "workfile", "parent": _create_or_convert_to_id(asset_id), "task_name": task_name, "filename": filename, "data": data, "files": files } def _prepare_update_data(old_doc, new_doc, replace): changes = {} for key, value in new_doc.items(): if key not in old_doc or value != old_doc[key]: changes[key] = value if replace: for key in old_doc.keys(): if key not in new_doc: changes[key] = REMOVED_VALUE return changes def prepare_subset_update_data(old_doc, new_doc, replace=True): """Compare two subset documents and prepare update data. Based on compared values will create update data for 'MongoUpdateOperation'. Empty output means that documents are identical. Returns: Dict[str, Any]: Changes between old and new document. """ return _prepare_update_data(old_doc, new_doc, replace) def prepare_version_update_data(old_doc, new_doc, replace=True): """Compare two version documents and prepare update data. Based on compared values will create update data for 'MongoUpdateOperation'. Empty output means that documents are identical. Returns: Dict[str, Any]: Changes between old and new document. """ return _prepare_update_data(old_doc, new_doc, replace) def prepare_hero_version_update_data(old_doc, new_doc, replace=True): """Compare two hero version documents and prepare update data. Based on compared values will create update data for 'UpdateOperation'. Empty output means that documents are identical. Returns: Dict[str, Any]: Changes between old and new document. """ changes = _prepare_update_data(old_doc, new_doc, replace) changes.pop("version_id", None) return changes def prepare_representation_update_data(old_doc, new_doc, replace=True): """Compare two representation documents and prepare update data. Based on compared values will create update data for 'MongoUpdateOperation'. Empty output means that documents are identical. Returns: Dict[str, Any]: Changes between old and new document. """ changes = _prepare_update_data(old_doc, new_doc, replace) context = changes.get("data", {}).get("context") # Make sure that both 'family' and 'subset' are in changes if # one of them changed (they'll both become 'product'). if ( context and ("family" in context or "subset" in context) ): context["family"] = new_doc["data"]["context"]["family"] context["subset"] = new_doc["data"]["context"]["subset"] return changes def prepare_workfile_info_update_data(old_doc, new_doc, replace=True): """Compare two workfile info documents and prepare update data. Based on compared values will create update data for 'MongoUpdateOperation'. Empty output means that documents are identical. Returns: Dict[str, Any]: Changes between old and new document. """ return _prepare_update_data(old_doc, new_doc, replace) class FailedOperations(Exception): pass def entity_data_json_default(value): if isinstance(value, datetime.datetime): return int(value.timestamp()) raise TypeError( "Object of type {} is not JSON serializable".format(str(type(value))) ) def failed_json_default(value): return "< Failed value {} > {}".format(type(value), str(value)) class ServerCreateOperation(CreateOperation): """Operation to create an entity. Args: project_name (str): On which project operation will happen. entity_type (str): Type of entity on which change happens. e.g. 'asset', 'representation' etc. data (Dict[str, Any]): Data of entity that will be created. """ def __init__(self, project_name, entity_type, data, session): self._session = session if not data: data = {} data = copy.deepcopy(data) if entity_type == "project": raise ValueError("Project cannot be created using operations") tasks = None if entity_type in "asset": # TODO handle tasks entity_type = "folder" if "data" in data: tasks = data["data"].get("tasks") project = self._session.get_project(project_name) new_data = convert_create_asset_to_v4(data, project, self.con) elif entity_type == "task": project = self._session.get_project(project_name) new_data = convert_create_task_to_v4(data, project, self.con) elif entity_type == "subset": new_data = convert_create_subset_to_v4(data, self.con) entity_type = "product" elif entity_type == "version": new_data = convert_create_version_to_v4(data, self.con) elif entity_type == "hero_version": new_data = convert_create_hero_version_to_v4( data, project_name, self.con ) entity_type = "version" elif entity_type in ("representation", "archived_representation"): new_data = convert_create_representation_to_v4(data, self.con) entity_type = "representation" elif entity_type == "workfile": new_data = convert_create_workfile_info_to_v4( data, project_name, self.con ) else: raise ValueError( "Unhandled entity type \"{}\"".format(entity_type) ) # Simple check if data can be dumped into json # - should raise error on 'ObjectId' object try: new_data = json.loads( json.dumps(new_data, default=entity_data_json_default) ) except: raise ValueError("Couldn't json parse body: {}".format( json.dumps(new_data, default=failed_json_default) )) super(ServerCreateOperation, self).__init__( project_name, entity_type, new_data ) if "id" not in self._data: self._data["id"] = create_entity_id() if tasks: copied_tasks = copy.deepcopy(tasks) for task_name, task in copied_tasks.items(): task["name"] = task_name task["folderId"] = self._data["id"] self.session.create_entity( project_name, "task", task, nested_id=self.id ) @property def con(self): return self.session.con @property def session(self): return self._session @property def entity_id(self): return self._data["id"] def to_server_operation(self): return { "id": self.id, "type": "create", "entityType": self.entity_type, "entityId": self.entity_id, "data": self._data } class ServerUpdateOperation(UpdateOperation): """Operation to update an entity. Args: project_name (str): On which project operation will happen. entity_type (str): Type of entity on which change happens. e.g. 'asset', 'representation' etc. entity_id (Union[str, ObjectId]): Identifier of an entity. update_data (Dict[str, Any]): Key -> value changes that will be set in database. If value is set to 'REMOVED_VALUE' the key will be removed. Only first level of dictionary is checked (on purpose). """ def __init__( self, project_name, entity_type, entity_id, update_data, session ): self._session = session update_data = copy.deepcopy(update_data) if entity_type == "project": raise ValueError("Project cannot be created using operations") if entity_type in ("asset", "archived_asset"): new_update_data = convert_update_folder_to_v4( project_name, entity_id, update_data, self.con ) entity_type = "folder" elif entity_type == "subset": new_update_data = convert_update_subset_to_v4( project_name, entity_id, update_data, self.con ) entity_type = "product" elif entity_type == "version": new_update_data = convert_update_version_to_v4( project_name, entity_id, update_data, self.con ) elif entity_type == "hero_version": new_update_data = convert_update_hero_version_to_v4( project_name, entity_id, update_data, self.con ) entity_type = "version" elif entity_type in ("representation", "archived_representation"): new_update_data = convert_update_representation_to_v4( project_name, entity_id, update_data, self.con ) entity_type = "representation" elif entity_type == "workfile": new_update_data = convert_update_workfile_info_to_v4( project_name, entity_id, update_data, self.con ) else: raise ValueError( "Unhandled entity type \"{}\"".format(entity_type) ) try: new_update_data = json.loads( json.dumps(new_update_data, default=entity_data_json_default) ) except: raise ValueError("Couldn't json parse body: {}".format( json.dumps(new_update_data, default=failed_json_default) )) super(ServerUpdateOperation, self).__init__( project_name, entity_type, entity_id, new_update_data ) @property def con(self): return self.session.con @property def session(self): return self._session def to_server_operation(self): if not self._update_data: return None update_data = {} for key, value in self._update_data.items(): if value is REMOVED_VALUE: value = None update_data[key] = value return { "id": self.id, "type": "update", "entityType": self.entity_type, "entityId": self.entity_id, "data": update_data } class ServerDeleteOperation(DeleteOperation): """Operation to delete an entity. Args: project_name (str): On which project operation will happen. entity_type (str): Type of entity on which change happens. e.g. 'asset', 'representation' etc. entity_id (Union[str, ObjectId]): Entity id that will be removed. """ def __init__(self, project_name, entity_type, entity_id, session): self._session = session if entity_type == "asset": entity_type = "folder" elif entity_type == "hero_version": entity_type = "version" elif entity_type == "subset": entity_type = "product" super(ServerDeleteOperation, self).__init__( project_name, entity_type, entity_id ) @property def con(self): return self.session.con @property def session(self): return self._session def to_server_operation(self): return { "id": self.id, "type": self.operation_name, "entityId": self.entity_id, "entityType": self.entity_type, } class OperationsSession(BaseOperationsSession): def __init__(self, con=None, *args, **kwargs): super(OperationsSession, self).__init__(*args, **kwargs) if con is None: con = get_ayon_server_api_connection() self._con = con self._project_cache = {} self._nested_operations = collections.defaultdict(list) @property def con(self): return self._con def get_project(self, project_name): if project_name not in self._project_cache: self._project_cache[project_name] = self.con.get_project( project_name) return copy.deepcopy(self._project_cache[project_name]) def commit(self): """Commit session operations.""" operations, self._operations = self._operations, [] if not operations: return operations_by_project = collections.defaultdict(list) for operation in operations: operations_by_project[operation.project_name].append(operation) body_by_id = {} results = [] for project_name, operations in operations_by_project.items(): operations_body = [] for operation in operations: body = operation.to_server_operation() if body is not None: try: json.dumps(body) except: raise ValueError("Couldn't json parse body: {}".format( json.dumps( body, indent=4, default=failed_json_default ) )) body_by_id[operation.id] = body operations_body.append(body) if operations_body: result = self._con.post( "projects/{}/operations".format(project_name), operations=operations_body, canFail=False ) results.append(result.data) for result in results: if result.get("success"): continue if "operations" not in result: raise FailedOperations( "Operation failed. Content: {}".format(str(result)) ) for op_result in result["operations"]: if not op_result["success"]: operation_id = op_result["id"] raise FailedOperations(( "Operation \"{}\" failed with data:\n{}\nError: {}." ).format( operation_id, json.dumps(body_by_id[operation_id], indent=4), op_result.get("error", "unknown"), )) def create_entity(self, project_name, entity_type, data, nested_id=None): """Fast access to 'ServerCreateOperation'. Args: project_name (str): On which project the creation happens. entity_type (str): Which entity type will be created. data (Dicst[str, Any]): Entity data. nested_id (str): Id of other operation from which is triggered operation -> Operations can trigger suboperations but they must be added to operations list after it's parent is added. Returns: ServerCreateOperation: Object of update operation. """ operation = ServerCreateOperation( project_name, entity_type, data, self ) if nested_id: self._nested_operations[nested_id].append(operation) else: self.add(operation) if operation.id in self._nested_operations: self.extend(self._nested_operations.pop(operation.id)) return operation def update_entity( self, project_name, entity_type, entity_id, update_data, nested_id=None ): """Fast access to 'ServerUpdateOperation'. Returns: ServerUpdateOperation: Object of update operation. """ operation = ServerUpdateOperation( project_name, entity_type, entity_id, update_data, self ) if nested_id: self._nested_operations[nested_id].append(operation) else: self.add(operation) if operation.id in self._nested_operations: self.extend(self._nested_operations.pop(operation.id)) return operation def delete_entity( self, project_name, entity_type, entity_id, nested_id=None ): """Fast access to 'ServerDeleteOperation'. Returns: ServerDeleteOperation: Object of delete operation. """ operation = ServerDeleteOperation( project_name, entity_type, entity_id, self ) if nested_id: self._nested_operations[nested_id].append(operation) else: self.add(operation) if operation.id in self._nested_operations: self.extend(self._nested_operations.pop(operation.id)) return operation def create_project( project_name, project_code, library_project=False, preset_name=None, con=None ): """Create project using OpenPype settings. This project creation function is not validating project document on creation. It is because project document is created blindly with only minimum required information about project which is it's name, code, type and schema. Entered project name must be unique and project must not exist yet. Note: This function is here to be OP v4 ready but in v3 has more logic to do. That's why inner imports are in the body. Args: project_name (str): New project name. Should be unique. project_code (str): Project's code should be unique too. library_project (bool): Project is library project. preset_name (str): Name of anatomy preset. Default is used if not passed. con (ServerAPI): Connection to server with logged user. Raises: ValueError: When project name already exists in MongoDB. Returns: dict: Created project document. """ if con is None: con = get_ayon_server_api_connection() return con.create_project( project_name, project_code, library_project, preset_name ) def delete_project(project_name, con=None): if con is None: con = get_ayon_server_api_connection() return con.delete_project(project_name) def create_thumbnail(project_name, src_filepath, thumbnail_id=None, con=None): if con is None: con = get_ayon_server_api_connection() return con.create_thumbnail(project_name, src_filepath, thumbnail_id) ================================================ FILE: openpype/client/server/thumbnails.py ================================================ """Cache of thumbnails downloaded from AYON server. Thumbnails are cached to appdirs to predefined directory. This should be moved to thumbnails logic in pipeline but because it would overflow OpenPype logic it's here for now. """ import os import time import collections import appdirs FileInfo = collections.namedtuple( "FileInfo", ("path", "size", "modification_time") ) class AYONThumbnailCache: """Cache of thumbnails on local storage. Thumbnails are cached to appdirs to predefined directory. Each project has own subfolder with thumbnails -> that's because each project has own thumbnail id validation and file names are thumbnail ids with matching extension. Extensions are predefined (.png and .jpeg). Cache has cleanup mechanism which is triggered on initialized by default. The cleanup has 2 levels: 1. soft cleanup which remove all files that are older then 'days_alive' 2. max size cleanup which remove all files until the thumbnails folder contains less then 'max_filesize' - this is time consuming so it's not triggered automatically Args: cleanup (bool): Trigger soft cleanup (Cleanup expired thumbnails). """ # Lifetime of thumbnails (in seconds) # - default 3 days days_alive = 3 # Max size of thumbnail directory (in bytes) # - default 2 Gb max_filesize = 2 * 1024 * 1024 * 1024 def __init__(self, cleanup=True): self._thumbnails_dir = None self._days_alive_secs = self.days_alive * 24 * 60 * 60 if cleanup: self.cleanup() def get_thumbnails_dir(self): """Root directory where thumbnails are stored. Returns: str: Path to thumbnails root. """ if self._thumbnails_dir is None: # TODO use generic function directory = appdirs.user_data_dir("AYON", "Ynput") self._thumbnails_dir = os.path.join(directory, "thumbnails") return self._thumbnails_dir thumbnails_dir = property(get_thumbnails_dir) def get_thumbnails_dir_file_info(self): """Get information about all files in thumbnails directory. Returns: List[FileInfo]: List of file information about all files. """ thumbnails_dir = self.thumbnails_dir files_info = [] if not os.path.exists(thumbnails_dir): return files_info for root, _, filenames in os.walk(thumbnails_dir): for filename in filenames: path = os.path.join(root, filename) files_info.append(FileInfo( path, os.path.getsize(path), os.path.getmtime(path) )) return files_info def get_thumbnails_dir_size(self, files_info=None): """Got full size of thumbnail directory. Args: files_info (List[FileInfo]): Prepared file information about files in thumbnail directory. Returns: int: File size of all files in thumbnail directory. """ if files_info is None: files_info = self.get_thumbnails_dir_file_info() if not files_info: return 0 return sum( file_info.size for file_info in files_info ) def cleanup(self, check_max_size=False): """Cleanup thumbnails directory. Args: check_max_size (bool): Also cleanup files to match max size of thumbnails directory. """ thumbnails_dir = self.get_thumbnails_dir() # Skip if thumbnails dir does not exists yet if not os.path.exists(thumbnails_dir): return self._soft_cleanup(thumbnails_dir) if check_max_size: self._max_size_cleanup(thumbnails_dir) def _soft_cleanup(self, thumbnails_dir): current_time = time.time() for root, _, filenames in os.walk(thumbnails_dir): for filename in filenames: path = os.path.join(root, filename) modification_time = os.path.getmtime(path) if current_time - modification_time > self._days_alive_secs: os.remove(path) def _max_size_cleanup(self, thumbnails_dir): files_info = self.get_thumbnails_dir_file_info() size = self.get_thumbnails_dir_size(files_info) if size < self.max_filesize: return sorted_file_info = collections.deque( sorted(files_info, key=lambda item: item.modification_time) ) diff = size - self.max_filesize while diff > 0: if not sorted_file_info: break file_info = sorted_file_info.popleft() diff -= file_info.size os.remove(file_info.path) def get_thumbnail_filepath(self, project_name, thumbnail_id): """Get thumbnail by thumbnail id. Args: project_name (str): Name of project. thumbnail_id (str): Thumbnail id. Returns: Union[str, None]: Path to thumbnail image or None if thumbnail is not cached yet. """ if not thumbnail_id: return None for ext in ( ".png", ".jpeg", ): filepath = os.path.join( self.thumbnails_dir, project_name, thumbnail_id + ext ) if os.path.exists(filepath): return filepath return None def get_project_dir(self, project_name): """Path to root directory for specific project. Args: project_name (str): Name of project for which root directory path should be returned. Returns: str: Path to root of project's thumbnails. """ return os.path.join(self.thumbnails_dir, project_name) def make_sure_project_dir_exists(self, project_name): project_dir = self.get_project_dir(project_name) if not os.path.exists(project_dir): os.makedirs(project_dir) return project_dir def store_thumbnail(self, project_name, thumbnail_id, content, mime_type): """Store thumbnail to cache folder. Args: project_name (str): Project where the thumbnail belong to. thumbnail_id (str): Id of thumbnail. content (bytes): Byte content of thumbnail file. mime_data (str): Type of content. Returns: str: Path to cached thumbnail image file. """ if mime_type == "image/png": ext = ".png" elif mime_type == "image/jpeg": ext = ".jpeg" else: raise ValueError( "Unknown mime type for thumbnail \"{}\"".format(mime_type)) project_dir = self.make_sure_project_dir_exists(project_name) thumbnail_path = os.path.join(project_dir, thumbnail_id + ext) with open(thumbnail_path, "wb") as stream: stream.write(content) current_time = time.time() os.utime(thumbnail_path, (current_time, current_time)) return thumbnail_path ================================================ FILE: openpype/client/server/utils.py ================================================ import os import uuid import ayon_api from openpype.client.operations_base import REMOVED_VALUE class _GlobalCache: initialized = False def get_ayon_server_api_connection(): if _GlobalCache.initialized: con = ayon_api.get_server_api_connection() else: from openpype.lib.local_settings import get_local_site_id _GlobalCache.initialized = True site_id = get_local_site_id() version = os.getenv("AYON_VERSION") if ayon_api.is_connection_created(): con = ayon_api.get_server_api_connection() con.set_site_id(site_id) con.set_client_version(version) else: con = ayon_api.create_connection(site_id, version) return con def create_entity_id(): return uuid.uuid1().hex def prepare_attribute_changes(old_entity, new_entity, replace=False): """Prepare changes of attributes on entities. Compare 'attrib' of old and new entity data to prepare only changed values that should be sent to server for update. Example: >>> # Limited entity data to 'attrib' >>> old_entity = { ... "attrib": {"attr_1": 1, "attr_2": "MyString", "attr_3": True} ... } >>> new_entity = { ... "attrib": {"attr_1": 2, "attr_3": True, "attr_4": 3} ... } >>> # Changes if replacement should not happen >>> expected_changes = { ... "attr_1": 2, ... "attr_4": 3 ... } >>> changes = prepare_attribute_changes(old_entity, new_entity) >>> changes == expected_changes True >>> # Changes if replacement should happen >>> expected_changes_replace = { ... "attr_1": 2, ... "attr_2": REMOVED_VALUE, ... "attr_4": 3 ... } >>> changes_replace = prepare_attribute_changes( ... old_entity, new_entity, True) >>> changes_replace == expected_changes_replace True Args: old_entity (dict[str, Any]): Data of entity queried from server. new_entity (dict[str, Any]): Entity data with applied changes. replace (bool): New entity should fully replace all old entity values. Returns: Dict[str, Any]: Values from new entity only if value has changed. """ attrib_changes = {} new_attrib = new_entity.get("attrib") old_attrib = old_entity.get("attrib") if new_attrib is None: if not replace: return attrib_changes new_attrib = {} if old_attrib is None: return new_attrib for attr, new_attr_value in new_attrib.items(): old_attr_value = old_attrib.get(attr) if old_attr_value != new_attr_value: attrib_changes[attr] = new_attr_value if replace: for attr in old_attrib: if attr not in new_attrib: attrib_changes[attr] = REMOVED_VALUE return attrib_changes def prepare_entity_changes(old_entity, new_entity, replace=False): """Prepare changes of AYON entities. Compare old and new entity to filter values from new data that changed. Args: old_entity (dict[str, Any]): Data of entity queried from server. new_entity (dict[str, Any]): Entity data with applied changes. replace (bool): All attributes should be replaced by new values. So all attribute values that are not on new entity will be removed. Returns: Dict[str, Any]: Only values from new entity that changed. """ changes = {} for key, new_value in new_entity.items(): if key == "attrib": continue old_value = old_entity.get(key) if old_value != new_value: changes[key] = new_value if replace: for key in old_entity: if key not in new_entity: changes[key] = REMOVED_VALUE attr_changes = prepare_attribute_changes(old_entity, new_entity, replace) if attr_changes: changes["attrib"] = attr_changes return changes ================================================ FILE: openpype/hooks/pre_add_last_workfile_arg.py ================================================ import os from openpype.lib.applications import PreLaunchHook, LaunchTypes class AddLastWorkfileToLaunchArgs(PreLaunchHook): """Add last workfile path to launch arguments. This is not possible to do for all applications the same way. Checks 'start_last_workfile', if set to False, it will not open last workfile. This property is set explicitly in Launcher. """ # Execute after workfile template copy order = 10 app_groups = { "3dsmax", "adsk_3dsmax", "maya", "nuke", "nukex", "hiero", "houdini", "nukestudio", "fusion", "blender", "photoshop", "tvpaint", "substancepainter", "aftereffects", "wrap" } launch_types = {LaunchTypes.local} def execute(self): if not self.data.get("start_last_workfile"): self.log.info("It is set to not start last workfile on start.") return last_workfile = self.data.get("last_workfile_path") if not last_workfile: self.log.warning("Last workfile was not collected.") return if not os.path.exists(last_workfile): self.log.info("Current context does not have any workfile yet.") return # Add path to workfile to arguments self.launch_context.launch_args.append(last_workfile) ================================================ FILE: openpype/hooks/pre_copy_template_workfile.py ================================================ import os import shutil from openpype.settings import get_project_settings from openpype.lib.applications import PreLaunchHook, LaunchTypes from openpype.pipeline.workfile import ( get_custom_workfile_template, get_custom_workfile_template_by_string_context ) class CopyTemplateWorkfile(PreLaunchHook): """Copy workfile template. This is not possible to do for all applications the same way. Prelaunch hook works only if last workfile leads to not existing file. - That is possible only if it's first version. """ # Before `AddLastWorkfileToLaunchArgs` order = 0 app_groups = {"blender", "photoshop", "tvpaint", "aftereffects", "wrap"} launch_types = {LaunchTypes.local} def execute(self): """Check if can copy template for context and do it if possible. First check if host for current project should create first workfile. Second check is if template is reachable and can be copied. Args: last_workfile(str): Path where template will be copied. Returns: None: This is a void method. """ last_workfile = self.data.get("last_workfile_path") if not last_workfile: self.log.warning(( "Last workfile was not collected." " Can't add it to launch arguments or determine if should" " copy template." )) return if os.path.exists(last_workfile): self.log.debug("Last workfile exists. Skipping {} process.".format( self.__class__.__name__ )) return self.log.info("Last workfile does not exist.") project_name = self.data["project_name"] asset_name = self.data["asset_name"] task_name = self.data["task_name"] host_name = self.application.host_name project_settings = get_project_settings(project_name) project_doc = self.data.get("project_doc") asset_doc = self.data.get("asset_doc") anatomy = self.data.get("anatomy") if project_doc and asset_doc: self.log.debug("Started filtering of custom template paths.") template_path = get_custom_workfile_template( project_doc, asset_doc, task_name, host_name, anatomy, project_settings ) else: self.log.warning(( "Global data collection probably did not execute." " Using backup solution." )) template_path = get_custom_workfile_template_by_string_context( project_name, asset_name, task_name, host_name, anatomy, project_settings ) if not template_path: self.log.info( "Registered custom templates didn't match current context." ) return if not os.path.exists(template_path): self.log.warning( "Couldn't find workfile template file \"{}\"".format( template_path ) ) return self.log.info( f"Creating workfile from template: \"{template_path}\"" ) # Copy template workfile to new destination shutil.copy2( os.path.normpath(template_path), os.path.normpath(last_workfile) ) ================================================ FILE: openpype/hooks/pre_create_extra_workdir_folders.py ================================================ import os from openpype.lib.applications import PreLaunchHook, LaunchTypes from openpype.pipeline.workfile import create_workdir_extra_folders class CreateWorkdirExtraFolders(PreLaunchHook): """Create extra folders for the work directory. Based on setting `project_settings/global/tools/Workfiles/extra_folders` profile filtering will decide whether extra folders need to be created in the work directory. """ # Execute after workfile template copy order = 15 launch_types = {LaunchTypes.local} def execute(self): if not self.application.is_host: return env = self.data.get("env") or {} workdir = env.get("AVALON_WORKDIR") if not workdir or not os.path.exists(workdir): return host_name = self.application.host_name task_type = self.data["task_type"] task_name = self.data["task_name"] project_name = self.data["project_name"] create_workdir_extra_folders( workdir, host_name, task_type, task_name, project_name, ) ================================================ FILE: openpype/hooks/pre_global_host_data.py ================================================ from openpype.client import get_project, get_asset_by_name from openpype.lib.applications import ( PreLaunchHook, EnvironmentPrepData, prepare_app_environments, prepare_context_environments ) from openpype.pipeline import Anatomy class GlobalHostDataHook(PreLaunchHook): order = -100 launch_types = set() def execute(self): """Prepare global objects to `data` that will be used for sure.""" self.prepare_global_data() if not self.data.get("asset_doc"): return app = self.launch_context.application temp_data = EnvironmentPrepData({ "project_name": self.data["project_name"], "asset_name": self.data["asset_name"], "task_name": self.data["task_name"], "app": app, "project_doc": self.data["project_doc"], "asset_doc": self.data["asset_doc"], "anatomy": self.data["anatomy"], "env": self.launch_context.env, "start_last_workfile": self.data.get("start_last_workfile"), "last_workfile_path": self.data.get("last_workfile_path"), "log": self.log }) prepare_app_environments(temp_data, self.launch_context.env_group) prepare_context_environments(temp_data) temp_data.pop("log") self.data.update(temp_data) def prepare_global_data(self): """Prepare global objects to `data` that will be used for sure.""" # Mongo documents project_name = self.data.get("project_name") if not project_name: self.log.info( "Skipping global data preparation." " Key `project_name` was not found in launch context." ) return self.log.debug("Project name is set to \"{}\"".format(project_name)) # Anatomy self.data["anatomy"] = Anatomy(project_name) # Project document project_doc = get_project(project_name) self.data["project_doc"] = project_doc asset_name = self.data.get("asset_name") if not asset_name: self.log.warning( "Asset name was not set. Skipping asset document query." ) return asset_doc = get_asset_by_name(project_name, asset_name) self.data["asset_doc"] = asset_doc ================================================ FILE: openpype/hooks/pre_mac_launch.py ================================================ import os from openpype.lib.applications import PreLaunchHook, LaunchTypes class LaunchWithTerminal(PreLaunchHook): """Mac specific pre arguments for application. Mac applications should be launched using "open" argument which is internal callbacks to open executable. We also add argument "-a" to tell it's application open. This is used only for executables ending with ".app". It is expected that these executables lead to app packages. """ order = 1000 platforms = {"darwin"} launch_types = {LaunchTypes.local} def execute(self): executable = str(self.launch_context.executable) # Skip executables not ending with ".app" or that are not folder if not executable.endswith(".app") or not os.path.isdir(executable): return # Check if first argument match executable path # - Few applications are not executed directly but through OpenPype # process (Photoshop, AfterEffects, Harmony, ...). These should not # use `open`. if self.launch_context.launch_args[0] != executable: return # Tell `open` to pass arguments if there are any if len(self.launch_context.launch_args) > 1: self.launch_context.launch_args.insert(1, "--args") # Prepend open arguments self.launch_context.launch_args.insert(0, ["open", "-na"]) ================================================ FILE: openpype/hooks/pre_new_console_apps.py ================================================ import subprocess from openpype.lib.applications import PreLaunchHook, LaunchTypes class LaunchNewConsoleApps(PreLaunchHook): """Foundry applications have specific way how to launch them. Nuke is executed "like" python process so it is required to pass `CREATE_NEW_CONSOLE` flag on windows to trigger creation of new console. At the same time the newly created console won't create its own stdout and stderr handlers so they should not be redirected to DEVNULL. """ # Should be as last hook because must change launch arguments to string order = 1000 app_groups = { "nuke", "nukeassist", "nukex", "hiero", "nukestudio", "mayapy" } platforms = {"windows"} launch_types = {LaunchTypes.local} def execute(self): # Change `creationflags` to CREATE_NEW_CONSOLE # - on Windows some apps will create new window using its console # Set `stdout` and `stderr` to None so new created console does not # have redirected output to DEVNULL in build self.launch_context.kwargs.update({ "creationflags": subprocess.CREATE_NEW_CONSOLE, "stdout": None, "stderr": None }) ================================================ FILE: openpype/hooks/pre_non_python_host_launch.py ================================================ import os from openpype.lib import get_openpype_execute_args from openpype.lib.applications import ( get_non_python_host_kwargs, PreLaunchHook, LaunchTypes, ) from openpype import PACKAGE_DIR as OPENPYPE_DIR class NonPythonHostHook(PreLaunchHook): """Launch arguments preparation. Non python host implementation do not launch host directly but use python script which launch the host. For these cases it is necessary to prepend python (or openpype) executable and script path before application's. """ app_groups = {"harmony", "photoshop", "aftereffects"} order = 20 launch_types = {LaunchTypes.local} def execute(self): # Pop executable executable_path = self.launch_context.launch_args.pop(0) # Pop rest of launch arguments - There should not be other arguments! remainders = [] while self.launch_context.launch_args: remainders.append(self.launch_context.launch_args.pop(0)) script_path = os.path.join( OPENPYPE_DIR, "scripts", "non_python_host_launch.py" ) new_launch_args = get_openpype_execute_args( "run", script_path, executable_path ) # Add workfile path if exists workfile_path = self.data["last_workfile_path"] if ( self.data.get("start_last_workfile") and workfile_path and os.path.exists(workfile_path)): new_launch_args.append(workfile_path) # Append as whole list as these areguments should not be separated self.launch_context.launch_args.append(new_launch_args) if remainders: self.launch_context.launch_args.extend(remainders) self.launch_context.kwargs = \ get_non_python_host_kwargs(self.launch_context.kwargs) ================================================ FILE: openpype/hooks/pre_ocio_hook.py ================================================ from openpype.lib.applications import PreLaunchHook from openpype.pipeline.colorspace import get_imageio_config from openpype.pipeline.template_data import get_template_data_with_names class OCIOEnvHook(PreLaunchHook): """Set OCIO environment variable for hosts that use OpenColorIO.""" order = 0 hosts = { "substancepainter", "fusion", "blender", "aftereffects", "3dsmax", "houdini", "maya", "nuke", "hiero", "resolve", } launch_types = set() def execute(self): """Hook entry method.""" template_data = get_template_data_with_names( project_name=self.data["project_name"], asset_name=self.data["asset_name"], task_name=self.data["task_name"], host_name=self.host_name, system_settings=self.data["system_settings"] ) config_data = get_imageio_config( project_name=self.data["project_name"], host_name=self.host_name, project_settings=self.data["project_settings"], anatomy_data=template_data, anatomy=self.data["anatomy"], env=self.launch_context.env, ) if config_data: ocio_path = config_data["path"] if self.host_name in ["nuke", "hiero"]: ocio_path = ocio_path.replace("\\", "/") self.log.info( f"Setting OCIO environment to config path: {ocio_path}") self.launch_context.env["OCIO"] = ocio_path else: self.log.debug("OCIO not set or enabled") ================================================ FILE: openpype/host/__init__.py ================================================ from .host import ( HostBase, ) from .interfaces import ( IWorkfileHost, ILoadHost, IPublishHost, INewPublisher, ) from .dirmap import HostDirmap __all__ = ( "HostBase", "IWorkfileHost", "ILoadHost", "IPublishHost", "INewPublisher", "HostDirmap", ) ================================================ FILE: openpype/host/dirmap.py ================================================ """Dirmap functionality used in host integrations inside DCCs. Idea for current dirmap implementation was used from Maya where is possible to enter source and destination roots and maya will try each found source in referenced file replace with each destination paths. First path which exists is used. """ import os from abc import ABCMeta, abstractmethod import platform import six from openpype.lib import Logger from openpype.modules import ModulesManager from openpype.settings import get_project_settings from openpype.settings.lib import get_site_local_overrides @six.add_metaclass(ABCMeta) class HostDirmap(object): """Abstract class for running dirmap on a workfile in a host. Dirmap is used to translate paths inside of host workfile from one OS to another. (Eg. arstist created workfile on Win, different artists opens same file on Linux.) Expects methods to be implemented inside of host: on_dirmap_enabled: run host code for enabling dirmap do_dirmap: run host code to do actual remapping """ def __init__( self, host_name, project_name, project_settings=None, sync_module=None ): self.host_name = host_name self.project_name = project_name self._project_settings = project_settings self._sync_module = sync_module # to limit reinit of Modules self._sync_module_discovered = sync_module is not None self._log = None @property def sync_module(self): if not self._sync_module_discovered: self._sync_module_discovered = True manager = ModulesManager() self._sync_module = manager.get("sync_server") return self._sync_module @property def project_settings(self): if self._project_settings is None: self._project_settings = get_project_settings(self.project_name) return self._project_settings @property def log(self): if self._log is None: self._log = Logger.get_logger(self.__class__.__name__) return self._log @abstractmethod def on_enable_dirmap(self): """Run host dependent operation for enabling dirmap if necessary.""" pass @abstractmethod def dirmap_routine(self, source_path, destination_path): """Run host dependent remapping from source_path to destination_path""" pass def process_dirmap(self, mapping=None): # type: (dict) -> None """Go through all paths in Settings and set them using `dirmap`. If artists has Site Sync enabled, take dirmap mapping directly from Local Settings when artist is syncing workfile locally. """ if not mapping: mapping = self.get_mappings() if not mapping: return self.on_enable_dirmap() for k, sp in enumerate(mapping["source-path"]): dst = mapping["destination-path"][k] try: # add trailing slash if missing sp = os.path.join(sp, '') dst = os.path.join(dst, '') print("{} -> {}".format(sp, dst)) self.dirmap_routine(sp, dst) except IndexError: # missing corresponding destination path self.log.error(( "invalid dirmap mapping, missing corresponding" " destination directory." )) break except RuntimeError: self.log.error( "invalid path {} -> {}, mapping not registered".format( sp, dst ) ) continue def get_mappings(self): """Get translation from source-path to destination-path. It checks if Site Sync is enabled and user chose to use local site, in that case configuration in Local Settings takes precedence """ dirmap_label = "{}-dirmap".format(self.host_name) mapping_sett = self.project_settings[self.host_name].get(dirmap_label, {}) local_mapping = self._get_local_sync_dirmap() mapping_enabled = mapping_sett.get("enabled") or bool(local_mapping) if not mapping_enabled: return {} mapping = ( local_mapping or mapping_sett["paths"] or {} ) if ( not mapping or not mapping.get("destination-path") or not mapping.get("source-path") ): return {} self.log.info("Processing directory mapping ...") self.log.info("mapping:: {}".format(mapping)) return mapping def _get_local_sync_dirmap(self): """ Returns dirmap if synch to local project is enabled. Only valid mapping is from roots of remote site to local site set in Local Settings. Returns: dict : { "source-path": [XXX], "destination-path": [YYYY]} """ project_name = self.project_name sync_module = self.sync_module mapping = {} if ( sync_module is None or not sync_module.enabled or project_name not in sync_module.get_enabled_projects() ): return mapping active_site = sync_module.get_local_normalized_site( sync_module.get_active_site(project_name)) remote_site = sync_module.get_local_normalized_site( sync_module.get_remote_site(project_name)) self.log.debug( "active {} - remote {}".format(active_site, remote_site) ) if active_site == "local" and active_site != remote_site: sync_settings = sync_module.get_sync_project_setting( project_name, exclude_locals=False, cached=False) active_overrides = get_site_local_overrides( project_name, active_site) remote_overrides = get_site_local_overrides( project_name, remote_site) self.log.debug("local overrides {}".format(active_overrides)) self.log.debug("remote overrides {}".format(remote_overrides)) current_platform = platform.system().lower() remote_provider = sync_module.get_provider_for_site( project_name, remote_site ) # dirmap has sense only with regular disk provider, in the workfile # won't be root on cloud or sftp provider if remote_provider != "local_drive": remote_site = "studio" for root_name, active_site_dir in active_overrides.items(): remote_site_dir = ( remote_overrides.get(root_name) or sync_settings["sites"][remote_site]["root"][root_name] ) if isinstance(remote_site_dir, dict): remote_site_dir = remote_site_dir.get(current_platform) if not remote_site_dir: continue if os.path.isdir(active_site_dir): if "destination-path" not in mapping: mapping["destination-path"] = [] mapping["destination-path"].append(active_site_dir) if "source-path" not in mapping: mapping["source-path"] = [] mapping["source-path"].append(remote_site_dir) self.log.debug("local sync mapping:: {}".format(mapping)) return mapping ================================================ FILE: openpype/host/host.py ================================================ import os import logging import contextlib from abc import ABCMeta, abstractproperty import six # NOTE can't import 'typing' because of issues in Maya 2020 # - shiboken crashes on 'typing' module import @six.add_metaclass(ABCMeta) class HostBase(object): """Base of host implementation class. Host is pipeline implementation of DCC application. This class should help to identify what must/should/can be implemented for specific functionality. Compared to 'avalon' concept: What was before considered as functions in host implementation folder. The host implementation should primarily care about adding ability of creation (mark subsets to be published) and optionally about referencing published representations as containers. Host may need extend some functionality like working with workfiles or loading. Not all host implementations may allow that for those purposes can be logic extended with implementing functions for the purpose. There are prepared interfaces to be able identify what must be implemented to be able use that functionality. - current statement is that it is not required to inherit from interfaces but all of the methods are validated (only their existence!) # Installation of host before (avalon concept): ```python from openpype.pipeline import install_host import openpype.hosts.maya.api as host install_host(host) ``` # Installation of host now: ```python from openpype.pipeline import install_host from openpype.hosts.maya.api import MayaHost host = MayaHost() install_host(host) ``` Todo: - move content of 'install_host' as method of this class - register host object - install legacy_io - install global plugin paths - store registered plugin paths to this object - handle current context (project, asset, task) - this must be done in many separated steps - have it's object of host tools instead of using globals This implementation will probably change over time when more functionality and responsibility will be added. """ _log = None def __init__(self): """Initialization of host. Register DCC callbacks, host specific plugin paths, targets etc. (Part of what 'install' did in 'avalon' concept.) Note: At this moment global "installation" must happen before host installation. Because of this current limitation it is recommended to implement 'install' method which is triggered after global 'install'. """ pass def install(self): """Install host specific functionality. This is where should be added menu with tools, registered callbacks and other host integration initialization. It is called automatically when 'openpype.pipeline.install_host' is triggered. """ pass @property def log(self): if self._log is None: self._log = logging.getLogger(self.__class__.__name__) return self._log @abstractproperty def name(self): """Host name.""" pass def get_current_project_name(self): """ Returns: Union[str, None]: Current project name. """ return os.environ.get("AVALON_PROJECT") def get_current_asset_name(self): """ Returns: Union[str, None]: Current asset name. """ return os.environ.get("AVALON_ASSET") def get_current_task_name(self): """ Returns: Union[str, None]: Current task name. """ return os.environ.get("AVALON_TASK") def get_current_context(self): """Get current context information. This method should be used to get current context of host. Usage of this method can be crucial for host implementations in DCCs where can be opened multiple workfiles at one moment and change of context can't be caught properly. Default implementation returns values from 'legacy_io.Session'. Returns: Dict[str, Union[str, None]]: Context with 3 keys 'project_name', 'asset_name' and 'task_name'. All of them can be 'None'. """ return { "project_name": self.get_current_project_name(), "asset_name": self.get_current_asset_name(), "task_name": self.get_current_task_name() } def get_context_title(self): """Context title shown for UI purposes. Should return current context title if possible. Note: This method is used only for UI purposes so it is possible to return some logical title for contextless cases. Is not meant for "Context menu" label. Returns: str: Context title. None: Default title is used based on UI implementation. """ # Use current context to fill the context title current_context = self.get_current_context() project_name = current_context["project_name"] asset_name = current_context["asset_name"] task_name = current_context["task_name"] items = [] if project_name: items.append(project_name) if asset_name: items.append(asset_name.lstrip("/")) if task_name: items.append(task_name) if items: return "/".join(items) return None @contextlib.contextmanager def maintained_selection(self): """Some functionlity will happen but selection should stay same. This is DCC specific. Some may not allow to implement this ability that is reason why default implementation is empty context manager. Yields: None: Yield when is ready to restore selected at the end. """ try: yield finally: pass ================================================ FILE: openpype/host/interfaces.py ================================================ from abc import ABCMeta, abstractmethod import six class MissingMethodsError(ValueError): """Exception when host miss some required methods for specific workflow. Args: host (HostBase): Host implementation where are missing methods. missing_methods (list[str]): List of missing methods. """ def __init__(self, host, missing_methods): joined_missing = ", ".join( ['"{}"'.format(item) for item in missing_methods] ) host_name = getattr(host, "name", None) if not host_name: try: host_name = host.__file__.replace("\\", "/").split("/")[-3] except Exception: host_name = str(host) message = ( "Host \"{}\" miss methods {}".format(host_name, joined_missing) ) super(MissingMethodsError, self).__init__(message) class ILoadHost: """Implementation requirements to be able use reference of representations. The load plugins can do referencing even without implementation of methods here, but switch and removement of containers would not be possible. Questions: - Is list container dependency of host or load plugins? - Should this be directly in HostBase? - how to find out if referencing is available? - do we need to know that? """ @staticmethod def get_missing_load_methods(host): """Look for missing methods on "old type" host implementation. Method is used for validation of implemented functions related to loading. Checks only existence of methods. Args: Union[ModuleType, HostBase]: Object of host where to look for required methods. Returns: list[str]: Missing method implementations for loading workflow. """ if isinstance(host, ILoadHost): return [] required = ["ls"] missing = [] for name in required: if not hasattr(host, name): missing.append(name) return missing @staticmethod def validate_load_methods(host): """Validate implemented methods of "old type" host for load workflow. Args: Union[ModuleType, HostBase]: Object of host to validate. Raises: MissingMethodsError: If there are missing methods on host implementation. """ missing = ILoadHost.get_missing_load_methods(host) if missing: raise MissingMethodsError(host, missing) @abstractmethod def get_containers(self): """Retrieve referenced containers from scene. This can be implemented in hosts where referencing can be used. Todo: Rename function to something more self explanatory. Suggestion: 'get_containers' Returns: list[dict]: Information about loaded containers. """ pass # --- Deprecated method names --- def ls(self): """Deprecated variant of 'get_containers'. Todo: Remove when all usages are replaced. """ return self.get_containers() @six.add_metaclass(ABCMeta) class IWorkfileHost: """Implementation requirements to be able use workfile utils and tool.""" @staticmethod def get_missing_workfile_methods(host): """Look for missing methods on "old type" host implementation. Method is used for validation of implemented functions related to workfiles. Checks only existence of methods. Args: Union[ModuleType, HostBase]: Object of host where to look for required methods. Returns: list[str]: Missing method implementations for workfiles workflow. """ if isinstance(host, IWorkfileHost): return [] required = [ "open_file", "save_file", "current_file", "has_unsaved_changes", "file_extensions", "work_root", ] missing = [] for name in required: if not hasattr(host, name): missing.append(name) return missing @staticmethod def validate_workfile_methods(host): """Validate methods of "old type" host for workfiles workflow. Args: Union[ModuleType, HostBase]: Object of host to validate. Raises: MissingMethodsError: If there are missing methods on host implementation. """ missing = IWorkfileHost.get_missing_workfile_methods(host) if missing: raise MissingMethodsError(host, missing) @abstractmethod def get_workfile_extensions(self): """Extensions that can be used as save. Questions: This could potentially use 'HostDefinition'. """ return [] @abstractmethod def save_workfile(self, dst_path=None): """Save currently opened scene. Args: dst_path (str): Where the current scene should be saved. Or use current path if 'None' is passed. """ pass @abstractmethod def open_workfile(self, filepath): """Open passed filepath in the host. Args: filepath (str): Path to workfile. """ pass @abstractmethod def get_current_workfile(self): """Retrieve path to current opened file. Returns: str: Path to file which is currently opened. None: If nothing is opened. """ return None def workfile_has_unsaved_changes(self): """Currently opened scene is saved. Not all hosts can know if current scene is saved because the API of DCC does not support it. Returns: bool: True if scene is saved and False if has unsaved modifications. None: Can't tell if workfiles has modifications. """ return None def work_root(self, session): """Modify workdir per host. Default implementation keeps workdir untouched. Warnings: We must handle this modification with more sophisticated way because this can't be called out of DCC so opening of last workfile (calculated before DCC is launched) is complicated. Also breaking defined work template is not a good idea. Only place where it's really used and can make sense is Maya. There workspace.mel can modify subfolders where to look for maya files. Args: session (dict): Session context data. Returns: str: Path to new workdir. """ return session["AVALON_WORKDIR"] # --- Deprecated method names --- def file_extensions(self): """Deprecated variant of 'get_workfile_extensions'. Todo: Remove when all usages are replaced. """ return self.get_workfile_extensions() def save_file(self, dst_path=None): """Deprecated variant of 'save_workfile'. Todo: Remove when all usages are replaced. """ self.save_workfile(dst_path) def open_file(self, filepath): """Deprecated variant of 'open_workfile'. Todo: Remove when all usages are replaced. """ return self.open_workfile(filepath) def current_file(self): """Deprecated variant of 'get_current_workfile'. Todo: Remove when all usages are replaced. """ return self.get_current_workfile() def has_unsaved_changes(self): """Deprecated variant of 'workfile_has_unsaved_changes'. Todo: Remove when all usages are replaced. """ return self.workfile_has_unsaved_changes() class IPublishHost: """Functions related to new creation system in new publisher. New publisher is not storing information only about each created instance but also some global data. At this moment are data related only to context publish plugins but that can extend in future. """ @staticmethod def get_missing_publish_methods(host): """Look for missing methods on "old type" host implementation. Method is used for validation of implemented functions related to new publish creation. Checks only existence of methods. Args: Union[ModuleType, HostBase]: Host module where to look for required methods. Returns: list[str]: Missing method implementations for new publisher workflow. """ if isinstance(host, IPublishHost): return [] required = [ "get_context_data", "update_context_data", "get_context_title", "get_current_context", ] missing = [] for name in required: if not hasattr(host, name): missing.append(name) return missing @staticmethod def validate_publish_methods(host): """Validate implemented methods of "old type" host. Args: Union[ModuleType, HostBase]: Host module to validate. Raises: MissingMethodsError: If there are missing methods on host implementation. """ missing = IPublishHost.get_missing_publish_methods(host) if missing: raise MissingMethodsError(host, missing) @abstractmethod def get_context_data(self): """Get global data related to creation-publishing from workfile. These data are not related to any created instance but to whole publishing context. Not saving/returning them will cause that each reset of publishing resets all values to default ones. Context data can contain information about enabled/disabled publish plugins or other values that can be filled by artist. Returns: dict: Context data stored using 'update_context_data'. """ pass @abstractmethod def update_context_data(self, data, changes): """Store global context data to workfile. Called when some values in context data has changed. Without storing the values in a way that 'get_context_data' would return them will each reset of publishing cause loose of filled values by artist. Best practice is to store values into workfile, if possible. Args: data (dict): New data as are. changes (dict): Only data that has been changed. Each value has tuple with '(, )' value. """ pass class INewPublisher(IPublishHost): """Legacy interface replaced by 'IPublishHost'. Deprecated: 'INewPublisher' is replaced by 'IPublishHost' please change your imports. There is no "reasonable" way hot mark these classes as deprecated to show warning of wrong import. Deprecated since 3.14.* will be removed in 3.15.* """ pass ================================================ FILE: openpype/hosts/__init__.py ================================================ ================================================ FILE: openpype/hosts/aftereffects/__init__.py ================================================ from .addon import AfterEffectsAddon __all__ = ( "AfterEffectsAddon", ) ================================================ FILE: openpype/hosts/aftereffects/addon.py ================================================ from openpype.modules import OpenPypeModule, IHostAddon class AfterEffectsAddon(OpenPypeModule, IHostAddon): name = "aftereffects" host_name = "aftereffects" def initialize(self, module_settings): self.enabled = True def add_implementation_envs(self, env, _app): """Modify environments to contain all required for implementation.""" defaults = { "OPENPYPE_LOG_NO_COLORS": "True", "WEBSOCKET_URL": "ws://localhost:8097/ws/" } for key, value in defaults.items(): if not env.get(key): env[key] = value def get_workfile_extensions(self): return [".aep"] ================================================ FILE: openpype/hosts/aftereffects/api/README.md ================================================ # AfterEffects Integration Requirements: This extension requires use of Javascript engine, which is available since CC 16.0. Please check your File>Project Settings>Expressions>Expressions Engine ## Setup The After Effects integration requires two components to work; `extension` and `server`. ### Extension To install the extension download [Extension Manager Command Line tool (ExManCmd)](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#option-2---exmancmd). ``` ExManCmd /install {path to addon}/api/extension.zxp ``` OR download [Anastasiy’s Extension Manager](https://install.anastasiy.com/) `{path to addon}` will be most likely in your AppData (on Windows, in your user data folder in Linux and MacOS.) ### Server The easiest way to get the server and After Effects launch is with: ``` python -c ^"import openpype.hosts.photoshop;openpype.hosts..aftereffects.launch(""c:\Program Files\Adobe\Adobe After Effects 2020\Support Files\AfterFX.exe"")^" ``` `avalon.aftereffects.launch` launches the application and server, and also closes the server when After Effects exists. ## Usage The After Effects extension can be found under `Window > Extensions > AYON`. Once launched you should be presented with a panel like this: ![Ayon Panel](panel.png "Ayon Panel") ## Developing ### Extension When developing the extension you can load it [unsigned](https://github.com/Adobe-CEP/CEP-Resources/blob/master/CEP_9.x/Documentation/CEP%209.0%20HTML%20Extension%20Cookbook.md#debugging-unsigned-extensions). When signing the extension you can use this [guide](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#package-distribute-install-guide). ``` ZXPSignCmd -selfSignedCert NA NA Ayon Avalon-After-Effects Ayon extension.p12 ZXPSignCmd -sign {path to addon}/api/extension {path to addon}/api/extension.zxp extension.p12 Ayon ``` ### Plugin Examples These plugins were made with the [polly config](https://github.com/mindbender-studio/config). To fully integrate and load, you will have to use this config and add `image` to the [integration plugin](https://github.com/mindbender-studio/config/blob/master/polly/plugins/publish/integrate_asset.py). Expected deployed extension location on default Windows: `c:\Program Files (x86)\Common Files\Adobe\CEP\extensions\io.ynput.AE.panel` For easier debugging of Javascript: https://community.adobe.com/t5/download-install/adobe-extension-debuger-problem/td-p/10911704?page=1 Add (optional) --enable-blink-features=ShadowDOMV0,CustomElementsV0 when starting Chrome then localhost:8092 Or use Visual Studio Code https://medium.com/adobetech/extendscript-debugger-for-visual-studio-code-public-release-a2ff6161fa01 ## Resources - https://javascript-tools-guide.readthedocs.io/introduction/index.html - https://github.com/Adobe-CEP/Getting-Started-guides - https://github.com/Adobe-CEP/CEP-Resources ================================================ FILE: openpype/hosts/aftereffects/api/__init__.py ================================================ """Public API Anything that isn't defined here is INTERNAL and unreliable for external use. """ from .ws_stub import ( get_stub, ) from .pipeline import ( AfterEffectsHost, ls, containerise ) from .lib import ( maintained_selection, get_extension_manifest_path, get_asset_settings, set_settings ) from .plugin import ( AfterEffectsLoader ) __all__ = [ # ws_stub "get_stub", # pipeline "ls", "containerise", # lib "maintained_selection", "get_extension_manifest_path", "get_asset_settings", "set_settings", # plugin "AfterEffectsLoader" ] ================================================ FILE: openpype/hosts/aftereffects/api/extension/.debug ================================================ ================================================ FILE: openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml ================================================ ./index.html ./jsx/hostscript.jsx true Panel AYON 200 100 ./icons/ayon_logo.png ./icons/iconRollover.png ./icons/iconDisabled.png ./icons/iconDarkNormal.png ./icons/iconDarkRollover.png ================================================ FILE: openpype/hosts/aftereffects/api/extension/css/boilerplate.css ================================================ /* * HTML5 ✰ Boilerplate * * What follows is the result of much research on cross-browser styling. * Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal, * Kroc Camen, and the H5BP dev community and team. * * Detailed information about this CSS: h5bp.com/css * * ==|== normalize ========================================================== */ /* ============================================================================= HTML5 display definitions ========================================================================== */ article, aside, details, figcaption, figure, footer, header, hgroup, nav, section { display: block; } audio, canvas, video { display: inline-block; *display: inline; *zoom: 1; } audio:not([controls]) { display: none; } [hidden] { display: none; } /* ============================================================================= Base ========================================================================== */ /* * 1. Correct text resizing oddly in IE6/7 when body font-size is set using em units * 2. Force vertical scrollbar in non-IE * 3. Prevent iOS text size adjust on device orientation change, without disabling user zoom: h5bp.com/g */ html { font-size: 100%; overflow-y: scroll; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; } body { margin: 0; font-size: 100%; line-height: 1.231; } body, button, input, select, textarea { font-family: helvetica, arial,"lucida grande", verdana, "メイリオ", "MS Pゴシック", sans-serif; color: #222; } /* * Remove text-shadow in selection highlight: h5bp.com/i * These selection declarations have to be separate * Also: hot pink! (or customize the background color to match your design) */ ::selection { text-shadow: none; background-color: highlight; color: highlighttext; } /* ============================================================================= Links ========================================================================== */ a { color: #00e; } a:visited { color: #551a8b; } a:hover { color: #06e; } a:focus { outline: thin dotted; } /* Improve readability when focused and hovered in all browsers: h5bp.com/h */ a:hover, a:active { outline: 0; } /* ============================================================================= Typography ========================================================================== */ abbr[title] { border-bottom: 1px dotted; } b, strong { font-weight: bold; } blockquote { margin: 1em 40px; } dfn { font-style: italic; } hr { display: block; height: 1px; border: 0; border-top: 1px solid #ccc; margin: 1em 0; padding: 0; } ins { background: #ff9; color: #000; text-decoration: none; } mark { background: #ff0; color: #000; font-style: italic; font-weight: bold; } /* Redeclare monospace font family: h5bp.com/j */ pre, code, kbd, samp { font-family: monospace, serif; _font-family: 'courier new', monospace; font-size: 1em; } /* Improve readability of pre-formatted text in all browsers */ pre { white-space: pre; white-space: pre-wrap; word-wrap: break-word; } q { quotes: none; } q:before, q:after { content: ""; content: none; } small { font-size: 85%; } /* Position subscript and superscript content without affecting line-height: h5bp.com/k */ sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sup { top: -0.5em; } sub { bottom: -0.25em; } /* ============================================================================= Lists ========================================================================== */ ul, ol { margin: 1em 0; padding: 0 0 0 40px; } dd { margin: 0 0 0 40px; } nav ul, nav ol { list-style: none; list-style-image: none; margin: 0; padding: 0; } /* ============================================================================= Embedded content ========================================================================== */ /* * 1. Improve image quality when scaled in IE7: h5bp.com/d * 2. Remove the gap between images and borders on image containers: h5bp.com/e */ img { border: 0; -ms-interpolation-mode: bicubic; vertical-align: middle; } /* * Correct overflow not hidden in IE9 */ svg:not(:root) { overflow: hidden; } /* ============================================================================= Figures ========================================================================== */ figure { margin: 0; } /* ============================================================================= Forms ========================================================================== */ form { margin: 0; } fieldset { border: 0; margin: 0; padding: 0; } /* Indicate that 'label' will shift focus to the associated form element */ label { cursor: pointer; } /* * 1. Correct color not inheriting in IE6/7/8/9 * 2. Correct alignment displayed oddly in IE6/7 */ legend { border: 0; *margin-left: -7px; padding: 0; } /* * 1. Correct font-size not inheriting in all browsers * 2. Remove margins in FF3/4 S5 Chrome * 3. Define consistent vertical alignment display in all browsers */ button, input, select, textarea { font-size: 100%; margin: 0; vertical-align: baseline; *vertical-align: middle; } /* * 1. Define line-height as normal to match FF3/4 (set using !important in the UA stylesheet) */ button, input { line-height: normal; } /* * 1. Display hand cursor for clickable form elements * 2. Allow styling of clickable form elements in iOS * 3. Correct inner spacing displayed oddly in IE7 (doesn't effect IE6) */ button, input[type="button"], input[type="reset"], input[type="submit"] { cursor: pointer; -webkit-appearance: button; *overflow: visible; } /* * Consistent box sizing and appearance */ input[type="checkbox"], input[type="radio"] { box-sizing: border-box; padding: 0; } input[type="search"] { -webkit-appearance: textfield; -moz-box-sizing: content-box; -webkit-box-sizing: content-box; box-sizing: content-box; } input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; } /* * Remove inner padding and border in FF3/4: h5bp.com/l */ button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; } /* * 1. Remove default vertical scrollbar in IE6/7/8/9 * 2. Allow only vertical resizing */ textarea { overflow: auto; vertical-align: top; resize: vertical; } /* Colors for form validity */ input:valid, textarea:valid { } input:invalid, textarea:invalid { background-color: #f0dddd; } /* ============================================================================= Tables ========================================================================== */ table { border-collapse: collapse; border-spacing: 0; } td { vertical-align: top; } /* ==|== primary styles ===================================================== Author: ========================================================================== */ /* ==|== media queries ====================================================== PLACEHOLDER Media Queries for Responsive Design. These override the primary ('mobile first') styles Modify as content requires. ========================================================================== */ @media only screen and (min-width: 480px) { /* Style adjustments for viewports 480px and over go here */ } @media only screen and (min-width: 768px) { /* Style adjustments for viewports 768px and over go here */ } /* ==|== non-semantic helper classes ======================================== Please define your styles before this section. ========================================================================== */ /* For image replacement */ .ir { display: block; border: 0; text-indent: -999em; overflow: hidden; background-color: transparent; background-repeat: no-repeat; text-align: left; direction: ltr; } .ir br { display: none; } /* Hide from both screenreaders and browsers: h5bp.com/u */ .hidden { display: none !important; visibility: hidden; } /* Hide only visually, but have it available for screenreaders: h5bp.com/v */ .visuallyhidden { border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; width: 1px; } /* Extends the .visuallyhidden class to allow the element to be focusable when navigated to via the keyboard: h5bp.com/p */ .visuallyhidden.focusable:active, .visuallyhidden.focusable:focus { clip: auto; height: auto; margin: 0; overflow: visible; position: static; width: auto; } /* Hide visually and from screenreaders, but maintain layout */ .invisible { visibility: hidden; } /* Contain floats: h5bp.com/q */ .clearfix:before, .clearfix:after { content: ""; display: table; } .clearfix:after { clear: both; } .clearfix { *zoom: 1; } /* ==|== print styles ======================================================= Print styles. Inlined to avoid required HTTP connection: h5bp.com/r ========================================================================== */ @media print { * { background: transparent !important; color: black !important; box-shadow:none !important; text-shadow: none !important; filter:none !important; -ms-filter: none !important; } /* Black prints faster: h5bp.com/s */ a, a:visited { text-decoration: underline; } a[href]:after { content: " (" attr(href) ")"; } abbr[title]:after { content: " (" attr(title) ")"; } .ir a:after, a[href^="javascript:"]:after, a[href^="#"]:after { content: ""; } /* Don't show links for images, or javascript/internal links */ pre, blockquote { border: 1px solid #999; page-break-inside: avoid; } table { display: table-header-group; } /* h5bp.com/t */ tr, img { page-break-inside: avoid; } img { max-width: 100% !important; } @page { margin: 0.5cm; } p, h2, h3 { orphans: 3; widows: 3; } h2, h3 { page-break-after: avoid; } } /* reflow reset for -webkit-margin-before: 1em */ p { margin: 0; } html { overflow-y: auto; background-color: transparent; height: 100%; } body { background: #fff; font: normal 100%; position: relative; height: 100%; } body, div, img, p, button, input, select, textarea { box-sizing: border-box; } .image { display: block; } input { cursor: default; display: block; } input[type=button] { background-color: #e5e9e8; border: 1px solid #9daca9; border-radius: 4px; box-shadow: inset 0 1px #fff; font: inherit; letter-spacing: inherit; text-indent: inherit; color: inherit; } input[type=button]:hover { background-color: #eff1f1; } input[type=button]:active { background-color: #d2d6d6; border: 1px solid #9daca9; box-shadow: inset 0 1px rgba(0,0,0,0.1); } /* Reset anchor styles to an unstyled default to be in parity with design surface. It is presumed that most link styles in real-world designs are custom (non-default). */ a, a:visited, a:hover, a:active { color: inherit; text-decoration: inherit; } ================================================ FILE: openpype/hosts/aftereffects/api/extension/css/styles.css ================================================ /*Your styles*/ body { margin: 10px; } #content { margin-right:auto; margin-left:auto; vertical-align:middle; width:100%; } #btn_test{ width: 100%; } /* Those classes will be edited at runtime with values specified by the settings of the CC application */ .hostFontColor{} .hostFontFamily{} .hostFontSize{} /*font family, color and size*/ .hostFont{} /*background color*/ .hostBgd{} /*lighter background color*/ .hostBgdLight{} /*darker background color*/ .hostBgdDark{} /*background color and font*/ .hostElt{} .hostButton{ border:1px solid; border-radius:2px; height:20px; vertical-align:bottom; font-family:inherit; color:inherit; font-size:inherit; } ================================================ FILE: openpype/hosts/aftereffects/api/extension/index.html ================================================ ================================================ FILE: openpype/hosts/aftereffects/api/extension/js/libs/CSInterface.js ================================================ /************************************************************************************************** * * ADOBE SYSTEMS INCORPORATED * Copyright 2013 Adobe Systems Incorporated * All Rights Reserved. * * NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the * terms of the Adobe license agreement accompanying it. If you have received this file from a * source other than Adobe, then your use, modification, or distribution of it requires the prior * written permission of Adobe. * **************************************************************************************************/ /** CSInterface - v8.0.0 */ /** * Stores constants for the window types supported by the CSXS infrastructure. */ function CSXSWindowType() { } /** Constant for the CSXS window type Panel. */ CSXSWindowType._PANEL = "Panel"; /** Constant for the CSXS window type Modeless. */ CSXSWindowType._MODELESS = "Modeless"; /** Constant for the CSXS window type ModalDialog. */ CSXSWindowType._MODAL_DIALOG = "ModalDialog"; /** EvalScript error message */ EvalScript_ErrMessage = "EvalScript error."; /** * @class Version * Defines a version number with major, minor, micro, and special * components. The major, minor and micro values are numeric; the special * value can be any string. * * @param major The major version component, a positive integer up to nine digits long. * @param minor The minor version component, a positive integer up to nine digits long. * @param micro The micro version component, a positive integer up to nine digits long. * @param special The special version component, an arbitrary string. * * @return A new \c Version object. */ function Version(major, minor, micro, special) { this.major = major; this.minor = minor; this.micro = micro; this.special = special; } /** * The maximum value allowed for a numeric version component. * This reflects the maximum value allowed in PlugPlug and the manifest schema. */ Version.MAX_NUM = 999999999; /** * @class VersionBound * Defines a boundary for a version range, which associates a \c Version object * with a flag for whether it is an inclusive or exclusive boundary. * * @param version The \c #Version object. * @param inclusive True if this boundary is inclusive, false if it is exclusive. * * @return A new \c VersionBound object. */ function VersionBound(version, inclusive) { this.version = version; this.inclusive = inclusive; } /** * @class VersionRange * Defines a range of versions using a lower boundary and optional upper boundary. * * @param lowerBound The \c #VersionBound object. * @param upperBound The \c #VersionBound object, or null for a range with no upper boundary. * * @return A new \c VersionRange object. */ function VersionRange(lowerBound, upperBound) { this.lowerBound = lowerBound; this.upperBound = upperBound; } /** * @class Runtime * Represents a runtime related to the CEP infrastructure. * Extensions can declare dependencies on particular * CEP runtime versions in the extension manifest. * * @param name The runtime name. * @param version A \c #VersionRange object that defines a range of valid versions. * * @return A new \c Runtime object. */ function Runtime(name, versionRange) { this.name = name; this.versionRange = versionRange; } /** * @class Extension * Encapsulates a CEP-based extension to an Adobe application. * * @param id The unique identifier of this extension. * @param name The localizable display name of this extension. * @param mainPath The path of the "index.html" file. * @param basePath The base path of this extension. * @param windowType The window type of the main window of this extension. Valid values are defined by \c #CSXSWindowType. * @param width The default width in pixels of the main window of this extension. * @param height The default height in pixels of the main window of this extension. * @param minWidth The minimum width in pixels of the main window of this extension. * @param minHeight The minimum height in pixels of the main window of this extension. * @param maxWidth The maximum width in pixels of the main window of this extension. * @param maxHeight The maximum height in pixels of the main window of this extension. * @param defaultExtensionDataXml The extension data contained in the default \c ExtensionDispatchInfo section of the extension manifest. * @param specialExtensionDataXml The extension data contained in the application-specific \c ExtensionDispatchInfo section of the extension manifest. * @param requiredRuntimeList An array of \c Runtime objects for runtimes required by this extension. * @param isAutoVisible True if this extension is visible on loading. * @param isPluginExtension True if this extension has been deployed in the Plugins folder of the host application. * * @return A new \c Extension object. */ function Extension(id, name, mainPath, basePath, windowType, width, height, minWidth, minHeight, maxWidth, maxHeight, defaultExtensionDataXml, specialExtensionDataXml, requiredRuntimeList, isAutoVisible, isPluginExtension) { this.id = id; this.name = name; this.mainPath = mainPath; this.basePath = basePath; this.windowType = windowType; this.width = width; this.height = height; this.minWidth = minWidth; this.minHeight = minHeight; this.maxWidth = maxWidth; this.maxHeight = maxHeight; this.defaultExtensionDataXml = defaultExtensionDataXml; this.specialExtensionDataXml = specialExtensionDataXml; this.requiredRuntimeList = requiredRuntimeList; this.isAutoVisible = isAutoVisible; this.isPluginExtension = isPluginExtension; } /** * @class CSEvent * A standard JavaScript event, the base class for CEP events. * * @param type The name of the event type. * @param scope The scope of event, can be "GLOBAL" or "APPLICATION". * @param appId The unique identifier of the application that generated the event. * @param extensionId The unique identifier of the extension that generated the event. * * @return A new \c CSEvent object */ function CSEvent(type, scope, appId, extensionId) { this.type = type; this.scope = scope; this.appId = appId; this.extensionId = extensionId; } /** Event-specific data. */ CSEvent.prototype.data = ""; /** * @class SystemPath * Stores operating-system-specific location constants for use in the * \c #CSInterface.getSystemPath() method. * @return A new \c SystemPath object. */ function SystemPath() { } /** The path to user data. */ SystemPath.USER_DATA = "userData"; /** The path to common files for Adobe applications. */ SystemPath.COMMON_FILES = "commonFiles"; /** The path to the user's default document folder. */ SystemPath.MY_DOCUMENTS = "myDocuments"; /** @deprecated. Use \c #SystemPath.Extension. */ SystemPath.APPLICATION = "application"; /** The path to current extension. */ SystemPath.EXTENSION = "extension"; /** The path to hosting application's executable. */ SystemPath.HOST_APPLICATION = "hostApplication"; /** * @class ColorType * Stores color-type constants. */ function ColorType() { } /** RGB color type. */ ColorType.RGB = "rgb"; /** Gradient color type. */ ColorType.GRADIENT = "gradient"; /** Null color type. */ ColorType.NONE = "none"; /** * @class RGBColor * Stores an RGB color with red, green, blue, and alpha values. * All values are in the range [0.0 to 255.0]. Invalid numeric values are * converted to numbers within this range. * * @param red The red value, in the range [0.0 to 255.0]. * @param green The green value, in the range [0.0 to 255.0]. * @param blue The blue value, in the range [0.0 to 255.0]. * @param alpha The alpha (transparency) value, in the range [0.0 to 255.0]. * The default, 255.0, means that the color is fully opaque. * * @return A new RGBColor object. */ function RGBColor(red, green, blue, alpha) { this.red = red; this.green = green; this.blue = blue; this.alpha = alpha; } /** * @class Direction * A point value in which the y component is 0 and the x component * is positive or negative for a right or left direction, * or the x component is 0 and the y component is positive or negative for * an up or down direction. * * @param x The horizontal component of the point. * @param y The vertical component of the point. * * @return A new \c Direction object. */ function Direction(x, y) { this.x = x; this.y = y; } /** * @class GradientStop * Stores gradient stop information. * * @param offset The offset of the gradient stop, in the range [0.0 to 1.0]. * @param rgbColor The color of the gradient at this point, an \c #RGBColor object. * * @return GradientStop object. */ function GradientStop(offset, rgbColor) { this.offset = offset; this.rgbColor = rgbColor; } /** * @class GradientColor * Stores gradient color information. * * @param type The gradient type, must be "linear". * @param direction A \c #Direction object for the direction of the gradient (up, down, right, or left). * @param numStops The number of stops in the gradient. * @param gradientStopList An array of \c #GradientStop objects. * * @return A new \c GradientColor object. */ function GradientColor(type, direction, numStops, arrGradientStop) { this.type = type; this.direction = direction; this.numStops = numStops; this.arrGradientStop = arrGradientStop; } /** * @class UIColor * Stores color information, including the type, anti-alias level, and specific color * values in a color object of an appropriate type. * * @param type The color type, 1 for "rgb" and 2 for "gradient". The supplied color object must correspond to this type. * @param antialiasLevel The anti-alias level constant. * @param color A \c #RGBColor or \c #GradientColor object containing specific color information. * * @return A new \c UIColor object. */ function UIColor(type, antialiasLevel, color) { this.type = type; this.antialiasLevel = antialiasLevel; this.color = color; } /** * @class AppSkinInfo * Stores window-skin properties, such as color and font. All color parameter values are \c #UIColor objects except that systemHighlightColor is \c #RGBColor object. * * @param baseFontFamily The base font family of the application. * @param baseFontSize The base font size of the application. * @param appBarBackgroundColor The application bar background color. * @param panelBackgroundColor The background color of the extension panel. * @param appBarBackgroundColorSRGB The application bar background color, as sRGB. * @param panelBackgroundColorSRGB The background color of the extension panel, as sRGB. * @param systemHighlightColor The highlight color of the extension panel, if provided by the host application. Otherwise, the operating-system highlight color. * * @return AppSkinInfo object. */ function AppSkinInfo(baseFontFamily, baseFontSize, appBarBackgroundColor, panelBackgroundColor, appBarBackgroundColorSRGB, panelBackgroundColorSRGB, systemHighlightColor) { this.baseFontFamily = baseFontFamily; this.baseFontSize = baseFontSize; this.appBarBackgroundColor = appBarBackgroundColor; this.panelBackgroundColor = panelBackgroundColor; this.appBarBackgroundColorSRGB = appBarBackgroundColorSRGB; this.panelBackgroundColorSRGB = panelBackgroundColorSRGB; this.systemHighlightColor = systemHighlightColor; } /** * @class HostEnvironment * Stores information about the environment in which the extension is loaded. * * @param appName The application's name. * @param appVersion The application's version. * @param appLocale The application's current license locale. * @param appUILocale The application's current UI locale. * @param appId The application's unique identifier. * @param isAppOnline True if the application is currently online. * @param appSkinInfo An \c #AppSkinInfo object containing the application's default color and font styles. * * @return A new \c HostEnvironment object. */ function HostEnvironment(appName, appVersion, appLocale, appUILocale, appId, isAppOnline, appSkinInfo) { this.appName = appName; this.appVersion = appVersion; this.appLocale = appLocale; this.appUILocale = appUILocale; this.appId = appId; this.isAppOnline = isAppOnline; this.appSkinInfo = appSkinInfo; } /** * @class HostCapabilities * Stores information about the host capabilities. * * @param EXTENDED_PANEL_MENU True if the application supports panel menu. * @param EXTENDED_PANEL_ICONS True if the application supports panel icon. * @param DELEGATE_APE_ENGINE True if the application supports delegated APE engine. * @param SUPPORT_HTML_EXTENSIONS True if the application supports HTML extensions. * @param DISABLE_FLASH_EXTENSIONS True if the application disables FLASH extensions. * * @return A new \c HostCapabilities object. */ function HostCapabilities(EXTENDED_PANEL_MENU, EXTENDED_PANEL_ICONS, DELEGATE_APE_ENGINE, SUPPORT_HTML_EXTENSIONS, DISABLE_FLASH_EXTENSIONS) { this.EXTENDED_PANEL_MENU = EXTENDED_PANEL_MENU; this.EXTENDED_PANEL_ICONS = EXTENDED_PANEL_ICONS; this.DELEGATE_APE_ENGINE = DELEGATE_APE_ENGINE; this.SUPPORT_HTML_EXTENSIONS = SUPPORT_HTML_EXTENSIONS; this.DISABLE_FLASH_EXTENSIONS = DISABLE_FLASH_EXTENSIONS; // Since 5.0.0 } /** * @class ApiVersion * Stores current api version. * * Since 4.2.0 * * @param major The major version * @param minor The minor version. * @param micro The micro version. * * @return ApiVersion object. */ function ApiVersion(major, minor, micro) { this.major = major; this.minor = minor; this.micro = micro; } /** * @class MenuItemStatus * Stores flyout menu item status * * Since 5.2.0 * * @param menuItemLabel The menu item label. * @param enabled True if user wants to enable the menu item. * @param checked True if user wants to check the menu item. * * @return MenuItemStatus object. */ function MenuItemStatus(menuItemLabel, enabled, checked) { this.menuItemLabel = menuItemLabel; this.enabled = enabled; this.checked = checked; } /** * @class ContextMenuItemStatus * Stores the status of the context menu item. * * Since 5.2.0 * * @param menuItemID The menu item id. * @param enabled True if user wants to enable the menu item. * @param checked True if user wants to check the menu item. * * @return MenuItemStatus object. */ function ContextMenuItemStatus(menuItemID, enabled, checked) { this.menuItemID = menuItemID; this.enabled = enabled; this.checked = checked; } //------------------------------ CSInterface ---------------------------------- /** * @class CSInterface * This is the entry point to the CEP extensibility infrastructure. * Instantiate this object and use it to: *
    *
  • Access information about the host application in which an extension is running
  • *
  • Launch an extension
  • *
  • Register interest in event notifications, and dispatch events
  • *
* * @return A new \c CSInterface object */ function CSInterface() { } /** * User can add this event listener to handle native application theme color changes. * Callback function gives extensions ability to fine-tune their theme color after the * global theme color has been changed. * The callback function should be like below: * * @example * // event is a CSEvent object, but user can ignore it. * function OnAppThemeColorChanged(event) * { * // Should get a latest HostEnvironment object from application. * var skinInfo = JSON.parse(window.__adobe_cep__.getHostEnvironment()).appSkinInfo; * // Gets the style information such as color info from the skinInfo, * // and redraw all UI controls of your extension according to the style info. * } */ CSInterface.THEME_COLOR_CHANGED_EVENT = "com.adobe.csxs.events.ThemeColorChanged"; /** The host environment data object. */ CSInterface.prototype.hostEnvironment = window.__adobe_cep__ ? JSON.parse(window.__adobe_cep__.getHostEnvironment()) : null; /** Retrieves information about the host environment in which the * extension is currently running. * * @return A \c #HostEnvironment object. */ CSInterface.prototype.getHostEnvironment = function() { this.hostEnvironment = JSON.parse(window.__adobe_cep__.getHostEnvironment()); return this.hostEnvironment; }; /** Closes this extension. */ CSInterface.prototype.closeExtension = function() { window.__adobe_cep__.closeExtension(); }; /** * Retrieves a path for which a constant is defined in the system. * * @param pathType The path-type constant defined in \c #SystemPath , * * @return The platform-specific system path string. */ CSInterface.prototype.getSystemPath = function(pathType) { var path = decodeURI(window.__adobe_cep__.getSystemPath(pathType)); var OSVersion = this.getOSInformation(); if (OSVersion.indexOf("Windows") >= 0) { path = path.replace("file:///", ""); } else if (OSVersion.indexOf("Mac") >= 0) { path = path.replace("file://", ""); } return path; }; /** * Evaluates a JavaScript script, which can use the JavaScript DOM * of the host application. * * @param script The JavaScript script. * @param callback Optional. A callback function that receives the result of execution. * If execution fails, the callback function receives the error message \c EvalScript_ErrMessage. */ CSInterface.prototype.evalScript = function(script, callback) { if(callback === null || callback === undefined) { callback = function(result){}; } window.__adobe_cep__.evalScript(script, callback); }; /** * Retrieves the unique identifier of the application. * in which the extension is currently running. * * @return The unique ID string. */ CSInterface.prototype.getApplicationID = function() { var appId = this.hostEnvironment.appId; return appId; }; /** * Retrieves host capability information for the application * in which the extension is currently running. * * @return A \c #HostCapabilities object. */ CSInterface.prototype.getHostCapabilities = function() { var hostCapabilities = JSON.parse(window.__adobe_cep__.getHostCapabilities() ); return hostCapabilities; }; /** * Triggers a CEP event programmatically. Yoy can use it to dispatch * an event of a predefined type, or of a type you have defined. * * @param event A \c CSEvent object. */ CSInterface.prototype.dispatchEvent = function(event) { if (typeof event.data == "object") { event.data = JSON.stringify(event.data); } window.__adobe_cep__.dispatchEvent(event); }; /** * Registers an interest in a CEP event of a particular type, and * assigns an event handler. * The event infrastructure notifies your extension when events of this type occur, * passing the event object to the registered handler function. * * @param type The name of the event type of interest. * @param listener The JavaScript handler function or method. * @param obj Optional, the object containing the handler method, if any. * Default is null. */ CSInterface.prototype.addEventListener = function(type, listener, obj) { window.__adobe_cep__.addEventListener(type, listener, obj); }; /** * Removes a registered event listener. * * @param type The name of the event type of interest. * @param listener The JavaScript handler function or method that was registered. * @param obj Optional, the object containing the handler method, if any. * Default is null. */ CSInterface.prototype.removeEventListener = function(type, listener, obj) { window.__adobe_cep__.removeEventListener(type, listener, obj); }; /** * Loads and launches another extension, or activates the extension if it is already loaded. * * @param extensionId The extension's unique identifier. * @param startupParams Not currently used, pass "". * * @example * To launch the extension "help" with ID "HLP" from this extension, call: * requestOpenExtension("HLP", ""); * */ CSInterface.prototype.requestOpenExtension = function(extensionId, params) { window.__adobe_cep__.requestOpenExtension(extensionId, params); }; /** * Retrieves the list of extensions currently loaded in the current host application. * The extension list is initialized once, and remains the same during the lifetime * of the CEP session. * * @param extensionIds Optional, an array of unique identifiers for extensions of interest. * If omitted, retrieves data for all extensions. * * @return Zero or more \c #Extension objects. */ CSInterface.prototype.getExtensions = function(extensionIds) { var extensionIdsStr = JSON.stringify(extensionIds); var extensionsStr = window.__adobe_cep__.getExtensions(extensionIdsStr); var extensions = JSON.parse(extensionsStr); return extensions; }; /** * Retrieves network-related preferences. * * @return A JavaScript object containing network preferences. */ CSInterface.prototype.getNetworkPreferences = function() { var result = window.__adobe_cep__.getNetworkPreferences(); var networkPre = JSON.parse(result); return networkPre; }; /** * Initializes the resource bundle for this extension with property values * for the current application and locale. * To support multiple locales, you must define a property file for each locale, * containing keyed display-string values for that locale. * See localization documentation for Extension Builder and related products. * * Keys can be in the * form key.value="localized string", for use in HTML text elements. * For example, in this input element, the localized \c key.value string is displayed * instead of the empty \c value string: * * * * @return An object containing the resource bundle information. */ CSInterface.prototype.initResourceBundle = function() { var resourceBundle = JSON.parse(window.__adobe_cep__.initResourceBundle()); var resElms = document.querySelectorAll('[data-locale]'); for (var n = 0; n < resElms.length; n++) { var resEl = resElms[n]; // Get the resource key from the element. var resKey = resEl.getAttribute('data-locale'); if (resKey) { // Get all the resources that start with the key. for (var key in resourceBundle) { if (key.indexOf(resKey) === 0) { var resValue = resourceBundle[key]; if (key.length == resKey.length) { resEl.innerHTML = resValue; } else if ('.' == key.charAt(resKey.length)) { var attrKey = key.substring(resKey.length + 1); resEl[attrKey] = resValue; } } } } } return resourceBundle; }; /** * Writes installation information to a file. * * @return The file path. */ CSInterface.prototype.dumpInstallationInfo = function() { return window.__adobe_cep__.dumpInstallationInfo(); }; /** * Retrieves version information for the current Operating System, * See http://www.useragentstring.com/pages/Chrome/ for Chrome \c navigator.userAgent values. * * @return A string containing the OS version, or "unknown Operation System". * If user customizes the User Agent by setting CEF command parameter "--user-agent", only * "Mac OS X" or "Windows" will be returned. */ CSInterface.prototype.getOSInformation = function() { var userAgent = navigator.userAgent; if ((navigator.platform == "Win32") || (navigator.platform == "Windows")) { var winVersion = "Windows"; var winBit = ""; if (userAgent.indexOf("Windows") > -1) { if (userAgent.indexOf("Windows NT 5.0") > -1) { winVersion = "Windows 2000"; } else if (userAgent.indexOf("Windows NT 5.1") > -1) { winVersion = "Windows XP"; } else if (userAgent.indexOf("Windows NT 5.2") > -1) { winVersion = "Windows Server 2003"; } else if (userAgent.indexOf("Windows NT 6.0") > -1) { winVersion = "Windows Vista"; } else if (userAgent.indexOf("Windows NT 6.1") > -1) { winVersion = "Windows 7"; } else if (userAgent.indexOf("Windows NT 6.2") > -1) { winVersion = "Windows 8"; } else if (userAgent.indexOf("Windows NT 6.3") > -1) { winVersion = "Windows 8.1"; } else if (userAgent.indexOf("Windows NT 10") > -1) { winVersion = "Windows 10"; } if (userAgent.indexOf("WOW64") > -1 || userAgent.indexOf("Win64") > -1) { winBit = " 64-bit"; } else { winBit = " 32-bit"; } } return winVersion + winBit; } else if ((navigator.platform == "MacIntel") || (navigator.platform == "Macintosh")) { var result = "Mac OS X"; if (userAgent.indexOf("Mac OS X") > -1) { result = userAgent.substring(userAgent.indexOf("Mac OS X"), userAgent.indexOf(")")); result = result.replace(/_/g, "."); } return result; } return "Unknown Operation System"; }; /** * Opens a page in the default system browser. * * Since 4.2.0 * * @param url The URL of the page/file to open, or the email address. * Must use HTTP/HTTPS/file/mailto protocol. For example: * "http://www.adobe.com" * "https://github.com" * "file:///C:/log.txt" * "mailto:test@adobe.com" * * @return One of these error codes:\n *
    \n *
  • NO_ERROR - 0
  • \n *
  • ERR_UNKNOWN - 1
  • \n *
  • ERR_INVALID_PARAMS - 2
  • \n *
  • ERR_INVALID_URL - 201
  • \n *
\n */ CSInterface.prototype.openURLInDefaultBrowser = function(url) { return cep.util.openURLInDefaultBrowser(url); }; /** * Retrieves extension ID. * * Since 4.2.0 * * @return extension ID. */ CSInterface.prototype.getExtensionID = function() { return window.__adobe_cep__.getExtensionId(); }; /** * Retrieves the scale factor of screen. * On Windows platform, the value of scale factor might be different from operating system's scale factor, * since host application may use its self-defined scale factor. * * Since 4.2.0 * * @return One of the following float number. *
    \n *
  • -1.0 when error occurs
  • \n *
  • 1.0 means normal screen
  • \n *
  • >1.0 means HiDPI screen
  • \n *
\n */ CSInterface.prototype.getScaleFactor = function() { return window.__adobe_cep__.getScaleFactor(); }; /** * Set a handler to detect any changes of scale factor. This only works on Mac. * * Since 4.2.0 * * @param handler The function to be called when scale factor is changed. * */ CSInterface.prototype.setScaleFactorChangedHandler = function(handler) { window.__adobe_cep__.setScaleFactorChangedHandler(handler); }; /** * Retrieves current API version. * * Since 4.2.0 * * @return ApiVersion object. * */ CSInterface.prototype.getCurrentApiVersion = function() { var apiVersion = JSON.parse(window.__adobe_cep__.getCurrentApiVersion()); return apiVersion; }; /** * Set panel flyout menu by an XML. * * Since 5.2.0 * * Register a callback function for "com.adobe.csxs.events.flyoutMenuClicked" to get notified when a * menu item is clicked. * The "data" attribute of event is an object which contains "menuId" and "menuName" attributes. * * Register callback functions for "com.adobe.csxs.events.flyoutMenuOpened" and "com.adobe.csxs.events.flyoutMenuClosed" * respectively to get notified when flyout menu is opened or closed. * * @param menu A XML string which describes menu structure. * An example menu XML: * * * * * * * * * * * * */ CSInterface.prototype.setPanelFlyoutMenu = function(menu) { if ("string" != typeof menu) { return; } window.__adobe_cep__.invokeSync("setPanelFlyoutMenu", menu); }; /** * Updates a menu item in the extension window's flyout menu, by setting the enabled * and selection status. * * Since 5.2.0 * * @param menuItemLabel The menu item label. * @param enabled True to enable the item, false to disable it (gray it out). * @param checked True to select the item, false to deselect it. * * @return false when the host application does not support this functionality (HostCapabilities.EXTENDED_PANEL_MENU is false). * Fails silently if menu label is invalid. * * @see HostCapabilities.EXTENDED_PANEL_MENU */ CSInterface.prototype.updatePanelMenuItem = function(menuItemLabel, enabled, checked) { var ret = false; if (this.getHostCapabilities().EXTENDED_PANEL_MENU) { var itemStatus = new MenuItemStatus(menuItemLabel, enabled, checked); ret = window.__adobe_cep__.invokeSync("updatePanelMenuItem", JSON.stringify(itemStatus)); } return ret; }; /** * Set context menu by XML string. * * Since 5.2.0 * * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. * - an item without menu ID or menu name is disabled and is not shown. * - if the item name is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. * - Checkable attribute takes precedence over Checked attribute. * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. The Chrome extension contextMenus API was taken as a reference. https://developer.chrome.com/extensions/contextMenus * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. * * @param menu A XML string which describes menu structure. * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. * * @description An example menu XML: * * * * * * * * * * * */ CSInterface.prototype.setContextMenu = function(menu, callback) { if ("string" != typeof menu) { return; } window.__adobe_cep__.invokeAsync("setContextMenu", menu, callback); }; /** * Set context menu by JSON string. * * Since 6.0.0 * * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. * - an item without menu ID or menu name is disabled and is not shown. * - if the item label is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. * - Checkable attribute takes precedence over Checked attribute. * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. The Chrome extension contextMenus API was taken as a reference. * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. https://developer.chrome.com/extensions/contextMenus * * @param menu A JSON string which describes menu structure. * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. * * @description An example menu JSON: * * { * "menu": [ * { * "id": "menuItemId1", * "label": "testExample1", * "enabled": true, * "checkable": true, * "checked": false, * "icon": "./image/small_16X16.png" * }, * { * "id": "menuItemId2", * "label": "testExample2", * "menu": [ * { * "id": "menuItemId2-1", * "label": "testExample2-1", * "menu": [ * { * "id": "menuItemId2-1-1", * "label": "testExample2-1-1", * "enabled": false, * "checkable": true, * "checked": true * } * ] * }, * { * "id": "menuItemId2-2", * "label": "testExample2-2", * "enabled": true, * "checkable": true, * "checked": true * } * ] * }, * { * "label": "---" * }, * { * "id": "menuItemId3", * "label": "testExample3", * "enabled": false, * "checkable": true, * "checked": false * } * ] * } * */ CSInterface.prototype.setContextMenuByJSON = function(menu, callback) { if ("string" != typeof menu) { return; } window.__adobe_cep__.invokeAsync("setContextMenuByJSON", menu, callback); }; /** * Updates a context menu item by setting the enabled and selection status. * * Since 5.2.0 * * @param menuItemID The menu item ID. * @param enabled True to enable the item, false to disable it (gray it out). * @param checked True to select the item, false to deselect it. */ CSInterface.prototype.updateContextMenuItem = function(menuItemID, enabled, checked) { var itemStatus = new ContextMenuItemStatus(menuItemID, enabled, checked); ret = window.__adobe_cep__.invokeSync("updateContextMenuItem", JSON.stringify(itemStatus)); }; /** * Get the visibility status of an extension window. * * Since 6.0.0 * * @return true if the extension window is visible; false if the extension window is hidden. */ CSInterface.prototype.isWindowVisible = function() { return window.__adobe_cep__.invokeSync("isWindowVisible", ""); }; /** * Resize extension's content to the specified dimensions. * 1. Works with modal and modeless extensions in all Adobe products. * 2. Extension's manifest min/max size constraints apply and take precedence. * 3. For panel extensions * 3.1 This works in all Adobe products except: * * Premiere Pro * * Prelude * * After Effects * 3.2 When the panel is in certain states (especially when being docked), * it will not change to the desired dimensions even when the * specified size satisfies min/max constraints. * * Since 6.0.0 * * @param width The new width * @param height The new height */ CSInterface.prototype.resizeContent = function(width, height) { window.__adobe_cep__.resizeContent(width, height); }; /** * Register the invalid certificate callback for an extension. * This callback will be triggered when the extension tries to access the web site that contains the invalid certificate on the main frame. * But if the extension does not call this function and tries to access the web site containing the invalid certificate, a default error page will be shown. * * Since 6.1.0 * * @param callback the callback function */ CSInterface.prototype.registerInvalidCertificateCallback = function(callback) { return window.__adobe_cep__.registerInvalidCertificateCallback(callback); }; /** * Register an interest in some key events to prevent them from being sent to the host application. * * This function works with modeless extensions and panel extensions. * Generally all the key events will be sent to the host application for these two extensions if the current focused element * is not text input or dropdown, * If you want to intercept some key events and want them to be handled in the extension, please call this function * in advance to prevent them being sent to the host application. * * Since 6.1.0 * * @param keyEventsInterest A JSON string describing those key events you are interested in. A null object or an empty string will lead to removing the interest * * This JSON string should be an array, each object has following keys: * * keyCode: [Required] represents an OS system dependent virtual key code identifying * the unmodified value of the pressed key. * ctrlKey: [optional] a Boolean that indicates if the control key was pressed (true) or not (false) when the event occurred. * altKey: [optional] a Boolean that indicates if the alt key was pressed (true) or not (false) when the event occurred. * shiftKey: [optional] a Boolean that indicates if the shift key was pressed (true) or not (false) when the event occurred. * metaKey: [optional] (Mac Only) a Boolean that indicates if the Meta key was pressed (true) or not (false) when the event occurred. * On Macintosh keyboards, this is the command key. To detect Windows key on Windows, please use keyCode instead. * An example JSON string: * * [ * { * "keyCode": 48 * }, * { * "keyCode": 123, * "ctrlKey": true * }, * { * "keyCode": 123, * "ctrlKey": true, * "metaKey": true * } * ] * */ CSInterface.prototype.registerKeyEventsInterest = function(keyEventsInterest) { return window.__adobe_cep__.registerKeyEventsInterest(keyEventsInterest); }; /** * Set the title of the extension window. * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. * * Since 6.1.0 * * @param title The window title. */ CSInterface.prototype.setWindowTitle = function(title) { window.__adobe_cep__.invokeSync("setWindowTitle", title); }; /** * Get the title of the extension window. * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. * * Since 6.1.0 * * @return The window title. */ CSInterface.prototype.getWindowTitle = function() { return window.__adobe_cep__.invokeSync("getWindowTitle", ""); }; ================================================ FILE: openpype/hosts/aftereffects/api/extension/js/libs/json.js ================================================ // json2.js // 2017-06-12 // Public Domain. // NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK. // USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO // NOT CONTROL. // This file creates a global JSON object containing two methods: stringify // and parse. This file provides the ES5 JSON capability to ES3 systems. // If a project might run on IE8 or earlier, then this file should be included. // This file does nothing on ES5 systems. // JSON.stringify(value, replacer, space) // value any JavaScript value, usually an object or array. // replacer an optional parameter that determines how object // values are stringified for objects. It can be a // function or an array of strings. // space an optional parameter that specifies the indentation // of nested structures. If it is omitted, the text will // be packed without extra whitespace. If it is a number, // it will specify the number of spaces to indent at each // level. If it is a string (such as "\t" or " "), // it contains the characters used to indent at each level. // This method produces a JSON text from a JavaScript value. // When an object value is found, if the object contains a toJSON // method, its toJSON method will be called and the result will be // stringified. A toJSON method does not serialize: it returns the // value represented by the name/value pair that should be serialized, // or undefined if nothing should be serialized. The toJSON method // will be passed the key associated with the value, and this will be // bound to the value. // For example, this would serialize Dates as ISO strings. // Date.prototype.toJSON = function (key) { // function f(n) { // // Format integers to have at least two digits. // return (n < 10) // ? "0" + n // : n; // } // return this.getUTCFullYear() + "-" + // f(this.getUTCMonth() + 1) + "-" + // f(this.getUTCDate()) + "T" + // f(this.getUTCHours()) + ":" + // f(this.getUTCMinutes()) + ":" + // f(this.getUTCSeconds()) + "Z"; // }; // You can provide an optional replacer method. It will be passed the // key and value of each member, with this bound to the containing // object. The value that is returned from your method will be // serialized. If your method returns undefined, then the member will // be excluded from the serialization. // If the replacer parameter is an array of strings, then it will be // used to select the members to be serialized. It filters the results // such that only members with keys listed in the replacer array are // stringified. // Values that do not have JSON representations, such as undefined or // functions, will not be serialized. Such values in objects will be // dropped; in arrays they will be replaced with null. You can use // a replacer function to replace those with JSON values. // JSON.stringify(undefined) returns undefined. // The optional space parameter produces a stringification of the // value that is filled with line breaks and indentation to make it // easier to read. // If the space parameter is a non-empty string, then that string will // be used for indentation. If the space parameter is a number, then // the indentation will be that many spaces. // Example: // text = JSON.stringify(["e", {pluribus: "unum"}]); // // text is '["e",{"pluribus":"unum"}]' // text = JSON.stringify(["e", {pluribus: "unum"}], null, "\t"); // // text is '[\n\t"e",\n\t{\n\t\t"pluribus": "unum"\n\t}\n]' // text = JSON.stringify([new Date()], function (key, value) { // return this[key] instanceof Date // ? "Date(" + this[key] + ")" // : value; // }); // // text is '["Date(---current time---)"]' // JSON.parse(text, reviver) // This method parses a JSON text to produce an object or array. // It can throw a SyntaxError exception. // The optional reviver parameter is a function that can filter and // transform the results. It receives each of the keys and values, // and its return value is used instead of the original value. // If it returns what it received, then the structure is not modified. // If it returns undefined then the member is deleted. // Example: // // Parse the text. Values that look like ISO date strings will // // be converted to Date objects. // myData = JSON.parse(text, function (key, value) { // var a; // if (typeof value === "string") { // a = // /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value); // if (a) { // return new Date(Date.UTC( // +a[1], +a[2] - 1, +a[3], +a[4], +a[5], +a[6] // )); // } // return value; // } // }); // myData = JSON.parse( // "[\"Date(09/09/2001)\"]", // function (key, value) { // var d; // if ( // typeof value === "string" // && value.slice(0, 5) === "Date(" // && value.slice(-1) === ")" // ) { // d = new Date(value.slice(5, -1)); // if (d) { // return d; // } // } // return value; // } // ); // This is a reference implementation. You are free to copy, modify, or // redistribute. /*jslint eval, for, this */ /*property JSON, apply, call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours, getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join, lastIndex, length, parse, prototype, push, replace, slice, stringify, test, toJSON, toString, valueOf */ // Create a JSON object only if one does not already exist. We create the // methods in a closure to avoid creating global variables. if (typeof JSON !== "object") { JSON = {}; } (function () { "use strict"; var rx_one = /^[\],:{}\s]*$/; var rx_two = /\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g; var rx_three = /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g; var rx_four = /(?:^|:|,)(?:\s*\[)+/g; var rx_escapable = /[\\"\u0000-\u001f\u007f-\u009f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; var rx_dangerous = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; function f(n) { // Format integers to have at least two digits. return (n < 10) ? "0" + n : n; } function this_value() { return this.valueOf(); } if (typeof Date.prototype.toJSON !== "function") { Date.prototype.toJSON = function () { return isFinite(this.valueOf()) ? ( this.getUTCFullYear() + "-" + f(this.getUTCMonth() + 1) + "-" + f(this.getUTCDate()) + "T" + f(this.getUTCHours()) + ":" + f(this.getUTCMinutes()) + ":" + f(this.getUTCSeconds()) + "Z" ) : null; }; Boolean.prototype.toJSON = this_value; Number.prototype.toJSON = this_value; String.prototype.toJSON = this_value; } var gap; var indent; var meta; var rep; function quote(string) { // If the string contains no control characters, no quote characters, and no // backslash characters, then we can safely slap some quotes around it. // Otherwise we must also replace the offending characters with safe escape // sequences. rx_escapable.lastIndex = 0; return rx_escapable.test(string) ? "\"" + string.replace(rx_escapable, function (a) { var c = meta[a]; return typeof c === "string" ? c : "\\u" + ("0000" + a.charCodeAt(0).toString(16)).slice(-4); }) + "\"" : "\"" + string + "\""; } function str(key, holder) { // Produce a string from holder[key]. var i; // The loop counter. var k; // The member key. var v; // The member value. var length; var mind = gap; var partial; var value = holder[key]; // If the value has a toJSON method, call it to obtain a replacement value. if ( value && typeof value === "object" && typeof value.toJSON === "function" ) { value = value.toJSON(key); } // If we were called with a replacer function, then call the replacer to // obtain a replacement value. if (typeof rep === "function") { value = rep.call(holder, key, value); } // What happens next depends on the value's type. switch (typeof value) { case "string": return quote(value); case "number": // JSON numbers must be finite. Encode non-finite numbers as null. return (isFinite(value)) ? String(value) : "null"; case "boolean": case "null": // If the value is a boolean or null, convert it to a string. Note: // typeof null does not produce "null". The case is included here in // the remote chance that this gets fixed someday. return String(value); // If the type is "object", we might be dealing with an object or an array or // null. case "object": // Due to a specification blunder in ECMAScript, typeof null is "object", // so watch out for that case. if (!value) { return "null"; } // Make an array to hold the partial results of stringifying this object value. gap += indent; partial = []; // Is the value an array? if (Object.prototype.toString.apply(value) === "[object Array]") { // The value is an array. Stringify every element. Use null as a placeholder // for non-JSON values. length = value.length; for (i = 0; i < length; i += 1) { partial[i] = str(i, value) || "null"; } // Join all of the elements together, separated with commas, and wrap them in // brackets. v = partial.length === 0 ? "[]" : gap ? ( "[\n" + gap + partial.join(",\n" + gap) + "\n" + mind + "]" ) : "[" + partial.join(",") + "]"; gap = mind; return v; } // If the replacer is an array, use it to select the members to be stringified. if (rep && typeof rep === "object") { length = rep.length; for (i = 0; i < length; i += 1) { if (typeof rep[i] === "string") { k = rep[i]; v = str(k, value); if (v) { partial.push(quote(k) + ( (gap) ? ": " : ":" ) + v); } } } } else { // Otherwise, iterate through all of the keys in the object. for (k in value) { if (Object.prototype.hasOwnProperty.call(value, k)) { v = str(k, value); if (v) { partial.push(quote(k) + ( (gap) ? ": " : ":" ) + v); } } } } // Join all of the member texts together, separated with commas, // and wrap them in braces. v = partial.length === 0 ? "{}" : gap ? "{\n" + gap + partial.join(",\n" + gap) + "\n" + mind + "}" : "{" + partial.join(",") + "}"; gap = mind; return v; } } // If the JSON object does not yet have a stringify method, give it one. if (typeof JSON.stringify !== "function") { meta = { // table of character substitutions "\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", "\r": "\\r", "\"": "\\\"", "\\": "\\\\" }; JSON.stringify = function (value, replacer, space) { // The stringify method takes a value and an optional replacer, and an optional // space parameter, and returns a JSON text. The replacer can be a function // that can replace values, or an array of strings that will select the keys. // A default replacer method can be provided. Use of the space parameter can // produce text that is more easily readable. var i; gap = ""; indent = ""; // If the space parameter is a number, make an indent string containing that // many spaces. if (typeof space === "number") { for (i = 0; i < space; i += 1) { indent += " "; } // If the space parameter is a string, it will be used as the indent string. } else if (typeof space === "string") { indent = space; } // If there is a replacer, it must be a function or an array. // Otherwise, throw an error. rep = replacer; if (replacer && typeof replacer !== "function" && ( typeof replacer !== "object" || typeof replacer.length !== "number" )) { throw new Error("JSON.stringify"); } // Make a fake root object containing our value under the key of "". // Return the result of stringifying the value. return str("", {"": value}); }; } // If the JSON object does not yet have a parse method, give it one. if (typeof JSON.parse !== "function") { JSON.parse = function (text, reviver) { // The parse method takes a text and an optional reviver function, and returns // a JavaScript value if the text is a valid JSON text. var j; function walk(holder, key) { // The walk method is used to recursively walk the resulting structure so // that modifications can be made. var k; var v; var value = holder[key]; if (value && typeof value === "object") { for (k in value) { if (Object.prototype.hasOwnProperty.call(value, k)) { v = walk(value, k); if (v !== undefined) { value[k] = v; } else { delete value[k]; } } } } return reviver.call(holder, key, value); } // Parsing happens in four stages. In the first stage, we replace certain // Unicode characters with escape sequences. JavaScript handles many characters // incorrectly, either silently deleting them, or treating them as line endings. text = String(text); rx_dangerous.lastIndex = 0; if (rx_dangerous.test(text)) { text = text.replace(rx_dangerous, function (a) { return ( "\\u" + ("0000" + a.charCodeAt(0).toString(16)).slice(-4) ); }); } // In the second stage, we run the text against regular expressions that look // for non-JSON patterns. We are especially concerned with "()" and "new" // because they can cause invocation, and "=" because it can cause mutation. // But just to be safe, we want to reject all unexpected forms. // We split the second stage into 4 regexp operations in order to work around // crippling inefficiencies in IE's and Safari's regexp engines. First we // replace the JSON backslash pairs with "@" (a non-JSON character). Second, we // replace all simple value tokens with "]" characters. Third, we delete all // open brackets that follow a colon or comma or that begin the text. Finally, // we look to see that the remaining characters are only whitespace or "]" or // "," or ":" or "{" or "}". If that is so, then the text is safe for eval. if ( rx_one.test( text .replace(rx_two, "@") .replace(rx_three, "]") .replace(rx_four, "") ) ) { // In the third stage we use the eval function to compile the text into a // JavaScript structure. The "{" operator is subject to a syntactic ambiguity // in JavaScript: it can begin a block or an object literal. We wrap the text // in parens to eliminate the ambiguity. j = eval("(" + text + ")"); // In the optional fourth stage, we recursively walk the new structure, passing // each name/value pair to a reviver function for possible transformation. return (typeof reviver === "function") ? walk({"": j}, "") : j; } // If the text is not JSON parseable, then a SyntaxError is thrown. throw new SyntaxError("JSON.parse"); }; } }()); ================================================ FILE: openpype/hosts/aftereffects/api/extension/js/libs/wsrpc.js ================================================ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = global || self, global.WSRPC = factory()); }(this, function () { 'use strict'; function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } var Deferred = function Deferred() { _classCallCheck(this, Deferred); var self = this; self.resolve = null; self.reject = null; self.done = false; function wrapper(func) { return function () { if (self.done) throw new Error('Promise already done'); self.done = true; return func.apply(this, arguments); }; } self.promise = new Promise(function (resolve, reject) { self.resolve = wrapper(resolve); self.reject = wrapper(reject); }); self.promise.isPending = function () { return !self.done; }; return self; }; function logGroup(group, level, args) { console.group(group); console[level].apply(this, args); console.groupEnd(); } function log() { if (!WSRPC.DEBUG) return; logGroup('WSRPC.DEBUG', 'trace', arguments); } function trace(msg) { if (!WSRPC.TRACE) return; var payload = msg; if ('data' in msg) payload = JSON.parse(msg.data); logGroup("WSRPC.TRACE", 'trace', [payload]); } function getAbsoluteWsUrl(url) { if (/^\w+:\/\//.test(url)) return url; if (typeof window == 'undefined' && window.location.host.length < 1) throw new Error("Can not construct absolute URL from ".concat(window.location)); var scheme = window.location.protocol === "https:" ? "wss:" : "ws:"; var port = window.location.port === '' ? ":".concat(window.location.port) : ''; var host = window.location.host; var path = url.replace(/^\/+/gm, ''); return "".concat(scheme, "//").concat(host).concat(port, "/").concat(path); } var readyState = Object.freeze({ 0: 'CONNECTING', 1: 'OPEN', 2: 'CLOSING', 3: 'CLOSED' }); var WSRPC = function WSRPC(URL) { var reconnectTimeout = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1000; _classCallCheck(this, WSRPC); var self = this; URL = getAbsoluteWsUrl(URL); self.id = 1; self.eventId = 0; self.socketStarted = false; self.eventStore = { onconnect: {}, onerror: {}, onclose: {}, onchange: {} }; self.connectionNumber = 0; self.oneTimeEventStore = { onconnect: [], onerror: [], onclose: [], onchange: [] }; self.callQueue = []; function createSocket() { var ws = new WebSocket(URL); var rejectQueue = function rejectQueue() { self.connectionNumber++; // rejects incoming calls var deferred; //reject all pending calls while (0 < self.callQueue.length) { var callObj = self.callQueue.shift(); deferred = self.store[callObj.id]; delete self.store[callObj.id]; if (deferred && deferred.promise.isPending()) { deferred.reject('WebSocket error occurred'); } } // reject all from the store for (var key in self.store) { if (!self.store.hasOwnProperty(key)) continue; deferred = self.store[key]; if (deferred && deferred.promise.isPending()) { deferred.reject('WebSocket error occurred'); } } }; function reconnect(callEvents) { setTimeout(function () { try { self.socket = createSocket(); self.id = 1; } catch (exc) { callEvents('onerror', exc); delete self.socket; console.error(exc); } }, reconnectTimeout); } ws.onclose = function (err) { log('ONCLOSE CALLED', 'STATE', self.public.state()); trace(err); for (var serial in self.store) { if (!self.store.hasOwnProperty(serial)) continue; if (self.store[serial].hasOwnProperty('reject')) { self.store[serial].reject('Connection closed'); } } rejectQueue(); callEvents('onclose', err); callEvents('onchange', err); reconnect(callEvents); }; ws.onerror = function (err) { log('ONERROR CALLED', 'STATE', self.public.state()); trace(err); rejectQueue(); callEvents('onerror', err); callEvents('onchange', err); log('WebSocket has been closed by error: ', err); }; function tryCallEvent(func, event) { try { return func(event); } catch (e) { if (e.hasOwnProperty('stack')) { log(e.stack); } else { log('Event function', func, 'raised unknown error:', e); } console.error(e); } } function callEvents(evName, event) { while (0 < self.oneTimeEventStore[evName].length) { var deferred = self.oneTimeEventStore[evName].shift(); if (deferred.hasOwnProperty('resolve') && deferred.promise.isPending()) deferred.resolve(); } for (var i in self.eventStore[evName]) { if (!self.eventStore[evName].hasOwnProperty(i)) continue; var cur = self.eventStore[evName][i]; tryCallEvent(cur, event); } } ws.onopen = function (ev) { log('ONOPEN CALLED', 'STATE', self.public.state()); trace(ev); while (0 < self.callQueue.length) { // noinspection JSUnresolvedFunction self.socket.send(JSON.stringify(self.callQueue.shift(), 0, 1)); } callEvents('onconnect', ev); callEvents('onchange', ev); }; function handleCall(self, data) { if (!self.routes.hasOwnProperty(data.method)) throw new Error('Route not found'); var connectionNumber = self.connectionNumber; var deferred = new Deferred(); deferred.promise.then(function (result) { if (connectionNumber !== self.connectionNumber) return; self.socket.send(JSON.stringify({ id: data.id, result: result })); }, function (error) { if (connectionNumber !== self.connectionNumber) return; self.socket.send(JSON.stringify({ id: data.id, error: error })); }); var func = self.routes[data.method]; if (self.asyncRoutes[data.method]) return func.apply(deferred, [data.params]); function badPromise() { throw new Error("You should register route with async flag."); } var promiseMock = { resolve: badPromise, reject: badPromise }; try { deferred.resolve(func.apply(promiseMock, [data.params])); } catch (e) { deferred.reject(e); console.error(e); } } function handleError(self, data) { if (!self.store.hasOwnProperty(data.id)) return log('Unknown callback'); var deferred = self.store[data.id]; if (typeof deferred === 'undefined') return log('Confirmation without handler'); delete self.store[data.id]; log('REJECTING', data.error); deferred.reject(data.error); } function handleResult(self, data) { var deferred = self.store[data.id]; if (typeof deferred === 'undefined') return log('Confirmation without handler'); delete self.store[data.id]; if (data.hasOwnProperty('result')) { return deferred.resolve(data.result); } return deferred.reject(data.error); } ws.onmessage = function (message) { log('ONMESSAGE CALLED', 'STATE', self.public.state()); trace(message); if (message.type !== 'message') return; var data; try { data = JSON.parse(message.data); log(data); if (data.hasOwnProperty('method')) { return handleCall(self, data); } else if (data.hasOwnProperty('error') && data.error === null) { return handleError(self, data); } else { return handleResult(self, data); } } catch (exception) { var err = { error: exception.message, result: null, id: data ? data.id : null }; self.socket.send(JSON.stringify(err)); console.error(exception); } }; return ws; } function makeCall(func, args, params) { self.id += 2; var deferred = new Deferred(); var callObj = Object.freeze({ id: self.id, method: func, params: args }); var state = self.public.state(); if (state === 'OPEN') { self.store[self.id] = deferred; self.socket.send(JSON.stringify(callObj)); } else if (state === 'CONNECTING') { log('SOCKET IS', state); self.store[self.id] = deferred; self.callQueue.push(callObj); } else { log('SOCKET IS', state); if (params && params['noWait']) { deferred.reject("Socket is: ".concat(state)); } else { self.store[self.id] = deferred; self.callQueue.push(callObj); } } return deferred.promise; } self.asyncRoutes = {}; self.routes = {}; self.store = {}; self.public = Object.freeze({ call: function call(func, args, params) { return makeCall(func, args, params); }, addRoute: function addRoute(route, callback, isAsync) { self.asyncRoutes[route] = isAsync || false; self.routes[route] = callback; }, deleteRoute: function deleteRoute(route) { delete self.asyncRoutes[route]; return delete self.routes[route]; }, addEventListener: function addEventListener(event, func) { var eventId = self.eventId++; self.eventStore[event][eventId] = func; return eventId; }, removeEventListener: function removeEventListener(event, index) { if (self.eventStore[event].hasOwnProperty(index)) { delete self.eventStore[event][index]; return true; } else { return false; } }, onEvent: function onEvent(event) { var deferred = new Deferred(); self.oneTimeEventStore[event].push(deferred); return deferred.promise; }, destroy: function destroy() { return self.socket.close(); }, state: function state() { return readyState[this.stateCode()]; }, stateCode: function stateCode() { if (self.socketStarted && self.socket) return self.socket.readyState; return 3; }, connect: function connect() { self.socketStarted = true; self.socket = createSocket(); } }); self.public.addRoute('log', function (argsObj) { //console.info("Websocket sent: ".concat(argsObj)); }); self.public.addRoute('ping', function (data) { return data; }); return self.public; }; WSRPC.DEBUG = false; WSRPC.TRACE = false; return WSRPC; })); //# sourceMappingURL=wsrpc.js.map ================================================ FILE: openpype/hosts/aftereffects/api/extension/js/main.js ================================================ /*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true, indent: 4, maxerr: 50 */ /*global $, window, location, CSInterface, SystemPath, themeManager*/ var csInterface = new CSInterface(); log.warn("script start"); WSRPC.DEBUG = false; WSRPC.TRACE = false; // get websocket server url from environment value async function startUp(url){ promis = runEvalScript("getEnv('" + url + "')"); var res = await promis; log.warn("res: " + res); promis = runEvalScript("getEnv('OPENPYPE_DEBUG')"); var debug = await promis; log.warn("debug: " + debug); if (debug && debug.toString() == '3'){ WSRPC.DEBUG = true; WSRPC.TRACE = true; } // run rest only after resolved promise main(res); } function get_extension_version(){ /** Returns version number from extension manifest.xml **/ log.debug("get_extension_version") var path = csInterface.getSystemPath(SystemPath.EXTENSION); log.debug("extension path " + path); var result = window.cep.fs.readFile(path + "/CSXS/manifest.xml"); var version = undefined; if(result.err === 0){ if (window.DOMParser) { const parser = new DOMParser(); const xmlDoc = parser.parseFromString(result.data.toString(), 'text/xml'); const children = xmlDoc.children; for (let i = 0; i <= children.length; i++) { if (children[i] && children[i].getAttribute('ExtensionBundleVersion')) { version = children[i].getAttribute('ExtensionBundleVersion'); } } } } return '{"result":"' + version + '"}' } function main(websocket_url){ // creates connection to 'websocket_url', registers routes var default_url = 'ws://localhost:8099/ws/'; if (websocket_url == ''){ websocket_url = default_url; } RPC = new WSRPC(websocket_url, 5000); // spin connection RPC.connect(); log.warn("connected"); RPC.addRoute('AfterEffects.open', function (data) { log.warn('Server called client route "open":', data); var escapedPath = EscapeStringForJSX(data.path); return runEvalScript("fileOpen('" + escapedPath +"')") .then(function(result){ log.warn("open: " + result); return result; }); }); RPC.addRoute('AfterEffects.get_metadata', function (data) { log.warn('Server called client route "get_metadata":', data); return runEvalScript("getMetadata()") .then(function(result){ log.warn("getMetadata: " + result); return result; }); }); RPC.addRoute('AfterEffects.get_active_document_name', function (data) { log.warn('Server called client route ' + '"get_active_document_name":', data); return runEvalScript("getActiveDocumentName()") .then(function(result){ log.warn("get_active_document_name: " + result); return result; }); }); RPC.addRoute('AfterEffects.get_active_document_full_name', function (data){ log.warn('Server called client route ' + '"get_active_document_full_name":', data); return runEvalScript("getActiveDocumentFullName()") .then(function(result){ log.warn("get_active_document_full_name: " + result); return result; }); }); RPC.addRoute('AfterEffects.add_item', function (data) { log.warn('Server called client route "add_item":', data); var escapedName = EscapeStringForJSX(data.name); return runEvalScript("addItem('" + escapedName +"', " + "'" + data.item_type + "')") .then(function(result){ log.warn("get_items: " + result); return result; }); }); RPC.addRoute('AfterEffects.get_items', function (data) { log.warn('Server called client route "get_items":', data); return runEvalScript("getItems(" + data.comps + "," + data.folders + "," + data.footages + ")") .then(function(result){ log.warn("get_items: " + result); return result; }); }); RPC.addRoute('AfterEffects.select_items', function (data) { log.warn('Server called client route "select_items":', data); return runEvalScript("selectItems(" + JSON.stringify(data.items) + ")") .then(function(result){ log.warn("select_items: " + result); return result; }); }); RPC.addRoute('AfterEffects.get_selected_items', function (data) { log.warn('Server called client route "get_selected_items":', data); return runEvalScript("getSelectedItems(" + data.comps + "," + data.folders + "," + data.footages + ")") .then(function(result){ log.warn("get_items: " + result); return result; }); }); RPC.addRoute('AfterEffects.import_file', function (data) { log.warn('Server called client route "import_file":', data); var escapedPath = EscapeStringForJSX(data.path); return runEvalScript("importFile('" + escapedPath +"', " + "'" + data.item_name + "'," + "'" + JSON.stringify( data.import_options) + "')") .then(function(result){ log.warn("importFile: " + result); return result; }); }); RPC.addRoute('AfterEffects.replace_item', function (data) { log.warn('Server called client route "replace_item":', data); var escapedPath = EscapeStringForJSX(data.path); return runEvalScript("replaceItem(" + data.item_id + ", " + "'" + escapedPath + "', " + "'" + data.item_name + "')") .then(function(result){ log.warn("replaceItem: " + result); return result; }); }); RPC.addRoute('AfterEffects.rename_item', function (data) { log.warn('Server called client route "rename_item":', data); return runEvalScript("renameItem(" + data.item_id + ", " + "'" + data.item_name + "')") .then(function(result){ log.warn("renameItem: " + result); return result; }); }); RPC.addRoute('AfterEffects.delete_item', function (data) { log.warn('Server called client route "delete_item":', data); return runEvalScript("deleteItem(" + data.item_id + ")") .then(function(result){ log.warn("deleteItem: " + result); return result; }); }); RPC.addRoute('AfterEffects.imprint', function (data) { log.warn('Server called client route "imprint":', data); var escaped = data.payload.replace(/\n/g, "\\n"); return runEvalScript("imprint('" + escaped +"')") .then(function(result){ log.warn("imprint: " + result); return result; }); }); RPC.addRoute('AfterEffects.set_label_color', function (data) { log.warn('Server called client route "set_label_color":', data); return runEvalScript("setLabelColor(" + data.item_id + "," + data.color_idx + ")") .then(function(result){ log.warn("imprint: " + result); return result; }); }); RPC.addRoute('AfterEffects.get_comp_properties', function (data) { log.warn('Server called client route "get_comp_properties":', data); return runEvalScript("getCompProperties(" + data.item_id + ")") .then(function(result){ log.warn("get_comp_properties: " + result); return result; }); }); RPC.addRoute('AfterEffects.set_comp_properties', function (data) { log.warn('Server called client route "set_work_area":', data); return runEvalScript("setCompProperties(" + data.item_id + ',' + data.start + ',' + data.duration + ',' + data.frame_rate + ',' + data.width + ',' + data.height + ")") .then(function(result){ log.warn("set_comp_properties: " + result); return result; }); }); RPC.addRoute('AfterEffects.saveAs', function (data) { log.warn('Server called client route "saveAs":', data); var escapedPath = EscapeStringForJSX(data.image_path); return runEvalScript("saveAs('" + escapedPath + "', " + data.as_copy + ")") .then(function(result){ log.warn("saveAs: " + result); return result; }); }); RPC.addRoute('AfterEffects.save', function (data) { log.warn('Server called client route "save":', data); return runEvalScript("save()") .then(function(result){ log.warn("save: " + result); return result; }); }); RPC.addRoute('AfterEffects.get_render_info', function (data) { log.warn('Server called client route "get_render_info":', data); return runEvalScript("getRenderInfo(" + data.comp_id +")") .then(function(result){ log.warn("get_render_info: " + result); return result; }); }); RPC.addRoute('AfterEffects.get_audio_url', function (data) { log.warn('Server called client route "get_audio_url":', data); return runEvalScript("getAudioUrlForComp(" + data.item_id + ")") .then(function(result){ log.warn("getAudioUrlForComp: " + result); return result; }); }); RPC.addRoute('AfterEffects.import_background', function (data) { log.warn('Server called client route "import_background":', data); return runEvalScript("importBackground(" + data.comp_id + ", " + "'" + data.comp_name + "', " + JSON.stringify(data.files) + ")") .then(function(result){ log.warn("importBackground: " + result); return result; }); }); RPC.addRoute('AfterEffects.reload_background', function (data) { log.warn('Server called client route "reload_background":', data); return runEvalScript("reloadBackground(" + data.comp_id + ", " + "'" + data.comp_name + "', " + JSON.stringify(data.files) + ")") .then(function(result){ log.warn("reloadBackground: " + result); return result; }); }); RPC.addRoute('AfterEffects.add_item_as_layer', function (data) { log.warn('Server called client route "add_item_as_layer":', data); return runEvalScript("addItemAsLayerToComp(" + data.comp_id + ", " + data.item_id + "," + " null )") .then(function(result){ log.warn("addItemAsLayerToComp: " + result); return result; }); }); RPC.addRoute('AfterEffects.add_item_instead_placeholder', function (data) { log.warn('Server called client route "add_item_instead_placeholder":', data); return runEvalScript("addItemInstead(" + data.placeholder_item_id + ", " + data.item_id + ")") .then(function(result){ log.warn("add_item_instead_placeholder: " + result); return result; }); }); RPC.addRoute('AfterEffects.render', function (data) { log.warn('Server called client route "render":', data); var escapedPath = EscapeStringForJSX(data.folder_url); return runEvalScript("render('" + escapedPath +"', " + data.comp_id + ")") .then(function(result){ log.warn("render: " + result); return result; }); }); RPC.addRoute('AfterEffects.get_extension_version', function (data) { log.warn('Server called client route "get_extension_version":', data); return get_extension_version(); }); RPC.addRoute('AfterEffects.get_app_version', function (data) { log.warn('Server called client route "get_app_version":', data); return runEvalScript("getAppVersion()") .then(function(result){ log.warn("get_app_version: " + result); return result; }); }); RPC.addRoute('AfterEffects.add_placeholder', function (data) { log.warn('Server called client route "add_placeholder":', data); var escapedName = EscapeStringForJSX(data.name); return runEvalScript("addPlaceholder('" + escapedName +"',"+ data.width + ',' + data.height + ',' + data.fps + ',' + data.duration + ")") .then(function(result){ log.warn("add_placeholder: " + result); return result; }); }); RPC.addRoute('AfterEffects.close', function (data) { log.warn('Server called client route "close":', data); return runEvalScript("close()"); }); RPC.addRoute('AfterEffects.print_msg', function (data) { log.warn('Server called client route "print_msg":', data); var escaped_msg = EscapeStringForJSX(data.msg); return runEvalScript("printMsg('" + escaped_msg +"')") .then(function(result){ log.warn("print_msg: " + result); return result; }); }); } /** main entry point **/ startUp("WEBSOCKET_URL"); (function () { 'use strict'; var csInterface = new CSInterface(); function init() { themeManager.init(); $("#btn_test").click(function () { csInterface.evalScript('sayHello()'); }); } init(); }()); function EscapeStringForJSX(str){ // Replaces: // \ with \\ // ' with \' // " with \" // See: https://stackoverflow.com/a/3967927/5285364 return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g,'\\"'); } function runEvalScript(script) { // because of asynchronous nature of functions in jsx // this waits for response return new Promise(function(resolve, reject){ csInterface.evalScript(script, resolve); }); } ================================================ FILE: openpype/hosts/aftereffects/api/extension/js/themeManager.js ================================================ /*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true, indent: 4, maxerr: 50 */ /*global window, document, CSInterface*/ /* Responsible for overwriting CSS at runtime according to CC app settings as defined by the end user. */ var themeManager = (function () { 'use strict'; /** * Convert the Color object to string in hexadecimal format; */ function toHex(color, delta) { function computeValue(value, delta) { var computedValue = !isNaN(delta) ? value + delta : value; if (computedValue < 0) { computedValue = 0; } else if (computedValue > 255) { computedValue = 255; } computedValue = Math.floor(computedValue); computedValue = computedValue.toString(16); return computedValue.length === 1 ? "0" + computedValue : computedValue; } var hex = ""; if (color) { hex = computeValue(color.red, delta) + computeValue(color.green, delta) + computeValue(color.blue, delta); } return hex; } function reverseColor(color, delta) { return toHex({ red: Math.abs(255 - color.red), green: Math.abs(255 - color.green), blue: Math.abs(255 - color.blue) }, delta); } function addRule(stylesheetId, selector, rule) { var stylesheet = document.getElementById(stylesheetId); if (stylesheet) { stylesheet = stylesheet.sheet; if (stylesheet.addRule) { stylesheet.addRule(selector, rule); } else if (stylesheet.insertRule) { stylesheet.insertRule(selector + ' { ' + rule + ' }', stylesheet.cssRules.length); } } } /** * Update the theme with the AppSkinInfo retrieved from the host product. */ function updateThemeWithAppSkinInfo(appSkinInfo) { var panelBgColor = appSkinInfo.panelBackgroundColor.color; var bgdColor = toHex(panelBgColor); var darkBgdColor = toHex(panelBgColor, 20); var fontColor = "F0F0F0"; if (panelBgColor.red > 122) { fontColor = "000000"; } var lightBgdColor = toHex(panelBgColor, -100); var styleId = "hostStyle"; addRule(styleId, ".hostElt", "background-color:" + "#" + bgdColor); addRule(styleId, ".hostElt", "font-size:" + appSkinInfo.baseFontSize + "px;"); addRule(styleId, ".hostElt", "font-family:" + appSkinInfo.baseFontFamily); addRule(styleId, ".hostElt", "color:" + "#" + fontColor); addRule(styleId, ".hostBgd", "background-color:" + "#" + bgdColor); addRule(styleId, ".hostBgdDark", "background-color: " + "#" + darkBgdColor); addRule(styleId, ".hostBgdLight", "background-color: " + "#" + lightBgdColor); addRule(styleId, ".hostFontSize", "font-size:" + appSkinInfo.baseFontSize + "px;"); addRule(styleId, ".hostFontFamily", "font-family:" + appSkinInfo.baseFontFamily); addRule(styleId, ".hostFontColor", "color:" + "#" + fontColor); addRule(styleId, ".hostFont", "font-size:" + appSkinInfo.baseFontSize + "px;"); addRule(styleId, ".hostFont", "font-family:" + appSkinInfo.baseFontFamily); addRule(styleId, ".hostFont", "color:" + "#" + fontColor); addRule(styleId, ".hostButton", "background-color:" + "#" + darkBgdColor); addRule(styleId, ".hostButton:hover", "background-color:" + "#" + bgdColor); addRule(styleId, ".hostButton:active", "background-color:" + "#" + darkBgdColor); addRule(styleId, ".hostButton", "border-color: " + "#" + lightBgdColor); } function onAppThemeColorChanged(event) { var skinInfo = JSON.parse(window.__adobe_cep__.getHostEnvironment()).appSkinInfo; updateThemeWithAppSkinInfo(skinInfo); } function init() { var csInterface = new CSInterface(); updateThemeWithAppSkinInfo(csInterface.hostEnvironment.appSkinInfo); csInterface.addEventListener(CSInterface.THEME_COLOR_CHANGED_EVENT, onAppThemeColorChanged); } return { init: init }; }()); ================================================ FILE: openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx ================================================ /*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true, indent: 4, maxerr: 50 */ /*global $, Folder*/ //@include "../js/libs/json.js" /* All public API function should return JSON! */ app.preferences.savePrefAsBool("General Section", "Show Welcome Screen", false) ; if(!Array.prototype.indexOf) { Array.prototype.indexOf = function ( item ) { var index = 0, length = this.length; for ( ; index < length; index++ ) { if ( this[index] === item ) return index; } return -1; }; } function sayHello(){ alert("hello from ExtendScript"); } function getEnv(variable){ return $.getenv(variable); } function getMetadata(){ /** * Returns payload in 'Label' field of project's metadata * **/ if (ExternalObject.AdobeXMPScript === undefined){ ExternalObject.AdobeXMPScript = new ExternalObject('lib:AdobeXMPScript'); } var proj = app.project; var meta = new XMPMeta(app.project.xmpPacket); var schemaNS = XMPMeta.getNamespaceURI("xmp"); var label = "xmp:Label"; if (meta.doesPropertyExist(schemaNS, label)){ var prop = meta.getProperty(schemaNS, label); return prop.value; } return _prepareSingleValue([]); } function imprint(payload){ /** * Stores payload in 'Label' field of project's metadata * * Args: * payload (string): json content */ if (ExternalObject.AdobeXMPScript === undefined){ ExternalObject.AdobeXMPScript = new ExternalObject('lib:AdobeXMPScript'); } var proj = app.project; var meta = new XMPMeta(app.project.xmpPacket); var schemaNS = XMPMeta.getNamespaceURI("xmp"); var label = "xmp:Label"; meta.setProperty(schemaNS, label, payload); app.project.xmpPacket = meta.serialize(); } function fileOpen(path){ /** * Opens (project) file on 'path' */ fp = new File(path); return _prepareSingleValue(app.open(fp)) } function getActiveDocumentName(){ /** * Returns file name of active document * */ var file = app.project.file; if (file){ return _prepareSingleValue(file.name) } return _prepareError("No file open currently"); } function getActiveDocumentFullName(){ /** * Returns absolute path to current project * */ var file = app.project.file; if (file){ var f = new File(file.fullName); var path = f.fsName; f.close(); return _prepareSingleValue(path) } return _prepareError("No file open currently"); } function addItem(name, item_type){ /** * Adds comp or folder to project items. * * Could be called when creating publishable instance to prepare * composition (and render queue). * * Args: * name (str): composition name * item_type (str): COMP|FOLDER * Returns: * SingleItemValue: eg {"result": VALUE} */ if (item_type == "COMP"){ // dummy values, will be rewritten later item = app.project.items.addComp(name, 1920, 1060, 1, 10, 25); }else if (item_type == "FOLDER"){ item = app.project.items.addFolder(name); }else{ return _prepareError("Only 'COMP' or 'FOLDER' can be created"); } return _prepareSingleValue(item.id); } function getItems(comps, folders, footages){ /** * Returns JSON representation of compositions and * if 'collectLayers' then layers in comps too. * * Args: * comps (bool): return selected compositions * folders (bool): return folders * footages (bool): return FootageItem * Returns: * (list) of JSON items */ var items = [] for (i = 1; i <= app.project.items.length; ++i){ var item = app.project.items[i]; if (!item){ continue; } var ret = _getItem(item, comps, folders, footages); if (ret){ items.push(ret); } } return '[' + items.join() + ']'; } function selectItems(items){ /** * Select all items from `items`, deselect other. * * Args: * items (list) */ for (i = 1; i <= app.project.items.length; ++i){ item = app.project.items[i]; if (items.indexOf(item.id) > -1){ item.selected = true; }else{ item.selected = false; } } } function getSelectedItems(comps, folders, footages){ /** * Returns list of selected items from Project menu * * Args: * comps (bool): return selected compositions * folders (bool): return folders * footages (bool): return FootageItem * Returns: * (list) of JSON items */ var items = [] for (i = 0; i < app.project.selection.length; ++i){ var item = app.project.selection[i]; if (!item){ continue; } var ret = _getItem(item, comps, folders, footages); if (ret){ items.push(ret); } } return '[' + items.join() + ']'; } function _getItem(item, comps, folders, footages){ /** * Auxiliary function as project items and selections * are indexed in different way :/ * Refactor */ var item_type = ''; var path = ''; var containing_comps = []; if (item instanceof FolderItem){ item_type = 'folder'; if (!folders){ return "{}"; } } if (item instanceof FootageItem){ if (!footages){ return "{}"; } item_type = 'footage'; if (item.file){ path = item.file.fsName; } if (item.usedIn){ for (j = 0; j < item.usedIn.length; ++j){ containing_comps.push(item.usedIn[j].id); } } } if (item instanceof CompItem){ item_type = 'comp'; if (!comps){ return "{}"; } } var item = {"name": item.name, "id": item.id, "type": item_type, "path": path, "containing_comps": containing_comps}; return JSON.stringify(item); } function importFile(path, item_name, import_options){ /** * Imports file (image tested for now) as a FootageItem. * Creates new composition * * Args: * path (string): absolute path to image file * item_name (string): label for composition * Returns: * JSON {name, id} */ var comp; var ret = {}; try{ import_options = JSON.parse(import_options); } catch (e){ return _prepareError("Couldn't parse import options " + import_options); } app.beginUndoGroup("Import File"); fp = new File(path); if (fp.exists){ try { im_opt = new ImportOptions(fp); importAsType = import_options["ImportAsType"]; if ('ImportAsType' in import_options){ // refactor if (importAsType.indexOf('COMP') > 0){ im_opt.importAs = ImportAsType.COMP; } if (importAsType.indexOf('FOOTAGE') > 0){ im_opt.importAs = ImportAsType.FOOTAGE; } if (importAsType.indexOf('COMP_CROPPED_LAYERS') > 0){ im_opt.importAs = ImportAsType.COMP_CROPPED_LAYERS; } if (importAsType.indexOf('PROJECT') > 0){ im_opt.importAs = ImportAsType.PROJECT; } } if ('sequence' in import_options){ im_opt.sequence = true; } comp = app.project.importFile(im_opt); if (app.project.selection.length == 2 && app.project.selection[0] instanceof FolderItem){ comp.parentFolder = app.project.selection[0] } } catch (error) { return _prepareError(error.toString() + importOptions.file.fsName); } finally { fp.close(); } }else{ return _prepareError("File " + path + " not found."); } if (comp){ comp.name = item_name; comp.label = 9; // Green ret = {"name": comp.name, "id": comp.id} } app.endUndoGroup(); return JSON.stringify(ret); } function setLabelColor(comp_id, color_idx){ /** * Set item_id label to 'color_idx' color * Args: * item_id (int): item id * color_idx (int): 0-16 index from Label */ var item = app.project.itemByID(comp_id); if (item){ item.label = color_idx; }else{ return _prepareError("There is no composition with "+ comp_id); } } function replaceItem(item_id, path, item_name){ /** * Replaces loaded file with new file and updates name * * Args: * item_id (int): id of composition, not a index! * path (string): absolute path to new file * item_name (string): new composition name */ app.beginUndoGroup("Replace File"); fp = new File(path); if (!fp.exists){ return _prepareError("File " + path + " not found."); } var item = app.project.itemByID(item_id); if (item){ try{ if (isFileSequence(item)) { item.replaceWithSequence(fp, false); }else{ item.replace(fp); } item.name = item_name; } catch (error) { return _prepareError(error.toString() + path); } finally { fp.close(); } }else{ return _prepareError("There is no item with "+ item_id); } app.endUndoGroup(); } function renameItem(item_id, new_name){ /** * Renames item with 'item_id' to 'new_name' * * Args: * item_id (int): id to search item * new_name (str) */ var item = app.project.itemByID(item_id); if (item){ item.name = new_name; }else{ return _prepareError("There is no composition with "+ comp_id); } } function deleteItem(item_id){ /** * Delete any 'item_id' * * Not restricted only to comp, it could delete * any item with 'id' */ var item = app.project.itemByID(item_id); if (item){ item.remove(); }else{ return _prepareError("There is no composition with "+ comp_id); } } function getCompProperties(comp_id){ /** * Returns information about composition - are that will be * rendered. * * Returns * (dict) */ var comp = app.project.itemByID(comp_id); if (!comp){ return _prepareError("There is no composition with "+ comp_id); } return JSON.stringify({ "id": comp.id, "name": comp.name, "frameStart": comp.displayStartFrame, "framesDuration": comp.duration * comp.frameRate, "frameRate": comp.frameRate, "width": comp.width, "height": comp.height}); } function setCompProperties(comp_id, frameStart, framesCount, frameRate, width, height){ /** * Sets work area info from outside (from Ftrack via OpenPype) */ var comp = app.project.itemByID(comp_id); if (!comp){ return _prepareError("There is no composition with "+ comp_id); } app.beginUndoGroup('change comp properties'); if (frameStart && framesCount && frameRate){ comp.displayStartFrame = frameStart; comp.duration = framesCount / frameRate; comp.frameRate = frameRate; } if (width && height){ var widthOld = comp.width; var widthNew = width; var widthDelta = widthNew - widthOld; var heightOld = comp.height; var heightNew = height; var heightDelta = heightNew - heightOld; var offset = [widthDelta / 2, heightDelta / 2]; comp.width = widthNew; comp.height = heightNew; for (var i = 1, il = comp.numLayers; i <= il; i++) { var layer = comp.layer(i); var positionProperty = layer.property('ADBE Transform Group').property('ADBE Position'); if (positionProperty.numKeys > 0) { for (var j = 1, jl = positionProperty.numKeys; j <= jl; j++) { var keyValue = positionProperty.keyValue(j); positionProperty.setValueAtKey(j, keyValue + offset); } } else { var positionValue = positionProperty.value; positionProperty.setValue(positionValue + offset); } } } app.endUndoGroup(); } function save(){ /** * Saves current project */ app.project.save(); //TODO path is wrong, File instead } function saveAs(path){ /** * Saves current project as 'path' * */ app.project.save(fp = new File(path)); } function getRenderInfo(comp_id){ /*** Get info from render queue. Currently pulls only file name to parse extension and if it is sequence in Python Args: comp_id (int): id of composition Return: (list) [{file_name:"xx.png", width:00, height:00}] **/ var item = app.project.itemByID(comp_id); if (!item){ return _prepareError("Composition with '" + comp_id + "' wasn't found! Recreate publishable instance(s)") } var comp_name = item.name; var output_metadata = [] try{ // render_item.duplicate() should create new item on renderQueue // BUT it works only sometimes, there are some weird synchronization issue // this method will be called always before render, so prepare items here // for render to spare the hassle for (i = 1; i <= app.project.renderQueue.numItems; ++i){ var render_item = app.project.renderQueue.item(i); if (render_item.comp.id != comp_id){ continue; } if (render_item.status == RQItemStatus.DONE){ render_item.duplicate(); // create new, cannot change status if DONE render_item.remove(); // remove existing to limit duplications continue; } } // properly validate as `numItems` won't change magically var comp_id_count = 0; for (i = 1; i <= app.project.renderQueue.numItems; ++i){ var render_item = app.project.renderQueue.item(i); if (render_item.comp.id != comp_id){ continue; } comp_id_count += 1; var item = render_item.outputModule(1); for (j = 1; j<= render_item.numOutputModules; ++j){ var file_url = item.file.toString(); output_metadata.push( JSON.stringify({ "file_name": file_url, "width": render_item.comp.width, "height": render_item.comp.height }) ); } } } catch (error) { return _prepareError("There is no render queue, create one"); } if (comp_id_count > 1){ return _prepareError("There cannot be more items in Render Queue for '" + comp_name + "'!") } if (comp_id_count == 0){ return _prepareError("There is no item in Render Queue for '" + comp_name + "'! Add composition to Render Queue.") } return '[' + output_metadata.join() + ']'; } function getAudioUrlForComp(comp_id){ /** * Searches composition for audio layer * * Only single AVLayer is expected! * Used for collecting Audio * * Args: * comp_id (int): id of composition * Return: * (str) with url to audio content */ var item = app.project.itemByID(comp_id); if (item){ for (i = 1; i <= item.numLayers; ++i){ var layer = item.layers[i]; if (layer instanceof AVLayer){ if (layer.hasAudio){ source_url = layer.source.file.fsName.toString() return _prepareSingleValue(source_url); } } } }else{ return _prepareError("There is no composition with "+ comp_id); } } function addItemAsLayerToComp(comp_id, item_id, found_comp){ /** * Adds already imported FootageItem ('item_id') as a new * layer to composition ('comp_id'). * * Args: * comp_id (int): id of target composition * item_id (int): FootageItem.id * found_comp (CompItem, optional): to limit quering if * comp already found previously */ var comp = found_comp || app.project.itemByID(comp_id); if (comp){ item = app.project.itemByID(item_id); if (item){ comp.layers.add(item); }else{ return _prepareError("There is no item with " + item_id); } }else{ return _prepareError("There is no composition with "+ comp_id); } } function importBackground(comp_id, composition_name, files_to_import){ /** * Imports backgrounds images to existing or new composition. * * If comp_id is not provided, new composition is created, basic * values (width, heights, frameRatio) takes from first imported * image. * * Args: * comp_id (int): id of existing composition (null if new) * composition_name (str): used when new composition * files_to_import (list): list of absolute paths to import and * add as layers * * Returns: * (str): json representation (id, name, members) */ var comp; var folder; var imported_ids = []; if (comp_id){ comp = app.project.itemByID(comp_id); folder = comp.parentFolder; }else{ if (app.project.selection.length > 1){ return _prepareError( "Too many items selected, select only target composition!"); }else{ selected_item = app.project.activeItem; if (selected_item instanceof Folder){ comp = selected_item; folder = selected_item; } } } if (files_to_import){ for (i = 0; i < files_to_import.length; ++i){ item = _importItem(files_to_import[i]); if (!item){ return _prepareError( "No item for " + item_json["id"] + ". Import background failed.") } if (!comp){ folder = app.project.items.addFolder(composition_name); imported_ids.push(folder.id); comp = app.project.items.addComp(composition_name, item.width, item.height, item.pixelAspect, 1, 26.7); // hardcode defaults imported_ids.push(comp.id); comp.parentFolder = folder; } imported_ids.push(item.id) item.parentFolder = folder; addItemAsLayerToComp(comp.id, item.id, comp); } } var item = {"name": comp.name, "id": folder.id, "members": imported_ids}; return JSON.stringify(item); } function reloadBackground(comp_id, composition_name, files_to_import){ /** * Reloads existing composition. * * It deletes complete composition with encompassing folder, recreates * from scratch via 'importBackground' functionality. * * Args: * comp_id (int): id of existing composition (null if new) * composition_name (str): used when new composition * files_to_import (list): list of absolute paths to import and * add as layers * * Returns: * (str): json representation (id, name, members) * */ var imported_ids = []; // keep track of members of composition comp = app.project.itemByID(comp_id); folder = comp.parentFolder; if (folder){ renameItem(folder.id, composition_name); imported_ids.push(folder.id); } if (comp){ renameItem(comp.id, composition_name); imported_ids.push(comp.id); } var existing_layer_names = []; var existing_layer_ids = []; // because ExtendedScript doesnt have keys() for (i = 1; i <= folder.items.length; ++i){ layer = folder.items[i]; //because comp.layers[i] doesnt have 'id' accessible if (layer instanceof CompItem){ continue; } existing_layer_names.push(layer.name); existing_layer_ids.push(layer.id); } var new_filenames = []; if (files_to_import){ for (i = 0; i < files_to_import.length; ++i){ file_name = _get_file_name(files_to_import[i]); new_filenames.push(file_name); idx = existing_layer_names.indexOf(file_name); if (idx >= 0){ // update var layer_id = existing_layer_ids[idx]; replaceItem(layer_id, files_to_import[i], file_name); imported_ids.push(layer_id); }else{ // new layer item = _importItem(files_to_import[i]); if (!item){ return _prepareError( "No item for " + files_to_import[i] + ". Reload background failed."); } imported_ids.push(item.id); item.parentFolder = folder; addItemAsLayerToComp(comp.id, item.id, comp); } } } _delete_obsolete_items(folder, new_filenames); var item = {"name": comp.name, "id": folder.id, "members": imported_ids}; return JSON.stringify(item); } function _get_file_name(file_url){ /** * Returns file name without extension from 'file_url' * * Args: * file_url (str): full absolute url * Returns: * (str) */ fp = new File(file_url); file_name = fp.name.substring(0, fp.name.lastIndexOf(".")); return file_name; } function _delete_obsolete_items(folder, new_filenames){ /*** * Goes through 'folder' and removes layers not in new * background * * Args: * folder (FolderItem) * new_filenames (array): list of layer names in new bg */ // remove items in old, but not in new delete_ids = [] for (i = 1; i <= folder.items.length; ++i){ layer = folder.items[i]; //because comp.layers[i] doesnt have 'id' accessible if (layer instanceof CompItem){ continue; } if (new_filenames.indexOf(layer.name) < 0){ delete_ids.push(layer.id); } } for (i = 0; i < delete_ids.length; ++i){ deleteItem(delete_ids[i]); } } function _importItem(file_url){ /** * Imports 'file_url' as new FootageItem * * Args: * file_url (str): file url with content * Returns: * (FootageItem) */ file_name = _get_file_name(file_url); //importFile prepared previously to return json item_json = importFile(file_url, file_name, JSON.stringify({"ImportAsType":"FOOTAGE"})); item_json = JSON.parse(item_json); item = app.project.itemByID(item_json["id"]); return item; } function isFileSequence (item){ /** * Check that item is a recognizable sequence */ if (item instanceof FootageItem && item.mainSource instanceof FileSource && !(item.mainSource.isStill) && item.hasVideo){ var extname = item.mainSource.file.fsName.split('.').pop(); return extname.match(new RegExp("(ai|bmp|bw|cin|cr2|crw|dcr|dng|dib|dpx|eps|erf|exr|gif|hdr|ico|icb|iff|jpe|jpeg|jpg|mos|mrw|nef|orf|pbm|pef|pct|pcx|pdf|pic|pict|png|ps|psd|pxr|raf|raw|rgb|rgbe|rla|rle|rpf|sgi|srf|tdi|tga|tif|tiff|vda|vst|x3f|xyze)", "i")) !== null; } return false; } function render(target_folder, comp_id){ var out_dir = new Folder(target_folder); var out_dir = out_dir.fsName; for (i = 1; i <= app.project.renderQueue.numItems; ++i){ var render_item = app.project.renderQueue.item(i); var composition = render_item.comp; if (composition.id == comp_id){ if (render_item.status == RQItemStatus.DONE){ var new_item = render_item.duplicate(); render_item.remove(); render_item = new_item; } render_item.render = true; var om1 = app.project.renderQueue.item(i).outputModule(1); var file_name = File.decode( om1.file.name ).replace('℗', ''); // Name contains special character, space? var omItem1_settable_str = app.project.renderQueue.item(i).outputModule(1).getSettings( GetSettingsFormat.STRING_SETTABLE ); var targetFolder = new Folder(target_folder); if (!targetFolder.exists) { targetFolder.create(); } om1.file = new File(targetFolder.fsName + '/' + file_name); }else{ if (render_item.status != RQItemStatus.DONE){ render_item.render = false; } } } app.beginSuppressDialogs(); app.project.renderQueue.render(); app.endSuppressDialogs(false); } function close(){ app.project.close(CloseOptions.DO_NOT_SAVE_CHANGES); app.quit(); } function getAppVersion(){ return _prepareSingleValue(app.version); } function printMsg(msg){ alert(msg); } function addPlaceholder(name, width, height, fps, duration){ /** Add AE PlaceholderItem to Project list. * * PlaceholderItem chosen as it doesn't require existing file and * might potentially allow nice functionality in the future. * */ app.beginUndoGroup('change comp properties'); try{ item = app.project.importPlaceholder(name, width, height, fps, duration); return _prepareSingleValue(item.id); }catch (error) { writeLn(_prepareError("Cannot add placeholder " + error.toString())); } app.endUndoGroup(); } function addItemInstead(placeholder_item_id, item_id){ /** Add new loaded item in place of load placeholder. * * Each placeholder could be placed multiple times into multiple * composition. This loops through all compositions and * places loaded item under placeholder. * Placeholder item gets deleted later separately according * to configuration in Settings. * * Args: * placeholder_item_id (int) * item_id (int) */ var item = app.project.itemByID(item_id); if (!item){ return _prepareError("There is no item with "+ item_id); } app.beginUndoGroup('Add loaded items'); for (i = 1; i <= app.project.items.length; ++i){ var comp = app.project.items[i]; if (!(comp instanceof CompItem)){ continue } var i = 1; while (i <= comp.numLayers) { var layer = comp.layer(i); var layer_source = layer.source; if (layer_source && layer_source.id == placeholder_item_id){ var new_layer = comp.layers.add(item); new_layer.moveAfter(layer); // copy all(?) properties to new layer layer.property("ADBE Transform Group").copyToComp(new_layer); i = i + 1; } i = i + 1; } } app.endUndoGroup(); } function _prepareSingleValue(value){ return JSON.stringify({"result": value}) } function _prepareError(error_msg){ return JSON.stringify({"error": error_msg}) } ================================================ FILE: openpype/hosts/aftereffects/api/launch_logic.py ================================================ import os import sys import subprocess import collections import logging import asyncio import functools import traceback from wsrpc_aiohttp import ( WebSocketRoute, WebSocketAsync ) from qtpy import QtCore from openpype.lib import Logger from openpype.tests.lib import is_in_tests from openpype.pipeline import install_host, legacy_io from openpype.modules import ModulesManager from openpype.tools.utils import host_tools, get_openpype_qt_app from openpype.tools.adobe_webserver.app import WebServerTool from .ws_stub import get_stub from .lib import set_settings log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) def safe_excepthook(*args): traceback.print_exception(*args) def main(*subprocess_args): """Main entrypoint to AE launching, called from pre hook.""" sys.excepthook = safe_excepthook from openpype.hosts.aftereffects.api import AfterEffectsHost host = AfterEffectsHost() install_host(host) os.environ["OPENPYPE_LOG_NO_COLORS"] = "False" app = get_openpype_qt_app() app.setQuitOnLastWindowClosed(False) launcher = ProcessLauncher(subprocess_args) launcher.start() if os.environ.get("HEADLESS_PUBLISH"): manager = ModulesManager() webpublisher_addon = manager["webpublisher"] launcher.execute_in_main_thread( functools.partial( webpublisher_addon.headless_publish, log, "CloseAE", is_in_tests() ) ) elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True): save = False if os.getenv("WORKFILES_SAVE_AS"): save = True launcher.execute_in_main_thread( lambda: host_tools.show_tool_by_name("workfiles", save=save) ) sys.exit(app.exec_()) def show_tool_by_name(tool_name): kwargs = {} if tool_name == "loader": kwargs["use_context"] = True host_tools.show_tool_by_name(tool_name, **kwargs) class ProcessLauncher(QtCore.QObject): """Launches webserver, connects to it, runs main thread.""" route_name = "AfterEffects" _main_thread_callbacks = collections.deque() def __init__(self, subprocess_args): self._subprocess_args = subprocess_args self._log = None super(ProcessLauncher, self).__init__() # Keep track if launcher was alreadu started self._started = False self._process = None self._websocket_server = None start_process_timer = QtCore.QTimer() start_process_timer.setInterval(100) loop_timer = QtCore.QTimer() loop_timer.setInterval(200) start_process_timer.timeout.connect(self._on_start_process_timer) loop_timer.timeout.connect(self._on_loop_timer) self._start_process_timer = start_process_timer self._loop_timer = loop_timer @property def log(self): if self._log is None: self._log = Logger.get_logger("{}-launcher".format( self.route_name)) return self._log @property def websocket_server_is_running(self): if self._websocket_server is not None: return self._websocket_server.is_running return False @property def is_process_running(self): if self._process is not None: return self._process.poll() is None return False @property def is_host_connected(self): """Returns True if connected, False if app is not running at all.""" if not self.is_process_running: return False try: _stub = get_stub() if _stub: return True except Exception: pass return None @classmethod def execute_in_main_thread(cls, callback): cls._main_thread_callbacks.append(callback) def start(self): if self._started: return self.log.info("Started launch logic of AfterEffects") self._started = True self._start_process_timer.start() def exit(self): """ Exit whole application. """ if self._start_process_timer.isActive(): self._start_process_timer.stop() if self._loop_timer.isActive(): self._loop_timer.stop() if self._websocket_server is not None: self._websocket_server.stop() if self._process: self._process.kill() self._process.wait() QtCore.QCoreApplication.exit() def _on_loop_timer(self): # TODO find better way and catch errors # Run only callbacks that are in queue at the moment cls = self.__class__ for _ in range(len(cls._main_thread_callbacks)): if cls._main_thread_callbacks: callback = cls._main_thread_callbacks.popleft() callback() if not self.is_process_running: self.log.info("Host process is not running. Closing") self.exit() elif not self.websocket_server_is_running: self.log.info("Websocket server is not running. Closing") self.exit() def _on_start_process_timer(self): # TODO add try except validations for each part in this method # Start server as first thing if self._websocket_server is None: self._init_server() return # TODO add waiting time # Wait for webserver if not self.websocket_server_is_running: return # Start application process if self._process is None: self._start_process() self.log.info("Waiting for host to connect") return # TODO add waiting time # Wait until host is connected if self.is_host_connected: self._start_process_timer.stop() self._loop_timer.start() elif ( not self.is_process_running or not self.websocket_server_is_running ): self.exit() def _init_server(self): if self._websocket_server is not None: return self.log.debug( "Initialization of websocket server for host communication" ) self._websocket_server = websocket_server = WebServerTool() if websocket_server.port_occupied( websocket_server.host_name, websocket_server.port ): self.log.info( "Server already running, sending actual context and exit." ) asyncio.run(websocket_server.send_context_change(self.route_name)) self.exit() return # Add Websocket route websocket_server.add_route("*", "/ws/", WebSocketAsync) # Add after effects route to websocket handler print("Adding {} route".format(self.route_name)) WebSocketAsync.add_route( self.route_name, AfterEffectsRoute ) self.log.info("Starting websocket server for host communication") websocket_server.start_server() def _start_process(self): if self._process is not None: return self.log.info("Starting host process") try: self._process = subprocess.Popen( self._subprocess_args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) except Exception: self.log.info("exce", exc_info=True) self.exit() class AfterEffectsRoute(WebSocketRoute): """ One route, mimicking external application (like Harmony, etc). All functions could be called from client. 'do_notify' function calls function on the client - mimicking notification after long running job on the server or similar """ instance = None def init(self, **kwargs): # Python __init__ must be return "self". # This method might return anything. log.debug("someone called AfterEffects route") self.instance = self return kwargs # server functions async def ping(self): log.debug("someone called AfterEffects route ping") # This method calls function on the client side # client functions async def set_context(self, project, asset, task): """ Sets 'project' and 'asset' to envs, eg. setting context Args: project (str) asset (str) """ log.info("Setting context change") log.info("project {} asset {} ".format(project, asset)) if project: legacy_io.Session["AVALON_PROJECT"] = project os.environ["AVALON_PROJECT"] = project if asset: legacy_io.Session["AVALON_ASSET"] = asset os.environ["AVALON_ASSET"] = asset if task: legacy_io.Session["AVALON_TASK"] = task os.environ["AVALON_TASK"] = task async def read(self): log.debug("aftereffects.read client calls server server calls " "aftereffects client") return await self.socket.call('aftereffects.read') # panel routes for tools async def workfiles_route(self): self._tool_route("workfiles") async def loader_route(self): self._tool_route("loader") async def publish_route(self): self._tool_route("publisher") async def sceneinventory_route(self): self._tool_route("sceneinventory") async def setresolution_route(self): self._settings_route(False, True) async def setframes_route(self): self._settings_route(True, False) async def setall_route(self): self._settings_route(True, True) async def experimental_tools_route(self): self._tool_route("experimental_tools") def _tool_route(self, _tool_name): """The address accessed when clicking on the buttons.""" partial_method = functools.partial(show_tool_by_name, _tool_name) ProcessLauncher.execute_in_main_thread(partial_method) # Required return statement. return "nothing" def _settings_route(self, frames, resolution): partial_method = functools.partial(set_settings, frames, resolution) ProcessLauncher.execute_in_main_thread(partial_method) # Required return statement. return "nothing" def create_placeholder_route(self): from openpype.hosts.aftereffects.api.workfile_template_builder import \ create_placeholder partial_method = functools.partial(create_placeholder) ProcessLauncher.execute_in_main_thread(partial_method) # Required return statement. return "nothing" def update_placeholder_route(self): from openpype.hosts.aftereffects.api.workfile_template_builder import \ update_placeholder partial_method = functools.partial(update_placeholder) ProcessLauncher.execute_in_main_thread(partial_method) # Required return statement. return "nothing" def build_workfile_template_route(self): from openpype.hosts.aftereffects.api.workfile_template_builder import \ build_workfile_template partial_method = functools.partial(build_workfile_template) ProcessLauncher.execute_in_main_thread(partial_method) # Required return statement. return "nothing" ================================================ FILE: openpype/hosts/aftereffects/api/lib.py ================================================ import os import re import json import contextlib import logging from openpype.pipeline.context_tools import get_current_context from openpype.client import get_asset_by_name from .ws_stub import get_stub log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) @contextlib.contextmanager def maintained_selection(): """Maintain selection during context.""" selection = get_stub().get_selected_items(True, False, False) try: yield selection finally: pass def get_extension_manifest_path(): return os.path.join( os.path.dirname(os.path.abspath(__file__)), "extension", "CSXS", "manifest.xml" ) def get_unique_layer_name(layers, name): """ Gets all layer names and if 'name' is present in them, increases suffix by 1 (eg. creates unique layer name - for Loader) Args: layers (list): of strings, names only name (string): checked value Returns: (string): name_00X (without version) """ names = {} for layer in layers: layer_name = re.sub(r'_\d{3}$', '', layer) if layer_name in names.keys(): names[layer_name] = names[layer_name] + 1 else: names[layer_name] = 1 occurrences = names.get(name, 0) return "{}_{:0>3d}".format(name, occurrences + 1) def get_background_layers(file_url): """ Pulls file name from background json file, enrich with folder url for AE to be able import files. Order is important, follows order in json. Args: file_url (str): abs url of background json Returns: (list): of abs paths to images """ with open(file_url) as json_file: data = json.load(json_file) layers = list() bg_folder = os.path.dirname(file_url) for child in data['children']: if child.get("filename"): layers.append(os.path.join(bg_folder, child.get("filename")). replace("\\", "/")) else: for layer in child['children']: if layer.get("filename"): layers.append(os.path.join(bg_folder, layer.get("filename")). replace("\\", "/")) return layers def get_asset_settings(asset_doc): """Get settings on current asset from database. Returns: dict: Scene data. """ asset_data = asset_doc["data"] fps = asset_data.get("fps", 0) frame_start = asset_data.get("frameStart", 0) frame_end = asset_data.get("frameEnd", 0) handle_start = asset_data.get("handleStart", 0) handle_end = asset_data.get("handleEnd", 0) resolution_width = asset_data.get("resolutionWidth", 0) resolution_height = asset_data.get("resolutionHeight", 0) duration = (frame_end - frame_start + 1) + handle_start + handle_end return { "fps": fps, "frameStart": frame_start, "frameEnd": frame_end, "handleStart": handle_start, "handleEnd": handle_end, "resolutionWidth": resolution_width, "resolutionHeight": resolution_height, "duration": duration } def set_settings(frames, resolution, comp_ids=None, print_msg=True): """Sets number of frames and resolution to selected comps. Args: frames (bool): True if set frame info resolution (bool): True if set resolution comp_ids (list): specific composition ids, if empty it tries to look for currently selected print_msg (bool): True throw JS alert with msg """ frame_start = frames_duration = fps = width = height = None current_context = get_current_context() asset_doc = get_asset_by_name(current_context["project_name"], current_context["asset_name"]) settings = get_asset_settings(asset_doc) msg = '' if frames: frame_start = settings["frameStart"] - settings["handleStart"] frames_duration = settings["duration"] fps = settings["fps"] msg += f"frame start:{frame_start}, duration:{frames_duration}, "\ f"fps:{fps}" if resolution: width = settings["resolutionWidth"] height = settings["resolutionHeight"] msg += f"width:{width} and height:{height}" stub = get_stub() if not comp_ids: comps = stub.get_selected_items(True, False, False) comp_ids = [comp.id for comp in comps] if not comp_ids: stub.print_msg("Select at least one composition to apply settings.") return for comp_id in comp_ids: msg = f"Setting for comp {comp_id} " + msg log.debug(msg) stub.set_comp_properties(comp_id, frame_start, frames_duration, fps, width, height) if print_msg: stub.print_msg(msg) ================================================ FILE: openpype/hosts/aftereffects/api/pipeline.py ================================================ import os from qtpy import QtWidgets import pyblish.api from openpype.lib import Logger, register_event_callback from openpype.pipeline import ( register_loader_plugin_path, register_creator_plugin_path, AVALON_CONTAINER_ID, ) from openpype.hosts.aftereffects.api.workfile_template_builder import ( AEPlaceholderLoadPlugin, AEPlaceholderCreatePlugin ) from openpype.pipeline.load import any_outdated_containers import openpype.hosts.aftereffects from openpype.host import ( HostBase, IWorkfileHost, ILoadHost, IPublishHost ) from openpype.tools.utils import get_openpype_qt_app from .launch_logic import get_stub from .ws_stub import ConnectionNotEstablishedYet log = Logger.get_logger(__name__) HOST_DIR = os.path.dirname( os.path.abspath(openpype.hosts.aftereffects.__file__) ) PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") class AfterEffectsHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): name = "aftereffects" def __init__(self): self._stub = None super(AfterEffectsHost, self).__init__() @property def stub(self): """ Handle pulling stub from PS to run operations on host Returns: (AEServerStub) or None """ if self._stub: return self._stub try: stub = get_stub() # only after Photoshop is up except ConnectionNotEstablishedYet: print("Not connected yet, ignoring") return self._stub = stub return self._stub def install(self): print("Installing Pype config...") pyblish.api.register_host("aftereffects") pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) register_event_callback("application.launched", application_launch) def get_workfile_extensions(self): return [".aep"] def save_workfile(self, dst_path=None): self.stub.saveAs(dst_path, True) def open_workfile(self, filepath): self.stub.open(filepath) return True def get_current_workfile(self): try: full_name = get_stub().get_active_document_full_name() if full_name and full_name != "null": return os.path.normpath(full_name).replace("\\", "/") except ValueError: print("Nothing opened") pass return None def get_containers(self): return ls() def get_context_data(self): meta = self.stub.get_metadata() for item in meta: if item.get("id") == "publish_context": item.pop("id") return item return {} def update_context_data(self, data, changes): item = data item["id"] = "publish_context" self.stub.imprint(item["id"], item) def get_workfile_build_placeholder_plugins(self): return [ AEPlaceholderLoadPlugin, AEPlaceholderCreatePlugin ] # created instances section def list_instances(self): """List all created instances from current workfile which will be published. Pulls from File > File Info For SubsetManager Returns: (list) of dictionaries matching instances format """ stub = self.stub if not stub: return [] instances = [] layers_meta = stub.get_metadata() for instance in layers_meta: if instance.get("id") == "pyblish.avalon.instance": instances.append(instance) return instances def remove_instance(self, instance): """Remove instance from current workfile metadata. Updates metadata of current file in File > File Info and removes icon highlight on group layer. For SubsetManager Args: instance (dict): instance representation from subsetmanager model """ stub = self.stub if not stub: return inst_id = instance.get("instance_id") or instance.get("uuid") # legacy if not inst_id: log.warning("No instance identifier for {}".format(instance)) return stub.remove_instance(inst_id) if instance.get("members"): item = stub.get_item(instance["members"][0]) if item: stub.rename_item(item.id, item.name.replace(stub.PUBLISH_ICON, '')) def application_launch(): """Triggered after start of app""" check_inventory() def ls(): """Yields containers from active AfterEffects document. This is the host-equivalent of api.ls(), but instead of listing assets on disk, it lists assets already loaded in AE; once loaded they are called 'containers'. Used in Manage tool. Containers could be on multiple levels, single images/videos/was as a FootageItem, or multiple items - backgrounds (folder with automatically created composition and all imported layers). Yields: dict: container """ try: stub = get_stub() # only after AfterEffects is up except ConnectionNotEstablishedYet: print("Not connected yet, ignoring") return layers_meta = stub.get_metadata() for item in stub.get_items(comps=True, folders=True, footages=True): data = stub.read(item, layers_meta) # Skip non-tagged layers. if not data: continue # Filter to only containers. if "container" not in data["id"]: continue # Append transient data data["objectName"] = item.name.replace(stub.LOADED_ICON, '') data["layer"] = item yield data def check_inventory(): """Checks loaded containers if they are of highest version""" if not any_outdated_containers(): return # Warn about outdated containers. _app = get_openpype_qt_app() message_box = QtWidgets.QMessageBox() message_box.setIcon(QtWidgets.QMessageBox.Warning) msg = "There are outdated containers in the scene." message_box.setText(msg) message_box.exec_() def containerise(name, namespace, comp, context, loader=None, suffix="_CON"): """ Containerisation enables a tracking of version, author and origin for loaded assets. Creates dictionary payloads that gets saved into file metadata. Each container contains of who loaded (loader) and members (single or multiple in case of background). Arguments: name (str): Name of resulting assembly namespace (str): Namespace under which to host container comp (AEItem): Composition to containerise context (dict): Asset information loader (str, optional): Name of loader used to produce this container. suffix (str, optional): Suffix of container, defaults to `_CON`. Returns: container (str): Name of container assembly """ data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace, "loader": str(loader), "representation": str(context["representation"]["_id"]), "members": comp.members or [comp.id] } stub = get_stub() stub.imprint(comp.id, data) return comp def cache_and_get_instances(creator): """Cache instances in shared data. Storing all instances as a list as legacy instances might be still present. Args: creator (Creator): Plugin which would like to get instances from host. Returns: List[]: list of all instances stored in metadata """ shared_key = "openpype.photoshop.instances" if shared_key not in creator.collection_shared_data: creator.collection_shared_data[shared_key] = \ creator.host.list_instances() return creator.collection_shared_data[shared_key] ================================================ FILE: openpype/hosts/aftereffects/api/plugin.py ================================================ import six from abc import ABCMeta from openpype.pipeline import LoaderPlugin from .launch_logic import get_stub @six.add_metaclass(ABCMeta) class AfterEffectsLoader(LoaderPlugin): @staticmethod def get_stub(): return get_stub() ================================================ FILE: openpype/hosts/aftereffects/api/workfile_template_builder.py ================================================ import os.path import uuid import shutil from openpype.pipeline import registered_host from openpype.tools.workfile_template_build import ( WorkfileBuildPlaceholderDialog, ) from openpype.pipeline.workfile.workfile_template_builder import ( AbstractTemplateBuilder, PlaceholderPlugin, LoadPlaceholderItem, CreatePlaceholderItem, PlaceholderLoadMixin, PlaceholderCreateMixin ) from openpype.hosts.aftereffects.api import get_stub from openpype.hosts.aftereffects.api.lib import set_settings PLACEHOLDER_SET = "PLACEHOLDERS_SET" PLACEHOLDER_ID = "openpype.placeholder" class AETemplateBuilder(AbstractTemplateBuilder): """Concrete implementation of AbstractTemplateBuilder for AE""" def import_template(self, path): """Import template into current scene. Block if a template is already loaded. Args: path (str): A path to current template (usually given by get_template_preset implementation) Returns: bool: Whether the template was successfully imported or not """ stub = get_stub() if not os.path.exists(path): stub.print_msg(f"Template file on {path} doesn't exist.") return stub.save() workfile_path = stub.get_active_document_full_name() shutil.copy2(path, workfile_path) stub.open(workfile_path) return True class AEPlaceholderPlugin(PlaceholderPlugin): """Contains generic methods for all PlaceholderPlugins.""" def collect_placeholders(self): """Collect info from file metadata about created placeholders. Returns: (list) (LoadPlaceholderItem) """ output = [] scene_placeholders = self._collect_scene_placeholders() for item in scene_placeholders: if item.get("plugin_identifier") != self.identifier: continue if isinstance(self, AEPlaceholderLoadPlugin): item = LoadPlaceholderItem(item["uuid"], item["data"], self) elif isinstance(self, AEPlaceholderCreatePlugin): item = CreatePlaceholderItem(item["uuid"], item["data"], self) else: raise NotImplementedError(f"Not implemented for {type(self)}") output.append(item) return output def update_placeholder(self, placeholder_item, placeholder_data): """Resave changed properties for placeholders""" item_id, metadata_item = self._get_item(placeholder_item) stub = get_stub() if not item_id: stub.print_msg("Cannot find item for " f"{placeholder_item.scene_identifier}") return metadata_item["data"] = placeholder_data stub.imprint(item_id, metadata_item) def _get_item(self, placeholder_item): """Returns item id and item metadata for placeholder from file meta""" stub = get_stub() placeholder_uuid = placeholder_item.scene_identifier for metadata_item in stub.get_metadata(): if not metadata_item.get("is_placeholder"): continue if placeholder_uuid in metadata_item.get("uuid"): return metadata_item["members"][0], metadata_item return None, None def _collect_scene_placeholders(self): """" Cache placeholder data to shared data. Returns: (list) of dicts """ placeholder_items = self.builder.get_shared_populate_data( "placeholder_items" ) if not placeholder_items: placeholder_items = [] for item in get_stub().get_metadata(): if not item.get("is_placeholder"): continue placeholder_items.append(item) self.builder.set_shared_populate_data( "placeholder_items", placeholder_items ) return placeholder_items def _imprint_item(self, item_id, name, placeholder_data, stub): if not item_id: raise ValueError("Couldn't create a placeholder") container_data = { "id": "openpype.placeholder", "name": name, "is_placeholder": True, "plugin_identifier": self.identifier, "uuid": str(uuid.uuid4()), # scene_identifier "data": placeholder_data, "members": [item_id] } stub.imprint(item_id, container_data) class AEPlaceholderCreatePlugin(AEPlaceholderPlugin, PlaceholderCreateMixin): """Adds Create placeholder. This adds composition and runs Create """ identifier = "aftereffects.create" label = "AfterEffects create" def create_placeholder(self, placeholder_data): stub = get_stub() name = "CREATEPLACEHOLDER" item_id = stub.add_item(name, "COMP") self._imprint_item(item_id, name, placeholder_data, stub) def populate_placeholder(self, placeholder): """Replace 'placeholder' with publishable instance. Renames prepared composition name, creates publishable instance, sets frame/duration settings according to DB. """ pre_create_data = {"use_selection": True} item_id, item = self._get_item(placeholder) get_stub().select_items([item_id]) self.populate_create_placeholder(placeholder, pre_create_data) # apply settings for populated composition item_id, metadata_item = self._get_item(placeholder) set_settings(True, True, [item_id]) def get_placeholder_options(self, options=None): return self.get_create_plugin_options(options) class AEPlaceholderLoadPlugin(AEPlaceholderPlugin, PlaceholderLoadMixin): identifier = "aftereffects.load" label = "AfterEffects load" def create_placeholder(self, placeholder_data): """Creates AE's Placeholder item in Project items list. Sets dummy resolution/duration/fps settings, will be replaced when populated. """ stub = get_stub() name = "LOADERPLACEHOLDER" item_id = stub.add_placeholder(name, 1920, 1060, 25, 10) self._imprint_item(item_id, name, placeholder_data, stub) def populate_placeholder(self, placeholder): """Use Openpype Loader from `placeholder` to create new FootageItems New FootageItems are created, files are imported. """ self.populate_load_placeholder(placeholder) errors = placeholder.get_errors() stub = get_stub() if errors: stub.print_msg("\n".join(errors)) else: if not placeholder.data["keep_placeholder"]: metadata = stub.get_metadata() for item in metadata: if not item.get("is_placeholder"): continue scene_identifier = item.get("uuid") if (scene_identifier and scene_identifier == placeholder.scene_identifier): stub.delete_item(item["members"][0]) stub.remove_instance(placeholder.scene_identifier, metadata) def get_placeholder_options(self, options=None): return self.get_load_plugin_options(options) def load_succeed(self, placeholder, container): placeholder_item_id, _ = self._get_item(placeholder) item_id = container.id get_stub().add_item_instead_placeholder(placeholder_item_id, item_id) def build_workfile_template(*args, **kwargs): builder = AETemplateBuilder(registered_host()) builder.build_template(*args, **kwargs) def update_workfile_template(*args): builder = AETemplateBuilder(registered_host()) builder.rebuild_template() def create_placeholder(*args): """Called when new workile placeholder should be created.""" host = registered_host() builder = AETemplateBuilder(host) window = WorkfileBuildPlaceholderDialog(host, builder) window.exec_() def update_placeholder(*args): """Called after placeholder item is selected to modify it.""" host = registered_host() builder = AETemplateBuilder(host) stub = get_stub() selected_items = stub.get_selected_items(True, True, True) if len(selected_items) != 1: stub.print_msg("Please select just 1 placeholder") return selected_id = selected_items[0].id placeholder_item = None placeholder_items_by_id = { placeholder_item.scene_identifier: placeholder_item for placeholder_item in builder.get_placeholders() } for metadata_item in stub.get_metadata(): if not metadata_item.get("is_placeholder"): continue if selected_id in metadata_item.get("members"): placeholder_item = placeholder_items_by_id.get( metadata_item["uuid"]) break if not placeholder_item: stub.print_msg("Didn't find placeholder metadata. " "Remove and re-create placeholder.") return window = WorkfileBuildPlaceholderDialog(host, builder) window.set_update_mode(placeholder_item) window.exec_() ================================================ FILE: openpype/hosts/aftereffects/api/ws_stub.py ================================================ """ Stub handling connection from server to client. Used anywhere solution is calling client methods. """ import json import logging import attr from wsrpc_aiohttp import WebSocketAsync from openpype.tools.adobe_webserver.app import WebServerTool class ConnectionNotEstablishedYet(Exception): pass @attr.s class AEItem(object): """ Object denoting Item in AE. Each item is created in AE by any Loader, but contains same fields, which are being used in later processing. """ # metadata id = attr.ib() # id created by AE, could be used for querying name = attr.ib() # name of item item_type = attr.ib(default=None) # item type (footage, folder, comp) # all imported elements, single for # regular image, array for Backgrounds members = attr.ib(factory=list) frameStart = attr.ib(default=None) framesDuration = attr.ib(default=None) frameRate = attr.ib(default=None) file_name = attr.ib(default=None) instance_id = attr.ib(default=None) # New Publisher width = attr.ib(default=None) height = attr.ib(default=None) is_placeholder = attr.ib(default=False) uuid = attr.ib(default=False) path = attr.ib(default=False) # path to FootageItem to validate # list of composition Footage is in containing_comps = attr.ib(factory=list) class AfterEffectsServerStub(): """ Stub for calling function on client (Photoshop js) side. Expects that client is already connected (started when avalon menu is opened). 'self.websocketserver.call' is used as async wrapper """ PUBLISH_ICON = '\u2117 ' LOADED_ICON = '\u25bc' def __init__(self): self.websocketserver = WebServerTool.get_instance() self.client = self.get_client() self.log = logging.getLogger(self.__class__.__name__) @staticmethod def get_client(): """ Return first connected client to WebSocket TODO implement selection by Route :return: client """ clients = WebSocketAsync.get_clients() client = None if len(clients) > 0: key = list(clients.keys())[0] client = clients.get(key) return client def open(self, path): """ Open file located at 'path' (local). Args: path(string): file path locally Returns: None """ res = self.websocketserver.call(self.client.call ('AfterEffects.open', path=path)) return self._handle_return(res) def get_metadata(self): """ Get complete stored JSON with metadata from AE.Metadata.Label field. It contains containers loaded by any Loader OR instances created by Creator. Returns: (list) """ res = self.websocketserver.call(self.client.call ('AfterEffects.get_metadata')) metadata = self._handle_return(res) return metadata or [] def read(self, item, layers_meta=None): """ Parses item metadata from Label field of active document. Used as filter to pick metadata for specific 'item' only. Args: item (AEItem): pulled info from AE layers_meta (dict): full list from Headline (load and inject for better performance in loops) Returns: (dict): """ if layers_meta is None: layers_meta = self.get_metadata() for item_meta in layers_meta: if 'container' in item_meta.get('id') and \ str(item.id) == str(item_meta.get('members')[0]): return item_meta self.log.debug("Couldn't find layer metadata") def imprint(self, item_id, data, all_items=None, items_meta=None): """ Save item metadata to Label field of metadata of active document Args: item_id (int|str): id of FootageItem or instance_id for workfiles data(string): json representation for single layer all_items (list of item): for performance, could be injected for usage in loop, if not, single call will be triggered items_meta(string): json representation from Headline (for performance - provide only if imprint is in loop - value should be same) Returns: None """ if not items_meta: items_meta = self.get_metadata() result_meta = [] # fix existing is_new = True for item_meta in items_meta: if ((item_meta.get('members') and str(item_id) == str(item_meta.get('members')[0])) or item_meta.get("instance_id") == item_id): is_new = False if data: item_meta.update(data) result_meta.append(item_meta) else: result_meta.append(item_meta) if is_new: result_meta.append(data) # Ensure only valid ids are stored. if not all_items: # loaders create FootageItem now all_items = self.get_items(comps=True, folders=True, footages=True) item_ids = [int(item.id) for item in all_items] cleaned_data = [] for meta in result_meta: # do not added instance with nonexistend item id if meta.get("members"): if int(meta["members"][0]) not in item_ids: continue cleaned_data.append(meta) payload = json.dumps(cleaned_data, indent=4) res = self.websocketserver.call(self.client.call ('AfterEffects.imprint', payload=payload)) return self._handle_return(res) def get_active_document_full_name(self): """ Returns absolute path of active document via ws call Returns(string): file name """ res = self.websocketserver.call(self.client.call( 'AfterEffects.get_active_document_full_name')) return self._handle_return(res) def get_active_document_name(self): """ Returns just a name of active document via ws call Returns(string): file name """ res = self.websocketserver.call(self.client.call( 'AfterEffects.get_active_document_name')) return self._handle_return(res) def get_items(self, comps, folders=False, footages=False): """ Get all items from Project panel according to arguments. There are multiple different types: CompItem (could have multiple layers - source for Creator, will be rendered) FolderItem (collection type, currently used for Background loading) FootageItem (imported file - created by Loader) Args: comps (bool): return CompItems folders (bool): return FolderItem footages (bool: return FootageItem Returns: (list) of namedtuples """ res = self.websocketserver.call( self.client.call('AfterEffects.get_items', comps=comps, folders=folders, footages=footages) ) return self._to_records(self._handle_return(res)) def select_items(self, items): """ Select items in Project list Args: items (list): of int item ids """ self.websocketserver.call( self.client.call('AfterEffects.select_items', items=items)) def get_selected_items(self, comps, folders=False, footages=False): """ Same as get_items but using selected items only Args: comps (bool): return CompItems folders (bool): return FolderItem footages (bool: return FootageItem Returns: (list) of namedtuples """ res = self.websocketserver.call(self.client.call ('AfterEffects.get_selected_items', comps=comps, folders=folders, footages=footages) ) return self._to_records(self._handle_return(res)) def add_item(self, name, item_type): """ Adds either composition or folder to project item list. Args: name (str) item_type (str): COMP|FOLDER """ res = self.websocketserver.call(self.client.call ('AfterEffects.add_item', name=name, item_type=item_type)) return self._handle_return(res) def get_item(self, item_id): """ Returns metadata for particular 'item_id' or None Args: item_id (int, or string) """ for item in self.get_items(True, True, True): if str(item.id) == str(item_id): return item return None def import_file(self, path, item_name, import_options=None): """ Imports file as a FootageItem. Used in Loader Args: path (string): absolute path for asset file item_name (string): label for created FootageItem import_options (dict): different files (img vs psd) need different config """ res = self.websocketserver.call( self.client.call('AfterEffects.import_file', path=path, item_name=item_name, import_options=import_options) ) records = self._to_records(self._handle_return(res)) if records: return records.pop() def replace_item(self, item_id, path, item_name): """ Replace FootageItem with new file Args: item_id (int): path (string):absolute path item_name (string): label on item in Project list """ res = self.websocketserver.call(self.client.call ('AfterEffects.replace_item', item_id=item_id, path=path, item_name=item_name)) return self._handle_return(res) def rename_item(self, item_id, item_name): """ Replace item with item_name Args: item_id (int): item_name (string): label on item in Project list """ res = self.websocketserver.call(self.client.call ('AfterEffects.rename_item', item_id=item_id, item_name=item_name)) return self._handle_return(res) def delete_item(self, item_id): """ Deletes *Item in a file Args: item_id (int): """ res = self.websocketserver.call(self.client.call ('AfterEffects.delete_item', item_id=item_id)) return self._handle_return(res) def remove_instance(self, instance_id, metadata=None): """ Removes instance with 'instance_id' from file's metadata and saves them. Keep matching item in file though. Args: instance_id(string): instance id """ cleaned_data = [] if metadata is None: metadata = self.get_metadata() for instance in metadata: inst_id = instance.get("instance_id") or instance.get("uuid") if inst_id != instance_id: cleaned_data.append(instance) payload = json.dumps(cleaned_data, indent=4) res = self.websocketserver.call(self.client.call ('AfterEffects.imprint', payload=payload)) return self._handle_return(res) def is_saved(self): # TODO return True def set_label_color(self, item_id, color_idx): """ Used for highlight additional information in Project panel. Green color is loaded asset, blue is created asset Args: item_id (int): color_idx (int): 0-16 Label colors from AE Project view """ res = self.websocketserver.call(self.client.call ('AfterEffects.set_label_color', item_id=item_id, color_idx=color_idx)) return self._handle_return(res) def get_comp_properties(self, comp_id): """ Get composition information for render purposes Returns startFrame, frameDuration, fps, width, height. Args: comp_id (int): Returns: (AEItem) """ res = self.websocketserver.call(self.client.call ('AfterEffects.get_comp_properties', item_id=comp_id )) records = self._to_records(self._handle_return(res)) if records: return records.pop() def set_comp_properties(self, comp_id, start, duration, frame_rate, width, height): """ Set work area to predefined values (from Ftrack). Work area directs what gets rendered. Beware of rounding, AE expects seconds, not frames directly. Args: comp_id (int): start (int): workAreaStart in frames duration (int): in frames frame_rate (float): frames in seconds width (int): resolution width height (int): resolution height """ res = self.websocketserver.call(self.client.call ('AfterEffects.set_comp_properties', item_id=comp_id, start=start, duration=duration, frame_rate=frame_rate, width=width, height=height)) return self._handle_return(res) def save(self): """ Saves active document Returns: None """ res = self.websocketserver.call(self.client.call ('AfterEffects.save')) return self._handle_return(res) def saveAs(self, project_path, as_copy): """ Saves active project to aep (copy) or png or jpg Args: project_path(string): full local path as_copy: Returns: None """ res = self.websocketserver.call(self.client.call ('AfterEffects.saveAs', image_path=project_path, as_copy=as_copy)) return self._handle_return(res) def get_render_info(self, comp_id): """ Get render queue info for render purposes Returns: (list) of (AEItem): with 'file_name' field """ res = self.websocketserver.call(self.client.call ('AfterEffects.get_render_info', comp_id=comp_id)) records = self._to_records(self._handle_return(res)) return records def get_audio_url(self, item_id): """ Get audio layer absolute url for comp Args: item_id (int): composition id Returns: (str): absolute path url """ res = self.websocketserver.call(self.client.call ('AfterEffects.get_audio_url', item_id=item_id)) return self._handle_return(res) def import_background(self, comp_id, comp_name, files): """ Imports backgrounds images to existing or new composition. If comp_id is not provided, new composition is created, basic values (width, heights, frameRatio) takes from first imported image. All images from background json are imported as a FootageItem and separate layer is created for each of them under composition. Order of imported 'files' is important. Args: comp_id (int): id of existing composition (null if new) comp_name (str): used when new composition files (list): list of absolute paths to import and add as layers Returns: (AEItem): object with id of created folder, all imported images """ res = self.websocketserver.call(self.client.call ('AfterEffects.import_background', comp_id=comp_id, comp_name=comp_name, files=files)) records = self._to_records(self._handle_return(res)) if records: return records.pop() def reload_background(self, comp_id, comp_name, files): """ Reloads backgrounds images to existing composition. It actually deletes complete folder with imported images and created composition for safety. Args: comp_id (int): id of existing composition to be overwritten comp_name (str): new name of composition (could be same as old if version up only) files (list): list of absolute paths to import and add as layers Returns: (AEItem): object with id of created folder, all imported images """ res = self.websocketserver.call(self.client.call ('AfterEffects.reload_background', comp_id=comp_id, comp_name=comp_name, files=files)) records = self._to_records(self._handle_return(res)) if records: return records.pop() def add_item_as_layer(self, comp_id, item_id): """ Adds already imported FootageItem ('item_id') as a new layer to composition ('comp_id'). Args: comp_id (int): id of target composition item_id (int): FootageItem.id comp already found previously """ res = self.websocketserver.call(self.client.call ('AfterEffects.add_item_as_layer', comp_id=comp_id, item_id=item_id)) records = self._to_records(self._handle_return(res)) if records: return records.pop() def add_item_instead_placeholder(self, placeholder_item_id, item_id): """ Adds item_id to layers where plaeholder_item_id is present. 1 placeholder could result in multiple loaded containers (eg items) Args: placeholder_item_id (int): id of placeholder item item_id (int): loaded FootageItem id """ res = self.websocketserver.call(self.client.call ('AfterEffects.add_item_instead_placeholder', # noqa placeholder_item_id=placeholder_item_id, # noqa item_id=item_id)) return self._handle_return(res) def add_placeholder(self, name, width, height, fps, duration): """ Adds new FootageItem as a placeholder for workfile builder Placeholder requires width etc, currently probably only hardcoded values. Args: name (str) width (int) height (int) fps (float) duration (int) """ res = self.websocketserver.call(self.client.call ('AfterEffects.add_placeholder', name=name, width=width, height=height, fps=fps, duration=duration)) return self._handle_return(res) def render(self, folder_url, comp_id): """ Render all renderqueueitem to 'folder_url' Args: folder_url(string): local folder path for collecting Returns: None """ res = self.websocketserver.call(self.client.call ('AfterEffects.render', folder_url=folder_url, comp_id=comp_id)) return self._handle_return(res) def get_extension_version(self): """Returns version number of installed extension.""" res = self.websocketserver.call(self.client.call( 'AfterEffects.get_extension_version')) return self._handle_return(res) def get_app_version(self): """Returns version number of installed application (17.5...).""" res = self.websocketserver.call(self.client.call( 'AfterEffects.get_app_version')) return self._handle_return(res) def close(self): res = self.websocketserver.call(self.client.call('AfterEffects.close')) return self._handle_return(res) def print_msg(self, msg): """Triggers Javascript alert dialog.""" self.websocketserver.call(self.client.call ('AfterEffects.print_msg', msg=msg)) def _handle_return(self, res): """Wraps return, throws ValueError if 'error' key is present.""" if res and isinstance(res, str) and res != "undefined": try: parsed = json.loads(res) except json.decoder.JSONDecodeError: raise ValueError("Received broken JSON {}".format(res)) if not parsed: # empty list return parsed first_item = parsed if isinstance(parsed, list): first_item = parsed[0] if first_item: if first_item.get("error"): raise ValueError(first_item["error"]) # singular values (file name etc) if first_item.get("result") is not None: return first_item["result"] return parsed # parsed return res def _to_records(self, payload): """ Converts string json representation into list of AEItem dot notation access to work. Returns: payload(dict): - dictionary from json representation, expected to come from _handle_return """ if not payload: return [] if isinstance(payload, str): # safety fallback try: payload = json.loads(payload) except json.decoder.JSONDecodeError: raise ValueError("Received broken JSON {}".format(payload)) if isinstance(payload, dict): payload = [payload] ret = [] # convert to AEItem to use dot donation for d in payload: if not d: continue # currently implemented and expected fields item = AEItem(d.get('id'), d.get('name'), d.get('type'), d.get('members'), d.get('frameStart'), d.get('framesDuration'), d.get('frameRate'), d.get('file_name'), d.get("instance_id"), d.get("width"), d.get("height"), d.get("is_placeholder"), d.get("uuid"), d.get("path"), d.get("containing_comps"),) ret.append(item) return ret def get_stub(): """ Convenience function to get server RPC stub to call methods directed for host (Photoshop). It expects already created connection, started from client. Currently created when panel is opened (PS: Window>Extensions>Avalon) :return: where functions could be called from """ ae_stub = AfterEffectsServerStub() if not ae_stub.client: raise ConnectionNotEstablishedYet("Connection is not created yet") return ae_stub ================================================ FILE: openpype/hosts/aftereffects/plugins/__init__.py ================================================ ================================================ FILE: openpype/hosts/aftereffects/plugins/create/create_render.py ================================================ import re from openpype import resources from openpype.lib import BoolDef, UISeparatorDef from openpype.hosts.aftereffects import api from openpype.pipeline import ( Creator, CreatedInstance, CreatorError ) from openpype.hosts.aftereffects.api.pipeline import cache_and_get_instances from openpype.hosts.aftereffects.api.lib import set_settings from openpype.lib import prepare_template_data from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS class RenderCreator(Creator): """Creates 'render' instance for publishing. Result of 'render' instance is video or sequence of images for particular composition based of configuration in its RenderQueue. """ identifier = "render" label = "Render" family = "render" description = "Render creator" create_allow_context_change = True # Settings mark_for_review = True force_setting_values = True def create(self, subset_name_from_ui, data, pre_create_data): stub = api.get_stub() # only after After Effects is up try: _ = stub.get_active_document_full_name() except ValueError: raise CreatorError( "Please save workfile via Workfile app first!" ) if pre_create_data.get("use_selection"): comps = stub.get_selected_items( comps=True, folders=False, footages=False ) else: comps = stub.get_items(comps=True, folders=False, footages=False) if not comps: raise CreatorError( "Nothing to create. Select composition in Project Bin if " "'Use selection' is toggled or create at least " "one composition." ) use_composition_name = (pre_create_data.get("use_composition_name") or len(comps) > 1) for comp in comps: composition_name = re.sub( "[^{}]+".format(SUBSET_NAME_ALLOWED_SYMBOLS), "", comp.name ) if use_composition_name: if "{composition}" not in subset_name_from_ui.lower(): subset_name_from_ui += "{Composition}" dynamic_fill = prepare_template_data({"composition": composition_name}) subset_name = subset_name_from_ui.format(**dynamic_fill) data["composition_name"] = composition_name else: subset_name = subset_name_from_ui subset_name = re.sub(r"\{composition\}", '', subset_name, flags=re.IGNORECASE) for inst in self.create_context.instances: if subset_name == inst.subset_name: raise CreatorError("{} already exists".format( inst.subset_name)) data["members"] = [comp.id] data["orig_comp_name"] = composition_name new_instance = CreatedInstance(self.family, subset_name, data, self) if "farm" in pre_create_data: use_farm = pre_create_data["farm"] new_instance.creator_attributes["farm"] = use_farm review = pre_create_data["mark_for_review"] new_instance. creator_attributes["mark_for_review"] = review api.get_stub().imprint(new_instance.id, new_instance.data_to_store()) self._add_instance_to_context(new_instance) stub.rename_item(comp.id, subset_name) if self.force_setting_values: set_settings(True, True, [comp.id], print_msg=False) def get_pre_create_attr_defs(self): output = [ BoolDef("use_selection", tooltip="Composition for publishable instance should be " "selected by default.", default=True, label="Use selection"), BoolDef("use_composition_name", label="Use composition name in subset"), UISeparatorDef(), BoolDef("farm", label="Render on farm"), BoolDef( "mark_for_review", label="Review", default=self.mark_for_review ) ] return output def get_instance_attr_defs(self): return [ BoolDef("farm", label="Render on farm"), BoolDef( "mark_for_review", label="Review", default=False ) ] def get_icon(self): return resources.get_openpype_splash_filepath() def collect_instances(self): for instance_data in cache_and_get_instances(self): # legacy instances have family=='render' or 'renderLocal', use them creator_id = (instance_data.get("creator_identifier") or instance_data.get("family", '').replace("Local", '')) if creator_id == self.identifier: instance_data = self._handle_legacy(instance_data) instance = CreatedInstance.from_existing( instance_data, self ) self._add_instance_to_context(instance) def update_instances(self, update_list): for created_inst, _changes in update_list: api.get_stub().imprint(created_inst.get("instance_id"), created_inst.data_to_store()) subset_change = _changes.get("subset") if subset_change: api.get_stub().rename_item(created_inst.data["members"][0], subset_change.new_value) def remove_instances(self, instances): """Removes metadata and renames to original comp name if available.""" for instance in instances: self._remove_instance_from_context(instance) self.host.remove_instance(instance) comp_id = instance.data["members"][0] comp = api.get_stub().get_item(comp_id) orig_comp_name = instance.data.get("orig_comp_name") if comp: if orig_comp_name: new_comp_name = orig_comp_name else: new_comp_name = "dummyCompName" api.get_stub().rename_item(comp_id, new_comp_name) def apply_settings(self, project_settings): plugin_settings = ( project_settings["aftereffects"]["create"]["RenderCreator"] ) self.mark_for_review = plugin_settings["mark_for_review"] self.force_setting_values = plugin_settings["force_setting_values"] self.default_variants = plugin_settings.get( "default_variants", plugin_settings.get("defaults") or [] ) def get_detail_description(self): return """Creator for Render instances Main publishable item in AfterEffects will be of `render` family. Result of this item (instance) is picture sequence or video that could be a final delivery product or loaded and used in another DCCs. Select single composition and create instance of 'render' family or turn off 'Use selection' to create instance for all compositions. 'Use composition name in subset' allows to explicitly add composition name into created subset name. Position of composition name could be set in `project_settings/global/tools/creator/subset_name_profiles` with some form of '{composition}' placeholder. Composition name will be used implicitly if multiple composition should be handled at same time. If {composition} placeholder is not us 'subset_name_profiles' composition name will be capitalized and set at the end of subset name if necessary. If composition name should be used, it will be cleaned up of characters that would cause an issue in published file names. """ def get_dynamic_data(self, variant, task_name, asset_doc, project_name, host_name, instance): dynamic_data = {} if instance is not None: composition_name = instance.get("composition_name") if composition_name: dynamic_data["composition"] = composition_name else: dynamic_data["composition"] = "{composition}" return dynamic_data def _handle_legacy(self, instance_data): """Converts old instances to new format.""" if not instance_data.get("members"): instance_data["members"] = [instance_data.get("uuid")] if instance_data.get("uuid"): # uuid not needed, replaced with unique instance_id api.get_stub().remove_instance(instance_data.get("uuid")) instance_data.pop("uuid") if not instance_data.get("task"): instance_data["task"] = self.create_context.get_current_task_name() if not instance_data.get("creator_attributes"): is_old_farm = instance_data["family"] != "renderLocal" instance_data["creator_attributes"] = {"farm": is_old_farm} instance_data["family"] = self.family if instance_data["creator_attributes"].get("mark_for_review") is None: instance_data["creator_attributes"]["mark_for_review"] = True return instance_data ================================================ FILE: openpype/hosts/aftereffects/plugins/create/workfile_creator.py ================================================ from openpype import AYON_SERVER_ENABLED import openpype.hosts.aftereffects.api as api from openpype.client import get_asset_by_name from openpype.pipeline import ( AutoCreator, CreatedInstance ) from openpype.hosts.aftereffects.api.pipeline import cache_and_get_instances class AEWorkfileCreator(AutoCreator): identifier = "workfile" family = "workfile" default_variant = "Main" def get_instance_attr_defs(self): return [] def collect_instances(self): for instance_data in cache_and_get_instances(self): creator_id = instance_data.get("creator_identifier") if creator_id == self.identifier: subset_name = instance_data["subset"] instance = CreatedInstance( self.family, subset_name, instance_data, self ) self._add_instance_to_context(instance) def update_instances(self, update_list): # nothing to change on workfiles pass def create(self, options=None): existing_instance = None for instance in self.create_context.instances: if instance.family == self.family: existing_instance = instance break context = self.create_context project_name = context.get_current_project_name() asset_name = context.get_current_asset_name() task_name = context.get_current_task_name() host_name = context.host_name existing_asset_name = None if existing_instance is not None: if AYON_SERVER_ENABLED: existing_asset_name = existing_instance.get("folderPath") if existing_asset_name is None: existing_asset_name = existing_instance["asset"] if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, project_name, host_name ) data = { "task": task_name, "variant": self.default_variant } if AYON_SERVER_ENABLED: data["folderPath"] = asset_name else: data["asset"] = asset_name data.update(self.get_dynamic_data( self.default_variant, task_name, asset_doc, project_name, host_name, None )) new_instance = CreatedInstance( self.family, subset_name, data, self ) self._add_instance_to_context(new_instance) api.get_stub().imprint(new_instance.get("instance_id"), new_instance.data_to_store()) elif ( existing_asset_name != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, project_name, host_name ) if AYON_SERVER_ENABLED: existing_instance["folderPath"] = asset_name else: existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name ================================================ FILE: openpype/hosts/aftereffects/plugins/load/load_background.py ================================================ import re from openpype.pipeline import get_representation_path from openpype.hosts.aftereffects import api from openpype.hosts.aftereffects.api.lib import ( get_background_layers, get_unique_layer_name, ) class BackgroundLoader(api.AfterEffectsLoader): """ Load images from Background family Creates for each background separate folder with all imported images from background json AND automatically created composition with layers, each layer for separate image. For each load container is created and stored in project (.aep) metadata """ label = "Load JSON Background" families = ["background"] representations = ["json"] def load(self, context, name=None, namespace=None, data=None): stub = self.get_stub() items = stub.get_items(comps=True) existing_items = [layer.name.replace(stub.LOADED_ICON, '') for layer in items] comp_name = get_unique_layer_name( existing_items, "{}_{}".format(context["asset"]["name"], name)) path = self.filepath_from_context(context) layers = get_background_layers(path) if not layers: raise ValueError("No layers found in {}".format(path)) comp = stub.import_background(None, stub.LOADED_ICON + comp_name, layers) if not comp: raise ValueError("Import background failed. " "Please contact support") self[:] = [comp] namespace = namespace or comp_name return api.containerise( name, namespace, comp, context, self.__class__.__name__ ) def update(self, container, representation): """ Switch asset or change version """ stub = self.get_stub() context = representation.get("context", {}) _ = container.pop("layer") # without iterator number (_001, 002...) namespace_from_container = re.sub(r'_\d{3}$', '', container["namespace"]) comp_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != comp_name: items = stub.get_items(comps=True) existing_items = [layer.name for layer in items] comp_name = get_unique_layer_name( existing_items, "{}_{}".format(context["asset"], context["subset"])) else: # switching version - keep same name comp_name = container["namespace"] path = get_representation_path(representation) layers = get_background_layers(path) comp = stub.reload_background(container["members"][1], stub.LOADED_ICON + comp_name, layers) # update container container["representation"] = str(representation["_id"]) container["name"] = context["subset"] container["namespace"] = comp_name container["members"] = comp.members stub.imprint(comp.id, container) def remove(self, container): """ Removes element from scene: deletes layer + removes from file metadata. Args: container (dict): container to be removed - used to get layer_id """ stub = self.get_stub() layer = container.pop("layer") stub.imprint(layer.id, {}) stub.delete_item(layer.id) def switch(self, container, representation): self.update(container, representation) ================================================ FILE: openpype/hosts/aftereffects/plugins/load/load_file.py ================================================ import re from openpype.pipeline import get_representation_path from openpype.hosts.aftereffects import api from openpype.hosts.aftereffects.api.lib import get_unique_layer_name class FileLoader(api.AfterEffectsLoader): """Load images Stores the imported asset in a container named after the asset. """ label = "Load file" families = ["image", "plate", "render", "prerender", "review", "audio"] representations = ["*"] def load(self, context, name=None, namespace=None, data=None): stub = self.get_stub() layers = stub.get_items(comps=True, folders=True, footages=True) existing_layers = [layer.name for layer in layers] comp_name = get_unique_layer_name( existing_layers, "{}_{}".format(context["asset"]["name"], name)) import_options = {} path = self.filepath_from_context(context) if len(context["representation"]["files"]) > 1: import_options['sequence'] = True if not path: repr_id = context["representation"]["_id"] self.log.warning( "Representation id `{}` is failing to load".format(repr_id)) return path = path.replace("\\", "/") if '.psd' in path: import_options['ImportAsType'] = 'ImportAsType.COMP' comp = stub.import_file(path, stub.LOADED_ICON + comp_name, import_options) if not comp: self.log.warning( "Representation `{}` is failing to load".format(path)) self.log.warning("Check host app for alert error.") return self[:] = [comp] namespace = namespace or comp_name return api.containerise( name, namespace, comp, context, self.__class__.__name__ ) def update(self, container, representation): """ Switch asset or change version """ stub = self.get_stub() layer = container.pop("layer") context = representation.get("context", {}) namespace_from_container = re.sub(r'_\d{3}$', '', container["namespace"]) layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: layers = stub.get_items(comps=True) existing_layers = [layer.name for layer in layers] layer_name = get_unique_layer_name( existing_layers, "{}_{}".format(context["asset"], context["subset"])) else: # switching version - keep same name layer_name = container["namespace"] path = get_representation_path(representation) # with aftereffects.maintained_selection(): # TODO stub.replace_item(layer.id, path, stub.LOADED_ICON + layer_name) stub.imprint( layer.id, {"representation": str(representation["_id"]), "name": context["subset"], "namespace": layer_name} ) def remove(self, container): """ Removes element from scene: deletes layer + removes from Headline Args: container (dict): container to be removed - used to get layer_id """ stub = self.get_stub() layer = container.pop("layer") stub.imprint(layer.id, {}) stub.delete_item(layer.id) def switch(self, container, representation): self.update(container, representation) ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/add_publish_highlight.py ================================================ import pyblish.api from openpype.hosts.aftereffects.api import get_stub class AddPublishHighlight(pyblish.api.InstancePlugin): """ Revert back rendered comp name and add publish highlight """ label = "Add render highlight" order = pyblish.api.IntegratorOrder + 8.0 hosts = ["aftereffects"] families = ["render.farm"] optional = True def process(self, instance): stub = get_stub() item = instance.data # comp name contains highlight icon stub.rename_item(item["comp_id"], item["comp_name"]) ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/closeAE.py ================================================ # -*- coding: utf-8 -*- """Close AE after publish. For Webpublishing only.""" import pyblish.api from openpype.hosts.aftereffects.api import get_stub class CloseAE(pyblish.api.ContextPlugin): """Close AE after publish. For Webpublishing only. """ order = pyblish.api.IntegratorOrder + 14 label = "Close AE" optional = True active = True hosts = ["aftereffects"] targets = ["automated"] def process(self, context): self.log.info("CloseAE") stub = get_stub() self.log.info("Shutting down AE") stub.save() stub.close() self.log.info("AE closed") ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/collect_audio.py ================================================ import os import pyblish.api from openpype.hosts.aftereffects.api import get_stub class CollectAudio(pyblish.api.ContextPlugin): """Inject audio file url for rendered composition into context. Needs to run AFTER 'collect_render'. Use collected comp_id to check if there is an AVLayer in this composition """ order = pyblish.api.CollectorOrder + 0.499 label = "Collect Audio" hosts = ["aftereffects"] def process(self, context): for instance in context: if 'render.farm' in instance.data.get("families", []): comp_id = instance.data["comp_id"] if not comp_id: self.log.debug("No comp_id filled in instance") continue context.data["audioFile"] = os.path.normpath( get_stub().get_audio_url(comp_id) ).replace("\\", "/") ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/collect_current_file.py ================================================ import os import pyblish.api from openpype.hosts.aftereffects.api import get_stub class CollectCurrentFile(pyblish.api.ContextPlugin): """Inject the current working file into context""" order = pyblish.api.CollectorOrder - 0.49 label = "Current File" hosts = ["aftereffects"] def process(self, context): context.data["currentFile"] = os.path.normpath( get_stub().get_active_document_full_name() ).replace("\\", "/") ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/collect_extension_version.py ================================================ import os import re import pyblish.api from openpype.hosts.aftereffects.api import ( get_stub, get_extension_manifest_path ) class CollectExtensionVersion(pyblish.api.ContextPlugin): """ Pulls and compares version of installed extension. It is recommended to use same extension as in provided Openpype code. Please use Anastasiy’s Extension Manager or ZXPInstaller to update extension in case of an error. You can locate extension.zxp in your installed Openpype code in `repos/avalon-core/avalon/aftereffects` """ # This technically should be a validator, but other collectors might be # impacted with usage of obsolete extension, so collector that runs first # was chosen order = pyblish.api.CollectorOrder - 0.5 label = "Collect extension version" hosts = ["aftereffects"] optional = True active = True def process(self, context): installed_version = get_stub().get_extension_version() if not installed_version: raise ValueError("Unknown version, probably old extension") manifest_url = get_extension_manifest_path() if not os.path.exists(manifest_url): self.log.debug("Unable to locate extension manifest, not checking") return expected_version = None with open(manifest_url) as fp: content = fp.read() found = re.findall(r'(ExtensionBundleVersion=")([0-9\.]+)(")', content) if found: expected_version = found[0][1] if expected_version != installed_version: msg = ( "Expected version '{}' found '{}'\n Please update" " your installed extension, it might not work properly." ).format(expected_version, installed_version) raise ValueError(msg) ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/collect_render.py ================================================ import os import re import tempfile import attr import pyblish.api from openpype.settings import get_project_settings from openpype.pipeline import publish from openpype.pipeline.publish import RenderInstance from openpype.hosts.aftereffects.api import get_stub @attr.s class AERenderInstance(RenderInstance): # extend generic, composition name is needed comp_name = attr.ib(default=None) comp_id = attr.ib(default=None) fps = attr.ib(default=None) projectEntity = attr.ib(default=None) stagingDir = attr.ib(default=None) app_version = attr.ib(default=None) publish_attributes = attr.ib(default={}) file_names = attr.ib(default=[]) class CollectAERender(publish.AbstractCollectRender): order = pyblish.api.CollectorOrder + 0.405 label = "Collect After Effects Render Layers" hosts = ["aftereffects"] padding_width = 6 rendered_extension = 'png' _stub = None @classmethod def get_stub(cls): if not cls._stub: cls._stub = get_stub() return cls._stub def get_instances(self, context): instances = [] instances_to_remove = [] app_version = CollectAERender.get_stub().get_app_version() app_version = app_version[0:4] current_file = context.data["currentFile"] version = context.data["version"] project_entity = context.data["projectEntity"] compositions = CollectAERender.get_stub().get_items(True) compositions_by_id = {item.id: item for item in compositions} for inst in context: if not inst.data.get("active", True): continue family = inst.data["family"] if family not in ["render", "renderLocal"]: # legacy continue comp_id = int(inst.data["members"][0]) comp_info = CollectAERender.get_stub().get_comp_properties( comp_id) if not comp_info: self.log.warning("Orphaned instance, deleting metadata") inst_id = inst.data.get("instance_id") or str(comp_id) CollectAERender.get_stub().remove_instance(inst_id) continue frame_start = comp_info.frameStart frame_end = round(comp_info.frameStart + comp_info.framesDuration) - 1 fps = comp_info.frameRate # TODO add resolution when supported by extension task_name = inst.data.get("task") # legacy render_q = CollectAERender.get_stub().get_render_info(comp_id) if not render_q: raise ValueError("No file extension set in Render Queue") render_item = render_q[0] instance_families = inst.data.get("families", []) subset_name = inst.data["subset"] instance = AERenderInstance( family="render", families=instance_families, version=version, time="", source=current_file, label="{} - {}".format(subset_name, family), subset=subset_name, asset=inst.data["asset"], task=task_name, attachTo=False, setMembers='', publish=True, name=subset_name, resolutionWidth=render_item.width, resolutionHeight=render_item.height, pixelAspect=1, tileRendering=False, tilesX=0, tilesY=0, review="review" in instance_families, frameStart=frame_start, frameEnd=frame_end, frameStep=1, fps=fps, app_version=app_version, publish_attributes=inst.data.get("publish_attributes", {}), file_names=[item.file_name for item in render_q] ) comp = compositions_by_id.get(comp_id) if not comp: raise ValueError("There is no composition for item {}". format(comp_id)) instance.outputDir = self._get_output_dir(instance) instance.comp_name = comp.name instance.comp_id = comp_id is_local = "renderLocal" in inst.data["family"] # legacy if inst.data.get("creator_attributes"): is_local = not inst.data["creator_attributes"].get("farm") if is_local: # for local renders instance = self._update_for_local(instance, project_entity) else: fam = "render.farm" if fam not in instance.families: instance.families.append(fam) instance.renderer = "aerender" instance.farm = True # to skip integrate if "review" in instance.families: # to skip ExtractReview locally instance.families.remove("review") instances.append(instance) instances_to_remove.append(inst) for instance in instances_to_remove: context.remove(instance) return instances def get_expected_files(self, render_instance): """ Returns list of rendered files that should be created by Deadline. These are not published directly, they are source for later 'submit_publish_job'. Args: render_instance (RenderInstance): to pull anatomy and parts used in url Returns: (list) of absolute urls to rendered file """ start = render_instance.frameStart end = render_instance.frameEnd base_dir = self._get_output_dir(render_instance) expected_files = [] for file_name in render_instance.file_names: _, ext = os.path.splitext(os.path.basename(file_name)) ext = ext.replace('.', '') version_str = "v{:03d}".format(render_instance.version) if "#" not in file_name: # single frame (mov)W path = os.path.join(base_dir, "{}_{}_{}.{}".format( render_instance.asset, render_instance.subset, version_str, ext )) expected_files.append(path) else: for frame in range(start, end + 1): path = os.path.join(base_dir, "{}_{}_{}.{}.{}".format( render_instance.asset, render_instance.subset, version_str, str(frame).zfill(self.padding_width), ext )) expected_files.append(path) return expected_files def _get_output_dir(self, render_instance): """ Returns dir path of rendered files, used in submit_publish_job for metadata.json location. Should be in separate folder inside of work area. Args: render_instance (RenderInstance): Returns: (str): absolute path to rendered files """ # render to folder of workfile base_dir = os.path.dirname(render_instance.source) file_name, _ = os.path.splitext( os.path.basename(render_instance.source)) base_dir = os.path.join(base_dir, 'renders', 'aftereffects', file_name) # for submit_publish_job return base_dir def _update_for_local(self, instance, project_entity): """Update old saved instances to current publishing format""" instance.stagingDir = tempfile.mkdtemp() instance.projectEntity = project_entity fam = "render.local" if fam not in instance.families: instance.families.append(fam) return instance ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/collect_review.py ================================================ """ Requires: None Provides: instance -> family ("review") """ import pyblish.api class CollectReview(pyblish.api.ContextPlugin): """Add review to families if instance created with 'mark_for_review' flag """ label = "Collect Review" hosts = ["aftereffects"] order = pyblish.api.CollectorOrder + 0.1 def process(self, context): for instance in context: creator_attributes = instance.data.get("creator_attributes") or {} if ( creator_attributes.get("mark_for_review") and "review" not in instance.data["families"] ): instance.data["families"].append("review") ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/collect_workfile.py ================================================ import os import pyblish.api from openpype.client import get_asset_name_identifier from openpype.pipeline.create import get_subset_name class CollectWorkfile(pyblish.api.ContextPlugin): """ Adds the AE render instances """ label = "Collect After Effects Workfile Instance" order = pyblish.api.CollectorOrder + 0.1 default_variant = "Main" def process(self, context): existing_instance = None for instance in context: if instance.data["family"] == "workfile": self.log.debug("Workfile instance found, won't create new") existing_instance = instance break current_file = context.data["currentFile"] staging_dir = os.path.dirname(current_file) scene_file = os.path.basename(current_file) if existing_instance is None: # old publish instance = self._get_new_instance(context, scene_file) else: instance = existing_instance # creating representation representation = { 'name': 'aep', 'ext': 'aep', 'files': scene_file, "stagingDir": staging_dir, } if not instance.data.get("representations"): instance.data["representations"] = [] instance.data["representations"].append(representation) instance.data["publish"] = instance.data["active"] # for DL def _get_new_instance(self, context, scene_file): task = context.data["task"] version = context.data["version"] asset_entity = context.data["assetEntity"] project_entity = context.data["projectEntity"] asset_name = get_asset_name_identifier(asset_entity) instance_data = { "active": True, "asset": asset_name, "task": task, "frameStart": context.data['frameStart'], "frameEnd": context.data['frameEnd'], "handleStart": context.data['handleStart'], "handleEnd": context.data['handleEnd'], "fps": asset_entity["data"]["fps"], "resolutionWidth": asset_entity["data"].get( "resolutionWidth", project_entity["data"]["resolutionWidth"]), "resolutionHeight": asset_entity["data"].get( "resolutionHeight", project_entity["data"]["resolutionHeight"]), "pixelAspect": 1, "step": 1, "version": version } # workfile instance family = "workfile" subset = get_subset_name( family, self.default_variant, context.data["anatomyData"]["task"]["name"], context.data["assetEntity"], context.data["anatomyData"]["project"]["name"], host_name=context.data["hostName"], project_settings=context.data["project_settings"] ) # Create instance instance = context.create_instance(subset) # creating instance data instance.data.update({ "subset": subset, "label": scene_file, "family": family, "families": [family], "representations": list() }) instance.data.update(instance_data) return instance ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/extract_local_render.py ================================================ import os from openpype.pipeline import publish from openpype.hosts.aftereffects.api import get_stub class ExtractLocalRender(publish.Extractor): """Render RenderQueue locally.""" order = publish.Extractor.order - 0.47 label = "Extract Local Render" hosts = ["aftereffects"] families = ["renderLocal", "render.local"] def process(self, instance): stub = get_stub() staging_dir = instance.data["stagingDir"] self.log.debug("staging_dir::{}".format(staging_dir)) # pull file name collected value from Render Queue Output module if not instance.data["file_names"]: raise ValueError("No file extension set in Render Queue") comp_id = instance.data['comp_id'] stub.render(staging_dir, comp_id) representations = [] for file_name in instance.data["file_names"]: _, ext = os.path.splitext(os.path.basename(file_name)) ext = ext[1:] first_file_path = None files = [] for found_file_name in os.listdir(staging_dir): if not found_file_name.endswith(ext): continue files.append(found_file_name) if first_file_path is None: first_file_path = os.path.join(staging_dir, found_file_name) if not files: self.log.info("no files") return # single file cannot be wrapped in array resulting_files = files if len(files) == 1: resulting_files = files[0] repre_data = { "frameStart": instance.data["frameStart"], "frameEnd": instance.data["frameEnd"], "name": ext, "ext": ext, "files": resulting_files, "stagingDir": staging_dir } first_repre = not representations if instance.data["review"] and first_repre: repre_data["tags"] = ["review"] # TODO return back when Extract from source same as regular # thumbnail_path = os.path.join(staging_dir, files[0]) # instance.data["thumbnailSource"] = thumbnail_path representations.append(repre_data) instance.data["representations"] = representations ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/extract_save_scene.py ================================================ import pyblish.api from openpype.pipeline import publish from openpype.hosts.aftereffects.api import get_stub class ExtractSaveScene(pyblish.api.ContextPlugin): """Save scene before extraction.""" order = publish.Extractor.order - 0.48 label = "Extract Save Scene" hosts = ["aftereffects"] def process(self, context): stub = get_stub() stub.save() ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml ================================================ Footage item missing ## Footage item missing FootageItem `{name}` contains missing `{path}`. Render will not produce any frames and AE will stop react to any integration ### How to repair? Remove `{name}` or provide missing file. ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/help/validate_instance_asset.xml ================================================ Subset context ## Invalid subset context Context of the given subset doesn't match your current scene. ### How to repair? You can fix this with "repair" button on the right and refresh Publish at the bottom right. ### __Detailed Info__ (optional) This might happen if you are reuse old workfile and open it in different context. (Eg. you created subset "renderCompositingDefault" from asset "Robot' in "your_project_Robot_compositing.aep", now you opened this workfile in a context "Sloth" but existing subset for "Robot" asset stayed in the workfile.) ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/help/validate_scene_settings.xml ================================================ Scene setting ## Invalid scene setting found One of the settings in a scene doesn't match to asset settings in database. {invalid_setting_str} ### How to repair? Change values for {invalid_keys_str} in the scene OR change them in the asset database if they are wrong there. In the scene it is right mouse click on published composition > `Composition Settings`. ### __Detailed Info__ (optional) This error is shown when for example resolution in the scene doesn't match to resolution set on the asset in the database. Either value in the database or in the scene is wrong. Scene file doesn't exist ## Scene file doesn't exist Collected scene {scene_url} doesn't exist. ### How to repair? Re-save file, start publish from the beginning again. ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/increment_workfile.py ================================================ import pyblish.api from openpype.lib import version_up from openpype.pipeline.publish import get_errored_plugins_from_context from openpype.hosts.aftereffects.api import get_stub class IncrementWorkfile(pyblish.api.InstancePlugin): """Increment the current workfile. Saves the current scene with an increased version number. """ label = "Increment Workfile" order = pyblish.api.IntegratorOrder + 9.0 hosts = ["aftereffects"] families = ["workfile"] optional = True def process(self, instance): errored_plugins = get_errored_plugins_from_context(instance.context) if errored_plugins: raise RuntimeError( "Skipping incrementing current file because publishing failed." ) scene_path = version_up(instance.context.data["currentFile"]) get_stub().saveAs(scene_path, True) self.log.info("Incremented workfile to: {}".format(scene_path)) ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py ================================================ import json import pyblish.api from openpype.hosts.aftereffects.api import AfterEffectsHost class PreCollectRender(pyblish.api.ContextPlugin): """ Checks if render instance is of old type, adds to families to both existing collectors work same way. Could be removed in the future when no one uses old publish. """ label = "PreCollect Render" order = pyblish.api.CollectorOrder + 0.400 hosts = ["aftereffects"] family_remapping = { "render": ("render.farm", "farm"), # (family, label) "renderLocal": ("render.local", "local") } def process(self, context): if context.data.get("newPublishing"): self.log.debug("Not applicable for New Publisher, skip") return for inst in AfterEffectsHost().list_instances(): if inst.get("creator_attributes"): raise ValueError("Instance created in New publisher, " "cannot be published in Pyblish.\n" "Please publish in New Publisher " "or recreate instances with legacy Creators") if inst["family"] not in self.family_remapping.keys(): continue if not inst["members"]: raise ValueError("Couldn't find id, unable to publish. " + "Please recreate instance.") instance = context.create_instance(inst["subset"]) inst["families"] = [self.family_remapping[inst["family"]][0]] instance.data.update(inst) self._debug_log(instance) def _debug_log(self, instance): def _default_json(value): return str(value) self.log.info( json.dumps(instance.data, indent=4, default=_default_json) ) ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/remove_publish_highlight.py ================================================ from openpype.pipeline import publish from openpype.hosts.aftereffects.api import get_stub class RemovePublishHighlight(publish.Extractor): """Clean utf characters which are not working in DL Published compositions are marked with unicode icon which causes problems on specific render environments. Clean it first, sent to rendering, add it later back to avoid confusion. """ order = publish.Extractor.order - 0.49 # just before save label = "Clean render comp" hosts = ["aftereffects"] families = ["render.farm"] def process(self, instance): stub = get_stub() self.log.debug("instance::{}".format(instance.data)) item = instance.data comp_name = item["comp_name"].replace(stub.PUBLISH_ICON, '') stub.rename_item(item["comp_id"], comp_name) instance.data["comp_name"] = comp_name ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py ================================================ # -*- coding: utf-8 -*- """Validate presence of footage items in composition Requires: """ import os import pyblish.api from openpype.pipeline import ( PublishXmlValidationError ) from openpype.hosts.aftereffects.api import get_stub class ValidateFootageItems(pyblish.api.InstancePlugin): """ Validates if FootageItems contained in composition exist. AE fails silently and doesn't render anything if footage item file is missing. This will result in nonresponsiveness of AE UI as it expects reaction from user, but it will not provide dialog. This validator tries to check existence of the files. It will not protect from missing frame in multiframes though (as AE api doesn't provide this information and it cannot be told how many frames should be there easily). Missing frame is replaced by placeholder. """ order = pyblish.api.ValidatorOrder label = "Validate Footage Items" families = ["render.farm", "render.local", "render"] hosts = ["aftereffects"] optional = True def process(self, instance): """Plugin entry point.""" comp_id = instance.data["comp_id"] for footage_item in get_stub().get_items(comps=False, folders=False, footages=True): self.log.info(footage_item) if comp_id not in footage_item.containing_comps: continue path = footage_item.path if path and not os.path.exists(path): msg = f"File {path} not found." formatting = {"name": footage_item.name, "path": path} raise PublishXmlValidationError(self, msg, formatting_data=formatting) ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py ================================================ import pyblish.api from openpype.pipeline import get_current_asset_name from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, ) from openpype.hosts.aftereffects.api import get_stub class ValidateInstanceAssetRepair(pyblish.api.Action): """Repair the instance asset with value from Context.""" label = "Repair" icon = "wrench" on = "failed" def process(self, context, plugin): # Get the errored instances failed = [] for result in context.data["results"]: if (result["error"] is not None and result["instance"] is not None and result["instance"] not in failed): failed.append(result["instance"]) # Apply pyblish.logic to get the instances for the plug-in instances = pyblish.api.instances_by_plugin(failed, plugin) stub = get_stub() for instance in instances: data = stub.read(instance[0]) data["asset"] = get_current_asset_name() stub.imprint(instance[0].instance_id, data) class ValidateInstanceAsset(pyblish.api.InstancePlugin): """Validate the instance asset is the current selected context asset. As it might happen that multiple worfiles are opened at same time, switching between them would mess with selected context. (From Launcher or Ftrack). In that case outputs might be output under wrong asset! Repair action will use Context asset value (from Workfiles or Launcher) Closing and reopening with Workfiles will refresh Context value. """ label = "Validate Instance Asset" hosts = ["aftereffects"] actions = [ValidateInstanceAssetRepair] order = ValidateContentsOrder def process(self, instance): instance_asset = instance.data["asset"] current_asset = get_current_asset_name() msg = ( f"Instance asset {instance_asset} is not the same " f"as current context {current_asset}." ) if instance_asset != current_asset: raise PublishXmlValidationError(self, msg) ================================================ FILE: openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py ================================================ # -*- coding: utf-8 -*- """Validate scene settings. Requires: instance -> assetEntity instance -> anatomyData """ import os import re import pyblish.api from openpype.pipeline import ( PublishXmlValidationError, OptionalPyblishPluginMixin ) from openpype.hosts.aftereffects.api import get_asset_settings class ValidateSceneSettings(OptionalPyblishPluginMixin, pyblish.api.InstancePlugin): """ Ensures that Composition Settings (right mouse on comp) are same as in FTrack on task. By default checks only duration - how many frames should be rendered. Compares: Frame start - Frame end + 1 from FTrack against Duration in Composition Settings. If this complains: Check error message where is discrepancy. Check FTrack task 'pype' section of task attributes for expected values. Check/modify rendered Composition Settings. If you know what you are doing run publishing again, uncheck this validation before Validation phase. """ """ Dev docu: Could be configured by 'presets/plugins/aftereffects/publish' skip_timelines_check - fill task name for which skip validation of frameStart frameEnd fps handleStart handleEnd skip_resolution_check - fill entity type ('asset') to skip validation resolutionWidth resolutionHeight TODO support in extension is missing for now By defaults validates duration (how many frames should be published) """ order = pyblish.api.ValidatorOrder label = "Validate Scene Settings" families = ["render.farm", "render.local", "render"] hosts = ["aftereffects"] optional = True skip_timelines_check = [".*"] # * >> skip for all skip_resolution_check = [".*"] def process(self, instance): """Plugin entry point.""" # Skip the instance if is not active by data on the instance if not self.is_active(instance.data): return asset_doc = instance.data["assetEntity"] expected_settings = get_asset_settings(asset_doc) self.log.info("config from DB::{}".format(expected_settings)) task_name = instance.data["anatomyData"]["task"]["name"] if any(re.search(pattern, task_name) for pattern in self.skip_resolution_check): expected_settings.pop("resolutionWidth") expected_settings.pop("resolutionHeight") if any(re.search(pattern, task_name) for pattern in self.skip_timelines_check): expected_settings.pop('fps', None) expected_settings.pop('frameStart', None) expected_settings.pop('frameEnd', None) expected_settings.pop('handleStart', None) expected_settings.pop('handleEnd', None) # handle case where ftrack uses only two decimal places # 23.976023976023978 vs. 23.98 fps = instance.data.get("fps") if fps: if isinstance(fps, float): fps = float( "{:.2f}".format(fps)) expected_settings["fps"] = fps duration = instance.data.get("frameEndHandle") - \ instance.data.get("frameStartHandle") + 1 self.log.debug("validated items::{}".format(expected_settings)) current_settings = { "fps": fps, "frameStart": instance.data.get("frameStart"), "frameEnd": instance.data.get("frameEnd"), "handleStart": instance.data.get("handleStart"), "handleEnd": instance.data.get("handleEnd"), "frameStartHandle": instance.data.get("frameStartHandle"), "frameEndHandle": instance.data.get("frameEndHandle"), "resolutionWidth": instance.data.get("resolutionWidth"), "resolutionHeight": instance.data.get("resolutionHeight"), "duration": duration } self.log.info("current_settings:: {}".format(current_settings)) invalid_settings = [] invalid_keys = set() for key, value in expected_settings.items(): if value != current_settings[key]: msg = "'{}' expected: '{}' found: '{}'".format( key, value, current_settings[key]) if key == "duration" and expected_settings.get("handleStart"): msg += "Handles included in calculation. Remove " \ "handles in DB or extend frame range in " \ "Composition Setting." invalid_settings.append(msg) invalid_keys.add(key) if invalid_settings: msg = "Found invalid settings:\n{}".format( "\n".join(invalid_settings) ) invalid_keys_str = ",".join(invalid_keys) break_str = "
" invalid_setting_str = "Found invalid settings:
{}".\ format(break_str.join(invalid_settings)) formatting_data = { "invalid_setting_str": invalid_setting_str, "invalid_keys_str": invalid_keys_str } raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) if not os.path.exists(instance.data.get("source")): scene_url = instance.data.get("source") msg = "Scene file {} not found (saved under wrong name)".format( scene_url ) formatting_data = { "scene_url": scene_url } raise PublishXmlValidationError(self, msg, key="file_not_found", formatting_data=formatting_data) ================================================ FILE: openpype/hosts/blender/__init__.py ================================================ from .addon import BlenderAddon __all__ = ( "BlenderAddon", ) ================================================ FILE: openpype/hosts/blender/addon.py ================================================ import os from openpype.modules import OpenPypeModule, IHostAddon BLENDER_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) class BlenderAddon(OpenPypeModule, IHostAddon): name = "blender" host_name = "blender" def initialize(self, module_settings): self.enabled = True def add_implementation_envs(self, env, _app): """Modify environments to contain all required for implementation.""" # Prepare path to implementation script implementation_user_script_path = os.path.join( BLENDER_ROOT_DIR, "blender_addon" ) # Add blender implementation script path to PYTHONPATH python_path = env.get("PYTHONPATH") or "" python_path_parts = [ path for path in python_path.split(os.pathsep) if path ] python_path_parts.insert(0, implementation_user_script_path) env["PYTHONPATH"] = os.pathsep.join(python_path_parts) # Modify Blender user scripts path previous_user_scripts = set() # Implementation path is added to set for easier paths check inside # loops - will be removed at the end previous_user_scripts.add(implementation_user_script_path) openpype_blender_user_scripts = ( env.get("OPENPYPE_BLENDER_USER_SCRIPTS") or "" ) for path in openpype_blender_user_scripts.split(os.pathsep): if path: previous_user_scripts.add(os.path.normpath(path)) blender_user_scripts = env.get("BLENDER_USER_SCRIPTS") or "" for path in blender_user_scripts.split(os.pathsep): if path: previous_user_scripts.add(os.path.normpath(path)) # Remove implementation path from user script paths as is set to # `BLENDER_USER_SCRIPTS` previous_user_scripts.remove(implementation_user_script_path) env["BLENDER_USER_SCRIPTS"] = implementation_user_script_path # Set custom user scripts env env["OPENPYPE_BLENDER_USER_SCRIPTS"] = os.pathsep.join( previous_user_scripts ) # Define Qt binding if not defined if not env.get("QT_PREFERRED_BINDING"): env["QT_PREFERRED_BINDING"] = "PySide2" def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] return [ os.path.join(BLENDER_ROOT_DIR, "hooks") ] def get_workfile_extensions(self): return [".blend"] ================================================ FILE: openpype/hosts/blender/api/__init__.py ================================================ """Public API Anything that isn't defined here is INTERNAL and unreliable for external use. """ from .pipeline import ( install, uninstall, ls, publish, containerise, BlenderHost, ) from .plugin import ( Creator, Loader, ) from .workio import ( open_file, save_file, current_file, has_unsaved_changes, file_extensions, work_root, ) from .lib import ( lsattr, lsattrs, read, maintained_selection, maintained_time, get_selection, # unique_name, ) from .capture import capture from .render_lib import prepare_rendering __all__ = [ "install", "uninstall", "ls", "publish", "containerise", "BlenderHost", "Creator", "Loader", # Workfiles API "open_file", "save_file", "current_file", "has_unsaved_changes", "file_extensions", "work_root", # Utility functions "maintained_selection", "maintained_time", "lsattr", "lsattrs", "read", "get_selection", "capture", # "unique_name", "prepare_rendering", ] ================================================ FILE: openpype/hosts/blender/api/action.py ================================================ import bpy import pyblish.api from openpype.pipeline.publish import get_errored_instances_from_context class SelectInvalidAction(pyblish.api.Action): """Select invalid objects in Blender when a publish plug-in failed.""" label = "Select Invalid" on = "failed" icon = "search" def process(self, context, plugin): errored_instances = get_errored_instances_from_context(context, plugin=plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes...") invalid = list() for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: if isinstance(invalid_nodes, (list, tuple)): invalid.extend(invalid_nodes) else: self.log.warning( "Failed plug-in doesn't have any selectable objects." ) bpy.ops.object.select_all(action='DESELECT') # Make sure every node is only processed once invalid = list(set(invalid)) if not invalid: self.log.info("No invalid nodes found.") return invalid_names = [obj.name for obj in invalid] self.log.info( "Selecting invalid objects: %s", ", ".join(invalid_names) ) # Select the objects and also make the last one the active object. for obj in invalid: obj.select_set(True) bpy.context.view_layer.objects.active = invalid[-1] ================================================ FILE: openpype/hosts/blender/api/capture.py ================================================ """Blender Capture Playblasting with independent viewport, camera and display options """ import contextlib import bpy from .lib import maintained_time from .plugin import deselect_all, create_blender_context def capture( camera=None, width=None, height=None, filename=None, start_frame=None, end_frame=None, step_frame=None, sound=None, isolate=None, maintain_aspect_ratio=True, overwrite=False, image_settings=None, display_options=None ): """Playblast in an independent windows Arguments: camera (str, optional): Name of camera, defaults to "Camera" width (int, optional): Width of output in pixels height (int, optional): Height of output in pixels filename (str, optional): Name of output file path. Defaults to current render output path. start_frame (int, optional): Defaults to current start frame. end_frame (int, optional): Defaults to current end frame. step_frame (int, optional): Defaults to 1. sound (str, optional): Specify the sound node to be used during playblast. When None (default) no sound will be used. isolate (list): List of nodes to isolate upon capturing maintain_aspect_ratio (bool, optional): Modify height in order to maintain aspect ratio. overwrite (bool, optional): Whether or not to overwrite if file already exists. If disabled and file exists and error will be raised. image_settings (dict, optional): Supplied image settings for render, using `ImageSettings` display_options (dict, optional): Supplied display options for render """ scene = bpy.context.scene camera = camera or "Camera" # Ensure camera exists. if camera not in scene.objects and camera != "AUTO": raise RuntimeError("Camera does not exist: {0}".format(camera)) # Ensure resolution. if width and height: maintain_aspect_ratio = False width = width or scene.render.resolution_x height = height or scene.render.resolution_y if maintain_aspect_ratio: ratio = scene.render.resolution_x / scene.render.resolution_y height = round(width / ratio) # Get frame range. if start_frame is None: start_frame = scene.frame_start if end_frame is None: end_frame = scene.frame_end if step_frame is None: step_frame = 1 frame_range = (start_frame, end_frame, step_frame) if filename is None: filename = scene.render.filepath render_options = { "filepath": "{}.".format(filename.rstrip(".")), "resolution_x": width, "resolution_y": height, "use_overwrite": overwrite, } with _independent_window() as window: applied_view(window, camera, isolate, options=display_options) with contextlib.ExitStack() as stack: stack.enter_context(maintain_camera(window, camera)) stack.enter_context(applied_frame_range(window, *frame_range)) stack.enter_context(applied_render_options(window, render_options)) stack.enter_context(applied_image_settings(window, image_settings)) stack.enter_context(maintained_time()) bpy.ops.render.opengl( animation=True, render_keyed_only=False, sequencer=False, write_still=False, view_context=True ) return filename ImageSettings = { "file_format": "FFMPEG", "color_mode": "RGB", "ffmpeg": { "format": "QUICKTIME", "use_autosplit": False, "codec": "H264", "constant_rate_factor": "MEDIUM", "gopsize": 18, "use_max_b_frames": False, }, } def isolate_objects(window, objects): """Isolate selection""" deselect_all() for obj in objects: obj.select_set(True) context = create_blender_context(selected=objects, window=window) with bpy.context.temp_override(**context): bpy.ops.view3d.view_axis(type="FRONT") bpy.ops.view3d.localview() deselect_all() def _apply_options(entity, options): for option, value in options.items(): if isinstance(value, dict): _apply_options(getattr(entity, option), value) else: setattr(entity, option, value) def applied_view(window, camera, isolate=None, options=None): """Apply view options to window.""" area = window.screen.areas[0] space = area.spaces[0] area.ui_type = "VIEW_3D" types = {"MESH", "GPENCIL"} objects = [obj for obj in window.scene.objects if obj.type in types] if camera == "AUTO": space.region_3d.view_perspective = "ORTHO" isolate_objects(window, isolate or objects) else: isolate_objects(window, isolate or objects) space.camera = window.scene.objects.get(camera) space.region_3d.view_perspective = "CAMERA" if isinstance(options, dict): _apply_options(space, options) else: space.shading.type = "SOLID" space.shading.color_type = "MATERIAL" space.show_gizmo = False space.overlay.show_overlays = False @contextlib.contextmanager def applied_frame_range(window, start, end, step): """Context manager for setting frame range.""" # Store current frame range current_frame_start = window.scene.frame_start current_frame_end = window.scene.frame_end current_frame_step = window.scene.frame_step # Apply frame range window.scene.frame_start = start window.scene.frame_end = end window.scene.frame_step = step try: yield finally: # Restore frame range window.scene.frame_start = current_frame_start window.scene.frame_end = current_frame_end window.scene.frame_step = current_frame_step @contextlib.contextmanager def applied_render_options(window, options): """Context manager for setting render options.""" render = window.scene.render # Store current settings original = {} for opt in options.copy(): try: original[opt] = getattr(render, opt) except ValueError: options.pop(opt) # Apply settings _apply_options(render, options) try: yield finally: # Restore previous settings _apply_options(render, original) @contextlib.contextmanager def applied_image_settings(window, options): """Context manager to override image settings.""" options = options or ImageSettings.copy() ffmpeg = options.pop("ffmpeg", {}) render = window.scene.render # Store current image settings original = {} for opt in options.copy(): try: original[opt] = getattr(render.image_settings, opt) except ValueError: options.pop(opt) # Store current ffmpeg settings original_ffmpeg = {} for opt in ffmpeg.copy(): try: original_ffmpeg[opt] = getattr(render.ffmpeg, opt) except ValueError: ffmpeg.pop(opt) # Apply image settings for opt, value in options.items(): setattr(render.image_settings, opt, value) # Apply ffmpeg settings for opt, value in ffmpeg.items(): setattr(render.ffmpeg, opt, value) try: yield finally: # Restore previous settings for opt, value in original.items(): setattr(render.image_settings, opt, value) for opt, value in original_ffmpeg.items(): setattr(render.ffmpeg, opt, value) @contextlib.contextmanager def maintain_camera(window, camera): """Context manager to override camera.""" current_camera = window.scene.camera if camera in window.scene.objects: window.scene.camera = window.scene.objects.get(camera) try: yield finally: window.scene.camera = current_camera @contextlib.contextmanager def _independent_window(): """Create capture-window context.""" context = create_blender_context() current_windows = set(bpy.context.window_manager.windows) with bpy.context.temp_override(**context): bpy.ops.wm.window_new() window = list( set(bpy.context.window_manager.windows) - current_windows)[0] context["window"] = window try: yield window finally: bpy.ops.wm.window_close() ================================================ FILE: openpype/hosts/blender/api/colorspace.py ================================================ import attr import bpy @attr.s class LayerMetadata(object): """Data class for Render Layer metadata.""" frameStart = attr.ib() frameEnd = attr.ib() @attr.s class RenderProduct(object): """ Getting Colorspace as Specific Render Product Parameter for submitting publish job. """ colorspace = attr.ib() # colorspace view = attr.ib() # OCIO view transform productName = attr.ib(default=None) class ARenderProduct(object): def __init__(self): """Constructor.""" # Initialize self.layer_data = self._get_layer_data() self.layer_data.products = self.get_render_products() def _get_layer_data(self): scene = bpy.context.scene return LayerMetadata( frameStart=int(scene.frame_start), frameEnd=int(scene.frame_end), ) def get_render_products(self): """To be implemented by renderer class. This should return a list of RenderProducts. Returns: list: List of RenderProduct """ return [ RenderProduct( colorspace="sRGB", view="ACES 1.0", productName="" ) ] ================================================ FILE: openpype/hosts/blender/api/lib.py ================================================ import os import traceback import importlib import contextlib from typing import Dict, List, Union import bpy import addon_utils from openpype.lib import Logger from . import pipeline log = Logger.get_logger(__name__) def load_scripts(paths): """Copy of `load_scripts` from Blender's implementation. It is possible that this function will be changed in future and usage will be based on Blender version. """ import bpy_types loaded_modules = set() previous_classes = [ cls for cls in bpy.types.bpy_struct.__subclasses__() ] def register_module_call(mod): register = getattr(mod, "register", None) if register: try: register() except: traceback.print_exc() else: print("\nWarning! '%s' has no register function, " "this is now a requirement for registerable scripts" % mod.__file__) def unregister_module_call(mod): unregister = getattr(mod, "unregister", None) if unregister: try: unregister() except: traceback.print_exc() def test_reload(mod): # reloading this causes internal errors # because the classes from this module are stored internally # possibly to refresh internal references too but for now, best not to. if mod == bpy_types: return mod try: return importlib.reload(mod) except: traceback.print_exc() def test_register(mod): if mod: register_module_call(mod) bpy.utils._global_loaded_modules.append(mod.__name__) from bpy_restrict_state import RestrictBlend with RestrictBlend(): for base_path in paths: for path_subdir in bpy.utils._script_module_dirs: path = os.path.join(base_path, path_subdir) if not os.path.isdir(path): continue bpy.utils._sys_path_ensure_prepend(path) # Only add to 'sys.modules' unless this is 'startup'. if path_subdir != "startup": continue for mod in bpy.utils.modules_from_path(path, loaded_modules): test_register(mod) addons_paths = [] for base_path in paths: addons_path = os.path.join(base_path, "addons") if not os.path.exists(addons_path): continue addons_paths.append(addons_path) addons_module_path = os.path.join(addons_path, "modules") if os.path.exists(addons_module_path): bpy.utils._sys_path_ensure_prepend(addons_module_path) if addons_paths: # Fake addons origin_paths = addon_utils.paths def new_paths(): paths = origin_paths() + addons_paths return paths addon_utils.paths = new_paths addon_utils.modules_refresh() # load template (if set) if any(bpy.utils.app_template_paths()): import bl_app_template_utils bl_app_template_utils.reset(reload_scripts=False) del bl_app_template_utils for cls in bpy.types.bpy_struct.__subclasses__(): if cls in previous_classes: continue if not getattr(cls, "is_registered", False): continue for subcls in cls.__subclasses__(): if not subcls.is_registered: print( "Warning, unregistered class: %s(%s)" % (subcls.__name__, cls.__name__) ) def append_user_scripts(): user_scripts = os.environ.get("OPENPYPE_BLENDER_USER_SCRIPTS") if not user_scripts: return try: load_scripts(user_scripts.split(os.pathsep)) except Exception: print("Couldn't load user scripts \"{}\"".format(user_scripts)) traceback.print_exc() def set_app_templates_path(): # Blender requires the app templates to be in `BLENDER_USER_SCRIPTS`. # After running Blender, we set that variable to our custom path, so # that the user can use their custom app templates. # We look among the scripts paths for one of the paths that contains # the app templates. The path must contain the subfolder # `startup/bl_app_templates_user`. paths = os.environ.get("OPENPYPE_BLENDER_USER_SCRIPTS").split(os.pathsep) app_templates_path = None for path in paths: if os.path.isdir( os.path.join(path, "startup", "bl_app_templates_user")): app_templates_path = path break if app_templates_path and os.path.isdir(app_templates_path): os.environ["BLENDER_USER_SCRIPTS"] = app_templates_path def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict): r"""Write `data` to `node` as userDefined attributes Arguments: node: Long name of node data: Dictionary of key/value pairs Example: >>> import bpy >>> def compute(): ... return 6 ... >>> bpy.ops.mesh.primitive_cube_add() >>> cube = bpy.context.view_layer.objects.active >>> imprint(cube, { ... "regularString": "myFamily", ... "computedValue": lambda: compute() ... }) ... >>> cube['avalon']['computedValue'] 6 """ imprint_data = dict() for key, value in data.items(): if value is None: continue if callable(value): # Support values evaluated at imprint value = value() if not isinstance(value, (int, float, bool, str, list, dict)): raise TypeError(f"Unsupported type: {type(value)}") imprint_data[key] = value pipeline.metadata_update(node, imprint_data) def lsattr(attr: str, value: Union[str, int, bool, List, Dict, None] = None) -> List: r"""Return nodes matching `attr` and `value` Arguments: attr: Name of Blender property value: Value of attribute. If none is provided, return all nodes with this attribute. Example: >>> lsattr("id", "myId") ... [bpy.data.objects["myNode"] >>> lsattr("id") ... [bpy.data.objects["myNode"], bpy.data.objects["myOtherNode"]] Returns: list """ return lsattrs({attr: value}) def lsattrs(attrs: Dict) -> List: r"""Return nodes with the given attribute(s). Arguments: attrs: Name and value pairs of expected matches Example: >>> lsattrs({"age": 5}) # Return nodes with an `age` of 5 # Return nodes with both `age` and `color` of 5 and blue >>> lsattrs({"age": 5, "color": "blue"}) Returns a list. """ # For now return all objects, not filtered by scene/collection/view_layer. matches = set() for coll in dir(bpy.data): if not isinstance( getattr(bpy.data, coll), bpy.types.bpy_prop_collection, ): continue for node in getattr(bpy.data, coll): for attr, value in attrs.items(): avalon_prop = node.get(pipeline.AVALON_PROPERTY) if not avalon_prop: continue if (avalon_prop.get(attr) and (value is None or avalon_prop.get(attr) == value)): matches.add(node) return list(matches) def read(node: bpy.types.bpy_struct_meta_idprop): """Return user-defined attributes from `node`""" data = dict(node.get(pipeline.AVALON_PROPERTY, {})) # Ignore hidden/internal data data = { key: value for key, value in data.items() if not key.startswith("_") } return data def get_selected_collections(): """ Returns a list of the currently selected collections in the outliner. Raises: RuntimeError: If the outliner cannot be found in the main Blender window. Returns: list: A list of `bpy.types.Collection` objects that are currently selected in the outliner. """ window = bpy.context.window or bpy.context.window_manager.windows[0] try: area = next( area for area in window.screen.areas if area.type == 'OUTLINER') region = next( region for region in area.regions if region.type == 'WINDOW') except StopIteration as e: raise RuntimeError("Could not find outliner. An outliner space " "must be in the main Blender window.") from e with bpy.context.temp_override( window=window, area=area, region=region, screen=window.screen ): ids = bpy.context.selected_ids return [id for id in ids if isinstance(id, bpy.types.Collection)] def get_selection(include_collections: bool = False) -> List[bpy.types.Object]: """ Returns a list of selected objects in the current Blender scene. Args: include_collections (bool, optional): Whether to include selected collections in the result. Defaults to False. Returns: List[bpy.types.Object]: A list of selected objects. """ selection = [obj for obj in bpy.context.scene.objects if obj.select_get()] if include_collections: selection.extend(get_selected_collections()) return selection @contextlib.contextmanager def maintained_selection(): r"""Maintain selection during context Example: >>> with maintained_selection(): ... # Modify selection ... bpy.ops.object.select_all(action='DESELECT') >>> # Selection restored """ previous_selection = get_selection() previous_active = bpy.context.view_layer.objects.active try: yield finally: # Clear the selection for node in get_selection(): node.select_set(state=False) if previous_selection: for node in previous_selection: try: node.select_set(state=True) except ReferenceError: # This could happen if a selected node was deleted during # the context. log.exception("Failed to reselect") continue try: bpy.context.view_layer.objects.active = previous_active except ReferenceError: # This could happen if the active node was deleted during the # context. log.exception("Failed to set active object.") @contextlib.contextmanager def maintained_time(): """Maintain current frame during context.""" current_time = bpy.context.scene.frame_current try: yield finally: bpy.context.scene.frame_current = current_time ================================================ FILE: openpype/hosts/blender/api/ops.py ================================================ """Blender operators and menus for use with Avalon.""" import os import sys import platform import time import traceback import collections from pathlib import Path from types import ModuleType from typing import Dict, List, Optional, Union from qtpy import QtWidgets, QtCore import bpy import bpy.utils.previews from openpype import style from openpype import AYON_SERVER_ENABLED from openpype.pipeline import get_current_asset_name, get_current_task_name from openpype.tools.utils import host_tools from .workio import OpenFileCacher from . import pipeline PREVIEW_COLLECTIONS: Dict = dict() # This seems like a good value to keep the Qt app responsive and doesn't slow # down Blender. At least on macOS I the interface of Blender gets very laggy if # you make it smaller. TIMER_INTERVAL: float = 0.01 if platform.system() == "Windows" else 0.1 def execute_function_in_main_thread(f): """Decorator to move a function call into main thread items""" def wrapper(*args, **kwargs): mti = MainThreadItem(f, *args, **kwargs) execute_in_main_thread(mti) return wrapper class BlenderApplication(QtWidgets.QApplication): _instance = None blender_windows = {} def __init__(self, *args, **kwargs): super(BlenderApplication, self).__init__(*args, **kwargs) self.setQuitOnLastWindowClosed(False) self.setStyleSheet(style.load_stylesheet()) self.lastWindowClosed.connect(self.__class__.reset) @classmethod def get_app(cls): if cls._instance is None: cls._instance = cls(sys.argv) return cls._instance @classmethod def reset(cls): cls._instance = None @classmethod def store_window(cls, identifier, window): current_window = cls.get_window(identifier) cls.blender_windows[identifier] = window if current_window: current_window.close() # current_window.deleteLater() @classmethod def get_window(cls, identifier): return cls.blender_windows.get(identifier) class MainThreadItem: """Structure to store information about callback in main thread. Item should be used to execute callback in main thread which may be needed for execution of Qt objects. Item store callback (callable variable), arguments and keyword arguments for the callback. Item hold information about it's process. """ not_set = object() sleep_time = 0.1 def __init__(self, callback, *args, **kwargs): self.done = False self.exception = self.not_set self.result = self.not_set self.callback = callback self.args = args self.kwargs = kwargs def execute(self): """Execute callback and store its result. Method must be called from main thread. Item is marked as `done` when callback execution finished. Store output of callback of exception information when callback raises one. """ print("Executing process in main thread") if self.done: print("- item is already processed") return callback = self.callback args = self.args kwargs = self.kwargs print("Running callback: {}".format(str(callback))) try: result = callback(*args, **kwargs) self.result = result except Exception: self.exception = sys.exc_info() finally: print("Done") self.done = True def wait(self): """Wait for result from main thread. This method stops current thread until callback is executed. Returns: object: Output of callback. May be any type or object. Raises: Exception: Reraise any exception that happened during callback execution. """ while not self.done: print(self.done) time.sleep(self.sleep_time) if self.exception is self.not_set: return self.result raise self.exception class GlobalClass: app = None main_thread_callbacks = collections.deque() is_windows = platform.system().lower() == "windows" def execute_in_main_thread(main_thead_item): print("execute_in_main_thread") GlobalClass.main_thread_callbacks.append(main_thead_item) def _process_app_events() -> Optional[float]: """Process the events of the Qt app if the window is still visible. If the app has any top level windows and at least one of them is visible return the time after which this function should be run again. Else return None, so the function is not run again and will be unregistered. """ while GlobalClass.main_thread_callbacks: main_thread_item = GlobalClass.main_thread_callbacks.popleft() main_thread_item.execute() if main_thread_item.exception is not MainThreadItem.not_set: _clc, val, tb = main_thread_item.exception msg = str(val) detail = "\n".join(traceback.format_exception(_clc, val, tb)) dialog = QtWidgets.QMessageBox( QtWidgets.QMessageBox.Warning, "Error", msg) dialog.setMinimumWidth(500) dialog.setDetailedText(detail) dialog.exec_() # Refresh Manager if GlobalClass.app: manager = GlobalClass.app.get_window("WM_OT_avalon_manager") if manager: manager.refresh() if not GlobalClass.is_windows: if OpenFileCacher.opening_file: return TIMER_INTERVAL app = GlobalClass.app if app._instance: app.processEvents() return TIMER_INTERVAL return TIMER_INTERVAL class LaunchQtApp(bpy.types.Operator): """A Base class for opertors to launch a Qt app.""" _app: QtWidgets.QApplication _window = Union[QtWidgets.QDialog, ModuleType] _tool_name: str = None _init_args: Optional[List] = list() _init_kwargs: Optional[Dict] = dict() bl_idname: str = None def __init__(self): if self.bl_idname is None: raise NotImplementedError("Attribute `bl_idname` must be set!") print(f"Initialising {self.bl_idname}...") self._app = BlenderApplication.get_app() GlobalClass.app = self._app if not bpy.app.timers.is_registered(_process_app_events): bpy.app.timers.register( _process_app_events, persistent=True ) def execute(self, context): """Execute the operator. The child class must implement `execute()` where it only has to set `self._window` to the desired Qt window and then simply run `return super().execute(context)`. `self._window` is expected to have a `show` method. If the `show` method requires arguments, you can set `self._show_args` and `self._show_kwargs`. `args` should be a list, `kwargs` a dictionary. """ if self._tool_name is None: if self._window is None: raise AttributeError("`self._window` is not set.") else: window = self._app.get_window(self.bl_idname) if window is None: window = host_tools.get_tool_by_name(self._tool_name) self._app.store_window(self.bl_idname, window) self._window = window if not isinstance(self._window, (QtWidgets.QWidget, ModuleType)): raise AttributeError( "`window` should be a `QWidget or module`. Got: {}".format( str(type(window)) ) ) self.before_window_show() def pull_to_front(window): """Pull window forward to screen. If Window is minimized this will un-minimize, then it can be raised and activated to the front. """ window.setWindowState( (window.windowState() & ~QtCore.Qt.WindowMinimized) | QtCore.Qt.WindowActive ) window.raise_() window.activateWindow() if isinstance(self._window, ModuleType): self._window.show() pull_to_front(self._window) # Pull window to the front window = None if hasattr(self._window, "window"): window = self._window.window elif hasattr(self._window, "_window"): window = self._window.window if window: self._app.store_window(self.bl_idname, window) else: origin_flags = self._window.windowFlags() on_top_flags = origin_flags | QtCore.Qt.WindowStaysOnTopHint self._window.setWindowFlags(on_top_flags) self._window.show() pull_to_front(self._window) # if on_top_flags != origin_flags: # self._window.setWindowFlags(origin_flags) # self._window.show() return {'FINISHED'} def before_window_show(self): return class LaunchCreator(LaunchQtApp): """Launch Avalon Creator.""" bl_idname = "wm.avalon_creator" bl_label = "Create..." _tool_name = "creator" def before_window_show(self): self._window.refresh() def execute(self, context): host_tools.show_publisher(tab="create") return {"FINISHED"} class LaunchLoader(LaunchQtApp): """Launch Avalon Loader.""" bl_idname = "wm.avalon_loader" bl_label = "Load..." _tool_name = "loader" def before_window_show(self): if AYON_SERVER_ENABLED: return self._window.set_context( {"asset": get_current_asset_name()}, refresh=True ) class LaunchPublisher(LaunchQtApp): """Launch Avalon Publisher.""" bl_idname = "wm.avalon_publisher" bl_label = "Publish..." def execute(self, context): host_tools.show_publisher(tab="publish") return {"FINISHED"} class LaunchManager(LaunchQtApp): """Launch Avalon Manager.""" bl_idname = "wm.avalon_manager" bl_label = "Manage..." _tool_name = "sceneinventory" def before_window_show(self): if AYON_SERVER_ENABLED: return self._window.refresh() class LaunchLibrary(LaunchQtApp): """Launch Library Loader.""" bl_idname = "wm.library_loader" bl_label = "Library..." _tool_name = "libraryloader" def before_window_show(self): if AYON_SERVER_ENABLED: return self._window.refresh() class LaunchWorkFiles(LaunchQtApp): """Launch Avalon Work Files.""" bl_idname = "wm.avalon_workfiles" bl_label = "Work Files..." _tool_name = "workfiles" def execute(self, context): result = super().execute(context) if not AYON_SERVER_ENABLED: self._window.set_context({ "asset": get_current_asset_name(), "task": get_current_task_name() }) return result def before_window_show(self): if AYON_SERVER_ENABLED: return self._window.root = str(Path( os.environ.get("AVALON_WORKDIR", ""), os.environ.get("AVALON_SCENEDIR", ""), )) self._window.refresh() class SetFrameRange(bpy.types.Operator): bl_idname = "wm.ayon_set_frame_range" bl_label = "Set Frame Range" def execute(self, context): data = pipeline.get_asset_data() pipeline.set_frame_range(data) return {"FINISHED"} class SetResolution(bpy.types.Operator): bl_idname = "wm.ayon_set_resolution" bl_label = "Set Resolution" def execute(self, context): data = pipeline.get_asset_data() pipeline.set_resolution(data) return {"FINISHED"} class TOPBAR_MT_avalon(bpy.types.Menu): """Avalon menu.""" bl_idname = "TOPBAR_MT_avalon" bl_label = os.environ.get("AVALON_LABEL") def draw(self, context): """Draw the menu in the UI.""" layout = self.layout pcoll = PREVIEW_COLLECTIONS.get("avalon") if pcoll: pyblish_menu_icon = pcoll["pyblish_menu_icon"] pyblish_menu_icon_id = pyblish_menu_icon.icon_id else: pyblish_menu_icon_id = 0 asset = get_current_asset_name() task = get_current_task_name() context_label = f"{asset}, {task}" context_label_item = layout.row() context_label_item.operator( LaunchWorkFiles.bl_idname, text=context_label ) context_label_item.enabled = False layout.separator() layout.operator(LaunchCreator.bl_idname, text="Create...") layout.operator(LaunchLoader.bl_idname, text="Load...") layout.operator( LaunchPublisher.bl_idname, text="Publish...", icon_value=pyblish_menu_icon_id, ) layout.operator(LaunchManager.bl_idname, text="Manage...") layout.operator(LaunchLibrary.bl_idname, text="Library...") layout.separator() layout.operator(SetFrameRange.bl_idname, text="Set Frame Range") layout.operator(SetResolution.bl_idname, text="Set Resolution") layout.separator() layout.operator(LaunchWorkFiles.bl_idname, text="Work Files...") def draw_avalon_menu(self, context): """Draw the Avalon menu in the top bar.""" self.layout.menu(TOPBAR_MT_avalon.bl_idname) classes = [ LaunchCreator, LaunchLoader, LaunchPublisher, LaunchManager, LaunchLibrary, LaunchWorkFiles, SetFrameRange, SetResolution, TOPBAR_MT_avalon, ] def register(): "Register the operators and menu." pcoll = bpy.utils.previews.new() pyblish_icon_file = Path(__file__).parent / "icons" / "pyblish-32x32.png" pcoll.load("pyblish_menu_icon", str(pyblish_icon_file.absolute()), 'IMAGE') PREVIEW_COLLECTIONS["avalon"] = pcoll BlenderApplication.get_app() for cls in classes: bpy.utils.register_class(cls) bpy.types.TOPBAR_MT_editor_menus.append(draw_avalon_menu) def unregister(): """Unregister the operators and menu.""" pcoll = PREVIEW_COLLECTIONS.pop("avalon") bpy.utils.previews.remove(pcoll) bpy.types.TOPBAR_MT_editor_menus.remove(draw_avalon_menu) for cls in reversed(classes): bpy.utils.unregister_class(cls) ================================================ FILE: openpype/hosts/blender/api/pipeline.py ================================================ import os import sys import traceback from typing import Callable, Dict, Iterator, List, Optional import bpy from . import lib from . import ops import pyblish.api from openpype.host import ( HostBase, IWorkfileHost, IPublishHost, ILoadHost ) from openpype.client import get_asset_by_name from openpype.pipeline import ( schema, legacy_io, get_current_project_name, get_current_asset_name, register_loader_plugin_path, register_creator_plugin_path, deregister_loader_plugin_path, deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) from openpype.lib import ( Logger, register_event_callback, emit_event ) import openpype.hosts.blender from openpype.settings import get_project_settings from .workio import ( open_file, save_file, current_file, has_unsaved_changes, file_extensions, work_root, ) HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.blender.__file__)) PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") ORIGINAL_EXCEPTHOOK = sys.excepthook AVALON_INSTANCES = "AVALON_INSTANCES" AVALON_CONTAINERS = "AVALON_CONTAINERS" AVALON_PROPERTY = 'avalon' IS_HEADLESS = bpy.app.background log = Logger.get_logger(__name__) class BlenderHost(HostBase, IWorkfileHost, IPublishHost, ILoadHost): name = "blender" def install(self): """Override install method from HostBase. Install Blender host functionality.""" install() def get_containers(self) -> Iterator: """List containers from active Blender scene.""" return ls() def get_workfile_extensions(self) -> List[str]: """Override get_workfile_extensions method from IWorkfileHost. Get workfile possible extensions. Returns: List[str]: Workfile extensions. """ return file_extensions() def save_workfile(self, dst_path: str = None): """Override save_workfile method from IWorkfileHost. Save currently opened workfile. Args: dst_path (str): Where the current scene should be saved. Or use current path if `None` is passed. """ save_file(dst_path if dst_path else bpy.data.filepath) def open_workfile(self, filepath: str): """Override open_workfile method from IWorkfileHost. Open workfile at specified filepath in the host. Args: filepath (str): Path to workfile. """ open_file(filepath) def get_current_workfile(self) -> str: """Override get_current_workfile method from IWorkfileHost. Retrieve currently opened workfile path. Returns: str: Path to currently opened workfile. """ return current_file() def workfile_has_unsaved_changes(self) -> bool: """Override wokfile_has_unsaved_changes method from IWorkfileHost. Returns True if opened workfile has no unsaved changes. Returns: bool: True if scene is saved and False if it has unsaved modifications. """ return has_unsaved_changes() def work_root(self, session) -> str: """Override work_root method from IWorkfileHost. Modify workdir per host. Args: session (dict): Session context data. Returns: str: Path to new workdir. """ return work_root(session) def get_context_data(self) -> dict: """Override abstract method from IPublishHost. Get global data related to creation-publishing from workfile. Returns: dict: Context data stored using 'update_context_data'. """ property = bpy.context.scene.get(AVALON_PROPERTY) if property: return property.to_dict() return {} def update_context_data(self, data: dict, changes: dict): """Override abstract method from IPublishHost. Store global context data to workfile. Args: data (dict): New data as are. changes (dict): Only data that has been changed. Each value has tuple with '(, )' value. """ bpy.context.scene[AVALON_PROPERTY] = data def pype_excepthook_handler(*args): traceback.print_exception(*args) def install(): """Install Blender configuration for Avalon.""" sys.excepthook = pype_excepthook_handler pyblish.api.register_host("blender") pyblish.api.register_plugin_path(str(PUBLISH_PATH)) register_loader_plugin_path(str(LOAD_PATH)) register_creator_plugin_path(str(CREATE_PATH)) lib.append_user_scripts() lib.set_app_templates_path() register_event_callback("new", on_new) register_event_callback("open", on_open) _register_callbacks() _register_events() if not IS_HEADLESS: ops.register() def uninstall(): """Uninstall Blender configuration for Avalon.""" sys.excepthook = ORIGINAL_EXCEPTHOOK pyblish.api.deregister_host("blender") pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) deregister_loader_plugin_path(str(LOAD_PATH)) deregister_creator_plugin_path(str(CREATE_PATH)) if not IS_HEADLESS: ops.unregister() def show_message(title, message): from openpype.widgets.message_window import Window from .ops import BlenderApplication BlenderApplication.get_app() Window( parent=None, title=title, message=message, level="warning") def message_window(title, message): from .ops import ( MainThreadItem, execute_in_main_thread, _process_app_events ) mti = MainThreadItem(show_message, title, message) execute_in_main_thread(mti) _process_app_events() def get_asset_data(): project_name = get_current_project_name() asset_name = get_current_asset_name() asset_doc = get_asset_by_name(project_name, asset_name) return asset_doc.get("data") def set_frame_range(data): scene = bpy.context.scene # Default scene settings frameStart = scene.frame_start frameEnd = scene.frame_end fps = scene.render.fps / scene.render.fps_base if not data: return if data.get("frameStart"): frameStart = data.get("frameStart") if data.get("frameEnd"): frameEnd = data.get("frameEnd") if data.get("fps"): fps = data.get("fps") scene.frame_start = frameStart scene.frame_end = frameEnd scene.render.fps = round(fps) scene.render.fps_base = round(fps) / fps def set_resolution(data): scene = bpy.context.scene # Default scene settings resolution_x = scene.render.resolution_x resolution_y = scene.render.resolution_y if not data: return if data.get("resolutionWidth"): resolution_x = data.get("resolutionWidth") if data.get("resolutionHeight"): resolution_y = data.get("resolutionHeight") scene.render.resolution_x = resolution_x scene.render.resolution_y = resolution_y def on_new(): project = os.environ.get("AVALON_PROJECT") settings = get_project_settings(project).get("blender") set_resolution_startup = settings.get("set_resolution_startup") set_frames_startup = settings.get("set_frames_startup") data = get_asset_data() if set_resolution_startup: set_resolution(data) if set_frames_startup: set_frame_range(data) unit_scale_settings = settings.get("unit_scale_settings") unit_scale_enabled = unit_scale_settings.get("enabled") if unit_scale_enabled: unit_scale = unit_scale_settings.get("base_file_unit_scale") bpy.context.scene.unit_settings.scale_length = unit_scale def on_open(): project = os.environ.get("AVALON_PROJECT") settings = get_project_settings(project).get("blender") set_resolution_startup = settings.get("set_resolution_startup") set_frames_startup = settings.get("set_frames_startup") data = get_asset_data() if set_resolution_startup: set_resolution(data) if set_frames_startup: set_frame_range(data) unit_scale_settings = settings.get("unit_scale_settings") unit_scale_enabled = unit_scale_settings.get("enabled") apply_on_opening = unit_scale_settings.get("apply_on_opening") if unit_scale_enabled and apply_on_opening: unit_scale = unit_scale_settings.get("base_file_unit_scale") prev_unit_scale = bpy.context.scene.unit_settings.scale_length if unit_scale != prev_unit_scale: bpy.context.scene.unit_settings.scale_length = unit_scale message_window( "Base file unit scale changed", "Base file unit scale changed to match the project settings.") @bpy.app.handlers.persistent def _on_save_pre(*args): emit_event("before.save") @bpy.app.handlers.persistent def _on_save_post(*args): emit_event("save") @bpy.app.handlers.persistent def _on_load_post(*args): # Detect new file or opening an existing file if bpy.data.filepath: # Likely this was an open operation since it has a filepath emit_event("open") else: emit_event("new") ops.OpenFileCacher.post_load() def _register_callbacks(): """Register callbacks for certain events.""" def _remove_handler(handlers: List, callback: Callable): """Remove the callback from the given handler list.""" try: handlers.remove(callback) except ValueError: pass # TODO (jasper): implement on_init callback? # Be sure to remove existig ones first. _remove_handler(bpy.app.handlers.save_pre, _on_save_pre) _remove_handler(bpy.app.handlers.save_post, _on_save_post) _remove_handler(bpy.app.handlers.load_post, _on_load_post) bpy.app.handlers.save_pre.append(_on_save_pre) bpy.app.handlers.save_post.append(_on_save_post) bpy.app.handlers.load_post.append(_on_load_post) log.info("Installed event handler _on_save_pre...") log.info("Installed event handler _on_save_post...") log.info("Installed event handler _on_load_post...") def _on_task_changed(): """Callback for when the task in the context is changed.""" # TODO (jasper): Blender has no concept of projects or workspace. # It would be nice to override 'bpy.ops.wm.open_mainfile' so it takes the # workdir as starting directory. But I don't know if that is possible. # Another option would be to create a custom 'File Selector' and add the # `directory` attribute, so it opens in that directory (does it?). # https://docs.blender.org/api/blender2.8/bpy.types.Operator.html#calling-a-file-selector # https://docs.blender.org/api/blender2.8/bpy.types.WindowManager.html#bpy.types.WindowManager.fileselect_add workdir = legacy_io.Session["AVALON_WORKDIR"] log.debug("New working directory: %s", workdir) def _register_events(): """Install callbacks for specific events.""" register_event_callback("taskChanged", _on_task_changed) log.info("Installed event callback for 'taskChanged'...") def _discover_gui() -> Optional[Callable]: """Return the most desirable of the currently registered GUIs""" # Prefer last registered guis = reversed(pyblish.api.registered_guis()) for gui in guis: try: gui = __import__(gui).show except (ImportError, AttributeError): continue else: return gui return None def add_to_avalon_container(container: bpy.types.Collection): """Add the container to the Avalon container.""" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) if not avalon_container: avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) # Link the container to the scene so it's easily visible to the artist # and can be managed easily. Otherwise it's only found in "Blender # File" view and it will be removed by Blenders garbage collection, # unless you set a 'fake user'. bpy.context.scene.collection.children.link(avalon_container) avalon_container.children.link(container) # Disable Avalon containers for the view layers. for view_layer in bpy.context.scene.view_layers: for child in view_layer.layer_collection.children: if child.collection == avalon_container: child.exclude = True def metadata_update(node: bpy.types.bpy_struct_meta_idprop, data: Dict): """Imprint the node with metadata. Existing metadata will be updated. """ if not node.get(AVALON_PROPERTY): node[AVALON_PROPERTY] = dict() for key, value in data.items(): if value is None: continue node[AVALON_PROPERTY][key] = value def containerise(name: str, namespace: str, nodes: List, context: Dict, loader: Optional[str] = None, suffix: Optional[str] = "CON") -> bpy.types.Collection: """Bundle `nodes` into an assembly and imprint it with metadata Containerisation enables a tracking of version, author and origin for loaded assets. Arguments: name: Name of resulting assembly namespace: Namespace under which to host container nodes: Long names of nodes to containerise context: Asset information loader: Name of loader used to produce this container. suffix: Suffix of container, defaults to `_CON`. Returns: The container assembly """ node_name = f"{context['asset']['name']}_{name}" if namespace: node_name = f"{namespace}:{node_name}" if suffix: node_name = f"{node_name}_{suffix}" container = bpy.data.collections.new(name=node_name) # Link the children nodes for obj in nodes: container.objects.link(obj) data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace or '', "loader": str(loader), "representation": str(context["representation"]["_id"]), } metadata_update(container, data) add_to_avalon_container(container) return container def containerise_existing( container: bpy.types.Collection, name: str, namespace: str, context: Dict, loader: Optional[str] = None, suffix: Optional[str] = "CON") -> bpy.types.Collection: """Imprint or update container with metadata. Arguments: name: Name of resulting assembly namespace: Namespace under which to host container context: Asset information loader: Name of loader used to produce this container. suffix: Suffix of container, defaults to `_CON`. Returns: The container assembly """ node_name = container.name if suffix: node_name = f"{node_name}_{suffix}" container.name = node_name data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace or '', "loader": str(loader), "representation": str(context["representation"]["_id"]), } metadata_update(container, data) add_to_avalon_container(container) return container def parse_container(container: bpy.types.Collection, validate: bool = True) -> Dict: """Return the container node's full container data. Args: container: A container node name. validate: turn the validation for the container on or off Returns: The container schema data for this container node. """ data = lib.read(container) # Append transient data data["objectName"] = container.name if validate: schema.validate(data) return data def ls() -> Iterator: """List containers from active Blender scene. This is the host-equivalent of api.ls(), but instead of listing assets on disk, it lists assets already loaded in Blender; once loaded they are called containers. """ for container in lib.lsattr("id", AVALON_CONTAINER_ID): yield parse_container(container) def publish(): """Shorthand to publish from within host.""" return pyblish.util.publish() ================================================ FILE: openpype/hosts/blender/api/plugin.py ================================================ """Shared functionality for pipeline plugins for Blender.""" import itertools from pathlib import Path from typing import Dict, List, Optional import bpy from openpype import AYON_SERVER_ENABLED from openpype.pipeline import ( Creator, CreatedInstance, LoaderPlugin, ) from openpype.lib import BoolDef from .pipeline import ( AVALON_CONTAINERS, AVALON_INSTANCES, AVALON_PROPERTY, ) from .ops import ( MainThreadItem, execute_in_main_thread ) from .lib import imprint VALID_EXTENSIONS = [".blend", ".json", ".abc", ".fbx"] def prepare_scene_name( asset: str, subset: str, namespace: Optional[str] = None ) -> str: """Return a consistent name for an asset.""" name = f"{asset}" if namespace: name = f"{name}_{namespace}" name = f"{name}_{subset}" # Blender name for a collection or object cannot be longer than 63 # characters. If the name is longer, it will raise an error. if len(name) > 63: raise ValueError(f"Scene name '{name}' would be too long.") return name def get_unique_number( asset: str, subset: str ) -> str: """Return a unique number based on the asset name.""" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) if not avalon_container: return "01" # Check the names of both object and collection containers obj_asset_groups = avalon_container.objects obj_group_names = { c.name for c in obj_asset_groups if c.type == 'EMPTY' and c.get(AVALON_PROPERTY)} coll_asset_groups = avalon_container.children coll_group_names = { c.name for c in coll_asset_groups if c.get(AVALON_PROPERTY)} container_names = obj_group_names.union(coll_group_names) count = 1 name = f"{asset}_{count:0>2}_{subset}" while name in container_names: count += 1 name = f"{asset}_{count:0>2}_{subset}" return f"{count:0>2}" def prepare_data(data, container_name=None): name = data.name local_data = data.make_local() if container_name: local_data.name = f"{container_name}:{name}" else: local_data.name = f"{name}" return local_data def create_blender_context(active: Optional[bpy.types.Object] = None, selected: Optional[bpy.types.Object] = None, window: Optional[bpy.types.Window] = None): """Create a new Blender context. If an object is passed as parameter, it is set as selected and active. """ if not isinstance(selected, list): selected = [selected] override_context = bpy.context.copy() windows = [window] if window else bpy.context.window_manager.windows for win in windows: for area in win.screen.areas: if area.type == 'VIEW_3D': for region in area.regions: if region.type == 'WINDOW': override_context['window'] = win override_context['screen'] = win.screen override_context['area'] = area override_context['region'] = region override_context['scene'] = bpy.context.scene override_context['active_object'] = active override_context['selected_objects'] = selected return override_context raise Exception("Could not create a custom Blender context.") def get_parent_collection(collection): """Get the parent of the input collection""" check_list = [bpy.context.scene.collection] for c in check_list: if collection.name in c.children.keys(): return c check_list.extend(c.children) return None def get_local_collection_with_name(name): for collection in bpy.data.collections: if collection.name == name and collection.library is None: return collection return None def deselect_all(): """Deselect all objects in the scene. Blender gives context error if trying to deselect object that it isn't in object mode. """ modes = [] active = bpy.context.view_layer.objects.active for obj in bpy.data.objects: if obj.mode != 'OBJECT': modes.append((obj, obj.mode)) bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.select_all(action='DESELECT') for p in modes: bpy.context.view_layer.objects.active = p[0] bpy.ops.object.mode_set(mode=p[1]) bpy.context.view_layer.objects.active = active class BaseCreator(Creator): """Base class for Blender Creator plug-ins.""" defaults = ['Main'] create_as_asset_group = False @staticmethod def cache_subsets(shared_data): """Cache instances for Creators shared data. Create `blender_cached_subsets` key when needed in shared data and fill it with all collected instances from the scene under its respective creator identifiers. If legacy instances are detected in the scene, create `blender_cached_legacy_subsets` key and fill it with all legacy subsets from this family as a value. # key or value? Args: shared_data(Dict[str, Any]): Shared data. Return: Dict[str, Any]: Shared data with cached subsets. """ if not shared_data.get('blender_cached_subsets'): cache = {} cache_legacy = {} avalon_instances = bpy.data.collections.get(AVALON_INSTANCES) avalon_instance_objs = ( avalon_instances.objects if avalon_instances else [] ) for obj_or_col in itertools.chain( avalon_instance_objs, bpy.data.collections ): avalon_prop = obj_or_col.get(AVALON_PROPERTY, {}) if not avalon_prop: continue if avalon_prop.get('id') != 'pyblish.avalon.instance': continue creator_id = avalon_prop.get('creator_identifier') if creator_id: # Creator instance cache.setdefault(creator_id, []).append(obj_or_col) else: family = avalon_prop.get('family') if family: # Legacy creator instance cache_legacy.setdefault(family, []).append(obj_or_col) shared_data["blender_cached_subsets"] = cache shared_data["blender_cached_legacy_subsets"] = cache_legacy return shared_data def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): """Override abstract method from Creator. Create new instance and store it. Args: subset_name(str): Subset name of created instance. instance_data(dict): Instance base data. pre_create_data(dict): Data based on pre creation attributes. Those may affect how creator works. """ # Get Instance Container or create it if it does not exist instances = bpy.data.collections.get(AVALON_INSTANCES) if not instances: instances = bpy.data.collections.new(name=AVALON_INSTANCES) bpy.context.scene.collection.children.link(instances) # Create asset group if AYON_SERVER_ENABLED: asset_name = instance_data["folderPath"].split("/")[-1] else: asset_name = instance_data["asset"] name = prepare_scene_name(asset_name, subset_name) if self.create_as_asset_group: # Create instance as empty instance_node = bpy.data.objects.new(name=name, object_data=None) instance_node.empty_display_type = 'SINGLE_ARROW' instances.objects.link(instance_node) else: # Create instance collection instance_node = bpy.data.collections.new(name=name) instances.children.link(instance_node) self.set_instance_data(subset_name, instance_data) instance = CreatedInstance( self.family, subset_name, instance_data, self ) instance.transient_data["instance_node"] = instance_node self._add_instance_to_context(instance) imprint(instance_node, instance_data) return instance_node def collect_instances(self): """Override abstract method from BaseCreator. Collect existing instances related to this creator plugin.""" # Cache subsets in shared data self.cache_subsets(self.collection_shared_data) # Get cached subsets cached_subsets = self.collection_shared_data.get( "blender_cached_subsets" ) if not cached_subsets: return # Process only instances that were created by this creator for instance_node in cached_subsets.get(self.identifier, []): property = instance_node.get(AVALON_PROPERTY) # Create instance object from existing data instance = CreatedInstance.from_existing( instance_data=property.to_dict(), creator=self ) instance.transient_data["instance_node"] = instance_node # Add instance to create context self._add_instance_to_context(instance) def update_instances(self, update_list): """Override abstract method from BaseCreator. Store changes of existing instances so they can be recollected. Args: update_list(List[UpdateData]): Changed instances and their changes, as a list of tuples. """ if AYON_SERVER_ENABLED: asset_name_key = "folderPath" else: asset_name_key = "asset" for created_instance, changes in update_list: data = created_instance.data_to_store() node = created_instance.transient_data["instance_node"] if not node: # We can't update if we don't know the node self.log.error( f"Unable to update instance {created_instance} " f"without instance node." ) return # Rename the instance node in the scene if subset or asset changed. # Do not rename the instance if the family is workfile, as the # workfile instance is included in the AVALON_CONTAINER collection. if ( "subset" in changes.changed_keys or asset_name_key in changes.changed_keys ) and created_instance.family != "workfile": asset_name = data[asset_name_key] if AYON_SERVER_ENABLED: asset_name = asset_name.split("/")[-1] name = prepare_scene_name( asset=asset_name, subset=data["subset"] ) node.name = name imprint(node, data) def remove_instances(self, instances: List[CreatedInstance]): for instance in instances: node = instance.transient_data["instance_node"] if isinstance(node, bpy.types.Collection): for children in node.children_recursive: if isinstance(children, bpy.types.Collection): bpy.data.collections.remove(children) else: bpy.data.objects.remove(children) bpy.data.collections.remove(node) elif isinstance(node, bpy.types.Object): bpy.data.objects.remove(node) self._remove_instance_from_context(instance) def set_instance_data( self, subset_name: str, instance_data: dict ): """Fill instance data with required items. Args: subset_name(str): Subset name of created instance. instance_data(dict): Instance base data. instance_node(bpy.types.ID): Instance node in blender scene. """ if not instance_data: instance_data = {} instance_data.update( { "id": "pyblish.avalon.instance", "creator_identifier": self.identifier, "subset": subset_name, } ) def get_pre_create_attr_defs(self): return [ BoolDef("use_selection", label="Use selection", default=True) ] class Loader(LoaderPlugin): """Base class for Loader plug-ins.""" hosts = ["blender"] class AssetLoader(LoaderPlugin): """A basic AssetLoader for Blender This will implement the basic logic for linking/appending assets into another Blender scene. The `update` method should be implemented by a sub-class, because it's different for different types (e.g. model, rig, animation, etc.). """ @staticmethod def _get_instance_empty(instance_name: str, nodes: List) -> Optional[bpy.types.Object]: """Get the 'instance empty' that holds the collection instance.""" for node in nodes: if not isinstance(node, bpy.types.Object): continue if (node.type == 'EMPTY' and node.instance_type == 'COLLECTION' and node.instance_collection and node.name == instance_name): return node return None @staticmethod def _get_instance_collection(instance_name: str, nodes: List) -> Optional[bpy.types.Collection]: """Get the 'instance collection' (container) for this asset.""" for node in nodes: if not isinstance(node, bpy.types.Collection): continue if node.name == instance_name: return node return None @staticmethod def _get_library_from_container(container: bpy.types.Collection) -> bpy.types.Library: """Find the library file from the container. It traverses the objects from this collection, checks if there is only 1 library from which the objects come from and returns the library. Warning: No nested collections are supported at the moment! """ assert not container.children, "Nested collections are not supported." assert container.objects, "The collection doesn't contain any objects." libraries = set() for obj in container.objects: assert obj.library, f"'{obj.name}' is not linked." libraries.add(obj.library) assert len( libraries) == 1, "'{container.name}' contains objects from more then 1 library." return list(libraries)[0] def process_asset(self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None): """Must be implemented by a sub-class""" raise NotImplementedError("Must be implemented by a sub-class") def load(self, context: dict, name: Optional[str] = None, namespace: Optional[str] = None, options: Optional[Dict] = None) -> Optional[bpy.types.Collection]: """ Run the loader on Blender main thread""" mti = MainThreadItem(self._load, context, name, namespace, options) execute_in_main_thread(mti) def _load(self, context: dict, name: Optional[str] = None, namespace: Optional[str] = None, options: Optional[Dict] = None ) -> Optional[bpy.types.Collection]: """Load asset via database Arguments: context: Full parenthood of representation to load name: Use pre-defined name namespace: Use pre-defined namespace options: Additional settings dictionary """ # TODO: make it possible to add the asset several times by # just re-using the collection filepath = self.filepath_from_context(context) assert Path(filepath).exists(), f"{filepath} doesn't exist." asset = context["asset"]["name"] subset = context["subset"]["name"] unique_number = get_unique_number( asset, subset ) namespace = namespace or f"{asset}_{unique_number}" name = name or prepare_scene_name( asset, subset, unique_number ) nodes = self.process_asset( context=context, name=name, namespace=namespace, options=options, ) # Only containerise if anything was loaded by the Loader. if not nodes: return None # Only containerise if it's not already a collection from a .blend file. # representation = context["representation"]["name"] # if representation != "blend": # from openpype.hosts.blender.api.pipeline import containerise # return containerise( # name=name, # namespace=namespace, # nodes=nodes, # context=context, # loader=self.__class__.__name__, # ) # asset = context["asset"]["name"] # subset = context["subset"]["name"] # instance_name = prepare_scene_name( # asset, subset, unique_number # ) + '_CON' # return self._get_instance_collection(instance_name, nodes) def exec_update(self, container: Dict, representation: Dict): """Must be implemented by a sub-class""" raise NotImplementedError("Must be implemented by a sub-class") def update(self, container: Dict, representation: Dict): """ Run the update on Blender main thread""" mti = MainThreadItem(self.exec_update, container, representation) execute_in_main_thread(mti) def exec_remove(self, container: Dict) -> bool: """Must be implemented by a sub-class""" raise NotImplementedError("Must be implemented by a sub-class") def remove(self, container: Dict) -> bool: """ Run the remove on Blender main thread""" mti = MainThreadItem(self.exec_remove, container) execute_in_main_thread(mti) ================================================ FILE: openpype/hosts/blender/api/render_lib.py ================================================ from pathlib import Path import bpy from openpype import AYON_SERVER_ENABLED from openpype.settings import get_project_settings from openpype.pipeline import get_current_project_name def get_default_render_folder(settings): """Get default render folder from blender settings.""" return (settings["blender"] ["RenderSettings"] ["default_render_image_folder"]) def get_aov_separator(settings): """Get aov separator from blender settings.""" aov_sep = (settings["blender"] ["RenderSettings"] ["aov_separator"]) if aov_sep == "dash": return "-" elif aov_sep == "underscore": return "_" elif aov_sep == "dot": return "." else: raise ValueError(f"Invalid aov separator: {aov_sep}") def get_image_format(settings): """Get image format from blender settings.""" return (settings["blender"] ["RenderSettings"] ["image_format"]) def get_multilayer(settings): """Get multilayer from blender settings.""" return (settings["blender"] ["RenderSettings"] ["multilayer_exr"]) def get_renderer(settings): """Get renderer from blender settings.""" return (settings["blender"] ["RenderSettings"] ["renderer"]) def get_compositing(settings): """Get compositing from blender settings.""" return (settings["blender"] ["RenderSettings"] ["compositing"]) def get_render_product(output_path, name, aov_sep): """ Generate the path to the render product. Blender interprets the `#` as the frame number, when it renders. Args: file_path (str): The path to the blender scene. render_folder (str): The render folder set in settings. file_name (str): The name of the blender scene. instance (pyblish.api.Instance): The instance to publish. ext (str): The image format to render. """ filepath = output_path / name.lstrip("/") render_product = f"{filepath}{aov_sep}beauty.####" render_product = render_product.replace("\\", "/") return render_product def set_render_format(ext, multilayer): # Set Blender to save the file with the right extension bpy.context.scene.render.use_file_extension = True image_settings = bpy.context.scene.render.image_settings if ext == "exr": image_settings.file_format = ( "OPEN_EXR_MULTILAYER" if multilayer else "OPEN_EXR") elif ext == "bmp": image_settings.file_format = "BMP" elif ext == "rgb": image_settings.file_format = "IRIS" elif ext == "png": image_settings.file_format = "PNG" elif ext == "jpeg": image_settings.file_format = "JPEG" elif ext == "jp2": image_settings.file_format = "JPEG2000" elif ext == "tga": image_settings.file_format = "TARGA" elif ext == "tif": image_settings.file_format = "TIFF" def set_render_passes(settings, renderer): aov_list = set(settings["blender"]["RenderSettings"]["aov_list"]) custom_passes = settings["blender"]["RenderSettings"]["custom_passes"] # Common passes for both renderers vl = bpy.context.view_layer # Data Passes vl.use_pass_combined = "combined" in aov_list vl.use_pass_z = "z" in aov_list vl.use_pass_mist = "mist" in aov_list vl.use_pass_normal = "normal" in aov_list # Light Passes vl.use_pass_diffuse_direct = "diffuse_light" in aov_list vl.use_pass_diffuse_color = "diffuse_color" in aov_list vl.use_pass_glossy_direct = "specular_light" in aov_list vl.use_pass_glossy_color = "specular_color" in aov_list vl.use_pass_emit = "emission" in aov_list vl.use_pass_environment = "environment" in aov_list vl.use_pass_ambient_occlusion = "ao" in aov_list # Cryptomatte Passes vl.use_pass_cryptomatte_object = "cryptomatte_object" in aov_list vl.use_pass_cryptomatte_material = "cryptomatte_material" in aov_list vl.use_pass_cryptomatte_asset = "cryptomatte_asset" in aov_list if renderer == "BLENDER_EEVEE": # Eevee exclusive passes eevee = vl.eevee # Light Passes vl.use_pass_shadow = "shadow" in aov_list eevee.use_pass_volume_direct = "volume_light" in aov_list # Effects Passes eevee.use_pass_bloom = "bloom" in aov_list eevee.use_pass_transparent = "transparent" in aov_list # Cryptomatte Passes vl.use_pass_cryptomatte_accurate = "cryptomatte_accurate" in aov_list elif renderer == "CYCLES": # Cycles exclusive passes cycles = vl.cycles # Data Passes vl.use_pass_position = "position" in aov_list vl.use_pass_vector = "vector" in aov_list vl.use_pass_uv = "uv" in aov_list cycles.denoising_store_passes = "denoising" in aov_list vl.use_pass_object_index = "object_index" in aov_list vl.use_pass_material_index = "material_index" in aov_list cycles.pass_debug_sample_count = "sample_count" in aov_list # Light Passes vl.use_pass_diffuse_indirect = "diffuse_indirect" in aov_list vl.use_pass_glossy_indirect = "specular_indirect" in aov_list vl.use_pass_transmission_direct = "transmission_direct" in aov_list vl.use_pass_transmission_indirect = "transmission_indirect" in aov_list vl.use_pass_transmission_color = "transmission_color" in aov_list cycles.use_pass_volume_direct = "volume_light" in aov_list cycles.use_pass_volume_indirect = "volume_indirect" in aov_list cycles.use_pass_shadow_catcher = "shadow" in aov_list aovs_names = [aov.name for aov in vl.aovs] for cp in custom_passes: cp_name = cp["attribute"] if AYON_SERVER_ENABLED else cp[0] if cp_name not in aovs_names: aov = vl.aovs.add() aov.name = cp_name else: aov = vl.aovs[cp_name] aov.type = (cp["value"] if AYON_SERVER_ENABLED else cp[1].get("type", "VALUE")) return list(aov_list), custom_passes def _create_aov_slot(name, aov_sep, slots, rpass_name, multi_exr, output_path): filename = f"{name}{aov_sep}{rpass_name}.####" slot = slots.new(rpass_name if multi_exr else filename) filepath = str(output_path / filename.lstrip("/")) return slot, filepath def set_node_tree( output_path, render_product, name, aov_sep, ext, multilayer, compositing ): # Set the scene to use the compositor node tree to render bpy.context.scene.use_nodes = True tree = bpy.context.scene.node_tree comp_layer_type = "CompositorNodeRLayers" output_type = "CompositorNodeOutputFile" compositor_type = "CompositorNodeComposite" # Get the Render Layer, Composite and the previous output nodes render_layer_node = None composite_node = None old_output_node = None for node in tree.nodes: if node.bl_idname == comp_layer_type: render_layer_node = node elif node.bl_idname == compositor_type: composite_node = node elif node.bl_idname == output_type and "AYON" in node.name: old_output_node = node if render_layer_node and composite_node and old_output_node: break # If there's not a Render Layers node, we create it if not render_layer_node: render_layer_node = tree.nodes.new(comp_layer_type) # Get the enabled output sockets, that are the active passes for the # render. # We also exclude some layers. exclude_sockets = ["Image", "Alpha", "Noisy Image"] passes = [ socket for socket in render_layer_node.outputs if socket.enabled and socket.name not in exclude_sockets ] # Create a new output node output = tree.nodes.new(output_type) image_settings = bpy.context.scene.render.image_settings output.format.file_format = image_settings.file_format slots = None # In case of a multilayer exr, we don't need to use the output node, # because the blender render already outputs a multilayer exr. multi_exr = ext == "exr" and multilayer slots = output.layer_slots if multi_exr else output.file_slots output.base_path = render_product if multi_exr else str(output_path) slots.clear() aov_file_products = [] old_links = { link.from_socket.name: link for link in tree.links if link.to_node == old_output_node} # Create a new socket for the beauty output pass_name = "rgba" if multi_exr else "beauty" slot, _ = _create_aov_slot( name, aov_sep, slots, pass_name, multi_exr, output_path) tree.links.new(render_layer_node.outputs["Image"], slot) if compositing: # Create a new socket for the composite output pass_name = "composite" comp_socket, filepath = _create_aov_slot( name, aov_sep, slots, pass_name, multi_exr, output_path) aov_file_products.append(("Composite", filepath)) # For each active render pass, we add a new socket to the output node # and link it for rpass in passes: slot, filepath = _create_aov_slot( name, aov_sep, slots, rpass.name, multi_exr, output_path) aov_file_products.append((rpass.name, filepath)) # If the rpass was not connected with the old output node, we connect # it with the new one. if not old_links.get(rpass.name): tree.links.new(rpass, slot) for link in list(old_links.values()): # Check if the socket is still available in the new output node. socket = output.inputs.get(link.to_socket.name) # If it is, we connect it with the new output node. if socket: tree.links.new(link.from_socket, socket) # Then, we remove the old link. tree.links.remove(link) # If there's a composite node, we connect its input with the new output if compositing and composite_node: for link in tree.links: if link.to_node == composite_node: tree.links.new(link.from_socket, comp_socket) break if old_output_node: output.location = old_output_node.location tree.nodes.remove(old_output_node) output.name = "AYON File Output" output.label = "AYON File Output" return [] if multi_exr else aov_file_products def imprint_render_settings(node, data): RENDER_DATA = "render_data" if not node.get(RENDER_DATA): node[RENDER_DATA] = {} for key, value in data.items(): if value is None: continue node[RENDER_DATA][key] = value def prepare_rendering(asset_group): name = asset_group.name filepath = Path(bpy.data.filepath) assert filepath, "Workfile not saved. Please save the file first." dirpath = filepath.parent file_name = Path(filepath.name).stem project = get_current_project_name() settings = get_project_settings(project) render_folder = get_default_render_folder(settings) aov_sep = get_aov_separator(settings) ext = get_image_format(settings) multilayer = get_multilayer(settings) renderer = get_renderer(settings) compositing = get_compositing(settings) set_render_format(ext, multilayer) bpy.context.scene.render.engine = renderer aov_list, custom_passes = set_render_passes(settings, renderer) output_path = Path.joinpath(dirpath, render_folder, file_name) render_product = get_render_product(output_path, name, aov_sep) aov_file_product = set_node_tree( output_path, render_product, name, aov_sep, ext, multilayer, compositing) # Clear the render filepath, so that the output is handled only by the # output node in the compositor. bpy.context.scene.render.filepath = "" render_settings = { "render_folder": render_folder, "aov_separator": aov_sep, "image_format": ext, "multilayer_exr": multilayer, "aov_list": aov_list, "custom_passes": custom_passes, "render_product": render_product, "aov_file_product": aov_file_product, "review": True, } imprint_render_settings(asset_group, render_settings) ================================================ FILE: openpype/hosts/blender/api/workio.py ================================================ """Host API required for Work Files.""" from pathlib import Path from typing import List, Optional import bpy class OpenFileCacher: """Store information about opening file. When file is opening QApplcation events should not be processed. """ opening_file = False @classmethod def post_load(cls): cls.opening_file = False @classmethod def set_opening(cls): cls.opening_file = True def open_file(filepath: str) -> Optional[str]: """Open the scene file in Blender.""" OpenFileCacher.set_opening() preferences = bpy.context.preferences load_ui = preferences.filepaths.use_load_ui use_scripts = preferences.filepaths.use_scripts_auto_execute result = bpy.ops.wm.open_mainfile( filepath=filepath, load_ui=load_ui, use_scripts=use_scripts, ) if result == {'FINISHED'}: return filepath return None def save_file(filepath: str, copy: bool = False) -> Optional[str]: """Save the open scene file.""" preferences = bpy.context.preferences compress = preferences.filepaths.use_file_compression relative_remap = preferences.filepaths.use_relative_paths result = bpy.ops.wm.save_as_mainfile( filepath=filepath, compress=compress, relative_remap=relative_remap, copy=copy, ) if result == {'FINISHED'}: return filepath return None def current_file() -> Optional[str]: """Return the path of the open scene file.""" current_filepath = bpy.data.filepath if Path(current_filepath).is_file(): return current_filepath return None def has_unsaved_changes() -> bool: """Does the open scene file have unsaved changes?""" return bpy.data.is_dirty def file_extensions() -> List[str]: """Return the supported file extensions for Blender scene files.""" return [".blend"] def work_root(session: dict) -> str: """Return the default root to browse for work files.""" work_dir = session["AVALON_WORKDIR"] scene_dir = session.get("AVALON_SCENEDIR") if scene_dir: return str(Path(work_dir, scene_dir)) return work_dir ================================================ FILE: openpype/hosts/blender/blender_addon/startup/init.py ================================================ from openpype.pipeline import install_host from openpype.hosts.blender.api import BlenderHost def register(): install_host(BlenderHost()) def unregister(): pass ================================================ FILE: openpype/hosts/blender/hooks/pre_add_run_python_script_arg.py ================================================ from pathlib import Path from openpype.lib.applications import PreLaunchHook, LaunchTypes class AddPythonScriptToLaunchArgs(PreLaunchHook): """Add python script to be executed before Blender launch.""" # Append after file argument order = 15 app_groups = {"blender"} launch_types = {LaunchTypes.local} def execute(self): if not self.launch_context.data.get("python_scripts"): return # Add path to workfile to arguments for python_script_path in self.launch_context.data["python_scripts"]: self.log.info( f"Adding python script {python_script_path} to launch" ) # Test script path exists python_script_path = Path(python_script_path) if not python_script_path.exists(): self.log.warning( f"Python script {python_script_path} doesn't exist. " "Skipped..." ) continue if "--" in self.launch_context.launch_args: # Insert before separator separator_index = self.launch_context.launch_args.index("--") self.launch_context.launch_args.insert( separator_index, "-P", ) self.launch_context.launch_args.insert( separator_index + 1, python_script_path.as_posix(), ) else: self.launch_context.launch_args.extend( ["-P", python_script_path.as_posix()] ) # Ensure separator if "--" not in self.launch_context.launch_args: self.launch_context.launch_args.append("--") self.launch_context.launch_args.extend( [*self.launch_context.data.get("script_args", [])] ) ================================================ FILE: openpype/hosts/blender/hooks/pre_pyside_install.py ================================================ import os import re import subprocess from platform import system from openpype.lib.applications import PreLaunchHook, LaunchTypes class InstallPySideToBlender(PreLaunchHook): """Install Qt binding to blender's python packages. Prelaunch hook does 2 things: 1.) Blender's python packages are pushed to the beginning of PYTHONPATH. 2.) Check if blender has installed PySide2 and will try to install if not. For pipeline implementation is required to have Qt binding installed in blender's python packages. """ app_groups = {"blender"} launch_types = {LaunchTypes.local} def execute(self): # Prelaunch hook is not crucial try: self.inner_execute() except Exception: self.log.warning( "Processing of {} crashed.".format(self.__class__.__name__), exc_info=True ) def inner_execute(self): # Get blender's python directory version_regex = re.compile(r"^[2-4]\.[0-9]+$") platform = system().lower() executable = self.launch_context.executable.executable_path expected_executable = "blender" if platform == "windows": expected_executable += ".exe" if os.path.basename(executable).lower() != expected_executable: self.log.info(( f"Executable does not lead to {expected_executable} file." "Can't determine blender's python to check/install PySide2." )) return versions_dir = os.path.dirname(executable) if platform == "darwin": versions_dir = os.path.join( os.path.dirname(versions_dir), "Resources" ) version_subfolders = [] for dir_entry in os.scandir(versions_dir): if dir_entry.is_dir() and version_regex.match(dir_entry.name): version_subfolders.append(dir_entry.name) if not version_subfolders: self.log.info( "Didn't find version subfolder next to Blender executable" ) return if len(version_subfolders) > 1: self.log.info(( "Found more than one version subfolder next" " to blender executable. {}" ).format(", ".join([ '"./{}"'.format(name) for name in version_subfolders ]))) return version_subfolder = version_subfolders[0] python_dir = os.path.join(versions_dir, version_subfolder, "python") python_lib = os.path.join(python_dir, "lib") python_version = "python" if platform != "windows": for dir_entry in os.scandir(python_lib): if dir_entry.is_dir() and dir_entry.name.startswith("python"): python_lib = dir_entry.path python_version = dir_entry.name break # Change PYTHONPATH to contain blender's packages as first python_paths = [ python_lib, os.path.join(python_lib, "site-packages"), ] python_path = self.launch_context.env.get("PYTHONPATH") or "" for path in python_path.split(os.pathsep): if path: python_paths.append(path) self.launch_context.env["PYTHONPATH"] = os.pathsep.join(python_paths) # Get blender's python executable python_bin = os.path.join(python_dir, "bin") if platform == "windows": python_executable = os.path.join(python_bin, "python.exe") else: python_executable = os.path.join(python_bin, python_version) # Check for python with enabled 'pymalloc' if not os.path.exists(python_executable): python_executable += "m" if not os.path.exists(python_executable): self.log.warning( "Couldn't find python executable for blender. {}".format( executable ) ) return # Check if PySide2 is installed and skip if yes if self.is_pyside_installed(python_executable): self.log.debug("Blender has already installed PySide2.") return # Install PySide2 in blender's python if platform == "windows": result = self.install_pyside_windows(python_executable) else: result = self.install_pyside(python_executable) if result: self.log.info("Successfully installed PySide2 module to blender.") else: self.log.warning("Failed to install PySide2 module to blender.") def install_pyside_windows(self, python_executable): """Install PySide2 python module to blender's python. Installation requires administration rights that's why it is required to use "pywin32" module which can execute command's and ask for administration rights. """ try: import win32api import win32con import win32process import win32event import pywintypes from win32comext.shell.shell import ShellExecuteEx from win32comext.shell import shellcon except Exception: self.log.warning("Couldn't import \"pywin32\" modules") return try: # Parameters # - use "-m pip" as module pip to install PySide2 and argument # "--ignore-installed" is to force install module to blender's # site-packages and make sure it is binary compatible parameters = "-m pip install --ignore-installed PySide2" # Execute command and ask for administrator's rights process_info = ShellExecuteEx( nShow=win32con.SW_SHOWNORMAL, fMask=shellcon.SEE_MASK_NOCLOSEPROCESS, lpVerb="runas", lpFile=python_executable, lpParameters=parameters, lpDirectory=os.path.dirname(python_executable) ) process_handle = process_info["hProcess"] win32event.WaitForSingleObject(process_handle, win32event.INFINITE) returncode = win32process.GetExitCodeProcess(process_handle) return returncode == 0 except pywintypes.error: pass def install_pyside(self, python_executable): """Install PySide2 python module to blender's python.""" try: # Parameters # - use "-m pip" as module pip to install PySide2 and argument # "--ignore-installed" is to force install module to blender's # site-packages and make sure it is binary compatible args = [ python_executable, "-m", "pip", "install", "--ignore-installed", "PySide2", ] process = subprocess.Popen( args, stdout=subprocess.PIPE, universal_newlines=True ) process.communicate() return process.returncode == 0 except PermissionError: self.log.warning( "Permission denied with command:" "\"{}\".".format(" ".join(args)) ) except OSError as error: self.log.warning(f"OS error has occurred: \"{error}\".") except subprocess.SubprocessError: pass def is_pyside_installed(self, python_executable): """Check if PySide2 module is in blender's pip list. Check that PySide2 is installed directly in blender's site-packages. It is possible that it is installed in user's site-packages but that may be incompatible with blender's python. """ # Get pip list from blender's python executable args = [python_executable, "-m", "pip", "list"] process = subprocess.Popen(args, stdout=subprocess.PIPE) stdout, _ = process.communicate() lines = stdout.decode().split(os.linesep) # Second line contain dashes that define maximum length of module name. # Second column of dashes define maximum length of module version. package_dashes, *_ = lines[1].split(" ") package_len = len(package_dashes) # Got through printed lines starting at line 3 for idx in range(2, len(lines)): line = lines[idx] if not line: continue package_name = line[0:package_len].strip() if package_name.lower() == "pyside2": return True return False ================================================ FILE: openpype/hosts/blender/hooks/pre_windows_console.py ================================================ import subprocess from openpype.lib.applications import PreLaunchHook, LaunchTypes class BlenderConsoleWindows(PreLaunchHook): """Foundry applications have specific way how to launch them. Blender is executed "like" python process so it is required to pass `CREATE_NEW_CONSOLE` flag on windows to trigger creation of new console. At the same time the newly created console won't create it's own stdout and stderr handlers so they should not be redirected to DEVNULL. """ # Should be as last hook because must change launch arguments to string order = 1000 app_groups = {"blender"} platforms = {"windows"} launch_types = {LaunchTypes.local} def execute(self): # Change `creationflags` to CREATE_NEW_CONSOLE # - on Windows will blender create new window using it's console # Set `stdout` and `stderr` to None so new created console does not # have redirected output to DEVNULL in build self.launch_context.kwargs.update({ "creationflags": subprocess.CREATE_NEW_CONSOLE, "stdout": None, "stderr": None }) ================================================ FILE: openpype/hosts/blender/plugins/create/convert_legacy.py ================================================ # -*- coding: utf-8 -*- """Converter for legacy Houdini subsets.""" from openpype.pipeline.create.creator_plugins import SubsetConvertorPlugin from openpype.hosts.blender.api.lib import imprint class BlenderLegacyConvertor(SubsetConvertorPlugin): """Find and convert any legacy subsets in the scene. This Converter will find all legacy subsets in the scene and will transform them to the current system. Since the old subsets doesn't retain any information about their original creators, the only mapping we can do is based on their families. Its limitation is that you can have multiple creators creating subset of the same family and there is no way to handle it. This code should nevertheless cover all creators that came with OpenPype. """ identifier = "io.openpype.creators.blender.legacy" family_to_id = { "action": "io.openpype.creators.blender.action", "camera": "io.openpype.creators.blender.camera", "animation": "io.openpype.creators.blender.animation", "blendScene": "io.openpype.creators.blender.blendscene", "layout": "io.openpype.creators.blender.layout", "model": "io.openpype.creators.blender.model", "pointcache": "io.openpype.creators.blender.pointcache", "render": "io.openpype.creators.blender.render", "review": "io.openpype.creators.blender.review", "rig": "io.openpype.creators.blender.rig", } def __init__(self, *args, **kwargs): super(BlenderLegacyConvertor, self).__init__(*args, **kwargs) self.legacy_subsets = {} def find_instances(self): """Find legacy subsets in the scene. Legacy subsets are the ones that doesn't have `creator_identifier` parameter on them. This is using cached entries done in :py:meth:`~BaseCreator.cache_subsets()` """ self.legacy_subsets = self.collection_shared_data.get( "blender_cached_legacy_subsets") if not self.legacy_subsets: return self.add_convertor_item( "Found {} incompatible subset{}".format( len(self.legacy_subsets), "s" if len(self.legacy_subsets) > 1 else "" ) ) def convert(self): """Convert all legacy subsets to current. It is enough to add `creator_identifier` and `instance_node`. """ if not self.legacy_subsets: return for family, instance_nodes in self.legacy_subsets.items(): if family in self.family_to_id: for instance_node in instance_nodes: creator_identifier = self.family_to_id[family] self.log.info( "Converting {} to {}".format(instance_node.name, creator_identifier) ) imprint(instance_node, data={ "creator_identifier": creator_identifier }) ================================================ FILE: openpype/hosts/blender/plugins/create/create_action.py ================================================ """Create an animation asset.""" import bpy from openpype.hosts.blender.api import lib, plugin class CreateAction(plugin.BaseCreator): """Action output for character rigs.""" identifier = "io.openpype.creators.blender.action" label = "Action" family = "action" icon = "male" def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): # Run parent create method collection = super().create( subset_name, instance_data, pre_create_data ) # Get instance name name = plugin.prepare_scene_name(instance_data["asset"], subset_name) if pre_create_data.get("use_selection"): for obj in lib.get_selection(): if (obj.animation_data is not None and obj.animation_data.action is not None): empty_obj = bpy.data.objects.new(name=name, object_data=None) empty_obj.animation_data_create() empty_obj.animation_data.action = obj.animation_data.action empty_obj.animation_data.action.name = name collection.objects.link(empty_obj) return collection ================================================ FILE: openpype/hosts/blender/plugins/create/create_animation.py ================================================ """Create an animation asset.""" from openpype.hosts.blender.api import plugin, lib class CreateAnimation(plugin.BaseCreator): """Animation output for character rigs.""" identifier = "io.openpype.creators.blender.animation" label = "Animation" family = "animation" icon = "male" def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): # Run parent create method collection = super().create( subset_name, instance_data, pre_create_data ) if pre_create_data.get("use_selection"): selected = lib.get_selection() for obj in selected: collection.objects.link(obj) elif pre_create_data.get("asset_group"): # Use for Load Blend automated creation of animation instances # upon loading rig files obj = pre_create_data.get("asset_group") collection.objects.link(obj) return collection ================================================ FILE: openpype/hosts/blender/plugins/create/create_blendScene.py ================================================ """Create a Blender scene asset.""" import bpy from openpype.hosts.blender.api import plugin, lib class CreateBlendScene(plugin.BaseCreator): """Generic group of assets.""" identifier = "io.openpype.creators.blender.blendscene" label = "Blender Scene" family = "blendScene" icon = "cubes" maintain_selection = False def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): instance_node = super().create(subset_name, instance_data, pre_create_data) if pre_create_data.get("use_selection"): selection = lib.get_selection(include_collections=True) for data in selection: if isinstance(data, bpy.types.Collection): instance_node.children.link(data) elif isinstance(data, bpy.types.Object): instance_node.objects.link(data) return instance_node ================================================ FILE: openpype/hosts/blender/plugins/create/create_camera.py ================================================ """Create a camera asset.""" import bpy from openpype.hosts.blender.api import plugin, lib from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateCamera(plugin.BaseCreator): """Polygonal static geometry.""" identifier = "io.openpype.creators.blender.camera" label = "Camera" family = "camera" icon = "video-camera" create_as_asset_group = True def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): asset_group = super().create(subset_name, instance_data, pre_create_data) bpy.context.view_layer.objects.active = asset_group if pre_create_data.get("use_selection"): for obj in lib.get_selection(): obj.parent = asset_group else: plugin.deselect_all() camera = bpy.data.cameras.new(subset_name) camera_obj = bpy.data.objects.new(subset_name, camera) instances = bpy.data.collections.get(AVALON_INSTANCES) instances.objects.link(camera_obj) bpy.context.view_layer.objects.active = asset_group camera_obj.parent = asset_group return asset_group ================================================ FILE: openpype/hosts/blender/plugins/create/create_layout.py ================================================ """Create a layout asset.""" import bpy from openpype.hosts.blender.api import plugin, lib class CreateLayout(plugin.BaseCreator): """Layout output for character rigs.""" identifier = "io.openpype.creators.blender.layout" label = "Layout" family = "layout" icon = "cubes" create_as_asset_group = True def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): asset_group = super().create(subset_name, instance_data, pre_create_data) # Add selected objects to instance if pre_create_data.get("use_selection"): bpy.context.view_layer.objects.active = asset_group for obj in lib.get_selection(): obj.parent = asset_group return asset_group ================================================ FILE: openpype/hosts/blender/plugins/create/create_model.py ================================================ """Create a model asset.""" import bpy from openpype.hosts.blender.api import plugin, lib class CreateModel(plugin.BaseCreator): """Polygonal static geometry.""" identifier = "io.openpype.creators.blender.model" label = "Model" family = "model" icon = "cube" create_as_asset_group = True def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): asset_group = super().create(subset_name, instance_data, pre_create_data) # Add selected objects to instance if pre_create_data.get("use_selection"): bpy.context.view_layer.objects.active = asset_group for obj in lib.get_selection(): obj.parent = asset_group return asset_group ================================================ FILE: openpype/hosts/blender/plugins/create/create_pointcache.py ================================================ """Create a pointcache asset.""" from openpype.hosts.blender.api import plugin, lib class CreatePointcache(plugin.BaseCreator): """Polygonal static geometry.""" identifier = "io.openpype.creators.blender.pointcache" label = "Point Cache" family = "pointcache" icon = "gears" def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): # Run parent create method collection = super().create( subset_name, instance_data, pre_create_data ) if pre_create_data.get("use_selection"): objects = lib.get_selection() for obj in objects: collection.objects.link(obj) if obj.type == 'EMPTY': objects.extend(obj.children) return collection ================================================ FILE: openpype/hosts/blender/plugins/create/create_render.py ================================================ """Create render.""" import bpy from openpype.lib import version_up from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.render_lib import prepare_rendering from openpype.hosts.blender.api.workio import save_file class CreateRenderlayer(plugin.BaseCreator): """Single baked camera.""" identifier = "io.openpype.creators.blender.render" label = "Render" family = "render" icon = "eye" def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): try: # Run parent create method collection = super().create( subset_name, instance_data, pre_create_data ) prepare_rendering(collection) except Exception: # Remove the instance if there was an error bpy.data.collections.remove(collection) raise # TODO: this is undesiderable, but it's the only way to be sure that # the file is saved before the render starts. # Blender, by design, doesn't set the file as dirty if modifications # happen by script. So, when creating the instance and setting the # render settings, the file is not marked as dirty. This means that # there is the risk of sending to deadline a file without the right # settings. Even the validator to check that the file is saved will # detect the file as saved, even if it isn't. The only solution for # now it is to force the file to be saved. filepath = version_up(bpy.data.filepath) save_file(filepath, copy=False) return collection ================================================ FILE: openpype/hosts/blender/plugins/create/create_review.py ================================================ """Create review.""" from openpype.hosts.blender.api import plugin, lib class CreateReview(plugin.BaseCreator): """Single baked camera.""" identifier = "io.openpype.creators.blender.review" label = "Review" family = "review" icon = "video-camera" def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): # Run parent create method collection = super().create( subset_name, instance_data, pre_create_data ) if pre_create_data.get("use_selection"): selected = lib.get_selection() for obj in selected: collection.objects.link(obj) return collection ================================================ FILE: openpype/hosts/blender/plugins/create/create_rig.py ================================================ """Create a rig asset.""" import bpy from openpype.hosts.blender.api import plugin, lib class CreateRig(plugin.BaseCreator): """Artist-friendly rig with controls to direct motion.""" identifier = "io.openpype.creators.blender.rig" label = "Rig" family = "rig" icon = "wheelchair" create_as_asset_group = True def create( self, subset_name: str, instance_data: dict, pre_create_data: dict ): asset_group = super().create(subset_name, instance_data, pre_create_data) # Add selected objects to instance if pre_create_data.get("use_selection"): bpy.context.view_layer.objects.active = asset_group for obj in lib.get_selection(): obj.parent = asset_group return asset_group ================================================ FILE: openpype/hosts/blender/plugins/create/create_workfile.py ================================================ import bpy from openpype import AYON_SERVER_ENABLED from openpype.pipeline import CreatedInstance, AutoCreator from openpype.client import get_asset_by_name from openpype.hosts.blender.api.plugin import BaseCreator from openpype.hosts.blender.api.pipeline import ( AVALON_PROPERTY, AVALON_CONTAINERS ) class CreateWorkfile(BaseCreator, AutoCreator): """Workfile auto-creator. The workfile instance stores its data on the `AVALON_CONTAINERS` collection as custom attributes, because unlike other instances it doesn't have an instance node of its own. """ identifier = "io.openpype.creators.blender.workfile" label = "Workfile" family = "workfile" icon = "fa5.file" def create(self): """Create workfile instances.""" workfile_instance = next( ( instance for instance in self.create_context.instances if instance.creator_identifier == self.identifier ), None, ) project_name = self.project_name asset_name = self.create_context.get_current_asset_name() task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name existing_asset_name = None if workfile_instance is not None: if AYON_SERVER_ENABLED: existing_asset_name = workfile_instance.get("folderPath") if existing_asset_name is None: existing_asset_name = workfile_instance["asset"] if not workfile_instance: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( task_name, task_name, asset_doc, project_name, host_name ) data = { "task": task_name, "variant": task_name, } if AYON_SERVER_ENABLED: data["folderPath"] = asset_name else: data["asset"] = asset_name data.update( self.get_dynamic_data( task_name, task_name, asset_doc, project_name, host_name, workfile_instance, ) ) self.log.info("Auto-creating workfile instance...") workfile_instance = CreatedInstance( self.family, subset_name, data, self ) self._add_instance_to_context(workfile_instance) elif ( existing_asset_name != asset_name or workfile_instance["task"] != task_name ): # Update instance context if it's different asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( task_name, task_name, asset_doc, project_name, host_name ) if AYON_SERVER_ENABLED: workfile_instance["folderPath"] = asset_name else: workfile_instance["asset"] = asset_name workfile_instance["task"] = task_name workfile_instance["subset"] = subset_name instance_node = bpy.data.collections.get(AVALON_CONTAINERS) if not instance_node: instance_node = bpy.data.collections.new(name=AVALON_CONTAINERS) workfile_instance.transient_data["instance_node"] = instance_node def collect_instances(self): instance_node = bpy.data.collections.get(AVALON_CONTAINERS) if not instance_node: return property = instance_node.get(AVALON_PROPERTY) if not property: return # Create instance object from existing data instance = CreatedInstance.from_existing( instance_data=property.to_dict(), creator=self ) instance.transient_data["instance_node"] = instance_node # Add instance to create context self._add_instance_to_context(instance) def remove_instances(self, instances): for instance in instances: node = instance.transient_data["instance_node"] del node[AVALON_PROPERTY] self._remove_instance_from_context(instance) ================================================ FILE: openpype/hosts/blender/plugins/load/import_workfile.py ================================================ import bpy from openpype.hosts.blender.api import plugin def append_workfile(context, fname, do_import): asset = context['asset']['name'] subset = context['subset']['name'] group_name = plugin.prepare_scene_name(asset, subset) # We need to preserve the original names of the scenes, otherwise, # if there are duplicate names in the current workfile, the imported # scenes will be renamed by Blender to avoid conflicts. original_scene_names = [] with bpy.data.libraries.load(fname) as (data_from, data_to): for attr in dir(data_to): if attr == "scenes": for scene in data_from.scenes: original_scene_names.append(scene) setattr(data_to, attr, getattr(data_from, attr)) current_scene = bpy.context.scene for scene, s_name in zip(data_to.scenes, original_scene_names): scene.name = f"{group_name}_{s_name}" if do_import: collection = bpy.data.collections.new(f"{group_name}_{s_name}") for obj in scene.objects: collection.objects.link(obj) current_scene.collection.children.link(collection) for coll in scene.collection.children: collection.children.link(coll) class AppendBlendLoader(plugin.AssetLoader): """Append workfile in Blender (unmanaged) Warning: The loaded content will be unmanaged and is *not* visible in the scene inventory. It's purely intended to merge content into your scene so you could also use it as a new base. """ representations = ["blend"] families = ["workfile"] label = "Append Workfile" order = 9 icon = "arrow-circle-down" color = "#775555" def load(self, context, name=None, namespace=None, data=None): path = self.filepath_from_context(context) append_workfile(context, path, False) # We do not containerize imported content, it remains unmanaged return class ImportBlendLoader(plugin.AssetLoader): """Import workfile in the current Blender scene (unmanaged) Warning: The loaded content will be unmanaged and is *not* visible in the scene inventory. It's purely intended to merge content into your scene so you could also use it as a new base. """ representations = ["blend"] families = ["workfile"] label = "Import Workfile" order = 9 icon = "arrow-circle-down" color = "#775555" def load(self, context, name=None, namespace=None, data=None): path = self.filepath_from_context(context) append_workfile(context, path, True) # We do not containerize imported content, it remains unmanaged return ================================================ FILE: openpype/hosts/blender/plugins/load/load_abc.py ================================================ """Load an asset in Blender from an Alembic file.""" from pathlib import Path from pprint import pformat from typing import Dict, List, Optional import bpy from openpype.pipeline import ( get_representation_path, AVALON_CONTAINER_ID, ) from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, ) from openpype.hosts.blender.api import plugin, lib class CacheModelLoader(plugin.AssetLoader): """Load cache models. Stores the imported asset in a collection named after the asset. Note: At least for now it only supports Alembic files. """ families = ["model", "pointcache", "animation"] representations = ["abc"] label = "Load Alembic" icon = "code-fork" color = "orange" def _remove(self, asset_group): objects = list(asset_group.children) empties = [] for obj in objects: if obj.type == 'MESH': for material_slot in list(obj.material_slots): bpy.data.materials.remove(material_slot.material) bpy.data.meshes.remove(obj.data) elif obj.type == 'EMPTY': objects.extend(obj.children) empties.append(obj) for empty in empties: bpy.data.objects.remove(empty) def _process(self, libpath, asset_group, group_name): plugin.deselect_all() relative = bpy.context.preferences.filepaths.use_relative_paths bpy.ops.wm.alembic_import( filepath=libpath, relative_path=relative ) imported = lib.get_selection() # Use first EMPTY without parent as container container = next( (obj for obj in imported if obj.type == "EMPTY" and not obj.parent), None ) objects = [] if container: nodes = list(container.children) for obj in nodes: obj.parent = asset_group bpy.data.objects.remove(container) objects.extend(nodes) for obj in nodes: objects.extend(obj.children_recursive) else: for obj in imported: obj.parent = asset_group objects = imported for obj in objects: # Unlink the object from all collections collections = obj.users_collection for collection in collections: collection.objects.unlink(obj) name = obj.name obj.name = f"{group_name}:{name}" if obj.type != 'EMPTY': name_data = obj.data.name obj.data.name = f"{group_name}:{name_data}" for material_slot in obj.material_slots: name_mat = material_slot.material.name material_slot.material.name = f"{group_name}:{name_mat}" if not obj.get(AVALON_PROPERTY): obj[AVALON_PROPERTY] = {} avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": group_name}) plugin.deselect_all() return objects def _link_objects(self, objects, collection, containers, asset_group): # Link the imported objects to any collection where the asset group is # linked to, except the AVALON_CONTAINERS collection group_collections = [ collection for collection in asset_group.users_collection if collection != containers] for obj in objects: for collection in group_collections: collection.objects.link(obj) def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None ) -> Optional[List]: """ Arguments: name: Use pre-defined name namespace: Use pre-defined namespace context: Full parenthood of representation to load options: Additional settings dictionary """ libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" containers = bpy.data.collections.get(AVALON_CONTAINERS) if not containers: containers = bpy.data.collections.new(name=AVALON_CONTAINERS) bpy.context.scene.collection.children.link(containers) asset_group = bpy.data.objects.new(group_name, object_data=None) asset_group.empty_display_type = 'SINGLE_ARROW' containers.objects.link(asset_group) objects = self._process(libpath, asset_group, group_name) # Link the asset group to the active collection collection = bpy.context.view_layer.active_layer_collection.collection collection.objects.link(asset_group) self._link_objects(objects, asset_group, containers, asset_group) asset_group[AVALON_PROPERTY] = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace or '', "loader": str(self.__class__.__name__), "representation": str(context["representation"]["_id"]), "libpath": libpath, "asset_name": asset_name, "parent": str(context["representation"]["parent"]), "family": context["representation"]["context"]["family"], "objectName": group_name } self[:] = objects return objects def exec_update(self, container: Dict, representation: Dict): """Update the loaded asset. This will remove all objects of the current collection, load the new ones and add them to the collection. If the objects of the collection are used in another collection they will not be removed, only unlinked. Normally this should not be the case though. Warning: No nested collections are supported at the moment! """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( "Container: %s\nRepresentation: %s", pformat(container, indent=2), pformat(representation, indent=2), ) assert asset_group, ( f"The asset is not loaded: {container['objectName']}" ) assert libpath, ( "No existing library file found for {container['objectName']}" ) assert libpath.is_file(), ( f"The file doesn't exist: {libpath}" ) assert extension in plugin.VALID_EXTENSIONS, ( f"Unsupported file: {libpath}" ) metadata = asset_group.get(AVALON_PROPERTY) group_libpath = metadata["libpath"] normalized_group_libpath = ( str(Path(bpy.path.abspath(group_libpath)).resolve()) ) normalized_libpath = ( str(Path(bpy.path.abspath(str(libpath))).resolve()) ) self.log.debug( "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", normalized_group_libpath, normalized_libpath, ) if normalized_group_libpath == normalized_libpath: self.log.info("Library already loaded, not updating...") return mat = asset_group.matrix_basis.copy() self._remove(asset_group) objects = self._process(str(libpath), asset_group, object_name) containers = bpy.data.collections.get(AVALON_CONTAINERS) self._link_objects(objects, asset_group, containers, asset_group) asset_group.matrix_basis = mat metadata["libpath"] = str(libpath) metadata["representation"] = str(representation["_id"]) def exec_remove(self, container: Dict) -> bool: """Remove an existing container from a Blender scene. Arguments: container (openpype:container-1.0): Container to remove, from `host.ls()`. Returns: bool: Whether the container was deleted. Warning: No nested collections are supported at the moment! """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) if not asset_group: return False self._remove(asset_group) bpy.data.objects.remove(asset_group) return True ================================================ FILE: openpype/hosts/blender/plugins/load/load_action.py ================================================ """Load an action in Blender.""" import logging from pathlib import Path from pprint import pformat from typing import Dict, List, Optional import bpy from openpype.pipeline import get_representation_path from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( containerise_existing, AVALON_PROPERTY, ) logger = logging.getLogger("openpype").getChild("blender").getChild("load_action") class BlendActionLoader(plugin.AssetLoader): """Load action from a .blend file. Warning: Loading the same asset more then once is not properly supported at the moment. """ families = ["action"] representations = ["blend"] label = "Link Action" icon = "code-fork" color = "orange" def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None ) -> Optional[List]: """ Arguments: name: Use pre-defined name namespace: Use pre-defined namespace context: Full parenthood of representation to load options: Additional settings dictionary """ libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] lib_container = plugin.prepare_scene_name(asset, subset) container_name = plugin.prepare_scene_name( asset, subset, namespace ) container = bpy.data.collections.new(lib_container) container.name = container_name containerise_existing( container, name, namespace, context, self.__class__.__name__, ) container_metadata = container.get(AVALON_PROPERTY) container_metadata["libpath"] = libpath container_metadata["lib_container"] = lib_container relative = bpy.context.preferences.filepaths.use_relative_paths with bpy.data.libraries.load( libpath, link=True, relative=relative ) as (_, data_to): data_to.collections = [lib_container] collection = bpy.context.scene.collection collection.children.link(bpy.data.collections[lib_container]) animation_container = collection.children[lib_container].make_local() objects_list = [] # Link meshes first, then armatures. # The armature is unparented for all the non-local meshes, # when it is made local. for obj in animation_container.objects: obj = obj.make_local() anim_data = obj.animation_data if anim_data is not None and anim_data.action is not None: anim_data.action.make_local() if not obj.get(AVALON_PROPERTY): obj[AVALON_PROPERTY] = dict() avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": container_name}) objects_list.append(obj) animation_container.pop(AVALON_PROPERTY) # Save the list of objects in the metadata container container_metadata["objects"] = objects_list bpy.ops.object.select_all(action='DESELECT') nodes = list(container.objects) nodes.append(container) self[:] = nodes return nodes def update(self, container: Dict, representation: Dict): """Update the loaded asset. This will remove all objects of the current collection, load the new ones and add them to the collection. If the objects of the collection are used in another collection they will not be removed, only unlinked. Normally this should not be the case though. Warning: No nested collections are supported at the moment! """ collection = bpy.data.collections.get( container["objectName"] ) libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() logger.info( "Container: %s\nRepresentation: %s", pformat(container, indent=2), pformat(representation, indent=2), ) assert collection, ( f"The asset is not loaded: {container['objectName']}" ) assert not (collection.children), ( "Nested collections are not supported." ) assert libpath, ( "No existing library file found for {container['objectName']}" ) assert libpath.is_file(), ( f"The file doesn't exist: {libpath}" ) assert extension in plugin.VALID_EXTENSIONS, ( f"Unsupported file: {libpath}" ) collection_metadata = collection.get(AVALON_PROPERTY) collection_libpath = collection_metadata["libpath"] normalized_collection_libpath = ( str(Path(bpy.path.abspath(collection_libpath)).resolve()) ) normalized_libpath = ( str(Path(bpy.path.abspath(str(libpath))).resolve()) ) logger.debug( "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", normalized_collection_libpath, normalized_libpath, ) if normalized_collection_libpath == normalized_libpath: logger.info("Library already loaded, not updating...") return strips = [] for obj in list(collection_metadata["objects"]): # Get all the strips that use the action arm_objs = [ arm for arm in bpy.data.objects if arm.type == 'ARMATURE'] for armature_obj in arm_objs: if armature_obj.animation_data is not None: for track in armature_obj.animation_data.nla_tracks: for strip in track.strips: if strip.action == obj.animation_data.action: strips.append(strip) bpy.data.actions.remove(obj.animation_data.action) bpy.data.objects.remove(obj) lib_container = collection_metadata["lib_container"] bpy.data.collections.remove(bpy.data.collections[lib_container]) relative = bpy.context.preferences.filepaths.use_relative_paths with bpy.data.libraries.load( str(libpath), link=True, relative=relative ) as (_, data_to): data_to.collections = [lib_container] scene = bpy.context.scene scene.collection.children.link(bpy.data.collections[lib_container]) anim_container = scene.collection.children[lib_container].make_local() objects_list = [] # Link meshes first, then armatures. # The armature is unparented for all the non-local meshes, # when it is made local. for obj in anim_container.objects: obj = obj.make_local() anim_data = obj.animation_data if anim_data is not None and anim_data.action is not None: anim_data.action.make_local() for strip in strips: strip.action = anim_data.action strip.action_frame_end = anim_data.action.frame_range[1] if not obj.get(AVALON_PROPERTY): obj[AVALON_PROPERTY] = dict() avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": collection.name}) objects_list.append(obj) anim_container.pop(AVALON_PROPERTY) # Save the list of objects in the metadata container collection_metadata["objects"] = objects_list collection_metadata["libpath"] = str(libpath) collection_metadata["representation"] = str(representation["_id"]) bpy.ops.object.select_all(action='DESELECT') def remove(self, container: Dict) -> bool: """Remove an existing container from a Blender scene. Arguments: container (openpype:container-1.0): Container to remove, from `host.ls()`. Returns: bool: Whether the container was deleted. Warning: No nested collections are supported at the moment! """ collection = bpy.data.collections.get( container["objectName"] ) if not collection: return False assert not (collection.children), ( "Nested collections are not supported." ) collection_metadata = collection.get(AVALON_PROPERTY) objects = collection_metadata["objects"] lib_container = collection_metadata["lib_container"] for obj in list(objects): # Get all the strips that use the action arm_objs = [ arm for arm in bpy.data.objects if arm.type == 'ARMATURE'] for armature_obj in arm_objs: if armature_obj.animation_data is not None: for track in armature_obj.animation_data.nla_tracks: for strip in track.strips: if strip.action == obj.animation_data.action: track.strips.remove(strip) bpy.data.actions.remove(obj.animation_data.action) bpy.data.objects.remove(obj) bpy.data.collections.remove(bpy.data.collections[lib_container]) bpy.data.collections.remove(collection) return True ================================================ FILE: openpype/hosts/blender/plugins/load/load_animation.py ================================================ """Load an animation in Blender.""" from typing import Dict, List, Optional import bpy from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class BlendAnimationLoader(plugin.AssetLoader): """Load animations from a .blend file. Warning: Loading the same asset more then once is not properly supported at the moment. """ families = ["animation"] representations = ["blend"] label = "Link Animation" icon = "code-fork" color = "orange" def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None ) -> Optional[List]: """ Arguments: name: Use pre-defined name namespace: Use pre-defined namespace context: Full parenthood of representation to load options: Additional settings dictionary """ libpath = self.filepath_from_context(context) with bpy.data.libraries.load( libpath, link=True, relative=False ) as (data_from, data_to): data_to.objects = data_from.objects data_to.actions = data_from.actions container = data_to.objects[0] assert container, "No asset group found" target_namespace = container.get(AVALON_PROPERTY).get('namespace') action = data_to.actions[0].make_local().copy() for obj in bpy.data.objects: if obj.get(AVALON_PROPERTY) and obj.get(AVALON_PROPERTY).get( 'namespace') == target_namespace: if obj.children[0]: if not obj.children[0].animation_data: obj.children[0].animation_data_create() obj.children[0].animation_data.action = action break bpy.data.objects.remove(container) filename = bpy.path.basename(libpath) # Blender has a limit of 63 characters for any data name. # If the filename is longer, it will be truncated. if len(filename) > 63: filename = filename[:63] library = bpy.data.libraries.get(filename) bpy.data.libraries.remove(library) ================================================ FILE: openpype/hosts/blender/plugins/load/load_audio.py ================================================ """Load audio in Blender.""" from pathlib import Path from pprint import pformat from typing import Dict, List, Optional import bpy from openpype.pipeline import ( get_representation_path, AVALON_CONTAINER_ID, ) from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, ) class AudioLoader(plugin.AssetLoader): """Load audio in Blender.""" families = ["audio"] representations = ["wav"] label = "Load Audio" icon = "volume-up" color = "orange" def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None ) -> Optional[List]: """ Arguments: name: Use pre-defined name namespace: Use pre-defined namespace context: Full parenthood of representation to load options: Additional settings dictionary """ libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) if not avalon_container: avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) bpy.context.scene.collection.children.link(avalon_container) asset_group = bpy.data.objects.new(group_name, object_data=None) avalon_container.objects.link(asset_group) # Blender needs the Sequence Editor in the current window, to be able # to load the audio. We take one of the areas in the window, save its # type, and switch to the Sequence Editor. After loading the audio, # we switch back to the previous area. window_manager = bpy.context.window_manager old_type = window_manager.windows[-1].screen.areas[0].type window_manager.windows[-1].screen.areas[0].type = "SEQUENCE_EDITOR" # We override the context to load the audio in the sequence editor. oc = bpy.context.copy() oc["area"] = window_manager.windows[-1].screen.areas[0] with bpy.context.temp_override(**oc): bpy.ops.sequencer.sound_strip_add(filepath=libpath, frame_start=1) window_manager.windows[-1].screen.areas[0].type = old_type p = Path(libpath) audio = p.name asset_group[AVALON_PROPERTY] = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace or '', "loader": str(self.__class__.__name__), "representation": str(context["representation"]["_id"]), "libpath": libpath, "asset_name": asset_name, "parent": str(context["representation"]["parent"]), "family": context["representation"]["context"]["family"], "objectName": group_name, "audio": audio } objects = [] self[:] = objects return [objects] def exec_update(self, container: Dict, representation: Dict): """Update an audio strip in the sequence editor. Arguments: container (openpype:container-1.0): Container to update, from `host.ls()`. representation (openpype:representation-1.0): Representation to update, from `host.ls()`. """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) libpath = Path(get_representation_path(representation)) self.log.info( "Container: %s\nRepresentation: %s", pformat(container, indent=2), pformat(representation, indent=2), ) assert asset_group, ( f"The asset is not loaded: {container['objectName']}" ) assert libpath, ( "No existing library file found for {container['objectName']}" ) assert libpath.is_file(), ( f"The file doesn't exist: {libpath}" ) metadata = asset_group.get(AVALON_PROPERTY) group_libpath = metadata["libpath"] normalized_group_libpath = ( str(Path(bpy.path.abspath(group_libpath)).resolve()) ) normalized_libpath = ( str(Path(bpy.path.abspath(str(libpath))).resolve()) ) self.log.debug( "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", normalized_group_libpath, normalized_libpath, ) if normalized_group_libpath == normalized_libpath: self.log.info("Library already loaded, not updating...") return old_audio = container["audio"] p = Path(libpath) new_audio = p.name # Blender needs the Sequence Editor in the current window, to be able # to update the audio. We take one of the areas in the window, save its # type, and switch to the Sequence Editor. After updating the audio, # we switch back to the previous area. window_manager = bpy.context.window_manager old_type = window_manager.windows[-1].screen.areas[0].type window_manager.windows[-1].screen.areas[0].type = "SEQUENCE_EDITOR" # We override the context to load the audio in the sequence editor. oc = bpy.context.copy() oc["area"] = window_manager.windows[-1].screen.areas[0] with bpy.context.temp_override(**oc): # We deselect all sequencer strips, and then select the one we # need to remove. bpy.ops.sequencer.select_all(action='DESELECT') scene = bpy.context.scene scene.sequence_editor.sequences_all[old_audio].select = True bpy.ops.sequencer.delete() bpy.data.sounds.remove(bpy.data.sounds[old_audio]) bpy.ops.sequencer.sound_strip_add( filepath=str(libpath), frame_start=1) window_manager.windows[-1].screen.areas[0].type = old_type metadata["libpath"] = str(libpath) metadata["representation"] = str(representation["_id"]) metadata["parent"] = str(representation["parent"]) metadata["audio"] = new_audio def exec_remove(self, container: Dict) -> bool: """Remove an audio strip from the sequence editor and the container. Arguments: container (openpype:container-1.0): Container to remove, from `host.ls()`. Returns: bool: Whether the container was deleted. """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) if not asset_group: return False audio = container["audio"] # Blender needs the Sequence Editor in the current window, to be able # to remove the audio. We take one of the areas in the window, save its # type, and switch to the Sequence Editor. After removing the audio, # we switch back to the previous area. window_manager = bpy.context.window_manager old_type = window_manager.windows[-1].screen.areas[0].type window_manager.windows[-1].screen.areas[0].type = "SEQUENCE_EDITOR" # We override the context to load the audio in the sequence editor. oc = bpy.context.copy() oc["area"] = window_manager.windows[-1].screen.areas[0] with bpy.context.temp_override(**oc): # We deselect all sequencer strips, and then select the one we # need to remove. bpy.ops.sequencer.select_all(action='DESELECT') scene = bpy.context.scene scene.sequence_editor.sequences_all[audio].select = True bpy.ops.sequencer.delete() window_manager.windows[-1].screen.areas[0].type = old_type bpy.data.sounds.remove(bpy.data.sounds[audio]) bpy.data.objects.remove(asset_group) return True ================================================ FILE: openpype/hosts/blender/plugins/load/load_blend.py ================================================ from typing import Dict, List, Optional from pathlib import Path import bpy from openpype.pipeline import ( get_representation_path, AVALON_CONTAINER_ID, registered_host ) from openpype.pipeline.create import CreateContext from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.lib import imprint from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, ) class BlendLoader(plugin.AssetLoader): """Load assets from a .blend file.""" families = ["model", "rig", "layout", "camera"] representations = ["blend"] label = "Append Blend" icon = "code-fork" color = "orange" @staticmethod def _get_asset_container(objects): empties = [obj for obj in objects if obj.type == 'EMPTY'] for empty in empties: if empty.get(AVALON_PROPERTY) and empty.parent is None: return empty return None @staticmethod def get_all_container_parents(asset_group): parent_containers = [] parent = asset_group.parent while parent: if parent.get(AVALON_PROPERTY): parent_containers.append(parent) parent = parent.parent return parent_containers def _post_process_layout(self, container, asset, representation): rigs = [ obj for obj in container.children_recursive if ( obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY) and obj.get(AVALON_PROPERTY).get('family') == 'rig' ) ] if not rigs: return # Create animation instances for each rig creator_identifier = "io.openpype.creators.blender.animation" host = registered_host() create_context = CreateContext(host) for rig in rigs: create_context.create( creator_identifier=creator_identifier, variant=rig.name.split(':')[-1], pre_create_data={ "use_selection": False, "asset_group": rig } ) def _process_data(self, libpath, group_name): # Append all the data from the .blend file with bpy.data.libraries.load( libpath, link=False, relative=False ) as (data_from, data_to): for attr in dir(data_to): setattr(data_to, attr, getattr(data_from, attr)) members = [] # Rename the object to add the asset name for attr in dir(data_to): for data in getattr(data_to, attr): data.name = f"{group_name}:{data.name}" members.append(data) container = self._get_asset_container(data_to.objects) assert container, "No asset group found" container.name = group_name container.empty_display_type = 'SINGLE_ARROW' # Link the collection to the scene bpy.context.scene.collection.objects.link(container) # Link all the container children to the collection for obj in container.children_recursive: bpy.context.scene.collection.objects.link(obj) # Remove the library from the blend file filepath = bpy.path.basename(libpath) # Blender has a limit of 63 characters for any data name. # If the filepath is longer, it will be truncated. if len(filepath) > 63: filepath = filepath[:63] library = bpy.data.libraries.get(filepath) bpy.data.libraries.remove(library) return container, members def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None ) -> Optional[List]: """ Arguments: name: Use pre-defined name namespace: Use pre-defined namespace context: Full parenthood of representation to load options: Additional settings dictionary """ libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] try: family = context["representation"]["context"]["family"] except ValueError: family = "model" representation = str(context["representation"]["_id"]) asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) if not avalon_container: avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) bpy.context.scene.collection.children.link(avalon_container) container, members = self._process_data(libpath, group_name) if family == "layout": self._post_process_layout(container, asset, representation) avalon_container.objects.link(container) data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace or '', "loader": str(self.__class__.__name__), "representation": str(context["representation"]["_id"]), "libpath": libpath, "asset_name": asset_name, "parent": str(context["representation"]["parent"]), "family": context["representation"]["context"]["family"], "objectName": group_name, "members": members, } container[AVALON_PROPERTY] = data objects = [ obj for obj in bpy.data.objects if obj.name.startswith(f"{group_name}:") ] self[:] = objects return objects def exec_update(self, container: Dict, representation: Dict): """ Update the loaded asset. """ group_name = container["objectName"] asset_group = bpy.data.objects.get(group_name) libpath = Path(get_representation_path(representation)).as_posix() assert asset_group, ( f"The asset is not loaded: {container['objectName']}" ) transform = asset_group.matrix_basis.copy() old_data = dict(asset_group.get(AVALON_PROPERTY)) old_members = old_data.get("members", []) parent = asset_group.parent actions = {} objects_with_anim = [ obj for obj in asset_group.children_recursive if obj.animation_data] for obj in objects_with_anim: # Check if the object has an action and, if so, add it to a dict # so we can restore it later. Save and restore the action only # if it wasn't originally loaded from the current asset. if obj.animation_data.action not in old_members: actions[obj.name] = obj.animation_data.action self.exec_remove(container) asset_group, members = self._process_data(libpath, group_name) avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) avalon_container.objects.link(asset_group) asset_group.matrix_basis = transform asset_group.parent = parent # Restore the actions for obj in asset_group.children_recursive: if obj.name in actions: if not obj.animation_data: obj.animation_data_create() obj.animation_data.action = actions[obj.name] # Restore the old data, but reset memebers, as they don't exist anymore # This avoids a crash, because the memory addresses of those members # are not valid anymore old_data["members"] = [] asset_group[AVALON_PROPERTY] = old_data new_data = { "libpath": libpath, "representation": str(representation["_id"]), "parent": str(representation["parent"]), "members": members, } imprint(asset_group, new_data) # We need to update all the parent container members parent_containers = self.get_all_container_parents(asset_group) for parent_container in parent_containers: parent_members = parent_container[AVALON_PROPERTY]["members"] parent_container[AVALON_PROPERTY]["members"] = ( parent_members + members) def exec_remove(self, container: Dict) -> bool: """ Remove an existing container from a Blender scene. """ group_name = container["objectName"] asset_group = bpy.data.objects.get(group_name) attrs = [ attr for attr in dir(bpy.data) if isinstance( getattr(bpy.data, attr), bpy.types.bpy_prop_collection ) ] members = asset_group.get(AVALON_PROPERTY).get("members", []) # We need to update all the parent container members parent_containers = self.get_all_container_parents(asset_group) for parent in parent_containers: parent.get(AVALON_PROPERTY)["members"] = list(filter( lambda i: i not in members, parent.get(AVALON_PROPERTY).get("members", []))) for attr in attrs: for data in getattr(bpy.data, attr): if data in members: # Skip the asset group if data == asset_group: continue getattr(bpy.data, attr).remove(data) bpy.data.objects.remove(asset_group) ================================================ FILE: openpype/hosts/blender/plugins/load/load_blendscene.py ================================================ from typing import Dict, List, Optional from pathlib import Path import bpy from openpype.pipeline import ( get_representation_path, AVALON_CONTAINER_ID, ) from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.lib import imprint from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, ) class BlendSceneLoader(plugin.AssetLoader): """Load assets from a .blend file.""" families = ["blendScene"] representations = ["blend"] label = "Append Blend" icon = "code-fork" color = "orange" @staticmethod def _get_asset_container(collections): for coll in collections: parents = [c for c in collections if c.user_of_id(coll)] if coll.get(AVALON_PROPERTY) and not parents: return coll return None def _process_data(self, libpath, group_name, family): # Append all the data from the .blend file with bpy.data.libraries.load( libpath, link=False, relative=False ) as (data_from, data_to): for attr in dir(data_to): setattr(data_to, attr, getattr(data_from, attr)) members = [] # Rename the object to add the asset name for attr in dir(data_to): for data in getattr(data_to, attr): data.name = f"{group_name}:{data.name}" members.append(data) container = self._get_asset_container( data_to.collections) assert container, "No asset group found" container.name = group_name # Link the group to the scene bpy.context.scene.collection.children.link(container) # Remove the library from the blend file filepath = bpy.path.basename(libpath) # Blender has a limit of 63 characters for any data name. # If the filepath is longer, it will be truncated. if len(filepath) > 63: filepath = filepath[:63] library = bpy.data.libraries.get(filepath) bpy.data.libraries.remove(library) return container, members def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None ) -> Optional[List]: """ Arguments: name: Use pre-defined name namespace: Use pre-defined namespace context: Full parenthood of representation to load options: Additional settings dictionary """ libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] try: family = context["representation"]["context"]["family"] except ValueError: family = "model" asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) if not avalon_container: avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) bpy.context.scene.collection.children.link(avalon_container) container, members = self._process_data(libpath, group_name, family) avalon_container.children.link(container) data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace or '', "loader": str(self.__class__.__name__), "representation": str(context["representation"]["_id"]), "libpath": libpath, "asset_name": asset_name, "parent": str(context["representation"]["parent"]), "family": context["representation"]["context"]["family"], "objectName": group_name, "members": members, } container[AVALON_PROPERTY] = data objects = [ obj for obj in bpy.data.objects if obj.name.startswith(f"{group_name}:") ] self[:] = objects return objects def exec_update(self, container: Dict, representation: Dict): """ Update the loaded asset. """ group_name = container["objectName"] asset_group = bpy.data.collections.get(group_name) libpath = Path(get_representation_path(representation)).as_posix() assert asset_group, ( f"The asset is not loaded: {container['objectName']}" ) # Get the parents of the members of the asset group, so we can # re-link them after the update. # Also gets the transform for each object to reapply after the update. collection_parents = {} member_transforms = {} members = asset_group.get(AVALON_PROPERTY).get("members", []) loaded_collections = {c for c in bpy.data.collections if c in members} loaded_collections.add(bpy.data.collections.get(AVALON_CONTAINERS)) for member in members: if isinstance(member, bpy.types.Object): member_parents = set(member.users_collection) member_transforms[member.name] = member.matrix_basis.copy() elif isinstance(member, bpy.types.Collection): member_parents = { c for c in bpy.data.collections if c.user_of_id(member)} else: continue member_parents = member_parents.difference(loaded_collections) if member_parents: collection_parents[member.name] = list(member_parents) old_data = dict(asset_group.get(AVALON_PROPERTY)) self.exec_remove(container) family = container["family"] asset_group, members = self._process_data(libpath, group_name, family) for member in members: if member.name in collection_parents: for parent in collection_parents[member.name]: if isinstance(member, bpy.types.Object): parent.objects.link(member) elif isinstance(member, bpy.types.Collection): parent.children.link(member) if member.name in member_transforms and isinstance( member, bpy.types.Object ): member.matrix_basis = member_transforms[member.name] avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) avalon_container.children.link(asset_group) # Restore the old data, but reset members, as they don't exist anymore # This avoids a crash, because the memory addresses of those members # are not valid anymore old_data["members"] = [] asset_group[AVALON_PROPERTY] = old_data new_data = { "libpath": libpath, "representation": str(representation["_id"]), "parent": str(representation["parent"]), "members": members, } imprint(asset_group, new_data) def exec_remove(self, container: Dict) -> bool: """ Remove an existing container from a Blender scene. """ group_name = container["objectName"] asset_group = bpy.data.collections.get(group_name) members = set(asset_group.get(AVALON_PROPERTY).get("members", [])) if members: for attr_name in dir(bpy.data): attr = getattr(bpy.data, attr_name) if not isinstance(attr, bpy.types.bpy_prop_collection): continue # ensure to make a list copy because we # we remove members as we iterate for data in list(attr): if data not in members or data == asset_group: continue attr.remove(data) bpy.data.collections.remove(asset_group) ================================================ FILE: openpype/hosts/blender/plugins/load/load_camera_abc.py ================================================ """Load an asset in Blender from an Alembic file.""" from pathlib import Path from pprint import pformat from typing import Dict, List, Optional import bpy from openpype.pipeline import ( get_representation_path, AVALON_CONTAINER_ID, ) from openpype.hosts.blender.api import plugin, lib from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, ) class AbcCameraLoader(plugin.AssetLoader): """Load a camera from Alembic file. Stores the imported asset in an empty named after the asset. """ families = ["camera"] representations = ["abc"] label = "Load Camera (ABC)" icon = "code-fork" color = "orange" def _remove(self, asset_group): objects = list(asset_group.children) for obj in objects: if obj.type == "CAMERA": bpy.data.cameras.remove(obj.data) elif obj.type == "EMPTY": objects.extend(obj.children) bpy.data.objects.remove(obj) def _process(self, libpath, asset_group, group_name): plugin.deselect_all() bpy.ops.wm.alembic_import(filepath=libpath) objects = lib.get_selection() for obj in objects: obj.parent = asset_group for obj in objects: name = obj.name obj.name = f"{group_name}:{name}" if obj.type != "EMPTY": name_data = obj.data.name obj.data.name = f"{group_name}:{name_data}" if not obj.get(AVALON_PROPERTY): obj[AVALON_PROPERTY] = dict() avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": group_name}) plugin.deselect_all() return objects def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None, ) -> Optional[List]: """ Arguments: name: Use pre-defined name namespace: Use pre-defined namespace context: Full parenthood of representation to load options: Additional settings dictionary """ libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) if not avalon_container: avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) bpy.context.scene.collection.children.link(avalon_container) asset_group = bpy.data.objects.new(group_name, object_data=None) avalon_container.objects.link(asset_group) self._process(libpath, asset_group, group_name) objects = [] nodes = list(asset_group.children) for obj in nodes: objects.append(obj) nodes.extend(list(obj.children)) bpy.context.scene.collection.objects.link(asset_group) asset_group[AVALON_PROPERTY] = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace or "", "loader": str(self.__class__.__name__), "representation": str(context["representation"]["_id"]), "libpath": libpath, "asset_name": asset_name, "parent": str(context["representation"]["parent"]), "family": context["representation"]["context"]["family"], "objectName": group_name, } self[:] = objects return objects def exec_update(self, container: Dict, representation: Dict): """Update the loaded asset. This will remove all objects of the current collection, load the new ones and add them to the collection. If the objects of the collection are used in another collection they will not be removed, only unlinked. Normally this should not be the case though. Warning: No nested collections are supported at the moment! """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( "Container: %s\nRepresentation: %s", pformat(container, indent=2), pformat(representation, indent=2), ) assert asset_group, ( f"The asset is not loaded: {container['objectName']}") assert libpath, ( f"No existing library file found for {container['objectName']}") assert libpath.is_file(), f"The file doesn't exist: {libpath}" assert extension in plugin.VALID_EXTENSIONS, ( f"Unsupported file: {libpath}") metadata = asset_group.get(AVALON_PROPERTY) group_libpath = metadata["libpath"] normalized_group_libpath = str( Path(bpy.path.abspath(group_libpath)).resolve()) normalized_libpath = str( Path(bpy.path.abspath(str(libpath))).resolve()) self.log.debug( "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", normalized_group_libpath, normalized_libpath, ) if normalized_group_libpath == normalized_libpath: self.log.info("Library already loaded, not updating...") return mat = asset_group.matrix_basis.copy() self._remove(asset_group) self._process(str(libpath), asset_group, object_name) asset_group.matrix_basis = mat metadata["libpath"] = str(libpath) metadata["representation"] = str(representation["_id"]) def exec_remove(self, container: Dict) -> bool: """Remove an existing container from a Blender scene. Arguments: container (openpype:container-1.0): Container to remove, from `host.ls()`. Returns: bool: Whether the container was deleted. Warning: No nested collections are supported at the moment! """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) if not asset_group: return False self._remove(asset_group) bpy.data.objects.remove(asset_group) return True ================================================ FILE: openpype/hosts/blender/plugins/load/load_camera_fbx.py ================================================ """Load an asset in Blender from an Alembic file.""" from pathlib import Path from pprint import pformat from typing import Dict, List, Optional import bpy from openpype.pipeline import ( get_representation_path, AVALON_CONTAINER_ID, ) from openpype.hosts.blender.api import plugin, lib from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, ) class FbxCameraLoader(plugin.AssetLoader): """Load a camera from FBX. Stores the imported asset in an empty named after the asset. """ families = ["camera"] representations = ["fbx"] label = "Load Camera (FBX)" icon = "code-fork" color = "orange" def _remove(self, asset_group): objects = list(asset_group.children) for obj in objects: if obj.type == 'CAMERA': bpy.data.cameras.remove(obj.data) elif obj.type == 'EMPTY': objects.extend(obj.children) bpy.data.objects.remove(obj) def _process(self, libpath, asset_group, group_name): plugin.deselect_all() collection = bpy.context.view_layer.active_layer_collection.collection bpy.ops.import_scene.fbx(filepath=libpath) parent = bpy.context.scene.collection objects = lib.get_selection() for obj in objects: obj.parent = asset_group for obj in objects: parent.objects.link(obj) collection.objects.unlink(obj) for obj in objects: name = obj.name obj.name = f"{group_name}:{name}" if obj.type != 'EMPTY': name_data = obj.data.name obj.data.name = f"{group_name}:{name_data}" if not obj.get(AVALON_PROPERTY): obj[AVALON_PROPERTY] = dict() avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": group_name}) plugin.deselect_all() return objects def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None ) -> Optional[List]: """ Arguments: name: Use pre-defined name namespace: Use pre-defined namespace context: Full parenthood of representation to load options: Additional settings dictionary """ libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) if not avalon_container: avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) bpy.context.scene.collection.children.link(avalon_container) asset_group = bpy.data.objects.new(group_name, object_data=None) avalon_container.objects.link(asset_group) self._process(libpath, asset_group, group_name) objects = [] nodes = list(asset_group.children) for obj in nodes: objects.append(obj) nodes.extend(list(obj.children)) bpy.context.scene.collection.objects.link(asset_group) asset_group[AVALON_PROPERTY] = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace or '', "loader": str(self.__class__.__name__), "representation": str(context["representation"]["_id"]), "libpath": libpath, "asset_name": asset_name, "parent": str(context["representation"]["parent"]), "family": context["representation"]["context"]["family"], "objectName": group_name } self[:] = objects return objects def exec_update(self, container: Dict, representation: Dict): """Update the loaded asset. This will remove all objects of the current collection, load the new ones and add them to the collection. If the objects of the collection are used in another collection they will not be removed, only unlinked. Normally this should not be the case though. Warning: No nested collections are supported at the moment! """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( "Container: %s\nRepresentation: %s", pformat(container, indent=2), pformat(representation, indent=2), ) assert asset_group, ( f"The asset is not loaded: {container['objectName']}" ) assert libpath, ( "No existing library file found for {container['objectName']}" ) assert libpath.is_file(), ( f"The file doesn't exist: {libpath}" ) assert extension in plugin.VALID_EXTENSIONS, ( f"Unsupported file: {libpath}" ) metadata = asset_group.get(AVALON_PROPERTY) group_libpath = metadata["libpath"] normalized_group_libpath = ( str(Path(bpy.path.abspath(group_libpath)).resolve()) ) normalized_libpath = ( str(Path(bpy.path.abspath(str(libpath))).resolve()) ) self.log.debug( "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", normalized_group_libpath, normalized_libpath, ) if normalized_group_libpath == normalized_libpath: self.log.info("Library already loaded, not updating...") return mat = asset_group.matrix_basis.copy() self._remove(asset_group) self._process(str(libpath), asset_group, object_name) asset_group.matrix_basis = mat metadata["libpath"] = str(libpath) metadata["representation"] = str(representation["_id"]) def exec_remove(self, container: Dict) -> bool: """Remove an existing container from a Blender scene. Arguments: container (openpype:container-1.0): Container to remove, from `host.ls()`. Returns: bool: Whether the container was deleted. Warning: No nested collections are supported at the moment! """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) if not asset_group: return False self._remove(asset_group) bpy.data.objects.remove(asset_group) return True ================================================ FILE: openpype/hosts/blender/plugins/load/load_fbx.py ================================================ """Load an asset in Blender from an Alembic file.""" from pathlib import Path from pprint import pformat from typing import Dict, List, Optional import bpy from openpype.pipeline import ( get_representation_path, AVALON_CONTAINER_ID, ) from openpype.hosts.blender.api import plugin, lib from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, ) class FbxModelLoader(plugin.AssetLoader): """Load FBX models. Stores the imported asset in an empty named after the asset. """ families = ["model", "rig"] representations = ["fbx"] label = "Load FBX" icon = "code-fork" color = "orange" def _remove(self, asset_group): objects = list(asset_group.children) for obj in objects: if obj.type == 'MESH': for material_slot in list(obj.material_slots): if material_slot.material: bpy.data.materials.remove(material_slot.material) bpy.data.meshes.remove(obj.data) elif obj.type == 'ARMATURE': objects.extend(obj.children) bpy.data.armatures.remove(obj.data) elif obj.type == 'CURVE': bpy.data.curves.remove(obj.data) elif obj.type == 'EMPTY': objects.extend(obj.children) bpy.data.objects.remove(obj) def _process(self, libpath, asset_group, group_name, action): plugin.deselect_all() collection = bpy.context.view_layer.active_layer_collection.collection bpy.ops.import_scene.fbx(filepath=libpath) parent = bpy.context.scene.collection imported = lib.get_selection() empties = [obj for obj in imported if obj.type == 'EMPTY'] container = None for empty in empties: if not empty.parent: container = empty break assert container, "No asset group found" # Children must be linked before parents, # otherwise the hierarchy will break objects = [] nodes = list(container.children) for obj in nodes: obj.parent = asset_group bpy.data.objects.remove(container) for obj in nodes: objects.append(obj) nodes.extend(list(obj.children)) objects.reverse() for obj in objects: parent.objects.link(obj) collection.objects.unlink(obj) for obj in objects: name = obj.name obj.name = f"{group_name}:{name}" if obj.type != 'EMPTY': name_data = obj.data.name obj.data.name = f"{group_name}:{name_data}" if obj.type == 'MESH': for material_slot in obj.material_slots: name_mat = material_slot.material.name material_slot.material.name = f"{group_name}:{name_mat}" elif obj.type == 'ARMATURE': anim_data = obj.animation_data if action is not None: anim_data.action = action elif anim_data.action is not None: name_action = anim_data.action.name anim_data.action.name = f"{group_name}:{name_action}" if not obj.get(AVALON_PROPERTY): obj[AVALON_PROPERTY] = dict() avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": group_name}) plugin.deselect_all() return objects def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None ) -> Optional[List]: """ Arguments: name: Use pre-defined name namespace: Use pre-defined namespace context: Full parenthood of representation to load options: Additional settings dictionary """ libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) if not avalon_container: avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) bpy.context.scene.collection.children.link(avalon_container) asset_group = bpy.data.objects.new(group_name, object_data=None) avalon_container.objects.link(asset_group) objects = self._process(libpath, asset_group, group_name, None) objects = [] nodes = list(asset_group.children) for obj in nodes: objects.append(obj) nodes.extend(list(obj.children)) bpy.context.scene.collection.objects.link(asset_group) asset_group[AVALON_PROPERTY] = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace or '', "loader": str(self.__class__.__name__), "representation": str(context["representation"]["_id"]), "libpath": libpath, "asset_name": asset_name, "parent": str(context["representation"]["parent"]), "family": context["representation"]["context"]["family"], "objectName": group_name } self[:] = objects return objects def exec_update(self, container: Dict, representation: Dict): """Update the loaded asset. This will remove all objects of the current collection, load the new ones and add them to the collection. If the objects of the collection are used in another collection they will not be removed, only unlinked. Normally this should not be the case though. Warning: No nested collections are supported at the moment! """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( "Container: %s\nRepresentation: %s", pformat(container, indent=2), pformat(representation, indent=2), ) assert asset_group, ( f"The asset is not loaded: {container['objectName']}" ) assert libpath, ( "No existing library file found for {container['objectName']}" ) assert libpath.is_file(), ( f"The file doesn't exist: {libpath}" ) assert extension in plugin.VALID_EXTENSIONS, ( f"Unsupported file: {libpath}" ) metadata = asset_group.get(AVALON_PROPERTY) group_libpath = metadata["libpath"] normalized_group_libpath = ( str(Path(bpy.path.abspath(group_libpath)).resolve()) ) normalized_libpath = ( str(Path(bpy.path.abspath(str(libpath))).resolve()) ) self.log.debug( "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", normalized_group_libpath, normalized_libpath, ) if normalized_group_libpath == normalized_libpath: self.log.info("Library already loaded, not updating...") return # Get the armature of the rig objects = asset_group.children armatures = [obj for obj in objects if obj.type == 'ARMATURE'] action = None if armatures: armature = armatures[0] if armature.animation_data and armature.animation_data.action: action = armature.animation_data.action mat = asset_group.matrix_basis.copy() self._remove(asset_group) self._process(str(libpath), asset_group, object_name, action) asset_group.matrix_basis = mat metadata["libpath"] = str(libpath) metadata["representation"] = str(representation["_id"]) def exec_remove(self, container: Dict) -> bool: """Remove an existing container from a Blender scene. Arguments: container (openpype:container-1.0): Container to remove, from `host.ls()`. Returns: bool: Whether the container was deleted. Warning: No nested collections are supported at the moment! """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) if not asset_group: return False self._remove(asset_group) bpy.data.objects.remove(asset_group) return True ================================================ FILE: openpype/hosts/blender/plugins/load/load_layout_json.py ================================================ """Load a layout in Blender.""" import json from pathlib import Path from pprint import pformat from typing import Dict, Optional import bpy from openpype.pipeline import ( discover_loader_plugins, remove_container, load_container, get_representation_path, loaders_from_representation, AVALON_CONTAINER_ID, ) from openpype.hosts.blender.api.pipeline import ( AVALON_INSTANCES, AVALON_CONTAINERS, AVALON_PROPERTY, ) from openpype.hosts.blender.api import plugin class JsonLayoutLoader(plugin.AssetLoader): """Load layout published from Unreal.""" families = ["layout"] representations = ["json"] label = "Load Layout" icon = "code-fork" color = "orange" animation_creator_name = "CreateAnimation" def _remove(self, asset_group): objects = list(asset_group.children) for obj in objects: remove_container(obj.get(AVALON_PROPERTY)) def _remove_animation_instances(self, asset_group): instances = bpy.data.collections.get(AVALON_INSTANCES) if instances: for obj in list(asset_group.children): anim_collection = instances.children.get( obj.name + "_animation") if anim_collection: bpy.data.collections.remove(anim_collection) def _get_loader(self, loaders, family): name = "" if family == 'rig': name = "BlendRigLoader" elif family == 'model': name = "BlendModelLoader" if name == "": return None for loader in loaders: if loader.__name__ == name: return loader return None def _process(self, libpath, asset, asset_group, actions): plugin.deselect_all() with open(libpath, "r") as fp: data = json.load(fp) all_loaders = discover_loader_plugins() for element in data: reference = element.get('reference') family = element.get('family') loaders = loaders_from_representation(all_loaders, reference) loader = self._get_loader(loaders, family) if not loader: continue instance_name = element.get('instance_name') action = None if actions: action = actions.get(instance_name, None) options = { 'parent': asset_group, 'transform': element.get('transform'), 'action': action, 'create_animation': True if family == 'rig' else False, 'animation_asset': asset } if element.get('animation'): options['animation_file'] = str(Path(libpath).with_suffix( '')) + "." + element.get('animation') # This should return the loaded asset, but the load call will be # added to the queue to run in the Blender main thread, so # at this time it will not return anything. The assets will be # loaded in the next Blender cycle, so we use the options to # set the transform, parent and assign the action, if there is one. load_container( loader, reference, namespace=instance_name, options=options ) # Camera creation when loading a layout is not necessary for now, # but the code is worth keeping in case we need it in the future. # # Create the camera asset and the camera instance # creator_plugin = get_legacy_creator_by_name("CreateCamera") # if not creator_plugin: # raise ValueError("Creator plugin \"CreateCamera\" was " # "not found.") # TODO: Refactor legacy create usage to new style creators # legacy_create( # creator_plugin, # name="camera", # # name=f"{unique_number}_{subset}_animation", # asset=asset, # options={"useSelection": False} # # data={"dependencies": str(context["representation"]["_id"])} # ) def process_asset(self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None): """ Arguments: name: Use pre-defined name namespace: Use pre-defined namespace context: Full parenthood of representation to load options: Additional settings dictionary """ libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) if not avalon_container: avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) bpy.context.scene.collection.children.link(avalon_container) asset_group = bpy.data.objects.new(group_name, object_data=None) asset_group.empty_display_type = 'SINGLE_ARROW' avalon_container.objects.link(asset_group) self._process(libpath, asset, asset_group, None) bpy.context.scene.collection.objects.link(asset_group) asset_group[AVALON_PROPERTY] = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace or '', "loader": str(self.__class__.__name__), "representation": str(context["representation"]["_id"]), "libpath": libpath, "asset_name": asset_name, "parent": str(context["representation"]["parent"]), "family": context["representation"]["context"]["family"], "objectName": group_name } self[:] = asset_group.children return asset_group.children def exec_update(self, container: Dict, representation: Dict): """Update the loaded asset. This will remove all objects of the current collection, load the new ones and add them to the collection. If the objects of the collection are used in another collection they will not be removed, only unlinked. Normally this should not be the case though. """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( "Container: %s\nRepresentation: %s", pformat(container, indent=2), pformat(representation, indent=2), ) assert asset_group, ( f"The asset is not loaded: {container['objectName']}" ) assert libpath, ( "No existing library file found for {container['objectName']}" ) assert libpath.is_file(), ( f"The file doesn't exist: {libpath}" ) assert extension in plugin.VALID_EXTENSIONS, ( f"Unsupported file: {libpath}" ) metadata = asset_group.get(AVALON_PROPERTY) group_libpath = metadata["libpath"] normalized_group_libpath = ( str(Path(bpy.path.abspath(group_libpath)).resolve()) ) normalized_libpath = ( str(Path(bpy.path.abspath(str(libpath))).resolve()) ) self.log.debug( "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", normalized_group_libpath, normalized_libpath, ) if normalized_group_libpath == normalized_libpath: self.log.info("Library already loaded, not updating...") return actions = {} for obj in asset_group.children: obj_meta = obj.get(AVALON_PROPERTY) if obj_meta.get('family') == 'rig': rig = None for child in obj.children: if child.type == 'ARMATURE': rig = child break if not rig: raise Exception("No armature in the rig asset group.") if rig.animation_data and rig.animation_data.action: namespace = obj_meta.get('namespace') actions[namespace] = rig.animation_data.action mat = asset_group.matrix_basis.copy() self._remove_animation_instances(asset_group) self._remove(asset_group) self._process(str(libpath), asset_group, actions) asset_group.matrix_basis = mat metadata["libpath"] = str(libpath) metadata["representation"] = str(representation["_id"]) def exec_remove(self, container: Dict) -> bool: """Remove an existing container from a Blender scene. Arguments: container (openpype:container-1.0): Container to remove, from `host.ls()`. Returns: bool: Whether the container was deleted. """ object_name = container["objectName"] asset_group = bpy.data.objects.get(object_name) if not asset_group: return False self._remove_animation_instances(asset_group) self._remove(asset_group) bpy.data.objects.remove(asset_group) return True ================================================ FILE: openpype/hosts/blender/plugins/load/load_look.py ================================================ """Load a model asset in Blender.""" from pathlib import Path from pprint import pformat from typing import Dict, List, Optional import os import json import bpy from openpype.pipeline import get_representation_path from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( containerise_existing, AVALON_PROPERTY ) class BlendLookLoader(plugin.AssetLoader): """Load models from a .blend file. Because they come from a .blend file we can simply link the collection that contains the model. There is no further need to 'containerise' it. """ families = ["look"] representations = ["json"] label = "Load Look" icon = "code-fork" color = "orange" def get_all_children(self, obj): children = list(obj.children) for child in children: children.extend(child.children) return children def _process(self, libpath, container_name, objects): with open(libpath, "r") as fp: data = json.load(fp) path = os.path.dirname(libpath) materials_path = f"{path}/resources" materials = [] for entry in data: file = entry.get('fbx_filename') if file is None: continue bpy.ops.import_scene.fbx(filepath=f"{materials_path}/{file}") mesh = [o for o in bpy.context.scene.objects if o.select_get()][0] material = mesh.data.materials[0] material.name = f"{material.name}:{container_name}" texture_file = entry.get('tga_filename') if texture_file: node_tree = material.node_tree pbsdf = node_tree.nodes['Principled BSDF'] base_color = pbsdf.inputs[0] tex_node = base_color.links[0].from_node tex_node.image.filepath = f"{materials_path}/{texture_file}" materials.append(material) for obj in objects: for child in self.get_all_children(obj): mesh_name = child.name.split(':')[0] if mesh_name == material.name.split(':')[0]: child.data.materials.clear() child.data.materials.append(material) break bpy.data.objects.remove(mesh) return materials, objects def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None ) -> Optional[List]: """ Arguments: name: Use pre-defined name namespace: Use pre-defined namespace context: Full parenthood of representation to load options: Additional settings dictionary """ libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] lib_container = plugin.prepare_scene_name( asset, subset ) unique_number = plugin.get_unique_number( asset, subset ) namespace = namespace or f"{asset}_{unique_number}" container_name = plugin.prepare_scene_name( asset, subset, unique_number ) container = bpy.data.collections.new(lib_container) container.name = container_name containerise_existing( container, name, namespace, context, self.__class__.__name__, ) metadata = container.get(AVALON_PROPERTY) metadata["libpath"] = libpath metadata["lib_container"] = lib_container selected = [o for o in bpy.context.scene.objects if o.select_get()] materials, objects = self._process(libpath, container_name, selected) # Save the list of imported materials in the metadata container metadata["objects"] = objects metadata["materials"] = materials metadata["parent"] = str(context["representation"]["parent"]) metadata["family"] = context["representation"]["context"]["family"] nodes = list(container.objects) nodes.append(container) self[:] = nodes return nodes def update(self, container: Dict, representation: Dict): collection = bpy.data.collections.get(container["objectName"]) libpath = Path(get_representation_path(representation)) extension = libpath.suffix.lower() self.log.info( "Container: %s\nRepresentation: %s", pformat(container, indent=2), pformat(representation, indent=2), ) assert collection, ( f"The asset is not loaded: {container['objectName']}" ) assert not (collection.children), ( "Nested collections are not supported." ) assert libpath, ( "No existing library file found for {container['objectName']}" ) assert libpath.is_file(), ( f"The file doesn't exist: {libpath}" ) assert extension in plugin.VALID_EXTENSIONS, ( f"Unsupported file: {libpath}" ) collection_metadata = collection.get(AVALON_PROPERTY) collection_libpath = collection_metadata["libpath"] normalized_collection_libpath = ( str(Path(bpy.path.abspath(collection_libpath)).resolve()) ) normalized_libpath = ( str(Path(bpy.path.abspath(str(libpath))).resolve()) ) self.log.debug( "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", normalized_collection_libpath, normalized_libpath, ) if normalized_collection_libpath == normalized_libpath: self.log.info("Library already loaded, not updating...") return for obj in collection_metadata['objects']: for child in self.get_all_children(obj): child.data.materials.clear() for material in collection_metadata['materials']: bpy.data.materials.remove(material) namespace = collection_metadata['namespace'] name = collection_metadata['name'] container_name = f"{namespace}_{name}" materials, objects = self._process( libpath, container_name, collection_metadata['objects']) collection_metadata["objects"] = objects collection_metadata["materials"] = materials collection_metadata["libpath"] = str(libpath) collection_metadata["representation"] = str(representation["_id"]) def remove(self, container: Dict) -> bool: collection = bpy.data.collections.get(container["objectName"]) if not collection: return False collection_metadata = collection.get(AVALON_PROPERTY) for obj in collection_metadata['objects']: for child in self.get_all_children(obj): child.data.materials.clear() for material in collection_metadata['materials']: bpy.data.materials.remove(material) bpy.data.collections.remove(collection) return True ================================================ FILE: openpype/hosts/blender/plugins/publish/collect_current_file.py ================================================ import pyblish.api from openpype.hosts.blender.api import workio class CollectBlenderCurrentFile(pyblish.api.ContextPlugin): """Inject the current working file into context""" order = pyblish.api.CollectorOrder - 0.5 label = "Blender Current File" hosts = ["blender"] def process(self, context): """Inject the current working file""" current_file = workio.current_file() context.data["currentFile"] = current_file ================================================ FILE: openpype/hosts/blender/plugins/publish/collect_instance.py ================================================ import bpy import pyblish.api from openpype.pipeline.publish import KnownPublishError from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class CollectBlenderInstanceData(pyblish.api.InstancePlugin): """Validator to verify that the instance is not empty""" order = pyblish.api.CollectorOrder hosts = ["blender"] families = ["model", "pointcache", "animation", "rig", "camera", "layout", "blendScene"] label = "Collect Instance" def process(self, instance): instance_node = instance.data["transientData"]["instance_node"] # Collect members of the instance members = [instance_node] if isinstance(instance_node, bpy.types.Collection): members.extend(instance_node.objects) members.extend(instance_node.children) # Special case for animation instances, include armatures if instance.data["family"] == "animation": for obj in instance_node.objects: if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY): members.extend( child for child in obj.children if child.type == 'ARMATURE' ) elif isinstance(instance_node, bpy.types.Object): members.extend(instance_node.children_recursive) else: raise KnownPublishError( f"Unsupported instance node type '{type(instance_node)}' " f"for instance '{instance}'" ) instance[:] = members ================================================ FILE: openpype/hosts/blender/plugins/publish/collect_render.py ================================================ # -*- coding: utf-8 -*- """Collect render data.""" import os import re import bpy from openpype.hosts.blender.api import colorspace import pyblish.api class CollectBlenderRender(pyblish.api.InstancePlugin): """Gather all publishable render instances.""" order = pyblish.api.CollectorOrder + 0.01 hosts = ["blender"] families = ["render"] label = "Collect Render" sync_workfile_version = False @staticmethod def generate_expected_beauty( render_product, frame_start, frame_end, frame_step, ext ): """ Generate the expected files for the render product for the beauty render. This returns a list of files that should be rendered. It replaces the sequence of `#` with the frame number. """ path = os.path.dirname(render_product) file = os.path.basename(render_product) expected_files = [] for frame in range(frame_start, frame_end + 1, frame_step): frame_str = str(frame).rjust(4, "0") filename = re.sub("#+", frame_str, file) expected_file = f"{os.path.join(path, filename)}.{ext}" expected_files.append(expected_file.replace("\\", "/")) return { "beauty": expected_files } @staticmethod def generate_expected_aovs( aov_file_product, frame_start, frame_end, frame_step, ext ): """ Generate the expected files for the render product for the beauty render. This returns a list of files that should be rendered. It replaces the sequence of `#` with the frame number. """ expected_files = {} for aov_name, aov_file in aov_file_product: path = os.path.dirname(aov_file) file = os.path.basename(aov_file) aov_files = [] for frame in range(frame_start, frame_end + 1, frame_step): frame_str = str(frame).rjust(4, "0") filename = re.sub("#+", frame_str, file) expected_file = f"{os.path.join(path, filename)}.{ext}" aov_files.append(expected_file.replace("\\", "/")) expected_files[aov_name] = aov_files return expected_files def process(self, instance): context = instance.context instance_node = instance.data["transientData"]["instance_node"] render_data = instance_node.get("render_data") assert render_data, "No render data found." render_product = render_data.get("render_product") aov_file_product = render_data.get("aov_file_product") ext = render_data.get("image_format") multilayer = render_data.get("multilayer_exr") frame_start = context.data["frameStart"] frame_end = context.data["frameEnd"] frame_handle_start = context.data["frameStartHandle"] frame_handle_end = context.data["frameEndHandle"] expected_beauty = self.generate_expected_beauty( render_product, int(frame_start), int(frame_end), int(bpy.context.scene.frame_step), ext) expected_aovs = self.generate_expected_aovs( aov_file_product, int(frame_start), int(frame_end), int(bpy.context.scene.frame_step), ext) expected_files = expected_beauty | expected_aovs instance.data.update({ "families": ["render", "render.farm"], "frameStart": frame_start, "frameEnd": frame_end, "frameStartHandle": frame_handle_start, "frameEndHandle": frame_handle_end, "fps": context.data["fps"], "byFrameStep": bpy.context.scene.frame_step, "review": render_data.get("review", False), "multipartExr": ext == "exr" and multilayer, "farm": True, "expectedFiles": [expected_files], # OCIO not currently implemented in Blender, but the following # settings are required by the schema, so it is hardcoded. # TODO: Implement OCIO in Blender "colorspaceConfig": "", "colorspaceDisplay": "sRGB", "colorspaceView": "ACES 1.0 SDR-video", "renderProducts": colorspace.ARenderProduct(), }) ================================================ FILE: openpype/hosts/blender/plugins/publish/collect_review.py ================================================ import bpy import pyblish.api class CollectReview(pyblish.api.InstancePlugin): """Collect Review data """ order = pyblish.api.CollectorOrder + 0.3 label = "Collect Review Data" families = ["review"] def process(self, instance): self.log.debug(f"instance: {instance}") datablock = instance.data["transientData"]["instance_node"] # get cameras cameras = [ obj for obj in datablock.all_objects if isinstance(obj, bpy.types.Object) and obj.type == "CAMERA" ] assert len(cameras) == 1, ( f"Not a single camera found in extraction: {cameras}" ) camera = cameras[0].name self.log.debug(f"camera: {camera}") focal_length = cameras[0].data.lens # get isolate objects list from meshes instance members. types = {"MESH", "GPENCIL"} isolate_objects = [ obj for obj in instance if isinstance(obj, bpy.types.Object) and obj.type in types ] if not instance.data.get("remove"): # Store focal length in `burninDataMembers` burninData = instance.data.setdefault("burninDataMembers", {}) burninData["focalLength"] = focal_length instance.data.update({ "review_camera": camera, "frameStart": instance.context.data["frameStart"], "frameEnd": instance.context.data["frameEnd"], "fps": instance.context.data["fps"], "isolate": isolate_objects, }) self.log.debug(f"instance data: {instance.data}") # TODO : Collect audio audio_tracks = [] instance.data["audio"] = [] for track in audio_tracks: instance.data["audio"].append( { "offset": track.offset.get(), "filename": track.filename.get(), } ) ================================================ FILE: openpype/hosts/blender/plugins/publish/collect_workfile.py ================================================ from pathlib import Path from pyblish.api import InstancePlugin, CollectorOrder class CollectWorkfile(InstancePlugin): """Inject workfile data into its instance.""" order = CollectorOrder label = "Collect Workfile" hosts = ["blender"] families = ["workfile"] def process(self, instance): """Process collector.""" context = instance.context filepath = Path(context.data["currentFile"]) ext = filepath.suffix instance.data.update( { "setMembers": [filepath.as_posix()], "frameStart": context.data.get("frameStart", 1), "frameEnd": context.data.get("frameEnd", 1), "handleStart": context.data.get("handleStart", 1), "handledEnd": context.data.get("handleEnd", 1), "representations": [ { "name": ext.lstrip("."), "ext": ext.lstrip("."), "files": filepath.name, "stagingDir": filepath.parent, } ], } ) ================================================ FILE: openpype/hosts/blender/plugins/publish/extract_abc.py ================================================ import os import bpy from openpype.pipeline import publish from openpype.hosts.blender.api import plugin class ExtractABC(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract as ABC.""" label = "Extract ABC" hosts = ["blender"] families = ["pointcache"] def process(self, instance): if not self.is_active(instance.data): return # Define extract output file path stagingdir = self.staging_dir(instance) asset_name = instance.data["assetEntity"]["name"] subset = instance.data["subset"] instance_name = f"{asset_name}_{subset}" filename = f"{instance_name}.abc" filepath = os.path.join(stagingdir, filename) # Perform extraction self.log.debug("Performing extraction..") plugin.deselect_all() asset_group = instance.data["transientData"]["instance_node"] selected = [] for obj in instance: if isinstance(obj, bpy.types.Object): obj.select_set(True) selected.append(obj) context = plugin.create_blender_context( active=asset_group, selected=selected) with bpy.context.temp_override(**context): # We export the abc bpy.ops.wm.alembic_export( filepath=filepath, selected=True, flatten=False ) plugin.deselect_all() if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'abc', 'ext': 'abc', 'files': filename, "stagingDir": stagingdir, } instance.data["representations"].append(representation) self.log.debug("Extracted instance '%s' to: %s", instance.name, representation) class ExtractModelABC(ExtractABC): """Extract model as ABC.""" label = "Extract Model ABC" hosts = ["blender"] families = ["model"] optional = True ================================================ FILE: openpype/hosts/blender/plugins/publish/extract_abc_animation.py ================================================ import os import bpy from openpype.pipeline import publish from openpype.hosts.blender.api import plugin class ExtractAnimationABC( publish.Extractor, publish.OptionalPyblishPluginMixin, ): """Extract as ABC.""" label = "Extract Animation ABC" hosts = ["blender"] families = ["animation"] optional = True def process(self, instance): if not self.is_active(instance.data): return # Define extract output file path stagingdir = self.staging_dir(instance) asset_name = instance.data["assetEntity"]["name"] subset = instance.data["subset"] instance_name = f"{asset_name}_{subset}" filename = f"{instance_name}.abc" filepath = os.path.join(stagingdir, filename) # Perform extraction self.log.debug("Performing extraction..") plugin.deselect_all() selected = [] asset_group = instance.data["transientData"]["instance_node"] objects = [] for obj in instance: if isinstance(obj, bpy.types.Collection): for child in obj.all_objects: objects.append(child) for obj in objects: children = [o for o in bpy.data.objects if o.parent == obj] for child in children: objects.append(child) for obj in objects: obj.select_set(True) selected.append(obj) context = plugin.create_blender_context( active=asset_group, selected=selected) with bpy.context.temp_override(**context): # We export the abc bpy.ops.wm.alembic_export( filepath=filepath, selected=True, flatten=False ) plugin.deselect_all() if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'abc', 'ext': 'abc', 'files': filename, "stagingDir": stagingdir, } instance.data["representations"].append(representation) self.log.debug("Extracted instance '%s' to: %s", instance.name, representation) ================================================ FILE: openpype/hosts/blender/plugins/publish/extract_blend.py ================================================ import os import bpy from openpype.pipeline import publish class ExtractBlend(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract a blend file.""" label = "Extract Blend" hosts = ["blender"] families = ["model", "camera", "rig", "action", "layout", "blendScene"] optional = True def process(self, instance): if not self.is_active(instance.data): return # Define extract output file path stagingdir = self.staging_dir(instance) asset_name = instance.data["assetEntity"]["name"] subset = instance.data["subset"] instance_name = f"{asset_name}_{subset}" filename = f"{instance_name}.blend" filepath = os.path.join(stagingdir, filename) # Perform extraction self.log.debug("Performing extraction..") data_blocks = set() for data in instance: data_blocks.add(data) # Pack used images in the blend files. if not ( isinstance(data, bpy.types.Object) and data.type == 'MESH' ): continue for material_slot in data.material_slots: mat = material_slot.material if not (mat and mat.use_nodes): continue tree = mat.node_tree if tree.type != 'SHADER': continue for node in tree.nodes: if node.bl_idname != 'ShaderNodeTexImage': continue # Check if image is not packed already # and pack it if not. if node.image and node.image.packed_file is None: node.image.pack() bpy.data.libraries.write(filepath, data_blocks) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'blend', 'ext': 'blend', 'files': filename, "stagingDir": stagingdir, } instance.data["representations"].append(representation) self.log.debug("Extracted instance '%s' to: %s", instance.name, representation) ================================================ FILE: openpype/hosts/blender/plugins/publish/extract_blend_animation.py ================================================ import os import bpy from openpype.pipeline import publish class ExtractBlendAnimation( publish.Extractor, publish.OptionalPyblishPluginMixin, ): """Extract a blend file.""" label = "Extract Blend" hosts = ["blender"] families = ["animation"] optional = True def process(self, instance): if not self.is_active(instance.data): return # Define extract output file path stagingdir = self.staging_dir(instance) asset_name = instance.data["assetEntity"]["name"] subset = instance.data["subset"] instance_name = f"{asset_name}_{subset}" filename = f"{instance_name}.blend" filepath = os.path.join(stagingdir, filename) # Perform extraction self.log.debug("Performing extraction..") data_blocks = set() for obj in instance: if isinstance(obj, bpy.types.Object) and obj.type == 'EMPTY': child = obj.children[0] if child and child.type == 'ARMATURE': if child.animation_data and child.animation_data.action: if not obj.animation_data: obj.animation_data_create() obj.animation_data.action = child.animation_data.action obj.animation_data_clear() data_blocks.add(child.animation_data.action) data_blocks.add(obj) bpy.data.libraries.write(filepath, data_blocks) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'blend', 'ext': 'blend', 'files': filename, "stagingDir": stagingdir, } instance.data["representations"].append(representation) self.log.debug("Extracted instance '%s' to: %s", instance.name, representation) ================================================ FILE: openpype/hosts/blender/plugins/publish/extract_camera_abc.py ================================================ import os import bpy from openpype.pipeline import publish from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class ExtractCameraABC(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract camera as ABC.""" label = "Extract Camera (ABC)" hosts = ["blender"] families = ["camera"] optional = True def process(self, instance): if not self.is_active(instance.data): return # Define extract output file path stagingdir = self.staging_dir(instance) asset_name = instance.data["assetEntity"]["name"] subset = instance.data["subset"] instance_name = f"{asset_name}_{subset}" filename = f"{instance_name}.abc" filepath = os.path.join(stagingdir, filename) # Perform extraction self.log.debug("Performing extraction..") plugin.deselect_all() asset_group = instance.data["transientData"]["instance_node"] # Need to cast to list because children is a tuple selected = list(asset_group.children) active = selected[0] for obj in selected: obj.select_set(True) context = plugin.create_blender_context( active=active, selected=selected) with bpy.context.temp_override(**context): # We export the abc bpy.ops.wm.alembic_export( filepath=filepath, selected=True, flatten=True ) plugin.deselect_all() if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'abc', 'ext': 'abc', 'files': filename, "stagingDir": stagingdir, } instance.data["representations"].append(representation) self.log.debug("Extracted instance '%s' to: %s", instance.name, representation) ================================================ FILE: openpype/hosts/blender/plugins/publish/extract_camera_fbx.py ================================================ import os import bpy from openpype.pipeline import publish from openpype.hosts.blender.api import plugin class ExtractCamera(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract as the camera as FBX.""" label = "Extract Camera (FBX)" hosts = ["blender"] families = ["camera"] optional = True def process(self, instance): if not self.is_active(instance.data): return # Define extract output file path stagingdir = self.staging_dir(instance) asset_name = instance.data["assetEntity"]["name"] subset = instance.data["subset"] instance_name = f"{asset_name}_{subset}" filename = f"{instance_name}.fbx" filepath = os.path.join(stagingdir, filename) # Perform extraction self.log.debug("Performing extraction..") plugin.deselect_all() selected = [] camera = None for obj in instance: if obj.type == "CAMERA": obj.select_set(True) selected.append(obj) camera = obj break assert camera, "No camera found" context = plugin.create_blender_context( active=camera, selected=selected) scale_length = bpy.context.scene.unit_settings.scale_length bpy.context.scene.unit_settings.scale_length = 0.01 with bpy.context.temp_override(**context): # We export the fbx bpy.ops.export_scene.fbx( filepath=filepath, use_active_collection=False, use_selection=True, bake_anim_use_nla_strips=False, bake_anim_use_all_actions=False, add_leaf_bones=False, armature_nodetype='ROOT', object_types={'CAMERA'}, bake_anim_simplify_factor=0.0 ) bpy.context.scene.unit_settings.scale_length = scale_length plugin.deselect_all() if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'fbx', 'ext': 'fbx', 'files': filename, "stagingDir": stagingdir, } instance.data["representations"].append(representation) self.log.debug("Extracted instance '%s' to: %s", instance.name, representation) ================================================ FILE: openpype/hosts/blender/plugins/publish/extract_fbx.py ================================================ import os import bpy from openpype.pipeline import publish from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class ExtractFBX(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract as FBX.""" label = "Extract FBX" hosts = ["blender"] families = ["model", "rig"] optional = True def process(self, instance): if not self.is_active(instance.data): return # Define extract output file path stagingdir = self.staging_dir(instance) asset_name = instance.data["assetEntity"]["name"] subset = instance.data["subset"] instance_name = f"{asset_name}_{subset}" filename = f"{instance_name}.fbx" filepath = os.path.join(stagingdir, filename) # Perform extraction self.log.debug("Performing extraction..") plugin.deselect_all() asset_group = instance.data["transientData"]["instance_node"] selected = [] for obj in instance: obj.select_set(True) selected.append(obj) context = plugin.create_blender_context( active=asset_group, selected=selected) new_materials = [] new_materials_objs = [] objects = list(asset_group.children) for obj in objects: objects.extend(obj.children) if obj.type == 'MESH' and len(obj.data.materials) == 0: mat = bpy.data.materials.new(obj.name) obj.data.materials.append(mat) new_materials.append(mat) new_materials_objs.append(obj) scale_length = bpy.context.scene.unit_settings.scale_length bpy.context.scene.unit_settings.scale_length = 0.01 with bpy.context.temp_override(**context): # We export the fbx bpy.ops.export_scene.fbx( filepath=filepath, use_active_collection=False, use_selection=True, mesh_smooth_type='FACE', add_leaf_bones=False ) bpy.context.scene.unit_settings.scale_length = scale_length plugin.deselect_all() for mat in new_materials: bpy.data.materials.remove(mat) for obj in new_materials_objs: obj.data.materials.pop() if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'fbx', 'ext': 'fbx', 'files': filename, "stagingDir": stagingdir, } instance.data["representations"].append(representation) self.log.debug("Extracted instance '%s' to: %s", instance.name, representation) ================================================ FILE: openpype/hosts/blender/plugins/publish/extract_fbx_animation.py ================================================ import os import json import bpy import bpy_extras import bpy_extras.anim_utils from openpype.pipeline import publish from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY def get_all_parents(obj): """Get all recursive parents of object""" result = [] while True: obj = obj.parent if not obj: break result.append(obj) return result def get_highest_root(objects): # Get the highest object that is also in the collection included_objects = {obj.name_full for obj in objects} num_parents_to_obj = {} for obj in objects: if isinstance(obj, bpy.types.Object): parents = get_all_parents(obj) # included parents parents = [parent for parent in parents if parent.name_full in included_objects] if not parents: # A node without parents must be a highest root return obj num_parents_to_obj.setdefault(len(parents), obj) minimum_parent = min(num_parents_to_obj) return num_parents_to_obj[minimum_parent] class ExtractAnimationFBX( publish.Extractor, publish.OptionalPyblishPluginMixin, ): """Extract as animation.""" label = "Extract FBX" hosts = ["blender"] families = ["animation"] optional = True def process(self, instance): if not self.is_active(instance.data): return # Define extract output file path stagingdir = self.staging_dir(instance) # Perform extraction self.log.debug("Performing extraction..") asset_group = instance.data["transientData"]["instance_node"] # Get objects in this collection (but not in children collections) # and for those objects include the children hierarchy # TODO: Would it make more sense for the Collect Instance collector # to also always retrieve all the children? objects = set(asset_group.objects) # From the direct children of the collection find the 'root' node # that we want to export - it is the 'highest' node in a hierarchy root = get_highest_root(objects) for obj in list(objects): objects.update(obj.children_recursive) # Find all armatures among the objects, assume to find only one armatures = [obj for obj in objects if obj.type == "ARMATURE"] if not armatures: raise RuntimeError( f"Unable to find ARMATURE in collection: " f"{asset_group.name}" ) elif len(armatures) > 1: self.log.warning( "Found more than one ARMATURE, using " f"only first of: {armatures}" ) armature = armatures[0] object_action_pairs = [] original_actions = [] starting_frames = [] ending_frames = [] # For each armature, we make a copy of the current action if armature.animation_data and armature.animation_data.action: curr_action = armature.animation_data.action copy_action = curr_action.copy() curr_frame_range = curr_action.frame_range starting_frames.append(curr_frame_range[0]) ending_frames.append(curr_frame_range[1]) else: self.log.info( f"Armature '{armature.name}' has no animation, " f"skipping FBX animation extraction for {instance}." ) return asset_group_name = asset_group.name asset_name = asset_group.get(AVALON_PROPERTY).get("asset_name") if asset_name: # Rename for the export; this data is only present when loaded # from a JSON Layout (layout family) asset_group.name = asset_name # Remove : from the armature name for the export armature_name = armature.name original_name = armature_name.split(':')[1] armature.name = original_name object_action_pairs.append((armature, copy_action)) original_actions.append(curr_action) # We compute the starting and ending frames max_frame = min(starting_frames) min_frame = max(ending_frames) # We bake the copy of the current action for each object bpy_extras.anim_utils.bake_action_objects( object_action_pairs, frames=range(int(min_frame), int(max_frame)), do_object=False, do_clean=False ) for obj in bpy.data.objects: obj.select_set(False) root.select_set(True) armature.select_set(True) asset_name = instance.data["assetEntity"]["name"] subset = instance.data["subset"] instance_name = f"{asset_name}_{subset}" fbx_filename = f"{instance_name}_{armature.name}.fbx" filepath = os.path.join(stagingdir, fbx_filename) override = plugin.create_blender_context( active=root, selected=[root, armature]) with bpy.context.temp_override(**override): # We export the fbx bpy.ops.export_scene.fbx( filepath=filepath, use_active_collection=False, use_selection=True, bake_anim_use_nla_strips=False, bake_anim_use_all_actions=False, add_leaf_bones=False, armature_nodetype='ROOT', object_types={'EMPTY', 'ARMATURE'} ) armature.name = armature_name asset_group.name = asset_group_name root.select_set(True) armature.select_set(False) # We delete the baked action and set the original one back for i in range(0, len(object_action_pairs)): pair = object_action_pairs[i] action = original_actions[i] if action: pair[0].animation_data.action = action if pair[1]: pair[1].user_clear() bpy.data.actions.remove(pair[1]) json_filename = f"{instance_name}.json" json_path = os.path.join(stagingdir, json_filename) json_dict = { "instance_name": asset_group.get(AVALON_PROPERTY).get("objectName") } # collection = instance.data.get("name") # container = None # for obj in bpy.data.collections[collection].objects: # if obj.type == "ARMATURE": # container_name = obj.get("avalon").get("container_name") # container = bpy.data.collections[container_name] # if container: # json_dict = { # "instance_name": container.get("avalon").get("instance_name") # } with open(json_path, "w+") as file: json.dump(json_dict, fp=file, indent=2) if "representations" not in instance.data: instance.data["representations"] = [] fbx_representation = { 'name': 'fbx', 'ext': 'fbx', 'files': fbx_filename, "stagingDir": stagingdir, } json_representation = { 'name': 'json', 'ext': 'json', 'files': json_filename, "stagingDir": stagingdir, } instance.data["representations"].append(fbx_representation) instance.data["representations"].append(json_representation) self.log.debug("Extracted instance '{}' to: {}".format( instance.name, fbx_representation)) ================================================ FILE: openpype/hosts/blender/plugins/publish/extract_layout.py ================================================ import os import json import bpy import bpy_extras import bpy_extras.anim_utils from openpype.client import get_representation_by_name from openpype.pipeline import publish from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class ExtractLayout(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract a layout.""" label = "Extract Layout (JSON)" hosts = ["blender"] families = ["layout"] optional = True def _export_animation(self, asset, instance, stagingdir, fbx_count): n = fbx_count for obj in asset.children: if obj.type != "ARMATURE": continue object_action_pairs = [] original_actions = [] starting_frames = [] ending_frames = [] # For each armature, we make a copy of the current action curr_action = None copy_action = None if obj.animation_data and obj.animation_data.action: curr_action = obj.animation_data.action copy_action = curr_action.copy() curr_frame_range = curr_action.frame_range starting_frames.append(curr_frame_range[0]) ending_frames.append(curr_frame_range[1]) else: self.log.info("Object has no animation.") continue asset_group_name = asset.name asset.name = asset.get(AVALON_PROPERTY).get("asset_name") armature_name = obj.name original_name = armature_name.split(':')[1] obj.name = original_name object_action_pairs.append((obj, copy_action)) original_actions.append(curr_action) # We compute the starting and ending frames max_frame = min(starting_frames) min_frame = max(ending_frames) # We bake the copy of the current action for each object bpy_extras.anim_utils.bake_action_objects( object_action_pairs, frames=range(int(min_frame), int(max_frame)), do_object=False, do_clean=False ) for o in bpy.data.objects: o.select_set(False) asset.select_set(True) obj.select_set(True) fbx_filename = f"{n:03d}.fbx" filepath = os.path.join(stagingdir, fbx_filename) override = plugin.create_blender_context( active=asset, selected=[asset, obj]) with bpy.context.temp_override(**override): # We export the fbx bpy.ops.export_scene.fbx( filepath=filepath, use_active_collection=False, use_selection=True, bake_anim_use_nla_strips=False, bake_anim_use_all_actions=False, add_leaf_bones=False, armature_nodetype='ROOT', object_types={'EMPTY', 'ARMATURE'} ) obj.name = armature_name asset.name = asset_group_name asset.select_set(False) obj.select_set(False) # We delete the baked action and set the original one back for i in range(0, len(object_action_pairs)): pair = object_action_pairs[i] action = original_actions[i] if action: pair[0].animation_data.action = action if pair[1]: pair[1].user_clear() bpy.data.actions.remove(pair[1]) return fbx_filename, n + 1 return None, n def process(self, instance): if not self.is_active(instance.data): return # Define extract output file path stagingdir = self.staging_dir(instance) # Perform extraction self.log.debug("Performing extraction..") if "representations" not in instance.data: instance.data["representations"] = [] json_data = [] fbx_files = [] asset_group = instance.data["transientData"]["instance_node"] fbx_count = 0 project_name = instance.context.data["projectEntity"]["name"] for asset in asset_group.children: metadata = asset.get(AVALON_PROPERTY) if not metadata: # Avoid raising error directly if there's just invalid data # inside the instance; better to log it to the artist # TODO: This should actually be validated in a validator self.log.warning( f"Found content in layout that is not a loaded " f"asset, skipping: {asset.name_full}" ) continue version_id = metadata["parent"] family = metadata["family"] self.log.debug("Parent: {}".format(version_id)) # Get blend reference blend = get_representation_by_name( project_name, "blend", version_id, fields=["_id"] ) blend_id = None if blend: blend_id = blend["_id"] # Get fbx reference fbx = get_representation_by_name( project_name, "fbx", version_id, fields=["_id"] ) fbx_id = None if fbx: fbx_id = fbx["_id"] # Get abc reference abc = get_representation_by_name( project_name, "abc", version_id, fields=["_id"] ) abc_id = None if abc: abc_id = abc["_id"] json_element = {} if blend_id: json_element["reference"] = str(blend_id) if fbx_id: json_element["reference_fbx"] = str(fbx_id) if abc_id: json_element["reference_abc"] = str(abc_id) json_element["family"] = family json_element["instance_name"] = asset.name json_element["asset_name"] = metadata["asset_name"] json_element["file_path"] = metadata["libpath"] json_element["transform"] = { "translation": { "x": asset.location.x, "y": asset.location.y, "z": asset.location.z }, "rotation": { "x": asset.rotation_euler.x, "y": asset.rotation_euler.y, "z": asset.rotation_euler.z }, "scale": { "x": asset.scale.x, "y": asset.scale.y, "z": asset.scale.z } } json_element["transform_matrix"] = [] for row in list(asset.matrix_world.transposed()): json_element["transform_matrix"].append(list(row)) json_element["basis"] = [ [1, 0, 0, 0], [0, -1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1] ] # Extract the animation as well if family == "rig": f, n = self._export_animation( asset, instance, stagingdir, fbx_count) if f: fbx_files.append(f) json_element["animation"] = f fbx_count = n json_data.append(json_element) asset_name = instance.data["assetEntity"]["name"] subset = instance.data["subset"] instance_name = f"{asset_name}_{subset}" json_filename = f"{instance_name}.json" json_path = os.path.join(stagingdir, json_filename) with open(json_path, "w+") as file: json.dump(json_data, fp=file, indent=2) json_representation = { 'name': 'json', 'ext': 'json', 'files': json_filename, "stagingDir": stagingdir, } instance.data["representations"].append(json_representation) self.log.debug(fbx_files) if len(fbx_files) == 1: fbx_representation = { 'name': 'fbx', 'ext': '000.fbx', 'files': fbx_files[0], "stagingDir": stagingdir, } instance.data["representations"].append(fbx_representation) elif len(fbx_files) > 1: fbx_representation = { 'name': 'fbx', 'ext': 'fbx', 'files': fbx_files, "stagingDir": stagingdir, } instance.data["representations"].append(fbx_representation) self.log.debug("Extracted instance '%s' to: %s", instance.name, json_representation) ================================================ FILE: openpype/hosts/blender/plugins/publish/extract_playblast.py ================================================ import os import clique import bpy import pyblish.api from openpype.pipeline import publish from openpype.hosts.blender.api import capture from openpype.hosts.blender.api.lib import maintained_time class ExtractPlayblast(publish.Extractor, publish.OptionalPyblishPluginMixin): """ Extract viewport playblast. Takes review camera and creates review Quicktime video based on viewport capture. """ label = "Extract Playblast" hosts = ["blender"] families = ["review"] optional = True order = pyblish.api.ExtractorOrder + 0.01 def process(self, instance): if not self.is_active(instance.data): return # get scene fps fps = instance.data.get("fps") if fps is None: fps = bpy.context.scene.render.fps instance.data["fps"] = fps self.log.debug(f"fps: {fps}") # If start and end frames cannot be determined, # get them from Blender timeline. start = instance.data.get("frameStart", bpy.context.scene.frame_start) end = instance.data.get("frameEnd", bpy.context.scene.frame_end) self.log.debug(f"start: {start}, end: {end}") assert end > start, "Invalid time range !" # get cameras camera = instance.data("review_camera", None) # get isolate objects list isolate = instance.data("isolate", None) # get output path stagingdir = self.staging_dir(instance) asset_name = instance.data["assetEntity"]["name"] subset = instance.data["subset"] filename = f"{asset_name}_{subset}" path = os.path.join(stagingdir, filename) self.log.debug(f"Outputting images to {path}") project_settings = instance.context.data["project_settings"]["blender"] presets = project_settings["publish"]["ExtractPlayblast"]["presets"] preset = presets.get("default") preset.update({ "camera": camera, "start_frame": start, "end_frame": end, "filename": path, "overwrite": True, "isolate": isolate, }) preset.setdefault( "image_settings", { "file_format": "PNG", "color_mode": "RGB", "color_depth": "8", "compression": 15, }, ) with maintained_time(): path = capture(**preset) self.log.debug(f"playblast path {path}") collected_files = os.listdir(stagingdir) collections, remainder = clique.assemble( collected_files, patterns=[f"{filename}\\.{clique.DIGITS_PATTERN}\\.png$"], ) if len(collections) > 1: raise RuntimeError( f"More than one collection found in stagingdir: {stagingdir}" ) elif len(collections) == 0: raise RuntimeError( f"No collection found in stagingdir: {stagingdir}" ) frame_collection = collections[0] self.log.debug(f"Found collection of interest {frame_collection}") instance.data.setdefault("representations", []) tags = ["review"] if not instance.data.get("keepImages"): tags.append("delete") representation = { "name": "png", "ext": "png", "files": list(frame_collection), "stagingDir": stagingdir, "frameStart": start, "frameEnd": end, "fps": fps, "tags": tags, "camera_name": camera } instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/blender/plugins/publish/extract_thumbnail.py ================================================ import os import glob import pyblish.api from openpype.pipeline import publish from openpype.hosts.blender.api import capture from openpype.hosts.blender.api.lib import maintained_time import bpy class ExtractThumbnail(publish.Extractor): """Extract viewport thumbnail. Takes review camera and creates a thumbnail based on viewport capture. """ label = "Extract Thumbnail" hosts = ["blender"] families = ["review"] order = pyblish.api.ExtractorOrder + 0.01 presets = {} def process(self, instance): self.log.debug("Extracting capture..") if instance.data.get("thumbnailSource"): self.log.debug("Thumbnail source found, skipping...") return stagingdir = self.staging_dir(instance) asset_name = instance.data["assetEntity"]["name"] subset = instance.data["subset"] filename = f"{asset_name}_{subset}" path = os.path.join(stagingdir, filename) self.log.debug(f"Outputting images to {path}") camera = instance.data.get("review_camera", "AUTO") start = instance.data.get("frameStart", bpy.context.scene.frame_start) family = instance.data.get("family") isolate = instance.data("isolate", None) preset = self.presets.get(family, {}) preset.update({ "camera": camera, "start_frame": start, "end_frame": start, "filename": path, "overwrite": True, "isolate": isolate, }) preset.setdefault( "image_settings", { "file_format": "JPEG", "color_mode": "RGB", "quality": 100, }, ) with maintained_time(): path = capture(**preset) thumbnail = os.path.basename(self._fix_output_path(path)) self.log.debug(f"thumbnail: {thumbnail}") instance.data.setdefault("representations", []) representation = { "name": "thumbnail", "ext": "jpg", "files": thumbnail, "stagingDir": stagingdir, "thumbnail": True } instance.data["representations"].append(representation) def _fix_output_path(self, filepath): """"Workaround to return correct filepath. To workaround this we just glob.glob() for any file extensions and assume the latest modified file is the correct file and return it. """ # Catch cancelled playblast if filepath is None: self.log.warning( "Playblast did not result in output path. " "Playblast is probably interrupted." ) return None if not os.path.exists(filepath): files = glob.glob(f"{filepath}.*.jpg") if not files: raise RuntimeError(f"Couldn't find playblast from: {filepath}") filepath = max(files, key=os.path.getmtime) return filepath ================================================ FILE: openpype/hosts/blender/plugins/publish/increment_workfile_version.py ================================================ import pyblish.api from openpype.pipeline.publish import OptionalPyblishPluginMixin from openpype.hosts.blender.api.workio import save_file class IncrementWorkfileVersion( pyblish.api.ContextPlugin, OptionalPyblishPluginMixin ): """Increment current workfile version.""" order = pyblish.api.IntegratorOrder + 0.9 label = "Increment Workfile Version" optional = True hosts = ["blender"] families = ["animation", "model", "rig", "action", "layout", "blendScene", "pointcache", "render.farm"] def process(self, context): if not self.is_active(context.data): return assert all(result["success"] for result in context.data["results"]), ( "Publishing not successful so version is not increased.") from openpype.lib import version_up path = context.data["currentFile"] filepath = version_up(path) save_file(filepath, copy=False) self.log.debug('Incrementing blender workfile version') ================================================ FILE: openpype/hosts/blender/plugins/publish/integrate_animation.py ================================================ import json import pyblish.api from openpype.pipeline.publish import OptionalPyblishPluginMixin class IntegrateAnimation( pyblish.api.InstancePlugin, OptionalPyblishPluginMixin, ): """Generate a JSON file for animation.""" label = "Integrate Animation" order = pyblish.api.IntegratorOrder + 0.1 optional = True hosts = ["blender"] families = ["setdress"] def process(self, instance): self.log.debug("Integrate Animation") representation = instance.data.get('representations')[0] json_path = representation.get('publishedFiles')[0] with open(json_path, "r") as file: data = json.load(file) # Update the json file for the setdress to add the published # representations of the animations for json_dict in data: i = None for elem in instance.context: if elem.data.get('subset') == json_dict['subset']: i = elem break if not i: continue rep = None pub_repr = i.data.get('published_representations') for elem in pub_repr: if pub_repr.get(elem).get('representation').get('name') == "fbx": rep = pub_repr.get(elem) break if not rep: continue obj_id = rep.get('representation').get('_id') if obj_id: json_dict['_id'] = str(obj_id) with open(json_path, "w") as file: json.dump(data, fp=file, indent=2) ================================================ FILE: openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py ================================================ from typing import List import bpy import pyblish.api import openpype.hosts.blender.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateCameraZeroKeyframe(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Camera must have a keyframe at frame 0. Unreal shifts the first keyframe to frame 0. Forcing the camera to have a keyframe at frame 0 will ensure that the animation will be the same in Unreal and Blender. """ order = ValidateContentsOrder hosts = ["blender"] families = ["camera"] label = "Zero Keyframe" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] @staticmethod def get_invalid(instance) -> List: invalid = [] for obj in instance: if isinstance(obj, bpy.types.Object) and obj.type == "CAMERA": if obj.animation_data and obj.animation_data.action: action = obj.animation_data.action frames_set = set() for fcu in action.fcurves: for kp in fcu.keyframe_points: frames_set.add(kp.co[0]) frames = list(frames_set) frames.sort() if frames[0] != 0.0: invalid.append(obj) return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: names = ", ".join(obj.name for obj in invalid) raise PublishValidationError( f"Camera must have a keyframe at frame 0: {names}" ) ================================================ FILE: openpype/hosts/blender/plugins/publish/validate_deadline_publish.py ================================================ import os import bpy import pyblish.api from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) from openpype.hosts.blender.api.render_lib import prepare_rendering class ValidateDeadlinePublish(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates Render File Directory is not the same in every submission """ order = ValidateContentsOrder families = ["render"] hosts = ["blender"] label = "Validate Render Output for Deadline" optional = True actions = [RepairAction] def process(self, instance): if not self.is_active(instance.data): return tree = bpy.context.scene.node_tree output_type = "CompositorNodeOutputFile" output_node = None # Remove all output nodes that inlcude "AYON" in the name. # There should be only one. for node in tree.nodes: if node.bl_idname == output_type and "AYON" in node.name: output_node = node break if not output_node: raise PublishValidationError( "No output node found in the compositor tree." ) filepath = bpy.data.filepath file = os.path.basename(filepath) filename, ext = os.path.splitext(file) if filename not in output_node.base_path: raise PublishValidationError( "Render output folder doesn't match the blender scene name! " "Use Repair action to fix the folder file path." ) @classmethod def repair(cls, instance): container = instance.data["transientData"]["instance_node"] prepare_rendering(container) bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) cls.log.debug("Reset the render output folder...") ================================================ FILE: openpype/hosts/blender/plugins/publish/validate_file_saved.py ================================================ import bpy import pyblish.api from openpype.pipeline.publish import ( OptionalPyblishPluginMixin, PublishValidationError ) class SaveWorkfileAction(pyblish.api.Action): """Save Workfile.""" label = "Save Workfile" on = "failed" icon = "save" def process(self, context, plugin): bpy.ops.wm.avalon_workfiles() class ValidateFileSaved(pyblish.api.ContextPlugin, OptionalPyblishPluginMixin): """Validate that the workfile has been saved.""" order = pyblish.api.ValidatorOrder - 0.01 hosts = ["blender"] label = "Validate File Saved" optional = False exclude_families = [] actions = [SaveWorkfileAction] def process(self, context): if not self.is_active(context.data): return if not context.data["currentFile"]: # File has not been saved at all and has no filename raise PublishValidationError( "Current file is empty. Save the file before continuing." ) # Do not validate workfile has unsaved changes if only instances # present of families that should be excluded families = { instance.data["family"] for instance in context # Consider only enabled instances if instance.data.get("publish", True) and instance.data.get("active", True) } def is_excluded(family): return any(family in exclude_family for exclude_family in self.exclude_families) if all(is_excluded(family) for family in families): self.log.debug("Only excluded families found, skipping workfile " "unsaved changes validation..") return if bpy.data.is_dirty: raise PublishValidationError("Workfile has unsaved changes.") ================================================ FILE: openpype/hosts/blender/plugins/publish/validate_instance_empty.py ================================================ import pyblish.api from openpype.pipeline.publish import PublishValidationError class ValidateInstanceEmpty(pyblish.api.InstancePlugin): """Validator to verify that the instance is not empty""" order = pyblish.api.ValidatorOrder - 0.01 hosts = ["blender"] families = ["model", "pointcache", "rig", "camera" "layout", "blendScene"] label = "Validate Instance is not Empty" optional = False def process(self, instance): # Members are collected by `collect_instance` so we only need to check # whether any member is included. The instance node will be included # as a member as well, hence we will check for at least 2 members if len(instance) < 2: raise PublishValidationError(f"Instance {instance.name} is empty.") ================================================ FILE: openpype/hosts/blender/plugins/publish/validate_mesh_has_uv.py ================================================ from typing import List import bpy import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin, PublishValidationError ) import openpype.hosts.blender.api.action class ValidateMeshHasUvs( pyblish.api.InstancePlugin, OptionalPyblishPluginMixin, ): """Validate that the current mesh has UV's.""" order = ValidateContentsOrder hosts = ["blender"] families = ["model"] label = "Mesh Has UVs" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] optional = True @staticmethod def has_uvs(obj: bpy.types.Object) -> bool: """Check if an object has uv's.""" if not obj.data.uv_layers: return False for uv_layer in obj.data.uv_layers: for polygon in obj.data.polygons: for loop_index in polygon.loop_indices: if ( loop_index >= len(uv_layer.data) or not uv_layer.data[loop_index].uv ): return False return True @classmethod def get_invalid(cls, instance) -> List: invalid = [] for obj in instance: if isinstance(obj, bpy.types.Object) and obj.type == 'MESH': if obj.mode != "OBJECT": cls.log.warning( f"Mesh object {obj.name} should be in 'OBJECT' mode" " to be properly checked." ) if not cls.has_uvs(obj): invalid.append(obj) return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( f"Meshes found in instance without valid UV's: {invalid}" ) ================================================ FILE: openpype/hosts/blender/plugins/publish/validate_mesh_no_negative_scale.py ================================================ from typing import List import bpy import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin, PublishValidationError ) import openpype.hosts.blender.api.action class ValidateMeshNoNegativeScale(pyblish.api.Validator, OptionalPyblishPluginMixin): """Ensure that meshes don't have a negative scale.""" order = ValidateContentsOrder hosts = ["blender"] families = ["model"] label = "Mesh No Negative Scale" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] @staticmethod def get_invalid(instance) -> List: invalid = [] for obj in instance: if isinstance(obj, bpy.types.Object) and obj.type == 'MESH': if any(v < 0 for v in obj.scale): invalid.append(obj) return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: names = ", ".join(obj.name for obj in invalid) raise PublishValidationError( f"Meshes found in instance with negative scale: {names}" ) ================================================ FILE: openpype/hosts/blender/plugins/publish/validate_no_colons_in_name.py ================================================ from typing import List import bpy import pyblish.api import openpype.hosts.blender.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin, PublishValidationError ) class ValidateNoColonsInName(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """There cannot be colons in names Object or bone names cannot include colons. Other software do not handle colons correctly. """ order = ValidateContentsOrder hosts = ["blender"] families = ["model", "rig"] label = "No Colons in names" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] @staticmethod def get_invalid(instance) -> List: invalid = [] for obj in instance: if ':' in obj.name: invalid.append(obj) if isinstance(obj, bpy.types.Object) and obj.type == 'ARMATURE': for bone in obj.data.bones: if ':' in bone.name: invalid.append(obj) break return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: names = ", ".join(obj.name for obj in invalid) raise PublishValidationError( f"Objects found with colon in name: {names}" ) ================================================ FILE: openpype/hosts/blender/plugins/publish/validate_object_mode.py ================================================ from typing import List import bpy import pyblish.api from openpype.pipeline.publish import ( OptionalPyblishPluginMixin, PublishValidationError ) import openpype.hosts.blender.api.action class ValidateObjectIsInObjectMode( pyblish.api.InstancePlugin, OptionalPyblishPluginMixin, ): """Validate that the objects in the instance are in Object Mode.""" order = pyblish.api.ValidatorOrder - 0.01 hosts = ["blender"] families = ["model", "rig", "layout"] label = "Validate Object Mode" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] optional = False @staticmethod def get_invalid(instance) -> List: invalid = [] for obj in instance: if isinstance(obj, bpy.types.Object) and obj.mode != "OBJECT": invalid.append(obj) return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: names = ", ".join(obj.name for obj in invalid) raise PublishValidationError( f"Object found in instance is not in Object Mode: {names}" ) ================================================ FILE: openpype/hosts/blender/plugins/publish/validate_render_camera_is_set.py ================================================ import bpy import pyblish.api from openpype.pipeline.publish import ( OptionalPyblishPluginMixin, PublishValidationError ) class ValidateRenderCameraIsSet(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate that there is a camera set as active for rendering.""" order = pyblish.api.ValidatorOrder hosts = ["blender"] families = ["render"] label = "Validate Render Camera Is Set" optional = False def process(self, instance): if not self.is_active(instance.data): return if not bpy.context.scene.camera: raise PublishValidationError("No camera is active for rendering.") ================================================ FILE: openpype/hosts/blender/plugins/publish/validate_transform_zero.py ================================================ from typing import List import mathutils import bpy import pyblish.api import openpype.hosts.blender.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin, PublishValidationError ) class ValidateTransformZero(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Transforms can't have any values To solve this issue, try freezing the transforms. So long as the transforms, rotation and scale values are zero, you're all good. """ order = ValidateContentsOrder hosts = ["blender"] families = ["model"] label = "Transform Zero" actions = [openpype.hosts.blender.api.action.SelectInvalidAction] _identity = mathutils.Matrix() @classmethod def get_invalid(cls, instance) -> List: invalid = [] for obj in instance: if ( isinstance(obj, bpy.types.Object) and obj.matrix_basis != cls._identity ): invalid.append(obj) return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: names = ", ".join(obj.name for obj in invalid) raise PublishValidationError( "Objects found in instance which do not" f" have transform set to zero: {names}" ) ================================================ FILE: openpype/hosts/celaction/__init__.py ================================================ from .addon import ( CELACTION_ROOT_DIR, CelactionAddon, ) __all__ = ( "CELACTION_ROOT_DIR", "CelactionAddon", ) ================================================ FILE: openpype/hosts/celaction/addon.py ================================================ import os from openpype.modules import OpenPypeModule, IHostAddon CELACTION_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) class CelactionAddon(OpenPypeModule, IHostAddon): name = "celaction" host_name = "celaction" def initialize(self, module_settings): self.enabled = True def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] return [ os.path.join(CELACTION_ROOT_DIR, "hooks") ] def add_implementation_envs(self, env, _app): # Set default values if are not already set via settings defaults = { "LOGLEVEL": "DEBUG" } for key, value in defaults.items(): if not env.get(key): env[key] = value def get_workfile_extensions(self): return [".scn"] ================================================ FILE: openpype/hosts/celaction/hooks/pre_celaction_setup.py ================================================ import os import shutil import winreg import subprocess from openpype.lib import get_openpype_execute_args from openpype.lib.applications import PreLaunchHook, LaunchTypes from openpype.hosts.celaction import CELACTION_ROOT_DIR class CelactionPrelaunchHook(PreLaunchHook): """ Bootstrap celacion with pype """ app_groups = {"celaction"} platforms = {"windows"} launch_types = {LaunchTypes.local} def execute(self): asset_doc = self.data["asset_doc"] width = asset_doc["data"]["resolutionWidth"] height = asset_doc["data"]["resolutionHeight"] # Add workfile path to launch arguments workfile_path = self.workfile_path() if workfile_path: self.launch_context.launch_args.append(workfile_path) # setting output parameters path_user_settings = "\\".join([ "Software", "CelAction", "CelAction2D", "User Settings" ]) winreg.CreateKey(winreg.HKEY_CURRENT_USER, path_user_settings) hKey = winreg.OpenKey( winreg.HKEY_CURRENT_USER, path_user_settings, 0, winreg.KEY_ALL_ACCESS ) path_to_cli = os.path.join( CELACTION_ROOT_DIR, "scripts", "publish_cli.py" ) subprocess_args = get_openpype_execute_args("run", path_to_cli) openpype_executable = subprocess_args.pop(0) workfile_settings = self.get_workfile_settings() winreg.SetValueEx( hKey, "SubmitAppTitle", 0, winreg.REG_SZ, openpype_executable ) # add required arguments for workfile path parameters = subprocess_args + [ "--currentFile", "*SCENE*" ] # Add custom parameters from workfile settings if "render_chunk" in workfile_settings["submission_overrides"]: parameters += [ "--chunk", "*CHUNK*" ] if "resolution" in workfile_settings["submission_overrides"]: parameters += [ "--resolutionWidth", "*X*", "--resolutionHeight", "*Y*" ] if "frame_range" in workfile_settings["submission_overrides"]: parameters += [ "--frameStart", "*START*", "--frameEnd", "*END*" ] winreg.SetValueEx( hKey, "SubmitParametersTitle", 0, winreg.REG_SZ, subprocess.list2cmdline(parameters) ) self.log.debug(f"__ parameters: \"{parameters}\"") # setting resolution parameters path_submit = "\\".join([ path_user_settings, "Dialogs", "SubmitOutput" ]) winreg.CreateKey(winreg.HKEY_CURRENT_USER, path_submit) hKey = winreg.OpenKey( winreg.HKEY_CURRENT_USER, path_submit, 0, winreg.KEY_ALL_ACCESS ) winreg.SetValueEx(hKey, "SaveScene", 0, winreg.REG_DWORD, 1) winreg.SetValueEx(hKey, "CustomX", 0, winreg.REG_DWORD, width) winreg.SetValueEx(hKey, "CustomY", 0, winreg.REG_DWORD, height) # making sure message dialogs don't appear when overwriting path_overwrite_scene = "\\".join([ path_user_settings, "Messages", "OverwriteScene" ]) winreg.CreateKey(winreg.HKEY_CURRENT_USER, path_overwrite_scene) hKey = winreg.OpenKey( winreg.HKEY_CURRENT_USER, path_overwrite_scene, 0, winreg.KEY_ALL_ACCESS ) winreg.SetValueEx(hKey, "Result", 0, winreg.REG_DWORD, 6) winreg.SetValueEx(hKey, "Valid", 0, winreg.REG_DWORD, 1) # set scane as not saved path_scene_saved = "\\".join([ path_user_settings, "Messages", "SceneSaved" ]) winreg.CreateKey(winreg.HKEY_CURRENT_USER, path_scene_saved) hKey = winreg.OpenKey( winreg.HKEY_CURRENT_USER, path_scene_saved, 0, winreg.KEY_ALL_ACCESS ) winreg.SetValueEx(hKey, "Result", 0, winreg.REG_DWORD, 1) winreg.SetValueEx(hKey, "Valid", 0, winreg.REG_DWORD, 1) def workfile_path(self): workfile_path = self.data["last_workfile_path"] # copy workfile from template if doesnt exist any on path if not os.path.exists(workfile_path): # TODO add ability to set different template workfile path via # settings template_path = os.path.join( CELACTION_ROOT_DIR, "resources", "celaction_template_scene.scn" ) if not os.path.exists(template_path): self.log.warning( "Couldn't find workfile template file in {}".format( template_path ) ) return self.log.info( f"Creating workfile from template: \"{template_path}\"" ) # Copy template workfile to new destinantion shutil.copy2( os.path.normpath(template_path), os.path.normpath(workfile_path) ) self.log.info(f"Workfile to open: \"{workfile_path}\"") return workfile_path def get_workfile_settings(self): return self.data["project_settings"]["celaction"]["workfile"] ================================================ FILE: openpype/hosts/celaction/plugins/__init__.py ================================================ ================================================ FILE: openpype/hosts/celaction/plugins/publish/collect_celaction_cli_kwargs.py ================================================ import pyblish.api import sys from pprint import pformat class CollectCelactionCliKwargs(pyblish.api.Collector): """ Collects all keyword arguments passed from the terminal """ label = "Collect Celaction Cli Kwargs" order = pyblish.api.Collector.order - 0.1 def process(self, context): args = list(sys.argv[1:]) self.log.info(str(args)) missing_kwargs = [] passing_kwargs = {} for key in ( "chunk", "frameStart", "frameEnd", "resolutionWidth", "resolutionHeight", "currentFile", ): arg_key = f"--{key}" if arg_key not in args: missing_kwargs.append(key) continue arg_idx = args.index(arg_key) args.pop(arg_idx) if key != "currentFile": value = args.pop(arg_idx) else: path_parts = [] while arg_idx < len(args): path_parts.append(args.pop(arg_idx)) value = " ".join(path_parts).strip('"') passing_kwargs[key] = value if missing_kwargs: self.log.debug("Missing arguments {}".format( ", ".join( [f'"{key}"' for key in missing_kwargs] ) )) self.log.info("Storing kwargs ...") self.log.debug("_ passing_kwargs: {}".format(pformat(passing_kwargs))) # set kwargs to context data context.set_data("passingKwargs", passing_kwargs) # get kwargs onto context data as keys with values for k, v in passing_kwargs.items(): self.log.info(f"Setting `{k}` to instance.data with value: `{v}`") if k in ["frameStart", "frameEnd"]: context.data[k] = passing_kwargs[k] = int(v) else: context.data[k] = v ================================================ FILE: openpype/hosts/celaction/plugins/publish/collect_celaction_instances.py ================================================ import os import pyblish.api from openpype.client import get_asset_name_identifier class CollectCelactionInstances(pyblish.api.ContextPlugin): """ Adds the celaction render instances """ label = "Collect Celaction Instances" order = pyblish.api.CollectorOrder + 0.1 def process(self, context): task = context.data["task"] current_file = context.data["currentFile"] staging_dir = os.path.dirname(current_file) scene_file = os.path.basename(current_file) version = context.data["version"] asset_entity = context.data["assetEntity"] project_entity = context.data["projectEntity"] asset_name = get_asset_name_identifier(asset_entity) shared_instance_data = { "asset": asset_name, "frameStart": asset_entity["data"]["frameStart"], "frameEnd": asset_entity["data"]["frameEnd"], "handleStart": asset_entity["data"]["handleStart"], "handleEnd": asset_entity["data"]["handleEnd"], "fps": asset_entity["data"]["fps"], "resolutionWidth": asset_entity["data"].get( "resolutionWidth", project_entity["data"]["resolutionWidth"]), "resolutionHeight": asset_entity["data"].get( "resolutionHeight", project_entity["data"]["resolutionHeight"]), "pixelAspect": 1, "step": 1, "version": version } celaction_kwargs = context.data.get( "passingKwargs", {}) if celaction_kwargs: shared_instance_data.update(celaction_kwargs) # workfile instance family = "workfile" subset = family + task.capitalize() # Create instance instance = context.create_instance(subset) # creating instance data instance.data.update({ "subset": subset, "label": scene_file, "family": family, "families": [], "representations": [] }) # adding basic script data instance.data.update(shared_instance_data) # creating representation representation = { 'name': 'scn', 'ext': 'scn', 'files': scene_file, "stagingDir": staging_dir, } instance.data["representations"].append(representation) self.log.info('Publishing Celaction workfile') # render instance subset = f"render{task}Main" instance = context.create_instance(name=subset) # getting instance state instance.data["publish"] = True # add assetEntity data into instance instance.data.update({ "label": "{} - farm".format(subset), "family": "render.farm", "families": [], "subset": subset }) # adding basic script data instance.data.update(shared_instance_data) self.log.info('Publishing Celaction render instance') self.log.debug(f"Instance data: `{instance.data}`") for i in context: self.log.debug(f"{i.data['families']}") ================================================ FILE: openpype/hosts/celaction/plugins/publish/collect_render_path.py ================================================ import os import pyblish.api import copy class CollectRenderPath(pyblish.api.InstancePlugin): """Generate file and directory path where rendered images will be""" label = "Collect Render Path" order = pyblish.api.CollectorOrder + 0.495 families = ["render.farm"] # Presets output_extension = "png" anatomy_template_key_render_files = None anatomy_template_key_metadata = None def process(self, instance): anatomy = instance.context.data["anatomy"] anatomy_data = copy.deepcopy(instance.data["anatomyData"]) padding = anatomy.templates.get("frame_padding", 4) anatomy_data.update({ "frame": f"%0{padding}d", "family": "render", "representation": self.output_extension, "ext": self.output_extension }) anatomy_filled = anatomy.format(anatomy_data) # get anatomy rendering keys r_anatomy_key = self.anatomy_template_key_render_files m_anatomy_key = self.anatomy_template_key_metadata # get folder and path for rendering images from celaction render_dir = anatomy_filled[r_anatomy_key]["folder"] render_path = anatomy_filled[r_anatomy_key]["path"] self.log.debug("__ render_path: `{}`".format(render_path)) # create dir if it doesnt exists try: if not os.path.isdir(render_dir): os.makedirs(render_dir, exist_ok=True) except OSError: # directory is not available self.log.warning("Path is unreachable: `{}`".format(render_dir)) # add rendering path to instance data instance.data["path"] = render_path # get anatomy for published renders folder path if anatomy_filled.get(m_anatomy_key): instance.data["publishRenderMetadataFolder"] = anatomy_filled[ m_anatomy_key]["folder"] self.log.info("Metadata render path: `{}`".format( instance.data["publishRenderMetadataFolder"] )) self.log.info(f"Render output path set to: `{render_path}`") ================================================ FILE: openpype/hosts/celaction/plugins/publish/integrate_version_up.py ================================================ import shutil import openpype import pyblish.api class VersionUpScene(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder + 0.5 label = 'Version Up Scene' families = ['workfile'] optional = True active = True def process(self, context): current_file = context.data.get('currentFile') v_up = openpype.lib.version_up(current_file) self.log.debug('Current file is: {}'.format(current_file)) self.log.debug('Version up: {}'.format(v_up)) shutil.copy2(current_file, v_up) self.log.info('Scene saved into new version: {}'.format(v_up)) ================================================ FILE: openpype/hosts/celaction/scripts/__init__.py ================================================ ================================================ FILE: openpype/hosts/celaction/scripts/publish_cli.py ================================================ import os import sys import pyblish.api import pyblish.util import openpype.hosts.celaction from openpype.lib import Logger from openpype.tools.utils import host_tools from openpype.pipeline import install_openpype_plugins log = Logger.get_logger("celaction") PUBLISH_HOST = "celaction" HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.celaction.__file__)) PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") def main(): # Registers pype's Global pyblish plugins install_openpype_plugins() if os.path.exists(PUBLISH_PATH): log.info(f"Registering path: {PUBLISH_PATH}") pyblish.api.register_plugin_path(PUBLISH_PATH) pyblish.api.register_host(PUBLISH_HOST) pyblish.api.register_target("local") return host_tools.show_publish() if __name__ == "__main__": result = main() sys.exit(not bool(result)) ================================================ FILE: openpype/hosts/equalizer/__init__.py ================================================ from .addon import ( EqualizerAddon, EQUALIZER_HOST_DIR, ) __all__ = [ "EqualizerAddon", "EQUALIZER_HOST_DIR", ] ================================================ FILE: openpype/hosts/equalizer/addon.py ================================================ import os from openpype.modules import OpenPypeModule, IHostAddon EQUALIZER_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) class EqualizerAddon(OpenPypeModule, IHostAddon): name = "equalizer" host_name = "equalizer" heartbeat = 500 def initialize(self, module_settings): self.heartbeat = module_settings.get("heartbeat_interval", 500) self.enabled = True def add_implementation_envs(self, env, _app): # 3dEqualizer utilize TDE4_ROOT for its root directory # and PYTHON_CUSTOM_SCRIPTS_3DE4 as a colon separated list of # directories to look for additional python scripts. # (Windows: list is separated by semicolons). # Ad startup_path = os.path.join(EQUALIZER_HOST_DIR, "startup") if "PYTHON_CUSTOM_SCRIPTS_3DE4" in env: startup_path = os.path.join( env["PYTHON_CUSTOM_SCRIPTS_3DE4"], startup_path) env["PYTHON_CUSTOM_SCRIPTS_3DE4"] = startup_path env["AYON_TDE4_HEARTBEAT_INTERVAL"] = str(self.heartbeat) def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] return [ os.path.join(EQUALIZER_HOST_DIR, "hooks") ] def get_workfile_extensions(self): return [".3de"] ================================================ FILE: openpype/hosts/equalizer/api/__init__.py ================================================ from .host import EqualizerHost from .plugin import EqualizerCreator, ExtractScriptBase from .pipeline import Container, maintained_model_selection __all__ = [ "EqualizerHost", "EqualizerCreator", "Container", "ExtractScriptBase", "maintained_model_selection", ] ================================================ FILE: openpype/hosts/equalizer/api/host.py ================================================ """3dequalizer host implementation. note: 3dequalizer 7.1v2 uses Python 3.7.9 """ import json import os import re import pyblish.api import tde4 # noqa: F401 from attrs import asdict from attrs.exceptions import NotAnAttrsClassError from qtpy import QtCore, QtWidgets from openpype.host import HostBase, ILoadHost, IPublishHost, IWorkfileHost from openpype.hosts.equalizer import EQUALIZER_HOST_DIR from openpype.hosts.equalizer.api.pipeline import Container from openpype.pipeline import ( register_creator_plugin_path, register_loader_plugin_path, ) CONTEXT_REGEX = re.compile( r"AYON_CONTEXT::(?P.*?)::AYON_CONTEXT_END", re.DOTALL) PLUGINS_DIR = os.path.join(EQUALIZER_HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") class EqualizerHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): name = "equalizer" _instance = None def __new__(cls): # singleton - ensure only one instance of the host is created. # This is necessary because 3DEqualizer doesn't have a way to # store custom data, so we need to store it in the project notes. if not hasattr(cls, "_instance") or not cls._instance: cls._instance = super(EqualizerHost, cls).__new__(cls) return cls._instance def __init__(self): self._qapp = None super(EqualizerHost, self).__init__() def workfile_has_unsaved_changes(self): """Return the state of the current workfile. 3DEqualizer returns state as 1 or zero, so we need to invert it. Returns: bool: True if the current workfile has unsaved changes. """ return not bool(tde4.isProjectUpToDate()) def get_workfile_extensions(self): return [".3de"] def save_workfile(self, dst_path=None): if not dst_path: dst_path = tde4.getProjectPath() result = tde4.saveProject(dst_path, True) if not bool(result): raise RuntimeError(f"Failed to save workfile {dst_path}.") return dst_path def open_workfile(self, filepath): result = tde4.loadProject(filepath, True) if not bool(result): raise RuntimeError(f"Failed to open workfile {filepath}.") return filepath def get_current_workfile(self): return tde4.getProjectPath() def get_containers(self): context = self.get_context_data() if context: return context.get("containers", []) return [] def add_container(self, container: Container): context_data = self.get_context_data() containers = self.get_containers() for _container in containers: if _container["name"] == container.name and _container["namespace"] == container.namespace: # noqa: E501 containers.remove(_container) break try: containers.append(asdict(container)) except NotAnAttrsClassError: print("not an attrs class") containers.append(container) context_data["containers"] = containers self.update_context_data(context_data, changes={}) def get_context_data(self) -> dict: """Get context data from the current workfile. 3Dequalizer doesn't have any custom node or other place to store metadata, so we store context data in the project notes encoded as JSON and wrapped in a special guard string `AYON_CONTEXT::...::AYON_CONTEXT_END`. Returns: dict: Context data. """ # sourcery skip: use-named-expression m = re.search(CONTEXT_REGEX, tde4.getProjectNotes()) try: context = json.loads(m["context"]) if m else {} except ValueError: self.log.debug("context data is not valid json") context = {} return context def update_context_data(self, data, changes): """Update context data in the current workfile. Serialize context data as json and store it in the project notes. If the context data is not found, create a placeholder there. See `get_context_data` for more info. Args: data (dict): Context data. changes (dict): Changes to the context data. Raises: RuntimeError: If the context data is not found. """ notes = tde4.getProjectNotes() m = re.search(CONTEXT_REGEX, notes) if not m: # context data not found, create empty placeholder tde4.setProjectNotes( f"{tde4.getProjectNotes()}\n" f"AYON_CONTEXT::::AYON_CONTEXT_END\n") original_data = self.get_context_data() updated_data = original_data.copy() updated_data.update(data) update_str = json.dumps(updated_data or {}, indent=4) tde4.setProjectNotes( re.sub( CONTEXT_REGEX, f"AYON_CONTEXT::{update_str}::AYON_CONTEXT_END", tde4.getProjectNotes() ) ) tde4.updateGUI() def install(self): if not QtCore.QCoreApplication.instance(): app = QtWidgets.QApplication([]) self._qapp = app self._qapp.setQuitOnLastWindowClosed(False) pyblish.api.register_host("equalizer") pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) heartbeat_interval = os.getenv("AYON_TDE4_HEARTBEAT_INTERVAL") or 500 tde4.setTimerCallbackFunction( "EqualizerHost._timer", int(heartbeat_interval)) @staticmethod def _timer(): QtWidgets.QApplication.instance().processEvents( QtCore.QEventLoop.AllEvents) @classmethod def get_host(cls): return cls._instance def get_main_window(self): return self._qapp.activeWindow() ================================================ FILE: openpype/hosts/equalizer/api/pipeline.py ================================================ from attrs import field, define from openpype.pipeline import AVALON_CONTAINER_ID import contextlib import tde4 @define class Container(object): name: str = field(default=None) id: str = field(init=False, default=AVALON_CONTAINER_ID) namespace: str = field(default="") loader: str = field(default=None) representation: str = field(default=None) @contextlib.contextmanager def maintained_model_selection(): """Maintain model selection during context.""" point_groups = tde4.getPGroupList() point_group = next( ( pg for pg in point_groups if tde4.getPGroupType(pg) == "CAMERA" ), None ) selected_models = tde4.get3DModelList(point_group, 1)\ if point_group else [] try: yield finally: if point_group: # 3 restore model selection for model in tde4.get3DModelList(point_group, 0): if model in selected_models: tde4.set3DModelSelectionFlag(point_group, model, 1) else: tde4.set3DModelSelectionFlag(point_group, model, 0) ================================================ FILE: openpype/hosts/equalizer/api/plugin.py ================================================ """Base plugin class for 3DEqualizer. note: 3dequalizer 7.1v2 uses Python 3.7.9 """ from abc import ABC from typing import Dict, List from openpype.hosts.equalizer.api import EqualizerHost from openpype.lib import BoolDef, EnumDef, NumberDef from openpype.pipeline import ( CreatedInstance, Creator, OptionalPyblishPluginMixin, ) class EqualizerCreator(ABC, Creator): @property def host(self) -> EqualizerHost: """Return the host application.""" # We need to cast the host to EqualizerHost, because the Creator # class is not aware of the host application. return super().host def create(self, subset_name, instance_data, pre_create_data): """Create a subset in the host application. Args: subset_name (str): Name of the subset to create. instance_data (dict): Data of the instance to create. pre_create_data (dict): Data from the pre-create step. Returns: openpype.pipeline.CreatedInstance: Created instance. """ self.log.debug("EqualizerCreator.create") instance = CreatedInstance( self.family, subset_name, instance_data, self) self._add_instance_to_context(instance) return instance def collect_instances(self): """Collect instances from the host application. Returns: list[openpype.pipeline.CreatedInstance]: List of instances. """ for instance_data in self.host.get_context_data().get( "publish_instances", []): created_instance = CreatedInstance.from_existing( instance_data, self ) self._add_instance_to_context(created_instance) def update_instances(self, update_list): # if not update_list: # return context = self.host.get_context_data() if not context.get("publish_instances"): context["publish_instances"] = [] instances_by_id = {} for instance in context.get("publish_instances"): # sourcery skip: use-named-expression instance_id = instance.get("instance_id") if instance_id: instances_by_id[instance_id] = instance for instance, changes in update_list: new_instance_data = changes.new_value instance_data = instances_by_id.get(instance.id) # instance doesn't exist, append everything if instance_data is None: context["publish_instances"].append(new_instance_data) continue # update only changed values on instance for key in set(instance_data) - set(new_instance_data): instance_data.pop(key) instance_data.update(new_instance_data) self.host.update_context_data(context, changes=update_list) def remove_instances(self, instances: List[Dict]): context = self.host.get_context_data() if not context.get("publish_instances"): context["publish_instances"] = [] ids_to_remove = [ instance.get("instance_id") for instance in instances ] for instance in context.get("publish_instances"): if instance.get("instance_id") in ids_to_remove: context["publish_instances"].remove(instance) self.host.update_context_data(context, changes={}) class ExtractScriptBase(OptionalPyblishPluginMixin): """Base class for extract script plugins.""" hide_reference_frame = False export_uv_textures = False overscan_percent_width = 100 overscan_percent_height = 100 units = "mm" @classmethod def apply_settings(cls, project_settings, system_settings): settings = project_settings["equalizer"]["publish"][ "ExtractMatchmoveScriptMaya"] # noqa cls.hide_reference_frame = settings.get( "hide_reference_frame", cls.hide_reference_frame) cls.export_uv_textures = settings.get( "export_uv_textures", cls.export_uv_textures) cls.overscan_percent_width = settings.get( "overscan_percent_width", cls.overscan_percent_width) cls.overscan_percent_height = settings.get( "overscan_percent_height", cls.overscan_percent_height) cls.units = settings.get("units", cls.units) @classmethod def get_attribute_defs(cls): defs = super(ExtractScriptBase, cls).get_attribute_defs() defs.extend([ BoolDef("hide_reference_frame", label="Hide Reference Frame", default=cls.hide_reference_frame), BoolDef("export_uv_textures", label="Export UV Textures", default=cls.export_uv_textures), NumberDef("overscan_percent_width", label="Overscan Width %", default=cls.overscan_percent_width, decimals=0, minimum=1, maximum=1000), NumberDef("overscan_percent_height", label="Overscan Height %", default=cls.overscan_percent_height, decimals=0, minimum=1, maximum=1000), EnumDef("units", ["mm", "cm", "m", "in", "ft", "yd"], default=cls.units, label="Units"), ]) return defs ================================================ FILE: openpype/hosts/equalizer/hooks/pre_pyside2_install.py ================================================ """Install PySide2 python module to 3dequalizer's python. If 3dequalizer doesn't have PySide2 module installed, it will try to install it. Note: This needs to be changed in the future so the UI is decoupled from the host application. """ import contextlib import os import subprocess from pathlib import Path from platform import system from openpype.lib.applications import LaunchTypes, PreLaunchHook class InstallPySide2(PreLaunchHook): """Install Qt binding to 3dequalizer's python packages.""" app_groups = {"3dequalizer", "sdv_3dequalizer"} launch_types = {LaunchTypes.local} def execute(self): try: self._execute() except Exception: self.log.warning(( f"Processing of {self.__class__.__name__} " "crashed."), exc_info=True ) def _execute(self): platform = system().lower() executable = Path(self.launch_context.executable.executable_path) expected_executable = "3de4" if platform == "windows": expected_executable += ".exe" if not self.launch_context.env.get("TDE4_HOME"): if executable.name.lower() != expected_executable: self.log.warning(( f"Executable {executable.as_posix()} does not lead " f"to {expected_executable} file. " "Can't determine 3dequalizer's python to " f"check/install PySide2. {executable.name}" )) return python_dir = executable.parent.parent / "sys_data" / "py37_inst" else: python_dir = Path(self.launch_context.env["TDE4_HOME"]) / "sys_data" / "py37_inst" # noqa: E501 if platform == "windows": python_executable = python_dir / "python.exe" else: python_executable = python_dir / "python" # Check for python with enabled 'pymalloc' if not python_executable.exists(): python_executable = python_dir / "pythonm" if not python_executable.exists(): self.log.warning( "Couldn't find python executable " f"for 3de4 {python_executable.as_posix()}" ) return # Check if PySide2 is installed and skip if yes if self.is_pyside_installed(python_executable): self.log.debug("3Dequalizer has already installed PySide2.") return # Install PySide2 in 3de4's python if platform == "windows": result = self.install_pyside_windows(python_executable) else: result = self.install_pyside(python_executable) if result: self.log.info("Successfully installed PySide2 module to 3de4.") else: self.log.warning("Failed to install PySide2 module to 3de4.") def install_pyside_windows(self, python_executable: Path): """Install PySide2 python module to 3de4's python. Installation requires administration rights that's why it is required to use "pywin32" module which can execute command's and ask for administration rights. Note: This is asking for administrative right always, no matter if it is actually needed or not. Unfortunately getting correct permissions for directory on Windows isn't that trivial. You can either use `win32security` module or run `icacls` command in subprocess and parse its output. """ try: import pywintypes import win32con import win32event import win32process from win32comext.shell import shellcon from win32comext.shell.shell import ShellExecuteEx except Exception: self.log.warning("Couldn't import 'pywin32' modules") return with contextlib.suppress(pywintypes.error): # Parameters # - use "-m pip" as module pip to install PySide2 and argument # "--ignore-installed" is to force install module to 3de4's # site-packages and make sure it is binary compatible parameters = "-m pip install --ignore-installed PySide2" # Execute command and ask for administrator's rights process_info = ShellExecuteEx( nShow=win32con.SW_SHOWNORMAL, fMask=shellcon.SEE_MASK_NOCLOSEPROCESS, lpVerb="runas", lpFile=python_executable.as_posix(), lpParameters=parameters, lpDirectory=python_executable.parent.as_posix() ) process_handle = process_info["hProcess"] win32event.WaitForSingleObject( process_handle, win32event.INFINITE) return_code = win32process.GetExitCodeProcess(process_handle) return return_code == 0 def install_pyside(self, python_executable: Path): """Install PySide2 python module to 3de4's python.""" args = [ python_executable.as_posix(), "-m", "pip", "install", "--ignore-installed", "PySide2", ] try: # Parameters # - use "-m pip" as module pip to install PySide2 and argument # "--ignore-installed" is to force install module to 3de4 # site-packages and make sure it is binary compatible process = subprocess.Popen( args, stdout=subprocess.PIPE, universal_newlines=True ) process.communicate() return process.returncode == 0 except PermissionError: self.log.warning( f'Permission denied with command:\"{" ".join(args)}\".') except OSError as error: self.log.warning(f"OS error has occurred: \"{error}\".") except subprocess.SubprocessError: pass @staticmethod def is_pyside_installed(python_executable: Path) -> bool: """Check if PySide2 module is in 3de4 python env. Args: python_executable (Path): Path to python executable. Returns: bool: True if PySide2 is installed, False otherwise. """ # Get pip list from 3de4's python executable args = [python_executable.as_posix(), "-m", "pip", "list"] process = subprocess.Popen(args, stdout=subprocess.PIPE) stdout, _ = process.communicate() lines = stdout.decode().split(os.linesep) # Second line contain dashes that define maximum length of module name. # Second column of dashes define maximum length of module version. package_dashes, *_ = lines[1].split(" ") package_len = len(package_dashes) # Got through printed lines starting at line 3 for idx in range(2, len(lines)): line = lines[idx] if not line: continue package_name = line[:package_len].strip() if package_name.lower() == "pyside2": return True return False ================================================ FILE: openpype/hosts/equalizer/plugins/__init__.py ================================================ ================================================ FILE: openpype/hosts/equalizer/plugins/create/__init__.py ================================================ ================================================ FILE: openpype/hosts/equalizer/plugins/create/create_lens_distortion_data.py ================================================ from openpype.hosts.equalizer.api import EqualizerCreator class CreateLensDistortionData(EqualizerCreator): identifier = "io.openpype.creators.equalizer.lens_distortion" label = "Lens Distortion" family = "lensDistortion" icon = "glasses" def create(self, subset_name, instance_data, pre_create_data): super().create(subset_name, instance_data, pre_create_data) ================================================ FILE: openpype/hosts/equalizer/plugins/create/create_matchmove.py ================================================ import tde4 from openpype.hosts.equalizer.api import EqualizerCreator from openpype.lib import EnumDef class CreateMatchMove(EqualizerCreator): identifier = "io.openpype.creators.equalizer.matchmove" label = "Match Move" family = "matchmove" icon = "camera" def get_instance_attr_defs(self): camera_enum = [ {"value": "__all__", "label": "All Cameras"}, {"value": "__current__", "label": "Current Camera"}, {"value": "__ref__", "label": "Reference Cameras"}, {"value": "__seq__", "label": "Sequence Cameras"}, ] camera_list = tde4.getCameraList() camera_enum.extend( {"label": tde4.getCameraName(camera), "value": camera} for camera in camera_list if tde4.getCameraEnabledFlag(camera) ) # try to get list of models model_enum = [ {"value": "__none__", "label": "No 3D Models At All"}, {"value": "__all__", "label": "All 3D Models"}, ] point_groups = tde4.getPGroupList() for point_group in point_groups: model_list = tde4.get3DModelList(point_group, 0) model_enum.extend( { "label": tde4.get3DModelName(point_group, model), "value": model } for model in model_list ) return [ EnumDef("camera_selection", items=camera_enum, default="__current__", label="Camera(s) to publish", tooltip="Select cameras to publish"), EnumDef("model_selection", items=model_enum, default="__none__", label="Model(s) to publish", tooltip="Select models to publish"), ] def create(self, subset_name, instance_data, pre_create_data): self.log.debug("CreateMatchMove.create") super().create(subset_name, instance_data, pre_create_data) ================================================ FILE: openpype/hosts/equalizer/plugins/load/__init__.py ================================================ ================================================ FILE: openpype/hosts/equalizer/plugins/load/load_plate.py ================================================ """Loader for image sequences. This loads published sequence to the current camera because this workflow is the most common in production. If current camera is not defined, it will try to use first camera and if there is no camera at all, it will create new one. TODO: * Support for setting handles, calculation frame ranges, EXR options, etc. * Add support for color management - at least put correct gamma to image corrections. """ import tde4 import openpype.pipeline.load as load from openpype.client import get_version_by_id from openpype.hosts.equalizer.api import Container, EqualizerHost from openpype.lib.transcoding import IMAGE_EXTENSIONS from openpype.pipeline import ( get_current_project_name, get_representation_context, ) class LoadPlate(load.LoaderPlugin): families = [ "imagesequence", "review", "render", "plate", "image", "online", ] representations = ["*"] extensions = {ext.lstrip(".") for ext in IMAGE_EXTENSIONS} label = "Load sequence" order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, options=None): representation = context["representation"] project_name = get_current_project_name() version = get_version_by_id(project_name, representation["parent"]) file_path = self.file_path(representation, context) camera = tde4.createCamera("SEQUENCE") tde4.setCameraName(camera, name) camera_name = tde4.getCameraName(camera) print( f"Loading: {file_path} into {camera_name}") # set the path to sequence on the camera tde4.setCameraPath(camera, file_path) # set the sequence attributes star/end/step tde4.setCameraSequenceAttr( camera, int(version["data"].get("frameStart")), int(version["data"].get("frameEnd")), 1) container = Container( name=name, namespace=camera_name, loader=self.__class__.__name__, representation=str(representation["_id"]), ) print(container) EqualizerHost.get_host().add_container(container) def update(self, container, representation): camera_list = tde4.getCameraList() try: camera = [ c for c in camera_list if tde4.getCameraName(c) == container["namespace"] ][0] except IndexError: self.log.error(f'Cannot find camera {container["namespace"]}') print(f'Cannot find camera {container["namespace"]}') return context = get_representation_context(representation) file_path = self.file_path(representation, context) # set the path to sequence on the camera tde4.setCameraPath(camera, file_path) version = get_version_by_id( get_current_project_name(), representation["parent"]) # set the sequence attributes star/end/step tde4.setCameraSequenceAttr( camera, int(version["data"].get("frameStart")), int(version["data"].get("frameEnd")), 1) print(container) EqualizerHost.get_host().add_container(container) def switch(self, container, representation): self.update(container, representation) def file_path(self, representation, context): is_sequence = len(representation["files"]) > 1 print(f"is sequence {is_sequence}") if is_sequence: frame = representation["context"]["frame"] hashes = "#" * len(str(frame)) if ( "{originalBasename}" in representation["data"]["template"] ): origin_basename = context["originalBasename"] context["originalBasename"] = origin_basename.replace( frame, hashes ) # Replace the frame with the hash in the frame representation["context"]["frame"] = hashes return self.filepath_from_context(context) ================================================ FILE: openpype/hosts/equalizer/plugins/publish/__init__.py ================================================ ================================================ FILE: openpype/hosts/equalizer/plugins/publish/collect_3de_installation_dir.py ================================================ """Collect camera data from the scene.""" import pyblish.api import tde4 from pathlib import Path class Collect3DE4InstallationDir(pyblish.api.InstancePlugin): """Collect camera data from the scene.""" order = pyblish.api.CollectorOrder hosts = ["equalizer"] label = "Collect 3Dequalizer directory" def process(self, instance): tde4_path = Path(tde4.get3DEInstallPath()) instance.data["tde4_path"] = tde4_path ================================================ FILE: openpype/hosts/equalizer/plugins/publish/collect_camera_data.py ================================================ """Collect camera data from the scene.""" import pyblish.api import tde4 class CollectCameraData(pyblish.api.InstancePlugin): """Collect camera data from the scene.""" order = pyblish.api.CollectorOrder families = ["matchmove"] hosts = ["equalizer"] label = "Collect camera data" def process(self, instance: pyblish.api.Instance): # handle camera selection. # possible values are: # - __current__ - current camera # - __ref__ - reference cameras # - __seq__ - sequence cameras # - __all__ - all cameras # - camera_id - specific camera try: camera_sel = instance.data["creator_attributes"]["camera_selection"] # noqa: E501 except KeyError: self.log.warning("No camera defined") return if camera_sel == "__all__": cameras = tde4.getCameraList() elif camera_sel == "__current__": cameras = [tde4.getCurrentCamera()] elif camera_sel in ["__ref__", "__seq__"]: cameras = [ c for c in tde4.getCameraList() if tde4.getCameraType(c) == "REF_FRAME" ] else: if camera_sel not in tde4.getCameraList(): self.log.warning("Invalid camera found") return cameras = [camera_sel] data = [] for camera in cameras: camera_name = tde4.getCameraName(camera) enabled = tde4.getCameraEnabledFlag(camera) # calculation range c_range_start, c_range_end = tde4.getCameraCalculationRange( camera) p_range_start, p_range_end = tde4.getCameraPlaybackRange(camera) fov = tde4.getCameraFOV(camera) fps = tde4.getCameraFPS(camera) # focal length is time based, so lets skip it for now # focal_length = tde4.getCameraFocalLength(camera, frame) path = tde4.getCameraPath(camera) camera_data = { "name": camera_name, "id": camera, "enabled": enabled, "calculation_range": (c_range_start, c_range_end), "playback_range": (p_range_start, p_range_end), "fov": fov, "fps": fps, # "focal_length": focal_length, "path": path } data.append(camera_data) instance.data["cameras"] = data ================================================ FILE: openpype/hosts/equalizer/plugins/publish/collect_workfile.py ================================================ # -*- coding: utf-8 -*- """Collect current work file.""" from pathlib import Path import pyblish.api import tde4 class CollectWorkfile(pyblish.api.ContextPlugin): """Inject the current working file into context""" order = pyblish.api.CollectorOrder - 0.01 label = "Collect 3DE4 Workfile" hosts = ['equalizer'] def process(self, context: pyblish.api.Context): """Inject the current working file.""" project_file = Path(tde4.getProjectPath()) current_file = project_file.as_posix() context.data['currentFile'] = current_file filename = project_file.stem ext = project_file.suffix task = context.data["task"] data = {} # create instance instance = context.create_instance(name=filename) subset = f'workfile{task.capitalize()}' data = { "subset": subset, "asset": context.data["asset"], "label": subset, "publish": True, "family": 'workfile', "families": ['workfile'], "setMembers": [current_file], "frameStart": context.data['frameStart'], "frameEnd": context.data['frameEnd'], "handleStart": context.data['handleStart'], "handleEnd": context.data['handleEnd'], "representations": [ { "name": ext.lstrip("."), "ext": ext.lstrip("."), "files": project_file.name, "stagingDir": project_file.parent.as_posix(), } ] } instance.data.update(data) self.log.info(f'Collected instance: {project_file.name}') self.log.info(f'Scene path: {current_file}') self.log.info(f'staging Dir: {project_file.parent.as_posix()}') self.log.info(f'subset: {subset}') ================================================ FILE: openpype/hosts/equalizer/plugins/publish/extract_lens_distortion_nuke.py ================================================ from pathlib import Path import pyblish.api import tde4 # noqa: F401 from openpype.lib import import_filepath from openpype.pipeline import OptionalPyblishPluginMixin, publish class ExtractLensDistortionNuke(publish.Extractor, OptionalPyblishPluginMixin): """Extract Nuke script for matchmove. Unfortunately built-in export script from 3DEqualizer is bound to its UI, and it is not possible to call it directly from Python. Because of that, we are executing the script in the same way as artist would do it, but we are patching the UI to silence it and to avoid any user interaction. TODO: Utilize attributes defined in ExtractScriptBase """ label = "Extract Lens Distortion Nuke node" families = ["lensDistortion"] hosts = ["equalizer"] order = pyblish.api.ExtractorOrder def process(self, instance: pyblish.api.Instance): if not self.is_active(instance.data): return cam = tde4.getCurrentCamera() offset = tde4.getCameraFrameOffset(cam) staging_dir = self.staging_dir(instance) file_path = Path(staging_dir) / "nuke_ld_export.nk" # import export script from 3DEqualizer exporter_path = instance.data["tde4_path"] / "sys_data" / "py_scripts" / "export_nuke_LD_3DE4_Lens_Distortion_Node.py" # noqa: E501 self.log.debug(f"Importing {exporter_path.as_posix()}") exporter = import_filepath(exporter_path.as_posix()) exporter.exportNukeDewarpNode(cam, offset, file_path.as_posix()) # create representation data if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': "lensDistortion", 'ext': "nk", 'files': file_path.name, "stagingDir": staging_dir, } self.log.debug(f"output: {file_path.as_posix()}") instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/equalizer/plugins/publish/extract_matchmove_script_maya.py ================================================ # -*- coding: utf-8 -*- """Extract project for Maya""" from pathlib import Path import pyblish.api import tde4 from openpype.hosts.equalizer.api import ( ExtractScriptBase, maintained_model_selection, ) from openpype.lib import import_filepath from openpype.pipeline import ( KnownPublishError, OptionalPyblishPluginMixin, publish, ) class ExtractMatchmoveScriptMaya(publish.Extractor, ExtractScriptBase, OptionalPyblishPluginMixin): """Extract Maya MEL script for matchmove. This is using built-in export script from 3DEqualizer. """ label = "Extract Maya Script" families = ["matchmove"] hosts = ["equalizer"] order = pyblish.api.ExtractorOrder def process(self, instance: pyblish.api.Instance): """Extracts Maya script from 3DEqualizer. This method is using export script shipped with 3DEqualizer to maintain as much compatibility as possible. Instead of invoking it from the UI, it calls directly the function that is doing the export. For that it needs to pass some data that are collected in 3dequalizer from the UI, so we need to determine them from the instance itself and from the state of the project. """ if not self.is_active(instance.data): return attr_data = self.get_attr_values_from_data(instance.data) # import maya export script from 3DEqualizer exporter_path = instance.data["tde4_path"] / "sys_data" / "py_scripts" / "export_maya.py" # noqa: E501 self.log.debug(f"Importing {exporter_path.as_posix()}") exporter = import_filepath(exporter_path.as_posix()) # get camera point group point_group = None point_groups = tde4.getPGroupList() for pg in point_groups: if tde4.getPGroupType(pg) == "CAMERA": point_group = pg break else: # this should never happen as it should be handled by validator raise RuntimeError("No camera point group found.") offset = tde4.getCameraFrameOffset(tde4.getCurrentCamera()) overscan_width = attr_data["overscan_percent_width"] / 100.0 overscan_height = attr_data["overscan_percent_height"] / 100.0 staging_dir = self.staging_dir(instance) unit_scales = { "mm": 10.0, # cm -> mm "cm": 1.0, # cm -> cm "m": 0.01, # cm -> m "in": 0.393701, # cm -> in "ft": 0.0328084, # cm -> ft "yd": 0.0109361 # cm -> yd } scale_factor = unit_scales[attr_data["units"]] model_selection_enum = instance.data["creator_attributes"]["model_selection"] # noqa: E501 with maintained_model_selection(): # handle model selection # We are passing it to existing function that is expecting # this value to be an index of selection type. # 1 - No models # 2 - Selected models # 3 - All models if model_selection_enum == "__none__": model_selection = 1 elif model_selection_enum == "__all__": model_selection = 3 else: # take model from instance and set its selection flag on # turn off all others model_selection = 2 point_groups = tde4.getPGroupList() for point_group in point_groups: model_list = tde4.get3DModelList(point_group, 0) if model_selection_enum in model_list: model_selection = 2 tde4.set3DModelSelectionFlag( point_group, instance.data["model_selection"], 1) break else: # clear all other model selections for model in model_list: tde4.set3DModelSelectionFlag(point_group, model, 0) file_path = Path(staging_dir) / "maya_export.mel" status = exporter._maya_export_mel_file( file_path.as_posix(), # staging path point_group, # camera point group [c["id"] for c in instance.data["cameras"] if c["enabled"]], model_selection, # model selection mode overscan_width, overscan_height, 1 if attr_data["export_uv_textures"] else 0, scale_factor, offset, # start frame 1 if attr_data["hide_reference_frame"] else 0) if status != 1: raise KnownPublishError("Export failed.") # create representation data if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': "mel", 'ext': "mel", 'files': file_path.name, "stagingDir": staging_dir, } self.log.debug(f"output: {file_path.as_posix()}") instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/equalizer/plugins/publish/extract_matchmove_script_nuke.py ================================================ # -*- coding: utf-8 -*- """Extract project for Nuke. Because original extractor script is intermingled with UI, we had to resort to this hacky solution. This is monkey-patching 3DEqualizer UI to silence it during the export. Advantage is that it is still using "vanilla" built-in export script, so it should be more compatible with future versions of the software. TODO: This can be refactored even better, split to multiple methods, etc. """ from pathlib import Path from unittest.mock import patch import pyblish.api import tde4 # noqa: F401 from openpype.pipeline import OptionalPyblishPluginMixin from openpype.pipeline import publish class ExtractMatchmoveScriptNuke(publish.Extractor, OptionalPyblishPluginMixin): """Extract Nuke script for matchmove. Unfortunately built-in export script from 3DEqualizer is bound to its UI, and it is not possible to call it directly from Python. Because of that, we are executing the script in the same way as artist would do it, but we are patching the UI to silence it and to avoid any user interaction. TODO: Utilize attributes defined in ExtractScriptBase """ label = "Extract Nuke Script" families = ["matchmove"] hosts = ["equalizer"] order = pyblish.api.ExtractorOrder def process(self, instance: pyblish.api.Instance): if not self.is_active(instance.data): return cam = tde4.getCurrentCamera() frame0 = tde4.getCameraFrameOffset(cam) frame0 -= 1 staging_dir = self.staging_dir(instance) file_path = Path(staging_dir) / "nuke_export.nk" # these patched methods are used to silence 3DEqualizer UI: def patched_getWidgetValue(requester, key: str): # noqa: N802 """Return value for given key in widget.""" if key == "file_browser": return file_path.as_posix() elif key == "startframe_field": return tde4.getCameraFrameOffset(cam) return "" # This is simulating artist clicking on "OK" button # in the export dialog. def patched_postCustomRequester(*args, **kwargs): # noqa: N802 return 1 # This is silencing success/error message after the script # is exported. def patched_postQuestionRequester(*args, **kwargs): # noqa: N802 return None # import maya export script from 3DEqualizer exporter_path = instance.data["tde4_path"] / "sys_data" / "py_scripts" / "export_nuke.py" # noqa: E501 self.log.debug("Patching 3dequalizer requester objects ...") with patch("tde4.getWidgetValue", patched_getWidgetValue), \ patch("tde4.postCustomRequester", patched_postCustomRequester), \ patch("tde4.postQuestionRequester", patched_postQuestionRequester): # noqa: E501 with exporter_path.open() as f: script = f.read() self.log.debug(f"Importing {exporter_path.as_posix()}") exec(script) # create representation data if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': "nk", 'ext': "nk", 'files': file_path.name, "stagingDir": staging_dir, } self.log.debug(f"output: {file_path.as_posix()}") instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/equalizer/plugins/publish/validate_camera_pointgroup.py ================================================ import pyblish.api import tde4 from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder, ) class ValidateCameraPoingroup(pyblish.api.InstancePlugin): """Validate Camera Point Group. There must be a camera point group in the scene. """ order = ValidateContentsOrder hosts = ["equalizer"] families = ["matchmove"] label = "Validate Camera Point Group" def process(self, instance): valid = False for point_group in tde4.getPGroupList(): if tde4.getPGroupType(point_group) == "CAMERA": valid = True break if not valid: raise PublishValidationError("Missing Camera Point Group") ================================================ FILE: openpype/hosts/equalizer/plugins/publish/validate_instance_camera_data.py ================================================ import pyblish.api from openpype.pipeline import PublishValidationError from openpype.pipeline.publish import ValidateContentsOrder class ValidateInstanceCameraData(pyblish.api.InstancePlugin): """Check if instance has camera data. There might not be any camera associated with the instance and without it, the instance is not valid. """ order = ValidateContentsOrder hosts = ["equalizer"] families = ["matchmove"] label = "Validate Instance has Camera data" def process(self, instance): try: _ = instance.data["cameras"] except KeyError as e: raise PublishValidationError("No camera data found") from e ================================================ FILE: openpype/hosts/equalizer/startup/ayon_create.py ================================================ # # 3DE4.script.name: Create ... # 3DE4.script.gui: Main Window::Ayon # 3DE4.script.comment: Open AYON Publisher tool # from openpype.pipeline import install_host, is_installed from openpype.hosts.equalizer.api import EqualizerHost from openpype.tools.utils import host_tools def install_3de_host(): print("Running AYON integration ...") install_host(EqualizerHost()) if not is_installed(): install_3de_host() # show the UI print("Opening publisher window ...") host_tools.show_publisher( tab="create", parent=EqualizerHost.get_host().get_main_window()) ================================================ FILE: openpype/hosts/equalizer/startup/ayon_load.py ================================================ # # 3DE4.script.name: Load ... # 3DE4.script.gui: Main Window::Ayon # 3DE4.script.comment: Open AYON Loader tool # from openpype.pipeline import install_host, is_installed from openpype.hosts.equalizer.api import EqualizerHost from openpype.tools.utils import host_tools def install_3de_host(): print("Running AYON integration ...") install_host(EqualizerHost()) if not is_installed(): install_3de_host() # show the UI print("Opening loader window ...") host_tools.show_loader( parent=EqualizerHost.get_host().get_main_window(), use_context=True) ================================================ FILE: openpype/hosts/equalizer/startup/ayon_manage.py ================================================ # # 3DE4.script.name: Manage ... # 3DE4.script.gui: Main Window::Ayon # 3DE4.script.comment: Open AYON Publisher tool # from openpype.pipeline import install_host, is_installed from openpype.hosts.equalizer.api import EqualizerHost from openpype.tools.utils import host_tools def install_3de_host(): print("Running AYON integration ...") install_host(EqualizerHost()) if not is_installed(): install_3de_host() # show the UI print("Opening Scene Manager window ...") host_tools.show_scene_inventory( parent=EqualizerHost.get_host().get_main_window()) ================================================ FILE: openpype/hosts/equalizer/startup/ayon_publish.py ================================================ # # 3DE4.script.name: Publish ... # 3DE4.script.gui: Main Window::Ayon # 3DE4.script.comment: Open AYON Publisher tool # from openpype.pipeline import install_host, is_installed from openpype.hosts.equalizer.api import EqualizerHost from openpype.tools.utils import host_tools def install_3de_host(): print("Running AYON integration ...") install_host(EqualizerHost()) if not is_installed(): install_3de_host() # show the UI print("Opening publisher window ...") host_tools.show_publisher( tab="publish", parent=EqualizerHost.get_host().get_main_window()) ================================================ FILE: openpype/hosts/equalizer/startup/ayon_workfile.py ================================================ # # 3DE4.script.name: Work files ... # 3DE4.script.gui: Main Window::Ayon # 3DE4.script.comment: Open AYON Publisher tool # from openpype.pipeline import install_host, is_installed from openpype.hosts.equalizer.api import EqualizerHost from openpype.tools.utils import host_tools def install_3de_host(): print("Running AYON integration ...") install_host(EqualizerHost()) if not is_installed(): install_3de_host() # show the UI print("Opening Workfile tool window ...") host_tools.show_workfiles( parent=EqualizerHost.get_host().get_main_window()) ================================================ FILE: openpype/hosts/equalizer/tests/test_plugin.py ================================================ """ 3DEqualizer plugin tests These test need to be run in 3DEqualizer. """ import json import re import unittest from attrs import asdict, define, field from attrs.exceptions import NotAnAttrsClassError AVALON_CONTAINER_ID = "test.container" CONTEXT_REGEX = re.compile( r"AYON_CONTEXT::(?P.*?)::AYON_CONTEXT_END", re.DOTALL) @define class Container(object): name: str = field(default=None) id: str = field(init=False, default=AVALON_CONTAINER_ID) namespace: str = field(default="") loader: str = field(default=None) representation: str = field(default=None) class Tde4Mock: """Simple class to mock few 3dequalizer functions. Just to run the test outside the host itself. """ _notes = "" def isProjectUpToDate(self): return True def setProjectNotes(self, notes): self._notes = notes def getProjectNotes(self): return self._notes tde4 = Tde4Mock() def get_context_data(): m = re.search(CONTEXT_REGEX, tde4.getProjectNotes()) return json.loads(m["context"]) if m else {} def update_context_data(data, _): m = re.search(CONTEXT_REGEX, tde4.getProjectNotes()) if not m: tde4.setProjectNotes("AYON_CONTEXT::::AYON_CONTEXT_END") update = json.dumps(data, indent=4) tde4.setProjectNotes( re.sub( CONTEXT_REGEX, f"AYON_CONTEXT::{update}::AYON_CONTEXT_END", tde4.getProjectNotes() ) ) def get_containers(): return get_context_data().get("containers", []) def add_container(container: Container): context_data = get_context_data() containers = get_context_data().get("containers", []) for _container in containers: if _container["name"] == container.name and _container["namespace"] == container.namespace: # noqa: E501 containers.remove(_container) break try: containers.append(asdict(container)) except NotAnAttrsClassError: print("not an attrs class") containers.append(container) context_data["containers"] = containers update_context_data(context_data, changes={}) class TestEqualizer(unittest.TestCase): def test_context_data(self): # ensure empty project notest data = get_context_data() self.assertEqual({}, data, "context data are not empty") # add container add_container( Container(name="test", representation="test_A") ) self.assertEqual( 1, len(get_containers()), "container not added") self.assertEqual( get_containers()[0]["name"], "test", "container name is not correct") # add another container add_container( Container(name="test2", representation="test_B") ) self.assertEqual( 2, len(get_containers()), "container not added") self.assertEqual( get_containers()[1]["name"], "test2", "container name is not correct") # update container add_container( Container(name="test2", representation="test_C") ) self.assertEqual( 2, len(get_containers()), "container not updated") self.assertEqual( get_containers()[1]["representation"], "test_C", "container name is not correct") print(f"--4: {tde4.getProjectNotes()}") if __name__ == "__main__": unittest.main() ================================================ FILE: openpype/hosts/flame/__init__.py ================================================ from .addon import ( HOST_DIR, FlameAddon, ) __all__ = ( "HOST_DIR", "FlameAddon", ) ================================================ FILE: openpype/hosts/flame/addon.py ================================================ import os from openpype.modules import OpenPypeModule, IHostAddon HOST_DIR = os.path.dirname(os.path.abspath(__file__)) class FlameAddon(OpenPypeModule, IHostAddon): name = "flame" host_name = "flame" def initialize(self, module_settings): self.enabled = True def add_implementation_envs(self, env, _app): # Add requirements to DL_PYTHON_HOOK_PATH env["DL_PYTHON_HOOK_PATH"] = os.path.join(HOST_DIR, "startup") env.pop("QT_AUTO_SCREEN_SCALE_FACTOR", None) # Set default values if are not already set via settings defaults = { "LOGLEVEL": "DEBUG" } for key, value in defaults.items(): if not env.get(key): env[key] = value def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] return [ os.path.join(HOST_DIR, "hooks") ] def get_workfile_extensions(self): return [".otoc"] ================================================ FILE: openpype/hosts/flame/api/__init__.py ================================================ """ OpenPype Autodesk Flame api """ from .constants import ( COLOR_MAP, MARKER_NAME, MARKER_COLOR, MARKER_DURATION, MARKER_PUBLISH_DEFAULT ) from .lib import ( CTX, FlameAppFramework, get_current_project, get_current_sequence, create_segment_data_marker, get_segment_data_marker, set_segment_data_marker, set_publish_attribute, get_publish_attribute, get_sequence_segments, maintained_segment_selection, reset_segment_selection, get_segment_attributes, get_clips_in_reels, get_reformated_filename, get_frame_from_filename, get_padding_from_filename, maintained_object_duplication, maintained_temp_file_path, get_clip_segment, get_batch_group_from_desktop, MediaInfoFile, TimeEffectMetadata ) from .utils import ( setup, get_flame_version, get_flame_install_root ) from .pipeline import ( install, uninstall, ls, containerise, update_container, remove_instance, list_instances, imprint, maintained_selection ) from .menu import ( FlameMenuProjectConnect, FlameMenuTimeline, FlameMenuUniversal ) from .plugin import ( Creator, PublishableClip, ClipLoader, OpenClipSolver ) from .workio import ( open_file, save_file, current_file, has_unsaved_changes, file_extensions, work_root ) from .render_utils import ( export_clip, get_preset_path_by_xml_name, modify_preset_file ) from .batch_utils import ( create_batch_group, create_batch_group_conent ) __all__ = [ # constants "COLOR_MAP", "MARKER_NAME", "MARKER_COLOR", "MARKER_DURATION", "MARKER_PUBLISH_DEFAULT", # lib "CTX", "FlameAppFramework", "get_current_project", "get_current_sequence", "create_segment_data_marker", "get_segment_data_marker", "set_segment_data_marker", "set_publish_attribute", "get_publish_attribute", "get_sequence_segments", "maintained_segment_selection", "reset_segment_selection", "get_segment_attributes", "get_clips_in_reels", "get_reformated_filename", "get_frame_from_filename", "get_padding_from_filename", "maintained_object_duplication", "maintained_temp_file_path", "get_clip_segment", "get_batch_group_from_desktop", "MediaInfoFile", "TimeEffectMetadata", # pipeline "install", "uninstall", "ls", "containerise", "update_container", "reload_pipeline", "maintained_selection", "remove_instance", "list_instances", "imprint", "maintained_selection", # utils "setup", "get_flame_version", "get_flame_install_root", # menu "FlameMenuProjectConnect", "FlameMenuTimeline", "FlameMenuUniversal", # plugin "Creator", "PublishableClip", "ClipLoader", "OpenClipSolver", # workio "open_file", "save_file", "current_file", "has_unsaved_changes", "file_extensions", "work_root", # render utils "export_clip", "get_preset_path_by_xml_name", "modify_preset_file", # batch utils "create_batch_group", "create_batch_group_conent" ] ================================================ FILE: openpype/hosts/flame/api/batch_utils.py ================================================ import flame def create_batch_group( name, frame_start, frame_duration, update_batch_group=None, **kwargs ): """Create Batch Group in active project's Desktop Args: name (str): name of batch group to be created frame_start (int): start frame of batch frame_end (int): end frame of batch update_batch_group (PyBatch)[optional]: batch group to update Return: PyBatch: active flame batch group """ # make sure some batch obj is present batch_group = update_batch_group or flame.batch schematic_reels = kwargs.get("shematic_reels") or ['LoadedReel1'] shelf_reels = kwargs.get("shelf_reels") or ['ShelfReel1'] handle_start = kwargs.get("handleStart") or 0 handle_end = kwargs.get("handleEnd") or 0 frame_start -= handle_start frame_duration += handle_start + handle_end if not update_batch_group: # Create batch group with name, start_frame value, duration value, # set of schematic reel names, set of shelf reel names batch_group = batch_group.create_batch_group( name, start_frame=frame_start, duration=frame_duration, reels=schematic_reels, shelf_reels=shelf_reels ) else: batch_group.name = name batch_group.start_frame = frame_start batch_group.duration = frame_duration # add reels to batch group _add_reels_to_batch_group( batch_group, schematic_reels, shelf_reels) # TODO: also update write node if there is any # TODO: also update loaders to start from correct frameStart if kwargs.get("switch_batch_tab"): # use this command to switch to the batch tab batch_group.go_to() return batch_group def _add_reels_to_batch_group(batch_group, reels, shelf_reels): # update or create defined reels # helper variables reel_names = [ r.name.get_value() for r in batch_group.reels ] shelf_reel_names = [ r.name.get_value() for r in batch_group.shelf_reels ] # add schematic reels for _r in reels: if _r in reel_names: continue batch_group.create_reel(_r) # add shelf reels for _sr in shelf_reels: if _sr in shelf_reel_names: continue batch_group.create_shelf_reel(_sr) def create_batch_group_conent(batch_nodes, batch_links, batch_group=None): """Creating batch group with links Args: batch_nodes (list of dict): each dict is node definition batch_links (list of dict): each dict is link definition batch_group (PyBatch, optional): batch group. Defaults to None. Return: dict: all batch nodes {name or id: PyNode} """ # make sure some batch obj is present batch_group = batch_group or flame.batch all_batch_nodes = { b.name.get_value(): b for b in batch_group.nodes } for node in batch_nodes: # NOTE: node_props needs to be ideally OrederDict type node_id, node_type, node_props = ( node["id"], node["type"], node["properties"]) # get node name for checking if exists node_name = node_props.pop("name", None) or node_id if all_batch_nodes.get(node_name): # update existing batch node batch_node = all_batch_nodes[node_name] else: # create new batch node batch_node = batch_group.create_node(node_type) # set name batch_node.name.set_value(node_name) # set attributes found in node props for key, value in node_props.items(): if not hasattr(batch_node, key): continue setattr(batch_node, key, value) # add created node for possible linking all_batch_nodes[node_id] = batch_node # link nodes to each other for link in batch_links: _from_n, _to_n = link["from_node"], link["to_node"] # check if all linking nodes are available if not all([ all_batch_nodes.get(_from_n["id"]), all_batch_nodes.get(_to_n["id"]) ]): continue # link nodes in defined link batch_group.connect_nodes( all_batch_nodes[_from_n["id"]], _from_n["connector"], all_batch_nodes[_to_n["id"]], _to_n["connector"] ) # sort batch nodes batch_group.organize() return all_batch_nodes ================================================ FILE: openpype/hosts/flame/api/constants.py ================================================ """ OpenPype Flame api constances """ # OpenPype marker workflow variables MARKER_NAME = "OpenPypeData" MARKER_DURATION = 0 MARKER_COLOR = "cyan" MARKER_PUBLISH_DEFAULT = False # OpenPype color definitions COLOR_MAP = { "red": (1.0, 0.0, 0.0), "orange": (1.0, 0.5, 0.0), "yellow": (1.0, 1.0, 0.0), "pink": (1.0, 0.5, 1.0), "white": (1.0, 1.0, 1.0), "green": (0.0, 1.0, 0.0), "cyan": (0.0, 1.0, 1.0), "blue": (0.0, 0.0, 1.0), "purple": (0.5, 0.0, 0.5), "magenta": (0.5, 0.0, 1.0), "black": (0.0, 0.0, 0.0) } ================================================ FILE: openpype/hosts/flame/api/lib.py ================================================ import sys import os import re import json import pickle import clique import tempfile import traceback import itertools import contextlib import xml.etree.cElementTree as cET from copy import deepcopy, copy from xml.etree import ElementTree as ET from pprint import pformat from openpype.lib import Logger, run_subprocess from .constants import ( MARKER_COLOR, MARKER_DURATION, MARKER_NAME, COLOR_MAP, MARKER_PUBLISH_DEFAULT ) log = Logger.get_logger(__name__) FRAME_PATTERN = re.compile(r"[\._](\d+)[\.]") class CTX: # singleton used for passing data between api modules app_framework = None flame_apps = [] selection = None @contextlib.contextmanager def io_preferences_file(klass, filepath, write=False): try: flag = "w" if write else "r" yield open(filepath, flag) except IOError as _error: klass.log.info("Unable to work with preferences `{}`: {}".format( filepath, _error)) class FlameAppFramework(object): # flameAppFramework class takes care of preferences class prefs_dict(dict): def __init__(self, master, name, **kwargs): self.name = name self.master = master if not self.master.get(self.name): self.master[self.name] = {} self.master[self.name].__init__() def __getitem__(self, k): return self.master[self.name].__getitem__(k) def __setitem__(self, k, v): return self.master[self.name].__setitem__(k, v) def __delitem__(self, k): return self.master[self.name].__delitem__(k) def get(self, k, default=None): return self.master[self.name].get(k, default) def setdefault(self, k, default=None): return self.master[self.name].setdefault(k, default) def pop(self, *args, **kwargs): return self.master[self.name].pop(*args, **kwargs) def update(self, mapping=(), **kwargs): self.master[self.name].update(mapping, **kwargs) def __contains__(self, k): return self.master[self.name].__contains__(k) def copy(self): # don"t delegate w/ super - dict.copy() -> dict :( return type(self)(self) def keys(self): return self.master[self.name].keys() @classmethod def fromkeys(cls, keys, v=None): return cls.master[cls.name].fromkeys(keys, v) def __repr__(self): return "{0}({1})".format( type(self).__name__, self.master[self.name].__repr__()) def master_keys(self): return self.master.keys() def __init__(self): self.name = self.__class__.__name__ self.bundle_name = "OpenPypeFlame" # self.prefs scope is limited to flame project and user self.prefs = {} self.prefs_user = {} self.prefs_global = {} self.log = log try: import flame self.flame = flame self.flame_project_name = self.flame.project.current_project.name self.flame_user_name = flame.users.current_user.name except Exception: self.flame = None self.flame_project_name = None self.flame_user_name = None import socket self.hostname = socket.gethostname() if sys.platform == "darwin": self.prefs_folder = os.path.join( os.path.expanduser("~"), "Library", "Caches", "OpenPype", self.bundle_name ) elif sys.platform.startswith("linux"): self.prefs_folder = os.path.join( os.path.expanduser("~"), ".OpenPype", self.bundle_name) self.prefs_folder = os.path.join( self.prefs_folder, self.hostname, ) self.log.info("[{}] waking up".format(self.__class__.__name__)) try: self.load_prefs() except RuntimeError: self.save_prefs() # menu auto-refresh defaults if not self.prefs_global.get("menu_auto_refresh"): self.prefs_global["menu_auto_refresh"] = { "media_panel": True, "batch": True, "main_menu": True, "timeline_menu": True } self.apps = [] def get_pref_file_paths(self): prefix = self.prefs_folder + os.path.sep + self.bundle_name prefs_file_path = "_".join([ prefix, self.flame_user_name, self.flame_project_name]) + ".prefs" prefs_user_file_path = "_".join([ prefix, self.flame_user_name]) + ".prefs" prefs_global_file_path = prefix + ".prefs" return (prefs_file_path, prefs_user_file_path, prefs_global_file_path) def load_prefs(self): (proj_pref_path, user_pref_path, glob_pref_path) = self.get_pref_file_paths() with io_preferences_file(self, proj_pref_path) as prefs_file: self.prefs = pickle.load(prefs_file) self.log.info( "Project - preferences contents:\n{}".format( pformat(self.prefs) )) with io_preferences_file(self, user_pref_path) as prefs_file: self.prefs_user = pickle.load(prefs_file) self.log.info( "User - preferences contents:\n{}".format( pformat(self.prefs_user) )) with io_preferences_file(self, glob_pref_path) as prefs_file: self.prefs_global = pickle.load(prefs_file) self.log.info( "Global - preferences contents:\n{}".format( pformat(self.prefs_global) )) return True def save_prefs(self): # make sure the preference folder is available if not os.path.isdir(self.prefs_folder): try: os.makedirs(self.prefs_folder) except Exception: self.log.info("Unable to create folder {}".format( self.prefs_folder)) return False # get all pref file paths (proj_pref_path, user_pref_path, glob_pref_path) = self.get_pref_file_paths() with io_preferences_file(self, proj_pref_path, True) as prefs_file: pickle.dump(self.prefs, prefs_file) self.log.info( "Project - preferences contents:\n{}".format( pformat(self.prefs) )) with io_preferences_file(self, user_pref_path, True) as prefs_file: pickle.dump(self.prefs_user, prefs_file) self.log.info( "User - preferences contents:\n{}".format( pformat(self.prefs_user) )) with io_preferences_file(self, glob_pref_path, True) as prefs_file: pickle.dump(self.prefs_global, prefs_file) self.log.info( "Global - preferences contents:\n{}".format( pformat(self.prefs_global) )) return True def get_current_project(): import flame return flame.project.current_project def get_current_sequence(selection): import flame def segment_to_sequence(_segment): track = _segment.parent version = track.parent return version.parent process_timeline = None if len(selection) == 1: if isinstance(selection[0], flame.PySequence): process_timeline = selection[0] if isinstance(selection[0], flame.PySegment): process_timeline = segment_to_sequence(selection[0]) else: for segment in selection: if isinstance(segment, flame.PySegment): process_timeline = segment_to_sequence(segment) break return process_timeline def rescan_hooks(): import flame try: flame.execute_shortcut("Rescan Python Hooks") except Exception: pass def get_metadata(project_name, _log=None): # TODO: can be replaced by MediaInfoFile class method from adsk.libwiretapPythonClientAPI import ( WireTapClient, WireTapServerHandle, WireTapNodeHandle, WireTapStr ) class GetProjectColorPolicy(object): def __init__(self, host_name=None, _log=None): # Create a connection to the Backburner manager using the Wiretap # python API. # self.log = _log or log self.host_name = host_name or "localhost" self._wiretap_client = WireTapClient() if not self._wiretap_client.init(): raise Exception("Could not initialize Wiretap Client") self._server = WireTapServerHandle( "{}:IFFFS".format(self.host_name)) def process(self, project_name): policy_node_handle = WireTapNodeHandle( self._server, "/projects/{}/syncolor/policy".format(project_name) ) self.log.info(policy_node_handle) policy = WireTapStr() if not policy_node_handle.getNodeTypeStr(policy): self.log.warning( "Could not retrieve policy of '%s': %s" % ( policy_node_handle.getNodeId().id(), policy_node_handle.lastError() ) ) return policy.c_str() policy_wiretap = GetProjectColorPolicy(_log=_log) return policy_wiretap.process(project_name) def get_segment_data_marker(segment, with_marker=None): """ Get openpype track item tag created by creator or loader plugin. Attributes: segment (flame.PySegment): flame api object with_marker (bool)[optional]: if true it will return also marker object Returns: dict: openpype tag data Returns(with_marker=True): flame.PyMarker, dict """ for marker in segment.markers: comment = marker.comment.get_value() color = marker.colour.get_value() name = marker.name.get_value() if (name == MARKER_NAME) and ( color == COLOR_MAP[MARKER_COLOR]): if not with_marker: return json.loads(comment) else: return marker, json.loads(comment) def set_segment_data_marker(segment, data=None): """ Set openpype track item tag to input segment. Attributes: segment (flame.PySegment): flame api object Returns: dict: json loaded data """ data = data or dict() marker_data = get_segment_data_marker(segment, True) if marker_data: # get available openpype tag if any marker, tag_data = marker_data # update tag data with new data tag_data.update(data) # update marker with tag data marker.comment = json.dumps(tag_data) else: # update tag data with new data marker = create_segment_data_marker(segment) # add tag data to marker's comment marker.comment = json.dumps(data) def set_publish_attribute(segment, value): """ Set Publish attribute in input Tag object Attribute: segment (flame.PySegment)): flame api object value (bool): True or False """ tag_data = get_segment_data_marker(segment) tag_data["publish"] = value # set data to the publish attribute set_segment_data_marker(segment, tag_data) def get_publish_attribute(segment): """ Get Publish attribute from input Tag object Attribute: segment (flame.PySegment)): flame api object Returns: bool: True or False """ tag_data = get_segment_data_marker(segment) if not tag_data: set_publish_attribute(segment, MARKER_PUBLISH_DEFAULT) return MARKER_PUBLISH_DEFAULT return tag_data["publish"] def create_segment_data_marker(segment): """ Create openpype marker on a segment. Attributes: segment (flame.PySegment): flame api object Returns: flame.PyMarker: flame api object """ # get duration of segment duration = segment.record_duration.relative_frame # calculate start frame of the new marker start_frame = int(segment.record_in.relative_frame) + int(duration / 2) # create marker marker = segment.create_marker(start_frame) # set marker name marker.name = MARKER_NAME # set duration marker.duration = MARKER_DURATION # set colour marker.colour = COLOR_MAP[MARKER_COLOR] # Red return marker def get_sequence_segments(sequence, selected=False): segments = [] # loop versions in sequence for ver in sequence.versions: # loop track in versions for track in ver.tracks: # ignore all empty tracks and hidden too if len(track.segments) == 0 and track.hidden: continue # loop all segment in remaining tracks for segment in track.segments: if segment.name.get_value() == "": continue if segment.hidden.get_value() is True: continue if ( selected is True and segment.selected.get_value() is not True ): continue # add it to original selection segments.append(segment) return segments @contextlib.contextmanager def maintained_segment_selection(sequence): """Maintain selection during context Attributes: sequence (flame.PySequence): python api object Yield: list of flame.PySegment Example: >>> with maintained_segment_selection(sequence) as selected_segments: ... for segment in selected_segments: ... segment.selected = False >>> print(segment.selected) True """ selected_segments = get_sequence_segments(sequence, True) try: # do the operation on selected segments yield selected_segments finally: # reset all selected clips reset_segment_selection(sequence) # select only original selection of segments for segment in selected_segments: segment.selected = True def reset_segment_selection(sequence): """Deselect all selected nodes """ for ver in sequence.versions: for track in ver.tracks: if len(track.segments) == 0 and track.hidden: continue for segment in track.segments: segment.selected = False def _get_shot_tokens_values(clip, tokens): old_value = None output = {} if not clip.shot_name: return output old_value = clip.shot_name.get_value() for token in tokens: clip.shot_name.set_value(token) _key = str(re.sub("[<>]", "", token)).replace(" ", "_") try: output[_key] = int(clip.shot_name.get_value()) except ValueError: output[_key] = clip.shot_name.get_value() clip.shot_name.set_value(old_value) return output def get_segment_attributes(segment): if segment.name.get_value() == "": return None # Add timeline segment to tree clip_data = { "shot_name": segment.shot_name.get_value(), "segment_name": segment.name.get_value(), "segment_comment": segment.comment.get_value(), "tape_name": segment.tape_name, "source_name": segment.source_name, "fpath": segment.file_path, "PySegment": segment } # head and tail with forward compatibility if segment.head: # `infinite` can be also returned if isinstance(segment.head, str): clip_data["segment_head"] = 0 else: clip_data["segment_head"] = int(segment.head) if segment.tail: # `infinite` can be also returned if isinstance(segment.tail, str): clip_data["segment_tail"] = 0 else: clip_data["segment_tail"] = int(segment.tail) # add all available shot tokens shot_tokens = _get_shot_tokens_values(segment, [ "", "", "", "", "", "", "" ]) clip_data.update(shot_tokens) # populate shot source metadata segment_attrs = [ "record_duration", "record_in", "record_out", "source_duration", "source_in", "source_out" ] segment_attrs_data = {} for attr_name in segment_attrs: if not hasattr(segment, attr_name): continue attr = getattr(segment, attr_name) segment_attrs_data[attr_name] = str(attr).replace("+", ":") if attr_name in ["record_in", "record_out"]: clip_data[attr_name] = attr.relative_frame else: clip_data[attr_name] = attr.frame clip_data["segment_timecodes"] = segment_attrs_data return clip_data def get_clips_in_reels(project): output_clips = [] project_desktop = project.current_workspace.desktop for reel_group in project_desktop.reel_groups: for reel in reel_group.reels: for clip in reel.clips: clip_data = { "PyClip": clip, "fps": float(str(clip.frame_rate)[:-4]) } attrs = [ "name", "width", "height", "ratio", "sample_rate", "bit_depth" ] for attr in attrs: val = getattr(clip, attr) clip_data[attr] = val version = clip.versions[-1] track = version.tracks[-1] for segment in track.segments: segment_data = get_segment_attributes(segment) clip_data.update(segment_data) output_clips.append(clip_data) return output_clips def get_reformated_filename(filename, padded=True): """ Return fixed python expression path Args: filename (str): file name Returns: type: string with reformated path Example: get_reformated_filename("plate.1001.exr") > plate.%04d.exr """ found = FRAME_PATTERN.search(filename) if not found: log.info("File name is not sequence: {}".format(filename)) return filename padding = get_padding_from_filename(filename) replacement = "%0{}d".format(padding) if padded else "%d" start_idx, end_idx = found.span(1) return replacement.join( [filename[:start_idx], filename[end_idx:]] ) def get_padding_from_filename(filename): """ Return padding number from Flame path style Args: filename (str): file name Returns: int: padding number Example: get_padding_from_filename("plate.0001.exr") > 4 """ found = get_frame_from_filename(filename) return len(found) if found else None def get_frame_from_filename(filename): """ Return sequence number from Flame path style Args: filename (str): file name Returns: int: sequence frame number Example: def get_frame_from_filename(path): ("plate.0001.exr") > 0001 """ found = re.findall(FRAME_PATTERN, filename) return found.pop() if found else None @contextlib.contextmanager def maintained_object_duplication(item): """Maintain input item duplication Attributes: item (any flame.PyObject): python api object Yield: duplicate input PyObject type """ import flame # Duplicate the clip to avoid modifying the original clip duplicate = flame.duplicate(item) try: # do the operation on selected segments yield duplicate finally: # delete the item at the end flame.delete(duplicate) @contextlib.contextmanager def maintained_temp_file_path(suffix=None): _suffix = suffix or "" try: # Store dumped json to temporary file temporary_file = tempfile.mktemp( suffix=_suffix, prefix="flame_maintained_") yield temporary_file.replace("\\", "/") except IOError as _error: raise IOError( "Not able to create temp json file: {}".format(_error)) finally: # Remove the temporary json os.remove(temporary_file) def get_clip_segment(flame_clip): name = flame_clip.name.get_value() version = flame_clip.versions[0] track = version.tracks[0] segments = track.segments if len(segments) < 1: raise ValueError("Clip `{}` has no segments!".format(name)) if len(segments) > 1: raise ValueError("Clip `{}` has too many segments!".format(name)) return segments[0] def get_batch_group_from_desktop(name): project = get_current_project() project_desktop = project.current_workspace.desktop for bgroup in project_desktop.batch_groups: if bgroup.name.get_value() in name: return bgroup class MediaInfoFile(object): """Class to get media info file clip data Raises: IOError: MEDIA_SCRIPT_PATH path doesn't exists TypeError: Not able to generate clip xml data file ET.ParseError: Missing clip in xml clip data IOError: Not able to save xml clip data to file Attributes: str: `MEDIA_SCRIPT_PATH` path to flame binary logging.Logger: `log` logger TODO: add method for getting metadata to dict """ MEDIA_SCRIPT_PATH = "/opt/Autodesk/mio/current/dl_get_media_info" log = log _clip_data = None _start_frame = None _fps = None _drop_mode = None _file_pattern = None def __init__(self, path, logger=None): # replace log if any if logger: self.log = logger # test if `dl_get_media_info` path exists self._validate_media_script_path() # derivate other feed variables feed_basename = os.path.basename(path) feed_dir = os.path.dirname(path) feed_ext = os.path.splitext(feed_basename)[1][1:].lower() with maintained_temp_file_path(".clip") as tmp_path: self.log.info("Temp File: {}".format(tmp_path)) self._generate_media_info_file(tmp_path, feed_ext, feed_dir) # get collection containing feed_basename from path self.file_pattern = self._get_collection( feed_basename, feed_dir, feed_ext) if ( not self.file_pattern and os.path.exists(os.path.join(feed_dir, feed_basename)) ): self.file_pattern = feed_basename # get clip data and make them single if there is multiple # clips data xml_data = self._make_single_clip_media_info( tmp_path, feed_basename, self.file_pattern) self.log.debug("xml_data: {}".format(xml_data)) self.log.debug("type: {}".format(type(xml_data))) # get all time related data and assign them self._get_time_info_from_origin(xml_data) self.log.debug("start_frame: {}".format(self.start_frame)) self.log.debug("fps: {}".format(self.fps)) self.log.debug("drop frame: {}".format(self.drop_mode)) self.clip_data = xml_data def _get_collection(self, feed_basename, feed_dir, feed_ext): """ Get collection string Args: feed_basename (str): file base name feed_dir (str): file's directory feed_ext (str): file extension Raises: AttributeError: feed_ext is not matching feed_basename Returns: str: collection basename with range of sequence """ partialname = self._separate_file_head(feed_basename, feed_ext) self.log.debug("__ partialname: {}".format(partialname)) # make sure partial input basename is having correct extensoon if not partialname: raise AttributeError( "Wrong input attributes. Basename - {}, Ext - {}".format( feed_basename, feed_ext ) ) # get all related files files = [ f for f in os.listdir(feed_dir) if partialname == self._separate_file_head(f, feed_ext) ] # ignore reminders as we dont need them collections = clique.assemble(files)[0] # in case no collection found return None # it is probably just single file if not collections: return # we expect only one collection collection = collections[0] self.log.debug("__ collection: {}".format(collection)) if collection.is_contiguous(): return self._format_collection(collection) # add `[` in front to make sure it want capture # shot name with the same number number_from_path = self._separate_number(feed_basename, feed_ext) search_number_pattern = "[" + number_from_path # convert to multiple collections _continues_colls = collection.separate() for _coll in _continues_colls: coll_to_text = self._format_collection( _coll, len(number_from_path)) self.log.debug("__ coll_to_text: {}".format(coll_to_text)) if search_number_pattern in coll_to_text: return coll_to_text @staticmethod def _format_collection(collection, padding=None): padding = padding or collection.padding # if no holes then return collection head = collection.format("{head}") tail = collection.format("{tail}") range_template = "[{{:0{0}d}}-{{:0{0}d}}]".format( padding) ranges = range_template.format( min(collection.indexes), max(collection.indexes) ) # if no holes then return collection return "{}{}{}".format(head, ranges, tail) def _separate_file_head(self, basename, extension): """ Get only head with out sequence and extension Args: basename (str): file base name extension (str): file extension Returns: str: file head """ # in case sequence file found = re.findall( r"(.*)[._][\d]*(?=.{})".format(extension), basename, ) if found: return found.pop() # in case single file name, ext = os.path.splitext(basename) if extension == ext[1:]: return name def _separate_number(self, basename, extension): """ Get only sequence number as string Args: basename (str): file base name extension (str): file extension Returns: str: number with padding """ # in case sequence file found = re.findall( r"[._]([\d]*)(?=.{})".format(extension), basename, ) if found: return found.pop() @property def clip_data(self): """Clip's xml clip data Returns: xml.etree.ElementTree: xml data """ return self._clip_data @clip_data.setter def clip_data(self, data): self._clip_data = data @property def start_frame(self): """ Clip's starting frame found in timecode Returns: int: number of frames """ return self._start_frame @start_frame.setter def start_frame(self, number): self._start_frame = int(number) @property def fps(self): """ Clip's frame rate Returns: float: frame rate """ return self._fps @fps.setter def fps(self, fl_number): self._fps = float(fl_number) @property def drop_mode(self): """ Clip's drop frame mode Returns: str: drop frame flag """ return self._drop_mode @drop_mode.setter def drop_mode(self, text): self._drop_mode = str(text) @property def file_pattern(self): """Clips file patter Returns: str: file pattern. ex. file.[1-2].exr """ return self._file_pattern @file_pattern.setter def file_pattern(self, fpattern): self._file_pattern = fpattern def _validate_media_script_path(self): if not os.path.isfile(self.MEDIA_SCRIPT_PATH): raise IOError("Media Script does not exist: `{}`".format( self.MEDIA_SCRIPT_PATH)) def _generate_media_info_file(self, fpath, feed_ext, feed_dir): """ Generate media info xml .clip file Args: fpath (str): .clip file path feed_ext (str): file extension to be filtered feed_dir (str): look up directory Raises: TypeError: Type error if it fails """ # Create cmd arguments for gettig xml file info file cmd_args = [ self.MEDIA_SCRIPT_PATH, "-e", feed_ext, "-o", fpath, feed_dir ] try: # execute creation of clip xml template data run_subprocess(cmd_args) except TypeError as error: raise TypeError( "Error creating `{}` due: {}".format(fpath, error)) def _make_single_clip_media_info(self, fpath, feed_basename, path_pattern): """ Separate only relative clip object form .clip file Args: fpath (str): clip file path feed_basename (str): search basename path_pattern (str): search file pattern (file.[1-2].exr) Raises: ET.ParseError: if nothing found Returns: ET.Element: xml element data of matching clip """ with open(fpath) as f: lines = f.readlines() _added_root = itertools.chain( "", deepcopy(lines)[1:], "") new_root = ET.fromstringlist(_added_root) # find the clip which is matching to my input name xml_clips = new_root.findall("clip") matching_clip = None for xml_clip in xml_clips: clip_name = xml_clip.find("name").text self.log.debug("__ clip_name: `{}`".format(clip_name)) if clip_name not in feed_basename: continue # test path pattern for out_track in xml_clip.iter("track"): for out_feed in out_track.iter("feed"): for span in out_feed.iter("span"): # start frame span_path = span.find("path") self.log.debug( "__ span_path.text: {}, path_pattern: {}".format( span_path.text, path_pattern ) ) if path_pattern in span_path.text: matching_clip = xml_clip if matching_clip is None: # return warning there is missing clip raise ET.ParseError( "Missing clip in `{}`. Available clips {}".format( feed_basename, [ xml_clip.find("name").text for xml_clip in xml_clips ] )) return matching_clip def _get_time_info_from_origin(self, xml_data): """Set time info to class attributes Args: xml_data (ET.Element): clip data """ try: for out_track in xml_data.iter("track"): for out_feed in out_track.iter("feed"): # start frame out_feed_nb_ticks_obj = out_feed.find( "startTimecode/nbTicks") self.start_frame = out_feed_nb_ticks_obj.text # fps out_feed_fps_obj = out_feed.find( "startTimecode/rate") self.fps = out_feed_fps_obj.text # drop frame mode out_feed_drop_mode_obj = out_feed.find( "startTimecode/dropMode") self.drop_mode = out_feed_drop_mode_obj.text break except Exception as msg: self.log.warning(msg) @staticmethod def write_clip_data_to_file(fpath, xml_element_data): """ Write xml element of clip data to file Args: fpath (string): file path xml_element_data (xml.etree.ElementTree.Element): xml data Raises: IOError: If data could not be written to file """ try: # save it as new file tree = cET.ElementTree(xml_element_data) tree.write( fpath, xml_declaration=True, method="xml", encoding="UTF-8" ) except IOError as error: raise IOError( "Not able to write data to file: {}".format(error)) class TimeEffectMetadata(object): log = log _data = {} _retime_modes = { 0: "speed", 1: "timewarp", 2: "duration" } def __init__(self, segment, logger=None): if logger: self.log = logger self._data = self._get_metadata(segment) @property def data(self): """ Returns timewarp effect data Returns: dict: retime data """ return self._data def _get_metadata(self, segment): effects = segment.effects or [] for effect in effects: if effect.type == "Timewarp": with maintained_temp_file_path(".timewarp_node") as tmp_path: self.log.info("Temp File: {}".format(tmp_path)) effect.save_setup(tmp_path) return self._get_attributes_from_xml(tmp_path) return {} def _get_attributes_from_xml(self, tmp_path): with open(tmp_path, "r") as tw_setup_file: tw_setup_string = tw_setup_file.read() tw_setup_file.close() tw_setup_xml = ET.fromstring(tw_setup_string) tw_setup = self._dictify(tw_setup_xml) # pprint(tw_setup) try: tw_setup_state = tw_setup["Setup"]["State"][0] mode = int( tw_setup_state["TW_RetimerMode"][0]["_text"] ) r_data = { "type": self._retime_modes[mode], "effectStart": int( tw_setup["Setup"]["Base"][0]["Range"][0]["Start"]), "effectEnd": int( tw_setup["Setup"]["Base"][0]["Range"][0]["End"]) } if mode == 0: # speed r_data[self._retime_modes[mode]] = float( tw_setup_state["TW_Speed"] [0]["Channel"][0]["Value"][0]["_text"] ) / 100 elif mode == 1: # timewarp print("timing") r_data[self._retime_modes[mode]] = self._get_anim_keys( tw_setup_state["TW_Timing"] ) elif mode == 2: # duration r_data[self._retime_modes[mode]] = { "start": { "source": int( tw_setup_state["TW_DurationTiming"][0]["Channel"] [0]["KFrames"][0]["Key"][0]["Value"][0]["_text"] ), "timeline": int( tw_setup_state["TW_DurationTiming"][0]["Channel"] [0]["KFrames"][0]["Key"][0]["Frame"][0]["_text"] ) }, "end": { "source": int( tw_setup_state["TW_DurationTiming"][0]["Channel"] [0]["KFrames"][0]["Key"][1]["Value"][0]["_text"] ), "timeline": int( tw_setup_state["TW_DurationTiming"][0]["Channel"] [0]["KFrames"][0]["Key"][1]["Frame"][0]["_text"] ) } } except Exception: lines = traceback.format_exception(*sys.exc_info()) self.log.error("\n".join(lines)) return return r_data def _get_anim_keys(self, setup_cat, index=None): return_data = { "extrapolation": ( setup_cat[0]["Channel"][0]["Extrap"][0]["_text"] ), "animKeys": [] } for key in setup_cat[0]["Channel"][0]["KFrames"][0]["Key"]: if index and int(key["Index"]) != index: continue key_data = { "source": float(key["Value"][0]["_text"]), "timeline": float(key["Frame"][0]["_text"]), "index": int(key["Index"]), "curveMode": key["CurveMode"][0]["_text"], "curveOrder": key["CurveOrder"][0]["_text"] } if key.get("TangentMode"): key_data["tangentMode"] = key["TangentMode"][0]["_text"] return_data["animKeys"].append(key_data) return return_data def _dictify(self, xml_, root=True): """ Convert xml object to dictionary Args: xml_ (xml.etree.ElementTree.Element): xml data root (bool, optional): is root available. Defaults to True. Returns: dict: dictionarized xml """ if root: return {xml_.tag: self._dictify(xml_, False)} d = copy(xml_.attrib) if xml_.text: d["_text"] = xml_.text for x in xml_.findall("./*"): if x.tag not in d: d[x.tag] = [] d[x.tag].append(self._dictify(x, False)) return d ================================================ FILE: openpype/hosts/flame/api/menu.py ================================================ from copy import deepcopy from pprint import pformat from qtpy import QtWidgets from openpype.pipeline import get_current_project_name from openpype.tools.utils.host_tools import HostToolsHelper menu_group_name = 'OpenPype' default_flame_export_presets = { 'Publish': { 'PresetVisibility': 2, 'PresetType': 0, 'PresetFile': 'OpenEXR/OpenEXR (16-bit fp PIZ).xml' }, 'Preview': { 'PresetVisibility': 3, 'PresetType': 2, 'PresetFile': 'Generate Preview.xml' }, 'Thumbnail': { 'PresetVisibility': 3, 'PresetType': 0, 'PresetFile': 'Generate Thumbnail.xml' } } def callback_selection(selection, function): import openpype.hosts.flame.api as opfapi opfapi.CTX.selection = selection print("Hook Selection: \n\t{}".format( pformat({ index: (type(item), item.name) for index, item in enumerate(opfapi.CTX.selection)}) )) function() class _FlameMenuApp(object): def __init__(self, framework): self.name = self.__class__.__name__ self.framework = framework self.log = framework.log self.menu_group_name = menu_group_name self.dynamic_menu_data = {} # flame module is only available when a # flame project is loaded and initialized self.flame = None try: import flame self.flame = flame except ImportError: self.flame = None self.flame_project_name = flame.project.current_project.name self.prefs = self.framework.prefs_dict(self.framework.prefs, self.name) self.prefs_user = self.framework.prefs_dict( self.framework.prefs_user, self.name) self.prefs_global = self.framework.prefs_dict( self.framework.prefs_global, self.name) self.mbox = QtWidgets.QMessageBox() project_name = get_current_project_name() self.menu = { "actions": [{ 'name': project_name or "project", 'isEnabled': False }], "name": self.menu_group_name } self.tools_helper = HostToolsHelper() def __getattr__(self, name): def method(*args, **kwargs): print('calling %s' % name) return method def rescan(self, *args, **kwargs): if not self.flame: try: import flame self.flame = flame except ImportError: self.flame = None if self.flame: self.flame.execute_shortcut('Rescan Python Hooks') self.log.info('Rescan Python Hooks') class FlameMenuProjectConnect(_FlameMenuApp): # flameMenuProjectconnect app takes care of the preferences dialog as well def __init__(self, framework): _FlameMenuApp.__init__(self, framework) def __getattr__(self, name): def method(*args, **kwargs): project = self.dynamic_menu_data.get(name) if project: self.link_project(project) return method def build_menu(self): if not self.flame: return [] menu = deepcopy(self.menu) menu['actions'].append({ "name": "Workfiles...", "execute": lambda x: self.tools_helper.show_workfiles() }) menu['actions'].append({ "name": "Load...", "execute": lambda x: self.tools_helper.show_loader() }) menu['actions'].append({ "name": "Manage...", "execute": lambda x: self.tools_helper.show_scene_inventory() }) menu['actions'].append({ "name": "Library...", "execute": lambda x: self.tools_helper.show_library_loader() }) return menu def refresh(self, *args, **kwargs): self.rescan() def rescan(self, *args, **kwargs): if not self.flame: try: import flame self.flame = flame except ImportError: self.flame = None if self.flame: self.flame.execute_shortcut('Rescan Python Hooks') self.log.info('Rescan Python Hooks') class FlameMenuTimeline(_FlameMenuApp): # flameMenuProjectconnect app takes care of the preferences dialog as well def __init__(self, framework): _FlameMenuApp.__init__(self, framework) def __getattr__(self, name): def method(*args, **kwargs): project = self.dynamic_menu_data.get(name) if project: self.link_project(project) return method def build_menu(self): if not self.flame: return [] menu = deepcopy(self.menu) menu['actions'].append({ "name": "Create...", "execute": lambda x: callback_selection( x, self.tools_helper.show_creator) }) menu['actions'].append({ "name": "Publish...", "execute": lambda x: callback_selection( x, self.tools_helper.show_publish) }) menu['actions'].append({ "name": "Load...", "execute": lambda x: self.tools_helper.show_loader() }) menu['actions'].append({ "name": "Manage...", "execute": lambda x: self.tools_helper.show_scene_inventory() }) menu['actions'].append({ "name": "Library...", "execute": lambda x: self.tools_helper.show_library_loader() }) return menu def refresh(self, *args, **kwargs): self.rescan() def rescan(self, *args, **kwargs): if not self.flame: try: import flame self.flame = flame except ImportError: self.flame = None if self.flame: self.flame.execute_shortcut('Rescan Python Hooks') self.log.info('Rescan Python Hooks') class FlameMenuUniversal(_FlameMenuApp): # flameMenuProjectconnect app takes care of the preferences dialog as well def __init__(self, framework): _FlameMenuApp.__init__(self, framework) def __getattr__(self, name): def method(*args, **kwargs): project = self.dynamic_menu_data.get(name) if project: self.link_project(project) return method def build_menu(self): if not self.flame: return [] menu = deepcopy(self.menu) menu['actions'].append({ "name": "Load...", "execute": lambda x: callback_selection( x, self.tools_helper.show_loader) }) menu['actions'].append({ "name": "Manage...", "execute": lambda x: self.tools_helper.show_scene_inventory() }) menu['actions'].append({ "name": "Library...", "execute": lambda x: self.tools_helper.show_library_loader() }) return menu def refresh(self, *args, **kwargs): self.rescan() def rescan(self, *args, **kwargs): if not self.flame: try: import flame self.flame = flame except ImportError: self.flame = None if self.flame: self.flame.execute_shortcut('Rescan Python Hooks') self.log.info('Rescan Python Hooks') ================================================ FILE: openpype/hosts/flame/api/pipeline.py ================================================ """ Basic avalon integration """ import os import contextlib from pyblish import api as pyblish from openpype.lib import Logger from openpype.pipeline import ( register_loader_plugin_path, register_creator_plugin_path, deregister_loader_plugin_path, deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) from .lib import ( set_segment_data_marker, set_publish_attribute, maintained_segment_selection, get_current_sequence, reset_segment_selection ) from .. import HOST_DIR API_DIR = os.path.join(HOST_DIR, "api") PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") AVALON_CONTAINERS = "AVALON_CONTAINERS" log = Logger.get_logger(__name__) def install(): pyblish.register_host("flame") pyblish.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) log.info("OpenPype Flame plug-ins registered ...") # register callback for switching publishable pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) log.info("OpenPype Flame host installed ...") def uninstall(): pyblish.deregister_host("flame") log.info("Deregistering Flame plug-ins..") pyblish.deregister_plugin_path(PUBLISH_PATH) deregister_loader_plugin_path(LOAD_PATH) deregister_creator_plugin_path(CREATE_PATH) # register callback for switching publishable pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) log.info("OpenPype Flame host uninstalled ...") def containerise(flame_clip_segment, name, namespace, context, loader=None, data=None): data_imprint = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": str(name), "namespace": str(namespace), "loader": str(loader), "representation": str(context["representation"]["_id"]), } if data: for k, v in data.items(): data_imprint[k] = v log.debug("_ data_imprint: {}".format(data_imprint)) set_segment_data_marker(flame_clip_segment, data_imprint) return True def ls(): """List available containers. """ return [] def parse_container(tl_segment, validate=True): """Return container data from timeline_item's openpype tag. """ # TODO: parse_container pass def update_container(tl_segment, data=None): """Update container data to input timeline_item's openpype tag. """ # TODO: update_container pass def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle node passthrough states on instance toggles.""" log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( instance, old_value, new_value)) # from openpype.hosts.resolve import ( # set_publish_attribute # ) # # Whether instances should be passthrough based on new value # timeline_item = instance.data["item"] # set_publish_attribute(timeline_item, new_value) def remove_instance(instance): """Remove instance marker from track item.""" # TODO: remove_instance pass def list_instances(): """List all created instances from current workfile.""" # TODO: list_instances pass def imprint(segment, data=None): """ Adding openpype data to Flame timeline segment. Also including publish attribute into tag. Arguments: segment (flame.PySegment)): flame api object data (dict): Any data which needst to be imprinted Examples: data = { 'asset': 'sq020sh0280', 'family': 'render', 'subset': 'subsetMain' } """ data = data or {} set_segment_data_marker(segment, data) # add publish attribute set_publish_attribute(segment, True) @contextlib.contextmanager def maintained_selection(): import flame from .lib import CTX # check if segment is selected if isinstance(CTX.selection[0], flame.PySegment): sequence = get_current_sequence(CTX.selection) try: with maintained_segment_selection(sequence) as selected: yield finally: # reset all selected clips reset_segment_selection(sequence) # select only original selection of segments for segment in selected: segment.selected = True ================================================ FILE: openpype/hosts/flame/api/plugin.py ================================================ import os import re import shutil from copy import deepcopy from xml.etree import ElementTree as ET import qargparse from qtpy import QtCore, QtWidgets from openpype import style from openpype.lib import Logger, StringTemplate from openpype.pipeline import LegacyCreator, LoaderPlugin from openpype.pipeline.colorspace import get_remapped_colorspace_to_native from openpype.settings import get_current_project_settings from . import constants from . import lib as flib from . import pipeline as fpipeline log = Logger.get_logger(__name__) class CreatorWidget(QtWidgets.QDialog): # output items items = dict() _results_back = None def __init__(self, name, info, ui_inputs, parent=None): super(CreatorWidget, self).__init__(parent) self.setObjectName(name) self.setWindowFlags( QtCore.Qt.Window | QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint | QtCore.Qt.WindowCloseButtonHint | QtCore.Qt.WindowStaysOnTopHint ) self.setWindowTitle(name or "Pype Creator Input") self.resize(500, 700) # Where inputs and labels are set self.content_widget = [QtWidgets.QWidget(self)] top_layout = QtWidgets.QFormLayout(self.content_widget[0]) top_layout.setObjectName("ContentLayout") top_layout.addWidget(Spacer(5, self)) # first add widget tag line top_layout.addWidget(QtWidgets.QLabel(info)) # main dynamic layout self.scroll_area = QtWidgets.QScrollArea(self, widgetResizable=True) self.scroll_area.setVerticalScrollBarPolicy( QtCore.Qt.ScrollBarAsNeeded) self.scroll_area.setVerticalScrollBarPolicy( QtCore.Qt.ScrollBarAlwaysOn) self.scroll_area.setHorizontalScrollBarPolicy( QtCore.Qt.ScrollBarAlwaysOff) self.scroll_area.setWidgetResizable(True) self.content_widget.append(self.scroll_area) scroll_widget = QtWidgets.QWidget(self) in_scroll_area = QtWidgets.QVBoxLayout(scroll_widget) self.content_layout = [in_scroll_area] # add preset data into input widget layout self.items = self.populate_widgets(ui_inputs) self.scroll_area.setWidget(scroll_widget) # Confirmation buttons btns_widget = QtWidgets.QWidget(self) btns_layout = QtWidgets.QHBoxLayout(btns_widget) cancel_btn = QtWidgets.QPushButton("Cancel") btns_layout.addWidget(cancel_btn) ok_btn = QtWidgets.QPushButton("Ok") btns_layout.addWidget(ok_btn) # Main layout of the dialog main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(10, 10, 10, 10) main_layout.setSpacing(0) # adding content widget for w in self.content_widget: main_layout.addWidget(w) main_layout.addWidget(btns_widget) ok_btn.clicked.connect(self._on_ok_clicked) cancel_btn.clicked.connect(self._on_cancel_clicked) self.setStyleSheet(style.load_stylesheet()) @classmethod def set_results_back(cls, value): cls._results_back = value @classmethod def get_results_back(cls): return cls._results_back def _on_ok_clicked(self): log.debug("ok is clicked: {}".format(self.items)) results_back = self._values(self.items) self.set_results_back(results_back) self.close() def _on_cancel_clicked(self): self.set_results_back(None) self.close() def showEvent(self, event): self.set_results_back(None) super(CreatorWidget, self).showEvent(event) def _values(self, data, new_data=None): new_data = new_data or dict() for k, v in data.items(): new_data[k] = { "target": None, "value": None } if v["type"] == "dict": new_data[k]["target"] = v["target"] new_data[k]["value"] = self._values(v["value"]) if v["type"] == "section": new_data.pop(k) new_data = self._values(v["value"], new_data) elif getattr(v["value"], "currentText", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].currentText() elif getattr(v["value"], "isChecked", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].isChecked() elif getattr(v["value"], "value", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].value() elif getattr(v["value"], "text", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].text() return new_data def camel_case_split(self, text): matches = re.finditer( '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', text) return " ".join([str(m.group(0)).capitalize() for m in matches]) def create_row(self, layout, type_name, text, **kwargs): # get type attribute from qwidgets attr = getattr(QtWidgets, type_name) # convert label text to normal capitalized text with spaces label_text = self.camel_case_split(text) # assign the new text to label widget label = QtWidgets.QLabel(label_text) label.setObjectName("LineLabel") # create attribute name text strip of spaces attr_name = text.replace(" ", "") # create attribute and assign default values setattr( self, attr_name, attr(parent=self)) # assign the created attribute to variable item = getattr(self, attr_name) for func, val in kwargs.items(): if getattr(item, func): func_attr = getattr(item, func) func_attr(val) # add to layout layout.addRow(label, item) return item def populate_widgets(self, data, content_layout=None): """ Populate widget from input dict. Each plugin has its own set of widget rows defined in dictionary each row values should have following keys: `type`, `target`, `label`, `order`, `value` and optionally also `toolTip`. Args: data (dict): widget rows or organized groups defined by types `dict` or `section` content_layout (QtWidgets.QFormLayout)[optional]: used when nesting Returns: dict: redefined data dict updated with created widgets """ content_layout = content_layout or self.content_layout[-1] # fix order of process by defined order value ordered_keys = list(data.keys()) for k, v in data.items(): try: # try removing a key from index which should # be filled with new ordered_keys.pop(v["order"]) except IndexError: pass # add key into correct order ordered_keys.insert(v["order"], k) # process ordered for k in ordered_keys: v = data[k] tool_tip = v.get("toolTip", "") if v["type"] == "dict": self.content_layout.append(QtWidgets.QWidget(self)) content_layout.addWidget(self.content_layout[-1]) self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) headline.addWidget(Spacer(20, self)) headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label self.content_layout.append(QtWidgets.QWidget(self)) self.content_layout[-1].setObjectName("sectionContent") nested_content_layout = QtWidgets.QFormLayout( self.content_layout[-1]) nested_content_layout.setObjectName("NestedContentLayout") content_layout.addWidget(self.content_layout[-1]) # add nested key as label data[k]["value"] = self.populate_widgets( v["value"], nested_content_layout) if v["type"] == "section": self.content_layout.append(QtWidgets.QWidget(self)) content_layout.addWidget(self.content_layout[-1]) self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) headline.addWidget(Spacer(20, self)) headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label self.content_layout.append(QtWidgets.QWidget(self)) self.content_layout[-1].setObjectName("sectionContent") nested_content_layout = QtWidgets.QFormLayout( self.content_layout[-1]) nested_content_layout.setObjectName("NestedContentLayout") content_layout.addWidget(self.content_layout[-1]) # add nested key as label data[k]["value"] = self.populate_widgets( v["value"], nested_content_layout) elif v["type"] == "QLineEdit": data[k]["value"] = self.create_row( content_layout, "QLineEdit", v["label"], setText=v["value"], setToolTip=tool_tip) elif v["type"] == "QComboBox": data[k]["value"] = self.create_row( content_layout, "QComboBox", v["label"], addItems=v["value"], setToolTip=tool_tip) elif v["type"] == "QCheckBox": data[k]["value"] = self.create_row( content_layout, "QCheckBox", v["label"], setChecked=v["value"], setToolTip=tool_tip) elif v["type"] == "QSpinBox": data[k]["value"] = self.create_row( content_layout, "QSpinBox", v["label"], setValue=v["value"], setMinimum=0, setMaximum=100000, setToolTip=tool_tip) return data class Spacer(QtWidgets.QWidget): def __init__(self, height, *args, **kwargs): super(self.__class__, self).__init__(*args, **kwargs) self.setFixedHeight(height) real_spacer = QtWidgets.QWidget(self) real_spacer.setObjectName("Spacer") real_spacer.setFixedHeight(height) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(real_spacer) self.setLayout(layout) class Creator(LegacyCreator): """Creator class wrapper """ clip_color = constants.COLOR_MAP["purple"] rename_index = None def __init__(self, *args, **kwargs): super(Creator, self).__init__(*args, **kwargs) self.presets = get_current_project_settings()[ "flame"]["create"].get(self.__class__.__name__, {}) # adding basic current context flame objects self.project = flib.get_current_project() self.sequence = flib.get_current_sequence(flib.CTX.selection) if (self.options or {}).get("useSelection"): self.selected = flib.get_sequence_segments(self.sequence, True) else: self.selected = flib.get_sequence_segments(self.sequence) def create_widget(self, *args, **kwargs): widget = CreatorWidget(*args, **kwargs) widget.exec_() return widget.get_results_back() class PublishableClip: """ Convert a segment to publishable instance Args: segment (flame.PySegment): flame api object kwargs (optional): additional data needed for rename=True (presets) Returns: flame.PySegment: flame api object """ vertical_clip_match = {} marker_data = {} types = { "shot": "shot", "folder": "folder", "episode": "episode", "sequence": "sequence", "track": "sequence", } # parents search pattern parents_search_pattern = r"\{([a-z]*?)\}" # default templates for non-ui use rename_default = False hierarchy_default = "{_folder_}/{_sequence_}/{_track_}" clip_name_default = "shot_{_trackIndex_:0>3}_{_clipIndex_:0>4}" subset_name_default = "[ track name ]" review_track_default = "[ none ]" subset_family_default = "plate" count_from_default = 10 count_steps_default = 10 vertical_sync_default = False driving_layer_default = "" index_from_segment_default = False use_shot_name_default = False include_handles_default = False retimed_handles_default = True retimed_framerange_default = True def __init__(self, segment, **kwargs): self.rename_index = kwargs["rename_index"] self.family = kwargs["family"] self.log = kwargs["log"] # get main parent objects self.current_segment = segment sequence_name = flib.get_current_sequence([segment]).name.get_value() self.sequence_name = str(sequence_name).replace(" ", "_") self.clip_data = flib.get_segment_attributes(segment) # segment (clip) main attributes self.cs_name = self.clip_data["segment_name"] self.cs_index = int(self.clip_data["segment"]) self.shot_name = self.clip_data["shot_name"] # get track name and index self.track_index = int(self.clip_data["track"]) track_name = self.clip_data["track_name"] self.track_name = str(track_name).replace(" ", "_").replace( "*", "noname{}".format(self.track_index)) # adding tag.family into tag if kwargs.get("avalon"): self.marker_data.update(kwargs["avalon"]) # add publish attribute to marker data self.marker_data.update({"publish": True}) # adding ui inputs if any self.ui_inputs = kwargs.get("ui_inputs", {}) self.log.info("Inside of plugin: {}".format( self.marker_data )) # populate default data before we get other attributes self._populate_segment_default_data() # use all populated default data to create all important attributes self._populate_attributes() # create parents with correct types self._create_parents() def convert(self): # solve segment data and add them to marker data self._convert_to_marker_data() # if track name is in review track name and also if driving track name # is not in review track name: skip tag creation if (self.track_name in self.review_layer) and ( self.driving_layer not in self.review_layer): return # deal with clip name new_name = self.marker_data.pop("newClipName") if self.rename and not self.use_shot_name: # rename segment self.current_segment.name = str(new_name) self.marker_data["asset"] = str(new_name) elif self.use_shot_name: self.marker_data["asset"] = self.shot_name self.marker_data["hierarchyData"]["shot"] = self.shot_name else: self.marker_data["asset"] = self.cs_name self.marker_data["hierarchyData"]["shot"] = self.cs_name if self.marker_data["heroTrack"] and self.review_layer: self.marker_data["reviewTrack"] = self.review_layer else: self.marker_data["reviewTrack"] = None # create pype tag on track_item and add data fpipeline.imprint(self.current_segment, self.marker_data) return self.current_segment def _populate_segment_default_data(self): """ Populate default formatting data from segment. """ self.current_segment_default_data = { "_folder_": "shots", "_sequence_": self.sequence_name, "_track_": self.track_name, "_clip_": self.cs_name, "_trackIndex_": self.track_index, "_clipIndex_": self.cs_index } def _populate_attributes(self): """ Populate main object attributes. """ # segment frame range and parent track name for vertical sync check self.clip_in = int(self.clip_data["record_in"]) self.clip_out = int(self.clip_data["record_out"]) # define ui inputs if non gui mode was used self.shot_num = self.cs_index self.log.debug( "____ self.shot_num: {}".format(self.shot_num)) # ui_inputs data or default values if gui was not used self.rename = self.ui_inputs.get( "clipRename", {}).get("value") or self.rename_default self.use_shot_name = self.ui_inputs.get( "useShotName", {}).get("value") or self.use_shot_name_default self.clip_name = self.ui_inputs.get( "clipName", {}).get("value") or self.clip_name_default self.hierarchy = self.ui_inputs.get( "hierarchy", {}).get("value") or self.hierarchy_default self.hierarchy_data = self.ui_inputs.get( "hierarchyData", {}).get("value") or \ self.current_segment_default_data.copy() self.index_from_segment = self.ui_inputs.get( "segmentIndex", {}).get("value") or self.index_from_segment_default self.count_from = self.ui_inputs.get( "countFrom", {}).get("value") or self.count_from_default self.count_steps = self.ui_inputs.get( "countSteps", {}).get("value") or self.count_steps_default self.subset_name = self.ui_inputs.get( "subsetName", {}).get("value") or self.subset_name_default self.subset_family = self.ui_inputs.get( "subsetFamily", {}).get("value") or self.subset_family_default self.vertical_sync = self.ui_inputs.get( "vSyncOn", {}).get("value") or self.vertical_sync_default self.driving_layer = self.ui_inputs.get( "vSyncTrack", {}).get("value") or self.driving_layer_default self.review_track = self.ui_inputs.get( "reviewTrack", {}).get("value") or self.review_track_default self.audio = self.ui_inputs.get( "audio", {}).get("value") or False self.include_handles = self.ui_inputs.get( "includeHandles", {}).get("value") or self.include_handles_default self.retimed_handles = ( self.ui_inputs.get("retimedHandles", {}).get("value") or self.retimed_handles_default ) self.retimed_framerange = ( self.ui_inputs.get("retimedFramerange", {}).get("value") or self.retimed_framerange_default ) # build subset name from layer name if self.subset_name == "[ track name ]": self.subset_name = self.track_name # create subset for publishing self.subset = self.subset_family + self.subset_name.capitalize() def _replace_hash_to_expression(self, name, text): """ Replace hash with number in correct padding. """ _spl = text.split("#") _len = (len(_spl) - 1) _repl = "{{{0}:0>{1}}}".format(name, _len) return text.replace(("#" * _len), _repl) def _convert_to_marker_data(self): """ Convert internal data to marker data. Populating the marker data into internal variable self.marker_data """ # define vertical sync attributes hero_track = True self.review_layer = "" if self.vertical_sync and self.track_name not in self.driving_layer: # if it is not then define vertical sync as None hero_track = False # increasing steps by index of rename iteration if not self.index_from_segment: self.count_steps *= self.rename_index hierarchy_formatting_data = {} hierarchy_data = deepcopy(self.hierarchy_data) _data = self.current_segment_default_data.copy() if self.ui_inputs: # adding tag metadata from ui for _k, _v in self.ui_inputs.items(): if _v["target"] == "tag": self.marker_data[_k] = _v["value"] # driving layer is set as positive match if hero_track or self.vertical_sync: # mark review layer if self.review_track and ( self.review_track not in self.review_track_default): # if review layer is defined and not the same as default self.review_layer = self.review_track # shot num calculate if self.index_from_segment: # use clip index from timeline self.shot_num = self.count_steps * self.cs_index else: if self.rename_index == 0: self.shot_num = self.count_from else: self.shot_num = self.count_from + self.count_steps # clip name sequence number _data.update({"shot": self.shot_num}) # solve # in test to pythonic expression for _k, _v in hierarchy_data.items(): if "#" not in _v["value"]: continue hierarchy_data[ _k]["value"] = self._replace_hash_to_expression( _k, _v["value"]) # fill up pythonic expresisons in hierarchy data for k, _v in hierarchy_data.items(): hierarchy_formatting_data[k] = _v["value"].format(**_data) else: # if no gui mode then just pass default data hierarchy_formatting_data = hierarchy_data tag_hierarchy_data = self._solve_tag_hierarchy_data( hierarchy_formatting_data ) tag_hierarchy_data.update({"heroTrack": True}) if hero_track and self.vertical_sync: self.vertical_clip_match.update({ (self.clip_in, self.clip_out): tag_hierarchy_data }) if not hero_track and self.vertical_sync: # driving layer is set as negative match for (_in, _out), hero_data in self.vertical_clip_match.items(): """ Since only one instance of hero clip is expected in `self.vertical_clip_match`, this will loop only once until none hero clip will be matched with hero clip. `tag_hierarchy_data` will be set only once for every clip which is not hero clip. """ _hero_data = deepcopy(hero_data) _hero_data.update({"heroTrack": False}) if _in <= self.clip_in and _out >= self.clip_out: data_subset = hero_data["subset"] # add track index in case duplicity of names in hero data if self.subset in data_subset: _hero_data["subset"] = self.subset + str( self.track_index) # in case track name and subset name is the same then add if self.subset_name == self.track_name: _hero_data["subset"] = self.subset # assign data to return hierarchy data to tag tag_hierarchy_data = _hero_data break # add data to return data dict self.marker_data.update(tag_hierarchy_data) def _solve_tag_hierarchy_data(self, hierarchy_formatting_data): """ Solve marker data from hierarchy data and templates. """ # fill up clip name and hierarchy keys hierarchy_filled = self.hierarchy.format(**hierarchy_formatting_data) clip_name_filled = self.clip_name.format(**hierarchy_formatting_data) # remove shot from hierarchy data: is not needed anymore hierarchy_formatting_data.pop("shot") return { "newClipName": clip_name_filled, "hierarchy": hierarchy_filled, "parents": self.parents, "hierarchyData": hierarchy_formatting_data, "subset": self.subset, "family": self.subset_family, "families": [self.family] } def _convert_to_entity(self, type, template): """ Converting input key to key with type. """ # convert to entity type entity_type = self.types.get(type, None) assert entity_type, "Missing entity type for `{}`".format( type ) # first collect formatting data to use for formatting template formatting_data = {} for _k, _v in self.hierarchy_data.items(): value = _v["value"].format( **self.current_segment_default_data) formatting_data[_k] = value return { "entity_type": entity_type, "entity_name": template.format( **formatting_data ) } def _create_parents(self): """ Create parents and return it in list. """ self.parents = [] pattern = re.compile(self.parents_search_pattern) par_split = [(pattern.findall(t).pop(), t) for t in self.hierarchy.split("/")] for type, template in par_split: parent = self._convert_to_entity(type, template) self.parents.append(parent) # Publishing plugin functions # Loader plugin functions class ClipLoader(LoaderPlugin): """A basic clip loader for Flame This will implement the basic behavior for a loader to inherit from that will containerize the reference and will implement the `remove` and `update` logic. """ log = log options = [ qargparse.Boolean( "handles", label="Set handles", default=0, help="Also set handles to clip as In/Out marks" ) ] _mapping = None _host_settings = None def apply_settings(cls, project_settings, system_settings): plugin_type_settings = ( project_settings .get("flame", {}) .get("load", {}) ) if not plugin_type_settings: return plugin_name = cls.__name__ plugin_settings = None # Look for plugin settings in host specific settings if plugin_name in plugin_type_settings: plugin_settings = plugin_type_settings[plugin_name] if not plugin_settings: return print(">>> We have preset for {}".format(plugin_name)) for option, value in plugin_settings.items(): if option == "enabled" and value is False: print(" - is disabled by preset") elif option == "representations": continue else: print(" - setting `{}`: `{}`".format(option, value)) setattr(cls, option, value) def get_colorspace(self, context): """Get colorspace name Look either to version data or representation data. Args: context (dict): version context data Returns: str: colorspace name or None """ version = context['version'] version_data = version.get("data", {}) colorspace = version_data.get( "colorspace", None ) if ( not colorspace or colorspace == "Unknown" ): colorspace = context["representation"]["data"].get( "colorspace", None) return colorspace @classmethod def get_native_colorspace(cls, input_colorspace): """Return native colorspace name. Args: input_colorspace (str | None): colorspace name Returns: str: native colorspace name defined in mapping or None """ # TODO: rewrite to support only pipeline's remapping if not cls._host_settings: cls._host_settings = get_current_project_settings()["flame"] # [Deprecated] way of remapping if not cls._mapping: mapping = ( cls._host_settings["imageio"]["profilesMapping"]["inputs"]) cls._mapping = { input["ocioName"]: input["flameName"] for input in mapping } native_name = cls._mapping.get(input_colorspace) if not native_name: native_name = get_remapped_colorspace_to_native( input_colorspace, "flame", cls._host_settings["imageio"]) return native_name class OpenClipSolver(flib.MediaInfoFile): create_new_clip = False log = log def __init__(self, openclip_file_path, feed_data, logger=None): self.out_file = openclip_file_path # replace log if any if logger: self.log = logger # new feed variables: feed_path = feed_data.pop("path") # initialize parent class super(OpenClipSolver, self).__init__( feed_path, logger=logger ) # get other metadata self.feed_version_name = feed_data["version"] self.feed_colorspace = feed_data.get("colorspace") self.log.debug("feed_version_name: {}".format(self.feed_version_name)) # layer rename variables self.layer_rename_template = feed_data["layer_rename_template"] self.layer_rename_patterns = feed_data["layer_rename_patterns"] self.context_data = feed_data["context_data"] # derivate other feed variables self.feed_basename = os.path.basename(feed_path) self.feed_dir = os.path.dirname(feed_path) self.feed_ext = os.path.splitext(self.feed_basename)[1][1:].lower() self.log.debug("feed_ext: {}".format(self.feed_ext)) self.log.debug("out_file: {}".format(self.out_file)) if not self._is_valid_tmp_file(self.out_file): self.create_new_clip = True def _is_valid_tmp_file(self, file): # check if file exists if os.path.isfile(file): # test also if file is not empty with open(file) as f: lines = f.readlines() if len(lines) > 2: return True # file is probably corrupted os.remove(file) return False def make(self): if self.create_new_clip: # New openClip self._create_new_open_clip() else: self._update_open_clip() def _clear_handler(self, xml_object): for handler in xml_object.findall("./handler"): self.log.info("Handler found") xml_object.remove(handler) def _create_new_open_clip(self): self.log.info("Building new openClip") for tmp_xml_track in self.clip_data.iter("track"): # solve track (layer) name self._rename_track_name(tmp_xml_track) tmp_xml_feeds = tmp_xml_track.find('feeds') tmp_xml_feeds.set('currentVersion', self.feed_version_name) for tmp_feed in tmp_xml_track.iter("feed"): tmp_feed.set('vuid', self.feed_version_name) # add colorspace if any is set if self.feed_colorspace: self._add_colorspace(tmp_feed, self.feed_colorspace) self._clear_handler(tmp_feed) tmp_xml_versions_obj = self.clip_data.find('versions') tmp_xml_versions_obj.set('currentVersion', self.feed_version_name) for xml_new_version in tmp_xml_versions_obj: xml_new_version.set('uid', self.feed_version_name) xml_new_version.set('type', 'version') self._clear_handler(self.clip_data) self.log.info("Adding feed version: {}".format(self.feed_basename)) self.write_clip_data_to_file(self.out_file, self.clip_data) def _get_xml_track_obj_by_uid(self, xml_data, uid): # loop all tracks of input xml data for xml_track in xml_data.iter("track"): track_uid = xml_track.get("uid") self.log.debug( ">> track_uid:uid: {}:{}".format(track_uid, uid)) # get matching uids if uid == track_uid: return xml_track def _rename_track_name(self, xml_track_data): layer_uid = xml_track_data.get("uid") name_obj = xml_track_data.find("name") layer_name = name_obj.text if ( self.layer_rename_patterns and not any( re.search(lp_.lower(), layer_name.lower()) for lp_ in self.layer_rename_patterns ) ): return formatting_data = self._update_formatting_data( layerName=layer_name, layerUID=layer_uid ) name_obj.text = StringTemplate( self.layer_rename_template ).format(formatting_data) def _update_formatting_data(self, **kwargs): """ Updating formatting data for layer rename Attributes: key=value (optional): will be included to formatting data as {key: value} Returns: dict: anatomy context data for formatting """ self.log.debug(">> self.clip_data: {}".format(self.clip_data)) clip_name_obj = self.clip_data.find("name") data = { "originalBasename": clip_name_obj.text } # include version context data data.update(self.context_data) # include input kwargs data data.update(kwargs) return data def _update_open_clip(self): self.log.info("Updating openClip ..") out_xml = ET.parse(self.out_file) out_xml = out_xml.getroot() self.log.debug(">> out_xml: {}".format(out_xml)) # loop tmp tracks updated_any = False for tmp_xml_track in self.clip_data.iter("track"): # solve track (layer) name self._rename_track_name(tmp_xml_track) # get tmp track uid tmp_track_uid = tmp_xml_track.get("uid") self.log.debug(">> tmp_track_uid: {}".format(tmp_track_uid)) # get out data track by uid out_track_element = self._get_xml_track_obj_by_uid( out_xml, tmp_track_uid) self.log.debug( ">> out_track_element: {}".format(out_track_element)) # loop tmp feeds for tmp_xml_feed in tmp_xml_track.iter("feed"): new_path_obj = tmp_xml_feed.find( "spans/span/path") new_path = new_path_obj.text # check if feed path already exists in track's feeds if ( out_track_element is not None and self._feed_exists(out_track_element, new_path) ): continue # rename versions on feeds tmp_xml_feed.set('vuid', self.feed_version_name) self._clear_handler(tmp_xml_feed) # update fps from MediaInfoFile class if self.fps is not None: tmp_feed_fps_obj = tmp_xml_feed.find( "startTimecode/rate") tmp_feed_fps_obj.text = str(self.fps) # update start_frame from MediaInfoFile class if self.start_frame is not None: tmp_feed_nb_ticks_obj = tmp_xml_feed.find( "startTimecode/nbTicks") tmp_feed_nb_ticks_obj.text = str(self.start_frame) # update drop_mode from MediaInfoFile class if self.drop_mode is not None: tmp_feed_drop_mode_obj = tmp_xml_feed.find( "startTimecode/dropMode") tmp_feed_drop_mode_obj.text = str(self.drop_mode) # add colorspace if any is set if self.feed_colorspace is not None: self._add_colorspace(tmp_xml_feed, self.feed_colorspace) # then append/update feed to correct track in output if out_track_element: self.log.debug("updating track element ..") # update already present track out_feeds = out_track_element.find('feeds') out_feeds.set('currentVersion', self.feed_version_name) out_feeds.append(tmp_xml_feed) self.log.info( "Appending new feed: {}".format( self.feed_version_name)) else: self.log.debug("adding new track element ..") # create new track as it doesnt exists yet # set current version to feeds on tmp tmp_xml_feeds = tmp_xml_track.find('feeds') tmp_xml_feeds.set('currentVersion', self.feed_version_name) out_tracks = out_xml.find("tracks") out_tracks.append(tmp_xml_track) updated_any = True if updated_any: # Append vUID to versions out_xml_versions_obj = out_xml.find('versions') out_xml_versions_obj.set( 'currentVersion', self.feed_version_name) new_version_obj = ET.Element( "version", {"type": "version", "uid": self.feed_version_name}) out_xml_versions_obj.insert(0, new_version_obj) self._clear_handler(out_xml) # fist create backup self._create_openclip_backup_file(self.out_file) self.log.info("Adding feed version: {}".format( self.feed_version_name)) self.write_clip_data_to_file(self.out_file, out_xml) self.log.debug("OpenClip Updated: {}".format(self.out_file)) def _feed_exists(self, xml_data, path): # loop all available feed paths and check if # the path is not already in file for src_path in xml_data.iter('path'): if path == src_path.text: self.log.warning( "Not appending file as it already is in .clip file") return True def _create_openclip_backup_file(self, file): bck_file = "{}.bak".format(file) # if backup does not exist if not os.path.isfile(bck_file): shutil.copy2(file, bck_file) else: # in case it exists and is already multiplied created = False for _i in range(1, 99): bck_file = "{name}.bak.{idx:0>2}".format( name=file, idx=_i) # create numbered backup file if not os.path.isfile(bck_file): shutil.copy2(file, bck_file) created = True break # in case numbered does not exists if not created: bck_file = "{}.bak.last".format(file) shutil.copy2(file, bck_file) def _add_colorspace(self, feed_obj, profile_name): feed_storage_obj = feed_obj.find("storageFormat") feed_clr_obj = feed_storage_obj.find("colourSpace") if feed_clr_obj is not None: feed_clr_obj = ET.Element( "colourSpace", {"type": "string"}) feed_clr_obj.text = profile_name feed_storage_obj.append(feed_clr_obj) ================================================ FILE: openpype/hosts/flame/api/render_utils.py ================================================ import os from xml.etree import ElementTree as ET from openpype.lib import Logger log = Logger.get_logger(__name__) def export_clip(export_path, clip, preset_path, **kwargs): """Flame exported wrapper Args: export_path (str): exporting directory path clip (PyClip): flame api object preset_path (str): full export path to xml file Kwargs: thumb_frame_number (int)[optional]: source frame number in_mark (int)[optional]: cut in mark out_mark (int)[optional]: cut out mark Raises: KeyError: Missing input kwarg `thumb_frame_number` in case `thumbnail` in `export_preset` FileExistsError: Missing export preset in shared folder """ import flame in_mark = out_mark = None # Set exporter exporter = flame.PyExporter() exporter.foreground = True exporter.export_between_marks = True if kwargs.get("thumb_frame_number"): thumb_frame_number = kwargs["thumb_frame_number"] # make sure it exists in kwargs if not thumb_frame_number: raise KeyError( "Missing key `thumb_frame_number` in input kwargs") in_mark = int(thumb_frame_number) out_mark = int(thumb_frame_number) + 1 elif kwargs.get("in_mark") and kwargs.get("out_mark"): in_mark = int(kwargs["in_mark"]) out_mark = int(kwargs["out_mark"]) else: exporter.export_between_marks = False try: # set in and out marks if they are available if in_mark and out_mark: clip.in_mark = in_mark clip.out_mark = out_mark # export with exporter exporter.export(clip, preset_path, export_path) finally: print('Exported: {} at {}-{}'.format( clip.name.get_value(), clip.in_mark, clip.out_mark )) def get_preset_path_by_xml_name(xml_preset_name): def _search_path(root): output = [] for root, _dirs, files in os.walk(root): for f in files: if f != xml_preset_name: continue file_path = os.path.join(root, f) output.append(file_path) return output def _validate_results(results): if results and len(results) == 1: return results.pop() elif results and len(results) > 1: print(( "More matching presets for `{}`: /n" "{}").format(xml_preset_name, results)) return results.pop() else: return None from .utils import ( get_flame_install_root, get_flame_version ) # get actual flame version and install path _version = get_flame_version()["full"] _install_root = get_flame_install_root() # search path templates shared_search_root = "{install_root}/shared/export/presets" install_search_root = ( "{install_root}/presets/{version}/export/presets/flame") # fill templates shared_search_root = shared_search_root.format( install_root=_install_root ) install_search_root = install_search_root.format( install_root=_install_root, version=_version ) # get search results shared_results = _search_path(shared_search_root) installed_results = _search_path(install_search_root) # first try to return shared results shared_preset_path = _validate_results(shared_results) if shared_preset_path: return os.path.dirname(shared_preset_path) # then try installed results installed_preset_path = _validate_results(installed_results) if installed_preset_path: return os.path.dirname(installed_preset_path) # if nothing found then return False return False def modify_preset_file(xml_path, staging_dir, data): """Modify xml preset with input data Args: xml_path (str ): path for input xml preset staging_dir (str): staging dir path data (dict): data where key is xmlTag and value as string Returns: str: _description_ """ # create temp path dirname, basename = os.path.split(xml_path) temp_path = os.path.join(staging_dir, basename) # change xml following data keys with open(xml_path, "r") as datafile: _root = ET.parse(datafile) for key, value in data.items(): try: if "/" in key: if not key.startswith("./"): key = ".//" + key split_key_path = key.split("/") element_key = split_key_path[-1] parent_obj_path = "/".join(split_key_path[:-1]) parent_obj = _root.find(parent_obj_path) element_obj = parent_obj.find(element_key) if not element_obj: append_element(parent_obj, element_key, value) else: finds = _root.findall(".//{}".format(key)) if not finds: raise AttributeError for element in finds: element.text = str(value) except AttributeError: log.warning( "Cannot create attribute: {}: {}. Skipping".format( key, value )) _root.write(temp_path) return temp_path def append_element(root_element_obj, key, value): new_element_obj = ET.Element(key) log.debug("__ new_element_obj: {}".format(new_element_obj)) new_element_obj.text = str(value) root_element_obj.insert(0, new_element_obj) ================================================ FILE: openpype/hosts/flame/api/scripts/wiretap_com.py ================================================ #!/usr/bin/env python2.7 # -*- coding: utf-8 -*- from __future__ import absolute_import import os import sys import subprocess import json import xml.dom.minidom as minidom from copy import deepcopy import datetime from libwiretapPythonClientAPI import ( # noqa WireTapClientInit, WireTapClientUninit, WireTapNodeHandle, WireTapServerHandle, WireTapInt, WireTapStr ) class WireTapCom(object): """ Comunicator class wrapper for talking to WireTap db. This way we are able to set new project with settings and correct colorspace policy. Also we are able to create new user or get actual user with similar name (users are usually cloning their profiles and adding date stamp into suffix). """ def __init__(self, host_name=None, volume_name=None, group_name=None): """Initialisation of WireTap communication class Args: host_name (str, optional): Name of host server. Defaults to None. volume_name (str, optional): Name of volume. Defaults to None. group_name (str, optional): Name of user group. Defaults to None. """ # set main attributes of server # if there are none set the default installation self.host_name = host_name or "localhost" self.volume_name = volume_name or "stonefs" self.group_name = group_name or "staff" # wiretap tools dir path self.wiretap_tools_dir = os.getenv("OPENPYPE_WIRETAP_TOOLS") # initialize WireTap client WireTapClientInit() # add the server to shared variable self._server = WireTapServerHandle("{}:IFFFS".format(self.host_name)) print("WireTap connected at '{}'...".format( self.host_name)) def close(self): self._server = None WireTapClientUninit() print("WireTap closed...") def get_launch_args( self, project_name, project_data, user_name, *args, **kwargs): """Forming launch arguments for OpenPype launcher. Args: project_name (str): name of project project_data (dict): Flame compatible project data user_name (str): name of user Returns: list: arguments """ workspace_name = kwargs.get("workspace_name") color_policy = kwargs.get("color_policy") project_exists = self._project_prep(project_name) if not project_exists: self._set_project_settings(project_name, project_data) self._set_project_colorspace(project_name, color_policy) user_name = self._user_prep(user_name) if workspace_name is None: # default workspace print("Using a default workspace") return [ "--start-project={}".format(project_name), "--start-user={}".format(user_name), "--create-workspace" ] else: print( "Using a custom workspace '{}'".format(workspace_name)) self._workspace_prep(project_name, workspace_name) return [ "--start-project={}".format(project_name), "--start-user={}".format(user_name), "--create-workspace", "--start-workspace={}".format(workspace_name) ] def _workspace_prep(self, project_name, workspace_name): """Preparing a workspace In case it doesn not exists it will create one Args: project_name (str): project name workspace_name (str): workspace name Raises: AttributeError: unable to create workspace """ workspace_exists = self._child_is_in_parent_path( "/projects/{}".format(project_name), workspace_name, "WORKSPACE" ) if not workspace_exists: project = WireTapNodeHandle( self._server, "/projects/{}".format(project_name)) workspace_node = WireTapNodeHandle() created_workspace = project.createNode( workspace_name, "WORKSPACE", workspace_node) if not created_workspace: raise AttributeError( "Cannot create workspace `{}` in " "project `{}`: `{}`".format( workspace_name, project_name, project.lastError()) ) print( "Workspace `{}` is successfully created".format(workspace_name)) def _project_prep(self, project_name): """Preparing a project In case it doesn not exists it will create one Args: project_name (str): project name Raises: AttributeError: unable to create project """ # test if projeft exists project_exists = self._child_is_in_parent_path( "/projects", project_name, "PROJECT") if not project_exists: volumes = self._get_all_volumes() if len(volumes) == 0: raise AttributeError( "Not able to create new project. No Volumes existing" ) # check if volumes exists if self.volume_name not in volumes: raise AttributeError( ("Volume '{}' does not exist in '{}'").format( self.volume_name, volumes) ) # form cmd arguments project_create_cmd = [ os.path.join( self.wiretap_tools_dir, "wiretap_create_node" ), '-n', os.path.join("/volumes", self.volume_name), '-d', project_name, '-g', ] project_create_cmd.append(self.group_name) print(project_create_cmd) exit_code = subprocess.call( project_create_cmd, cwd=os.path.expanduser('~'), preexec_fn=_subprocess_preexec_fn ) if exit_code != 0: RuntimeError("Cannot create project in flame db") print( "A new project '{}' is created.".format(project_name)) return project_exists def _get_all_volumes(self): """Request all available volumens from WireTap Returns: list: all available volumes in server Rises: AttributeError: unable to get any volumes children from server """ root = WireTapNodeHandle(self._server, "/volumes") children_num = WireTapInt(0) get_children_num = root.getNumChildren(children_num) if not get_children_num: raise AttributeError( "Cannot get number of volumes: {}".format(root.lastError()) ) volumes = [] # go through all children and get volume names child_obj = WireTapNodeHandle() for child_idx in range(children_num): # get a child if not root.getChild(child_idx, child_obj): raise AttributeError( "Unable to get child: {}".format(root.lastError())) node_name = WireTapStr() get_children_name = child_obj.getDisplayName(node_name) if not get_children_name: raise AttributeError( "Unable to get child name: {}".format( child_obj.lastError()) ) volumes.append(node_name.c_str()) return volumes def _user_prep(self, user_name): """Ensuring user does exists in user's stack Args: user_name (str): name of a user Raises: AttributeError: unable to create user """ # get all used usernames in db used_names = self._get_usernames() print(">> used_names: {}".format(used_names)) # filter only those which are sharing input user name filtered_users = [user for user in used_names if user_name in user] if filtered_users: # TODO: need to find lastly created following regex pattern for # date used in name return filtered_users.pop() # create new user name with date in suffix now = datetime.datetime.now() # current date and time date = now.strftime("%Y%m%d") new_user_name = "{}_{}".format(user_name, date) print(new_user_name) if not self._child_is_in_parent_path("/users", new_user_name, "USER"): # Create the new user users = WireTapNodeHandle(self._server, "/users") user_node = WireTapNodeHandle() created_user = users.createNode(new_user_name, "USER", user_node) if not created_user: raise AttributeError( "User {} cannot be created: {}".format( new_user_name, users.lastError()) ) print("User `{}` is created".format(new_user_name)) return new_user_name def _get_usernames(self): """Requesting all available users from WireTap Returns: list: all available user names Raises: AttributeError: there are no users in server """ root = WireTapNodeHandle(self._server, "/users") children_num = WireTapInt(0) get_children_num = root.getNumChildren(children_num) if not get_children_num: raise AttributeError( "Cannot get number of volumes: {}".format(root.lastError()) ) usernames = [] # go through all children and get volume names child_obj = WireTapNodeHandle() for child_idx in range(children_num): # get a child if not root.getChild(child_idx, child_obj): raise AttributeError( "Unable to get child: {}".format(root.lastError())) node_name = WireTapStr() get_children_name = child_obj.getDisplayName(node_name) if not get_children_name: raise AttributeError( "Unable to get child name: {}".format( child_obj.lastError()) ) usernames.append(node_name.c_str()) return usernames def _child_is_in_parent_path(self, parent_path, child_name, child_type): """Checking if a given child is in parent path. Args: parent_path (str): db path to parent child_name (str): name of child child_type (str): type of child Raises: AttributeError: Not able to get number of children AttributeError: Not able to get children form parent AttributeError: Not able to get children name AttributeError: Not able to get children type Returns: bool: True if child is in parent path """ parent = WireTapNodeHandle(self._server, parent_path) # iterate number of children children_num = WireTapInt(0) requested = parent.getNumChildren(children_num) if not requested: raise AttributeError(( "Error: Cannot request number of " "children from the node {}. Make sure your " "wiretap service is running: {}").format( parent_path, parent.lastError()) ) # iterate children child_obj = WireTapNodeHandle() for child_idx in range(children_num): if not parent.getChild(child_idx, child_obj): raise AttributeError( "Cannot get child: {}".format( parent.lastError())) node_name = WireTapStr() node_type = WireTapStr() if not child_obj.getDisplayName(node_name): raise AttributeError( "Unable to get child name: %s" % child_obj.lastError() ) if not child_obj.getNodeTypeStr(node_type): raise AttributeError( "Unable to obtain child type: %s" % child_obj.lastError() ) if (node_name.c_str() == child_name) and ( node_type.c_str() == child_type): return True return False def _set_project_settings(self, project_name, project_data): """Setting project attributes. Args: project_name (str): name of project project_data (dict): data with project attributes (flame compatible) Raises: AttributeError: Not able to set project attributes """ # generated xml from project_data dict _xml = "" for key, value in project_data.items(): _xml += "<{}>{}".format(key, value, key) _xml += "" pretty_xml = minidom.parseString(_xml).toprettyxml() print("__ xml: {}".format(pretty_xml)) # set project data to wiretap project_node = WireTapNodeHandle( self._server, "/projects/{}".format(project_name)) if not project_node.setMetaData("XML", _xml): raise AttributeError( "Not able to set project attributes {}. Error: {}".format( project_name, project_node.lastError()) ) print("Project settings successfully set.") def _set_project_colorspace(self, project_name, color_policy): """Set project's colorspace policy. Args: project_name (str): name of project color_policy (str): name of policy Raises: RuntimeError: Not able to set colorspace policy """ color_policy = color_policy or "Legacy" # check if the colour policy in custom dir if "/" in color_policy: # if unlikelly full path was used make it redundant color_policy = color_policy.replace("/syncolor/policies/", "") # expecting input is `Shared/NameOfPolicy` color_policy = "/syncolor/policies/{}".format( color_policy) else: color_policy = "/syncolor/policies/Autodesk/{}".format( color_policy) # create arguments project_colorspace_cmd = [ os.path.join( self.wiretap_tools_dir, "wiretap_duplicate_node" ), "-s", color_policy, "-n", "/projects/{}/syncolor".format(project_name) ] print(project_colorspace_cmd) exit_code = subprocess.call( project_colorspace_cmd, cwd=os.path.expanduser('~'), preexec_fn=_subprocess_preexec_fn ) if exit_code != 0: RuntimeError("Cannot set colorspace {} on project {}".format( color_policy, project_name )) def _subprocess_preexec_fn(): """ Helper function Setting permission mask to 0777 """ os.setpgrp() os.umask(0o000) if __name__ == "__main__": # get json exchange data json_path = sys.argv[-1] json_data = open(json_path).read() in_data = json.loads(json_data) out_data = deepcopy(in_data) # get main server attributes host_name = in_data.pop("host_name") volume_name = in_data.pop("volume_name") group_name = in_data.pop("group_name") # initialize class wiretap_handler = WireTapCom(host_name, volume_name, group_name) try: app_args = wiretap_handler.get_launch_args( project_name=in_data.pop("project_name"), project_data=in_data.pop("project_data"), user_name=in_data.pop("user_name"), **in_data ) finally: wiretap_handler.close() # set returned args back to out data out_data.update({ "app_args": app_args }) # write it out back to the exchange json file with open(json_path, "w") as file_stream: json.dump(out_data, file_stream, indent=4) ================================================ FILE: openpype/hosts/flame/api/utils.py ================================================ """ Flame utils for syncing scripts """ import os import shutil from openpype.lib import Logger log = Logger.get_logger(__name__) def _sync_utility_scripts(env=None): """ Synchronizing basic utlility scripts for flame. To be able to run start OpenPype within Flame we have to copy all utility_scripts and additional FLAME_SCRIPT_DIR into `/opt/Autodesk/shared/python`. This will be always synchronizing those folders. """ from .. import HOST_DIR env = env or os.environ # initiate inputs scripts = {} fsd_env = env.get("FLAME_SCRIPT_DIRS", "") flame_shared_dir = "/opt/Autodesk/shared/python" fsd_paths = [os.path.join( HOST_DIR, "api", "utility_scripts" )] # collect script dirs log.info("FLAME_SCRIPT_DIRS: `{fsd_env}`".format(**locals())) log.info("fsd_paths: `{fsd_paths}`".format(**locals())) # add application environment setting for FLAME_SCRIPT_DIR # to script path search for _dirpath in fsd_env.split(os.pathsep): if not os.path.isdir(_dirpath): log.warning("Path is not a valid dir: `{_dirpath}`".format( **locals())) continue fsd_paths.append(_dirpath) # collect scripts from dirs for path in fsd_paths: scripts.update({path: os.listdir(path)}) remove_black_list = [] for _k, s_list in scripts.items(): remove_black_list += s_list log.info("remove_black_list: `{remove_black_list}`".format(**locals())) log.info("Additional Flame script paths: `{fsd_paths}`".format(**locals())) log.info("Flame Scripts: `{scripts}`".format(**locals())) # make sure no script file is in folder if next(iter(os.listdir(flame_shared_dir)), None): for _itm in os.listdir(flame_shared_dir): skip = False # skip all scripts and folders which are not maintained if _itm not in remove_black_list: skip = True # do not skip if pyc in extension if not os.path.isdir(_itm) and "pyc" in os.path.splitext(_itm)[-1]: skip = False # continue if skip in true if skip: continue path = os.path.join(flame_shared_dir, _itm) log.info("Removing `{path}`...".format(**locals())) try: if os.path.isdir(path): shutil.rmtree(path, onerror=None) else: os.remove(path) except PermissionError as msg: log.warning( "Not able to remove: `{}`, Problem with: `{}`".format( path, msg ) ) # copy scripts into Resolve's utility scripts dir for dirpath, scriptlist in scripts.items(): # directory and scripts list for _script in scriptlist: # script in script list src = os.path.join(dirpath, _script) dst = os.path.join(flame_shared_dir, _script) log.info("Copying `{src}` to `{dst}`...".format(**locals())) try: if os.path.isdir(src): shutil.copytree( src, dst, symlinks=False, ignore=None, ignore_dangling_symlinks=False ) else: shutil.copy2(src, dst) except (PermissionError, FileExistsError) as msg: log.warning( "Not able to copy to: `{}`, Problem with: `{}`".format( dst, msg ) ) def setup(env=None): """ Wrapper installer started from `flame/hooks/pre_flame_setup.py` """ env = env or os.environ # synchronize resolve utility scripts _sync_utility_scripts(env) log.info("Flame OpenPype wrapper has been installed") def get_flame_version(): import flame return { "full": flame.get_version(), "major": flame.get_version_major(), "minor": flame.get_version_minor(), "patch": flame.get_version_patch() } def get_flame_install_root(): return "/opt/Autodesk" ================================================ FILE: openpype/hosts/flame/api/workio.py ================================================ """Host API required Work Files tool""" import os from openpype.lib import Logger # from .. import ( # get_project_manager, # get_current_project # ) log = Logger.get_logger(__name__) exported_projet_ext = ".otoc" def file_extensions(): return [exported_projet_ext] def has_unsaved_changes(): pass def save_file(filepath): pass def open_file(filepath): pass def current_file(): pass def work_root(session): return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") ================================================ FILE: openpype/hosts/flame/hooks/pre_flame_setup.py ================================================ import os import json import tempfile import contextlib import socket from pprint import pformat from openpype.lib import ( get_openpype_username, run_subprocess, ) from openpype.lib.applications import PreLaunchHook, LaunchTypes from openpype.hosts import flame as opflame class FlamePrelaunch(PreLaunchHook): """ Flame prelaunch hook Will make sure flame_script_dirs are copied to user's folder defined in environment var FLAME_SCRIPT_DIR. """ app_groups = {"flame"} permissions = 0o777 wtc_script_path = os.path.join( opflame.HOST_DIR, "api", "scripts", "wiretap_com.py") launch_types = {LaunchTypes.local} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.signature = "( {} )".format(self.__class__.__name__) def execute(self): _env = self.launch_context.env self.flame_python_exe = _env["OPENPYPE_FLAME_PYTHON_EXEC"] self.flame_pythonpath = _env["OPENPYPE_FLAME_PYTHONPATH"] """Hook entry method.""" project_doc = self.data["project_doc"] project_name = project_doc["name"] volume_name = _env.get("FLAME_WIRETAP_VOLUME") # get image io project_settings = self.data["project_settings"] imageio_flame = project_settings["flame"]["imageio"] # Check whether 'enabled' key from host imageio settings exists # so we can tell if host is using the new colormanagement framework. # If the 'enabled' isn't found we want 'colormanaged' set to True # because prior to the key existing we always did colormanagement for # Flame colormanaged = imageio_flame.get("enabled") # if key was not found, set to True # ensuring backward compatibility if colormanaged is None: colormanaged = True # get user name and host name user_name = get_openpype_username() user_name = user_name.replace(".", "_") hostname = socket.gethostname() # not returning wiretap host name self.log.debug("Collected user \"{}\"".format(user_name)) self.log.info(pformat(project_doc)) _db_p_data = project_doc["data"] width = _db_p_data["resolutionWidth"] height = _db_p_data["resolutionHeight"] fps = float(_db_p_data["fps"]) project_data = { "Name": project_doc["name"], "Nickname": _db_p_data["code"], "Description": "Created by OpenPype", "SetupDir": project_doc["name"], "FrameWidth": int(width), "FrameHeight": int(height), "AspectRatio": float((width / height) * _db_p_data["pixelAspect"]), "FrameRate": self._get_flame_fps(fps) } data_to_script = { # from settings "host_name": _env.get("FLAME_WIRETAP_HOSTNAME") or hostname, "volume_name": volume_name, "group_name": _env.get("FLAME_WIRETAP_GROUP"), # from project "project_name": project_name, "user_name": user_name, "project_data": project_data } # add color management data if colormanaged: project_data.update({ "FrameDepth": str(imageio_flame["project"]["frameDepth"]), "FieldDominance": str( imageio_flame["project"]["fieldDominance"]) }) data_to_script["color_policy"] = str( imageio_flame["project"]["colourPolicy"]) self.log.info(pformat(dict(_env))) self.log.info(pformat(data_to_script)) # add to python path from settings self._add_pythonpath() app_arguments = self._get_launch_arguments(data_to_script) # fix project data permission issue self._fix_permissions(project_name, volume_name) self.launch_context.launch_args.extend(app_arguments) def _fix_permissions(self, project_name, volume_name): """Work around for project data permissions Reported issue: when project is created locally on one machine, it is impossible to migrate it to other machine. Autodesk Flame is crating some unmanagable files which needs to be opened to 0o777. Args: project_name (str): project name volume_name (str): studio volume """ dirs_to_modify = [ "/usr/discreet/project/{}".format(project_name), "/opt/Autodesk/clip/{}/{}.prj".format(volume_name, project_name), "/usr/discreet/clip/{}/{}.prj".format(volume_name, project_name) ] for dirtm in dirs_to_modify: for root, dirs, files in os.walk(dirtm): try: for name in set(dirs) | set(files): path = os.path.join(root, name) st = os.stat(path) if oct(st.st_mode) != self.permissions: os.chmod(path, self.permissions) except OSError as exc: self.log.warning("Not able to open files: {}".format(exc)) def _get_flame_fps(self, fps_num): fps_table = { float(23.976): "23.976 fps", int(25): "25 fps", int(24): "24 fps", float(29.97): "29.97 fps DF", int(30): "30 fps", int(50): "50 fps", float(59.94): "59.94 fps DF", int(60): "60 fps" } match_key = min(fps_table.keys(), key=lambda x: abs(x - fps_num)) try: return fps_table[match_key] except KeyError as msg: raise KeyError(( "Missing FPS key in conversion table. " "Following keys are available: {}".format(fps_table.keys()) )) from msg def _add_pythonpath(self): pythonpath = self.launch_context.env.get("PYTHONPATH") # separate it explicitly by `;` that is what we use in settings new_pythonpath = self.flame_pythonpath.split(os.pathsep) new_pythonpath += pythonpath.split(os.pathsep) self.launch_context.env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) def _get_launch_arguments(self, script_data): # Dump data to string dumped_script_data = json.dumps(script_data) with make_temp_file(dumped_script_data) as tmp_json_path: # Prepare subprocess arguments args = [ self.flame_python_exe.format( **self.launch_context.env ), self.wtc_script_path, tmp_json_path ] self.log.info("Executing: {}".format(" ".join(args))) process_kwargs = { "logger": self.log, "env": self.launch_context.env } run_subprocess(args, **process_kwargs) # process returned json file to pass launch args return_json_data = open(tmp_json_path).read() returned_data = json.loads(return_json_data) app_args = returned_data.get("app_args") self.log.info("____ app_args: `{}`".format(app_args)) if not app_args: RuntimeError("App arguments were not solved") return app_args @contextlib.contextmanager def make_temp_file(data): try: # Store dumped json to temporary file temporary_json_file = tempfile.NamedTemporaryFile( mode="w", suffix=".json", delete=False ) temporary_json_file.write(data) temporary_json_file.close() temporary_json_filepath = temporary_json_file.name.replace( "\\", "/" ) yield temporary_json_filepath except IOError as _error: raise IOError( "Not able to create temp json file: {}".format( _error ) ) finally: # Remove the temporary json os.remove(temporary_json_filepath) ================================================ FILE: openpype/hosts/flame/otio/__init__.py ================================================ ================================================ FILE: openpype/hosts/flame/otio/flame_export.py ================================================ """ compatibility OpenTimelineIO 0.12.0 and newer """ import os import re import json import logging import opentimelineio as otio from . import utils import flame from pprint import pformat log = logging.getLogger(__name__) TRACK_TYPES = { "video": otio.schema.TrackKind.Video, "audio": otio.schema.TrackKind.Audio } MARKERS_COLOR_MAP = { (1.0, 0.0, 0.0): otio.schema.MarkerColor.RED, (1.0, 0.5, 0.0): otio.schema.MarkerColor.ORANGE, (1.0, 1.0, 0.0): otio.schema.MarkerColor.YELLOW, (1.0, 0.5, 1.0): otio.schema.MarkerColor.PINK, (1.0, 1.0, 1.0): otio.schema.MarkerColor.WHITE, (0.0, 1.0, 0.0): otio.schema.MarkerColor.GREEN, (0.0, 1.0, 1.0): otio.schema.MarkerColor.CYAN, (0.0, 0.0, 1.0): otio.schema.MarkerColor.BLUE, (0.5, 0.0, 0.5): otio.schema.MarkerColor.PURPLE, (0.5, 0.0, 1.0): otio.schema.MarkerColor.MAGENTA, (0.0, 0.0, 0.0): otio.schema.MarkerColor.BLACK } MARKERS_INCLUDE = True class CTX: _fps = None _tl_start_frame = None project = None clips = None @classmethod def set_fps(cls, new_fps): if not isinstance(new_fps, float): raise TypeError("Invalid fps type {}".format(type(new_fps))) if cls._fps != new_fps: cls._fps = new_fps @classmethod def get_fps(cls): return cls._fps @classmethod def set_tl_start_frame(cls, number): if not isinstance(number, int): raise TypeError("Invalid timeline start frame type {}".format( type(number))) if cls._tl_start_frame != number: cls._tl_start_frame = number @classmethod def get_tl_start_frame(cls): return cls._tl_start_frame def flatten(_list): for item in _list: if isinstance(item, (list, tuple)): for sub_item in flatten(item): yield sub_item else: yield item def get_current_flame_project(): project = flame.project.current_project return project def create_otio_rational_time(frame, fps): return otio.opentime.RationalTime( float(frame), float(fps) ) def create_otio_time_range(start_frame, frame_duration, fps): return otio.opentime.TimeRange( start_time=create_otio_rational_time(start_frame, fps), duration=create_otio_rational_time(frame_duration, fps) ) def _get_metadata(item): if hasattr(item, 'metadata'): return dict(item.metadata) if item.metadata else {} return {} def create_time_effects(otio_clip, speed): otio_effect = None # retime on track item if speed != 1.: # make effect otio_effect = otio.schema.LinearTimeWarp() otio_effect.name = "Speed" otio_effect.time_scalar = speed otio_effect.metadata = {} # freeze frame effect if speed == 0.: otio_effect = otio.schema.FreezeFrame() otio_effect.name = "FreezeFrame" otio_effect.metadata = {} if otio_effect: # add otio effect to clip effects otio_clip.effects.append(otio_effect) def _get_marker_color(flame_colour): # clamp colors to closes half numbers _flame_colour = [ (lambda x: round(x * 2) / 2)(c) for c in flame_colour] for color, otio_color_type in MARKERS_COLOR_MAP.items(): if _flame_colour == list(color): return otio_color_type return otio.schema.MarkerColor.RED def _get_flame_markers(item): output_markers = [] time_in = item.record_in.relative_frame for marker in item.markers: log.debug(marker) start_frame = marker.location.get_value().relative_frame start_frame = (start_frame - time_in) + 1 marker_data = { "name": marker.name.get_value(), "duration": marker.duration.get_value().relative_frame, "comment": marker.comment.get_value(), "start_frame": start_frame, "colour": marker.colour.get_value() } output_markers.append(marker_data) return output_markers def create_otio_markers(otio_item, item): markers = _get_flame_markers(item) for marker in markers: frame_rate = CTX.get_fps() marked_range = otio.opentime.TimeRange( start_time=otio.opentime.RationalTime( marker["start_frame"], frame_rate ), duration=otio.opentime.RationalTime( marker["duration"], frame_rate ) ) # testing the comment if it is not containing json string check_if_json = re.findall( re.compile(r"[{:}]"), marker["comment"] ) # to identify this as json, at least 3 items in the list should # be present ["{", ":", "}"] metadata = {} if len(check_if_json) >= 3: # this is json string try: # capture exceptions which are related to strings only metadata.update( json.loads(marker["comment"]) ) except ValueError as msg: log.error("Marker json conversion: {}".format(msg)) else: metadata["comment"] = marker["comment"] otio_marker = otio.schema.Marker( name=marker["name"], color=_get_marker_color( marker["colour"]), marked_range=marked_range, metadata=metadata ) otio_item.markers.append(otio_marker) def create_otio_reference(clip_data, fps=None): metadata = _get_metadata(clip_data) duration = int(clip_data["source_duration"]) # get file info for path and start frame frame_start = 0 fps = fps or CTX.get_fps() path = clip_data["fpath"] file_name = os.path.basename(path) file_head, extension = os.path.splitext(file_name) # get padding and other file infos log.debug("_ path: {}".format(path)) otio_ex_ref_item = None is_sequence = frame_number = utils.get_frame_from_filename(file_name) if is_sequence: file_head = file_name.split(frame_number)[:-1] frame_start = int(frame_number) padding = len(frame_number) metadata.update({ "isSequence": True, "padding": padding }) # if it is file sequence try to create `ImageSequenceReference` # the OTIO might not be compatible so return nothing and do it old way try: dirname = os.path.dirname(path) otio_ex_ref_item = otio.schema.ImageSequenceReference( target_url_base=dirname + os.sep, name_prefix=file_head, name_suffix=extension, start_frame=frame_start, frame_zero_padding=padding, rate=fps, available_range=create_otio_time_range( frame_start, duration, fps ) ) except AttributeError: pass if not otio_ex_ref_item: dirname, file_name = os.path.split(path) file_name = utils.get_reformated_filename(file_name, padded=False) reformated_path = os.path.join(dirname, file_name) # in case old OTIO or video file create `ExternalReference` otio_ex_ref_item = otio.schema.ExternalReference( target_url=reformated_path, available_range=create_otio_time_range( frame_start, duration, fps ) ) # add metadata to otio item add_otio_metadata(otio_ex_ref_item, clip_data, **metadata) return otio_ex_ref_item def create_otio_clip(clip_data): from openpype.hosts.flame.api import MediaInfoFile, TimeEffectMetadata segment = clip_data["PySegment"] # calculate source in media_info = MediaInfoFile(clip_data["fpath"], logger=log) media_timecode_start = media_info.start_frame media_fps = media_info.fps # Timewarp metadata tw_data = TimeEffectMetadata(segment, logger=log).data log.debug("__ tw_data: {}".format(tw_data)) # define first frame file_first_frame = utils.get_frame_from_filename( clip_data["fpath"]) if file_first_frame: file_first_frame = int(file_first_frame) first_frame = media_timecode_start or file_first_frame or 0 _clip_source_in = int(clip_data["source_in"]) _clip_source_out = int(clip_data["source_out"]) _clip_record_in = clip_data["record_in"] _clip_record_out = clip_data["record_out"] _clip_record_duration = int(clip_data["record_duration"]) log.debug("_ file_first_frame: {}".format(file_first_frame)) log.debug("_ first_frame: {}".format(first_frame)) log.debug("_ _clip_source_in: {}".format(_clip_source_in)) log.debug("_ _clip_source_out: {}".format(_clip_source_out)) log.debug("_ _clip_record_in: {}".format(_clip_record_in)) log.debug("_ _clip_record_out: {}".format(_clip_record_out)) # first solve if the reverse timing speed = 1 if clip_data["source_in"] > clip_data["source_out"]: source_in = _clip_source_out - int(first_frame) source_out = _clip_source_in - int(first_frame) speed = -1 else: source_in = _clip_source_in - int(first_frame) source_out = _clip_source_out - int(first_frame) log.debug("_ source_in: {}".format(source_in)) log.debug("_ source_out: {}".format(source_out)) if file_first_frame: log.debug("_ file_source_in: {}".format( file_first_frame + source_in)) log.debug("_ file_source_in: {}".format( file_first_frame + source_out)) source_duration = (source_out - source_in + 1) # secondly check if any change of speed if source_duration != _clip_record_duration: retime_speed = float(source_duration) / float(_clip_record_duration) log.debug("_ calculated speed: {}".format(retime_speed)) speed *= retime_speed # get speed from metadata if available if tw_data.get("speed"): speed = tw_data["speed"] log.debug("_ metadata speed: {}".format(speed)) log.debug("_ speed: {}".format(speed)) log.debug("_ source_duration: {}".format(source_duration)) log.debug("_ _clip_record_duration: {}".format(_clip_record_duration)) # create media reference media_reference = create_otio_reference( clip_data, media_fps) # creatae source range source_range = create_otio_time_range( source_in, _clip_record_duration, CTX.get_fps() ) otio_clip = otio.schema.Clip( name=clip_data["segment_name"], source_range=source_range, media_reference=media_reference ) # Add markers if MARKERS_INCLUDE: create_otio_markers(otio_clip, segment) if speed != 1: create_time_effects(otio_clip, speed) return otio_clip def create_otio_gap(gap_start, clip_start, tl_start_frame, fps): return otio.schema.Gap( source_range=create_otio_time_range( gap_start, (clip_start - tl_start_frame) - gap_start, fps ) ) def _get_colourspace_policy(): output = {} # get policies project path policy_dir = "/opt/Autodesk/project/{}/synColor/policy".format( CTX.project.name ) log.debug(policy_dir) policy_fp = os.path.join(policy_dir, "policy.cfg") if not os.path.exists(policy_fp): return output with open(policy_fp) as file: dict_conf = dict(line.strip().split(' = ', 1) for line in file) output.update( {"openpype.flame.{}".format(k): v for k, v in dict_conf.items()} ) return output def _create_otio_timeline(sequence): metadata = _get_metadata(sequence) # find colour policy files and add them to metadata colorspace_policy = _get_colourspace_policy() metadata.update(colorspace_policy) metadata.update({ "openpype.timeline.width": int(sequence.width), "openpype.timeline.height": int(sequence.height), "openpype.timeline.pixelAspect": 1 }) rt_start_time = create_otio_rational_time( CTX.get_tl_start_frame(), CTX.get_fps()) return otio.schema.Timeline( name=str(sequence.name)[1:-1], global_start_time=rt_start_time, metadata=metadata ) def create_otio_track(track_type, track_name): return otio.schema.Track( name=track_name, kind=TRACK_TYPES[track_type] ) def add_otio_gap(clip_data, otio_track, prev_out): gap_length = clip_data["record_in"] - prev_out if prev_out != 0: gap_length -= 1 gap = otio.opentime.TimeRange( duration=otio.opentime.RationalTime( gap_length, CTX.get_fps() ) ) otio_gap = otio.schema.Gap(source_range=gap) otio_track.append(otio_gap) def add_otio_metadata(otio_item, item, **kwargs): metadata = _get_metadata(item) # add additional metadata from kwargs if kwargs: metadata.update(kwargs) # add metadata to otio item metadata for key, value in metadata.items(): otio_item.metadata.update({key: value}) def _get_shot_tokens_values(clip, tokens): old_value = None output = {} old_value = clip.shot_name.get_value() for token in tokens: clip.shot_name.set_value(token) _key = re.sub("[ <>]", "", token) try: output[_key] = int(clip.shot_name.get_value()) except ValueError: output[_key] = clip.shot_name.get_value() clip.shot_name.set_value(old_value) return output def _get_segment_attributes(segment): log.debug("Segment name|hidden: {}|{}".format( segment.name.get_value(), segment.hidden )) if ( segment.name.get_value() == "" or segment.hidden.get_value() ): return None # Add timeline segment to tree clip_data = { "segment_name": segment.name.get_value(), "segment_comment": segment.comment.get_value(), "shot_name": segment.shot_name.get_value(), "tape_name": segment.tape_name, "source_name": segment.source_name, "fpath": segment.file_path, "PySegment": segment } # add all available shot tokens shot_tokens = _get_shot_tokens_values( segment, ["", "", "", ""] ) clip_data.update(shot_tokens) # populate shot source metadata segment_attrs = [ "record_duration", "record_in", "record_out", "source_duration", "source_in", "source_out" ] segment_attrs_data = {} for attr in segment_attrs: if not hasattr(segment, attr): continue _value = getattr(segment, attr) segment_attrs_data[attr] = str(_value).replace("+", ":") if attr in ["record_in", "record_out"]: clip_data[attr] = _value.relative_frame else: clip_data[attr] = _value.frame clip_data["segment_timecodes"] = segment_attrs_data return clip_data def create_otio_timeline(sequence): log.info(dir(sequence)) log.info(sequence.attributes) CTX.project = get_current_flame_project() # get current timeline CTX.set_fps( float(str(sequence.frame_rate)[:-4])) tl_start_frame = utils.timecode_to_frames( str(sequence.start_time).replace("+", ":"), CTX.get_fps() ) CTX.set_tl_start_frame(tl_start_frame) # convert timeline to otio otio_timeline = _create_otio_timeline(sequence) # create otio tracks and clips for ver in sequence.versions: for track in ver.tracks: # avoid all empty tracks # or hidden tracks if ( len(track.segments) == 0 or track.hidden.get_value() ): continue # convert track to otio otio_track = create_otio_track( "video", str(track.name)[1:-1]) all_segments = [] for segment in track.segments: clip_data = _get_segment_attributes(segment) if not clip_data: continue all_segments.append(clip_data) segments_ordered = dict(enumerate(all_segments)) log.debug("_ segments_ordered: {}".format( pformat(segments_ordered) )) if not segments_ordered: continue for itemindex, segment_data in segments_ordered.items(): log.debug("_ itemindex: {}".format(itemindex)) # Add Gap if needed prev_item = ( segment_data if itemindex == 0 else segments_ordered[itemindex - 1] ) log.debug("_ segment_data: {}".format(segment_data)) # calculate clip frame range difference from each other clip_diff = segment_data["record_in"] - prev_item["record_out"] # add gap if first track item is not starting # at first timeline frame if itemindex == 0 and segment_data["record_in"] > 0: add_otio_gap(segment_data, otio_track, 0) # or add gap if following track items are having # frame range differences from each other elif itemindex and clip_diff != 1: add_otio_gap( segment_data, otio_track, prev_item["record_out"]) # create otio clip and add it to track otio_clip = create_otio_clip(segment_data) otio_track.append(otio_clip) log.debug("_ otio_clip: {}".format(otio_clip)) # create otio marker # create otio metadata # add track to otio timeline otio_timeline.tracks.append(otio_track) return otio_timeline def write_to_file(otio_timeline, path): otio.adapters.write_to_file(otio_timeline, path) ================================================ FILE: openpype/hosts/flame/otio/utils.py ================================================ import re import opentimelineio as otio import logging log = logging.getLogger(__name__) FRAME_PATTERN = re.compile(r"[\._](\d+)[\.]") def timecode_to_frames(timecode, framerate): rt = otio.opentime.from_timecode(timecode, framerate) return int(otio.opentime.to_frames(rt)) def frames_to_timecode(frames, framerate): rt = otio.opentime.from_frames(frames, framerate) return otio.opentime.to_timecode(rt) def frames_to_seconds(frames, framerate): rt = otio.opentime.from_frames(frames, framerate) return otio.opentime.to_seconds(rt) def get_reformated_filename(filename, padded=True): """ Return fixed python expression path Args: filename (str): file name Returns: type: string with reformated path Example: get_reformated_filename("plate.1001.exr") > plate.%04d.exr """ found = FRAME_PATTERN.search(filename) if not found: log.info("File name is not sequence: {}".format(filename)) return filename padding = get_padding_from_filename(filename) replacement = "%0{}d".format(padding) if padded else "%d" start_idx, end_idx = found.span(1) return replacement.join( [filename[:start_idx], filename[end_idx:]] ) def get_padding_from_filename(filename): """ Return padding number from Flame path style Args: filename (str): file name Returns: int: padding number Example: get_padding_from_filename("plate.0001.exr") > 4 """ found = get_frame_from_filename(filename) return len(found) if found else None def get_frame_from_filename(filename): """ Return sequence number from Flame path style Args: filename (str): file name Returns: int: sequence frame number Example: def get_frame_from_filename(path): ("plate.0001.exr") > 0001 """ found = re.findall(FRAME_PATTERN, filename) return found.pop() if found else None ================================================ FILE: openpype/hosts/flame/plugins/create/create_shot_clip.py ================================================ from copy import deepcopy import openpype.hosts.flame.api as opfapi class CreateShotClip(opfapi.Creator): """Publishable clip""" label = "Create Publishable Clip" family = "clip" icon = "film" defaults = ["Main"] presets = None def process(self): # Creator copy of object attributes that are modified during `process` presets = deepcopy(self.presets) gui_inputs = self.get_gui_inputs() # get key pares from presets and match it on ui inputs for k, v in gui_inputs.items(): if v["type"] in ("dict", "section"): # nested dictionary (only one level allowed # for sections and dict) for _k, _v in v["value"].items(): if presets.get(_k) is not None: gui_inputs[k][ "value"][_k]["value"] = presets[_k] if presets.get(k) is not None: gui_inputs[k]["value"] = presets[k] # open widget for plugins inputs results_back = self.create_widget( "Pype publish attributes creator", "Define sequential rename and fill hierarchy data.", gui_inputs ) if len(self.selected) < 1: return if not results_back: print("Operation aborted") return # get ui output for track name for vertical sync v_sync_track = results_back["vSyncTrack"]["value"] # sort selected trackItems by sorted_selected_segments = [] unsorted_selected_segments = [] for _segment in self.selected: if _segment.parent.name.get_value() in v_sync_track: sorted_selected_segments.append(_segment) else: unsorted_selected_segments.append(_segment) sorted_selected_segments.extend(unsorted_selected_segments) kwargs = { "log": self.log, "ui_inputs": results_back, "avalon": self.data, "family": self.data["family"] } for i, segment in enumerate(sorted_selected_segments): kwargs["rename_index"] = i # convert track item to timeline media pool item opfapi.PublishableClip(segment, **kwargs).convert() def get_gui_inputs(self): gui_tracks = self._get_video_track_names( opfapi.get_current_sequence(opfapi.CTX.selection) ) return deepcopy({ "renameHierarchy": { "type": "section", "label": "Shot Hierarchy And Rename Settings", "target": "ui", "order": 0, "value": { "hierarchy": { "value": "{folder}/{sequence}", "type": "QLineEdit", "label": "Shot Parent Hierarchy", "target": "tag", "toolTip": "Parents folder for shot root folder, Template filled with `Hierarchy Data` section", # noqa "order": 0}, "useShotName": { "value": True, "type": "QCheckBox", "label": "Use Shot Name", "target": "ui", "toolTip": "Use name form Shot name clip attribute", # noqa "order": 1}, "clipRename": { "value": False, "type": "QCheckBox", "label": "Rename clips", "target": "ui", "toolTip": "Renaming selected clips on fly", # noqa "order": 2}, "clipName": { "value": "{sequence}{shot}", "type": "QLineEdit", "label": "Clip Name Template", "target": "ui", "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa "order": 3}, "segmentIndex": { "value": True, "type": "QCheckBox", "label": "Segment index", "target": "ui", "toolTip": "Take number from segment index", # noqa "order": 4}, "countFrom": { "value": 10, "type": "QSpinBox", "label": "Count sequence from", "target": "ui", "toolTip": "Set when the sequence number stafrom", # noqa "order": 5}, "countSteps": { "value": 10, "type": "QSpinBox", "label": "Stepping number", "target": "ui", "toolTip": "What number is adding every new step", # noqa "order": 6}, } }, "hierarchyData": { "type": "dict", "label": "Shot Template Keywords", "target": "tag", "order": 1, "value": { "folder": { "value": "shots", "type": "QLineEdit", "label": "{folder}", "target": "tag", "toolTip": "Name of folder used for root of generated shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 0}, "episode": { "value": "ep01", "type": "QLineEdit", "label": "{episode}", "target": "tag", "toolTip": "Name of episode.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 1}, "sequence": { "value": "sq01", "type": "QLineEdit", "label": "{sequence}", "target": "tag", "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 2}, "track": { "value": "{_track_}", "type": "QLineEdit", "label": "{track}", "target": "tag", "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 3}, "shot": { "value": "sh###", "type": "QLineEdit", "label": "{shot}", "target": "tag", "toolTip": "Name of shot. `#` is converted to paded number. \nAlso could be used with usable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 4} } }, "verticalSync": { "type": "section", "label": "Vertical Synchronization Of Attributes", "target": "ui", "order": 2, "value": { "vSyncOn": { "value": True, "type": "QCheckBox", "label": "Enable Vertical Sync", "target": "ui", "toolTip": "Switch on if you want clips above each other to share its attributes", # noqa "order": 0}, "vSyncTrack": { "value": gui_tracks, # noqa "type": "QComboBox", "label": "Hero track", "target": "ui", "toolTip": "Select driving track name which should be hero for all others", # noqa "order": 1} } }, "publishSettings": { "type": "section", "label": "Publish Settings", "target": "ui", "order": 3, "value": { "subsetName": { "value": ["[ track name ]", "main", "bg", "fg", "bg", "animatic"], "type": "QComboBox", "label": "Subset Name", "target": "ui", "toolTip": "chose subset name pattern, if [ track name ] is selected, name of track layer will be used", # noqa "order": 0}, "subsetFamily": { "value": ["plate", "take"], "type": "QComboBox", "label": "Subset Family", "target": "ui", "toolTip": "What use of this subset is for", # noqa "order": 1}, "reviewTrack": { "value": ["< none >"] + gui_tracks, "type": "QComboBox", "label": "Use Review Track", "target": "ui", "toolTip": "Generate preview videos on fly, if `< none >` is defined nothing will be generated.", # noqa "order": 2}, "audio": { "value": False, "type": "QCheckBox", "label": "Include audio", "target": "tag", "toolTip": "Process subsets with corresponding audio", # noqa "order": 3}, "sourceResolution": { "value": False, "type": "QCheckBox", "label": "Source resolution", "target": "tag", "toolTip": "Is resloution taken from timeline or source?", # noqa "order": 4}, } }, "frameRangeAttr": { "type": "section", "label": "Shot Attributes", "target": "ui", "order": 4, "value": { "workfileFrameStart": { "value": 1001, "type": "QSpinBox", "label": "Workfiles Start Frame", "target": "tag", "toolTip": "Set workfile starting frame number", # noqa "order": 0 }, "handleStart": { "value": 0, "type": "QSpinBox", "label": "Handle Start", "target": "tag", "toolTip": "Handle at start of clip", # noqa "order": 1 }, "handleEnd": { "value": 0, "type": "QSpinBox", "label": "Handle End", "target": "tag", "toolTip": "Handle at end of clip", # noqa "order": 2 }, "includeHandles": { "value": False, "type": "QCheckBox", "label": "Include handles", "target": "tag", "toolTip": "By default handles are excluded", # noqa "order": 3 }, "retimedHandles": { "value": True, "type": "QCheckBox", "label": "Retimed handles", "target": "tag", "toolTip": "By default handles are retimed.", # noqa "order": 4 }, "retimedFramerange": { "value": True, "type": "QCheckBox", "label": "Retimed framerange", "target": "tag", "toolTip": "By default framerange is retimed.", # noqa "order": 5 } } } }) def _get_video_track_names(self, sequence): track_names = [] for ver in sequence.versions: for track in ver.tracks: track_names.append(track.name.get_value()) return track_names ================================================ FILE: openpype/hosts/flame/plugins/load/load_clip.py ================================================ from copy import deepcopy import os import flame from pprint import pformat import openpype.hosts.flame.api as opfapi from openpype.lib import StringTemplate from openpype.lib.transcoding import ( VIDEO_EXTENSIONS, IMAGE_EXTENSIONS ) class LoadClip(opfapi.ClipLoader): """Load a subset to timeline as clip Place clip to timeline on its asset origin timings collected during conforming to project """ families = ["render2d", "source", "plate", "render", "review"] representations = ["*"] extensions = set( ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) ) label = "Load as clip" order = -10 icon = "code-fork" color = "orange" # settings reel_group_name = "OpenPype_Reels" reel_name = "Loaded" clip_name_template = "{asset}_{subset}<_{output}>" """ Anatomy keys from version context data and dynamically added: - {layerName} - original layer name token - {layerUID} - original layer UID token - {originalBasename} - original clip name taken from file """ layer_rename_template = "{asset}_{subset}<_{output}>" layer_rename_patterns = [] def load(self, context, name, namespace, options): # get flame objects fproject = flame.project.current_project self.fpd = fproject.current_workspace.desktop # load clip to timeline and get main variables version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) colorspace = self.get_colorspace(context) # in case output is not in context replace key to representation if not context["representation"]["context"].get("output"): self.clip_name_template = self.clip_name_template.replace( "output", "representation") self.layer_rename_template = self.layer_rename_template.replace( "output", "representation") formatting_data = deepcopy(context["representation"]["context"]) clip_name = StringTemplate(self.clip_name_template).format( formatting_data) # convert colorspace with ocio to flame mapping # in imageio flame section colorspace = self.get_native_colorspace(colorspace) self.log.info("Loading with colorspace: `{}`".format(colorspace)) # create workfile path workfile_dir = os.environ["AVALON_WORKDIR"] openclip_dir = os.path.join( workfile_dir, clip_name ) openclip_path = os.path.join( openclip_dir, clip_name + ".clip" ) if not os.path.exists(openclip_dir): os.makedirs(openclip_dir) # prepare clip data from context ad send it to openClipLoader path = self.filepath_from_context(context) loading_context = { "path": path.replace("\\", "/"), "colorspace": colorspace, "version": "v{:0>3}".format(version_name), "layer_rename_template": self.layer_rename_template, "layer_rename_patterns": self.layer_rename_patterns, "context_data": formatting_data } self.log.debug(pformat( loading_context )) self.log.debug(openclip_path) # make openpype clip file opfapi.OpenClipSolver( openclip_path, loading_context, logger=self.log).make() # prepare Reel group in actual desktop opc = self._get_clip( clip_name, openclip_path ) # add additional metadata from the version to imprint Avalon knob add_keys = [ "frameStart", "frameEnd", "source", "author", "fps", "handleStart", "handleEnd" ] # move all version data keys to tag data data_imprint = {} for key in add_keys: data_imprint.update({ key: version_data.get(key, str(None)) }) # add variables related to version context data_imprint.update({ "version": version_name, "colorspace": colorspace, "objectName": clip_name }) # TODO: finish the containerisation # opc_segment = opfapi.get_clip_segment(opc) # return opfapi.containerise( # opc_segment, # name, namespace, context, # self.__class__.__name__, # data_imprint) return opc def _get_clip(self, name, clip_path): reel = self._get_reel() # with maintained openclip as opc matching_clip = [cl for cl in reel.clips if cl.name.get_value() == name] if matching_clip: return matching_clip.pop() else: created_clips = flame.import_clips(str(clip_path), reel) return created_clips.pop() def _get_reel(self): matching_rgroup = [ rg for rg in self.fpd.reel_groups if rg.name.get_value() == self.reel_group_name ] if not matching_rgroup: reel_group = self.fpd.create_reel_group(str(self.reel_group_name)) for _r in reel_group.reels: if "reel" not in _r.name.get_value().lower(): continue self.log.debug("Removing: {}".format(_r.name)) flame.delete(_r) else: reel_group = matching_rgroup.pop() matching_reel = [ re for re in reel_group.reels if re.name.get_value() == self.reel_name ] if not matching_reel: reel_group = reel_group.create_reel(str(self.reel_name)) else: reel_group = matching_reel.pop() return reel_group def _get_segment_from_clip(self, clip): # unwrapping segment from input clip pass # def switch(self, container, representation): # self.update(container, representation) # def update(self, container, representation): # """ Updating previously loaded clips # """ # # load clip to timeline and get main variables # name = container['name'] # namespace = container['namespace'] # track_item = phiero.get_track_items( # track_item_name=namespace) # version = io.find_one({ # "type": "version", # "_id": representation["parent"] # }) # version_data = version.get("data", {}) # version_name = version.get("name", None) # colorspace = version_data.get("colorspace", None) # object_name = "{}_{}".format(name, namespace) # file = get_representation_path(representation).replace("\\", "/") # clip = track_item.source() # # reconnect media to new path # clip.reconnectMedia(file) # # set colorspace # if colorspace: # clip.setSourceMediaColourTransform(colorspace) # # add additional metadata from the version to imprint Avalon knob # add_keys = [ # "frameStart", "frameEnd", "source", "author", # "fps", "handleStart", "handleEnd" # ] # # move all version data keys to tag data # data_imprint = {} # for key in add_keys: # data_imprint.update({ # key: version_data.get(key, str(None)) # }) # # add variables related to version context # data_imprint.update({ # "representation": str(representation["_id"]), # "version": version_name, # "colorspace": colorspace, # "objectName": object_name # }) # # update color of clip regarding the version order # self.set_item_color(track_item, version) # return phiero.update_container(track_item, data_imprint) # def remove(self, container): # """ Removing previously loaded clips # """ # # load clip to timeline and get main variables # namespace = container['namespace'] # track_item = phiero.get_track_items( # track_item_name=namespace) # track = track_item.parent() # # remove track item from track # track.removeItem(track_item) # @classmethod # def multiselection(cls, track_item): # if not cls.track: # cls.track = track_item.parent() # cls.sequence = cls.track.parent() # @classmethod # def set_item_color(cls, track_item, version): # clip = track_item.source() # # define version name # version_name = version.get("name", None) # # get all versions in list # versions = io.find({ # "type": "version", # "parent": version["parent"] # }).distinct('name') # max_version = max(versions) # # set clip colour # if version_name == max_version: # clip.binItem().setColor(cls.clip_color_last) # else: # clip.binItem().setColor(cls.clip_color) ================================================ FILE: openpype/hosts/flame/plugins/load/load_clip_batch.py ================================================ from copy import deepcopy import os import flame from pprint import pformat import openpype.hosts.flame.api as opfapi from openpype.lib import StringTemplate from openpype.lib.transcoding import ( VIDEO_EXTENSIONS, IMAGE_EXTENSIONS ) class LoadClipBatch(opfapi.ClipLoader): """Load a subset to timeline as clip Place clip to timeline on its asset origin timings collected during conforming to project """ families = ["render2d", "source", "plate", "render", "review"] representations = ["*"] extensions = set( ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) ) label = "Load as clip to current batch" order = -10 icon = "code-fork" color = "orange" # settings reel_name = "OP_LoadedReel" clip_name_template = "{batch}_{asset}_{subset}<_{output}>" """ Anatomy keys from version context data and dynamically added: - {layerName} - original layer name token - {layerUID} - original layer UID token - {originalBasename} - original clip name taken from file """ layer_rename_template = "{asset}_{subset}<_{output}>" layer_rename_patterns = [] def load(self, context, name, namespace, options): # get flame objects self.batch = options.get("batch") or flame.batch # load clip to timeline and get main variables version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) colorspace = self.get_colorspace(context) # in case output is not in context replace key to representation if not context["representation"]["context"].get("output"): self.clip_name_template = self.clip_name_template.replace( "output", "representation") self.layer_rename_template = self.layer_rename_template.replace( "output", "representation") formatting_data = deepcopy(context["representation"]["context"]) formatting_data["batch"] = self.batch.name.get_value() clip_name = StringTemplate(self.clip_name_template).format( formatting_data) # convert colorspace with ocio to flame mapping # in imageio flame section colorspace = self.get_native_colorspace(colorspace) self.log.info("Loading with colorspace: `{}`".format(colorspace)) # create workfile path workfile_dir = options.get("workdir") or os.environ["AVALON_WORKDIR"] openclip_dir = os.path.join( workfile_dir, clip_name ) openclip_path = os.path.join( openclip_dir, clip_name + ".clip" ) if not os.path.exists(openclip_dir): os.makedirs(openclip_dir) # prepare clip data from context and send it to openClipLoader path = self.filepath_from_context(context) loading_context = { "path": path.replace("\\", "/"), "colorspace": colorspace, "version": "v{:0>3}".format(version_name), "layer_rename_template": self.layer_rename_template, "layer_rename_patterns": self.layer_rename_patterns, "context_data": formatting_data } self.log.debug(pformat( loading_context )) self.log.debug(openclip_path) # make openpype clip file opfapi.OpenClipSolver( openclip_path, loading_context, logger=self.log).make() # prepare Reel group in actual desktop opc = self._get_clip( clip_name, openclip_path ) # add additional metadata from the version to imprint Avalon knob add_keys = [ "frameStart", "frameEnd", "source", "author", "fps", "handleStart", "handleEnd" ] # move all version data keys to tag data data_imprint = { key: version_data.get(key, str(None)) for key in add_keys } # add variables related to version context data_imprint.update({ "version": version_name, "colorspace": colorspace, "objectName": clip_name }) # TODO: finish the containerisation # opc_segment = opfapi.get_clip_segment(opc) # return opfapi.containerise( # opc_segment, # name, namespace, context, # self.__class__.__name__, # data_imprint) return opc def _get_clip(self, name, clip_path): reel = self._get_reel() # with maintained openclip as opc matching_clip = None for cl in reel.clips: if cl.name.get_value() != name: continue matching_clip = cl if not matching_clip: created_clips = flame.import_clips(str(clip_path), reel) return created_clips.pop() return matching_clip def _get_reel(self): matching_reel = [ rg for rg in self.batch.reels if rg.name.get_value() == self.reel_name ] return ( matching_reel.pop() if matching_reel else self.batch.create_reel(str(self.reel_name)) ) ================================================ FILE: openpype/hosts/flame/plugins/publish/collect_test_selection.py ================================================ import os import pyblish.api import tempfile import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export as otio_export import opentimelineio as otio from pprint import pformat reload(otio_export) # noqa @pyblish.api.log class CollectTestSelection(pyblish.api.ContextPlugin): """testing selection sharing """ order = pyblish.api.CollectorOrder label = "test selection" hosts = ["flame"] active = False def process(self, context): self.log.info( "Active Selection: {}".format(opfapi.CTX.selection)) sequence = opfapi.get_current_sequence(opfapi.CTX.selection) self.test_imprint_data(sequence) self.test_otio_export(sequence) def test_otio_export(self, sequence): test_dir = os.path.normpath( tempfile.mkdtemp(prefix="test_pyblish_tmp_") ) export_path = os.path.normpath( os.path.join( test_dir, "otio_timeline_export.otio" ) ) otio_timeline = otio_export.create_otio_timeline(sequence) otio_export.write_to_file( otio_timeline, export_path ) read_timeline_otio = otio.adapters.read_from_file(export_path) if otio_timeline != read_timeline_otio: raise Exception("Exported timeline is different from original") self.log.info(pformat(otio_timeline)) self.log.info("Otio exported to: {}".format(export_path)) def test_imprint_data(self, sequence): with opfapi.maintained_segment_selection(sequence) as sel_segments: for segment in sel_segments: if str(segment.name)[1:-1] == "": continue self.log.debug("Segment with OpenPypeData: {}".format( segment.name)) opfapi.imprint(segment, { 'asset': segment.name.get_value(), 'family': 'render', 'subset': 'subsetMain' }) ================================================ FILE: openpype/hosts/flame/plugins/publish/collect_timeline_instances.py ================================================ import re from types import NoneType import pyblish import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export from openpype.pipeline.editorial import ( is_overlapping_otio_ranges, get_media_range_with_retimes ) # # developer reload modules from pprint import pformat # constatns NUM_PATERN = re.compile(r"([0-9\.]+)") TXT_PATERN = re.compile(r"([a-zA-Z]+)") class CollectTimelineInstances(pyblish.api.ContextPlugin): """Collect all Timeline segment selection.""" order = pyblish.api.CollectorOrder - 0.09 label = "Collect timeline Instances" hosts = ["flame"] audio_track_items = [] # settings xml_preset_attrs_from_comments = [] add_tasks = [] def process(self, context): selected_segments = context.data["flameSelectedSegments"] self.log.debug("__ selected_segments: {}".format(selected_segments)) self.otio_timeline = context.data["otioTimeline"] self.fps = context.data["fps"] # process all sellected for segment in selected_segments: # get openpype tag data marker_data = opfapi.get_segment_data_marker(segment) self.log.debug("__ marker_data: {}".format( pformat(marker_data))) if not marker_data: continue if marker_data.get("id") != "pyblish.avalon.instance": continue self.log.debug("__ segment.name: {}".format( segment.name )) comment_attributes = self._get_comment_attributes(segment) self.log.debug("_ comment_attributes: {}".format( pformat(comment_attributes))) clip_data = opfapi.get_segment_attributes(segment) clip_name = clip_data["segment_name"] self.log.debug("clip_name: {}".format(clip_name)) # get otio clip data otio_data = self._get_otio_clip_instance_data(clip_data) or {} self.log.debug("__ otio_data: {}".format(pformat(otio_data))) # get file path file_path = clip_data["fpath"] first_frame = opfapi.get_frame_from_filename(file_path) or 0 head, tail = self._get_head_tail( clip_data, otio_data["otioClip"], marker_data["handleStart"], marker_data["handleEnd"] ) # make sure there is not NoneType rather 0 if isinstance(head, NoneType): head = 0 if isinstance(tail, NoneType): tail = 0 # make sure value is absolute if head != 0: head = abs(head) if tail != 0: tail = abs(tail) # solve handles length marker_data["handleStart"] = min( marker_data["handleStart"], head) marker_data["handleEnd"] = min( marker_data["handleEnd"], tail) workfile_start = self._set_workfile_start(marker_data) with_audio = bool(marker_data.pop("audio")) # add marker data to instance data inst_data = dict(marker_data.items()) # add ocio_data to instance data inst_data.update(otio_data) asset = marker_data["asset"] subset = marker_data["subset"] # insert family into families family = marker_data["family"] families = [str(f) for f in marker_data["families"]] families.insert(0, str(family)) # form label label = asset if asset != clip_name: label += " ({})".format(clip_name) label += " {} [{}]".format(subset, ", ".join(families)) inst_data.update({ "name": "{}_{}".format(asset, subset), "label": label, "asset": asset, "item": segment, "families": families, "publish": marker_data["publish"], "fps": self.fps, "workfileFrameStart": workfile_start, "sourceFirstFrame": int(first_frame), "retimedHandles": marker_data.get("retimedHandles"), "shotDurationFromSource": ( not marker_data.get("retimedFramerange")), "path": file_path, "flameAddTasks": self.add_tasks, "tasks": { task["name"]: {"type": task["type"]} for task in self.add_tasks}, "representations": [], "newAssetPublishing": True }) self.log.debug("__ inst_data: {}".format(pformat(inst_data))) # add resolution self._get_resolution_to_data(inst_data, context) # add comment attributes if any inst_data.update(comment_attributes) # create instance instance = context.create_instance(**inst_data) # add colorspace data instance.data.update({ "versionData": { "colorspace": clip_data["colour_space"], } }) # create shot instance for shot attributes create/update self._create_shot_instance(context, clip_name, **inst_data) self.log.info("Creating instance: {}".format(instance)) self.log.info( "_ instance.data: {}".format(pformat(instance.data))) if not with_audio: continue # add audioReview attribute to plate instance data # if reviewTrack is on if marker_data.get("reviewTrack") is not None: instance.data["reviewAudio"] = True @staticmethod def _set_workfile_start(data): include_handles = data.get("includeHandles") workfile_start = data["workfileFrameStart"] handle_start = data["handleStart"] if include_handles: workfile_start += handle_start return workfile_start def _get_comment_attributes(self, segment): comment = segment.comment.get_value() # try to find attributes attributes = { "xml_overrides": { "pixelRatio": 1.00} } # search for `:` for split in self._split_comments(comment): # make sure we ignore if not `:` in key if ":" not in split: continue self._get_xml_preset_attrs( attributes, split) # add xml overrides resolution to instance data xml_overrides = attributes["xml_overrides"] if xml_overrides.get("width"): attributes.update({ "resolutionWidth": xml_overrides["width"], "resolutionHeight": xml_overrides["height"], "pixelAspect": xml_overrides["pixelRatio"] }) return attributes def _get_xml_preset_attrs(self, attributes, split): # split to key and value key, value = split.split(":") for attr_data in self.xml_preset_attrs_from_comments: a_name = attr_data["name"] a_type = attr_data["type"] # exclude all not related attributes if a_name.lower() not in key.lower(): continue # get pattern defined by type pattern = TXT_PATERN if a_type in ("number", "float"): pattern = NUM_PATERN res_goup = pattern.findall(value) # raise if nothing is found as it is not correctly defined if not res_goup: raise ValueError(( "Value for `{}` attribute is not " "set correctly: `{}`").format(a_name, split)) if "string" in a_type: _value = res_goup[0] if "float" in a_type: _value = float(res_goup[0]) if "number" in a_type: _value = int(res_goup[0]) attributes["xml_overrides"][a_name] = _value # condition for resolution in key if "resolution" in key.lower(): res_goup = NUM_PATERN.findall(value) # check if axpect was also defined # 1920x1080x1.5 aspect = res_goup[2] if len(res_goup) > 2 else 1 width = int(res_goup[0]) height = int(res_goup[1]) pixel_ratio = float(aspect) attributes["xml_overrides"].update({ "width": width, "height": height, "pixelRatio": pixel_ratio }) def _split_comments(self, comment_string): # first split comment by comma split_comments = [] if "," in comment_string: split_comments.extend(comment_string.split(",")) elif ";" in comment_string: split_comments.extend(comment_string.split(";")) else: split_comments.append(comment_string) return split_comments def _get_head_tail(self, clip_data, otio_clip, handle_start, handle_end): # calculate head and tail with forward compatibility head = clip_data.get("segment_head") tail = clip_data.get("segment_tail") self.log.debug("__ head: `{}`".format(head)) self.log.debug("__ tail: `{}`".format(tail)) # HACK: it is here to serve for versions below 2021.1 if not any([head, tail]): retimed_attributes = get_media_range_with_retimes( otio_clip, handle_start, handle_end) self.log.debug( ">> retimed_attributes: {}".format(retimed_attributes)) # retimed head and tail head = int(retimed_attributes["handleStart"]) tail = int(retimed_attributes["handleEnd"]) return head, tail def _get_resolution_to_data(self, data, context): assert data.get("otioClip"), "Missing `otioClip` data" # solve source resolution option if data.get("sourceResolution", None): otio_clip_metadata = data[ "otioClip"].media_reference.metadata data.update({ "resolutionWidth": otio_clip_metadata[ "openpype.source.width"], "resolutionHeight": otio_clip_metadata[ "openpype.source.height"], "pixelAspect": otio_clip_metadata[ "openpype.source.pixelAspect"] }) else: otio_tl_metadata = context.data["otioTimeline"].metadata data.update({ "resolutionWidth": otio_tl_metadata["openpype.timeline.width"], "resolutionHeight": otio_tl_metadata[ "openpype.timeline.height"], "pixelAspect": otio_tl_metadata[ "openpype.timeline.pixelAspect"] }) def _create_shot_instance(self, context, clip_name, **data): master_layer = data.get("heroTrack") hierarchy_data = data.get("hierarchyData") if not master_layer: return if not hierarchy_data: return asset = data["asset"] subset = "shotMain" # insert family into families family = "shot" # form label label = asset if asset != clip_name: label += " ({}) ".format(clip_name) label += " {}".format(subset) label += " [{}]".format(family) data.update({ "name": "{}_{}".format(asset, subset), "label": label, "subset": subset, "asset": asset, "family": family, "families": [] }) instance = context.create_instance(**data) self.log.info("Creating instance: {}".format(instance)) self.log.debug( "_ instance.data: {}".format(pformat(instance.data))) def _get_otio_clip_instance_data(self, clip_data): """ Return otio objects for timeline, track and clip Args: timeline_item_data (dict): timeline_item_data from list returned by resolve.get_current_timeline_items() otio_timeline (otio.schema.Timeline): otio object Returns: dict: otio clip object """ segment = clip_data["PySegment"] s_track_name = segment.parent.name.get_value() timeline_range = self._create_otio_time_range_from_timeline_item_data( clip_data) for otio_clip in self.otio_timeline.each_clip(): track_name = otio_clip.parent().name parent_range = otio_clip.range_in_parent() if s_track_name not in track_name: continue if otio_clip.name not in segment.name.get_value(): continue if is_overlapping_otio_ranges( parent_range, timeline_range, strict=True): # add pypedata marker to otio_clip metadata for marker in otio_clip.markers: if opfapi.MARKER_NAME in marker.name: otio_clip.metadata.update(marker.metadata) return {"otioClip": otio_clip} return None def _create_otio_time_range_from_timeline_item_data(self, clip_data): frame_start = int(clip_data["record_in"]) frame_duration = int(clip_data["record_duration"]) return flame_export.create_otio_time_range( frame_start, frame_duration, self.fps) ================================================ FILE: openpype/hosts/flame/plugins/publish/collect_timeline_otio.py ================================================ import pyblish.api from openpype.client import get_asset_name_identifier import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export from openpype.pipeline.create import get_subset_name class CollecTimelineOTIO(pyblish.api.ContextPlugin): """Inject the current working context into publish context""" label = "Collect Timeline OTIO" order = pyblish.api.CollectorOrder - 0.099 def process(self, context): # plugin defined family = "workfile" variant = "otioTimeline" # main asset_doc = context.data["assetEntity"] task_name = context.data["task"] project = opfapi.get_current_project() sequence = opfapi.get_current_sequence(opfapi.CTX.selection) # create subset name subset_name = get_subset_name( family, variant, task_name, asset_doc, context.data["projectName"], context.data["hostName"], project_settings=context.data["project_settings"] ) asset_name = get_asset_name_identifier(asset_doc) # adding otio timeline to context with opfapi.maintained_segment_selection(sequence) as selected_seg: otio_timeline = flame_export.create_otio_timeline(sequence) instance_data = { "name": subset_name, "asset": asset_name, "subset": subset_name, "family": "workfile", "families": [] } # create instance with workfile instance = context.create_instance(**instance_data) self.log.info("Creating instance: {}".format(instance)) # update context with main project attributes context.data.update({ "flameProject": project, "flameSequence": sequence, "otioTimeline": otio_timeline, "currentFile": "Flame/{}/{}".format( project.name, sequence.name ), "flameSelectedSegments": selected_seg, "fps": float(str(sequence.frame_rate)[:-4]) }) ================================================ FILE: openpype/hosts/flame/plugins/publish/extract_otio_file.py ================================================ import os import pyblish.api import opentimelineio as otio from openpype.pipeline import publish class ExtractOTIOFile(publish.Extractor): """ Extractor export OTIO file """ label = "Extract OTIO file" order = pyblish.api.ExtractorOrder - 0.45 families = ["workfile"] hosts = ["flame"] def process(self, instance): # create representation data if "representations" not in instance.data: instance.data["representations"] = [] name = instance.data["name"] staging_dir = self.staging_dir(instance) otio_timeline = instance.context.data["otioTimeline"] # create otio timeline representation otio_file_name = name + ".otio" otio_file_path = os.path.join(staging_dir, otio_file_name) # export otio file to temp dir otio.adapters.write_to_file(otio_timeline, otio_file_path) representation_otio = { 'name': "otio", 'ext': "otio", 'files': otio_file_name, "stagingDir": staging_dir, } instance.data["representations"].append(representation_otio) self.log.info("Added OTIO file representation: {}".format( representation_otio)) ================================================ FILE: openpype/hosts/flame/plugins/publish/extract_subset_resources.py ================================================ import os import re import tempfile from copy import deepcopy import pyblish.api from openpype.pipeline import publish from openpype.hosts.flame import api as opfapi from openpype.hosts.flame.api import MediaInfoFile from openpype.pipeline.editorial import ( get_media_range_with_retimes ) import flame class ExtractSubsetResources(publish.Extractor): """ Extractor for transcoding files from Flame clip """ label = "Extract subset resources" order = pyblish.api.ExtractorOrder families = ["clip"] hosts = ["flame"] # plugin defaults keep_original_representation = False default_presets = { "thumbnail": { "active": True, "ext": "jpg", "xml_preset_file": "Jpeg (8-bit).xml", "xml_preset_dir": "", "export_type": "File Sequence", "parsed_comment_attrs": False, "colorspace_out": "Output - sRGB", "representation_add_range": False, "representation_tags": ["thumbnail"], "path_regex": ".*" } } # hide publisher during exporting hide_ui_on_process = True # settings export_presets_mapping = {} def process(self, instance): if not self.keep_original_representation: # remove previeous representation if not needed instance.data["representations"] = [] # flame objects segment = instance.data["item"] asset_name = instance.data["asset"] segment_name = segment.name.get_value() clip_path = instance.data["path"] sequence_clip = instance.context.data["flameSequence"] # segment's parent track name s_track_name = segment.parent.name.get_value() # get configured workfile frame start/end (handles excluded) frame_start = instance.data["frameStart"] # get media source first frame source_first_frame = instance.data["sourceFirstFrame"] self.log.debug("_ frame_start: {}".format(frame_start)) self.log.debug("_ source_first_frame: {}".format(source_first_frame)) # get timeline in/out of segment clip_in = instance.data["clipIn"] clip_out = instance.data["clipOut"] # get retimed attributres retimed_data = self._get_retimed_attributes(instance) # get individual keys retimed_handle_start = retimed_data["handle_start"] retimed_handle_end = retimed_data["handle_end"] retimed_source_duration = retimed_data["source_duration"] retimed_speed = retimed_data["speed"] # get handles value - take only the max from both handle_start = instance.data["handleStart"] handle_end = instance.data["handleEnd"] handles = max(handle_start, handle_end) include_handles = instance.data.get("includeHandles") retimed_handles = instance.data.get("retimedHandles") # get media source range with handles source_start_handles = instance.data["sourceStartH"] source_end_handles = instance.data["sourceEndH"] # retime if needed if retimed_speed != 1.0: if retimed_handles: # handles are retimed source_start_handles = ( instance.data["sourceStart"] - retimed_handle_start) source_end_handles = ( source_start_handles + (retimed_source_duration - 1) + retimed_handle_start + retimed_handle_end ) else: # handles are not retimed source_end_handles = ( source_start_handles + (retimed_source_duration - 1) + handle_start + handle_end ) # get frame range with handles for representation range frame_start_handle = frame_start - handle_start repre_frame_start = frame_start_handle if include_handles: if retimed_speed == 1.0 or not retimed_handles: frame_start_handle = frame_start else: frame_start_handle = ( frame_start - handle_start) + retimed_handle_start self.log.debug("_ frame_start_handle: {}".format( frame_start_handle)) self.log.debug("_ repre_frame_start: {}".format( repre_frame_start)) # calculate duration with handles source_duration_handles = ( source_end_handles - source_start_handles) + 1 self.log.debug("_ source_duration_handles: {}".format( source_duration_handles)) # create staging dir path staging_dir = self.staging_dir(instance) # append staging dir for later cleanup instance.context.data["cleanupFullPaths"].append(staging_dir) # add default preset type for thumbnail and reviewable video # update them with settings and override in case the same # are found in there _preset_keys = [k.split('_')[0] for k in self.export_presets_mapping] export_presets = { k: v for k, v in deepcopy(self.default_presets).items() if k not in _preset_keys } export_presets.update(self.export_presets_mapping) if not instance.data.get("versionData"): instance.data["versionData"] = {} # set versiondata if any retime version_data = retimed_data.get("version_data") self.log.debug("_ version_data: {}".format(version_data)) if version_data: instance.data["versionData"].update(version_data) # version data start frame version_frame_start = frame_start if include_handles: version_frame_start = frame_start_handle if retimed_speed != 1.0: if retimed_handles: instance.data["versionData"].update({ "frameStart": version_frame_start, "frameEnd": ( (version_frame_start + source_duration_handles - 1) - (retimed_handle_start + retimed_handle_end) ) }) else: instance.data["versionData"].update({ "handleStart": handle_start, "handleEnd": handle_end, "frameStart": version_frame_start, "frameEnd": ( (version_frame_start + source_duration_handles - 1) - (handle_start + handle_end) ) }) self.log.debug("_ version_data: {}".format( instance.data["versionData"] )) # loop all preset names and for unique_name, preset_config in export_presets.items(): modify_xml_data = {} if self._should_skip(preset_config, clip_path, unique_name): continue # get all presets attributes extension = preset_config["ext"] preset_file = preset_config["xml_preset_file"] preset_dir = preset_config["xml_preset_dir"] export_type = preset_config["export_type"] repre_tags = preset_config["representation_tags"] parsed_comment_attrs = preset_config["parsed_comment_attrs"] color_out = preset_config["colorspace_out"] self.log.info( "Processing `{}` as `{}` to `{}` type...".format( preset_file, export_type, extension ) ) exporting_clip = None name_patern_xml = "_{}.".format( unique_name) if export_type == "Sequence Publish": # change export clip to sequence exporting_clip = flame.duplicate(sequence_clip) # only keep visible layer where instance segment is child self.hide_others( exporting_clip, segment_name, s_track_name) # change name pattern name_patern_xml = ( "__{}.").format( unique_name) # only for h264 with baked retime in_mark = clip_in out_mark = clip_out + 1 modify_xml_data.update({ "exportHandles": True, "nbHandles": handles }) else: in_mark = (source_start_handles - source_first_frame) + 1 out_mark = in_mark + source_duration_handles exporting_clip = self.import_clip(clip_path) exporting_clip.name.set_value("{}_{}".format( asset_name, segment_name)) # add xml tags modifications modify_xml_data.update({ # enum position low start from 0 "frameIndex": 0, "startFrame": repre_frame_start, "namePattern": name_patern_xml }) if parsed_comment_attrs: # add any xml overrides collected form segment.comment modify_xml_data.update(instance.data["xml_overrides"]) self.log.debug("_ in_mark: {}".format(in_mark)) self.log.debug("_ out_mark: {}".format(out_mark)) export_kwargs = {} # validate xml preset file is filled if preset_file == "": raise ValueError( ("Check Settings for {} preset: " "`XML preset file` is not filled").format( unique_name) ) # resolve xml preset dir if not filled if preset_dir == "": preset_dir = opfapi.get_preset_path_by_xml_name( preset_file) if not preset_dir: raise ValueError( ("Check Settings for {} preset: " "`XML preset file` {} is not found").format( unique_name, preset_file) ) # create preset path preset_orig_xml_path = str(os.path.join( preset_dir, preset_file )) # define kwargs based on preset type if "thumbnail" in unique_name: modify_xml_data.update({ "video/posterFrame": True, "video/useFrameAsPoster": 1, "namePattern": "__thumbnail" }) thumb_frame_number = int(in_mark + ( (out_mark - in_mark + 1) / 2)) self.log.debug("__ thumb_frame_number: {}".format( thumb_frame_number )) export_kwargs["thumb_frame_number"] = thumb_frame_number else: export_kwargs.update({ "in_mark": in_mark, "out_mark": out_mark }) preset_path = opfapi.modify_preset_file( preset_orig_xml_path, staging_dir, modify_xml_data) # get and make export dir paths export_dir_path = str(os.path.join( staging_dir, unique_name )) os.makedirs(export_dir_path) # export opfapi.export_clip( export_dir_path, exporting_clip, preset_path, **export_kwargs) repr_name = unique_name # make sure only first segment is used if underscore in name # HACK: `ftrackreview_withLUT` will result only in `ftrackreview` if ( "thumbnail" in unique_name or "ftrackreview" in unique_name ): repr_name = unique_name.split("_")[0] # create representation data representation_data = { "name": repr_name, "outputName": repr_name, "ext": extension, "stagingDir": export_dir_path, "tags": repre_tags, "data": { "colorspace": color_out }, "load_to_batch_group": preset_config.get( "load_to_batch_group"), "batch_group_loader_name": preset_config.get( "batch_group_loader_name") or None } # collect all available content of export dir files = os.listdir(export_dir_path) # make sure no nested folders inside n_stage_dir, n_files = self._unfolds_nested_folders( export_dir_path, files, extension) # fix representation in case of nested folders if n_stage_dir: representation_data["stagingDir"] = n_stage_dir files = n_files # add files to representation but add # imagesequence as list if ( # first check if path in files is not mov extension [ f for f in files if os.path.splitext(f)[-1] == ".mov" ] # then try if thumbnail is not in unique name or repr_name == "thumbnail" ): representation_data["files"] = files.pop() else: representation_data["files"] = files # add frame range if preset_config["representation_add_range"]: representation_data.update({ "frameStart": repre_frame_start, "frameEnd": ( repre_frame_start + source_duration_handles) - 1, "fps": instance.data["fps"] }) instance.data["representations"].append(representation_data) # add review family if found in tags if "review" in repre_tags: instance.data["families"].append("review") self.log.info("Added representation: {}".format( representation_data)) if export_type == "Sequence Publish": # at the end remove the duplicated clip flame.delete(exporting_clip) def _get_retimed_attributes(self, instance): handle_start = instance.data["handleStart"] handle_end = instance.data["handleEnd"] # get basic variables otio_clip = instance.data["otioClip"] # get available range trimmed with processed retimes retimed_attributes = get_media_range_with_retimes( otio_clip, handle_start, handle_end) self.log.debug( ">> retimed_attributes: {}".format(retimed_attributes)) r_media_in = int(retimed_attributes["mediaIn"]) r_media_out = int(retimed_attributes["mediaOut"]) version_data = retimed_attributes.get("versionData") return { "version_data": version_data, "handle_start": int(retimed_attributes["handleStart"]), "handle_end": int(retimed_attributes["handleEnd"]), "source_duration": ( (r_media_out - r_media_in) + 1 ), "speed": float(retimed_attributes["speed"]) } def _should_skip(self, preset_config, clip_path, unique_name): # get activating attributes activated_preset = preset_config["active"] filter_path_regex = preset_config.get("filter_path_regex") self.log.info( "Preset `{}` is active `{}` with filter `{}`".format( unique_name, activated_preset, filter_path_regex ) ) # skip if not activated presete if not activated_preset: return True # exclude by regex filter if any if ( filter_path_regex and not re.search(filter_path_regex, clip_path) ): return True def _unfolds_nested_folders(self, stage_dir, files_list, ext): """Unfolds nested folders Args: stage_dir (str): path string with directory files_list (list): list of file names ext (str): extension (jpg)[without dot] Raises: IOError: in case no files were collected form any directory Returns: str, list: new staging dir path, new list of file names or None, None: In case single file in `files_list` """ # exclude single files which are having extension # the same as input ext attr if ( # only one file in list len(files_list) == 1 # file is having extension as input and ext in os.path.splitext(files_list[0])[-1] ): return None, None elif ( # more then one file in list len(files_list) >= 1 # extension is correct and ext in os.path.splitext(files_list[0])[-1] # test file exists and os.path.exists( os.path.join(stage_dir, files_list[0]) ) ): return None, None new_stage_dir = None new_files_list = [] for file in files_list: search_path = os.path.join(stage_dir, file) if not os.path.isdir(search_path): continue for root, _dirs, files in os.walk(search_path): for _file in files: _fn, _ext = os.path.splitext(_file) if ext.lower() != _ext[1:].lower(): continue new_files_list.append(_file) if not new_stage_dir: new_stage_dir = root if not new_stage_dir: raise AssertionError( "Files in `{}` are not correct! Check `{}`".format( files_list, stage_dir) ) return new_stage_dir, new_files_list def hide_others(self, sequence_clip, segment_name, track_name): """Helper method used only if sequence clip is used Args: sequence_clip (flame.Clip): sequence clip segment_name (str): segment name track_name (str): track name """ # create otio tracks and clips for ver in sequence_clip.versions: for track in ver.tracks: if len(track.segments) == 0 and track.hidden.get_value(): continue # hide tracks which are not parent track if track.name.get_value() != track_name: track.hidden = True continue # hidde all other segments for segment in track.segments: if segment.name.get_value() != segment_name: segment.hidden = True def import_clip(self, path): """ Import clip from path """ dir_path = os.path.dirname(path) media_info = MediaInfoFile(path, logger=self.log) file_pattern = media_info.file_pattern self.log.debug("__ file_pattern: {}".format(file_pattern)) # rejoin the pattern to dir path new_path = os.path.join(dir_path, file_pattern) clips = flame.import_clips(new_path) self.log.info("Clips [{}] imported from `{}`".format(clips, path)) if not clips: self.log.warning("Path `{}` is not having any clips".format(path)) return None elif len(clips) > 1: self.log.warning( "Path `{}` is containing more that one clip".format(path) ) return clips[0] ================================================ FILE: openpype/hosts/flame/plugins/publish/integrate_batch_group.py ================================================ import os import copy from collections import OrderedDict from pprint import pformat import pyblish import openpype.hosts.flame.api as opfapi import openpype.pipeline as op_pipeline from openpype.pipeline.workfile import get_workdir class IntegrateBatchGroup(pyblish.api.InstancePlugin): """Integrate published shot to batch group""" order = pyblish.api.IntegratorOrder + 0.45 label = "Integrate Batch Groups" hosts = ["flame"] families = ["clip"] # settings default_loader = "LoadClip" def process(self, instance): add_tasks = instance.data["flameAddTasks"] # iterate all tasks from settings for task_data in add_tasks: # exclude batch group if not task_data["create_batch_group"]: continue # create or get already created batch group bgroup = self._get_batch_group(instance, task_data) # add batch group content all_batch_nodes = self._add_nodes_to_batch_with_links( instance, task_data, bgroup) for name, node in all_batch_nodes.items(): self.log.debug("name: {}, dir: {}".format( name, dir(node) )) self.log.debug("__ node.attributes: {}".format( node.attributes )) # load plate to batch group self.log.info("Loading subset `{}` into batch `{}`".format( instance.data["subset"], bgroup.name.get_value() )) self._load_clip_to_context(instance, bgroup) def _add_nodes_to_batch_with_links(self, instance, task_data, batch_group): # get write file node properties > OrederDict because order does matter write_pref_data = self._get_write_prefs(instance, task_data) batch_nodes = [ { "type": "comp", "properties": {}, "id": "comp_node01" }, { "type": "Write File", "properties": write_pref_data, "id": "write_file_node01" } ] batch_links = [ { "from_node": { "id": "comp_node01", "connector": "Result" }, "to_node": { "id": "write_file_node01", "connector": "Front" } } ] # add nodes into batch group return opfapi.create_batch_group_conent( batch_nodes, batch_links, batch_group) def _load_clip_to_context(self, instance, bgroup): # get all loaders for host loaders_by_name = { loader.__name__: loader for loader in op_pipeline.discover_loader_plugins() } # get all published representations published_representations = instance.data["published_representations"] repres_db_id_by_name = { repre_info["representation"]["name"]: repre_id for repre_id, repre_info in published_representations.items() } # get all loadable representations repres_by_name = { repre["name"]: repre for repre in instance.data["representations"] } # get repre_id for the loadable representations loader_name_by_repre_id = { repres_db_id_by_name[repr_name]: { "loader": repr_data["batch_group_loader_name"], # add repre data for exception logging "_repre_data": repr_data } for repr_name, repr_data in repres_by_name.items() if repr_data.get("load_to_batch_group") } self.log.debug("__ loader_name_by_repre_id: {}".format(pformat( loader_name_by_repre_id))) # get representation context from the repre_id repre_contexts = op_pipeline.load.get_repres_contexts( loader_name_by_repre_id.keys()) self.log.debug("__ repre_contexts: {}".format(pformat( repre_contexts))) # loop all returned repres from repre_context dict for repre_id, repre_context in repre_contexts.items(): self.log.debug("__ repre_id: {}".format(repre_id)) # get loader name by representation id loader_name = ( loader_name_by_repre_id[repre_id]["loader"] # if nothing was added to settings fallback to default or self.default_loader ) # get loader plugin loader_plugin = loaders_by_name.get(loader_name) if loader_plugin: # load to flame by representation context try: op_pipeline.load.load_with_repre_context( loader_plugin, repre_context, **{ "data": { "workdir": self.task_workdir, "batch": bgroup } }) except op_pipeline.load.IncompatibleLoaderError as msg: self.log.error( "Check allowed representations for Loader `{}` " "in settings > error: {}".format( loader_plugin.__name__, msg)) self.log.error( "Representaton context >>{}<< is not compatible " "with loader `{}`".format( pformat(repre_context), loader_plugin.__name__ ) ) else: self.log.warning( "Something got wrong and there is not Loader found for " "following data: {}".format( pformat(loader_name_by_repre_id)) ) def _get_batch_group(self, instance, task_data): frame_start = instance.data["frameStart"] frame_end = instance.data["frameEnd"] handle_start = instance.data["handleStart"] handle_end = instance.data["handleEnd"] frame_duration = (frame_end - frame_start) + 1 asset_name = instance.data["asset"] task_name = task_data["name"] batchgroup_name = "{}_{}".format(asset_name, task_name) batch_data = { "shematic_reels": [ "OP_LoadedReel" ], "handleStart": handle_start, "handleEnd": handle_end } self.log.debug( "__ batch_data: {}".format(pformat(batch_data))) # check if the batch group already exists bgroup = opfapi.get_batch_group_from_desktop(batchgroup_name) if not bgroup: self.log.info( "Creating new batch group: {}".format(batchgroup_name)) # create batch with utils bgroup = opfapi.create_batch_group( batchgroup_name, frame_start, frame_duration, **batch_data ) else: self.log.info( "Updating batch group: {}".format(batchgroup_name)) # update already created batch group bgroup = opfapi.create_batch_group( batchgroup_name, frame_start, frame_duration, update_batch_group=bgroup, **batch_data ) return bgroup def _get_anamoty_data_with_current_task(self, instance, task_data): anatomy_data = copy.deepcopy(instance.data["anatomyData"]) task_name = task_data["name"] task_type = task_data["type"] anatomy_obj = instance.context.data["anatomy"] # update task data in anatomy data project_task_types = anatomy_obj["tasks"] task_code = project_task_types.get(task_type, {}).get("short_name") anatomy_data.update({ "task": { "name": task_name, "type": task_type, "short": task_code } }) return anatomy_data def _get_write_prefs(self, instance, task_data): # update task in anatomy data anatomy_data = self._get_anamoty_data_with_current_task( instance, task_data) self.task_workdir = self._get_shot_task_dir_path( instance, task_data) self.log.debug("__ task_workdir: {}".format( self.task_workdir)) # TODO: this might be done with template in settings render_dir_path = os.path.join( self.task_workdir, "render", "flame") if not os.path.exists(render_dir_path): os.makedirs(render_dir_path, mode=0o777) # TODO: add most of these to `imageio/flame/batch/write_node` name = "{project[code]}_{asset}_{task[name]}".format( **anatomy_data ) # The path attribute where the rendered clip is exported # /path/to/file.[0001-0010].exr media_path = render_dir_path # name of file represented by tokens media_path_pattern = ( "_v/_v.") # The Create Open Clip attribute of the Write File node. \ # Determines if an Open Clip is created by the Write File node. create_clip = True # The Include Setup attribute of the Write File node. # Determines if a Batch Setup file is created by the Write File node. include_setup = True # The path attribute where the Open Clip file is exported by # the Write File node. create_clip_path = "" # The path attribute where the Batch setup file # is exported by the Write File node. include_setup_path = "./_v" # The file type for the files written by the Write File node. # Setting this attribute also overwrites format_extension, # bit_depth and compress_mode to match the defaults for # this file type. file_type = "OpenEXR" # The file extension for the files written by the Write File node. # This attribute resets to match file_type whenever file_type # is set. If you require a specific extension, you must # set format_extension after setting file_type. format_extension = "exr" # The bit depth for the files written by the Write File node. # This attribute resets to match file_type whenever file_type is set. bit_depth = "16" # The compressing attribute for the files exported by the Write # File node. Only relevant when file_type in 'OpenEXR', 'Sgi', 'Tiff' compress = True # The compression format attribute for the specific File Types # export by the Write File node. You must set compress_mode # after setting file_type. compress_mode = "DWAB" # The frame index mode attribute of the Write File node. # Value range: `Use Timecode` or `Use Start Frame` frame_index_mode = "Use Start Frame" frame_padding = 6 # The versioning mode of the Open Clip exported by the Write File node. # Only available if create_clip = True. version_mode = "Follow Iteration" version_name = "v" version_padding = 3 # need to make sure the order of keys is correct return OrderedDict(( ("name", name), ("media_path", media_path), ("media_path_pattern", media_path_pattern), ("create_clip", create_clip), ("include_setup", include_setup), ("create_clip_path", create_clip_path), ("include_setup_path", include_setup_path), ("file_type", file_type), ("format_extension", format_extension), ("bit_depth", bit_depth), ("compress", compress), ("compress_mode", compress_mode), ("frame_index_mode", frame_index_mode), ("frame_padding", frame_padding), ("version_mode", version_mode), ("version_name", version_name), ("version_padding", version_padding) )) def _get_shot_task_dir_path(self, instance, task_data): project_doc = instance.data["projectEntity"] asset_entity = instance.data["assetEntity"] anatomy = instance.context.data["anatomy"] project_settings = instance.context.data["project_settings"] return get_workdir( project_doc, asset_entity, task_data["name"], "flame", anatomy, project_settings=project_settings ) ================================================ FILE: openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml ================================================ sequence Creates a 8-bit Jpeg file per segment. NONE <name> True True image FX NoChange False 10 True False audio FX FlattenTracks True 10 4 1 2 ================================================ FILE: openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml ================================================ sequence Create MOV H264 files per segment with thumbnail NONE <name> True True movie FX FlattenTracks True 5 True False audio Original NoChange True 5 QuickTime <shot name> 0 PCS_709 None Autodesk Flame 2021 4 1 2 ================================================ FILE: openpype/hosts/flame/startup/openpype_babypublisher/modules/__init__.py ================================================ ================================================ FILE: openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py ================================================ import os import io import ConfigParser as CP from xml.etree import ElementTree as ET from contextlib import contextmanager PLUGIN_DIR = os.path.dirname(os.path.dirname(__file__)) EXPORT_PRESETS_DIR = os.path.join(PLUGIN_DIR, "export_preset") CONFIG_DIR = os.path.join(os.path.expanduser( "~/.openpype"), "openpype_babypublisher") @contextmanager def make_temp_dir(): import tempfile try: dirpath = tempfile.mkdtemp() yield dirpath except IOError as _error: raise IOError("Not able to create temp dir file: {}".format(_error)) finally: pass @contextmanager def get_config(section=None): cfg_file_path = os.path.join(CONFIG_DIR, "settings.ini") # create config dir if not os.path.exists(CONFIG_DIR): print("making dirs at: `{}`".format(CONFIG_DIR)) os.makedirs(CONFIG_DIR, mode=0o777) # write default data to settings.ini if not os.path.exists(cfg_file_path): default_cfg = cfg_default() config = CP.RawConfigParser() config.readfp(io.BytesIO(default_cfg)) with open(cfg_file_path, 'wb') as cfg_file: config.write(cfg_file) try: config = CP.RawConfigParser() config.read(cfg_file_path) if section: _cfg_data = { k: v for s in config.sections() for k, v in config.items(s) if s == section } else: _cfg_data = {s: dict(config.items(s)) for s in config.sections()} yield _cfg_data except IOError as _error: raise IOError('Not able to read settings.ini file: {}'.format(_error)) finally: pass def set_config(cfg_data, section=None): cfg_file_path = os.path.join(CONFIG_DIR, "settings.ini") config = CP.RawConfigParser() config.read(cfg_file_path) try: if not section: for section in cfg_data: for key, value in cfg_data[section].items(): config.set(section, key, value) else: for key, value in cfg_data.items(): config.set(section, key, value) with open(cfg_file_path, 'wb') as cfg_file: config.write(cfg_file) except IOError as _error: raise IOError('Not able to write settings.ini file: {}'.format(_error)) def cfg_default(): return """ [main] workfile_start_frame = 1001 shot_handles = 0 shot_name_template = {sequence}_{shot} hierarchy_template = shots[Folder]/{sequence}[Sequence] create_task_type = Compositing """ def configure_preset(file_path, data): split_fp = os.path.splitext(file_path) new_file_path = split_fp[0] + "_tmp" + split_fp[-1] with open(file_path, "r") as datafile: tree = ET.parse(datafile) for key, value in data.items(): for element in tree.findall(".//{}".format(key)): print(element) element.text = str(value) tree.write(new_file_path) return new_file_path def export_thumbnail(sequence, tempdir_path, data): import flame export_preset = os.path.join( EXPORT_PRESETS_DIR, "openpype_seg_thumbnails_jpg.xml" ) new_path = configure_preset(export_preset, data) poster_frame_exporter = flame.PyExporter() poster_frame_exporter.foreground = True poster_frame_exporter.export(sequence, new_path, tempdir_path) def export_video(sequence, tempdir_path, data): import flame export_preset = os.path.join( EXPORT_PRESETS_DIR, "openpype_seg_video_h264.xml" ) new_path = configure_preset(export_preset, data) poster_frame_exporter = flame.PyExporter() poster_frame_exporter.foreground = True poster_frame_exporter.export(sequence, new_path, tempdir_path) def timecode_to_frames(timecode, framerate): def _seconds(value): if isinstance(value, str): _zip_ft = zip((3600, 60, 1, 1 / framerate), value.split(':')) return sum(f * float(t) for f, t in _zip_ft) elif isinstance(value, (int, float)): return value / framerate return 0 def _frames(seconds): return seconds * framerate def tc_to_frames(_timecode, start=None): return _frames(_seconds(_timecode) - _seconds(start)) if '+' in timecode: timecode = timecode.replace('+', ':') elif '#' in timecode: timecode = timecode.replace('#', ':') frames = int(round(tc_to_frames(timecode, start='00:00:00:00'))) return frames ================================================ FILE: openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py ================================================ import os import sys import six import re import json import app_utils # Fill following constants or set them via environment variable FTRACK_MODULE_PATH = None FTRACK_API_KEY = None FTRACK_API_USER = None FTRACK_SERVER = None def import_ftrack_api(): try: import ftrack_api return ftrack_api except ImportError: import sys ftrk_m_p = FTRACK_MODULE_PATH or os.getenv("FTRACK_MODULE_PATH") sys.path.append(ftrk_m_p) import ftrack_api return ftrack_api def get_ftrack_session(): import os ftrack_api = import_ftrack_api() # fill your own credentials url = FTRACK_SERVER or os.getenv("FTRACK_SERVER") or "" user = FTRACK_API_USER or os.getenv("FTRACK_API_USER") or "" api = FTRACK_API_KEY or os.getenv("FTRACK_API_KEY") or "" first_validation = True if not user: print('- Ftrack Username is not set') first_validation = False if not api: print('- Ftrack API key is not set') first_validation = False if not first_validation: return False try: return ftrack_api.Session( server_url=url, api_user=user, api_key=api ) except Exception as _e: print("Can't log into Ftrack with used credentials: {}".format(_e)) ftrack_cred = { 'Ftrack server': str(url), 'Username': str(user), 'API key': str(api), } item_lens = [len(key) + 1 for key in ftrack_cred] justify_len = max(*item_lens) for key, value in ftrack_cred.items(): print('{} {}'.format((key + ':').ljust(justify_len, ' '), value)) return False def get_project_task_types(project_entity): tasks = {} proj_template = project_entity['project_schema'] temp_task_types = proj_template['_task_type_schema']['types'] for type in temp_task_types: if type['name'] not in tasks: tasks[type['name']] = type return tasks class FtrackComponentCreator: default_location = "ftrack.server" ftrack_locations = {} thumbnails = [] videos = [] temp_dir = None def __init__(self, session): self.session = session self._get_ftrack_location() def generate_temp_data(self, selection, change_preset_data): with app_utils.make_temp_dir() as tempdir_path: for seq in selection: app_utils.export_thumbnail( seq, tempdir_path, change_preset_data) app_utils.export_video(seq, tempdir_path, change_preset_data) return tempdir_path def collect_generated_data(self, tempdir_path): temp_files = os.listdir(tempdir_path) self.thumbnails = [f for f in temp_files if "jpg" in f] self.videos = [f for f in temp_files if "mov" in f] self.temp_dir = tempdir_path def get_thumb_path(self, shot_name): # get component files thumb_f = next((f for f in self.thumbnails if shot_name in f), None) return os.path.join(self.temp_dir, thumb_f) def get_video_path(self, shot_name): # get component files video_f = next((f for f in self.videos if shot_name in f), None) return os.path.join(self.temp_dir, video_f) def close(self): self.ftrack_locations = {} self.session = None def create_comonent(self, shot_entity, data, assetversion_entity=None): self.shot_entity = shot_entity location = self._get_ftrack_location() file_path = data["file_path"] # get extension file = os.path.basename(file_path) _n, ext = os.path.splitext(file) name = "ftrackreview-mp4" if "mov" in ext else "thumbnail" component_data = { "name": name, "file_path": file_path, "file_type": ext, "location": location } if name == "ftrackreview-mp4": duration = data["duration"] handles = data["handles"] fps = data["fps"] component_data["metadata"] = { 'ftr_meta': json.dumps({ 'frameIn': int(0), 'frameOut': int(duration + (handles * 2)), 'frameRate': float(fps) }) } if not assetversion_entity: # get assettype entity from session assettype_entity = self._get_assettype({"short": "reference"}) # get or create asset entity from session asset_entity = self._get_asset({ "name": "plateReference", "type": assettype_entity, "parent": self.shot_entity }) # get or create assetversion entity from session assetversion_entity = self._get_assetversion({ "version": 0, "asset": asset_entity }) # get or create component entity self._set_component(component_data, { "name": name, "version": assetversion_entity, }) return assetversion_entity def _overwrite_members(self, entity, data): origin_location = self._get_ftrack_location("ftrack.origin") location = data.pop("location") self._remove_component_from_location(entity, location) entity["file_type"] = data["file_type"] try: origin_location.add_component( entity, data["file_path"] ) # Add components to location. location.add_component( entity, origin_location, recursive=True) except Exception as __e: print("Error: {}".format(__e)) self._remove_component_from_location(entity, origin_location) origin_location.add_component( entity, data["file_path"] ) # Add components to location. location.add_component( entity, origin_location, recursive=True) def _remove_component_from_location(self, entity, location): print(location) # Removing existing members from location components = list(entity.get("members", [])) components += [entity] for component in components: for loc in component.get("component_locations", []): if location["id"] == loc["location_id"]: print("<< Removing component: {}".format(component)) location.remove_component( component, recursive=False ) # Deleting existing members on component entity for member in entity.get("members", []): self.session.delete(member) print("<< Deleting member: {}".format(member)) del(member) self._commit() # Reset members in memory if "members" in entity.keys(): entity["members"] = [] def _get_assettype(self, data): return self.session.query( self._query("AssetType", data)).first() def _set_component(self, comp_data, base_data): component_metadata = comp_data.pop("metadata", {}) component_entity = self.session.query( self._query("Component", base_data) ).first() if component_entity: # overwrite existing members in component entity # - get data for member from `ftrack.origin` location self._overwrite_members(component_entity, comp_data) # Adding metadata existing_component_metadata = component_entity["metadata"] existing_component_metadata.update(component_metadata) component_entity["metadata"] = existing_component_metadata return assetversion_entity = base_data["version"] location = comp_data.pop("location") component_entity = assetversion_entity.create_component( comp_data["file_path"], data=comp_data, location=location ) # Adding metadata existing_component_metadata = component_entity["metadata"] existing_component_metadata.update(component_metadata) component_entity["metadata"] = existing_component_metadata if comp_data["name"] == "thumbnail": self.shot_entity["thumbnail_id"] = component_entity["id"] assetversion_entity["thumbnail_id"] = component_entity["id"] self._commit() def _get_asset(self, data): # first find already created asset_entity = self.session.query( self._query("Asset", data) ).first() if asset_entity: return asset_entity asset_entity = self.session.create("Asset", data) # _commit if created self._commit() return asset_entity def _get_assetversion(self, data): assetversion_entity = self.session.query( self._query("AssetVersion", data) ).first() if assetversion_entity: return assetversion_entity assetversion_entity = self.session.create("AssetVersion", data) # _commit if created self._commit() return assetversion_entity def _commit(self): try: self.session.commit() except Exception: tp, value, tb = sys.exc_info() # self.session.rollback() # self.session._configure_locations() six.reraise(tp, value, tb) def _get_ftrack_location(self, name=None): name = name or self.default_location if name in self.ftrack_locations: return self.ftrack_locations[name] location = self.session.query( 'Location where name is "{}"'.format(name) ).one() self.ftrack_locations[name] = location return location def _query(self, entitytype, data): """ Generate a query expression from data supplied. If a value is not a string, we'll add the id of the entity to the query. Args: entitytype (str): The type of entity to query. data (dict): The data to identify the entity. exclusions (list): All keys to exclude from the query. Returns: str: String query to use with "session.query" """ queries = [] if sys.version_info[0] < 3: for key, value in data.items(): if not isinstance(value, (str, int)): print("value: {}".format(value)) if "id" in value.keys(): queries.append( "{0}.id is \"{1}\"".format(key, value["id"]) ) else: queries.append("{0} is \"{1}\"".format(key, value)) else: for key, value in data.items(): if not isinstance(value, (str, int)): print("value: {}".format(value)) if "id" in value.keys(): queries.append( "{0}.id is \"{1}\"".format(key, value["id"]) ) else: queries.append("{0} is \"{1}\"".format(key, value)) query = ( "select id from " + entitytype + " where " + " and ".join(queries) ) print(query) return query class FtrackEntityOperator: existing_tasks = [] def __init__(self, session, project_entity): self.session = session self.project_entity = project_entity def commit(self): try: self.session.commit() except Exception: tp, value, tb = sys.exc_info() self.session.rollback() self.session._configure_locations() six.reraise(tp, value, tb) def create_ftrack_entity(self, session, type, name, parent=None): parent = parent or self.project_entity entity = session.create(type, { 'name': name, 'parent': parent }) try: session.commit() except Exception: tp, value, tb = sys.exc_info() session.rollback() session._configure_locations() six.reraise(tp, value, tb) return entity def get_ftrack_entity(self, session, type, name, parent): query = '{} where name is "{}" and project_id is "{}"'.format( type, name, self.project_entity["id"]) entity = session.query(query).first() # if entity doesnt exist then create one if not entity: entity = self.create_ftrack_entity( session, type, name, parent ) return entity def create_parents(self, template): parents = [] t_split = template.split("/") replace_patern = re.compile(r"(\[.*\])") type_patern = re.compile(r"\[(.*)\]") for t_s in t_split: match_type = type_patern.findall(t_s) if not match_type: raise Exception(( "Missing correct type flag in : {}" "/n Example: name[Type]").format( t_s) ) new_name = re.sub(replace_patern, "", t_s) f_type = match_type.pop() parents.append((new_name, f_type)) return parents def create_task(self, task_type, task_types, parent): _exising_tasks = [ child for child in parent['children'] if child.entity_type.lower() == 'task' ] # add task into existing tasks if they are not already there for _t in _exising_tasks: if _t in self.existing_tasks: continue self.existing_tasks.append(_t) existing_task = [ task for task in self.existing_tasks if task['name'].lower() in task_type.lower() if task['parent'] == parent ] if existing_task: return existing_task.pop() task = self.session.create('Task', { "name": task_type.lower(), "parent": parent }) task["type"] = task_types[task_type] self.existing_tasks.append(task) return task ================================================ FILE: openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py ================================================ from qtpy import QtWidgets, QtCore import uiwidgets import app_utils import ftrack_lib def clear_inner_modules(): import sys if "ftrack_lib" in sys.modules.keys(): del sys.modules["ftrack_lib"] print("Ftrack Lib module removed from sys.modules") if "app_utils" in sys.modules.keys(): del sys.modules["app_utils"] print("app_utils module removed from sys.modules") if "uiwidgets" in sys.modules.keys(): del sys.modules["uiwidgets"] print("uiwidgets module removed from sys.modules") class MainWindow(QtWidgets.QWidget): def __init__(self, klass, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self.panel_class = klass def closeEvent(self, event): # clear all temp data print("Removing temp data") self.panel_class.clear_temp_data() self.panel_class.close() clear_inner_modules() ftrack_lib.FtrackEntityOperator.existing_tasks = [] # now the panel can be closed event.accept() class FlameBabyPublisherPanel(object): session = None temp_data_dir = None processed_components = [] project_entity = None task_types = {} all_task_types = {} # TreeWidget columns = { "Sequence name": { "columnWidth": 200, "order": 0 }, "Shot name": { "columnWidth": 200, "order": 1 }, "Clip duration": { "columnWidth": 100, "order": 2 }, "Shot description": { "columnWidth": 500, "order": 3 }, "Task description": { "columnWidth": 500, "order": 4 }, } def __init__(self, selection): print(selection) self.session = ftrack_lib.get_ftrack_session() self.selection = selection self.window = MainWindow(self) # creating ui self.window.setMinimumSize(1500, 600) self.window.setWindowTitle('OpenPype: Baby-publisher') self.window.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) self.window.setAttribute(QtCore.Qt.WA_DeleteOnClose) self.window.setFocusPolicy(QtCore.Qt.StrongFocus) self.window.setStyleSheet('background-color: #313131') self._create_project_widget() self._create_tree_widget() self._set_sequence_params() self._generate_widgets() self._generate_layouts() self._timeline_info() self._fix_resolution() self.window.show() def _generate_widgets(self): with app_utils.get_config("main") as cfg_data: cfg_d = cfg_data self._create_task_type_widget(cfg_d) # input fields self.shot_name_label = uiwidgets.FlameLabel( 'Shot name template', 'normal', self.window) self.shot_name_template_input = uiwidgets.FlameLineEdit( cfg_d["shot_name_template"], self.window) self.hierarchy_label = uiwidgets.FlameLabel( 'Parents template', 'normal', self.window) self.hierarchy_template_input = uiwidgets.FlameLineEdit( cfg_d["hierarchy_template"], self.window) self.start_frame_label = uiwidgets.FlameLabel( 'Workfile start frame', 'normal', self.window) self.start_frame_input = uiwidgets.FlameLineEdit( cfg_d["workfile_start_frame"], self.window) self.handles_label = uiwidgets.FlameLabel( 'Shot handles', 'normal', self.window) self.handles_input = uiwidgets.FlameLineEdit( cfg_d["shot_handles"], self.window) self.width_label = uiwidgets.FlameLabel( 'Sequence width', 'normal', self.window) self.width_input = uiwidgets.FlameLineEdit( str(self.seq_width), self.window) self.height_label = uiwidgets.FlameLabel( 'Sequence height', 'normal', self.window) self.height_input = uiwidgets.FlameLineEdit( str(self.seq_height), self.window) self.pixel_aspect_label = uiwidgets.FlameLabel( 'Pixel aspect ratio', 'normal', self.window) self.pixel_aspect_input = uiwidgets.FlameLineEdit( str(1.00), self.window) self.fps_label = uiwidgets.FlameLabel( 'Frame rate', 'normal', self.window) self.fps_input = uiwidgets.FlameLineEdit( str(self.fps), self.window) # Button self.select_all_btn = uiwidgets.FlameButton( 'Select All', self.select_all, self.window) self.remove_temp_data_btn = uiwidgets.FlameButton( 'Remove temp data', self.clear_temp_data, self.window) self.ftrack_send_btn = uiwidgets.FlameButton( 'Send to Ftrack', self._send_to_ftrack, self.window) def _generate_layouts(self): # left props v_shift = 0 prop_layout_l = QtWidgets.QGridLayout() prop_layout_l.setHorizontalSpacing(30) if self.project_selector_enabled: prop_layout_l.addWidget(self.project_select_label, v_shift, 0) prop_layout_l.addWidget(self.project_select_input, v_shift, 1) v_shift += 1 prop_layout_l.addWidget(self.shot_name_label, (v_shift + 0), 0) prop_layout_l.addWidget( self.shot_name_template_input, (v_shift + 0), 1) prop_layout_l.addWidget(self.hierarchy_label, (v_shift + 1), 0) prop_layout_l.addWidget( self.hierarchy_template_input, (v_shift + 1), 1) prop_layout_l.addWidget(self.start_frame_label, (v_shift + 2), 0) prop_layout_l.addWidget(self.start_frame_input, (v_shift + 2), 1) prop_layout_l.addWidget(self.handles_label, (v_shift + 3), 0) prop_layout_l.addWidget(self.handles_input, (v_shift + 3), 1) prop_layout_l.addWidget(self.task_type_label, (v_shift + 4), 0) prop_layout_l.addWidget( self.task_type_input, (v_shift + 4), 1) # right props prop_widget_r = QtWidgets.QWidget(self.window) prop_layout_r = QtWidgets.QGridLayout(prop_widget_r) prop_layout_r.setHorizontalSpacing(30) prop_layout_r.setAlignment( QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) prop_layout_r.setContentsMargins(0, 0, 0, 0) prop_layout_r.addWidget(self.width_label, 1, 0) prop_layout_r.addWidget(self.width_input, 1, 1) prop_layout_r.addWidget(self.height_label, 2, 0) prop_layout_r.addWidget(self.height_input, 2, 1) prop_layout_r.addWidget(self.pixel_aspect_label, 3, 0) prop_layout_r.addWidget(self.pixel_aspect_input, 3, 1) prop_layout_r.addWidget(self.fps_label, 4, 0) prop_layout_r.addWidget(self.fps_input, 4, 1) # prop layout prop_main_layout = QtWidgets.QHBoxLayout() prop_main_layout.addLayout(prop_layout_l, 1) prop_main_layout.addSpacing(20) prop_main_layout.addWidget(prop_widget_r, 1) # buttons layout hbox = QtWidgets.QHBoxLayout() hbox.addWidget(self.remove_temp_data_btn) hbox.addWidget(self.select_all_btn) hbox.addWidget(self.ftrack_send_btn) # put all layouts together main_frame = QtWidgets.QVBoxLayout(self.window) main_frame.setMargin(20) main_frame.addLayout(prop_main_layout) main_frame.addWidget(self.tree) main_frame.addLayout(hbox) def _set_sequence_params(self): for select in self.selection: self.seq_height = select.height self.seq_width = select.width self.fps = float(str(select.frame_rate)[:-4]) break def _create_task_type_widget(self, cfg_d): print(self.project_entity) self.task_types = ftrack_lib.get_project_task_types( self.project_entity) self.task_type_label = uiwidgets.FlameLabel( 'Create Task (type)', 'normal', self.window) self.task_type_input = uiwidgets.FlamePushButtonMenu( cfg_d["create_task_type"], self.task_types.keys(), self.window) def _create_project_widget(self): import flame # get project name from flame current project self.project_name = flame.project.current_project.name # get project from ftrack - # ftrack project name has to be the same as flame project! query = 'Project where full_name is "{}"'.format(self.project_name) # globally used variables self.project_entity = self.session.query(query).first() self.project_selector_enabled = bool(not self.project_entity) if self.project_selector_enabled: self.all_projects = self.session.query( "Project where status is active").all() self.project_entity = self.all_projects[0] project_names = [p["full_name"] for p in self.all_projects] self.all_task_types = { p["full_name"]: ftrack_lib.get_project_task_types(p).keys() for p in self.all_projects } self.project_select_label = uiwidgets.FlameLabel( 'Select Ftrack project', 'normal', self.window) self.project_select_input = uiwidgets.FlamePushButtonMenu( self.project_entity["full_name"], project_names, self.window) self.project_select_input.selection_changed.connect( self._on_project_changed) def _create_tree_widget(self): ordered_column_labels = self.columns.keys() for _name, _value in self.columns.items(): ordered_column_labels.pop(_value["order"]) ordered_column_labels.insert(_value["order"], _name) self.tree = uiwidgets.FlameTreeWidget( ordered_column_labels, self.window) # Allow multiple items in tree to be selected self.tree.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) # Set tree column width for _name, _val in self.columns.items(): self.tree.setColumnWidth( _val["order"], _val["columnWidth"] ) # Prevent weird characters when shrinking tree columns self.tree.setTextElideMode(QtCore.Qt.ElideNone) def _resolve_project_entity(self): if self.project_selector_enabled: selected_project_name = self.project_select_input.text() self.project_entity = next( (p for p in self.all_projects if p["full_name"] in selected_project_name), None ) def _save_ui_state_to_cfg(self): _cfg_data_back = { "shot_name_template": self.shot_name_template_input.text(), "workfile_start_frame": self.start_frame_input.text(), "shot_handles": self.handles_input.text(), "hierarchy_template": self.hierarchy_template_input.text(), "create_task_type": self.task_type_input.text() } # add cfg data back to settings.ini app_utils.set_config(_cfg_data_back, "main") def _send_to_ftrack(self): # resolve active project and add it to self.project_entity self._resolve_project_entity() self._save_ui_state_to_cfg() # get handles from gui input handles = self.handles_input.text() # get frame start from gui input frame_start = int(self.start_frame_input.text()) # get task type from gui input task_type = self.task_type_input.text() # get resolution from gui inputs fps = self.fps_input.text() entity_operator = ftrack_lib.FtrackEntityOperator( self.session, self.project_entity) component_creator = ftrack_lib.FtrackComponentCreator(self.session) if not self.temp_data_dir: self.window.hide() self.temp_data_dir = component_creator.generate_temp_data( self.selection, { "nbHandles": handles } ) self.window.show() # collect generated files to list data for farther use component_creator.collect_generated_data(self.temp_data_dir) # Get all selected items from treewidget for item in self.tree.selectedItems(): # frame ranges frame_duration = int(item.text(2)) frame_end = frame_start + frame_duration # description shot_description = item.text(3) task_description = item.text(4) # other sequence_name = item.text(0) shot_name = item.text(1) thumb_fp = component_creator.get_thumb_path(shot_name) video_fp = component_creator.get_video_path(shot_name) print("processed comps: {}".format(self.processed_components)) print("processed thumb_fp: {}".format(thumb_fp)) processed = False if thumb_fp not in self.processed_components: self.processed_components.append(thumb_fp) else: processed = True print("processed: {}".format(processed)) # populate full shot info shot_attributes = { "sequence": sequence_name, "shot": shot_name, "task": task_type } # format shot name template _shot_name = self.shot_name_template_input.text().format( **shot_attributes) # format hierarchy template _hierarchy_text = self.hierarchy_template_input.text().format( **shot_attributes) print(_hierarchy_text) # solve parents parents = entity_operator.create_parents(_hierarchy_text) print(parents) # obtain shot parents entities _parent = None for _name, _type in parents: p_entity = entity_operator.get_ftrack_entity( self.session, _type, _name, _parent ) print(p_entity) _parent = p_entity # obtain shot ftrack entity f_s_entity = entity_operator.get_ftrack_entity( self.session, "Shot", _shot_name, _parent ) print("Shot entity is: {}".format(f_s_entity)) if not processed: # first create thumbnail and get version entity assetversion_entity = component_creator.create_comonent( f_s_entity, { "file_path": thumb_fp } ) # secondly add video to version entity component_creator.create_comonent( f_s_entity, { "file_path": video_fp, "duration": frame_duration, "handles": int(handles), "fps": float(fps) }, assetversion_entity ) # create custom attributtes custom_attrs = { "frameStart": frame_start, "frameEnd": frame_end, "handleStart": int(handles), "handleEnd": int(handles), "resolutionWidth": int(self.width_input.text()), "resolutionHeight": int(self.height_input.text()), "pixelAspect": float(self.pixel_aspect_input.text()), "fps": float(fps) } # update custom attributes on shot entity for key in custom_attrs: f_s_entity['custom_attributes'][key] = custom_attrs[key] task_entity = entity_operator.create_task( task_type, self.task_types, f_s_entity) # Create notes. user = self.session.query( "User where username is \"{}\"".format(self.session.api_user) ).first() f_s_entity.create_note(shot_description, author=user) if task_description: task_entity.create_note(task_description, user) entity_operator.commit() component_creator.close() def _fix_resolution(self): # Center window in linux resolution = QtWidgets.QDesktopWidget().screenGeometry() self.window.move( (resolution.width() / 2) - (self.window.frameSize().width() / 2), (resolution.height() / 2) - (self.window.frameSize().height() / 2)) def _on_project_changed(self): task_types = self.all_task_types[self.project_name] self.task_type_input.set_menu_options(task_types) def _timeline_info(self): # identificar as informacoes dos segmentos na timeline for sequence in self.selection: frame_rate = float(str(sequence.frame_rate)[:-4]) for ver in sequence.versions: for track in ver.tracks: if len(track.segments) == 0 and track.hidden: continue for segment in track.segments: print(segment.attributes) if segment.name.get_value() == "": continue if segment.hidden.get_value() is True: continue # get clip frame duration record_duration = str(segment.record_duration)[1:-1] clip_duration = app_utils.timecode_to_frames( record_duration, frame_rate) # populate shot source metadata shot_description = "" for attr in ["tape_name", "source_name", "head", "tail", "file_path"]: if not hasattr(segment, attr): continue _value = getattr(segment, attr) _label = attr.replace("_", " ").capitalize() row = "{}: {}\n".format(_label, _value) shot_description += row # Add timeline segment to tree QtWidgets.QTreeWidgetItem(self.tree, [ sequence.name.get_value(), # seq name segment.shot_name.get_value(), # shot name str(clip_duration), # clip duration shot_description, # shot description segment.comment.get_value() # task description ]).setFlags( QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable ) # Select top item in tree self.tree.setCurrentItem(self.tree.topLevelItem(0)) def select_all(self, ): self.tree.selectAll() def clear_temp_data(self): import shutil self.processed_components = [] if self.temp_data_dir: shutil.rmtree(self.temp_data_dir) self.temp_data_dir = None print("All Temp data were destroyed ...") def close(self): self._save_ui_state_to_cfg() self.session.close() ================================================ FILE: openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py ================================================ from qtpy import QtWidgets, QtCore class FlameLabel(QtWidgets.QLabel): """ Custom Qt Flame Label Widget For different label looks set label_type as: 'normal', 'background', or 'outline' To use: label = FlameLabel('Label Name', 'normal', window) """ def __init__(self, label_name, label_type, parent_window, *args, **kwargs): super(FlameLabel, self).__init__(*args, **kwargs) self.setText(label_name) self.setParent(parent_window) self.setMinimumSize(130, 28) self.setMaximumHeight(28) self.setFocusPolicy(QtCore.Qt.NoFocus) # Set label stylesheet based on label_type if label_type == 'normal': self.setStyleSheet( 'QLabel {color: #9a9a9a; border-bottom: 1px inset #282828; font: 14px "Discreet"}' # noqa 'QLabel:disabled {color: #6a6a6a}' ) elif label_type == 'background': self.setAlignment(QtCore.Qt.AlignCenter) self.setStyleSheet( 'color: #9a9a9a; background-color: #393939; font: 14px "Discreet"' # noqa ) elif label_type == 'outline': self.setAlignment(QtCore.Qt.AlignCenter) self.setStyleSheet( 'color: #9a9a9a; background-color: #212121; border: 1px solid #404040; font: 14px "Discreet"' # noqa ) class FlameLineEdit(QtWidgets.QLineEdit): """ Custom Qt Flame Line Edit Widget Main window should include this: window.setFocusPolicy(QtCore.Qt.StrongFocus) To use: line_edit = FlameLineEdit('Some text here', window) """ def __init__(self, text, parent_window, *args, **kwargs): super(FlameLineEdit, self).__init__(*args, **kwargs) self.setText(text) self.setParent(parent_window) self.setMinimumHeight(28) self.setMinimumWidth(110) self.setStyleSheet( 'QLineEdit {color: #9a9a9a; background-color: #373e47; selection-color: #262626; selection-background-color: #b8b1a7; font: 14px "Discreet"}' # noqa 'QLineEdit:focus {background-color: #474e58}' # noqa 'QLineEdit:disabled {color: #6a6a6a; background-color: #373737}' ) class FlameTreeWidget(QtWidgets.QTreeWidget): """ Custom Qt Flame Tree Widget To use: tree_headers = ['Header1', 'Header2', 'Header3', 'Header4'] tree = FlameTreeWidget(tree_headers, window) """ def __init__(self, tree_headers, parent_window, *args, **kwargs): super(FlameTreeWidget, self).__init__(*args, **kwargs) self.setMinimumWidth(1000) self.setMinimumHeight(300) self.setSortingEnabled(True) self.sortByColumn(0, QtCore.Qt.AscendingOrder) self.setAlternatingRowColors(True) self.setFocusPolicy(QtCore.Qt.NoFocus) self.setStyleSheet( 'QTreeWidget {color: #9a9a9a; background-color: #2a2a2a; alternate-background-color: #2d2d2d; font: 14px "Discreet"}' # noqa 'QTreeWidget::item:selected {color: #d9d9d9; background-color: #474747; border: 1px solid #111111}' # noqa 'QHeaderView {color: #9a9a9a; background-color: #393939; font: 14px "Discreet"}' # noqa 'QTreeWidget::item:selected {selection-background-color: #111111}' 'QMenu {color: #9a9a9a; background-color: #24303d; font: 14px "Discreet"}' # noqa 'QMenu::item:selected {color: #d9d9d9; background-color: #3a4551}' ) self.verticalScrollBar().setStyleSheet('color: #818181') self.horizontalScrollBar().setStyleSheet('color: #818181') self.setHeaderLabels(tree_headers) class FlameButton(QtWidgets.QPushButton): """ Custom Qt Flame Button Widget To use: button = FlameButton('Button Name', do_this_when_pressed, window) """ def __init__(self, button_name, do_when_pressed, parent_window, *args, **kwargs): super(FlameButton, self).__init__(*args, **kwargs) self.setText(button_name) self.setParent(parent_window) self.setMinimumSize(QtCore.QSize(110, 28)) self.setMaximumSize(QtCore.QSize(110, 28)) self.setFocusPolicy(QtCore.Qt.NoFocus) self.clicked.connect(do_when_pressed) self.setStyleSheet( 'QPushButton {color: #9a9a9a; background-color: #424142; border-top: 1px inset #555555; border-bottom: 1px inset black; font: 14px "Discreet"}' # noqa 'QPushButton:pressed {color: #d9d9d9; background-color: #4f4f4f; border-top: 1px inset #666666; font: italic}' # noqa 'QPushButton:disabled {color: #747474; background-color: #353535; border-top: 1px solid #444444; border-bottom: 1px solid #242424}' # noqa ) class FlamePushButton(QtWidgets.QPushButton): """ Custom Qt Flame Push Button Widget To use: pushbutton = FlamePushButton(' Button Name', True_or_False, window) """ def __init__(self, button_name, button_checked, parent_window, *args, **kwargs): super(FlamePushButton, self).__init__(*args, **kwargs) self.setText(button_name) self.setParent(parent_window) self.setCheckable(True) self.setChecked(button_checked) self.setMinimumSize(155, 28) self.setMaximumSize(155, 28) self.setFocusPolicy(QtCore.Qt.NoFocus) self.setStyleSheet( 'QPushButton {color: #9a9a9a; background-color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, stop: .93 #424142, stop: .94 #2e3b48); text-align: left; border-top: 1px inset #555555; border-bottom: 1px inset black; font: 14px "Discreet"}' # noqa 'QPushButton:checked {color: #d9d9d9; background-color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, stop: .93 #4f4f4f, stop: .94 #5a7fb4); font: italic; border: 1px inset black; border-bottom: 1px inset #404040; border-right: 1px inset #404040}' # noqa 'QPushButton:disabled {color: #6a6a6a; background-color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, stop: .93 #383838, stop: .94 #353535); font: light; border-top: 1px solid #575757; border-bottom: 1px solid #242424; border-right: 1px solid #353535; border-left: 1px solid #353535}' # noqa 'QToolTip {color: black; background-color: #ffffde; border: black solid 1px}' # noqa ) class FlamePushButtonMenu(QtWidgets.QPushButton): """ Custom Qt Flame Menu Push Button Widget To use: push_button_menu_options = ['Item 1', 'Item 2', 'Item 3', 'Item 4'] menu_push_button = FlamePushButtonMenu('push_button_name', push_button_menu_options, window) or push_button_menu_options = ['Item 1', 'Item 2', 'Item 3', 'Item 4'] menu_push_button = FlamePushButtonMenu(push_button_menu_options[0], push_button_menu_options, window) """ selection_changed = QtCore.Signal(str) def __init__(self, button_name, menu_options, parent_window, *args, **kwargs): super(FlamePushButtonMenu, self).__init__(*args, **kwargs) self.setParent(parent_window) self.setMinimumHeight(28) self.setMinimumWidth(110) self.setFocusPolicy(QtCore.Qt.NoFocus) self.setStyleSheet( 'QPushButton {color: #9a9a9a; background-color: #24303d; font: 14px "Discreet"}' # noqa 'QPushButton:disabled {color: #747474; background-color: #353535; border-top: 1px solid #444444; border-bottom: 1px solid #242424}' # noqa ) pushbutton_menu = QtWidgets.QMenu(parent_window) pushbutton_menu.setFocusPolicy(QtCore.Qt.NoFocus) pushbutton_menu.setStyleSheet( 'QMenu {color: #9a9a9a; background-color:#24303d; font: 14px "Discreet"}' # noqa 'QMenu::item:selected {color: #d9d9d9; background-color: #3a4551}' ) self._pushbutton_menu = pushbutton_menu self.setMenu(pushbutton_menu) self.set_menu_options(menu_options, button_name) def set_menu_options(self, menu_options, current_option=None): self._pushbutton_menu.clear() current_option = current_option or menu_options[0] for option in menu_options: action = self._pushbutton_menu.addAction(option) action.triggered.connect(self._on_action_trigger) if current_option is not None: self.setText(current_option) def _on_action_trigger(self): action = self.sender() self.setText(action.text()) self.selection_changed.emit(action.text()) ================================================ FILE: openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py ================================================ from __future__ import print_function import os import sys # only testing dependency for nested modules in package import six # noqa SCRIPT_DIR = os.path.dirname(__file__) PACKAGE_DIR = os.path.join(SCRIPT_DIR, "modules") sys.path.append(PACKAGE_DIR) def flame_panel_executor(selection): if "panel_app" in sys.modules.keys(): print("panel_app module is already loaded") del sys.modules["panel_app"] import panel_app reload(panel_app) # noqa print("panel_app module removed from sys.modules") panel_app.FlameBabyPublisherPanel(selection) def scope_sequence(selection): import flame return any(isinstance(item, flame.PySequence) for item in selection) def get_media_panel_custom_ui_actions(): return [ { "name": "OpenPype: Baby-publisher", "actions": [ { "name": "Create Shots", "isVisible": scope_sequence, "execute": flame_panel_executor } ] } ] ================================================ FILE: openpype/hosts/flame/startup/openpype_in_flame.py ================================================ from __future__ import print_function import sys from qtpy import QtWidgets from pprint import pformat import atexit import openpype.hosts.flame.api as opfapi from openpype.pipeline import ( install_host, registered_host, ) def openpype_install(): """Registering OpenPype in context """ install_host(opfapi) print("Registered host: {}".format(registered_host())) # Exception handler def exeption_handler(exctype, value, _traceback): """Exception handler for improving UX Args: exctype (str): type of exception value (str): exception value tb (str): traceback to show """ import traceback msg = "OpenPype: Python exception {} in {}".format(value, exctype) mbox = QtWidgets.QMessageBox() mbox.setText(msg) mbox.setDetailedText( pformat(traceback.format_exception(exctype, value, _traceback))) mbox.setStyleSheet('QLabel{min-width: 800px;}') mbox.exec_() sys.__excepthook__(exctype, value, _traceback) # add exception handler into sys module sys.excepthook = exeption_handler # register clean up logic to be called at Flame exit def cleanup(): """Cleaning up Flame framework context """ if opfapi.CTX.flame_apps: print('`{}` cleaning up flame_apps:\n {}\n'.format( __file__, pformat(opfapi.CTX.flame_apps))) while len(opfapi.CTX.flame_apps): app = opfapi.CTX.flame_apps.pop() print('`{}` removing : {}'.format(__file__, app.name)) del app opfapi.CTX.flame_apps = [] if opfapi.CTX.app_framework: print('openpype\t: {} cleaning up'.format( opfapi.CTX.app_framework.bundle_name) ) opfapi.CTX.app_framework.save_prefs() opfapi.CTX.app_framework = None atexit.register(cleanup) def load_apps(): """Load available flame_apps into Flame framework """ opfapi.CTX.flame_apps.append( opfapi.FlameMenuProjectConnect(opfapi.CTX.app_framework)) opfapi.CTX.flame_apps.append( opfapi.FlameMenuTimeline(opfapi.CTX.app_framework)) opfapi.CTX.flame_apps.append( opfapi.FlameMenuUniversal(opfapi.CTX.app_framework)) opfapi.CTX.app_framework.log.info("Apps are loaded") def project_changed_dict(info): """Hook for project change action Args: info (str): info text """ cleanup() def app_initialized(parent=None): """Inicialization of Framework Args: parent (obj, optional): Parent object. Defaults to None. """ opfapi.CTX.app_framework = opfapi.FlameAppFramework() print("{} initializing".format( opfapi.CTX.app_framework.bundle_name)) load_apps() """ Initialisation of the hook is starting from here First it needs to test if it can import the flame module. This will happen only in case a project has been loaded. Then `app_initialized` will load main Framework which will load all menu objects as flame_apps. """ try: import flame # noqa app_initialized(parent=None) except ImportError: print("!!!! not able to import flame module !!!!") def rescan_hooks(): import flame # noqa flame.execute_shortcut('Rescan Python Hooks') def _build_app_menu(app_name): """Flame menu object generator Args: app_name (str): name of menu object app Returns: list: menu object """ menu = [] # first find the relative appname app = None for _app in opfapi.CTX.flame_apps: if _app.__class__.__name__ == app_name: app = _app if app: menu.append(app.build_menu()) if opfapi.CTX.app_framework: menu_auto_refresh = opfapi.CTX.app_framework.prefs_global.get( 'menu_auto_refresh', {}) if menu_auto_refresh.get('timeline_menu', True): try: import flame # noqa flame.schedule_idle_event(rescan_hooks) except ImportError: print("!-!!! not able to import flame module !!!!") return menu """ Flame hooks are starting here """ def project_saved(project_name, save_time, is_auto_save): """Hook to activate when project is saved Args: project_name (str): name of project save_time (str): time when it was saved is_auto_save (bool): autosave is on or off """ if opfapi.CTX.app_framework: opfapi.CTX.app_framework.save_prefs() def get_main_menu_custom_ui_actions(): """Hook to create submenu in start menu Returns: list: menu object """ # install openpype and the host openpype_install() return _build_app_menu("FlameMenuProjectConnect") def get_timeline_custom_ui_actions(): """Hook to create submenu in timeline Returns: list: menu object """ # install openpype and the host openpype_install() return _build_app_menu("FlameMenuTimeline") def get_batch_custom_ui_actions(): """Hook to create submenu in batch Returns: list: menu object """ # install openpype and the host openpype_install() return _build_app_menu("FlameMenuUniversal") def get_media_panel_custom_ui_actions(): """Hook to create submenu in desktop Returns: list: menu object """ # install openpype and the host openpype_install() return _build_app_menu("FlameMenuUniversal") ================================================ FILE: openpype/hosts/fusion/__init__.py ================================================ from .addon import ( get_fusion_version, FusionAddon, FUSION_HOST_DIR, FUSION_VERSIONS_DICT, ) __all__ = ( "get_fusion_version", "FusionAddon", "FUSION_HOST_DIR", "FUSION_VERSIONS_DICT", ) ================================================ FILE: openpype/hosts/fusion/addon.py ================================================ import os import re from openpype.modules import OpenPypeModule, IHostAddon from openpype.lib import Logger FUSION_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) # FUSION_VERSIONS_DICT is used by the pre-launch hooks # The keys correspond to all currently supported Fusion versions # Each value is a list of corresponding Python home variables and a profile # number, which is used by the profile hook to set Fusion profile variables. FUSION_VERSIONS_DICT = { 9: ("FUSION_PYTHON36_HOME", 9), 16: ("FUSION16_PYTHON36_HOME", 16), 17: ("FUSION16_PYTHON36_HOME", 16), 18: ("FUSION_PYTHON3_HOME", 16), } def get_fusion_version(app_name): """ The function is triggered by the prelaunch hooks to get the fusion version. `app_name` is obtained by prelaunch hooks from the `launch_context.env.get("AVALON_APP_NAME")`. To get a correct Fusion version, a version number should be present in the `applications/fusion/variants` key of the Blackmagic Fusion Application Settings. """ log = Logger.get_logger(__name__) if not app_name: return app_version_candidates = re.findall(r"\d+", app_name) if not app_version_candidates: return for app_version in app_version_candidates: if int(app_version) in FUSION_VERSIONS_DICT: return int(app_version) else: log.info( "Unsupported Fusion version: {app_version}".format( app_version=app_version ) ) class FusionAddon(OpenPypeModule, IHostAddon): name = "fusion" host_name = "fusion" def initialize(self, module_settings): self.enabled = True def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] return [os.path.join(FUSION_HOST_DIR, "hooks")] def add_implementation_envs(self, env, app): # Set default values if are not already set via settings defaults = {"OPENPYPE_LOG_NO_COLORS": "Yes"} for key, value in defaults.items(): if not env.get(key): env[key] = value def get_workfile_extensions(self): return [".comp"] ================================================ FILE: openpype/hosts/fusion/api/__init__.py ================================================ from .pipeline import ( FusionHost, ls, imprint_container, parse_container ) from .lib import ( maintained_selection, update_frame_range, set_asset_framerange, get_current_comp, get_bmd_library, comp_lock_and_undo_chunk ) from .menu import launch_openpype_menu __all__ = [ # pipeline "FusionHost", "ls", "imprint_container", "parse_container", # lib "maintained_selection", "update_frame_range", "set_asset_framerange", "get_current_comp", "get_bmd_library", "comp_lock_and_undo_chunk", # menu "launch_openpype_menu", ] ================================================ FILE: openpype/hosts/fusion/api/action.py ================================================ import pyblish.api from openpype.hosts.fusion.api.lib import get_current_comp from openpype.pipeline.publish import get_errored_instances_from_context class SelectInvalidAction(pyblish.api.Action): """Select invalid nodes in Fusion when plug-in failed. To retrieve the invalid nodes this assumes a static `get_invalid()` method is available on the plugin. """ label = "Select invalid" on = "failed" # This action is only available on a failed plug-in icon = "search" # Icon from Awesome Icon def process(self, context, plugin): errored_instances = get_errored_instances_from_context( context, plugin=plugin, ) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes..") invalid = list() for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: if isinstance(invalid_nodes, (list, tuple)): invalid.extend(invalid_nodes) else: self.log.warning( "Plug-in returned to be invalid, " "but has no selectable nodes." ) if not invalid: # Assume relevant comp is current comp and clear selection self.log.info("No invalid tools found.") comp = get_current_comp() flow = comp.CurrentFrame.FlowView flow.Select() # No args equals clearing selection return # Assume a single comp first_tool = invalid[0] comp = first_tool.Comp() flow = comp.CurrentFrame.FlowView flow.Select() # No args equals clearing selection names = set() for tool in invalid: flow.Select(tool, True) comp.SetActiveTool(tool) names.add(tool.Name) self.log.info( "Selecting invalid tools: %s" % ", ".join(sorted(names)) ) ================================================ FILE: openpype/hosts/fusion/api/lib.py ================================================ import os import sys import re import contextlib from openpype.lib import Logger from openpype.client import ( get_asset_by_name, get_subset_by_name, get_last_version_by_subset_id, get_representation_by_id, get_representation_by_name, get_representation_parents, ) from openpype.pipeline import ( switch_container, get_current_project_name, ) from openpype.pipeline.context_tools import get_current_project_asset self = sys.modules[__name__] self._project = None def update_frame_range(start, end, comp=None, set_render_range=True, handle_start=0, handle_end=0): """Set Fusion comp's start and end frame range Args: start (float, int): start frame end (float, int): end frame comp (object, Optional): comp object from fusion set_render_range (bool, Optional): When True this will also set the composition's render start and end frame. handle_start (float, int, Optional): frame handles before start frame handle_end (float, int, Optional): frame handles after end frame Returns: None """ if not comp: comp = get_current_comp() # Convert any potential none type to zero handle_start = handle_start or 0 handle_end = handle_end or 0 attrs = { "COMPN_GlobalStart": start - handle_start, "COMPN_GlobalEnd": end + handle_end } # set frame range if set_render_range: attrs.update({ "COMPN_RenderStart": start, "COMPN_RenderEnd": end }) with comp_lock_and_undo_chunk(comp): comp.SetAttrs(attrs) def set_asset_framerange(): """Set Comp's frame range based on current asset""" asset_doc = get_current_project_asset() start = asset_doc["data"]["frameStart"] end = asset_doc["data"]["frameEnd"] handle_start = asset_doc["data"]["handleStart"] handle_end = asset_doc["data"]["handleEnd"] update_frame_range(start, end, set_render_range=True, handle_start=handle_start, handle_end=handle_end) def set_asset_resolution(): """Set Comp's resolution width x height default based on current asset""" asset_doc = get_current_project_asset() width = asset_doc["data"]["resolutionWidth"] height = asset_doc["data"]["resolutionHeight"] comp = get_current_comp() print("Setting comp frame format resolution to {}x{}".format(width, height)) comp.SetPrefs({ "Comp.FrameFormat.Width": width, "Comp.FrameFormat.Height": height, }) def validate_comp_prefs(comp=None, force_repair=False): """Validate current comp defaults with asset settings. Validates fps, resolutionWidth, resolutionHeight, aspectRatio. This does *not* validate frameStart, frameEnd, handleStart and handleEnd. """ if comp is None: comp = get_current_comp() log = Logger.get_logger("validate_comp_prefs") fields = [ "name", "data.fps", "data.resolutionWidth", "data.resolutionHeight", "data.pixelAspect" ] asset_doc = get_current_project_asset(fields=fields) asset_data = asset_doc["data"] comp_frame_format_prefs = comp.GetPrefs("Comp.FrameFormat") # Pixel aspect ratio in Fusion is set as AspectX and AspectY so we convert # the data to something that is more sensible to Fusion asset_data["pixelAspectX"] = asset_data.pop("pixelAspect") asset_data["pixelAspectY"] = 1.0 validations = [ ("fps", "Rate", "FPS"), ("resolutionWidth", "Width", "Resolution Width"), ("resolutionHeight", "Height", "Resolution Height"), ("pixelAspectX", "AspectX", "Pixel Aspect Ratio X"), ("pixelAspectY", "AspectY", "Pixel Aspect Ratio Y") ] invalid = [] for key, comp_key, label in validations: asset_value = asset_data[key] comp_value = comp_frame_format_prefs.get(comp_key) if asset_value != comp_value: invalid_msg = "{} {} should be {}".format(label, comp_value, asset_value) invalid.append(invalid_msg) if not force_repair: # Do not log warning if we force repair anyway log.warning( "Comp {pref} {value} does not match asset " "'{asset_name}' {pref} {asset_value}".format( pref=label, value=comp_value, asset_name=asset_doc["name"], asset_value=asset_value) ) if invalid: def _on_repair(): attributes = dict() for key, comp_key, _label in validations: value = asset_data[key] comp_key_full = "Comp.FrameFormat.{}".format(comp_key) attributes[comp_key_full] = value comp.SetPrefs(attributes) if force_repair: log.info("Applying default Comp preferences..") _on_repair() return from . import menu from openpype.widgets import popup from openpype.style import load_stylesheet dialog = popup.Popup(parent=menu.menu) dialog.setWindowTitle("Fusion comp has invalid configuration") msg = "Comp preferences mismatches '{}'".format(asset_doc["name"]) msg += "\n" + "\n".join(invalid) dialog.setMessage(msg) dialog.setButtonText("Repair") dialog.on_clicked.connect(_on_repair) dialog.show() dialog.raise_() dialog.activateWindow() dialog.setStyleSheet(load_stylesheet()) @contextlib.contextmanager def maintained_selection(comp=None): """Reset comp selection from before the context after the context""" if comp is None: comp = get_current_comp() previous_selection = comp.GetToolList(True).values() try: yield finally: flow = comp.CurrentFrame.FlowView flow.Select() # No args equals clearing selection if previous_selection: for tool in previous_selection: flow.Select(tool, True) @contextlib.contextmanager def maintained_comp_range(comp=None, global_start=True, global_end=True, render_start=True, render_end=True): """Reset comp frame ranges from before the context after the context""" if comp is None: comp = get_current_comp() comp_attrs = comp.GetAttrs() preserve_attrs = {} if global_start: preserve_attrs["COMPN_GlobalStart"] = comp_attrs["COMPN_GlobalStart"] if global_end: preserve_attrs["COMPN_GlobalEnd"] = comp_attrs["COMPN_GlobalEnd"] if render_start: preserve_attrs["COMPN_RenderStart"] = comp_attrs["COMPN_RenderStart"] if render_end: preserve_attrs["COMPN_RenderEnd"] = comp_attrs["COMPN_RenderEnd"] try: yield finally: comp.SetAttrs(preserve_attrs) def get_frame_path(path): """Get filename for the Fusion Saver with padded number as '#' >>> get_frame_path("C:/test.exr") ('C:/test', 4, '.exr') >>> get_frame_path("filename.00.tif") ('filename.', 2, '.tif') >>> get_frame_path("foobar35.tif") ('foobar', 2, '.tif') Args: path (str): The path to render to. Returns: tuple: head, padding, tail (extension) """ filename, ext = os.path.splitext(path) # Find a final number group match = re.match('.*?([0-9]+)$', filename) if match: padding = len(match.group(1)) # remove number from end since fusion # will swap it with the frame number filename = filename[:-padding] else: padding = 4 # default Fusion padding return filename, padding, ext def get_fusion_module(): """Get current Fusion instance""" fusion = getattr(sys.modules["__main__"], "fusion", None) return fusion def get_bmd_library(): """Get bmd library""" bmd = getattr(sys.modules["__main__"], "bmd", None) return bmd def get_current_comp(): """Get current comp in this session""" fusion = get_fusion_module() if fusion is not None: comp = fusion.CurrentComp return comp @contextlib.contextmanager def comp_lock_and_undo_chunk( comp, undo_queue_name="Script CMD", keep_undo=True, ): """Lock comp and open an undo chunk during the context""" try: comp.Lock() comp.StartUndo(undo_queue_name) yield finally: comp.Unlock() comp.EndUndo(keep_undo) ================================================ FILE: openpype/hosts/fusion/api/menu.py ================================================ import os import sys from qtpy import QtWidgets, QtCore, QtGui from openpype.tools.utils import host_tools from openpype.style import load_stylesheet from openpype.lib import register_event_callback from openpype.hosts.fusion.scripts import ( duplicate_with_inputs, ) from openpype.hosts.fusion.api.lib import ( set_asset_framerange, set_asset_resolution, ) from openpype.pipeline import get_current_asset_name from openpype.resources import get_openpype_icon_filepath from openpype.tools.utils import get_qt_app from .pipeline import FusionEventHandler from .pulse import FusionPulse MENU_LABEL = os.environ["AVALON_LABEL"] self = sys.modules[__name__] self.menu = None class OpenPypeMenu(QtWidgets.QWidget): def __init__(self, *args, **kwargs): super(OpenPypeMenu, self).__init__(*args, **kwargs) self.setObjectName(f"{MENU_LABEL}Menu") icon_path = get_openpype_icon_filepath() icon = QtGui.QIcon(icon_path) self.setWindowIcon(icon) self.setWindowFlags( QtCore.Qt.Window | QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint | QtCore.Qt.WindowMinimizeButtonHint | QtCore.Qt.WindowCloseButtonHint | QtCore.Qt.WindowStaysOnTopHint ) self.render_mode_widget = None self.setWindowTitle(MENU_LABEL) asset_label = QtWidgets.QLabel("Context", self) asset_label.setStyleSheet( """QLabel { font-size: 14px; font-weight: 600; color: #5f9fb8; }""" ) asset_label.setAlignment(QtCore.Qt.AlignHCenter) workfiles_btn = QtWidgets.QPushButton("Workfiles...", self) create_btn = QtWidgets.QPushButton("Create...", self) load_btn = QtWidgets.QPushButton("Load...", self) publish_btn = QtWidgets.QPushButton("Publish...", self) manager_btn = QtWidgets.QPushButton("Manage...", self) libload_btn = QtWidgets.QPushButton("Library...", self) set_framerange_btn = QtWidgets.QPushButton("Set Frame Range", self) set_resolution_btn = QtWidgets.QPushButton("Set Resolution", self) duplicate_with_inputs_btn = QtWidgets.QPushButton( "Duplicate with input connections", self ) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(10, 20, 10, 20) layout.addWidget(asset_label) layout.addSpacing(20) layout.addWidget(workfiles_btn) layout.addSpacing(20) layout.addWidget(create_btn) layout.addWidget(load_btn) layout.addWidget(publish_btn) layout.addWidget(manager_btn) layout.addSpacing(20) layout.addWidget(libload_btn) layout.addSpacing(20) layout.addWidget(set_framerange_btn) layout.addWidget(set_resolution_btn) layout.addSpacing(20) layout.addWidget(duplicate_with_inputs_btn) self.setLayout(layout) # Store reference so we can update the label self.asset_label = asset_label workfiles_btn.clicked.connect(self.on_workfile_clicked) create_btn.clicked.connect(self.on_create_clicked) publish_btn.clicked.connect(self.on_publish_clicked) load_btn.clicked.connect(self.on_load_clicked) manager_btn.clicked.connect(self.on_manager_clicked) libload_btn.clicked.connect(self.on_libload_clicked) duplicate_with_inputs_btn.clicked.connect( self.on_duplicate_with_inputs_clicked ) set_resolution_btn.clicked.connect(self.on_set_resolution_clicked) set_framerange_btn.clicked.connect(self.on_set_framerange_clicked) self._callbacks = [] self.register_callback("taskChanged", self.on_task_changed) self.on_task_changed() # Force close current process if Fusion is closed self._pulse = FusionPulse(parent=self) self._pulse.start() # Detect Fusion events as OpenPype events self._event_handler = FusionEventHandler(parent=self) self._event_handler.start() def on_task_changed(self): # Update current context label label = get_current_asset_name() self.asset_label.setText(label) def register_callback(self, name, fn): # Create a wrapper callback that we only store # for as long as we want it to persist as callback def _callback(*args): fn() self._callbacks.append(_callback) register_event_callback(name, _callback) def deregister_all_callbacks(self): self._callbacks[:] = [] def on_workfile_clicked(self): host_tools.show_workfiles() def on_create_clicked(self): host_tools.show_publisher(tab="create") def on_publish_clicked(self): host_tools.show_publisher(tab="publish") def on_load_clicked(self): host_tools.show_loader(use_context=True) def on_manager_clicked(self): host_tools.show_scene_inventory() def on_libload_clicked(self): host_tools.show_library_loader() def on_duplicate_with_inputs_clicked(self): duplicate_with_inputs.duplicate_with_input_connections() def on_set_resolution_clicked(self): set_asset_resolution() def on_set_framerange_clicked(self): set_asset_framerange() def launch_openpype_menu(): app = get_qt_app() pype_menu = OpenPypeMenu() stylesheet = load_stylesheet() pype_menu.setStyleSheet(stylesheet) pype_menu.show() self.menu = pype_menu result = app.exec_() print("Shutting down..") sys.exit(result) ================================================ FILE: openpype/hosts/fusion/api/pipeline.py ================================================ """ Basic avalon integration """ import os import sys import logging import contextlib import pyblish.api from qtpy import QtCore from openpype.lib import ( Logger, register_event_callback, emit_event ) from openpype.pipeline import ( register_loader_plugin_path, register_creator_plugin_path, register_inventory_action_path, AVALON_CONTAINER_ID, ) from openpype.pipeline.load import any_outdated_containers from openpype.hosts.fusion import FUSION_HOST_DIR from openpype.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost from openpype.tools.utils import host_tools from .lib import ( get_current_comp, comp_lock_and_undo_chunk, validate_comp_prefs ) log = Logger.get_logger(__name__) PLUGINS_DIR = os.path.join(FUSION_HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") class FusionLogHandler(logging.Handler): # Keep a reference to fusion's Print function (Remote Object) _print = None @property def print(self): if self._print is not None: # Use cached return self._print _print = getattr(sys.modules["__main__"], "fusion").Print if _print is None: # Backwards compatibility: Print method on Fusion instance was # added around Fusion 17.4 and wasn't available on PyRemote Object # before _print = get_current_comp().Print self._print = _print return _print def emit(self, record): entry = self.format(record) self.print(entry) class FusionHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): name = "fusion" def install(self): """Install fusion-specific functionality of OpenPype. This is where you install menus and register families, data and loaders into fusion. It is called automatically when installing via `openpype.pipeline.install_host(openpype.hosts.fusion.api)` See the Maya equivalent for inspiration on how to implement this. """ # Remove all handlers associated with the root logger object, because # that one always logs as "warnings" incorrectly. for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) # Attach default logging handler that prints to active comp logger = logging.getLogger() formatter = logging.Formatter(fmt="%(message)s\n") handler = FusionLogHandler() handler.setFormatter(formatter) logger.addHandler(handler) logger.setLevel(logging.DEBUG) pyblish.api.register_host("fusion") pyblish.api.register_plugin_path(PUBLISH_PATH) log.info("Registering Fusion plug-ins..") register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) register_inventory_action_path(INVENTORY_PATH) # Register events register_event_callback("open", on_after_open) register_event_callback("save", on_save) register_event_callback("new", on_new) # region workfile io api def has_unsaved_changes(self): comp = get_current_comp() return comp.GetAttrs()["COMPB_Modified"] def get_workfile_extensions(self): return [".comp"] def save_workfile(self, dst_path=None): comp = get_current_comp() comp.Save(dst_path) def open_workfile(self, filepath): # Hack to get fusion, see # openpype.hosts.fusion.api.pipeline.get_current_comp() fusion = getattr(sys.modules["__main__"], "fusion", None) return fusion.LoadComp(filepath) def get_current_workfile(self): comp = get_current_comp() current_filepath = comp.GetAttrs()["COMPS_FileName"] if not current_filepath: return None return current_filepath def work_root(self, session): work_dir = session["AVALON_WORKDIR"] scene_dir = session.get("AVALON_SCENEDIR") if scene_dir: return os.path.join(work_dir, scene_dir) else: return work_dir # endregion @contextlib.contextmanager def maintained_selection(self): from .lib import maintained_selection return maintained_selection() def get_containers(self): return ls() def update_context_data(self, data, changes): comp = get_current_comp() comp.SetData("openpype", data) def get_context_data(self): comp = get_current_comp() return comp.GetData("openpype") or {} def on_new(event): comp = event["Rets"]["comp"] validate_comp_prefs(comp, force_repair=True) def on_save(event): comp = event["sender"] validate_comp_prefs(comp) def on_after_open(event): comp = event["sender"] validate_comp_prefs(comp) if any_outdated_containers(): log.warning("Scene has outdated content.") # Find OpenPype menu to attach to from . import menu def _on_show_scene_inventory(): # ensure that comp is active frame = comp.CurrentFrame if not frame: print("Comp is closed, skipping show scene inventory") return frame.ActivateFrame() # raise comp window host_tools.show_scene_inventory() from openpype.widgets import popup from openpype.style import load_stylesheet dialog = popup.Popup(parent=menu.menu) dialog.setWindowTitle("Fusion comp has outdated content") dialog.setMessage("There are outdated containers in " "your Fusion comp.") dialog.on_clicked.connect(_on_show_scene_inventory) dialog.show() dialog.raise_() dialog.activateWindow() dialog.setStyleSheet(load_stylesheet()) def ls(): """List containers from active Fusion scene This is the host-equivalent of api.ls(), but instead of listing assets on disk, it lists assets already loaded in Fusion; once loaded they are called 'containers' Yields: dict: container """ comp = get_current_comp() tools = comp.GetToolList(False).values() for tool in tools: container = parse_container(tool) if container: yield container def imprint_container(tool, name, namespace, context, loader=None): """Imprint a Loader with metadata Containerisation enables a tracking of version, author and origin for loaded assets. Arguments: tool (object): The node in Fusion to imprint as container, usually a Loader. name (str): Name of resulting assembly namespace (str): Namespace under which to host container context (dict): Asset information loader (str, optional): Name of loader used to produce this container. Returns: None """ data = [ ("schema", "openpype:container-2.0"), ("id", AVALON_CONTAINER_ID), ("name", str(name)), ("namespace", str(namespace)), ("loader", str(loader)), ("representation", str(context["representation"]["_id"])), ] for key, value in data: tool.SetData("avalon.{}".format(key), value) def parse_container(tool): """Returns imprinted container data of a tool This reads the imprinted data from `imprint_container`. """ data = tool.GetData('avalon') if not isinstance(data, dict): return # If not all required data return the empty container required = ['schema', 'id', 'name', 'namespace', 'loader', 'representation'] if not all(key in data for key in required): return container = {key: data[key] for key in required} # Store the tool's name container["objectName"] = tool.Name # Store reference to the tool object container["_tool"] = tool return container class FusionEventThread(QtCore.QThread): """QThread which will periodically ping Fusion app for any events. The fusion.UIManager must be set up to be notified of events before they'll be reported by this thread, for example: fusion.UIManager.AddNotify("Comp_Save", None) """ on_event = QtCore.Signal(dict) def run(self): app = getattr(sys.modules["__main__"], "app", None) if app is None: # No Fusion app found return # As optimization store the GetEvent method directly because every # getattr of UIManager.GetEvent tries to resolve the Remote Function # through the PyRemoteObject get_event = app.UIManager.GetEvent delay = int(os.environ.get("OPENPYPE_FUSION_CALLBACK_INTERVAL", 1000)) while True: if self.isInterruptionRequested(): return # Process all events that have been queued up until now while True: event = get_event(False) if not event: break self.on_event.emit(event) # Wait some time before processing events again # to not keep blocking the UI self.msleep(delay) class FusionEventHandler(QtCore.QObject): """Emits OpenPype events based on Fusion events captured in a QThread. This will emit the following OpenPype events based on Fusion actions: save: Comp_Save, Comp_SaveAs open: Comp_Opened new: Comp_New To use this you can attach it to you Qt UI so it runs in the background. E.g. >>> handler = FusionEventHandler(parent=window) >>> handler.start() """ ACTION_IDS = [ "Comp_Save", "Comp_SaveAs", "Comp_New", "Comp_Opened" ] def __init__(self, parent=None): super(FusionEventHandler, self).__init__(parent=parent) # Set up Fusion event callbacks fusion = getattr(sys.modules["__main__"], "fusion", None) ui = fusion.UIManager # Add notifications for the ones we want to listen to notifiers = [] for action_id in self.ACTION_IDS: notifier = ui.AddNotify(action_id, None) notifiers.append(notifier) # TODO: Not entirely sure whether these must be kept to avoid # garbage collection self._notifiers = notifiers self._event_thread = FusionEventThread(parent=self) self._event_thread.on_event.connect(self._on_event) def start(self): self._event_thread.start() def stop(self): self._event_thread.stop() def _on_event(self, event): """Handle Fusion events to emit OpenPype events""" if not event: return what = event["what"] # Comp Save if what in {"Comp_Save", "Comp_SaveAs"}: if not event["Rets"].get("success"): # If the Save action is cancelled it will still emit an # event but with "success": False so we ignore those cases return # Comp was saved emit_event("save", data=event) return # Comp New elif what in {"Comp_New"}: emit_event("new", data=event) # Comp Opened elif what in {"Comp_Opened"}: emit_event("open", data=event) ================================================ FILE: openpype/hosts/fusion/api/plugin.py ================================================ from copy import deepcopy import os from openpype.hosts.fusion.api import ( get_current_comp, comp_lock_and_undo_chunk, ) from openpype.lib import ( BoolDef, EnumDef, ) from openpype.pipeline import ( legacy_io, Creator, CreatedInstance ) class GenericCreateSaver(Creator): default_variants = ["Main", "Mask"] description = "Fusion Saver to generate image sequence" icon = "fa5.eye" instance_attributes = [ "reviewable" ] settings_category = "fusion" image_format = "exr" # TODO: This should be renamed together with Nuke so it is aligned temp_rendering_path_template = ( "{workdir}/renders/fusion/{subset}/{subset}.{frame}.{ext}") def create(self, subset_name, instance_data, pre_create_data): self.pass_pre_attributes_to_instance(instance_data, pre_create_data) instance = CreatedInstance( family=self.family, subset_name=subset_name, data=instance_data, creator=self, ) data = instance.data_to_store() comp = get_current_comp() with comp_lock_and_undo_chunk(comp): args = (-32768, -32768) # Magical position numbers saver = comp.AddTool("Saver", *args) self._update_tool_with_data(saver, data=data) # Register the CreatedInstance self._imprint(saver, data) # Insert the transient data instance.transient_data["tool"] = saver self._add_instance_to_context(instance) return instance def collect_instances(self): comp = get_current_comp() tools = comp.GetToolList(False, "Saver").values() for tool in tools: data = self.get_managed_tool_data(tool) if not data: continue # Add instance created_instance = CreatedInstance.from_existing(data, self) # Collect transient data created_instance.transient_data["tool"] = tool self._add_instance_to_context(created_instance) def update_instances(self, update_list): for created_inst, _changes in update_list: new_data = created_inst.data_to_store() tool = created_inst.transient_data["tool"] self._update_tool_with_data(tool, new_data) self._imprint(tool, new_data) def remove_instances(self, instances): for instance in instances: # Remove the tool from the scene tool = instance.transient_data["tool"] if tool: tool.Delete() # Remove the collected CreatedInstance to remove from UI directly self._remove_instance_from_context(instance) def _imprint(self, tool, data): # Save all data in a "openpype.{key}" = value data # Instance id is the tool's name so we don't need to imprint as data data.pop("instance_id", None) active = data.pop("active", None) if active is not None: # Use active value to set the passthrough state tool.SetAttrs({"TOOLB_PassThrough": not active}) for key, value in data.items(): tool.SetData(f"openpype.{key}", value) def _update_tool_with_data(self, tool, data): """Update tool node name and output path based on subset data""" if "subset" not in data: return original_subset = tool.GetData("openpype.subset") original_format = tool.GetData( "openpype.creator_attributes.image_format" ) subset = data["subset"] if ( original_subset != subset or original_format != data["creator_attributes"]["image_format"] ): self._configure_saver_tool(data, tool, subset) def _configure_saver_tool(self, data, tool, subset): formatting_data = deepcopy(data) # get frame padding from anatomy templates frame_padding = self.project_anatomy.templates["frame_padding"] # get output format ext = data["creator_attributes"]["image_format"] # Subset change detected workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"]) formatting_data.update({ "workdir": workdir, "frame": "0" * frame_padding, "ext": ext, "product": { "name": formatting_data["subset"], "type": formatting_data["family"], }, }) # build file path to render filepath = self.temp_rendering_path_template.format(**formatting_data) comp = get_current_comp() tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath)) # Rename tool if tool.Name != subset: print(f"Renaming {tool.Name} -> {subset}") tool.SetAttrs({"TOOLS_Name": subset}) def get_managed_tool_data(self, tool): """Return data of the tool if it matches creator identifier""" data = tool.GetData("openpype") if not isinstance(data, dict): return required = { "id": "pyblish.avalon.instance", "creator_identifier": self.identifier, } for key, value in required.items(): if key not in data or data[key] != value: return # Get active state from the actual tool state attrs = tool.GetAttrs() passthrough = attrs["TOOLB_PassThrough"] data["active"] = not passthrough # Override publisher's UUID generation because tool names are # already unique in Fusion in a comp data["instance_id"] = tool.Name return data def get_instance_attr_defs(self): """Settings for publish page""" return self.get_pre_create_attr_defs() def pass_pre_attributes_to_instance(self, instance_data, pre_create_data): creator_attrs = instance_data["creator_attributes"] = {} for pass_key in pre_create_data.keys(): creator_attrs[pass_key] = pre_create_data[pass_key] def _get_render_target_enum(self): rendering_targets = { "local": "Local machine rendering", "frames": "Use existing frames", } if "farm_rendering" in self.instance_attributes: rendering_targets["farm"] = "Farm rendering" return EnumDef( "render_target", items=rendering_targets, label="Render target" ) def _get_reviewable_bool(self): return BoolDef( "review", default=("reviewable" in self.instance_attributes), label="Review", ) def _get_image_format_enum(self): image_format_options = ["exr", "tga", "tif", "png", "jpg"] return EnumDef( "image_format", items=image_format_options, default=self.image_format, label="Output Image Format", ) ================================================ FILE: openpype/hosts/fusion/api/pulse.py ================================================ import os import sys from qtpy import QtCore class PulseThread(QtCore.QThread): no_response = QtCore.Signal() def __init__(self, parent=None): super(PulseThread, self).__init__(parent=parent) def run(self): app = getattr(sys.modules["__main__"], "app", None) # Interval in milliseconds interval = os.environ.get("OPENPYPE_FUSION_PULSE_INTERVAL", 1000) while True: if self.isInterruptionRequested(): return # We don't need to call Test because PyRemoteObject of the app # will actually fail to even resolve the Test function if it has # gone down. So we can actually already just check by confirming # the method is still getting resolved. (Optimization) if app.Test is None: self.no_response.emit() self.msleep(interval) class FusionPulse(QtCore.QObject): """A Timer that checks whether host app is still alive. This checks whether the Fusion process is still active at a certain interval. This is useful due to how Fusion runs its scripts. Each script runs in its own environment and process (a `fusionscript` process each). If Fusion would go down and we have a UI process running at the same time then it can happen that the `fusionscript.exe` will remain running in the background in limbo due to e.g. a Qt interface's QApplication that keeps running infinitely. Warning: When the host is not detected this will automatically exit the current process. """ def __init__(self, parent=None): super(FusionPulse, self).__init__(parent=parent) self._thread = PulseThread(parent=self) self._thread.no_response.connect(self.on_no_response) def on_no_response(self): print("Pulse detected no response from Fusion..") sys.exit(1) def start(self): self._thread.start() def stop(self): self._thread.requestInterruption() ================================================ FILE: openpype/hosts/fusion/deploy/MenuScripts/README.md ================================================ ### OpenPype deploy MenuScripts Note that this `MenuScripts` is not an official Fusion folder. OpenPype only uses this folder in `{fusion}/deploy/` to trigger the OpenPype menu actions. They are used in the actions defined in `.fu` files in `{fusion}/deploy/Config`. ================================================ FILE: openpype/hosts/fusion/deploy/MenuScripts/install_pyside2.py ================================================ # This is just a quick hack for users running Py3 locally but having no # Qt library installed import os import subprocess import importlib try: from qtpy import API_NAME print(f"Qt binding: {API_NAME}") mod = importlib.import_module(API_NAME) print(f"Qt path: {mod.__file__}") print("Qt library found, nothing to do..") except ImportError: print("Assuming no Qt library is installed..") print('Installing PySide2 for Python 3.6: ' f'{os.environ["FUSION16_PYTHON36_HOME"]}') # Get full path to python executable exe = "python.exe" if os.name == 'nt' else "python" python = os.path.join(os.environ["FUSION16_PYTHON36_HOME"], exe) assert os.path.exists(python), f"Python doesn't exist: {python}" # Do python -m pip install PySide2 args = [python, "-m", "pip", "install", "PySide2"] print(f"Args: {args}") subprocess.Popen(args) ================================================ FILE: openpype/hosts/fusion/deploy/MenuScripts/launch_menu.py ================================================ import os import sys if sys.version_info < (3, 7): # hack to handle discrepancy between distributed libraries and Python 3.6 # mostly because wrong version of urllib3 # TODO remove when not necessary from openpype import PACKAGE_DIR FUSION_HOST_DIR = os.path.join(PACKAGE_DIR, "hosts", "fusion") vendor_path = os.path.join(FUSION_HOST_DIR, "vendor") if vendor_path not in sys.path: sys.path.insert(0, vendor_path) print(f"Added vendorized libraries from {vendor_path}") from openpype.lib import Logger from openpype.pipeline import ( install_host, registered_host, ) def main(env): # This script working directory starts in Fusion application folder. # However the contents of that folder can conflict with Qt library dlls # so we make sure to move out of it to avoid DLL Load Failed errors. os.chdir("..") from openpype.hosts.fusion.api import FusionHost from openpype.hosts.fusion.api import menu # activate resolve from pype install_host(FusionHost()) log = Logger.get_logger(__name__) log.info(f"Registered host: {registered_host()}") menu.launch_openpype_menu() # Initiate a QTimer to check if Fusion is still alive every X interval # If Fusion is not found - kill itself # todo(roy): Implement timer that ensures UI doesn't remain when e.g. # Fusion closes down if __name__ == "__main__": result = main(os.environ) sys.exit(not bool(result)) ================================================ FILE: openpype/hosts/fusion/deploy/ayon/Config/menu.fu ================================================ { Action { ID = "AYON_Menu", Category = "AYON", Name = "AYON Menu", Targets = { Composition = { Execute = _Lua [=[ local scriptPath = app:MapPath("AYON:../MenuScripts/launch_menu.py") if bmd.fileexists(scriptPath) == false then print("[AYON Error] Can't run file: " .. scriptPath) else target:RunScript(scriptPath) end ]=], }, }, }, Action { ID = "AYON_Install_PySide2", Category = "AYON", Name = "Install PySide2", Targets = { Composition = { Execute = _Lua [=[ local scriptPath = app:MapPath("AYON:../MenuScripts/install_pyside2.py") if bmd.fileexists(scriptPath) == false then print("[AYON Error] Can't run file: " .. scriptPath) else target:RunScript(scriptPath) end ]=], }, }, }, Menus { Target = "ChildFrame", Before "Help" { Sub "AYON" { "AYON_Menu{}", "_", Sub "Admin" { "AYON_Install_PySide2{}" } } }, }, } ================================================ FILE: openpype/hosts/fusion/deploy/ayon/fusion_shared.prefs ================================================ { Locked = true, Global = { Paths = { Map = { ["AYON:"] = "$(OPENPYPE_FUSION)/deploy/ayon", ["Config:"] = "UserPaths:Config;AYON:Config", ["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts", }, }, Script = { PythonVersion = 3, Python3Forced = true }, UserInterface = { Language = "en_US" }, }, } ================================================ FILE: openpype/hosts/fusion/deploy/openpype/Config/menu.fu ================================================ { Action { ID = "OpenPype_Menu", Category = "OpenPype", Name = "OpenPype Menu", Targets = { Composition = { Execute = _Lua [=[ local scriptPath = app:MapPath("OpenPype:../MenuScripts/launch_menu.py") if bmd.fileexists(scriptPath) == false then print("[OpenPype Error] Can't run file: " .. scriptPath) else target:RunScript(scriptPath) end ]=], }, }, }, Action { ID = "OpenPype_Install_PySide2", Category = "OpenPype", Name = "Install PySide2", Targets = { Composition = { Execute = _Lua [=[ local scriptPath = app:MapPath("OpenPype:../MenuScripts/install_pyside2.py") if bmd.fileexists(scriptPath) == false then print("[OpenPype Error] Can't run file: " .. scriptPath) else target:RunScript(scriptPath) end ]=], }, }, }, Menus { Target = "ChildFrame", Before "Help" { Sub "OpenPype" { "OpenPype_Menu{}", "_", Sub "Admin" { "OpenPype_Install_PySide2{}" } } }, }, } ================================================ FILE: openpype/hosts/fusion/deploy/openpype/fusion_shared.prefs ================================================ { Locked = true, Global = { Paths = { Map = { ["OpenPype:"] = "$(OPENPYPE_FUSION)/deploy/openpype", ["Config:"] = "UserPaths:Config;OpenPype:Config", ["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts", }, }, Script = { PythonVersion = 3, Python3Forced = true }, UserInterface = { Language = "en_US" }, }, } ================================================ FILE: openpype/hosts/fusion/hooks/pre_fusion_profile_hook.py ================================================ import os import shutil import platform from pathlib import Path from openpype import AYON_SERVER_ENABLED from openpype.hosts.fusion import ( FUSION_HOST_DIR, FUSION_VERSIONS_DICT, get_fusion_version, ) from openpype.lib.applications import ( PreLaunchHook, LaunchTypes, ApplicationLaunchFailed, ) class FusionCopyPrefsPrelaunch(PreLaunchHook): """ Prepares local Fusion profile directory, copies existing Fusion profile. This also sets FUSION MasterPrefs variable, which is used to apply Master.prefs file to override some Fusion profile settings to: - enable the OpenPype menu - force Python 3 over Python 2 - force English interface Master.prefs is defined in openpype/hosts/fusion/deploy/fusion_shared.prefs """ app_groups = {"fusion"} order = 2 launch_types = {LaunchTypes.local} def get_fusion_profile_name(self, profile_version) -> str: # Returns 'Default', unless FUSION16_PROFILE is set return os.getenv(f"FUSION{profile_version}_PROFILE", "Default") def get_fusion_profile_dir(self, profile_version) -> Path: # Get FUSION_PROFILE_DIR variable fusion_profile = self.get_fusion_profile_name(profile_version) fusion_var_prefs_dir = os.getenv( f"FUSION{profile_version}_PROFILE_DIR" ) # Check if FUSION_PROFILE_DIR exists if fusion_var_prefs_dir and Path(fusion_var_prefs_dir).is_dir(): fu_prefs_dir = Path(fusion_var_prefs_dir, fusion_profile) self.log.info(f"{fusion_var_prefs_dir} is set to {fu_prefs_dir}") return fu_prefs_dir def get_profile_source(self, profile_version) -> Path: """Get Fusion preferences profile location. See Per-User_Preferences_and_Paths on VFXpedia for reference. """ fusion_profile = self.get_fusion_profile_name(profile_version) profile_source = self.get_fusion_profile_dir(profile_version) if profile_source: return profile_source # otherwise get default location of the profile folder fu_prefs_dir = f"Blackmagic Design/Fusion/Profiles/{fusion_profile}" if platform.system() == "Windows": profile_source = Path(os.getenv("AppData"), fu_prefs_dir) elif platform.system() == "Darwin": profile_source = Path( "~/Library/Application Support/", fu_prefs_dir ).expanduser() elif platform.system() == "Linux": profile_source = Path("~/.fusion", fu_prefs_dir).expanduser() self.log.info( f"Locating source Fusion prefs directory: {profile_source}" ) return profile_source def get_copy_fusion_prefs_settings(self): # Get copy preferences options from the global application settings copy_fusion_settings = self.data["project_settings"]["fusion"].get( "copy_fusion_settings", {} ) if not copy_fusion_settings: self.log.error("Copy prefs settings not found") copy_status = copy_fusion_settings.get("copy_status", False) force_sync = copy_fusion_settings.get("force_sync", False) copy_path = copy_fusion_settings.get("copy_path") or None if copy_path: copy_path = Path(copy_path).expanduser() return copy_status, copy_path, force_sync def copy_fusion_profile( self, copy_from: Path, copy_to: Path, force_sync: bool ) -> None: """On the first Fusion launch copy the contents of Fusion profile directory to the working predefined location. If the Openpype profile folder exists, skip copying, unless re-sync is checked. If the prefs were not copied on the first launch, clean Fusion profile will be created in fu_profile_dir. """ if copy_to.exists() and not force_sync: self.log.info( "Destination Fusion preferences folder already exists: " f"{copy_to} " ) return self.log.info("Starting copying Fusion preferences") self.log.debug(f"force_sync option is set to {force_sync}") try: copy_to.mkdir(exist_ok=True, parents=True) except PermissionError: self.log.warning(f"Creating the folder not permitted at {copy_to}") return if not copy_from.exists(): self.log.warning(f"Fusion preferences not found in {copy_from}") return for file in copy_from.iterdir(): if file.suffix in ( ".prefs", ".def", ".blocklist", ".fu", ".toolbars", ): # convert Path to str to be compatible with Python 3.6+ shutil.copy(str(file), str(copy_to)) self.log.info( f"Successfully copied preferences: {copy_from} to {copy_to}" ) def execute(self): ( copy_status, fu_profile_dir, force_sync, ) = self.get_copy_fusion_prefs_settings() # Get launched application context and return correct app version app_name = self.launch_context.env.get("AVALON_APP_NAME") app_version = get_fusion_version(app_name) if app_version is None: version_names = ", ".join(str(x) for x in FUSION_VERSIONS_DICT) raise ApplicationLaunchFailed( "Unable to detect valid Fusion version number from app " f"name: {app_name}.\nMake sure to include at least a digit " "to indicate the Fusion version like '18'.\n" f"Detectable Fusion versions are: {version_names}" ) _, profile_version = FUSION_VERSIONS_DICT[app_version] fu_profile = self.get_fusion_profile_name(profile_version) # do a copy of Fusion profile if copy_status toggle is enabled if copy_status and fu_profile_dir is not None: profile_source = self.get_profile_source(profile_version) dest_folder = Path(fu_profile_dir, fu_profile) self.copy_fusion_profile(profile_source, dest_folder, force_sync) # Add temporary profile directory variables to customize Fusion # to define where it can read custom scripts and tools from fu_profile_dir_variable = f"FUSION{profile_version}_PROFILE_DIR" self.log.info(f"Setting {fu_profile_dir_variable}: {fu_profile_dir}") self.launch_context.env[fu_profile_dir_variable] = str(fu_profile_dir) # Add custom Fusion Master Prefs and the temporary # profile directory variables to customize Fusion # to define where it can read custom scripts and tools from master_prefs_variable = f"FUSION{profile_version}_MasterPrefs" if AYON_SERVER_ENABLED: master_prefs = Path( FUSION_HOST_DIR, "deploy", "ayon", "fusion_shared.prefs") else: master_prefs = Path( FUSION_HOST_DIR, "deploy", "openpype", "fusion_shared.prefs") self.log.info(f"Setting {master_prefs_variable}: {master_prefs}") self.launch_context.env[master_prefs_variable] = str(master_prefs) ================================================ FILE: openpype/hosts/fusion/hooks/pre_fusion_setup.py ================================================ import os from openpype.lib.applications import ( PreLaunchHook, LaunchTypes, ApplicationLaunchFailed, ) from openpype.hosts.fusion import ( FUSION_HOST_DIR, FUSION_VERSIONS_DICT, get_fusion_version, ) class FusionPrelaunch(PreLaunchHook): """ Prepares OpenPype Fusion environment. Requires correct Python home variable to be defined in the environment settings for Fusion to point at a valid Python 3 build for Fusion. Python3 versions that are supported by Fusion: Fusion 9, 16, 17 : Python 3.6 Fusion 18 : Python 3.6 - 3.10 """ app_groups = {"fusion"} order = 1 launch_types = {LaunchTypes.local} def execute(self): # making sure python 3 is installed at provided path # Py 3.3-3.10 for Fusion 18+ or Py 3.6 for Fu 16-17 app_data = self.launch_context.env.get("AVALON_APP_NAME") app_version = get_fusion_version(app_data) if not app_version: raise ApplicationLaunchFailed( "Fusion version information not found in System settings.\n" "The key field in the 'applications/fusion/variants' should " "consist a number, corresponding to major Fusion version." ) py3_var, _ = FUSION_VERSIONS_DICT[app_version] fusion_python3_home = self.launch_context.env.get(py3_var, "") for path in fusion_python3_home.split(os.pathsep): # Allow defining multiple paths, separated by os.pathsep, # to allow "fallback" to other path. # But make to set only a single path as final variable. py3_dir = os.path.normpath(path) if os.path.isdir(py3_dir): break else: raise ApplicationLaunchFailed( "Python 3 is not installed at the provided path.\n" "Make sure the environment in fusion settings has " "'FUSION_PYTHON3_HOME' set correctly and make sure " "Python 3 is installed in the given path." f"\n\nPYTHON PATH: {fusion_python3_home}" ) self.log.info(f"Setting {py3_var}: '{py3_dir}'...") self.launch_context.env[py3_var] = py3_dir # Fusion 18+ requires FUSION_PYTHON3_HOME to also be on PATH if app_version >= 18: self.launch_context.env["PATH"] += os.pathsep + py3_dir self.launch_context.env[py3_var] = py3_dir # for hook installing PySide2 self.data["fusion_python3_home"] = py3_dir self.log.info(f"Setting OPENPYPE_FUSION: {FUSION_HOST_DIR}") self.launch_context.env["OPENPYPE_FUSION"] = FUSION_HOST_DIR ================================================ FILE: openpype/hosts/fusion/hooks/pre_pyside_install.py ================================================ import os import subprocess import platform import uuid from openpype.lib.applications import PreLaunchHook, LaunchTypes class InstallPySideToFusion(PreLaunchHook): """Automatically installs Qt binding to fusion's python packages. Check if fusion has installed PySide2 and will try to install if not. For pipeline implementation is required to have Qt binding installed in fusion's python packages. """ app_groups = {"fusion"} order = 2 launch_types = {LaunchTypes.local} def execute(self): # Prelaunch hook is not crucial try: settings = self.data["project_settings"][self.host_name] if not settings["hooks"]["InstallPySideToFusion"]["enabled"]: return self.inner_execute() except Exception: self.log.warning( "Processing of {} crashed.".format(self.__class__.__name__), exc_info=True ) def inner_execute(self): self.log.debug("Check for PySide2 installation.") fusion_python3_home = self.data.get("fusion_python3_home") if not fusion_python3_home: self.log.warning("'fusion_python3_home' was not provided. " "Installation of PySide2 not possible") return if platform.system().lower() == "windows": exe_filenames = ["python.exe"] else: exe_filenames = ["python3", "python"] for exe_filename in exe_filenames: python_executable = os.path.join(fusion_python3_home, exe_filename) if os.path.exists(python_executable): break if not os.path.exists(python_executable): self.log.warning( "Couldn't find python executable for fusion. {}".format( python_executable ) ) return # Check if PySide2 is installed and skip if yes if self._is_pyside_installed(python_executable): self.log.debug("Fusion has already installed PySide2.") return self.log.debug("Installing PySide2.") # Install PySide2 in fusion's python if self._windows_require_permissions( os.path.dirname(python_executable)): result = self._install_pyside_windows(python_executable) else: result = self._install_pyside(python_executable) if result: self.log.info("Successfully installed PySide2 module to fusion.") else: self.log.warning("Failed to install PySide2 module to fusion.") def _install_pyside_windows(self, python_executable): """Install PySide2 python module to fusion's python. Installation requires administration rights that's why it is required to use "pywin32" module which can execute command's and ask for administration rights. """ try: import win32api import win32con import win32process import win32event import pywintypes from win32comext.shell.shell import ShellExecuteEx from win32comext.shell import shellcon except Exception: self.log.warning("Couldn't import \"pywin32\" modules") return False try: # Parameters # - use "-m pip" as module pip to install PySide2 and argument # "--ignore-installed" is to force install module to fusion's # site-packages and make sure it is binary compatible parameters = "-m pip install --ignore-installed PySide2" # Execute command and ask for administrator's rights process_info = ShellExecuteEx( nShow=win32con.SW_SHOWNORMAL, fMask=shellcon.SEE_MASK_NOCLOSEPROCESS, lpVerb="runas", lpFile=python_executable, lpParameters=parameters, lpDirectory=os.path.dirname(python_executable) ) process_handle = process_info["hProcess"] win32event.WaitForSingleObject(process_handle, win32event.INFINITE) returncode = win32process.GetExitCodeProcess(process_handle) return returncode == 0 except pywintypes.error: return False def _install_pyside(self, python_executable): """Install PySide2 python module to fusion's python.""" try: # Parameters # - use "-m pip" as module pip to install PySide2 and argument # "--ignore-installed" is to force install module to fusion's # site-packages and make sure it is binary compatible env = dict(os.environ) del env['PYTHONPATH'] args = [ python_executable, "-m", "pip", "install", "--ignore-installed", "PySide2", ] process = subprocess.Popen( args, stdout=subprocess.PIPE, universal_newlines=True, env=env ) process.communicate() return process.returncode == 0 except PermissionError: self.log.warning( "Permission denied with command:" "\"{}\".".format(" ".join(args)) ) except OSError as error: self.log.warning(f"OS error has occurred: \"{error}\".") except subprocess.SubprocessError: pass def _is_pyside_installed(self, python_executable): """Check if PySide2 module is in fusion's pip list.""" args = [python_executable, "-c", "from qtpy import QtWidgets"] process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) _, stderr = process.communicate() stderr = stderr.decode() if stderr: return False return True def _windows_require_permissions(self, dirpath): if platform.system().lower() != "windows": return False try: # Attempt to create a temporary file in the folder temp_file_path = os.path.join(dirpath, uuid.uuid4().hex) with open(temp_file_path, "w"): pass os.remove(temp_file_path) # Clean up temporary file return False except PermissionError: return True except BaseException as exc: print(("Failed to determine if root requires permissions." "Unexpected error: {}").format(exc)) return False ================================================ FILE: openpype/hosts/fusion/plugins/create/create_image_saver.py ================================================ from openpype.lib import NumberDef from openpype.hosts.fusion.api.plugin import GenericCreateSaver from openpype.hosts.fusion.api import get_current_comp class CreateImageSaver(GenericCreateSaver): """Fusion Saver to generate single image. Created to explicitly separate single ('image') or multi frame('render) outputs. This might be temporary creator until 'alias' functionality will be implemented to limit creation of additional product types with similar, but not the same workflows. """ identifier = "io.openpype.creators.fusion.imagesaver" label = "Image (saver)" name = "image" family = "image" description = "Fusion Saver to generate image" default_frame = 0 def get_detail_description(self): return """Fusion Saver to generate single image. This creator is expected for publishing of single frame `image` product type. Artist should provide frame number (integer) to specify which frame should be published. It must be inside of global timeline frame range. Supports local and deadline rendering. Supports selection from predefined set of output file extensions: - exr - tga - png - tif - jpg Created to explicitly separate single frame ('image') or multi frame ('render') outputs. """ def get_pre_create_attr_defs(self): """Settings for create page""" attr_defs = [ self._get_render_target_enum(), self._get_reviewable_bool(), self._get_frame_int(), self._get_image_format_enum(), ] return attr_defs def _get_frame_int(self): return NumberDef( "frame", default=self.default_frame, label="Frame", tooltip="Set frame to be rendered, must be inside of global " "timeline range" ) ================================================ FILE: openpype/hosts/fusion/plugins/create/create_saver.py ================================================ from openpype.lib import EnumDef from openpype.hosts.fusion.api.plugin import GenericCreateSaver class CreateSaver(GenericCreateSaver): """Fusion Saver to generate image sequence of 'render' product type. Original Saver creator targeted for 'render' product type. It uses original not to descriptive name because of values in Settings. """ identifier = "io.openpype.creators.fusion.saver" label = "Render (saver)" name = "render" family = "render" description = "Fusion Saver to generate image sequence" default_frame_range_option = "asset_db" def get_detail_description(self): return """Fusion Saver to generate image sequence. This creator is expected for publishing of image sequences for 'render' product type. (But can publish even single frame 'render'.) Select what should be source of render range: - "Current asset context" - values set on Asset in DB (Ftrack) - "From render in/out" - from node itself - "From composition timeline" - from timeline Supports local and farm rendering. Supports selection from predefined set of output file extensions: - exr - tga - png - tif - jpg """ def get_pre_create_attr_defs(self): """Settings for create page""" attr_defs = [ self._get_render_target_enum(), self._get_reviewable_bool(), self._get_frame_range_enum(), self._get_image_format_enum(), ] return attr_defs def _get_frame_range_enum(self): frame_range_options = { "asset_db": "Current asset context", "render_range": "From render in/out", "comp_range": "From composition timeline", } return EnumDef( "frame_range_source", items=frame_range_options, label="Frame range source", default=self.default_frame_range_option ) ================================================ FILE: openpype/hosts/fusion/plugins/create/create_workfile.py ================================================ from openpype.hosts.fusion.api import ( get_current_comp ) from openpype import AYON_SERVER_ENABLED from openpype.client import get_asset_by_name from openpype.pipeline import ( AutoCreator, CreatedInstance, ) class FusionWorkfileCreator(AutoCreator): identifier = "workfile" family = "workfile" label = "Workfile" icon = "fa5.file" default_variant = "Main" create_allow_context_change = False data_key = "openpype_workfile" def collect_instances(self): comp = get_current_comp() data = comp.GetData(self.data_key) if not data: return instance = CreatedInstance( family=self.family, subset_name=data["subset"], data=data, creator=self ) instance.transient_data["comp"] = comp self._add_instance_to_context(instance) def update_instances(self, update_list): for created_inst, _changes in update_list: comp = created_inst.transient_data["comp"] if not hasattr(comp, "SetData"): # Comp is not alive anymore, likely closed by the user self.log.error("Workfile comp not found for existing instance." " Comp might have been closed in the meantime.") continue # Imprint data into the comp data = created_inst.data_to_store() comp.SetData(self.data_key, data) def create(self, options=None): comp = get_current_comp() if not comp: self.log.error("Unable to find current comp") return existing_instance = None for instance in self.create_context.instances: if instance.family == self.family: existing_instance = instance break project_name = self.create_context.get_current_project_name() asset_name = self.create_context.get_current_asset_name() task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name if existing_instance is None: existing_instance_asset = None elif AYON_SERVER_ENABLED: existing_instance_asset = existing_instance["folderPath"] else: existing_instance_asset = existing_instance["asset"] if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, project_name, host_name ) data = { "task": task_name, "variant": self.default_variant } if AYON_SERVER_ENABLED: data["folderPath"] = asset_name else: data["asset"] = asset_name data.update(self.get_dynamic_data( self.default_variant, task_name, asset_doc, project_name, host_name, None )) new_instance = CreatedInstance( self.family, subset_name, data, self ) new_instance.transient_data["comp"] = comp self._add_instance_to_context(new_instance) elif ( existing_instance_asset != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, project_name, host_name ) if AYON_SERVER_ENABLED: existing_instance["folderPath"] = asset_name else: existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name ================================================ FILE: openpype/hosts/fusion/plugins/inventory/select_containers.py ================================================ from openpype.pipeline import InventoryAction class FusionSelectContainers(InventoryAction): label = "Select Containers" icon = "mouse-pointer" color = "#d8d8d8" def process(self, containers): from openpype.hosts.fusion.api import ( get_current_comp, comp_lock_and_undo_chunk ) tools = [i["_tool"] for i in containers] comp = get_current_comp() flow = comp.CurrentFrame.FlowView with comp_lock_and_undo_chunk(comp, self.label): # Clear selection flow.Select() # Select tool for tool in tools: flow.Select(tool) ================================================ FILE: openpype/hosts/fusion/plugins/inventory/set_tool_color.py ================================================ from qtpy import QtGui, QtWidgets from openpype.pipeline import InventoryAction from openpype import style from openpype.hosts.fusion.api import ( get_current_comp, comp_lock_and_undo_chunk ) class FusionSetToolColor(InventoryAction): """Update the color of the selected tools""" label = "Set Tool Color" icon = "plus" color = "#d8d8d8" _fallback_color = QtGui.QColor(1.0, 1.0, 1.0) def process(self, containers): """Color all selected tools the selected colors""" result = [] comp = get_current_comp() # Get tool color first = containers[0] tool = first["_tool"] color = tool.TileColor if color is not None: qcolor = QtGui.QColor().fromRgbF(color["R"], color["G"], color["B"]) else: qcolor = self._fallback_color # Launch pick color picked_color = self.get_color_picker(qcolor) if not picked_color: return with comp_lock_and_undo_chunk(comp): for container in containers: # Convert color to RGB 0-1 floats rgb_f = picked_color.getRgbF() rgb_f_table = {"R": rgb_f[0], "G": rgb_f[1], "B": rgb_f[2]} # Update tool tool = container["_tool"] tool.TileColor = rgb_f_table result.append(container) return result def get_color_picker(self, color): """Launch color picker and return chosen color Args: color(QtGui.QColor): Start color to display Returns: QtGui.QColor """ color_dialog = QtWidgets.QColorDialog(color) color_dialog.setStyleSheet(style.load_stylesheet()) accepted = color_dialog.exec_() if not accepted: return return color_dialog.selectedColor() ================================================ FILE: openpype/hosts/fusion/plugins/load/actions.py ================================================ """A module containing generic loader actions that will display in the Loader. """ from openpype.pipeline import load class FusionSetFrameRangeLoader(load.LoaderPlugin): """Set frame range excluding pre- and post-handles""" families = ["animation", "camera", "imagesequence", "render", "yeticache", "pointcache", "render"] representations = ["*"] extensions = {"*"} label = "Set frame range" order = 11 icon = "clock-o" color = "white" def load(self, context, name, namespace, data): from openpype.hosts.fusion.api import lib version = context['version'] version_data = version.get("data", {}) start = version_data.get("frameStart", None) end = version_data.get("frameEnd", None) if start is None or end is None: print("Skipping setting frame range because start or " "end frame data is missing..") return lib.update_frame_range(start, end) class FusionSetFrameRangeWithHandlesLoader(load.LoaderPlugin): """Set frame range including pre- and post-handles""" families = ["animation", "camera", "imagesequence", "render", "yeticache", "pointcache", "render"] representations = ["*"] label = "Set frame range (with handles)" order = 12 icon = "clock-o" color = "white" def load(self, context, name, namespace, data): from openpype.hosts.fusion.api import lib version = context['version'] version_data = version.get("data", {}) start = version_data.get("frameStart", None) end = version_data.get("frameEnd", None) if start is None or end is None: print("Skipping setting frame range because start or " "end frame data is missing..") return # Include handles start -= version_data.get("handleStart", 0) end += version_data.get("handleEnd", 0) lib.update_frame_range(start, end) ================================================ FILE: openpype/hosts/fusion/plugins/load/load_alembic.py ================================================ from openpype.pipeline import ( load, get_representation_path, ) from openpype.hosts.fusion.api import ( imprint_container, get_current_comp, comp_lock_and_undo_chunk ) class FusionLoadAlembicMesh(load.LoaderPlugin): """Load Alembic mesh into Fusion""" families = ["pointcache", "model"] representations = ["*"] extensions = {"abc"} label = "Load alembic mesh" order = -10 icon = "code-fork" color = "orange" tool_type = "SurfaceAlembicMesh" def load(self, context, name, namespace, data): # Fallback to asset name when namespace is None if namespace is None: namespace = context['asset']['name'] # Create the Loader with the filename path set comp = get_current_comp() with comp_lock_and_undo_chunk(comp, "Create tool"): path = self.filepath_from_context(context) args = (-32768, -32768) tool = comp.AddTool(self.tool_type, *args) tool["Filename"] = path imprint_container(tool, name=name, namespace=namespace, context=context, loader=self.__class__.__name__) def switch(self, container, representation): self.update(container, representation) def update(self, container, representation): """Update Alembic path""" tool = container["_tool"] assert tool.ID == self.tool_type, f"Must be {self.tool_type}" comp = tool.Comp() path = get_representation_path(representation) with comp_lock_and_undo_chunk(comp, "Update tool"): tool["Filename"] = path # Update the imprinted representation tool.SetData("avalon.representation", str(representation["_id"])) def remove(self, container): tool = container["_tool"] assert tool.ID == self.tool_type, f"Must be {self.tool_type}" comp = tool.Comp() with comp_lock_and_undo_chunk(comp, "Remove tool"): tool.Delete() ================================================ FILE: openpype/hosts/fusion/plugins/load/load_fbx.py ================================================ from openpype.pipeline import ( load, get_representation_path, ) from openpype.hosts.fusion.api import ( imprint_container, get_current_comp, comp_lock_and_undo_chunk, ) class FusionLoadFBXMesh(load.LoaderPlugin): """Load FBX mesh into Fusion""" families = ["*"] representations = ["*"] extensions = { "3ds", "amc", "aoa", "asf", "bvh", "c3d", "dae", "dxf", "fbx", "htr", "mcd", "obj", "trc", } label = "Load FBX mesh" order = -10 icon = "code-fork" color = "orange" tool_type = "SurfaceFBXMesh" def load(self, context, name, namespace, data): # Fallback to asset name when namespace is None if namespace is None: namespace = context["asset"]["name"] # Create the Loader with the filename path set comp = get_current_comp() with comp_lock_and_undo_chunk(comp, "Create tool"): path = self.filepath_from_context(context) args = (-32768, -32768) tool = comp.AddTool(self.tool_type, *args) tool["ImportFile"] = path imprint_container( tool, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, ) def switch(self, container, representation): self.update(container, representation) def update(self, container, representation): """Update path""" tool = container["_tool"] assert tool.ID == self.tool_type, f"Must be {self.tool_type}" comp = tool.Comp() path = get_representation_path(representation) with comp_lock_and_undo_chunk(comp, "Update tool"): tool["ImportFile"] = path # Update the imprinted representation tool.SetData("avalon.representation", str(representation["_id"])) def remove(self, container): tool = container["_tool"] assert tool.ID == self.tool_type, f"Must be {self.tool_type}" comp = tool.Comp() with comp_lock_and_undo_chunk(comp, "Remove tool"): tool.Delete() ================================================ FILE: openpype/hosts/fusion/plugins/load/load_sequence.py ================================================ import contextlib import openpype.pipeline.load as load from openpype.pipeline.load import get_representation_context from openpype.hosts.fusion.api import ( imprint_container, get_current_comp, comp_lock_and_undo_chunk, ) from openpype.lib.transcoding import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS comp = get_current_comp() @contextlib.contextmanager def preserve_inputs(tool, inputs): """Preserve the tool's inputs after context""" comp = tool.Comp() values = {} for name in inputs: tool_input = getattr(tool, name) value = tool_input[comp.TIME_UNDEFINED] values[name] = value try: yield finally: for name, value in values.items(): tool_input = getattr(tool, name) tool_input[comp.TIME_UNDEFINED] = value @contextlib.contextmanager def preserve_trim(loader, log=None): """Preserve the relative trim of the Loader tool. This tries to preserve the loader's trim (trim in and trim out) after the context by reapplying the "amount" it trims on the clip's length at start and end. """ # Get original trim as amount of "trimming" from length time = loader.Comp().TIME_UNDEFINED length = loader.GetAttrs()["TOOLIT_Clip_Length"][1] - 1 trim_from_start = loader["ClipTimeStart"][time] trim_from_end = length - loader["ClipTimeEnd"][time] try: yield finally: length = loader.GetAttrs()["TOOLIT_Clip_Length"][1] - 1 if trim_from_start > length: trim_from_start = length if log: log.warning( "Reducing trim in to %d " "(because of less frames)" % trim_from_start ) remainder = length - trim_from_start if trim_from_end > remainder: trim_from_end = remainder if log: log.warning( "Reducing trim in to %d " "(because of less frames)" % trim_from_end ) loader["ClipTimeStart"][time] = trim_from_start loader["ClipTimeEnd"][time] = length - trim_from_end def loader_shift(loader, frame, relative=True): """Shift global in time by i preserving duration This moves the loader by i frames preserving global duration. When relative is False it will shift the global in to the start frame. Args: loader (tool): The fusion loader tool. frame (int): The amount of frames to move. relative (bool): When True the shift is relative, else the shift will change the global in to frame. Returns: int: The resulting relative frame change (how much it moved) """ comp = loader.Comp() time = comp.TIME_UNDEFINED old_in = loader["GlobalIn"][time] old_out = loader["GlobalOut"][time] if relative: shift = frame else: shift = frame - old_in if not shift: return 0 # Shifting global in will try to automatically compensate for the change # in the "ClipTimeStart" and "HoldFirstFrame" inputs, so we preserve those # input values to "just shift" the clip with preserve_inputs( loader, inputs=[ "ClipTimeStart", "ClipTimeEnd", "HoldFirstFrame", "HoldLastFrame", ], ): # GlobalIn cannot be set past GlobalOut or vice versa # so we must apply them in the order of the shift. if shift > 0: loader["GlobalOut"][time] = old_out + shift loader["GlobalIn"][time] = old_in + shift else: loader["GlobalIn"][time] = old_in + shift loader["GlobalOut"][time] = old_out + shift return int(shift) class FusionLoadSequence(load.LoaderPlugin): """Load image sequence into Fusion""" families = [ "imagesequence", "review", "render", "plate", "image", "onilne", ] representations = ["*"] extensions = set( ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) ) label = "Load sequence" order = -10 icon = "code-fork" color = "orange" def load(self, context, name, namespace, data): # Fallback to asset name when namespace is None if namespace is None: namespace = context["asset"]["name"] # Use the first file for now path = self.filepath_from_context(context) # Create the Loader with the filename path set comp = get_current_comp() with comp_lock_and_undo_chunk(comp, "Create Loader"): args = (-32768, -32768) tool = comp.AddTool("Loader", *args) tool["Clip"] = comp.ReverseMapPath(path) # Set global in point to start frame (if in version.data) start = self._get_start(context["version"], tool) loader_shift(tool, start, relative=False) imprint_container( tool, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, ) def switch(self, container, representation): self.update(container, representation) def update(self, container, representation): """Update the Loader's path Fusion automatically tries to reset some variables when changing the loader's path to a new file. These automatic changes are to its inputs: - ClipTimeStart: Fusion reset to 0 if duration changes - We keep the trim in as close as possible to the previous value. When there are less frames then the amount of trim we reduce it accordingly. - ClipTimeEnd: Fusion reset to 0 if duration changes - We keep the trim out as close as possible to the previous value within new amount of frames after trim in (ClipTimeStart) has been set. - GlobalIn: Fusion reset to comp's global in if duration changes - We change it to the "frameStart" - GlobalEnd: Fusion resets to globalIn + length if duration changes - We do the same like Fusion - allow fusion to take control. - HoldFirstFrame: Fusion resets this to 0 - We preserve the value. - HoldLastFrame: Fusion resets this to 0 - We preserve the value. - Reverse: Fusion resets to disabled if "Loop" is not enabled. - We preserve the value. - Depth: Fusion resets to "Format" - We preserve the value. - KeyCode: Fusion resets to "" - We preserve the value. - TimeCodeOffset: Fusion resets to 0 - We preserve the value. """ tool = container["_tool"] assert tool.ID == "Loader", "Must be Loader" comp = tool.Comp() context = get_representation_context(representation) path = self.filepath_from_context(context) # Get start frame from version data start = self._get_start(context["version"], tool) with comp_lock_and_undo_chunk(comp, "Update Loader"): # Update the loader's path whilst preserving some values with preserve_trim(tool, log=self.log): with preserve_inputs( tool, inputs=( "HoldFirstFrame", "HoldLastFrame", "Reverse", "Depth", "KeyCode", "TimeCodeOffset", ), ): tool["Clip"] = comp.ReverseMapPath(path) # Set the global in to the start frame of the sequence global_in_changed = loader_shift(tool, start, relative=False) if global_in_changed: # Log this change to the user self.log.debug( "Changed '%s' global in: %d" % (tool.Name, start) ) # Update the imprinted representation tool.SetData("avalon.representation", str(representation["_id"])) def remove(self, container): tool = container["_tool"] assert tool.ID == "Loader", "Must be Loader" comp = tool.Comp() with comp_lock_and_undo_chunk(comp, "Remove Loader"): tool.Delete() def _get_start(self, version_doc, tool): """Return real start frame of published files (incl. handles)""" data = version_doc["data"] # Get start frame directly with handle if it's in data start = data.get("frameStartHandle") if start is not None: return start # Get frame start without handles start = data.get("frameStart") if start is None: self.log.warning( "Missing start frame for version " "assuming starts at frame 0 for: " "{}".format(tool.Name) ) return 0 # Use `handleStart` if the data is available handle_start = data.get("handleStart") if handle_start: start -= handle_start return start ================================================ FILE: openpype/hosts/fusion/plugins/load/load_usd.py ================================================ from openpype.pipeline import ( load, get_representation_path, ) from openpype.hosts.fusion.api import ( imprint_container, get_current_comp, comp_lock_and_undo_chunk ) from openpype.hosts.fusion.api.lib import get_fusion_module class FusionLoadUSD(load.LoaderPlugin): """Load USD into Fusion Support for USD was added since Fusion 18.5 """ families = ["*"] representations = ["*"] extensions = {"usd", "usda", "usdz"} label = "Load USD" order = -10 icon = "code-fork" color = "orange" tool_type = "uLoader" @classmethod def apply_settings(cls, project_settings, system_settings): super(FusionLoadUSD, cls).apply_settings(project_settings, system_settings) if cls.enabled: # Enable only in Fusion 18.5+ fusion = get_fusion_module() version = fusion.GetVersion() major = version[1] minor = version[2] is_usd_supported = (major, minor) >= (18, 5) cls.enabled = is_usd_supported def load(self, context, name, namespace, data): # Fallback to asset name when namespace is None if namespace is None: namespace = context['asset']['name'] # Create the Loader with the filename path set comp = get_current_comp() with comp_lock_and_undo_chunk(comp, "Create tool"): path = self.fname args = (-32768, -32768) tool = comp.AddTool(self.tool_type, *args) tool["Filename"] = path imprint_container(tool, name=name, namespace=namespace, context=context, loader=self.__class__.__name__) def switch(self, container, representation): self.update(container, representation) def update(self, container, representation): tool = container["_tool"] assert tool.ID == self.tool_type, f"Must be {self.tool_type}" comp = tool.Comp() path = get_representation_path(representation) with comp_lock_and_undo_chunk(comp, "Update tool"): tool["Filename"] = path # Update the imprinted representation tool.SetData("avalon.representation", str(representation["_id"])) def remove(self, container): tool = container["_tool"] assert tool.ID == self.tool_type, f"Must be {self.tool_type}" comp = tool.Comp() with comp_lock_and_undo_chunk(comp, "Remove tool"): tool.Delete() ================================================ FILE: openpype/hosts/fusion/plugins/load/load_workfile.py ================================================ """Import workfiles into your current comp. As all imported nodes are free floating and will probably be changed there is no update or reload function added for this plugin """ from openpype.pipeline import load from openpype.hosts.fusion.api import ( get_current_comp, get_bmd_library, ) class FusionLoadWorkfile(load.LoaderPlugin): """Load the content of a workfile into Fusion""" families = ["workfile"] representations = ["*"] extensions = {"comp"} label = "Load Workfile" order = -10 icon = "code-fork" color = "orange" def load(self, context, name, namespace, data): # Get needed elements bmd = get_bmd_library() comp = get_current_comp() path = self.filepath_from_context(context) # Paste the content of the file into the current comp comp.Paste(bmd.readfile(path)) ================================================ FILE: openpype/hosts/fusion/plugins/publish/collect_comp.py ================================================ import pyblish.api from openpype.hosts.fusion.api import get_current_comp class CollectCurrentCompFusion(pyblish.api.ContextPlugin): """Collect current comp""" order = pyblish.api.CollectorOrder - 0.4 label = "Collect Current Comp" hosts = ["fusion"] def process(self, context): """Collect all image sequence tools""" current_comp = get_current_comp() assert current_comp, "Must have active Fusion composition" context.data["currentComp"] = current_comp # Store path to current file filepath = current_comp.GetAttrs().get("COMPS_FileName", "") context.data['currentFile'] = filepath ================================================ FILE: openpype/hosts/fusion/plugins/publish/collect_comp_frame_range.py ================================================ import pyblish.api def get_comp_render_range(comp): """Return comp's start-end render range and global start-end range.""" comp_attrs = comp.GetAttrs() start = comp_attrs["COMPN_RenderStart"] end = comp_attrs["COMPN_RenderEnd"] global_start = comp_attrs["COMPN_GlobalStart"] global_end = comp_attrs["COMPN_GlobalEnd"] # Whenever render ranges are undefined fall back # to the comp's global start and end if start == -1000000000: start = global_start if end == -1000000000: end = global_end return start, end, global_start, global_end class CollectFusionCompFrameRanges(pyblish.api.ContextPlugin): """Collect current comp""" # We run this after CollectorOrder - 0.1 otherwise it gets # overridden by global plug-in `CollectContextEntities` order = pyblish.api.CollectorOrder - 0.05 label = "Collect Comp Frame Ranges" hosts = ["fusion"] def process(self, context): """Collect all image sequence tools""" comp = context.data["currentComp"] # Store comp render ranges start, end, global_start, global_end = get_comp_render_range(comp) context.data.update({ "renderFrameStart": int(start), "renderFrameEnd": int(end), "compFrameStart": int(global_start), "compFrameEnd": int(global_end) }) ================================================ FILE: openpype/hosts/fusion/plugins/publish/collect_inputs.py ================================================ import pyblish.api from openpype.pipeline import registered_host def collect_input_containers(tools): """Collect containers that contain any of the node in `nodes`. This will return any loaded Avalon container that contains at least one of the nodes. As such, the Avalon container is an input for it. Or in short, there are member nodes of that container. Returns: list: Input avalon containers """ # Lookup by node ids lookup = frozenset([tool.Name for tool in tools]) containers = [] host = registered_host() for container in host.ls(): name = container["_tool"].Name # We currently assume no "groups" as containers but just single tools # like a single "Loader" operator. As such we just check whether the # Loader is part of the processing queue. if name in lookup: containers.append(container) return containers def iter_upstream(tool): """Yields all upstream inputs for the current tool. Yields: tool: The input tools. """ def get_connected_input_tools(tool): """Helper function that returns connected input tools for a tool.""" inputs = [] # Filter only to actual types that will have sensible upstream # connections. So we ignore just "Number" inputs as they can be # many to iterate, slowing things down quite a bit - and in practice # they don't have upstream connections. VALID_INPUT_TYPES = ['Image', 'Particles', 'Mask', 'DataType3D'] for type_ in VALID_INPUT_TYPES: for input_ in tool.GetInputList(type_).values(): output = input_.GetConnectedOutput() if output: input_tool = output.GetTool() inputs.append(input_tool) return inputs # Initialize process queue with the node's inputs itself queue = get_connected_input_tools(tool) # We keep track of which node names we have processed so far, to ensure we # don't process the same hierarchy again. We are not pushing the tool # itself into the set as that doesn't correctly recognize the same tool. # Since tool names are unique in a comp in Fusion we rely on that. collected = set(tool.Name for tool in queue) # Traverse upstream references for all nodes and yield them as we # process the queue. while queue: upstream_tool = queue.pop() yield upstream_tool # Find upstream tools that are not collected yet. upstream_inputs = get_connected_input_tools(upstream_tool) upstream_inputs = [t for t in upstream_inputs if t.Name not in collected] queue.extend(upstream_inputs) collected.update(tool.Name for tool in upstream_inputs) class CollectUpstreamInputs(pyblish.api.InstancePlugin): """Collect source input containers used for this publish. This will include `inputs` data of which loaded publishes were used in the generation of this publish. This leaves an upstream trace to what was used as input. """ label = "Collect Inputs" order = pyblish.api.CollectorOrder + 0.2 hosts = ["fusion"] families = ["render", "image"] def process(self, instance): # Get all upstream and include itself if not any(instance[:]): self.log.debug("No tool found in instance, skipping..") return tool = instance[0] nodes = list(iter_upstream(tool)) nodes.append(tool) # Collect containers for the given set of nodes containers = collect_input_containers(nodes) inputs = [c["representation"] for c in containers] instance.data["inputRepresentations"] = inputs self.log.debug("Collected inputs: %s" % inputs) ================================================ FILE: openpype/hosts/fusion/plugins/publish/collect_instances.py ================================================ import pyblish.api class CollectInstanceData(pyblish.api.InstancePlugin): """Collect Fusion saver instances This additionally stores the Comp start and end render range in the current context's data as "frameStart" and "frameEnd". """ order = pyblish.api.CollectorOrder label = "Collect Instances Data" hosts = ["fusion"] def process(self, instance): """Collect all image sequence tools""" context = instance.context # Include creator attributes directly as instance data creator_attributes = instance.data["creator_attributes"] instance.data.update(creator_attributes) frame_range_source = creator_attributes.get("frame_range_source") instance.data["frame_range_source"] = frame_range_source # get asset frame ranges to all instances # render family instances `asset_db` render target start = context.data["frameStart"] end = context.data["frameEnd"] handle_start = context.data["handleStart"] handle_end = context.data["handleEnd"] start_with_handle = start - handle_start end_with_handle = end + handle_end # conditions for render family instances if frame_range_source == "render_range": # set comp render frame ranges start = context.data["renderFrameStart"] end = context.data["renderFrameEnd"] handle_start = 0 handle_end = 0 start_with_handle = start end_with_handle = end if frame_range_source == "comp_range": comp_start = context.data["compFrameStart"] comp_end = context.data["compFrameEnd"] render_start = context.data["renderFrameStart"] render_end = context.data["renderFrameEnd"] # set comp frame ranges start = render_start end = render_end handle_start = render_start - comp_start handle_end = comp_end - render_end start_with_handle = comp_start end_with_handle = comp_end frame = instance.data["creator_attributes"].get("frame") # explicitly publishing only single frame if frame is not None: frame = int(frame) start = frame end = frame handle_start = 0 handle_end = 0 start_with_handle = frame end_with_handle = frame # Include start and end render frame in label subset = instance.data["subset"] label = ( "{subset} ({start}-{end}) [{handle_start}-{handle_end}]" ).format( subset=subset, start=int(start), end=int(end), handle_start=int(handle_start), handle_end=int(handle_end) ) instance.data.update({ "label": label, # todo: Allow custom frame range per instance "frameStart": start, "frameEnd": end, "frameStartHandle": start_with_handle, "frameEndHandle": end_with_handle, "handleStart": handle_start, "handleEnd": handle_end, "fps": context.data["fps"], }) # Add review family if the instance is marked as 'review' # This could be done through a 'review' Creator attribute. if instance.data.get("review", False): self.log.debug("Adding review family..") instance.data["families"].append("review") ================================================ FILE: openpype/hosts/fusion/plugins/publish/collect_render.py ================================================ import os import attr import pyblish.api from openpype.pipeline import publish from openpype.pipeline.publish import RenderInstance from openpype.hosts.fusion.api.lib import get_frame_path @attr.s class FusionRenderInstance(RenderInstance): # extend generic, composition name is needed fps = attr.ib(default=None) projectEntity = attr.ib(default=None) stagingDir = attr.ib(default=None) app_version = attr.ib(default=None) tool = attr.ib(default=None) workfileComp = attr.ib(default=None) publish_attributes = attr.ib(default={}) frameStartHandle = attr.ib(default=None) frameEndHandle = attr.ib(default=None) class CollectFusionRender( publish.AbstractCollectRender, publish.ColormanagedPyblishPluginMixin ): order = pyblish.api.CollectorOrder + 0.09 label = "Collect Fusion Render" hosts = ["fusion"] def get_instances(self, context): comp = context.data.get("currentComp") comp_frame_format_prefs = comp.GetPrefs("Comp.FrameFormat") aspect_x = comp_frame_format_prefs["AspectX"] aspect_y = comp_frame_format_prefs["AspectY"] instances = [] instances_to_remove = [] current_file = context.data["currentFile"] version = context.data["version"] project_entity = context.data["projectEntity"] for inst in context: if not inst.data.get("active", True): continue family = inst.data["family"] if family not in ["render", "image"]: continue task_name = context.data["task"] tool = inst.data["transientData"]["tool"] instance_families = inst.data.get("families", []) subset_name = inst.data["subset"] instance = FusionRenderInstance( family=family, tool=tool, workfileComp=comp, families=instance_families, version=version, time="", source=current_file, label=inst.data["label"], subset=subset_name, asset=inst.data["asset"], task=task_name, attachTo=False, setMembers='', publish=True, name=subset_name, resolutionWidth=comp_frame_format_prefs.get("Width"), resolutionHeight=comp_frame_format_prefs.get("Height"), pixelAspect=aspect_x / aspect_y, tileRendering=False, tilesX=0, tilesY=0, review="review" in instance_families, frameStart=inst.data["frameStart"], frameEnd=inst.data["frameEnd"], handleStart=inst.data["handleStart"], handleEnd=inst.data["handleEnd"], frameStartHandle=inst.data["frameStartHandle"], frameEndHandle=inst.data["frameEndHandle"], frameStep=1, fps=comp_frame_format_prefs.get("Rate"), app_version=comp.GetApp().Version, publish_attributes=inst.data.get("publish_attributes", {}) ) render_target = inst.data["creator_attributes"]["render_target"] # Add render target family render_target_family = f"render.{render_target}" if render_target_family not in instance.families: instance.families.append(render_target_family) # Add render target specific data if render_target in {"local", "frames"}: instance.projectEntity = project_entity if render_target == "farm": fam = "render.farm" if fam not in instance.families: instance.families.append(fam) instance.farm = True # to skip integrate if "review" in instance.families: # to skip ExtractReview locally instance.families.remove("review") # add new instance to the list and remove the original # instance since it is not needed anymore instances.append(instance) instances_to_remove.append(inst) for instance in instances_to_remove: context.remove(instance) return instances def post_collecting_action(self): for instance in self._context: if "render.frames" in instance.data.get("families", []): # adding representation data to the instance self._update_for_frames(instance) def get_expected_files(self, render_instance): """ Returns list of rendered files that should be created by Deadline. These are not published directly, they are source for later 'submit_publish_job'. Args: render_instance (RenderInstance): to pull anatomy and parts used in url Returns: (list) of absolute urls to rendered file """ start = render_instance.frameStart - render_instance.handleStart end = render_instance.frameEnd + render_instance.handleEnd comp = render_instance.workfileComp path = comp.MapPath( render_instance.tool["Clip"][ render_instance.workfileComp.TIME_UNDEFINED ] ) output_dir = os.path.dirname(path) render_instance.outputDir = output_dir basename = os.path.basename(path) head, padding, ext = get_frame_path(basename) expected_files = [] for frame in range(start, end + 1): expected_files.append( os.path.join( output_dir, f"{head}{str(frame).zfill(padding)}{ext}" ) ) return expected_files def _update_for_frames(self, instance): """Updating instance for render.frames family Adding representation data to the instance. Also setting colorspaceData to the representation based on file rules. """ expected_files = instance.data["expectedFiles"] start = instance.data["frameStart"] - instance.data["handleStart"] path = expected_files[0] basename = os.path.basename(path) staging_dir = os.path.dirname(path) _, padding, ext = get_frame_path(basename) repre = { "name": ext[1:], "ext": ext[1:], "frameStart": f"%0{padding}d" % start, "files": [os.path.basename(f) for f in expected_files], "stagingDir": staging_dir, } self.set_representation_colorspace( representation=repre, context=instance.context, ) # review representation if instance.data.get("review", False): repre["tags"] = ["review"] # add the repre to the instance if "representations" not in instance.data: instance.data["representations"] = [] instance.data["representations"].append(repre) return instance ================================================ FILE: openpype/hosts/fusion/plugins/publish/collect_workfile.py ================================================ import os import pyblish.api class CollectFusionWorkfile(pyblish.api.InstancePlugin): """Collect Fusion workfile representation.""" order = pyblish.api.CollectorOrder + 0.1 label = "Collect Workfile" hosts = ["fusion"] families = ["workfile"] def process(self, instance): current_file = instance.context.data["currentFile"] folder, file = os.path.split(current_file) filename, ext = os.path.splitext(file) instance.data['representations'] = [{ 'name': ext.lstrip("."), 'ext': ext.lstrip("."), 'files': file, "stagingDir": folder, }] ================================================ FILE: openpype/hosts/fusion/plugins/publish/extract_render_local.py ================================================ import os import logging import contextlib import collections import pyblish.api from openpype.pipeline import publish from openpype.hosts.fusion.api import comp_lock_and_undo_chunk from openpype.hosts.fusion.api.lib import get_frame_path, maintained_comp_range log = logging.getLogger(__name__) @contextlib.contextmanager def enabled_savers(comp, savers): """Enable only the `savers` in Comp during the context. Any Saver tool in the passed composition that is not in the savers list will be set to passthrough during the context. Args: comp (object): Fusion composition object. savers (list): List of Saver tool objects. """ passthrough_key = "TOOLB_PassThrough" original_states = {} enabled_saver_names = {saver.Name for saver in savers} all_savers = comp.GetToolList(False, "Saver").values() savers_by_name = {saver.Name: saver for saver in all_savers} try: for saver in all_savers: original_state = saver.GetAttrs()[passthrough_key] original_states[saver.Name] = original_state # The passthrough state we want to set (passthrough != enabled) state = saver.Name not in enabled_saver_names if state != original_state: saver.SetAttrs({passthrough_key: state}) yield finally: for saver_name, original_state in original_states.items(): saver = savers_by_name[saver_name] saver.SetAttrs({"TOOLB_PassThrough": original_state}) class FusionRenderLocal( pyblish.api.InstancePlugin, publish.ColormanagedPyblishPluginMixin ): """Render the current Fusion composition locally.""" order = pyblish.api.ExtractorOrder - 0.2 label = "Render Local" hosts = ["fusion"] families = ["render.local"] is_rendered_key = "_fusionrenderlocal_has_rendered" def process(self, instance): # Start render result = self.render(instance) if result is False: raise RuntimeError(f"Comp render failed for {instance}") self._add_representation(instance) # Log render status self.log.info( "Rendered '{nm}' for asset '{ast}' under the task '{tsk}'".format( nm=instance.data["name"], ast=instance.data["asset"], tsk=instance.data["task"], ) ) def render(self, instance): """Render instance. We try to render the minimal amount of times by combining the instances that have a matching frame range in one Fusion render. Then for the batch of instances we store whether the render succeeded or failed. """ if self.is_rendered_key in instance.data: # This instance was already processed in batch with another # instance, so we just return the render result directly self.log.debug(f"Instance {instance} was already rendered") return instance.data[self.is_rendered_key] instances_by_frame_range = self.get_render_instances_by_frame_range( instance.context ) # Render matching batch of instances that share the same frame range frame_range = self.get_instance_render_frame_range(instance) render_instances = instances_by_frame_range[frame_range] # We initialize render state false to indicate it wasn't successful # yet to keep track of whether Fusion succeeded. This is for cases # where an error below this might cause the comp render result not # to be stored for the instances of this batch for render_instance in render_instances: render_instance.data[self.is_rendered_key] = False savers_to_render = [inst.data["tool"] for inst in render_instances] current_comp = instance.context.data["currentComp"] frame_start, frame_end = frame_range self.log.info( f"Starting Fusion render frame range {frame_start}-{frame_end}" ) saver_names = ", ".join(saver.Name for saver in savers_to_render) self.log.info(f"Rendering tools: {saver_names}") with comp_lock_and_undo_chunk(current_comp): with maintained_comp_range(current_comp): with enabled_savers(current_comp, savers_to_render): result = current_comp.Render( { "Start": frame_start, "End": frame_end, "Wait": True, } ) # Store the render state for all the rendered instances for render_instance in render_instances: render_instance.data[self.is_rendered_key] = bool(result) return result def _add_representation(self, instance): """Add representation to instance""" expected_files = instance.data["expectedFiles"] start = instance.data["frameStart"] - instance.data["handleStart"] path = expected_files[0] _, padding, ext = get_frame_path(path) staging_dir = os.path.dirname(path) files = [os.path.basename(f) for f in expected_files] if len(expected_files) == 1: files = files[0] repre = { "name": ext[1:], "ext": ext[1:], "frameStart": f"%0{padding}d" % start, "files": files, "stagingDir": staging_dir, } self.set_representation_colorspace( representation=repre, context=instance.context, ) # review representation if instance.data.get("review", False): repre["tags"] = ["review"] # add the repre to the instance if "representations" not in instance.data: instance.data["representations"] = [] instance.data["representations"].append(repre) return instance def get_render_instances_by_frame_range(self, context): """Return enabled render.local instances grouped by their frame range. Arguments: context (pyblish.Context): The pyblish context Returns: dict: (start, end): instances mapping """ instances_to_render = [ instance for instance in context if # Only active instances instance.data.get("publish", True) and # Only render.local instances "render.local" in instance.data.get("families", []) ] # Instances by frame ranges instances_by_frame_range = collections.defaultdict(list) for instance in instances_to_render: start, end = self.get_instance_render_frame_range(instance) instances_by_frame_range[(start, end)].append(instance) return dict(instances_by_frame_range) def get_instance_render_frame_range(self, instance): start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] return start, end ================================================ FILE: openpype/hosts/fusion/plugins/publish/increment_current_file.py ================================================ import pyblish.api from openpype.pipeline import OptionalPyblishPluginMixin from openpype.pipeline import KnownPublishError class FusionIncrementCurrentFile( pyblish.api.ContextPlugin, OptionalPyblishPluginMixin ): """Increment the current file. Saves the current file with an increased version number. """ label = "Increment workfile version" order = pyblish.api.IntegratorOrder + 9.0 hosts = ["fusion"] optional = True def process(self, context): if not self.is_active(context.data): return from openpype.lib import version_up from openpype.pipeline.publish import get_errored_plugins_from_context errored_plugins = get_errored_plugins_from_context(context) if any( plugin.__name__ == "FusionSubmitDeadline" for plugin in errored_plugins ): raise KnownPublishError( "Skipping incrementing current file because " "submission to render farm failed." ) comp = context.data.get("currentComp") assert comp, "Must have comp" current_filepath = context.data["currentFile"] new_filepath = version_up(current_filepath) comp.Save(new_filepath) ================================================ FILE: openpype/hosts/fusion/plugins/publish/save_scene.py ================================================ import pyblish.api class FusionSaveComp(pyblish.api.ContextPlugin): """Save current comp""" label = "Save current file" order = pyblish.api.ExtractorOrder - 0.49 hosts = ["fusion"] families = ["render", "image", "workfile"] def process(self, context): comp = context.data.get("currentComp") assert comp, "Must have comp" current = comp.GetAttrs().get("COMPS_FileName", "") assert context.data['currentFile'] == current self.log.info("Saving current file: {}".format(current)) comp.Save() ================================================ FILE: openpype/hosts/fusion/plugins/publish/validate_background_depth.py ================================================ import pyblish.api from openpype.pipeline import ( publish, OptionalPyblishPluginMixin, PublishValidationError, ) from openpype.hosts.fusion.api.action import SelectInvalidAction class ValidateBackgroundDepth( pyblish.api.InstancePlugin, OptionalPyblishPluginMixin ): """Validate if all Background tool are set to float32 bit""" order = pyblish.api.ValidatorOrder label = "Validate Background Depth 32 bit" hosts = ["fusion"] families = ["render", "image"] optional = True actions = [SelectInvalidAction, publish.RepairAction] @classmethod def get_invalid(cls, instance): context = instance.context comp = context.data.get("currentComp") assert comp, "Must have Comp object" backgrounds = comp.GetToolList(False, "Background").values() if not backgrounds: return [] return [i for i in backgrounds if i.GetInput("Depth") != 4.0] def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Found {} Backgrounds tools which" " are not set to float32".format(len(invalid)), title=self.label, ) @classmethod def repair(cls, instance): comp = instance.context.data.get("currentComp") invalid = cls.get_invalid(instance) for i in invalid: i.SetInput("Depth", 4.0, comp.TIME_UNDEFINED) ================================================ FILE: openpype/hosts/fusion/plugins/publish/validate_comp_saved.py ================================================ import os import pyblish.api from openpype.pipeline import PublishValidationError class ValidateFusionCompSaved(pyblish.api.ContextPlugin): """Ensure current comp is saved""" order = pyblish.api.ValidatorOrder label = "Validate Comp Saved" families = ["render", "image"] hosts = ["fusion"] def process(self, context): comp = context.data.get("currentComp") assert comp, "Must have Comp object" attrs = comp.GetAttrs() filename = attrs["COMPS_FileName"] if not filename: raise PublishValidationError("Comp is not saved.", title=self.label) if not os.path.exists(filename): raise PublishValidationError( "Comp file does not exist: %s" % filename, title=self.label) if attrs["COMPB_Modified"]: self.log.warning("Comp is modified. Save your comp to ensure your " "changes propagate correctly.") ================================================ FILE: openpype/hosts/fusion/plugins/publish/validate_create_folder_checked.py ================================================ import pyblish.api from openpype.pipeline.publish import RepairAction from openpype.pipeline import PublishValidationError from openpype.hosts.fusion.api.action import SelectInvalidAction class ValidateCreateFolderChecked(pyblish.api.InstancePlugin): """Valid if all savers have the input attribute CreateDir checked on This attribute ensures that the folders to which the saver will write will be created. """ order = pyblish.api.ValidatorOrder label = "Validate Create Folder Checked" families = ["render", "image"] hosts = ["fusion"] actions = [RepairAction, SelectInvalidAction] @classmethod def get_invalid(cls, instance): tool = instance.data["tool"] create_dir = tool.GetInput("CreateDir") if create_dir == 0.0: cls.log.error( "%s has Create Folder turned off" % instance[0].Name ) return [tool] def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Found Saver with Create Folder During Render checked off", title=self.label, ) @classmethod def repair(cls, instance): invalid = cls.get_invalid(instance) for tool in invalid: tool.SetInput("CreateDir", 1.0) ================================================ FILE: openpype/hosts/fusion/plugins/publish/validate_expected_frames_existence.py ================================================ import os import pyblish.api from openpype.pipeline.publish import RepairAction from openpype.pipeline import PublishValidationError from openpype.hosts.fusion.api.action import SelectInvalidAction class ValidateLocalFramesExistence(pyblish.api.InstancePlugin): """Checks if files for savers that's set to publish expected frames exists """ order = pyblish.api.ValidatorOrder label = "Validate Expected Frames Exists" families = ["render.frames"] hosts = ["fusion"] actions = [RepairAction, SelectInvalidAction] @classmethod def get_invalid(cls, instance, non_existing_frames=None): if non_existing_frames is None: non_existing_frames = [] tool = instance.data["tool"] expected_files = instance.data["expectedFiles"] for file in expected_files: if not os.path.exists(file): cls.log.error( f"Missing file: {file}" ) non_existing_frames.append(file) if len(non_existing_frames) > 0: cls.log.error(f"Some of {tool.Name}'s files does not exist") return [tool] def process(self, instance): non_existing_frames = [] invalid = self.get_invalid(instance, non_existing_frames) if invalid: raise PublishValidationError( "{} is set to publish existing frames but " "some frames are missing. " "The missing file(s) are:\n\n{}".format( invalid[0].Name, "\n\n".join(non_existing_frames), ), title=self.label, ) @classmethod def repair(cls, instance): invalid = cls.get_invalid(instance) if invalid: tool = instance.data["tool"] # Change render target to local to render locally tool.SetData("openpype.creator_attributes.render_target", "local") cls.log.info( f"Reload the publisher and {tool.Name} " "will be set to render locally" ) ================================================ FILE: openpype/hosts/fusion/plugins/publish/validate_filename_has_extension.py ================================================ import os import pyblish.api from openpype.pipeline import PublishValidationError from openpype.hosts.fusion.api.action import SelectInvalidAction class ValidateFilenameHasExtension(pyblish.api.InstancePlugin): """Ensure the Saver has an extension in the filename path This disallows files written as `filename` instead of `filename.frame.ext`. Fusion does not always set an extension for your filename when changing the file format of the saver. """ order = pyblish.api.ValidatorOrder label = "Validate Filename Has Extension" families = ["render", "image"] hosts = ["fusion"] actions = [SelectInvalidAction] def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError("Found Saver without an extension", title=self.label) @classmethod def get_invalid(cls, instance): path = instance.data["expectedFiles"][0] fname, ext = os.path.splitext(path) if not ext: tool = instance.data["tool"] cls.log.error("%s has no extension specified" % tool.Name) return [tool] return [] ================================================ FILE: openpype/hosts/fusion/plugins/publish/validate_image_frame.py ================================================ import pyblish.api from openpype.pipeline import PublishValidationError class ValidateImageFrame(pyblish.api.InstancePlugin): """Validates that `image` product type contains only single frame.""" order = pyblish.api.ValidatorOrder label = "Validate Image Frame" families = ["image"] hosts = ["fusion"] def process(self, instance): render_start = instance.data["frameStartHandle"] render_end = instance.data["frameEndHandle"] too_many_frames = (isinstance(instance.data["expectedFiles"], list) and len(instance.data["expectedFiles"]) > 1) if render_end - render_start > 0 or too_many_frames: desc = ("Trying to render multiple frames. 'image' product type " "is meant for single frame. Please use 'render' creator.") raise PublishValidationError( title="Frame range outside of comp range", message=desc, description=desc ) ================================================ FILE: openpype/hosts/fusion/plugins/publish/validate_instance_frame_range.py ================================================ import pyblish.api from openpype.pipeline import PublishValidationError class ValidateInstanceFrameRange(pyblish.api.InstancePlugin): """Validate instance frame range is within comp's global render range.""" order = pyblish.api.ValidatorOrder label = "Validate Frame Range" families = ["render", "image"] hosts = ["fusion"] def process(self, instance): context = instance.context global_start = context.data["compFrameStart"] global_end = context.data["compFrameEnd"] render_start = instance.data["frameStartHandle"] render_end = instance.data["frameEndHandle"] if render_start < global_start or render_end > global_end: message = ( f"Instance {instance} render frame range " f"({render_start}-{render_end}) is outside of the comp's " f"global render range ({global_start}-{global_end}) and thus " f"can't be rendered. " ) description = ( f"{message}\n\n" f"Either update the comp's global range or the instance's " f"frame range to ensure the comp's frame range includes the " f"to render frame range for the instance." ) raise PublishValidationError( title="Frame range outside of comp range", message=message, description=description ) ================================================ FILE: openpype/hosts/fusion/plugins/publish/validate_saver_has_input.py ================================================ import pyblish.api from openpype.pipeline import PublishValidationError from openpype.hosts.fusion.api.action import SelectInvalidAction class ValidateSaverHasInput(pyblish.api.InstancePlugin): """Validate saver has incoming connection This ensures a Saver has at least an input connection. """ order = pyblish.api.ValidatorOrder label = "Validate Saver Has Input" families = ["render", "image"] hosts = ["fusion"] actions = [SelectInvalidAction] @classmethod def get_invalid(cls, instance): saver = instance.data["tool"] if not saver.Input.GetConnectedOutput(): return [saver] return [] def process(self, instance): invalid = self.get_invalid(instance) if invalid: saver_name = invalid[0].Name raise PublishValidationError( "Saver has no incoming connection: {} ({})".format(instance, saver_name), title=self.label) ================================================ FILE: openpype/hosts/fusion/plugins/publish/validate_saver_passthrough.py ================================================ import pyblish.api from openpype.pipeline import PublishValidationError from openpype.hosts.fusion.api.action import SelectInvalidAction class ValidateSaverPassthrough(pyblish.api.ContextPlugin): """Validate saver passthrough is similar to Pyblish publish state""" order = pyblish.api.ValidatorOrder label = "Validate Saver Passthrough" families = ["render", "image"] hosts = ["fusion"] actions = [SelectInvalidAction] def process(self, context): # Workaround for ContextPlugin always running, even if no instance # is present with the family instances = pyblish.api.instances_by_plugin(instances=list(context), plugin=self) if not instances: self.log.debug("Ignoring plugin.. (bugfix)") invalid_instances = [] for instance in instances: invalid = self.is_invalid(instance) if invalid: invalid_instances.append(instance) if invalid_instances: self.log.info("Reset pyblish to collect your current scene state, " "that should fix error.") raise PublishValidationError( "Invalid instances: {0}".format(invalid_instances), title=self.label) def is_invalid(self, instance): saver = instance.data["tool"] attr = saver.GetAttrs() active = not attr["TOOLB_PassThrough"] if active != instance.data.get("publish", True): self.log.info("Saver has different passthrough state than " "Pyblish: {} ({})".format(instance, saver.Name)) return [saver] return [] ================================================ FILE: openpype/hosts/fusion/plugins/publish/validate_saver_resolution.py ================================================ import pyblish.api from openpype.pipeline import ( PublishValidationError, OptionalPyblishPluginMixin, ) from openpype.hosts.fusion.api.action import SelectInvalidAction from openpype.hosts.fusion.api import comp_lock_and_undo_chunk class ValidateSaverResolution( pyblish.api.InstancePlugin, OptionalPyblishPluginMixin ): """Validate that the saver input resolution matches the asset resolution""" order = pyblish.api.ValidatorOrder label = "Validate Asset Resolution" families = ["render", "image"] hosts = ["fusion"] optional = True actions = [SelectInvalidAction] def process(self, instance): if not self.is_active(instance.data): return resolution = self.get_resolution(instance) expected_resolution = self.get_expected_resolution(instance) if resolution != expected_resolution: raise PublishValidationError( "The input's resolution does not match " "the asset's resolution {}x{}.\n\n" "The input's resolution is {}x{}.".format( expected_resolution[0], expected_resolution[1], resolution[0], resolution[1] ) ) @classmethod def get_invalid(cls, instance): saver = instance.data["tool"] try: resolution = cls.get_resolution(instance) except PublishValidationError: resolution = None expected_resolution = cls.get_expected_resolution(instance) if resolution != expected_resolution: return [saver] @classmethod def get_resolution(cls, instance): saver = instance.data["tool"] first_frame = instance.data["frameStartHandle"] return cls.get_tool_resolution(saver, frame=first_frame) @classmethod def get_expected_resolution(cls, instance): data = instance.data["assetEntity"]["data"] return data["resolutionWidth"], data["resolutionHeight"] @classmethod def get_tool_resolution(cls, tool, frame): """Return the 2D input resolution to a Fusion tool If the current tool hasn't been rendered its input resolution hasn't been saved. To combat this, add an expression in the comments field to read the resolution Args tool (Fusion Tool): The tool to query input resolution frame (int): The frame to query the resolution on. Returns: tuple: width, height as 2-tuple of integers """ comp = tool.Composition # False undo removes the undo-stack from the undo list with comp_lock_and_undo_chunk(comp, "Read resolution", False): # Save old comment old_comment = "" has_expression = False if tool["Comments"][frame] not in ["", None]: if tool["Comments"].GetExpression() is not None: has_expression = True old_comment = tool["Comments"].GetExpression() tool["Comments"].SetExpression(None) else: old_comment = tool["Comments"][frame] tool["Comments"][frame] = "" # Get input width tool["Comments"].SetExpression("self.Input.OriginalWidth") if tool["Comments"][frame] is None: raise PublishValidationError( "Cannot get resolution info for frame '{}'.\n\n " "Please check that saver has connected input.".format( frame ) ) width = int(tool["Comments"][frame]) # Get input height tool["Comments"].SetExpression("self.Input.OriginalHeight") height = int(tool["Comments"][frame]) # Reset old comment tool["Comments"].SetExpression(None) if has_expression: tool["Comments"].SetExpression(old_comment) else: tool["Comments"][frame] = old_comment return width, height ================================================ FILE: openpype/hosts/fusion/plugins/publish/validate_unique_subsets.py ================================================ from collections import defaultdict import pyblish.api from openpype.pipeline import PublishValidationError from openpype.hosts.fusion.api.action import SelectInvalidAction class ValidateUniqueSubsets(pyblish.api.ContextPlugin): """Ensure all instances have a unique subset name""" order = pyblish.api.ValidatorOrder label = "Validate Unique Subsets" families = ["render", "image"] hosts = ["fusion"] actions = [SelectInvalidAction] @classmethod def get_invalid(cls, context): # Collect instances per subset per asset instances_per_subset_asset = defaultdict(lambda: defaultdict(list)) for instance in context: asset = instance.data.get("asset", context.data.get("asset")) subset = instance.data.get("subset", context.data.get("subset")) instances_per_subset_asset[asset][subset].append(instance) # Find which asset + subset combination has more than one instance # Those are considered invalid because they'd integrate to the same # destination. invalid = [] for asset, instances_per_subset in instances_per_subset_asset.items(): for subset, instances in instances_per_subset.items(): if len(instances) > 1: cls.log.warning( "{asset} > {subset} used by more than " "one instance: {instances}".format( asset=asset, subset=subset, instances=instances ) ) invalid.extend(instances) # Return tools for the invalid instances so they can be selected invalid = [instance.data["tool"] for instance in invalid] return invalid def process(self, context): invalid = self.get_invalid(context) if invalid: raise PublishValidationError("Multiple instances are set to " "the same asset > subset.", title=self.label) ================================================ FILE: openpype/hosts/fusion/scripts/__init__.py ================================================ ================================================ FILE: openpype/hosts/fusion/scripts/duplicate_with_inputs.py ================================================ from openpype.hosts.fusion.api import ( comp_lock_and_undo_chunk, get_current_comp ) def is_connected(input): """Return whether an input has incoming connection""" return input.GetAttrs()["INPB_Connected"] def duplicate_with_input_connections(): """Duplicate selected tools with incoming connections.""" comp = get_current_comp() original_tools = comp.GetToolList(True).values() if not original_tools: return # nothing selected with comp_lock_and_undo_chunk( comp, "Duplicate With Input Connections"): # Generate duplicates comp.Copy() comp.SetActiveTool() comp.Paste() duplicate_tools = comp.GetToolList(True).values() # Copy connections for original, new in zip(original_tools, duplicate_tools): original_inputs = original.GetInputList().values() new_inputs = new.GetInputList().values() assert len(original_inputs) == len(new_inputs) for original_input, new_input in zip(original_inputs, new_inputs): if is_connected(original_input): if is_connected(new_input): # Already connected if it is between the copied tools continue new_input.ConnectTo(original_input.GetConnectedOutput()) assert is_connected(new_input), "Must be connected now" ================================================ FILE: openpype/hosts/fusion/vendor/attr/__init__.py ================================================ from __future__ import absolute_import, division, print_function import sys from functools import partial from . import converters, exceptions, filters, setters, validators from ._cmp import cmp_using from ._config import get_run_validators, set_run_validators from ._funcs import asdict, assoc, astuple, evolve, has, resolve_types from ._make import ( NOTHING, Attribute, Factory, attrib, attrs, fields, fields_dict, make_class, validate, ) from ._version_info import VersionInfo __version__ = "21.2.0" __version_info__ = VersionInfo._from_version_string(__version__) __title__ = "attrs" __description__ = "Classes Without Boilerplate" __url__ = "https://www.attrs.org/" __uri__ = __url__ __doc__ = __description__ + " <" + __uri__ + ">" __author__ = "Hynek Schlawack" __email__ = "hs@ox.cx" __license__ = "MIT" __copyright__ = "Copyright (c) 2015 Hynek Schlawack" s = attributes = attrs ib = attr = attrib dataclass = partial(attrs, auto_attribs=True) # happy Easter ;) __all__ = [ "Attribute", "Factory", "NOTHING", "asdict", "assoc", "astuple", "attr", "attrib", "attributes", "attrs", "cmp_using", "converters", "evolve", "exceptions", "fields", "fields_dict", "filters", "get_run_validators", "has", "ib", "make_class", "resolve_types", "s", "set_run_validators", "setters", "validate", "validators", ] if sys.version_info[:2] >= (3, 6): from ._next_gen import define, field, frozen, mutable __all__.extend((define, field, frozen, mutable)) ================================================ FILE: openpype/hosts/fusion/vendor/attr/__init__.pyi ================================================ import sys from typing import ( Any, Callable, Dict, Generic, List, Mapping, Optional, Sequence, Tuple, Type, TypeVar, Union, overload, ) # `import X as X` is required to make these public from . import converters as converters from . import exceptions as exceptions from . import filters as filters from . import setters as setters from . import validators as validators from ._version_info import VersionInfo __version__: str __version_info__: VersionInfo __title__: str __description__: str __url__: str __uri__: str __author__: str __email__: str __license__: str __copyright__: str _T = TypeVar("_T") _C = TypeVar("_C", bound=type) _EqOrderType = Union[bool, Callable[[Any], Any]] _ValidatorType = Callable[[Any, Attribute[_T], _T], Any] _ConverterType = Callable[[Any], Any] _FilterType = Callable[[Attribute[_T], _T], bool] _ReprType = Callable[[Any], str] _ReprArgType = Union[bool, _ReprType] _OnSetAttrType = Callable[[Any, Attribute[Any], Any], Any] _OnSetAttrArgType = Union[ _OnSetAttrType, List[_OnSetAttrType], setters._NoOpType ] _FieldTransformer = Callable[[type, List[Attribute[Any]]], List[Attribute[Any]]] # FIXME: in reality, if multiple validators are passed they must be in a list # or tuple, but those are invariant and so would prevent subtypes of # _ValidatorType from working when passed in a list or tuple. _ValidatorArgType = Union[_ValidatorType[_T], Sequence[_ValidatorType[_T]]] # _make -- NOTHING: object # NOTE: Factory lies about its return type to make this possible: # `x: List[int] # = Factory(list)` # Work around mypy issue #4554 in the common case by using an overload. if sys.version_info >= (3, 8): from typing import Literal @overload def Factory(factory: Callable[[], _T]) -> _T: ... @overload def Factory( factory: Callable[[Any], _T], takes_self: Literal[True], ) -> _T: ... @overload def Factory( factory: Callable[[], _T], takes_self: Literal[False], ) -> _T: ... else: @overload def Factory(factory: Callable[[], _T]) -> _T: ... @overload def Factory( factory: Union[Callable[[Any], _T], Callable[[], _T]], takes_self: bool = ..., ) -> _T: ... # Static type inference support via __dataclass_transform__ implemented as per: # https://github.com/microsoft/pyright/blob/1.1.135/specs/dataclass_transforms.md # This annotation must be applied to all overloads of "define" and "attrs" # # NOTE: This is a typing construct and does not exist at runtime. Extensions # wrapping attrs decorators should declare a separate __dataclass_transform__ # signature in the extension module using the specification linked above to # provide pyright support. def __dataclass_transform__( *, eq_default: bool = True, order_default: bool = False, kw_only_default: bool = False, field_descriptors: Tuple[Union[type, Callable[..., Any]], ...] = (()), ) -> Callable[[_T], _T]: ... class Attribute(Generic[_T]): name: str default: Optional[_T] validator: Optional[_ValidatorType[_T]] repr: _ReprArgType cmp: _EqOrderType eq: _EqOrderType order: _EqOrderType hash: Optional[bool] init: bool converter: Optional[_ConverterType] metadata: Dict[Any, Any] type: Optional[Type[_T]] kw_only: bool on_setattr: _OnSetAttrType def evolve(self, **changes: Any) -> "Attribute[Any]": ... # NOTE: We had several choices for the annotation to use for type arg: # 1) Type[_T] # - Pros: Handles simple cases correctly # - Cons: Might produce less informative errors in the case of conflicting # TypeVars e.g. `attr.ib(default='bad', type=int)` # 2) Callable[..., _T] # - Pros: Better error messages than #1 for conflicting TypeVars # - Cons: Terrible error messages for validator checks. # e.g. attr.ib(type=int, validator=validate_str) # -> error: Cannot infer function type argument # 3) type (and do all of the work in the mypy plugin) # - Pros: Simple here, and we could customize the plugin with our own errors. # - Cons: Would need to write mypy plugin code to handle all the cases. # We chose option #1. # `attr` lies about its return type to make the following possible: # attr() -> Any # attr(8) -> int # attr(validator=) -> Whatever the callable expects. # This makes this type of assignments possible: # x: int = attr(8) # # This form catches explicit None or no default but with no other arguments # returns Any. @overload def attrib( default: None = ..., validator: None = ..., repr: _ReprArgType = ..., cmp: Optional[_EqOrderType] = ..., hash: Optional[bool] = ..., init: bool = ..., metadata: Optional[Mapping[Any, Any]] = ..., type: None = ..., converter: None = ..., factory: None = ..., kw_only: bool = ..., eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> Any: ... # This form catches an explicit None or no default and infers the type from the # other arguments. @overload def attrib( default: None = ..., validator: Optional[_ValidatorArgType[_T]] = ..., repr: _ReprArgType = ..., cmp: Optional[_EqOrderType] = ..., hash: Optional[bool] = ..., init: bool = ..., metadata: Optional[Mapping[Any, Any]] = ..., type: Optional[Type[_T]] = ..., converter: Optional[_ConverterType] = ..., factory: Optional[Callable[[], _T]] = ..., kw_only: bool = ..., eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> _T: ... # This form catches an explicit default argument. @overload def attrib( default: _T, validator: Optional[_ValidatorArgType[_T]] = ..., repr: _ReprArgType = ..., cmp: Optional[_EqOrderType] = ..., hash: Optional[bool] = ..., init: bool = ..., metadata: Optional[Mapping[Any, Any]] = ..., type: Optional[Type[_T]] = ..., converter: Optional[_ConverterType] = ..., factory: Optional[Callable[[], _T]] = ..., kw_only: bool = ..., eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> _T: ... # This form covers type=non-Type: e.g. forward references (str), Any @overload def attrib( default: Optional[_T] = ..., validator: Optional[_ValidatorArgType[_T]] = ..., repr: _ReprArgType = ..., cmp: Optional[_EqOrderType] = ..., hash: Optional[bool] = ..., init: bool = ..., metadata: Optional[Mapping[Any, Any]] = ..., type: object = ..., converter: Optional[_ConverterType] = ..., factory: Optional[Callable[[], _T]] = ..., kw_only: bool = ..., eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> Any: ... @overload def field( *, default: None = ..., validator: None = ..., repr: _ReprArgType = ..., hash: Optional[bool] = ..., init: bool = ..., metadata: Optional[Mapping[Any, Any]] = ..., converter: None = ..., factory: None = ..., kw_only: bool = ..., eq: Optional[bool] = ..., order: Optional[bool] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> Any: ... # This form catches an explicit None or no default and infers the type from the # other arguments. @overload def field( *, default: None = ..., validator: Optional[_ValidatorArgType[_T]] = ..., repr: _ReprArgType = ..., hash: Optional[bool] = ..., init: bool = ..., metadata: Optional[Mapping[Any, Any]] = ..., converter: Optional[_ConverterType] = ..., factory: Optional[Callable[[], _T]] = ..., kw_only: bool = ..., eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> _T: ... # This form catches an explicit default argument. @overload def field( *, default: _T, validator: Optional[_ValidatorArgType[_T]] = ..., repr: _ReprArgType = ..., hash: Optional[bool] = ..., init: bool = ..., metadata: Optional[Mapping[Any, Any]] = ..., converter: Optional[_ConverterType] = ..., factory: Optional[Callable[[], _T]] = ..., kw_only: bool = ..., eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> _T: ... # This form covers type=non-Type: e.g. forward references (str), Any @overload def field( *, default: Optional[_T] = ..., validator: Optional[_ValidatorArgType[_T]] = ..., repr: _ReprArgType = ..., hash: Optional[bool] = ..., init: bool = ..., metadata: Optional[Mapping[Any, Any]] = ..., converter: Optional[_ConverterType] = ..., factory: Optional[Callable[[], _T]] = ..., kw_only: bool = ..., eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., ) -> Any: ... @overload @__dataclass_transform__(order_default=True, field_descriptors=(attrib, field)) def attrs( maybe_cls: _C, these: Optional[Dict[str, Any]] = ..., repr_ns: Optional[str] = ..., repr: bool = ..., cmp: Optional[_EqOrderType] = ..., hash: Optional[bool] = ..., init: bool = ..., slots: bool = ..., frozen: bool = ..., weakref_slot: bool = ..., str: bool = ..., auto_attribs: bool = ..., kw_only: bool = ..., cache_hash: bool = ..., auto_exc: bool = ..., eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., auto_detect: bool = ..., collect_by_mro: bool = ..., getstate_setstate: Optional[bool] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., field_transformer: Optional[_FieldTransformer] = ..., ) -> _C: ... @overload @__dataclass_transform__(order_default=True, field_descriptors=(attrib, field)) def attrs( maybe_cls: None = ..., these: Optional[Dict[str, Any]] = ..., repr_ns: Optional[str] = ..., repr: bool = ..., cmp: Optional[_EqOrderType] = ..., hash: Optional[bool] = ..., init: bool = ..., slots: bool = ..., frozen: bool = ..., weakref_slot: bool = ..., str: bool = ..., auto_attribs: bool = ..., kw_only: bool = ..., cache_hash: bool = ..., auto_exc: bool = ..., eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., auto_detect: bool = ..., collect_by_mro: bool = ..., getstate_setstate: Optional[bool] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., field_transformer: Optional[_FieldTransformer] = ..., ) -> Callable[[_C], _C]: ... @overload @__dataclass_transform__(field_descriptors=(attrib, field)) def define( maybe_cls: _C, *, these: Optional[Dict[str, Any]] = ..., repr: bool = ..., hash: Optional[bool] = ..., init: bool = ..., slots: bool = ..., frozen: bool = ..., weakref_slot: bool = ..., str: bool = ..., auto_attribs: bool = ..., kw_only: bool = ..., cache_hash: bool = ..., auto_exc: bool = ..., eq: Optional[bool] = ..., order: Optional[bool] = ..., auto_detect: bool = ..., getstate_setstate: Optional[bool] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., field_transformer: Optional[_FieldTransformer] = ..., ) -> _C: ... @overload @__dataclass_transform__(field_descriptors=(attrib, field)) def define( maybe_cls: None = ..., *, these: Optional[Dict[str, Any]] = ..., repr: bool = ..., hash: Optional[bool] = ..., init: bool = ..., slots: bool = ..., frozen: bool = ..., weakref_slot: bool = ..., str: bool = ..., auto_attribs: bool = ..., kw_only: bool = ..., cache_hash: bool = ..., auto_exc: bool = ..., eq: Optional[bool] = ..., order: Optional[bool] = ..., auto_detect: bool = ..., getstate_setstate: Optional[bool] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., field_transformer: Optional[_FieldTransformer] = ..., ) -> Callable[[_C], _C]: ... mutable = define frozen = define # they differ only in their defaults # TODO: add support for returning NamedTuple from the mypy plugin class _Fields(Tuple[Attribute[Any], ...]): def __getattr__(self, name: str) -> Attribute[Any]: ... def fields(cls: type) -> _Fields: ... def fields_dict(cls: type) -> Dict[str, Attribute[Any]]: ... def validate(inst: Any) -> None: ... def resolve_types( cls: _C, globalns: Optional[Dict[str, Any]] = ..., localns: Optional[Dict[str, Any]] = ..., attribs: Optional[List[Attribute[Any]]] = ..., ) -> _C: ... # TODO: add support for returning a proper attrs class from the mypy plugin # we use Any instead of _CountingAttr so that e.g. `make_class('Foo', # [attr.ib()])` is valid def make_class( name: str, attrs: Union[List[str], Tuple[str, ...], Dict[str, Any]], bases: Tuple[type, ...] = ..., repr_ns: Optional[str] = ..., repr: bool = ..., cmp: Optional[_EqOrderType] = ..., hash: Optional[bool] = ..., init: bool = ..., slots: bool = ..., frozen: bool = ..., weakref_slot: bool = ..., str: bool = ..., auto_attribs: bool = ..., kw_only: bool = ..., cache_hash: bool = ..., auto_exc: bool = ..., eq: Optional[_EqOrderType] = ..., order: Optional[_EqOrderType] = ..., collect_by_mro: bool = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., field_transformer: Optional[_FieldTransformer] = ..., ) -> type: ... # _funcs -- # TODO: add support for returning TypedDict from the mypy plugin # FIXME: asdict/astuple do not honor their factory args. Waiting on one of # these: # https://github.com/python/mypy/issues/4236 # https://github.com/python/typing/issues/253 def asdict( inst: Any, recurse: bool = ..., filter: Optional[_FilterType[Any]] = ..., dict_factory: Type[Mapping[Any, Any]] = ..., retain_collection_types: bool = ..., value_serializer: Optional[Callable[[type, Attribute[Any], Any], Any]] = ..., ) -> Dict[str, Any]: ... # TODO: add support for returning NamedTuple from the mypy plugin def astuple( inst: Any, recurse: bool = ..., filter: Optional[_FilterType[Any]] = ..., tuple_factory: Type[Sequence[Any]] = ..., retain_collection_types: bool = ..., ) -> Tuple[Any, ...]: ... def has(cls: type) -> bool: ... def assoc(inst: _T, **changes: Any) -> _T: ... def evolve(inst: _T, **changes: Any) -> _T: ... # _config -- def set_run_validators(run: bool) -> None: ... def get_run_validators() -> bool: ... # aliases -- s = attributes = attrs ib = attr = attrib dataclass = attrs # Technically, partial(attrs, auto_attribs=True) ;) ================================================ FILE: openpype/hosts/fusion/vendor/attr/_cmp.py ================================================ from __future__ import absolute_import, division, print_function import functools from ._compat import new_class from ._make import _make_ne _operation_names = {"eq": "==", "lt": "<", "le": "<=", "gt": ">", "ge": ">="} def cmp_using( eq=None, lt=None, le=None, gt=None, ge=None, require_same_type=True, class_name="Comparable", ): """ Create a class that can be passed into `attr.ib`'s ``eq``, ``order``, and ``cmp`` arguments to customize field comparison. The resulting class will have a full set of ordering methods if at least one of ``{lt, le, gt, ge}`` and ``eq`` are provided. :param Optional[callable] eq: `callable` used to evaluate equality of two objects. :param Optional[callable] lt: `callable` used to evaluate whether one object is less than another object. :param Optional[callable] le: `callable` used to evaluate whether one object is less than or equal to another object. :param Optional[callable] gt: `callable` used to evaluate whether one object is greater than another object. :param Optional[callable] ge: `callable` used to evaluate whether one object is greater than or equal to another object. :param bool require_same_type: When `True`, equality and ordering methods will return `NotImplemented` if objects are not of the same type. :param Optional[str] class_name: Name of class. Defaults to 'Comparable'. See `comparison` for more details. .. versionadded:: 21.1.0 """ body = { "__slots__": ["value"], "__init__": _make_init(), "_requirements": [], "_is_comparable_to": _is_comparable_to, } # Add operations. num_order_functions = 0 has_eq_function = False if eq is not None: has_eq_function = True body["__eq__"] = _make_operator("eq", eq) body["__ne__"] = _make_ne() if lt is not None: num_order_functions += 1 body["__lt__"] = _make_operator("lt", lt) if le is not None: num_order_functions += 1 body["__le__"] = _make_operator("le", le) if gt is not None: num_order_functions += 1 body["__gt__"] = _make_operator("gt", gt) if ge is not None: num_order_functions += 1 body["__ge__"] = _make_operator("ge", ge) type_ = new_class(class_name, (object,), {}, lambda ns: ns.update(body)) # Add same type requirement. if require_same_type: type_._requirements.append(_check_same_type) # Add total ordering if at least one operation was defined. if 0 < num_order_functions < 4: if not has_eq_function: # functools.total_ordering requires __eq__ to be defined, # so raise early error here to keep a nice stack. raise ValueError( "eq must be define is order to complete ordering from " "lt, le, gt, ge." ) type_ = functools.total_ordering(type_) return type_ def _make_init(): """ Create __init__ method. """ def __init__(self, value): """ Initialize object with *value*. """ self.value = value return __init__ def _make_operator(name, func): """ Create operator method. """ def method(self, other): if not self._is_comparable_to(other): return NotImplemented result = func(self.value, other.value) if result is NotImplemented: return NotImplemented return result method.__name__ = "__%s__" % (name,) method.__doc__ = "Return a %s b. Computed by attrs." % ( _operation_names[name], ) return method def _is_comparable_to(self, other): """ Check whether `other` is comparable to `self`. """ for func in self._requirements: if not func(self, other): return False return True def _check_same_type(self, other): """ Return True if *self* and *other* are of the same type, False otherwise. """ return other.value.__class__ is self.value.__class__ ================================================ FILE: openpype/hosts/fusion/vendor/attr/_cmp.pyi ================================================ from typing import Type from . import _CompareWithType def cmp_using( eq: Optional[_CompareWithType], lt: Optional[_CompareWithType], le: Optional[_CompareWithType], gt: Optional[_CompareWithType], ge: Optional[_CompareWithType], require_same_type: bool, class_name: str, ) -> Type: ... ================================================ FILE: openpype/hosts/fusion/vendor/attr/_compat.py ================================================ from __future__ import absolute_import, division, print_function import platform import sys import types import warnings PY2 = sys.version_info[0] == 2 PYPY = platform.python_implementation() == "PyPy" if PYPY or sys.version_info[:2] >= (3, 6): ordered_dict = dict else: from collections import OrderedDict ordered_dict = OrderedDict if PY2: from collections import Mapping, Sequence from UserDict import IterableUserDict # We 'bundle' isclass instead of using inspect as importing inspect is # fairly expensive (order of 10-15 ms for a modern machine in 2016) def isclass(klass): return isinstance(klass, (type, types.ClassType)) def new_class(name, bases, kwds, exec_body): """ A minimal stub of types.new_class that we need for make_class. """ ns = {} exec_body(ns) return type(name, bases, ns) # TYPE is used in exceptions, repr(int) is different on Python 2 and 3. TYPE = "type" def iteritems(d): return d.iteritems() # Python 2 is bereft of a read-only dict proxy, so we make one! class ReadOnlyDict(IterableUserDict): """ Best-effort read-only dict wrapper. """ def __setitem__(self, key, val): # We gently pretend we're a Python 3 mappingproxy. raise TypeError( "'mappingproxy' object does not support item assignment" ) def update(self, _): # We gently pretend we're a Python 3 mappingproxy. raise AttributeError( "'mappingproxy' object has no attribute 'update'" ) def __delitem__(self, _): # We gently pretend we're a Python 3 mappingproxy. raise TypeError( "'mappingproxy' object does not support item deletion" ) def clear(self): # We gently pretend we're a Python 3 mappingproxy. raise AttributeError( "'mappingproxy' object has no attribute 'clear'" ) def pop(self, key, default=None): # We gently pretend we're a Python 3 mappingproxy. raise AttributeError( "'mappingproxy' object has no attribute 'pop'" ) def popitem(self): # We gently pretend we're a Python 3 mappingproxy. raise AttributeError( "'mappingproxy' object has no attribute 'popitem'" ) def setdefault(self, key, default=None): # We gently pretend we're a Python 3 mappingproxy. raise AttributeError( "'mappingproxy' object has no attribute 'setdefault'" ) def __repr__(self): # Override to be identical to the Python 3 version. return "mappingproxy(" + repr(self.data) + ")" def metadata_proxy(d): res = ReadOnlyDict() res.data.update(d) # We blocked update, so we have to do it like this. return res def just_warn(*args, **kw): # pragma: no cover """ We only warn on Python 3 because we are not aware of any concrete consequences of not setting the cell on Python 2. """ else: # Python 3 and later. from collections.abc import Mapping, Sequence # noqa def just_warn(*args, **kw): """ We only warn on Python 3 because we are not aware of any concrete consequences of not setting the cell on Python 2. """ warnings.warn( "Running interpreter doesn't sufficiently support code object " "introspection. Some features like bare super() or accessing " "__class__ will not work with slotted classes.", RuntimeWarning, stacklevel=2, ) def isclass(klass): return isinstance(klass, type) TYPE = "class" def iteritems(d): return d.items() new_class = types.new_class def metadata_proxy(d): return types.MappingProxyType(dict(d)) def make_set_closure_cell(): """Return a function of two arguments (cell, value) which sets the value stored in the closure cell `cell` to `value`. """ # pypy makes this easy. (It also supports the logic below, but # why not do the easy/fast thing?) if PYPY: def set_closure_cell(cell, value): cell.__setstate__((value,)) return set_closure_cell # Otherwise gotta do it the hard way. # Create a function that will set its first cellvar to `value`. def set_first_cellvar_to(value): x = value return # This function will be eliminated as dead code, but # not before its reference to `x` forces `x` to be # represented as a closure cell rather than a local. def force_x_to_be_a_cell(): # pragma: no cover return x try: # Extract the code object and make sure our assumptions about # the closure behavior are correct. if PY2: co = set_first_cellvar_to.func_code else: co = set_first_cellvar_to.__code__ if co.co_cellvars != ("x",) or co.co_freevars != (): raise AssertionError # pragma: no cover # Convert this code object to a code object that sets the # function's first _freevar_ (not cellvar) to the argument. if sys.version_info >= (3, 8): # CPython 3.8+ has an incompatible CodeType signature # (added a posonlyargcount argument) but also added # CodeType.replace() to do this without counting parameters. set_first_freevar_code = co.replace( co_cellvars=co.co_freevars, co_freevars=co.co_cellvars ) else: args = [co.co_argcount] if not PY2: args.append(co.co_kwonlyargcount) args.extend( [ co.co_nlocals, co.co_stacksize, co.co_flags, co.co_code, co.co_consts, co.co_names, co.co_varnames, co.co_filename, co.co_name, co.co_firstlineno, co.co_lnotab, # These two arguments are reversed: co.co_cellvars, co.co_freevars, ] ) set_first_freevar_code = types.CodeType(*args) def set_closure_cell(cell, value): # Create a function using the set_first_freevar_code, # whose first closure cell is `cell`. Calling it will # change the value of that cell. setter = types.FunctionType( set_first_freevar_code, {}, "setter", (), (cell,) ) # And call it to set the cell. setter(value) # Make sure it works on this interpreter: def make_func_with_cell(): x = None def func(): return x # pragma: no cover return func if PY2: cell = make_func_with_cell().func_closure[0] else: cell = make_func_with_cell().__closure__[0] set_closure_cell(cell, 100) if cell.cell_contents != 100: raise AssertionError # pragma: no cover except Exception: return just_warn else: return set_closure_cell set_closure_cell = make_set_closure_cell() ================================================ FILE: openpype/hosts/fusion/vendor/attr/_config.py ================================================ from __future__ import absolute_import, division, print_function __all__ = ["set_run_validators", "get_run_validators"] _run_validators = True def set_run_validators(run): """ Set whether or not validators are run. By default, they are run. """ if not isinstance(run, bool): raise TypeError("'run' must be bool.") global _run_validators _run_validators = run def get_run_validators(): """ Return whether or not validators are run. """ return _run_validators ================================================ FILE: openpype/hosts/fusion/vendor/attr/_funcs.py ================================================ from __future__ import absolute_import, division, print_function import copy from ._compat import iteritems from ._make import NOTHING, _obj_setattr, fields from .exceptions import AttrsAttributeNotFoundError def asdict( inst, recurse=True, filter=None, dict_factory=dict, retain_collection_types=False, value_serializer=None, ): """ Return the ``attrs`` attribute values of *inst* as a dict. Optionally recurse into other ``attrs``-decorated classes. :param inst: Instance of an ``attrs``-decorated class. :param bool recurse: Recurse into classes that are also ``attrs``-decorated. :param callable filter: A callable whose return code determines whether an attribute or element is included (``True``) or dropped (``False``). Is called with the `attr.Attribute` as the first argument and the value as the second argument. :param callable dict_factory: A callable to produce dictionaries from. For example, to produce ordered dictionaries instead of normal Python dictionaries, pass in ``collections.OrderedDict``. :param bool retain_collection_types: Do not convert to ``list`` when encountering an attribute whose type is ``tuple`` or ``set``. Only meaningful if ``recurse`` is ``True``. :param Optional[callable] value_serializer: A hook that is called for every attribute or dict key/value. It receives the current instance, field and value and must return the (updated) value. The hook is run *after* the optional *filter* has been applied. :rtype: return type of *dict_factory* :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` class. .. versionadded:: 16.0.0 *dict_factory* .. versionadded:: 16.1.0 *retain_collection_types* .. versionadded:: 20.3.0 *value_serializer* """ attrs = fields(inst.__class__) rv = dict_factory() for a in attrs: v = getattr(inst, a.name) if filter is not None and not filter(a, v): continue if value_serializer is not None: v = value_serializer(inst, a, v) if recurse is True: if has(v.__class__): rv[a.name] = asdict( v, True, filter, dict_factory, retain_collection_types, value_serializer, ) elif isinstance(v, (tuple, list, set, frozenset)): cf = v.__class__ if retain_collection_types is True else list rv[a.name] = cf( [ _asdict_anything( i, filter, dict_factory, retain_collection_types, value_serializer, ) for i in v ] ) elif isinstance(v, dict): df = dict_factory rv[a.name] = df( ( _asdict_anything( kk, filter, df, retain_collection_types, value_serializer, ), _asdict_anything( vv, filter, df, retain_collection_types, value_serializer, ), ) for kk, vv in iteritems(v) ) else: rv[a.name] = v else: rv[a.name] = v return rv def _asdict_anything( val, filter, dict_factory, retain_collection_types, value_serializer, ): """ ``asdict`` only works on attrs instances, this works on anything. """ if getattr(val.__class__, "__attrs_attrs__", None) is not None: # Attrs class. rv = asdict( val, True, filter, dict_factory, retain_collection_types, value_serializer, ) elif isinstance(val, (tuple, list, set, frozenset)): cf = val.__class__ if retain_collection_types is True else list rv = cf( [ _asdict_anything( i, filter, dict_factory, retain_collection_types, value_serializer, ) for i in val ] ) elif isinstance(val, dict): df = dict_factory rv = df( ( _asdict_anything( kk, filter, df, retain_collection_types, value_serializer ), _asdict_anything( vv, filter, df, retain_collection_types, value_serializer ), ) for kk, vv in iteritems(val) ) else: rv = val if value_serializer is not None: rv = value_serializer(None, None, rv) return rv def astuple( inst, recurse=True, filter=None, tuple_factory=tuple, retain_collection_types=False, ): """ Return the ``attrs`` attribute values of *inst* as a tuple. Optionally recurse into other ``attrs``-decorated classes. :param inst: Instance of an ``attrs``-decorated class. :param bool recurse: Recurse into classes that are also ``attrs``-decorated. :param callable filter: A callable whose return code determines whether an attribute or element is included (``True``) or dropped (``False``). Is called with the `attr.Attribute` as the first argument and the value as the second argument. :param callable tuple_factory: A callable to produce tuples from. For example, to produce lists instead of tuples. :param bool retain_collection_types: Do not convert to ``list`` or ``dict`` when encountering an attribute which type is ``tuple``, ``dict`` or ``set``. Only meaningful if ``recurse`` is ``True``. :rtype: return type of *tuple_factory* :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` class. .. versionadded:: 16.2.0 """ attrs = fields(inst.__class__) rv = [] retain = retain_collection_types # Very long. :/ for a in attrs: v = getattr(inst, a.name) if filter is not None and not filter(a, v): continue if recurse is True: if has(v.__class__): rv.append( astuple( v, recurse=True, filter=filter, tuple_factory=tuple_factory, retain_collection_types=retain, ) ) elif isinstance(v, (tuple, list, set, frozenset)): cf = v.__class__ if retain is True else list rv.append( cf( [ astuple( j, recurse=True, filter=filter, tuple_factory=tuple_factory, retain_collection_types=retain, ) if has(j.__class__) else j for j in v ] ) ) elif isinstance(v, dict): df = v.__class__ if retain is True else dict rv.append( df( ( astuple( kk, tuple_factory=tuple_factory, retain_collection_types=retain, ) if has(kk.__class__) else kk, astuple( vv, tuple_factory=tuple_factory, retain_collection_types=retain, ) if has(vv.__class__) else vv, ) for kk, vv in iteritems(v) ) ) else: rv.append(v) else: rv.append(v) return rv if tuple_factory is list else tuple_factory(rv) def has(cls): """ Check whether *cls* is a class with ``attrs`` attributes. :param type cls: Class to introspect. :raise TypeError: If *cls* is not a class. :rtype: bool """ return getattr(cls, "__attrs_attrs__", None) is not None def assoc(inst, **changes): """ Copy *inst* and apply *changes*. :param inst: Instance of a class with ``attrs`` attributes. :param changes: Keyword changes in the new copy. :return: A copy of inst with *changes* incorporated. :raise attr.exceptions.AttrsAttributeNotFoundError: If *attr_name* couldn't be found on *cls*. :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` class. .. deprecated:: 17.1.0 Use `evolve` instead. """ import warnings warnings.warn( "assoc is deprecated and will be removed after 2018/01.", DeprecationWarning, stacklevel=2, ) new = copy.copy(inst) attrs = fields(inst.__class__) for k, v in iteritems(changes): a = getattr(attrs, k, NOTHING) if a is NOTHING: raise AttrsAttributeNotFoundError( "{k} is not an attrs attribute on {cl}.".format( k=k, cl=new.__class__ ) ) _obj_setattr(new, k, v) return new def evolve(inst, **changes): """ Create a new instance, based on *inst* with *changes* applied. :param inst: Instance of a class with ``attrs`` attributes. :param changes: Keyword changes in the new copy. :return: A copy of inst with *changes* incorporated. :raise TypeError: If *attr_name* couldn't be found in the class ``__init__``. :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` class. .. versionadded:: 17.1.0 """ cls = inst.__class__ attrs = fields(cls) for a in attrs: if not a.init: continue attr_name = a.name # To deal with private attributes. init_name = attr_name if attr_name[0] != "_" else attr_name[1:] if init_name not in changes: changes[init_name] = getattr(inst, attr_name) return cls(**changes) def resolve_types(cls, globalns=None, localns=None, attribs=None): """ Resolve any strings and forward annotations in type annotations. This is only required if you need concrete types in `Attribute`'s *type* field. In other words, you don't need to resolve your types if you only use them for static type checking. With no arguments, names will be looked up in the module in which the class was created. If this is not what you want, e.g. if the name only exists inside a method, you may pass *globalns* or *localns* to specify other dictionaries in which to look up these names. See the docs of `typing.get_type_hints` for more details. :param type cls: Class to resolve. :param Optional[dict] globalns: Dictionary containing global variables. :param Optional[dict] localns: Dictionary containing local variables. :param Optional[list] attribs: List of attribs for the given class. This is necessary when calling from inside a ``field_transformer`` since *cls* is not an ``attrs`` class yet. :raise TypeError: If *cls* is not a class. :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` class and you didn't pass any attribs. :raise NameError: If types cannot be resolved because of missing variables. :returns: *cls* so you can use this function also as a class decorator. Please note that you have to apply it **after** `attr.s`. That means the decorator has to come in the line **before** `attr.s`. .. versionadded:: 20.1.0 .. versionadded:: 21.1.0 *attribs* """ try: # Since calling get_type_hints is expensive we cache whether we've # done it already. cls.__attrs_types_resolved__ except AttributeError: import typing hints = typing.get_type_hints(cls, globalns=globalns, localns=localns) for field in fields(cls) if attribs is None else attribs: if field.name in hints: # Since fields have been frozen we must work around it. _obj_setattr(field, "type", hints[field.name]) cls.__attrs_types_resolved__ = True # Return the class so you can use it as a decorator too. return cls ================================================ FILE: openpype/hosts/fusion/vendor/attr/_make.py ================================================ from __future__ import absolute_import, division, print_function import copy import inspect import linecache import sys import threading import uuid import warnings from operator import itemgetter from . import _config, setters from ._compat import ( PY2, PYPY, isclass, iteritems, metadata_proxy, new_class, ordered_dict, set_closure_cell, ) from .exceptions import ( DefaultAlreadySetError, FrozenInstanceError, NotAnAttrsClassError, PythonTooOldError, UnannotatedAttributeError, ) if not PY2: import typing # This is used at least twice, so cache it here. _obj_setattr = object.__setattr__ _init_converter_pat = "__attr_converter_%s" _init_factory_pat = "__attr_factory_{}" _tuple_property_pat = ( " {attr_name} = _attrs_property(_attrs_itemgetter({index}))" ) _classvar_prefixes = ( "typing.ClassVar", "t.ClassVar", "ClassVar", "typing_extensions.ClassVar", ) # we don't use a double-underscore prefix because that triggers # name mangling when trying to create a slot for the field # (when slots=True) _hash_cache_field = "_attrs_cached_hash" _empty_metadata_singleton = metadata_proxy({}) # Unique object for unequivocal getattr() defaults. _sentinel = object() class _Nothing(object): """ Sentinel class to indicate the lack of a value when ``None`` is ambiguous. ``_Nothing`` is a singleton. There is only ever one of it. .. versionchanged:: 21.1.0 ``bool(NOTHING)`` is now False. """ _singleton = None def __new__(cls): if _Nothing._singleton is None: _Nothing._singleton = super(_Nothing, cls).__new__(cls) return _Nothing._singleton def __repr__(self): return "NOTHING" def __bool__(self): return False def __len__(self): return 0 # __bool__ for Python 2 NOTHING = _Nothing() """ Sentinel to indicate the lack of a value when ``None`` is ambiguous. """ class _CacheHashWrapper(int): """ An integer subclass that pickles / copies as None This is used for non-slots classes with ``cache_hash=True``, to avoid serializing a potentially (even likely) invalid hash value. Since ``None`` is the default value for uncalculated hashes, whenever this is copied, the copy's value for the hash should automatically reset. See GH #613 for more details. """ if PY2: # For some reason `type(None)` isn't callable in Python 2, but we don't # actually need a constructor for None objects, we just need any # available function that returns None. def __reduce__(self, _none_constructor=getattr, _args=(0, "", None)): return _none_constructor, _args else: def __reduce__(self, _none_constructor=type(None), _args=()): return _none_constructor, _args def attrib( default=NOTHING, validator=None, repr=True, cmp=None, hash=None, init=True, metadata=None, type=None, converter=None, factory=None, kw_only=False, eq=None, order=None, on_setattr=None, ): """ Create a new attribute on a class. .. warning:: Does *not* do anything unless the class is also decorated with `attr.s`! :param default: A value that is used if an ``attrs``-generated ``__init__`` is used and no value is passed while instantiating or the attribute is excluded using ``init=False``. If the value is an instance of `Factory`, its callable will be used to construct a new value (useful for mutable data types like lists or dicts). If a default is not set (or set manually to `attr.NOTHING`), a value *must* be supplied when instantiating; otherwise a `TypeError` will be raised. The default can also be set using decorator notation as shown below. :type default: Any value :param callable factory: Syntactic sugar for ``default=attr.Factory(factory)``. :param validator: `callable` that is called by ``attrs``-generated ``__init__`` methods after the instance has been initialized. They receive the initialized instance, the `Attribute`, and the passed value. The return value is *not* inspected so the validator has to throw an exception itself. If a `list` is passed, its items are treated as validators and must all pass. Validators can be globally disabled and re-enabled using `get_run_validators`. The validator can also be set using decorator notation as shown below. :type validator: `callable` or a `list` of `callable`\\ s. :param repr: Include this attribute in the generated ``__repr__`` method. If ``True``, include the attribute; if ``False``, omit it. By default, the built-in ``repr()`` function is used. To override how the attribute value is formatted, pass a ``callable`` that takes a single value and returns a string. Note that the resulting string is used as-is, i.e. it will be used directly *instead* of calling ``repr()`` (the default). :type repr: a `bool` or a `callable` to use a custom function. :param eq: If ``True`` (default), include this attribute in the generated ``__eq__`` and ``__ne__`` methods that check two instances for equality. To override how the attribute value is compared, pass a ``callable`` that takes a single value and returns the value to be compared. :type eq: a `bool` or a `callable`. :param order: If ``True`` (default), include this attributes in the generated ``__lt__``, ``__le__``, ``__gt__`` and ``__ge__`` methods. To override how the attribute value is ordered, pass a ``callable`` that takes a single value and returns the value to be ordered. :type order: a `bool` or a `callable`. :param cmp: Setting *cmp* is equivalent to setting *eq* and *order* to the same value. Must not be mixed with *eq* or *order*. :type cmp: a `bool` or a `callable`. :param Optional[bool] hash: Include this attribute in the generated ``__hash__`` method. If ``None`` (default), mirror *eq*'s value. This is the correct behavior according the Python spec. Setting this value to anything else than ``None`` is *discouraged*. :param bool init: Include this attribute in the generated ``__init__`` method. It is possible to set this to ``False`` and set a default value. In that case this attributed is unconditionally initialized with the specified default value or factory. :param callable converter: `callable` that is called by ``attrs``-generated ``__init__`` methods to convert attribute's value to the desired format. It is given the passed-in value, and the returned value will be used as the new value of the attribute. The value is converted before being passed to the validator, if any. :param metadata: An arbitrary mapping, to be used by third-party components. See `extending_metadata`. :param type: The type of the attribute. In Python 3.6 or greater, the preferred method to specify the type is using a variable annotation (see `PEP 526 `_). This argument is provided for backward compatibility. Regardless of the approach used, the type will be stored on ``Attribute.type``. Please note that ``attrs`` doesn't do anything with this metadata by itself. You can use it as part of your own code or for `static type checking `. :param kw_only: Make this attribute keyword-only (Python 3+) in the generated ``__init__`` (if ``init`` is ``False``, this parameter is ignored). :param on_setattr: Allows to overwrite the *on_setattr* setting from `attr.s`. If left `None`, the *on_setattr* value from `attr.s` is used. Set to `attr.setters.NO_OP` to run **no** `setattr` hooks for this attribute -- regardless of the setting in `attr.s`. :type on_setattr: `callable`, or a list of callables, or `None`, or `attr.setters.NO_OP` .. versionadded:: 15.2.0 *convert* .. versionadded:: 16.3.0 *metadata* .. versionchanged:: 17.1.0 *validator* can be a ``list`` now. .. versionchanged:: 17.1.0 *hash* is ``None`` and therefore mirrors *eq* by default. .. versionadded:: 17.3.0 *type* .. deprecated:: 17.4.0 *convert* .. versionadded:: 17.4.0 *converter* as a replacement for the deprecated *convert* to achieve consistency with other noun-based arguments. .. versionadded:: 18.1.0 ``factory=f`` is syntactic sugar for ``default=attr.Factory(f)``. .. versionadded:: 18.2.0 *kw_only* .. versionchanged:: 19.2.0 *convert* keyword argument removed. .. versionchanged:: 19.2.0 *repr* also accepts a custom callable. .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01. .. versionadded:: 19.2.0 *eq* and *order* .. versionadded:: 20.1.0 *on_setattr* .. versionchanged:: 20.3.0 *kw_only* backported to Python 2 .. versionchanged:: 21.1.0 *eq*, *order*, and *cmp* also accept a custom callable .. versionchanged:: 21.1.0 *cmp* undeprecated """ eq, eq_key, order, order_key = _determine_attrib_eq_order( cmp, eq, order, True ) if hash is not None and hash is not True and hash is not False: raise TypeError( "Invalid value for hash. Must be True, False, or None." ) if factory is not None: if default is not NOTHING: raise ValueError( "The `default` and `factory` arguments are mutually " "exclusive." ) if not callable(factory): raise ValueError("The `factory` argument must be a callable.") default = Factory(factory) if metadata is None: metadata = {} # Apply syntactic sugar by auto-wrapping. if isinstance(on_setattr, (list, tuple)): on_setattr = setters.pipe(*on_setattr) if validator and isinstance(validator, (list, tuple)): validator = and_(*validator) if converter and isinstance(converter, (list, tuple)): converter = pipe(*converter) return _CountingAttr( default=default, validator=validator, repr=repr, cmp=None, hash=hash, init=init, converter=converter, metadata=metadata, type=type, kw_only=kw_only, eq=eq, eq_key=eq_key, order=order, order_key=order_key, on_setattr=on_setattr, ) def _compile_and_eval(script, globs, locs=None, filename=""): """ "Exec" the script with the given global (globs) and local (locs) variables. """ bytecode = compile(script, filename, "exec") eval(bytecode, globs, locs) def _make_method(name, script, filename, globs=None): """ Create the method with the script given and return the method object. """ locs = {} if globs is None: globs = {} _compile_and_eval(script, globs, locs, filename) # In order of debuggers like PDB being able to step through the code, # we add a fake linecache entry. linecache.cache[filename] = ( len(script), None, script.splitlines(True), filename, ) return locs[name] def _make_attr_tuple_class(cls_name, attr_names): """ Create a tuple subclass to hold `Attribute`s for an `attrs` class. The subclass is a bare tuple with properties for names. class MyClassAttributes(tuple): __slots__ = () x = property(itemgetter(0)) """ attr_class_name = "{}Attributes".format(cls_name) attr_class_template = [ "class {}(tuple):".format(attr_class_name), " __slots__ = ()", ] if attr_names: for i, attr_name in enumerate(attr_names): attr_class_template.append( _tuple_property_pat.format(index=i, attr_name=attr_name) ) else: attr_class_template.append(" pass") globs = {"_attrs_itemgetter": itemgetter, "_attrs_property": property} _compile_and_eval("\n".join(attr_class_template), globs) return globs[attr_class_name] # Tuple class for extracted attributes from a class definition. # `base_attrs` is a subset of `attrs`. _Attributes = _make_attr_tuple_class( "_Attributes", [ # all attributes to build dunder methods for "attrs", # attributes that have been inherited "base_attrs", # map inherited attributes to their originating classes "base_attrs_map", ], ) def _is_class_var(annot): """ Check whether *annot* is a typing.ClassVar. The string comparison hack is used to avoid evaluating all string annotations which would put attrs-based classes at a performance disadvantage compared to plain old classes. """ annot = str(annot) # Annotation can be quoted. if annot.startswith(("'", '"')) and annot.endswith(("'", '"')): annot = annot[1:-1] return annot.startswith(_classvar_prefixes) def _has_own_attribute(cls, attrib_name): """ Check whether *cls* defines *attrib_name* (and doesn't just inherit it). Requires Python 3. """ attr = getattr(cls, attrib_name, _sentinel) if attr is _sentinel: return False for base_cls in cls.__mro__[1:]: a = getattr(base_cls, attrib_name, None) if attr is a: return False return True def _get_annotations(cls): """ Get annotations for *cls*. """ if _has_own_attribute(cls, "__annotations__"): return cls.__annotations__ return {} def _counter_getter(e): """ Key function for sorting to avoid re-creating a lambda for every class. """ return e[1].counter def _collect_base_attrs(cls, taken_attr_names): """ Collect attr.ibs from base classes of *cls*, except *taken_attr_names*. """ base_attrs = [] base_attr_map = {} # A dictionary of base attrs to their classes. # Traverse the MRO and collect attributes. for base_cls in reversed(cls.__mro__[1:-1]): for a in getattr(base_cls, "__attrs_attrs__", []): if a.inherited or a.name in taken_attr_names: continue a = a.evolve(inherited=True) base_attrs.append(a) base_attr_map[a.name] = base_cls # For each name, only keep the freshest definition i.e. the furthest at the # back. base_attr_map is fine because it gets overwritten with every new # instance. filtered = [] seen = set() for a in reversed(base_attrs): if a.name in seen: continue filtered.insert(0, a) seen.add(a.name) return filtered, base_attr_map def _collect_base_attrs_broken(cls, taken_attr_names): """ Collect attr.ibs from base classes of *cls*, except *taken_attr_names*. N.B. *taken_attr_names* will be mutated. Adhere to the old incorrect behavior. Notably it collects from the front and considers inherited attributes which leads to the buggy behavior reported in #428. """ base_attrs = [] base_attr_map = {} # A dictionary of base attrs to their classes. # Traverse the MRO and collect attributes. for base_cls in cls.__mro__[1:-1]: for a in getattr(base_cls, "__attrs_attrs__", []): if a.name in taken_attr_names: continue a = a.evolve(inherited=True) taken_attr_names.add(a.name) base_attrs.append(a) base_attr_map[a.name] = base_cls return base_attrs, base_attr_map def _transform_attrs( cls, these, auto_attribs, kw_only, collect_by_mro, field_transformer ): """ Transform all `_CountingAttr`s on a class into `Attribute`s. If *these* is passed, use that and don't look for them on the class. *collect_by_mro* is True, collect them in the correct MRO order, otherwise use the old -- incorrect -- order. See #428. Return an `_Attributes`. """ cd = cls.__dict__ anns = _get_annotations(cls) if these is not None: ca_list = [(name, ca) for name, ca in iteritems(these)] if not isinstance(these, ordered_dict): ca_list.sort(key=_counter_getter) elif auto_attribs is True: ca_names = { name for name, attr in cd.items() if isinstance(attr, _CountingAttr) } ca_list = [] annot_names = set() for attr_name, type in anns.items(): if _is_class_var(type): continue annot_names.add(attr_name) a = cd.get(attr_name, NOTHING) if not isinstance(a, _CountingAttr): if a is NOTHING: a = attrib() else: a = attrib(default=a) ca_list.append((attr_name, a)) unannotated = ca_names - annot_names if len(unannotated) > 0: raise UnannotatedAttributeError( "The following `attr.ib`s lack a type annotation: " + ", ".join( sorted(unannotated, key=lambda n: cd.get(n).counter) ) + "." ) else: ca_list = sorted( ( (name, attr) for name, attr in cd.items() if isinstance(attr, _CountingAttr) ), key=lambda e: e[1].counter, ) own_attrs = [ Attribute.from_counting_attr( name=attr_name, ca=ca, type=anns.get(attr_name) ) for attr_name, ca in ca_list ] if collect_by_mro: base_attrs, base_attr_map = _collect_base_attrs( cls, {a.name for a in own_attrs} ) else: base_attrs, base_attr_map = _collect_base_attrs_broken( cls, {a.name for a in own_attrs} ) attr_names = [a.name for a in base_attrs + own_attrs] AttrsClass = _make_attr_tuple_class(cls.__name__, attr_names) if kw_only: own_attrs = [a.evolve(kw_only=True) for a in own_attrs] base_attrs = [a.evolve(kw_only=True) for a in base_attrs] attrs = AttrsClass(base_attrs + own_attrs) # Mandatory vs non-mandatory attr order only matters when they are part of # the __init__ signature and when they aren't kw_only (which are moved to # the end and can be mandatory or non-mandatory in any order, as they will # be specified as keyword args anyway). Check the order of those attrs: had_default = False for a in (a for a in attrs if a.init is not False and a.kw_only is False): if had_default is True and a.default is NOTHING: raise ValueError( "No mandatory attributes allowed after an attribute with a " "default value or factory. Attribute in question: %r" % (a,) ) if had_default is False and a.default is not NOTHING: had_default = True if field_transformer is not None: attrs = field_transformer(cls, attrs) return _Attributes((attrs, base_attrs, base_attr_map)) if PYPY: def _frozen_setattrs(self, name, value): """ Attached to frozen classes as __setattr__. """ if isinstance(self, BaseException) and name in ( "__cause__", "__context__", ): BaseException.__setattr__(self, name, value) return raise FrozenInstanceError() else: def _frozen_setattrs(self, name, value): """ Attached to frozen classes as __setattr__. """ raise FrozenInstanceError() def _frozen_delattrs(self, name): """ Attached to frozen classes as __delattr__. """ raise FrozenInstanceError() class _ClassBuilder(object): """ Iteratively build *one* class. """ __slots__ = ( "_attr_names", "_attrs", "_base_attr_map", "_base_names", "_cache_hash", "_cls", "_cls_dict", "_delete_attribs", "_frozen", "_has_pre_init", "_has_post_init", "_is_exc", "_on_setattr", "_slots", "_weakref_slot", "_has_own_setattr", "_has_custom_setattr", ) def __init__( self, cls, these, slots, frozen, weakref_slot, getstate_setstate, auto_attribs, kw_only, cache_hash, is_exc, collect_by_mro, on_setattr, has_custom_setattr, field_transformer, ): attrs, base_attrs, base_map = _transform_attrs( cls, these, auto_attribs, kw_only, collect_by_mro, field_transformer, ) self._cls = cls self._cls_dict = dict(cls.__dict__) if slots else {} self._attrs = attrs self._base_names = set(a.name for a in base_attrs) self._base_attr_map = base_map self._attr_names = tuple(a.name for a in attrs) self._slots = slots self._frozen = frozen self._weakref_slot = weakref_slot self._cache_hash = cache_hash self._has_pre_init = bool(getattr(cls, "__attrs_pre_init__", False)) self._has_post_init = bool(getattr(cls, "__attrs_post_init__", False)) self._delete_attribs = not bool(these) self._is_exc = is_exc self._on_setattr = on_setattr self._has_custom_setattr = has_custom_setattr self._has_own_setattr = False self._cls_dict["__attrs_attrs__"] = self._attrs if frozen: self._cls_dict["__setattr__"] = _frozen_setattrs self._cls_dict["__delattr__"] = _frozen_delattrs self._has_own_setattr = True if getstate_setstate: ( self._cls_dict["__getstate__"], self._cls_dict["__setstate__"], ) = self._make_getstate_setstate() def __repr__(self): return "<_ClassBuilder(cls={cls})>".format(cls=self._cls.__name__) def build_class(self): """ Finalize class based on the accumulated configuration. Builder cannot be used after calling this method. """ if self._slots is True: return self._create_slots_class() else: return self._patch_original_class() def _patch_original_class(self): """ Apply accumulated methods and return the class. """ cls = self._cls base_names = self._base_names # Clean class of attribute definitions (`attr.ib()`s). if self._delete_attribs: for name in self._attr_names: if ( name not in base_names and getattr(cls, name, _sentinel) is not _sentinel ): try: delattr(cls, name) except AttributeError: # This can happen if a base class defines a class # variable and we want to set an attribute with the # same name by using only a type annotation. pass # Attach our dunder methods. for name, value in self._cls_dict.items(): setattr(cls, name, value) # If we've inherited an attrs __setattr__ and don't write our own, # reset it to object's. if not self._has_own_setattr and getattr( cls, "__attrs_own_setattr__", False ): cls.__attrs_own_setattr__ = False if not self._has_custom_setattr: cls.__setattr__ = object.__setattr__ return cls def _create_slots_class(self): """ Build and return a new class with a `__slots__` attribute. """ cd = { k: v for k, v in iteritems(self._cls_dict) if k not in tuple(self._attr_names) + ("__dict__", "__weakref__") } # If our class doesn't have its own implementation of __setattr__ # (either from the user or by us), check the bases, if one of them has # an attrs-made __setattr__, that needs to be reset. We don't walk the # MRO because we only care about our immediate base classes. # XXX: This can be confused by subclassing a slotted attrs class with # XXX: a non-attrs class and subclass the resulting class with an attrs # XXX: class. See `test_slotted_confused` for details. For now that's # XXX: OK with us. if not self._has_own_setattr: cd["__attrs_own_setattr__"] = False if not self._has_custom_setattr: for base_cls in self._cls.__bases__: if base_cls.__dict__.get("__attrs_own_setattr__", False): cd["__setattr__"] = object.__setattr__ break # Traverse the MRO to collect existing slots # and check for an existing __weakref__. existing_slots = dict() weakref_inherited = False for base_cls in self._cls.__mro__[1:-1]: if base_cls.__dict__.get("__weakref__", None) is not None: weakref_inherited = True existing_slots.update( { name: getattr(base_cls, name) for name in getattr(base_cls, "__slots__", []) } ) base_names = set(self._base_names) names = self._attr_names if ( self._weakref_slot and "__weakref__" not in getattr(self._cls, "__slots__", ()) and "__weakref__" not in names and not weakref_inherited ): names += ("__weakref__",) # We only add the names of attributes that aren't inherited. # Setting __slots__ to inherited attributes wastes memory. slot_names = [name for name in names if name not in base_names] # There are slots for attributes from current class # that are defined in parent classes. # As their descriptors may be overriden by a child class, # we collect them here and update the class dict reused_slots = { slot: slot_descriptor for slot, slot_descriptor in iteritems(existing_slots) if slot in slot_names } slot_names = [name for name in slot_names if name not in reused_slots] cd.update(reused_slots) if self._cache_hash: slot_names.append(_hash_cache_field) cd["__slots__"] = tuple(slot_names) qualname = getattr(self._cls, "__qualname__", None) if qualname is not None: cd["__qualname__"] = qualname # Create new class based on old class and our methods. cls = type(self._cls)(self._cls.__name__, self._cls.__bases__, cd) # The following is a fix for # https://github.com/python-attrs/attrs/issues/102. On Python 3, # if a method mentions `__class__` or uses the no-arg super(), the # compiler will bake a reference to the class in the method itself # as `method.__closure__`. Since we replace the class with a # clone, we rewrite these references so it keeps working. for item in cls.__dict__.values(): if isinstance(item, (classmethod, staticmethod)): # Class- and staticmethods hide their functions inside. # These might need to be rewritten as well. closure_cells = getattr(item.__func__, "__closure__", None) elif isinstance(item, property): # Workaround for property `super()` shortcut (PY3-only). # There is no universal way for other descriptors. closure_cells = getattr(item.fget, "__closure__", None) else: closure_cells = getattr(item, "__closure__", None) if not closure_cells: # Catch None or the empty list. continue for cell in closure_cells: try: match = cell.cell_contents is self._cls except ValueError: # ValueError: Cell is empty pass else: if match: set_closure_cell(cell, cls) return cls def add_repr(self, ns): self._cls_dict["__repr__"] = self._add_method_dunders( _make_repr(self._attrs, ns=ns) ) return self def add_str(self): repr = self._cls_dict.get("__repr__") if repr is None: raise ValueError( "__str__ can only be generated if a __repr__ exists." ) def __str__(self): return self.__repr__() self._cls_dict["__str__"] = self._add_method_dunders(__str__) return self def _make_getstate_setstate(self): """ Create custom __setstate__ and __getstate__ methods. """ # __weakref__ is not writable. state_attr_names = tuple( an for an in self._attr_names if an != "__weakref__" ) def slots_getstate(self): """ Automatically created by attrs. """ return tuple(getattr(self, name) for name in state_attr_names) hash_caching_enabled = self._cache_hash def slots_setstate(self, state): """ Automatically created by attrs. """ __bound_setattr = _obj_setattr.__get__(self, Attribute) for name, value in zip(state_attr_names, state): __bound_setattr(name, value) # The hash code cache is not included when the object is # serialized, but it still needs to be initialized to None to # indicate that the first call to __hash__ should be a cache # miss. if hash_caching_enabled: __bound_setattr(_hash_cache_field, None) return slots_getstate, slots_setstate def make_unhashable(self): self._cls_dict["__hash__"] = None return self def add_hash(self): self._cls_dict["__hash__"] = self._add_method_dunders( _make_hash( self._cls, self._attrs, frozen=self._frozen, cache_hash=self._cache_hash, ) ) return self def add_init(self): self._cls_dict["__init__"] = self._add_method_dunders( _make_init( self._cls, self._attrs, self._has_pre_init, self._has_post_init, self._frozen, self._slots, self._cache_hash, self._base_attr_map, self._is_exc, self._on_setattr is not None and self._on_setattr is not setters.NO_OP, attrs_init=False, ) ) return self def add_attrs_init(self): self._cls_dict["__attrs_init__"] = self._add_method_dunders( _make_init( self._cls, self._attrs, self._has_pre_init, self._has_post_init, self._frozen, self._slots, self._cache_hash, self._base_attr_map, self._is_exc, self._on_setattr is not None and self._on_setattr is not setters.NO_OP, attrs_init=True, ) ) return self def add_eq(self): cd = self._cls_dict cd["__eq__"] = self._add_method_dunders( _make_eq(self._cls, self._attrs) ) cd["__ne__"] = self._add_method_dunders(_make_ne()) return self def add_order(self): cd = self._cls_dict cd["__lt__"], cd["__le__"], cd["__gt__"], cd["__ge__"] = ( self._add_method_dunders(meth) for meth in _make_order(self._cls, self._attrs) ) return self def add_setattr(self): if self._frozen: return self sa_attrs = {} for a in self._attrs: on_setattr = a.on_setattr or self._on_setattr if on_setattr and on_setattr is not setters.NO_OP: sa_attrs[a.name] = a, on_setattr if not sa_attrs: return self if self._has_custom_setattr: # We need to write a __setattr__ but there already is one! raise ValueError( "Can't combine custom __setattr__ with on_setattr hooks." ) # docstring comes from _add_method_dunders def __setattr__(self, name, val): try: a, hook = sa_attrs[name] except KeyError: nval = val else: nval = hook(self, a, val) _obj_setattr(self, name, nval) self._cls_dict["__attrs_own_setattr__"] = True self._cls_dict["__setattr__"] = self._add_method_dunders(__setattr__) self._has_own_setattr = True return self def _add_method_dunders(self, method): """ Add __module__ and __qualname__ to a *method* if possible. """ try: method.__module__ = self._cls.__module__ except AttributeError: pass try: method.__qualname__ = ".".join( (self._cls.__qualname__, method.__name__) ) except AttributeError: pass try: method.__doc__ = "Method generated by attrs for class %s." % ( self._cls.__qualname__, ) except AttributeError: pass return method _CMP_DEPRECATION = ( "The usage of `cmp` is deprecated and will be removed on or after " "2021-06-01. Please use `eq` and `order` instead." ) def _determine_attrs_eq_order(cmp, eq, order, default_eq): """ Validate the combination of *cmp*, *eq*, and *order*. Derive the effective values of eq and order. If *eq* is None, set it to *default_eq*. """ if cmp is not None and any((eq is not None, order is not None)): raise ValueError("Don't mix `cmp` with `eq' and `order`.") # cmp takes precedence due to bw-compatibility. if cmp is not None: return cmp, cmp # If left None, equality is set to the specified default and ordering # mirrors equality. if eq is None: eq = default_eq if order is None: order = eq if eq is False and order is True: raise ValueError("`order` can only be True if `eq` is True too.") return eq, order def _determine_attrib_eq_order(cmp, eq, order, default_eq): """ Validate the combination of *cmp*, *eq*, and *order*. Derive the effective values of eq and order. If *eq* is None, set it to *default_eq*. """ if cmp is not None and any((eq is not None, order is not None)): raise ValueError("Don't mix `cmp` with `eq' and `order`.") def decide_callable_or_boolean(value): """ Decide whether a key function is used. """ if callable(value): value, key = True, value else: key = None return value, key # cmp takes precedence due to bw-compatibility. if cmp is not None: cmp, cmp_key = decide_callable_or_boolean(cmp) return cmp, cmp_key, cmp, cmp_key # If left None, equality is set to the specified default and ordering # mirrors equality. if eq is None: eq, eq_key = default_eq, None else: eq, eq_key = decide_callable_or_boolean(eq) if order is None: order, order_key = eq, eq_key else: order, order_key = decide_callable_or_boolean(order) if eq is False and order is True: raise ValueError("`order` can only be True if `eq` is True too.") return eq, eq_key, order, order_key def _determine_whether_to_implement( cls, flag, auto_detect, dunders, default=True ): """ Check whether we should implement a set of methods for *cls*. *flag* is the argument passed into @attr.s like 'init', *auto_detect* the same as passed into @attr.s and *dunders* is a tuple of attribute names whose presence signal that the user has implemented it themselves. Return *default* if no reason for either for or against is found. auto_detect must be False on Python 2. """ if flag is True or flag is False: return flag if flag is None and auto_detect is False: return default # Logically, flag is None and auto_detect is True here. for dunder in dunders: if _has_own_attribute(cls, dunder): return False return default def attrs( maybe_cls=None, these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, getstate_setstate=None, on_setattr=None, field_transformer=None, ): r""" A class decorator that adds `dunder `_\ -methods according to the specified attributes using `attr.ib` or the *these* argument. :param these: A dictionary of name to `attr.ib` mappings. This is useful to avoid the definition of your attributes within the class body because you can't (e.g. if you want to add ``__repr__`` methods to Django models) or don't want to. If *these* is not ``None``, ``attrs`` will *not* search the class body for attributes and will *not* remove any attributes from it. If *these* is an ordered dict (`dict` on Python 3.6+, `collections.OrderedDict` otherwise), the order is deduced from the order of the attributes inside *these*. Otherwise the order of the definition of the attributes is used. :type these: `dict` of `str` to `attr.ib` :param str repr_ns: When using nested classes, there's no way in Python 2 to automatically detect that. Therefore it's possible to set the namespace explicitly for a more meaningful ``repr`` output. :param bool auto_detect: Instead of setting the *init*, *repr*, *eq*, *order*, and *hash* arguments explicitly, assume they are set to ``True`` **unless any** of the involved methods for one of the arguments is implemented in the *current* class (i.e. it is *not* inherited from some base class). So for example by implementing ``__eq__`` on a class yourself, ``attrs`` will deduce ``eq=False`` and will create *neither* ``__eq__`` *nor* ``__ne__`` (but Python classes come with a sensible ``__ne__`` by default, so it *should* be enough to only implement ``__eq__`` in most cases). .. warning:: If you prevent ``attrs`` from creating the ordering methods for you (``order=False``, e.g. by implementing ``__le__``), it becomes *your* responsibility to make sure its ordering is sound. The best way is to use the `functools.total_ordering` decorator. Passing ``True`` or ``False`` to *init*, *repr*, *eq*, *order*, *cmp*, or *hash* overrides whatever *auto_detect* would determine. *auto_detect* requires Python 3. Setting it ``True`` on Python 2 raises a `PythonTooOldError`. :param bool repr: Create a ``__repr__`` method with a human readable representation of ``attrs`` attributes.. :param bool str: Create a ``__str__`` method that is identical to ``__repr__``. This is usually not necessary except for `Exception`\ s. :param Optional[bool] eq: If ``True`` or ``None`` (default), add ``__eq__`` and ``__ne__`` methods that check two instances for equality. They compare the instances as if they were tuples of their ``attrs`` attributes if and only if the types of both classes are *identical*! :param Optional[bool] order: If ``True``, add ``__lt__``, ``__le__``, ``__gt__``, and ``__ge__`` methods that behave like *eq* above and allow instances to be ordered. If ``None`` (default) mirror value of *eq*. :param Optional[bool] cmp: Setting *cmp* is equivalent to setting *eq* and *order* to the same value. Must not be mixed with *eq* or *order*. :param Optional[bool] hash: If ``None`` (default), the ``__hash__`` method is generated according how *eq* and *frozen* are set. 1. If *both* are True, ``attrs`` will generate a ``__hash__`` for you. 2. If *eq* is True and *frozen* is False, ``__hash__`` will be set to None, marking it unhashable (which it is). 3. If *eq* is False, ``__hash__`` will be left untouched meaning the ``__hash__`` method of the base class will be used (if base class is ``object``, this means it will fall back to id-based hashing.). Although not recommended, you can decide for yourself and force ``attrs`` to create one (e.g. if the class is immutable even though you didn't freeze it programmatically) by passing ``True`` or not. Both of these cases are rather special and should be used carefully. See our documentation on `hashing`, Python's documentation on `object.__hash__`, and the `GitHub issue that led to the default \ behavior `_ for more details. :param bool init: Create a ``__init__`` method that initializes the ``attrs`` attributes. Leading underscores are stripped for the argument name. If a ``__attrs_pre_init__`` method exists on the class, it will be called before the class is initialized. If a ``__attrs_post_init__`` method exists on the class, it will be called after the class is fully initialized. If ``init`` is ``False``, an ``__attrs_init__`` method will be injected instead. This allows you to define a custom ``__init__`` method that can do pre-init work such as ``super().__init__()``, and then call ``__attrs_init__()`` and ``__attrs_post_init__()``. :param bool slots: Create a `slotted class ` that's more memory-efficient. Slotted classes are generally superior to the default dict classes, but have some gotchas you should know about, so we encourage you to read the `glossary entry `. :param bool frozen: Make instances immutable after initialization. If someone attempts to modify a frozen instance, `attr.exceptions.FrozenInstanceError` is raised. .. note:: 1. This is achieved by installing a custom ``__setattr__`` method on your class, so you can't implement your own. 2. True immutability is impossible in Python. 3. This *does* have a minor a runtime performance `impact ` when initializing new instances. In other words: ``__init__`` is slightly slower with ``frozen=True``. 4. If a class is frozen, you cannot modify ``self`` in ``__attrs_post_init__`` or a self-written ``__init__``. You can circumvent that limitation by using ``object.__setattr__(self, "attribute_name", value)``. 5. Subclasses of a frozen class are frozen too. :param bool weakref_slot: Make instances weak-referenceable. This has no effect unless ``slots`` is also enabled. :param bool auto_attribs: If ``True``, collect `PEP 526`_-annotated attributes (Python 3.6 and later only) from the class body. In this case, you **must** annotate every field. If ``attrs`` encounters a field that is set to an `attr.ib` but lacks a type annotation, an `attr.exceptions.UnannotatedAttributeError` is raised. Use ``field_name: typing.Any = attr.ib(...)`` if you don't want to set a type. If you assign a value to those attributes (e.g. ``x: int = 42``), that value becomes the default value like if it were passed using ``attr.ib(default=42)``. Passing an instance of `Factory` also works as expected in most cases (see warning below). Attributes annotated as `typing.ClassVar`, and attributes that are neither annotated nor set to an `attr.ib` are **ignored**. .. warning:: For features that use the attribute name to create decorators (e.g. `validators `), you still *must* assign `attr.ib` to them. Otherwise Python will either not find the name or try to use the default value to call e.g. ``validator`` on it. These errors can be quite confusing and probably the most common bug report on our bug tracker. .. _`PEP 526`: https://www.python.org/dev/peps/pep-0526/ :param bool kw_only: Make all attributes keyword-only (Python 3+) in the generated ``__init__`` (if ``init`` is ``False``, this parameter is ignored). :param bool cache_hash: Ensure that the object's hash code is computed only once and stored on the object. If this is set to ``True``, hashing must be either explicitly or implicitly enabled for this class. If the hash code is cached, avoid any reassignments of fields involved in hash code computation or mutations of the objects those fields point to after object creation. If such changes occur, the behavior of the object's hash code is undefined. :param bool auto_exc: If the class subclasses `BaseException` (which implicitly includes any subclass of any exception), the following happens to behave like a well-behaved Python exceptions class: - the values for *eq*, *order*, and *hash* are ignored and the instances compare and hash by the instance's ids (N.B. ``attrs`` will *not* remove existing implementations of ``__hash__`` or the equality methods. It just won't add own ones.), - all attributes that are either passed into ``__init__`` or have a default value are additionally available as a tuple in the ``args`` attribute, - the value of *str* is ignored leaving ``__str__`` to base classes. :param bool collect_by_mro: Setting this to `True` fixes the way ``attrs`` collects attributes from base classes. The default behavior is incorrect in certain cases of multiple inheritance. It should be on by default but is kept off for backward-compatability. See issue `#428 `_ for more details. :param Optional[bool] getstate_setstate: .. note:: This is usually only interesting for slotted classes and you should probably just set *auto_detect* to `True`. If `True`, ``__getstate__`` and ``__setstate__`` are generated and attached to the class. This is necessary for slotted classes to be pickleable. If left `None`, it's `True` by default for slotted classes and ``False`` for dict classes. If *auto_detect* is `True`, and *getstate_setstate* is left `None`, and **either** ``__getstate__`` or ``__setstate__`` is detected directly on the class (i.e. not inherited), it is set to `False` (this is usually what you want). :param on_setattr: A callable that is run whenever the user attempts to set an attribute (either by assignment like ``i.x = 42`` or by using `setattr` like ``setattr(i, "x", 42)``). It receives the same arguments as validators: the instance, the attribute that is being modified, and the new value. If no exception is raised, the attribute is set to the return value of the callable. If a list of callables is passed, they're automatically wrapped in an `attr.setters.pipe`. :param Optional[callable] field_transformer: A function that is called with the original class object and all fields right before ``attrs`` finalizes the class. You can use this, e.g., to automatically add converters or validators to fields based on their types. See `transform-fields` for more details. .. versionadded:: 16.0.0 *slots* .. versionadded:: 16.1.0 *frozen* .. versionadded:: 16.3.0 *str* .. versionadded:: 16.3.0 Support for ``__attrs_post_init__``. .. versionchanged:: 17.1.0 *hash* supports ``None`` as value which is also the default now. .. versionadded:: 17.3.0 *auto_attribs* .. versionchanged:: 18.1.0 If *these* is passed, no attributes are deleted from the class body. .. versionchanged:: 18.1.0 If *these* is ordered, the order is retained. .. versionadded:: 18.2.0 *weakref_slot* .. deprecated:: 18.2.0 ``__lt__``, ``__le__``, ``__gt__``, and ``__ge__`` now raise a `DeprecationWarning` if the classes compared are subclasses of each other. ``__eq`` and ``__ne__`` never tried to compared subclasses to each other. .. versionchanged:: 19.2.0 ``__lt__``, ``__le__``, ``__gt__``, and ``__ge__`` now do not consider subclasses comparable anymore. .. versionadded:: 18.2.0 *kw_only* .. versionadded:: 18.2.0 *cache_hash* .. versionadded:: 19.1.0 *auto_exc* .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01. .. versionadded:: 19.2.0 *eq* and *order* .. versionadded:: 20.1.0 *auto_detect* .. versionadded:: 20.1.0 *collect_by_mro* .. versionadded:: 20.1.0 *getstate_setstate* .. versionadded:: 20.1.0 *on_setattr* .. versionadded:: 20.3.0 *field_transformer* .. versionchanged:: 21.1.0 ``init=False`` injects ``__attrs_init__`` .. versionchanged:: 21.1.0 Support for ``__attrs_pre_init__`` .. versionchanged:: 21.1.0 *cmp* undeprecated """ if auto_detect and PY2: raise PythonTooOldError( "auto_detect only works on Python 3 and later." ) eq_, order_ = _determine_attrs_eq_order(cmp, eq, order, None) hash_ = hash # work around the lack of nonlocal if isinstance(on_setattr, (list, tuple)): on_setattr = setters.pipe(*on_setattr) def wrap(cls): if getattr(cls, "__class__", None) is None: raise TypeError("attrs only works with new-style classes.") is_frozen = frozen or _has_frozen_base_class(cls) is_exc = auto_exc is True and issubclass(cls, BaseException) has_own_setattr = auto_detect and _has_own_attribute( cls, "__setattr__" ) if has_own_setattr and is_frozen: raise ValueError("Can't freeze a class with a custom __setattr__.") builder = _ClassBuilder( cls, these, slots, is_frozen, weakref_slot, _determine_whether_to_implement( cls, getstate_setstate, auto_detect, ("__getstate__", "__setstate__"), default=slots, ), auto_attribs, kw_only, cache_hash, is_exc, collect_by_mro, on_setattr, has_own_setattr, field_transformer, ) if _determine_whether_to_implement( cls, repr, auto_detect, ("__repr__",) ): builder.add_repr(repr_ns) if str is True: builder.add_str() eq = _determine_whether_to_implement( cls, eq_, auto_detect, ("__eq__", "__ne__") ) if not is_exc and eq is True: builder.add_eq() if not is_exc and _determine_whether_to_implement( cls, order_, auto_detect, ("__lt__", "__le__", "__gt__", "__ge__") ): builder.add_order() builder.add_setattr() if ( hash_ is None and auto_detect is True and _has_own_attribute(cls, "__hash__") ): hash = False else: hash = hash_ if hash is not True and hash is not False and hash is not None: # Can't use `hash in` because 1 == True for example. raise TypeError( "Invalid value for hash. Must be True, False, or None." ) elif hash is False or (hash is None and eq is False) or is_exc: # Don't do anything. Should fall back to __object__'s __hash__ # which is by id. if cache_hash: raise TypeError( "Invalid value for cache_hash. To use hash caching," " hashing must be either explicitly or implicitly " "enabled." ) elif hash is True or ( hash is None and eq is True and is_frozen is True ): # Build a __hash__ if told so, or if it's safe. builder.add_hash() else: # Raise TypeError on attempts to hash. if cache_hash: raise TypeError( "Invalid value for cache_hash. To use hash caching," " hashing must be either explicitly or implicitly " "enabled." ) builder.make_unhashable() if _determine_whether_to_implement( cls, init, auto_detect, ("__init__",) ): builder.add_init() else: builder.add_attrs_init() if cache_hash: raise TypeError( "Invalid value for cache_hash. To use hash caching," " init must be True." ) return builder.build_class() # maybe_cls's type depends on the usage of the decorator. It's a class # if it's used as `@attrs` but ``None`` if used as `@attrs()`. if maybe_cls is None: return wrap else: return wrap(maybe_cls) _attrs = attrs """ Internal alias so we can use it in functions that take an argument called *attrs*. """ if PY2: def _has_frozen_base_class(cls): """ Check whether *cls* has a frozen ancestor by looking at its __setattr__. """ return ( getattr(cls.__setattr__, "__module__", None) == _frozen_setattrs.__module__ and cls.__setattr__.__name__ == _frozen_setattrs.__name__ ) else: def _has_frozen_base_class(cls): """ Check whether *cls* has a frozen ancestor by looking at its __setattr__. """ return cls.__setattr__ == _frozen_setattrs def _generate_unique_filename(cls, func_name): """ Create a "filename" suitable for a function being generated. """ unique_id = uuid.uuid4() extra = "" count = 1 while True: unique_filename = "".format( func_name, cls.__module__, getattr(cls, "__qualname__", cls.__name__), extra, ) # To handle concurrency we essentially "reserve" our spot in # the linecache with a dummy line. The caller can then # set this value correctly. cache_line = (1, None, (str(unique_id),), unique_filename) if ( linecache.cache.setdefault(unique_filename, cache_line) == cache_line ): return unique_filename # Looks like this spot is taken. Try again. count += 1 extra = "-{0}".format(count) def _make_hash(cls, attrs, frozen, cache_hash): attrs = tuple( a for a in attrs if a.hash is True or (a.hash is None and a.eq is True) ) tab = " " unique_filename = _generate_unique_filename(cls, "hash") type_hash = hash(unique_filename) hash_def = "def __hash__(self" hash_func = "hash((" closing_braces = "))" if not cache_hash: hash_def += "):" else: if not PY2: hash_def += ", *" hash_def += ( ", _cache_wrapper=" + "__import__('attr._make')._make._CacheHashWrapper):" ) hash_func = "_cache_wrapper(" + hash_func closing_braces += ")" method_lines = [hash_def] def append_hash_computation_lines(prefix, indent): """ Generate the code for actually computing the hash code. Below this will either be returned directly or used to compute a value which is then cached, depending on the value of cache_hash """ method_lines.extend( [ indent + prefix + hash_func, indent + " %d," % (type_hash,), ] ) for a in attrs: method_lines.append(indent + " self.%s," % a.name) method_lines.append(indent + " " + closing_braces) if cache_hash: method_lines.append(tab + "if self.%s is None:" % _hash_cache_field) if frozen: append_hash_computation_lines( "object.__setattr__(self, '%s', " % _hash_cache_field, tab * 2 ) method_lines.append(tab * 2 + ")") # close __setattr__ else: append_hash_computation_lines( "self.%s = " % _hash_cache_field, tab * 2 ) method_lines.append(tab + "return self.%s" % _hash_cache_field) else: append_hash_computation_lines("return ", tab) script = "\n".join(method_lines) return _make_method("__hash__", script, unique_filename) def _add_hash(cls, attrs): """ Add a hash method to *cls*. """ cls.__hash__ = _make_hash(cls, attrs, frozen=False, cache_hash=False) return cls def _make_ne(): """ Create __ne__ method. """ def __ne__(self, other): """ Check equality and either forward a NotImplemented or return the result negated. """ result = self.__eq__(other) if result is NotImplemented: return NotImplemented return not result return __ne__ def _make_eq(cls, attrs): """ Create __eq__ method for *cls* with *attrs*. """ attrs = [a for a in attrs if a.eq] unique_filename = _generate_unique_filename(cls, "eq") lines = [ "def __eq__(self, other):", " if other.__class__ is not self.__class__:", " return NotImplemented", ] # We can't just do a big self.x = other.x and... clause due to # irregularities like nan == nan is false but (nan,) == (nan,) is true. globs = {} if attrs: lines.append(" return (") others = [" ) == ("] for a in attrs: if a.eq_key: cmp_name = "_%s_key" % (a.name,) # Add the key function to the global namespace # of the evaluated function. globs[cmp_name] = a.eq_key lines.append( " %s(self.%s)," % ( cmp_name, a.name, ) ) others.append( " %s(other.%s)," % ( cmp_name, a.name, ) ) else: lines.append(" self.%s," % (a.name,)) others.append(" other.%s," % (a.name,)) lines += others + [" )"] else: lines.append(" return True") script = "\n".join(lines) return _make_method("__eq__", script, unique_filename, globs) def _make_order(cls, attrs): """ Create ordering methods for *cls* with *attrs*. """ attrs = [a for a in attrs if a.order] def attrs_to_tuple(obj): """ Save us some typing. """ return tuple( key(value) if key else value for value, key in ( (getattr(obj, a.name), a.order_key) for a in attrs ) ) def __lt__(self, other): """ Automatically created by attrs. """ if other.__class__ is self.__class__: return attrs_to_tuple(self) < attrs_to_tuple(other) return NotImplemented def __le__(self, other): """ Automatically created by attrs. """ if other.__class__ is self.__class__: return attrs_to_tuple(self) <= attrs_to_tuple(other) return NotImplemented def __gt__(self, other): """ Automatically created by attrs. """ if other.__class__ is self.__class__: return attrs_to_tuple(self) > attrs_to_tuple(other) return NotImplemented def __ge__(self, other): """ Automatically created by attrs. """ if other.__class__ is self.__class__: return attrs_to_tuple(self) >= attrs_to_tuple(other) return NotImplemented return __lt__, __le__, __gt__, __ge__ def _add_eq(cls, attrs=None): """ Add equality methods to *cls* with *attrs*. """ if attrs is None: attrs = cls.__attrs_attrs__ cls.__eq__ = _make_eq(cls, attrs) cls.__ne__ = _make_ne() return cls _already_repring = threading.local() def _make_repr(attrs, ns): """ Make a repr method that includes relevant *attrs*, adding *ns* to the full name. """ # Figure out which attributes to include, and which function to use to # format them. The a.repr value can be either bool or a custom callable. attr_names_with_reprs = tuple( (a.name, repr if a.repr is True else a.repr) for a in attrs if a.repr is not False ) def __repr__(self): """ Automatically created by attrs. """ try: working_set = _already_repring.working_set except AttributeError: working_set = set() _already_repring.working_set = working_set if id(self) in working_set: return "..." real_cls = self.__class__ if ns is None: qualname = getattr(real_cls, "__qualname__", None) if qualname is not None: class_name = qualname.rsplit(">.", 1)[-1] else: class_name = real_cls.__name__ else: class_name = ns + "." + real_cls.__name__ # Since 'self' remains on the stack (i.e.: strongly referenced) for the # duration of this call, it's safe to depend on id(...) stability, and # not need to track the instance and therefore worry about properties # like weakref- or hash-ability. working_set.add(id(self)) try: result = [class_name, "("] first = True for name, attr_repr in attr_names_with_reprs: if first: first = False else: result.append(", ") result.extend( (name, "=", attr_repr(getattr(self, name, NOTHING))) ) return "".join(result) + ")" finally: working_set.remove(id(self)) return __repr__ def _add_repr(cls, ns=None, attrs=None): """ Add a repr method to *cls*. """ if attrs is None: attrs = cls.__attrs_attrs__ cls.__repr__ = _make_repr(attrs, ns) return cls def fields(cls): """ Return the tuple of ``attrs`` attributes for a class. The tuple also allows accessing the fields by their names (see below for examples). :param type cls: Class to introspect. :raise TypeError: If *cls* is not a class. :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` class. :rtype: tuple (with name accessors) of `attr.Attribute` .. versionchanged:: 16.2.0 Returned tuple allows accessing the fields by name. """ if not isclass(cls): raise TypeError("Passed object must be a class.") attrs = getattr(cls, "__attrs_attrs__", None) if attrs is None: raise NotAnAttrsClassError( "{cls!r} is not an attrs-decorated class.".format(cls=cls) ) return attrs def fields_dict(cls): """ Return an ordered dictionary of ``attrs`` attributes for a class, whose keys are the attribute names. :param type cls: Class to introspect. :raise TypeError: If *cls* is not a class. :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` class. :rtype: an ordered dict where keys are attribute names and values are `attr.Attribute`\\ s. This will be a `dict` if it's naturally ordered like on Python 3.6+ or an :class:`~collections.OrderedDict` otherwise. .. versionadded:: 18.1.0 """ if not isclass(cls): raise TypeError("Passed object must be a class.") attrs = getattr(cls, "__attrs_attrs__", None) if attrs is None: raise NotAnAttrsClassError( "{cls!r} is not an attrs-decorated class.".format(cls=cls) ) return ordered_dict(((a.name, a) for a in attrs)) def validate(inst): """ Validate all attributes on *inst* that have a validator. Leaves all exceptions through. :param inst: Instance of a class with ``attrs`` attributes. """ if _config._run_validators is False: return for a in fields(inst.__class__): v = a.validator if v is not None: v(inst, a, getattr(inst, a.name)) def _is_slot_cls(cls): return "__slots__" in cls.__dict__ def _is_slot_attr(a_name, base_attr_map): """ Check if the attribute name comes from a slot class. """ return a_name in base_attr_map and _is_slot_cls(base_attr_map[a_name]) def _make_init( cls, attrs, pre_init, post_init, frozen, slots, cache_hash, base_attr_map, is_exc, has_global_on_setattr, attrs_init, ): if frozen and has_global_on_setattr: raise ValueError("Frozen classes can't use on_setattr.") needs_cached_setattr = cache_hash or frozen filtered_attrs = [] attr_dict = {} for a in attrs: if not a.init and a.default is NOTHING: continue filtered_attrs.append(a) attr_dict[a.name] = a if a.on_setattr is not None: if frozen is True: raise ValueError("Frozen classes can't use on_setattr.") needs_cached_setattr = True elif ( has_global_on_setattr and a.on_setattr is not setters.NO_OP ) or _is_slot_attr(a.name, base_attr_map): needs_cached_setattr = True unique_filename = _generate_unique_filename(cls, "init") script, globs, annotations = _attrs_to_init_script( filtered_attrs, frozen, slots, pre_init, post_init, cache_hash, base_attr_map, is_exc, needs_cached_setattr, has_global_on_setattr, attrs_init, ) if cls.__module__ in sys.modules: # This makes typing.get_type_hints(CLS.__init__) resolve string types. globs.update(sys.modules[cls.__module__].__dict__) globs.update({"NOTHING": NOTHING, "attr_dict": attr_dict}) if needs_cached_setattr: # Save the lookup overhead in __init__ if we need to circumvent # setattr hooks. globs["_cached_setattr"] = _obj_setattr init = _make_method( "__attrs_init__" if attrs_init else "__init__", script, unique_filename, globs, ) init.__annotations__ = annotations return init def _setattr(attr_name, value_var, has_on_setattr): """ Use the cached object.setattr to set *attr_name* to *value_var*. """ return "_setattr('%s', %s)" % (attr_name, value_var) def _setattr_with_converter(attr_name, value_var, has_on_setattr): """ Use the cached object.setattr to set *attr_name* to *value_var*, but run its converter first. """ return "_setattr('%s', %s(%s))" % ( attr_name, _init_converter_pat % (attr_name,), value_var, ) def _assign(attr_name, value, has_on_setattr): """ Unless *attr_name* has an on_setattr hook, use normal assignment. Otherwise relegate to _setattr. """ if has_on_setattr: return _setattr(attr_name, value, True) return "self.%s = %s" % (attr_name, value) def _assign_with_converter(attr_name, value_var, has_on_setattr): """ Unless *attr_name* has an on_setattr hook, use normal assignment after conversion. Otherwise relegate to _setattr_with_converter. """ if has_on_setattr: return _setattr_with_converter(attr_name, value_var, True) return "self.%s = %s(%s)" % ( attr_name, _init_converter_pat % (attr_name,), value_var, ) if PY2: def _unpack_kw_only_py2(attr_name, default=None): """ Unpack *attr_name* from _kw_only dict. """ if default is not None: arg_default = ", %s" % default else: arg_default = "" return "%s = _kw_only.pop('%s'%s)" % ( attr_name, attr_name, arg_default, ) def _unpack_kw_only_lines_py2(kw_only_args): """ Unpack all *kw_only_args* from _kw_only dict and handle errors. Given a list of strings "{attr_name}" and "{attr_name}={default}" generates list of lines of code that pop attrs from _kw_only dict and raise TypeError similar to builtin if required attr is missing or extra key is passed. >>> print("\n".join(_unpack_kw_only_lines_py2(["a", "b=42"]))) try: a = _kw_only.pop('a') b = _kw_only.pop('b', 42) except KeyError as _key_error: raise TypeError( ... if _kw_only: raise TypeError( ... """ lines = ["try:"] lines.extend( " " + _unpack_kw_only_py2(*arg.split("=")) for arg in kw_only_args ) lines += """\ except KeyError as _key_error: raise TypeError( '__init__() missing required keyword-only argument: %s' % _key_error ) if _kw_only: raise TypeError( '__init__() got an unexpected keyword argument %r' % next(iter(_kw_only)) ) """.split( "\n" ) return lines def _attrs_to_init_script( attrs, frozen, slots, pre_init, post_init, cache_hash, base_attr_map, is_exc, needs_cached_setattr, has_global_on_setattr, attrs_init, ): """ Return a script of an initializer for *attrs* and a dict of globals. The globals are expected by the generated script. If *frozen* is True, we cannot set the attributes directly so we use a cached ``object.__setattr__``. """ lines = [] if pre_init: lines.append("self.__attrs_pre_init__()") if needs_cached_setattr: lines.append( # Circumvent the __setattr__ descriptor to save one lookup per # assignment. # Note _setattr will be used again below if cache_hash is True "_setattr = _cached_setattr.__get__(self, self.__class__)" ) if frozen is True: if slots is True: fmt_setter = _setattr fmt_setter_with_converter = _setattr_with_converter else: # Dict frozen classes assign directly to __dict__. # But only if the attribute doesn't come from an ancestor slot # class. # Note _inst_dict will be used again below if cache_hash is True lines.append("_inst_dict = self.__dict__") def fmt_setter(attr_name, value_var, has_on_setattr): if _is_slot_attr(attr_name, base_attr_map): return _setattr(attr_name, value_var, has_on_setattr) return "_inst_dict['%s'] = %s" % (attr_name, value_var) def fmt_setter_with_converter( attr_name, value_var, has_on_setattr ): if has_on_setattr or _is_slot_attr(attr_name, base_attr_map): return _setattr_with_converter( attr_name, value_var, has_on_setattr ) return "_inst_dict['%s'] = %s(%s)" % ( attr_name, _init_converter_pat % (attr_name,), value_var, ) else: # Not frozen. fmt_setter = _assign fmt_setter_with_converter = _assign_with_converter args = [] kw_only_args = [] attrs_to_validate = [] # This is a dictionary of names to validator and converter callables. # Injecting this into __init__ globals lets us avoid lookups. names_for_globals = {} annotations = {"return": None} for a in attrs: if a.validator: attrs_to_validate.append(a) attr_name = a.name has_on_setattr = a.on_setattr is not None or ( a.on_setattr is not setters.NO_OP and has_global_on_setattr ) arg_name = a.name.lstrip("_") has_factory = isinstance(a.default, Factory) if has_factory and a.default.takes_self: maybe_self = "self" else: maybe_self = "" if a.init is False: if has_factory: init_factory_name = _init_factory_pat.format(a.name) if a.converter is not None: lines.append( fmt_setter_with_converter( attr_name, init_factory_name + "(%s)" % (maybe_self,), has_on_setattr, ) ) conv_name = _init_converter_pat % (a.name,) names_for_globals[conv_name] = a.converter else: lines.append( fmt_setter( attr_name, init_factory_name + "(%s)" % (maybe_self,), has_on_setattr, ) ) names_for_globals[init_factory_name] = a.default.factory else: if a.converter is not None: lines.append( fmt_setter_with_converter( attr_name, "attr_dict['%s'].default" % (attr_name,), has_on_setattr, ) ) conv_name = _init_converter_pat % (a.name,) names_for_globals[conv_name] = a.converter else: lines.append( fmt_setter( attr_name, "attr_dict['%s'].default" % (attr_name,), has_on_setattr, ) ) elif a.default is not NOTHING and not has_factory: arg = "%s=attr_dict['%s'].default" % (arg_name, attr_name) if a.kw_only: kw_only_args.append(arg) else: args.append(arg) if a.converter is not None: lines.append( fmt_setter_with_converter( attr_name, arg_name, has_on_setattr ) ) names_for_globals[ _init_converter_pat % (a.name,) ] = a.converter else: lines.append(fmt_setter(attr_name, arg_name, has_on_setattr)) elif has_factory: arg = "%s=NOTHING" % (arg_name,) if a.kw_only: kw_only_args.append(arg) else: args.append(arg) lines.append("if %s is not NOTHING:" % (arg_name,)) init_factory_name = _init_factory_pat.format(a.name) if a.converter is not None: lines.append( " " + fmt_setter_with_converter( attr_name, arg_name, has_on_setattr ) ) lines.append("else:") lines.append( " " + fmt_setter_with_converter( attr_name, init_factory_name + "(" + maybe_self + ")", has_on_setattr, ) ) names_for_globals[ _init_converter_pat % (a.name,) ] = a.converter else: lines.append( " " + fmt_setter(attr_name, arg_name, has_on_setattr) ) lines.append("else:") lines.append( " " + fmt_setter( attr_name, init_factory_name + "(" + maybe_self + ")", has_on_setattr, ) ) names_for_globals[init_factory_name] = a.default.factory else: if a.kw_only: kw_only_args.append(arg_name) else: args.append(arg_name) if a.converter is not None: lines.append( fmt_setter_with_converter( attr_name, arg_name, has_on_setattr ) ) names_for_globals[ _init_converter_pat % (a.name,) ] = a.converter else: lines.append(fmt_setter(attr_name, arg_name, has_on_setattr)) if a.init is True: if a.type is not None and a.converter is None: annotations[arg_name] = a.type elif a.converter is not None and not PY2: # Try to get the type from the converter. sig = None try: sig = inspect.signature(a.converter) except (ValueError, TypeError): # inspect failed pass if sig: sig_params = list(sig.parameters.values()) if ( sig_params and sig_params[0].annotation is not inspect.Parameter.empty ): annotations[arg_name] = sig_params[0].annotation if attrs_to_validate: # we can skip this if there are no validators. names_for_globals["_config"] = _config lines.append("if _config._run_validators is True:") for a in attrs_to_validate: val_name = "__attr_validator_" + a.name attr_name = "__attr_" + a.name lines.append( " %s(self, %s, self.%s)" % (val_name, attr_name, a.name) ) names_for_globals[val_name] = a.validator names_for_globals[attr_name] = a if post_init: lines.append("self.__attrs_post_init__()") # because this is set only after __attrs_post_init is called, a crash # will result if post-init tries to access the hash code. This seemed # preferable to setting this beforehand, in which case alteration to # field values during post-init combined with post-init accessing the # hash code would result in silent bugs. if cache_hash: if frozen: if slots: # if frozen and slots, then _setattr defined above init_hash_cache = "_setattr('%s', %s)" else: # if frozen and not slots, then _inst_dict defined above init_hash_cache = "_inst_dict['%s'] = %s" else: init_hash_cache = "self.%s = %s" lines.append(init_hash_cache % (_hash_cache_field, "None")) # For exceptions we rely on BaseException.__init__ for proper # initialization. if is_exc: vals = ",".join("self." + a.name for a in attrs if a.init) lines.append("BaseException.__init__(self, %s)" % (vals,)) args = ", ".join(args) if kw_only_args: if PY2: lines = _unpack_kw_only_lines_py2(kw_only_args) + lines args += "%s**_kw_only" % (", " if args else "",) # leading comma else: args += "%s*, %s" % ( ", " if args else "", # leading comma ", ".join(kw_only_args), # kw_only args ) return ( """\ def {init_name}(self, {args}): {lines} """.format( init_name=("__attrs_init__" if attrs_init else "__init__"), args=args, lines="\n ".join(lines) if lines else "pass", ), names_for_globals, annotations, ) class Attribute(object): """ *Read-only* representation of an attribute. Instances of this class are frequently used for introspection purposes like: - `fields` returns a tuple of them. - Validators get them passed as the first argument. - The *field transformer* hook receives a list of them. :attribute name: The name of the attribute. :attribute inherited: Whether or not that attribute has been inherited from a base class. Plus *all* arguments of `attr.ib` (except for ``factory`` which is only syntactic sugar for ``default=Factory(...)``. .. versionadded:: 20.1.0 *inherited* .. versionadded:: 20.1.0 *on_setattr* .. versionchanged:: 20.2.0 *inherited* is not taken into account for equality checks and hashing anymore. .. versionadded:: 21.1.0 *eq_key* and *order_key* For the full version history of the fields, see `attr.ib`. """ __slots__ = ( "name", "default", "validator", "repr", "eq", "eq_key", "order", "order_key", "hash", "init", "metadata", "type", "converter", "kw_only", "inherited", "on_setattr", ) def __init__( self, name, default, validator, repr, cmp, # XXX: unused, remove along with other cmp code. hash, init, inherited, metadata=None, type=None, converter=None, kw_only=False, eq=None, eq_key=None, order=None, order_key=None, on_setattr=None, ): eq, eq_key, order, order_key = _determine_attrib_eq_order( cmp, eq_key or eq, order_key or order, True ) # Cache this descriptor here to speed things up later. bound_setattr = _obj_setattr.__get__(self, Attribute) # Despite the big red warning, people *do* instantiate `Attribute` # themselves. bound_setattr("name", name) bound_setattr("default", default) bound_setattr("validator", validator) bound_setattr("repr", repr) bound_setattr("eq", eq) bound_setattr("eq_key", eq_key) bound_setattr("order", order) bound_setattr("order_key", order_key) bound_setattr("hash", hash) bound_setattr("init", init) bound_setattr("converter", converter) bound_setattr( "metadata", ( metadata_proxy(metadata) if metadata else _empty_metadata_singleton ), ) bound_setattr("type", type) bound_setattr("kw_only", kw_only) bound_setattr("inherited", inherited) bound_setattr("on_setattr", on_setattr) def __setattr__(self, name, value): raise FrozenInstanceError() @classmethod def from_counting_attr(cls, name, ca, type=None): # type holds the annotated value. deal with conflicts: if type is None: type = ca.type elif ca.type is not None: raise ValueError( "Type annotation and type argument cannot both be present" ) inst_dict = { k: getattr(ca, k) for k in Attribute.__slots__ if k not in ( "name", "validator", "default", "type", "inherited", ) # exclude methods and deprecated alias } return cls( name=name, validator=ca._validator, default=ca._default, type=type, cmp=None, inherited=False, **inst_dict ) @property def cmp(self): """ Simulate the presence of a cmp attribute and warn. """ warnings.warn(_CMP_DEPRECATION, DeprecationWarning, stacklevel=2) return self.eq and self.order # Don't use attr.evolve since fields(Attribute) doesn't work def evolve(self, **changes): """ Copy *self* and apply *changes*. This works similarly to `attr.evolve` but that function does not work with ``Attribute``. It is mainly meant to be used for `transform-fields`. .. versionadded:: 20.3.0 """ new = copy.copy(self) new._setattrs(changes.items()) return new # Don't use _add_pickle since fields(Attribute) doesn't work def __getstate__(self): """ Play nice with pickle. """ return tuple( getattr(self, name) if name != "metadata" else dict(self.metadata) for name in self.__slots__ ) def __setstate__(self, state): """ Play nice with pickle. """ self._setattrs(zip(self.__slots__, state)) def _setattrs(self, name_values_pairs): bound_setattr = _obj_setattr.__get__(self, Attribute) for name, value in name_values_pairs: if name != "metadata": bound_setattr(name, value) else: bound_setattr( name, metadata_proxy(value) if value else _empty_metadata_singleton, ) _a = [ Attribute( name=name, default=NOTHING, validator=None, repr=True, cmp=None, eq=True, order=False, hash=(name != "metadata"), init=True, inherited=False, ) for name in Attribute.__slots__ ] Attribute = _add_hash( _add_eq( _add_repr(Attribute, attrs=_a), attrs=[a for a in _a if a.name != "inherited"], ), attrs=[a for a in _a if a.hash and a.name != "inherited"], ) class _CountingAttr(object): """ Intermediate representation of attributes that uses a counter to preserve the order in which the attributes have been defined. *Internal* data structure of the attrs library. Running into is most likely the result of a bug like a forgotten `@attr.s` decorator. """ __slots__ = ( "counter", "_default", "repr", "eq", "eq_key", "order", "order_key", "hash", "init", "metadata", "_validator", "converter", "type", "kw_only", "on_setattr", ) __attrs_attrs__ = tuple( Attribute( name=name, default=NOTHING, validator=None, repr=True, cmp=None, hash=True, init=True, kw_only=False, eq=True, eq_key=None, order=False, order_key=None, inherited=False, on_setattr=None, ) for name in ( "counter", "_default", "repr", "eq", "order", "hash", "init", "on_setattr", ) ) + ( Attribute( name="metadata", default=None, validator=None, repr=True, cmp=None, hash=False, init=True, kw_only=False, eq=True, eq_key=None, order=False, order_key=None, inherited=False, on_setattr=None, ), ) cls_counter = 0 def __init__( self, default, validator, repr, cmp, hash, init, converter, metadata, type, kw_only, eq, eq_key, order, order_key, on_setattr, ): _CountingAttr.cls_counter += 1 self.counter = _CountingAttr.cls_counter self._default = default self._validator = validator self.converter = converter self.repr = repr self.eq = eq self.eq_key = eq_key self.order = order self.order_key = order_key self.hash = hash self.init = init self.metadata = metadata self.type = type self.kw_only = kw_only self.on_setattr = on_setattr def validator(self, meth): """ Decorator that adds *meth* to the list of validators. Returns *meth* unchanged. .. versionadded:: 17.1.0 """ if self._validator is None: self._validator = meth else: self._validator = and_(self._validator, meth) return meth def default(self, meth): """ Decorator that allows to set the default for an attribute. Returns *meth* unchanged. :raises DefaultAlreadySetError: If default has been set before. .. versionadded:: 17.1.0 """ if self._default is not NOTHING: raise DefaultAlreadySetError() self._default = Factory(meth, takes_self=True) return meth _CountingAttr = _add_eq(_add_repr(_CountingAttr)) class Factory(object): """ Stores a factory callable. If passed as the default value to `attr.ib`, the factory is used to generate a new value. :param callable factory: A callable that takes either none or exactly one mandatory positional argument depending on *takes_self*. :param bool takes_self: Pass the partially initialized instance that is being initialized as a positional argument. .. versionadded:: 17.1.0 *takes_self* """ __slots__ = ("factory", "takes_self") def __init__(self, factory, takes_self=False): """ `Factory` is part of the default machinery so if we want a default value here, we have to implement it ourselves. """ self.factory = factory self.takes_self = takes_self def __getstate__(self): """ Play nice with pickle. """ return tuple(getattr(self, name) for name in self.__slots__) def __setstate__(self, state): """ Play nice with pickle. """ for name, value in zip(self.__slots__, state): setattr(self, name, value) _f = [ Attribute( name=name, default=NOTHING, validator=None, repr=True, cmp=None, eq=True, order=False, hash=True, init=True, inherited=False, ) for name in Factory.__slots__ ] Factory = _add_hash(_add_eq(_add_repr(Factory, attrs=_f), attrs=_f), attrs=_f) def make_class(name, attrs, bases=(object,), **attributes_arguments): """ A quick way to create a new class called *name* with *attrs*. :param str name: The name for the new class. :param attrs: A list of names or a dictionary of mappings of names to attributes. If *attrs* is a list or an ordered dict (`dict` on Python 3.6+, `collections.OrderedDict` otherwise), the order is deduced from the order of the names or attributes inside *attrs*. Otherwise the order of the definition of the attributes is used. :type attrs: `list` or `dict` :param tuple bases: Classes that the new class will subclass. :param attributes_arguments: Passed unmodified to `attr.s`. :return: A new class with *attrs*. :rtype: type .. versionadded:: 17.1.0 *bases* .. versionchanged:: 18.1.0 If *attrs* is ordered, the order is retained. """ if isinstance(attrs, dict): cls_dict = attrs elif isinstance(attrs, (list, tuple)): cls_dict = dict((a, attrib()) for a in attrs) else: raise TypeError("attrs argument must be a dict or a list.") pre_init = cls_dict.pop("__attrs_pre_init__", None) post_init = cls_dict.pop("__attrs_post_init__", None) user_init = cls_dict.pop("__init__", None) body = {} if pre_init is not None: body["__attrs_pre_init__"] = pre_init if post_init is not None: body["__attrs_post_init__"] = post_init if user_init is not None: body["__init__"] = user_init type_ = new_class(name, bases, {}, lambda ns: ns.update(body)) # For pickling to work, the __module__ variable needs to be set to the # frame where the class is created. Bypass this step in environments where # sys._getframe is not defined (Jython for example) or sys._getframe is not # defined for arguments greater than 0 (IronPython). try: type_.__module__ = sys._getframe(1).f_globals.get( "__name__", "__main__" ) except (AttributeError, ValueError): pass # We do it here for proper warnings with meaningful stacklevel. cmp = attributes_arguments.pop("cmp", None) ( attributes_arguments["eq"], attributes_arguments["order"], ) = _determine_attrs_eq_order( cmp, attributes_arguments.get("eq"), attributes_arguments.get("order"), True, ) return _attrs(these=cls_dict, **attributes_arguments)(type_) # These are required by within this module so we define them here and merely # import into .validators / .converters. @attrs(slots=True, hash=True) class _AndValidator(object): """ Compose many validators to a single one. """ _validators = attrib() def __call__(self, inst, attr, value): for v in self._validators: v(inst, attr, value) def and_(*validators): """ A validator that composes multiple validators into one. When called on a value, it runs all wrapped validators. :param callables validators: Arbitrary number of validators. .. versionadded:: 17.1.0 """ vals = [] for validator in validators: vals.extend( validator._validators if isinstance(validator, _AndValidator) else [validator] ) return _AndValidator(tuple(vals)) def pipe(*converters): """ A converter that composes multiple converters into one. When called on a value, it runs all wrapped converters, returning the *last* value. Type annotations will be inferred from the wrapped converters', if they have any. :param callables converters: Arbitrary number of converters. .. versionadded:: 20.1.0 """ def pipe_converter(val): for converter in converters: val = converter(val) return val if not PY2: if not converters: # If the converter list is empty, pipe_converter is the identity. A = typing.TypeVar("A") pipe_converter.__annotations__ = {"val": A, "return": A} else: # Get parameter type. sig = None try: sig = inspect.signature(converters[0]) except (ValueError, TypeError): # inspect failed pass if sig: params = list(sig.parameters.values()) if ( params and params[0].annotation is not inspect.Parameter.empty ): pipe_converter.__annotations__["val"] = params[ 0 ].annotation # Get return type. sig = None try: sig = inspect.signature(converters[-1]) except (ValueError, TypeError): # inspect failed pass if sig and sig.return_annotation is not inspect.Signature().empty: pipe_converter.__annotations__[ "return" ] = sig.return_annotation return pipe_converter ================================================ FILE: openpype/hosts/fusion/vendor/attr/_next_gen.py ================================================ """ These are Python 3.6+-only and keyword-only APIs that call `attr.s` and `attr.ib` with different default values. """ from functools import partial from attr.exceptions import UnannotatedAttributeError from . import setters from ._make import NOTHING, _frozen_setattrs, attrib, attrs def define( maybe_cls=None, *, these=None, repr=None, hash=None, init=None, slots=True, frozen=False, weakref_slot=True, str=False, auto_attribs=None, kw_only=False, cache_hash=False, auto_exc=True, eq=None, order=False, auto_detect=True, getstate_setstate=None, on_setattr=None, field_transformer=None, ): r""" The only behavioral differences are the handling of the *auto_attribs* option: :param Optional[bool] auto_attribs: If set to `True` or `False`, it behaves exactly like `attr.s`. If left `None`, `attr.s` will try to guess: 1. If any attributes are annotated and no unannotated `attr.ib`\ s are found, it assumes *auto_attribs=True*. 2. Otherwise it assumes *auto_attribs=False* and tries to collect `attr.ib`\ s. and that mutable classes (``frozen=False``) validate on ``__setattr__``. .. versionadded:: 20.1.0 """ def do_it(cls, auto_attribs): return attrs( maybe_cls=cls, these=these, repr=repr, hash=hash, init=init, slots=slots, frozen=frozen, weakref_slot=weakref_slot, str=str, auto_attribs=auto_attribs, kw_only=kw_only, cache_hash=cache_hash, auto_exc=auto_exc, eq=eq, order=order, auto_detect=auto_detect, collect_by_mro=True, getstate_setstate=getstate_setstate, on_setattr=on_setattr, field_transformer=field_transformer, ) def wrap(cls): """ Making this a wrapper ensures this code runs during class creation. We also ensure that frozen-ness of classes is inherited. """ nonlocal frozen, on_setattr had_on_setattr = on_setattr not in (None, setters.NO_OP) # By default, mutable classes validate on setattr. if frozen is False and on_setattr is None: on_setattr = setters.validate # However, if we subclass a frozen class, we inherit the immutability # and disable on_setattr. for base_cls in cls.__bases__: if base_cls.__setattr__ is _frozen_setattrs: if had_on_setattr: raise ValueError( "Frozen classes can't use on_setattr " "(frozen-ness was inherited)." ) on_setattr = setters.NO_OP break if auto_attribs is not None: return do_it(cls, auto_attribs) try: return do_it(cls, True) except UnannotatedAttributeError: return do_it(cls, False) # maybe_cls's type depends on the usage of the decorator. It's a class # if it's used as `@attrs` but ``None`` if used as `@attrs()`. if maybe_cls is None: return wrap else: return wrap(maybe_cls) mutable = define frozen = partial(define, frozen=True, on_setattr=None) def field( *, default=NOTHING, validator=None, repr=True, hash=None, init=True, metadata=None, converter=None, factory=None, kw_only=False, eq=None, order=None, on_setattr=None, ): """ Identical to `attr.ib`, except keyword-only and with some arguments removed. .. versionadded:: 20.1.0 """ return attrib( default=default, validator=validator, repr=repr, hash=hash, init=init, metadata=metadata, converter=converter, factory=factory, kw_only=kw_only, eq=eq, order=order, on_setattr=on_setattr, ) ================================================ FILE: openpype/hosts/fusion/vendor/attr/_version_info.py ================================================ from __future__ import absolute_import, division, print_function from functools import total_ordering from ._funcs import astuple from ._make import attrib, attrs @total_ordering @attrs(eq=False, order=False, slots=True, frozen=True) class VersionInfo(object): """ A version object that can be compared to tuple of length 1--4: >>> attr.VersionInfo(19, 1, 0, "final") <= (19, 2) True >>> attr.VersionInfo(19, 1, 0, "final") < (19, 1, 1) True >>> vi = attr.VersionInfo(19, 2, 0, "final") >>> vi < (19, 1, 1) False >>> vi < (19,) False >>> vi == (19, 2,) True >>> vi == (19, 2, 1) False .. versionadded:: 19.2 """ year = attrib(type=int) minor = attrib(type=int) micro = attrib(type=int) releaselevel = attrib(type=str) @classmethod def _from_version_string(cls, s): """ Parse *s* and return a _VersionInfo. """ v = s.split(".") if len(v) == 3: v.append("final") return cls( year=int(v[0]), minor=int(v[1]), micro=int(v[2]), releaselevel=v[3] ) def _ensure_tuple(self, other): """ Ensure *other* is a tuple of a valid length. Returns a possibly transformed *other* and ourselves as a tuple of the same length as *other*. """ if self.__class__ is other.__class__: other = astuple(other) if not isinstance(other, tuple): raise NotImplementedError if not (1 <= len(other) <= 4): raise NotImplementedError return astuple(self)[: len(other)], other def __eq__(self, other): try: us, them = self._ensure_tuple(other) except NotImplementedError: return NotImplemented return us == them def __lt__(self, other): try: us, them = self._ensure_tuple(other) except NotImplementedError: return NotImplemented # Since alphabetically "dev0" < "final" < "post1" < "post2", we don't # have to do anything special with releaselevel for now. return us < them ================================================ FILE: openpype/hosts/fusion/vendor/attr/_version_info.pyi ================================================ class VersionInfo: @property def year(self) -> int: ... @property def minor(self) -> int: ... @property def micro(self) -> int: ... @property def releaselevel(self) -> str: ... ================================================ FILE: openpype/hosts/fusion/vendor/attr/converters.py ================================================ """ Commonly useful converters. """ from __future__ import absolute_import, division, print_function from ._compat import PY2 from ._make import NOTHING, Factory, pipe if not PY2: import inspect import typing __all__ = [ "pipe", "optional", "default_if_none", ] def optional(converter): """ A converter that allows an attribute to be optional. An optional attribute is one which can be set to ``None``. Type annotations will be inferred from the wrapped converter's, if it has any. :param callable converter: the converter that is used for non-``None`` values. .. versionadded:: 17.1.0 """ def optional_converter(val): if val is None: return None return converter(val) if not PY2: sig = None try: sig = inspect.signature(converter) except (ValueError, TypeError): # inspect failed pass if sig: params = list(sig.parameters.values()) if params and params[0].annotation is not inspect.Parameter.empty: optional_converter.__annotations__["val"] = typing.Optional[ params[0].annotation ] if sig.return_annotation is not inspect.Signature.empty: optional_converter.__annotations__["return"] = typing.Optional[ sig.return_annotation ] return optional_converter def default_if_none(default=NOTHING, factory=None): """ A converter that allows to replace ``None`` values by *default* or the result of *factory*. :param default: Value to be used if ``None`` is passed. Passing an instance of `attr.Factory` is supported, however the ``takes_self`` option is *not*. :param callable factory: A callable that takes no parameters whose result is used if ``None`` is passed. :raises TypeError: If **neither** *default* or *factory* is passed. :raises TypeError: If **both** *default* and *factory* are passed. :raises ValueError: If an instance of `attr.Factory` is passed with ``takes_self=True``. .. versionadded:: 18.2.0 """ if default is NOTHING and factory is None: raise TypeError("Must pass either `default` or `factory`.") if default is not NOTHING and factory is not None: raise TypeError( "Must pass either `default` or `factory` but not both." ) if factory is not None: default = Factory(factory) if isinstance(default, Factory): if default.takes_self: raise ValueError( "`takes_self` is not supported by default_if_none." ) def default_if_none_converter(val): if val is not None: return val return default.factory() else: def default_if_none_converter(val): if val is not None: return val return default return default_if_none_converter ================================================ FILE: openpype/hosts/fusion/vendor/attr/converters.pyi ================================================ from typing import Callable, Optional, TypeVar, overload from . import _ConverterType _T = TypeVar("_T") def pipe(*validators: _ConverterType) -> _ConverterType: ... def optional(converter: _ConverterType) -> _ConverterType: ... @overload def default_if_none(default: _T) -> _ConverterType: ... @overload def default_if_none(*, factory: Callable[[], _T]) -> _ConverterType: ... ================================================ FILE: openpype/hosts/fusion/vendor/attr/exceptions.py ================================================ from __future__ import absolute_import, division, print_function class FrozenError(AttributeError): """ A frozen/immutable instance or attribute have been attempted to be modified. It mirrors the behavior of ``namedtuples`` by using the same error message and subclassing `AttributeError`. .. versionadded:: 20.1.0 """ msg = "can't set attribute" args = [msg] class FrozenInstanceError(FrozenError): """ A frozen instance has been attempted to be modified. .. versionadded:: 16.1.0 """ class FrozenAttributeError(FrozenError): """ A frozen attribute has been attempted to be modified. .. versionadded:: 20.1.0 """ class AttrsAttributeNotFoundError(ValueError): """ An ``attrs`` function couldn't find an attribute that the user asked for. .. versionadded:: 16.2.0 """ class NotAnAttrsClassError(ValueError): """ A non-``attrs`` class has been passed into an ``attrs`` function. .. versionadded:: 16.2.0 """ class DefaultAlreadySetError(RuntimeError): """ A default has been set using ``attr.ib()`` and is attempted to be reset using the decorator. .. versionadded:: 17.1.0 """ class UnannotatedAttributeError(RuntimeError): """ A class with ``auto_attribs=True`` has an ``attr.ib()`` without a type annotation. .. versionadded:: 17.3.0 """ class PythonTooOldError(RuntimeError): """ It was attempted to use an ``attrs`` feature that requires a newer Python version. .. versionadded:: 18.2.0 """ class NotCallableError(TypeError): """ A ``attr.ib()`` requiring a callable has been set with a value that is not callable. .. versionadded:: 19.2.0 """ def __init__(self, msg, value): super(TypeError, self).__init__(msg, value) self.msg = msg self.value = value def __str__(self): return str(self.msg) ================================================ FILE: openpype/hosts/fusion/vendor/attr/exceptions.pyi ================================================ from typing import Any class FrozenError(AttributeError): msg: str = ... class FrozenInstanceError(FrozenError): ... class FrozenAttributeError(FrozenError): ... class AttrsAttributeNotFoundError(ValueError): ... class NotAnAttrsClassError(ValueError): ... class DefaultAlreadySetError(RuntimeError): ... class UnannotatedAttributeError(RuntimeError): ... class PythonTooOldError(RuntimeError): ... class NotCallableError(TypeError): msg: str = ... value: Any = ... def __init__(self, msg: str, value: Any) -> None: ... ================================================ FILE: openpype/hosts/fusion/vendor/attr/filters.py ================================================ """ Commonly useful filters for `attr.asdict`. """ from __future__ import absolute_import, division, print_function from ._compat import isclass from ._make import Attribute def _split_what(what): """ Returns a tuple of `frozenset`s of classes and attributes. """ return ( frozenset(cls for cls in what if isclass(cls)), frozenset(cls for cls in what if isinstance(cls, Attribute)), ) def include(*what): """ Whitelist *what*. :param what: What to whitelist. :type what: `list` of `type` or `attr.Attribute`\\ s :rtype: `callable` """ cls, attrs = _split_what(what) def include_(attribute, value): return value.__class__ in cls or attribute in attrs return include_ def exclude(*what): """ Blacklist *what*. :param what: What to blacklist. :type what: `list` of classes or `attr.Attribute`\\ s. :rtype: `callable` """ cls, attrs = _split_what(what) def exclude_(attribute, value): return value.__class__ not in cls and attribute not in attrs return exclude_ ================================================ FILE: openpype/hosts/fusion/vendor/attr/filters.pyi ================================================ from typing import Any, Union from . import Attribute, _FilterType def include(*what: Union[type, Attribute[Any]]) -> _FilterType[Any]: ... def exclude(*what: Union[type, Attribute[Any]]) -> _FilterType[Any]: ... ================================================ FILE: openpype/hosts/fusion/vendor/attr/py.typed ================================================ ================================================ FILE: openpype/hosts/fusion/vendor/attr/setters.py ================================================ """ Commonly used hooks for on_setattr. """ from __future__ import absolute_import, division, print_function from . import _config from .exceptions import FrozenAttributeError def pipe(*setters): """ Run all *setters* and return the return value of the last one. .. versionadded:: 20.1.0 """ def wrapped_pipe(instance, attrib, new_value): rv = new_value for setter in setters: rv = setter(instance, attrib, rv) return rv return wrapped_pipe def frozen(_, __, ___): """ Prevent an attribute to be modified. .. versionadded:: 20.1.0 """ raise FrozenAttributeError() def validate(instance, attrib, new_value): """ Run *attrib*'s validator on *new_value* if it has one. .. versionadded:: 20.1.0 """ if _config._run_validators is False: return new_value v = attrib.validator if not v: return new_value v(instance, attrib, new_value) return new_value def convert(instance, attrib, new_value): """ Run *attrib*'s converter -- if it has one -- on *new_value* and return the result. .. versionadded:: 20.1.0 """ c = attrib.converter if c: return c(new_value) return new_value NO_OP = object() """ Sentinel for disabling class-wide *on_setattr* hooks for certain attributes. Does not work in `pipe` or within lists. .. versionadded:: 20.1.0 """ ================================================ FILE: openpype/hosts/fusion/vendor/attr/setters.pyi ================================================ from typing import Any, NewType, NoReturn, TypeVar, cast from . import Attribute, _OnSetAttrType _T = TypeVar("_T") def frozen( instance: Any, attribute: Attribute[Any], new_value: Any ) -> NoReturn: ... def pipe(*setters: _OnSetAttrType) -> _OnSetAttrType: ... def validate(instance: Any, attribute: Attribute[_T], new_value: _T) -> _T: ... # convert is allowed to return Any, because they can be chained using pipe. def convert( instance: Any, attribute: Attribute[Any], new_value: Any ) -> Any: ... _NoOpType = NewType("_NoOpType", object) NO_OP: _NoOpType ================================================ FILE: openpype/hosts/fusion/vendor/attr/validators.py ================================================ """ Commonly useful validators. """ from __future__ import absolute_import, division, print_function import re from ._make import _AndValidator, and_, attrib, attrs from .exceptions import NotCallableError __all__ = [ "and_", "deep_iterable", "deep_mapping", "in_", "instance_of", "is_callable", "matches_re", "optional", "provides", ] @attrs(repr=False, slots=True, hash=True) class _InstanceOfValidator(object): type = attrib() def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if not isinstance(value, self.type): raise TypeError( "'{name}' must be {type!r} (got {value!r} that is a " "{actual!r}).".format( name=attr.name, type=self.type, actual=value.__class__, value=value, ), attr, self.type, value, ) def __repr__(self): return "".format( type=self.type ) def instance_of(type): """ A validator that raises a `TypeError` if the initializer is called with a wrong type for this particular attribute (checks are performed using `isinstance` therefore it's also valid to pass a tuple of types). :param type: The type to check for. :type type: type or tuple of types :raises TypeError: With a human readable error message, the attribute (of type `attr.Attribute`), the expected type, and the value it got. """ return _InstanceOfValidator(type) @attrs(repr=False, frozen=True, slots=True) class _MatchesReValidator(object): regex = attrib() flags = attrib() match_func = attrib() def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if not self.match_func(value): raise ValueError( "'{name}' must match regex {regex!r}" " ({value!r} doesn't)".format( name=attr.name, regex=self.regex.pattern, value=value ), attr, self.regex, value, ) def __repr__(self): return "".format( regex=self.regex ) def matches_re(regex, flags=0, func=None): r""" A validator that raises `ValueError` if the initializer is called with a string that doesn't match *regex*. :param str regex: a regex string to match against :param int flags: flags that will be passed to the underlying re function (default 0) :param callable func: which underlying `re` function to call (options are `re.fullmatch`, `re.search`, `re.match`, default is ``None`` which means either `re.fullmatch` or an emulation of it on Python 2). For performance reasons, they won't be used directly but on a pre-`re.compile`\ ed pattern. .. versionadded:: 19.2.0 """ fullmatch = getattr(re, "fullmatch", None) valid_funcs = (fullmatch, None, re.search, re.match) if func not in valid_funcs: raise ValueError( "'func' must be one of %s." % ( ", ".join( sorted( e and e.__name__ or "None" for e in set(valid_funcs) ) ), ) ) pattern = re.compile(regex, flags) if func is re.match: match_func = pattern.match elif func is re.search: match_func = pattern.search else: if fullmatch: match_func = pattern.fullmatch else: pattern = re.compile(r"(?:{})\Z".format(regex), flags) match_func = pattern.match return _MatchesReValidator(pattern, flags, match_func) @attrs(repr=False, slots=True, hash=True) class _ProvidesValidator(object): interface = attrib() def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if not self.interface.providedBy(value): raise TypeError( "'{name}' must provide {interface!r} which {value!r} " "doesn't.".format( name=attr.name, interface=self.interface, value=value ), attr, self.interface, value, ) def __repr__(self): return "".format( interface=self.interface ) def provides(interface): """ A validator that raises a `TypeError` if the initializer is called with an object that does not provide the requested *interface* (checks are performed using ``interface.providedBy(value)`` (see `zope.interface `_). :param interface: The interface to check for. :type interface: ``zope.interface.Interface`` :raises TypeError: With a human readable error message, the attribute (of type `attr.Attribute`), the expected interface, and the value it got. """ return _ProvidesValidator(interface) @attrs(repr=False, slots=True, hash=True) class _OptionalValidator(object): validator = attrib() def __call__(self, inst, attr, value): if value is None: return self.validator(inst, attr, value) def __repr__(self): return "".format( what=repr(self.validator) ) def optional(validator): """ A validator that makes an attribute optional. An optional attribute is one which can be set to ``None`` in addition to satisfying the requirements of the sub-validator. :param validator: A validator (or a list of validators) that is used for non-``None`` values. :type validator: callable or `list` of callables. .. versionadded:: 15.1.0 .. versionchanged:: 17.1.0 *validator* can be a list of validators. """ if isinstance(validator, list): return _OptionalValidator(_AndValidator(validator)) return _OptionalValidator(validator) @attrs(repr=False, slots=True, hash=True) class _InValidator(object): options = attrib() def __call__(self, inst, attr, value): try: in_options = value in self.options except TypeError: # e.g. `1 in "abc"` in_options = False if not in_options: raise ValueError( "'{name}' must be in {options!r} (got {value!r})".format( name=attr.name, options=self.options, value=value ) ) def __repr__(self): return "".format( options=self.options ) def in_(options): """ A validator that raises a `ValueError` if the initializer is called with a value that does not belong in the options provided. The check is performed using ``value in options``. :param options: Allowed options. :type options: list, tuple, `enum.Enum`, ... :raises ValueError: With a human readable error message, the attribute (of type `attr.Attribute`), the expected options, and the value it got. .. versionadded:: 17.1.0 """ return _InValidator(options) @attrs(repr=False, slots=False, hash=True) class _IsCallableValidator(object): def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if not callable(value): message = ( "'{name}' must be callable " "(got {value!r} that is a {actual!r})." ) raise NotCallableError( msg=message.format( name=attr.name, value=value, actual=value.__class__ ), value=value, ) def __repr__(self): return "" def is_callable(): """ A validator that raises a `attr.exceptions.NotCallableError` if the initializer is called with a value for this particular attribute that is not callable. .. versionadded:: 19.1.0 :raises `attr.exceptions.NotCallableError`: With a human readable error message containing the attribute (`attr.Attribute`) name, and the value it got. """ return _IsCallableValidator() @attrs(repr=False, slots=True, hash=True) class _DeepIterable(object): member_validator = attrib(validator=is_callable()) iterable_validator = attrib( default=None, validator=optional(is_callable()) ) def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if self.iterable_validator is not None: self.iterable_validator(inst, attr, value) for member in value: self.member_validator(inst, attr, member) def __repr__(self): iterable_identifier = ( "" if self.iterable_validator is None else " {iterable!r}".format(iterable=self.iterable_validator) ) return ( "" ).format( iterable_identifier=iterable_identifier, member=self.member_validator, ) def deep_iterable(member_validator, iterable_validator=None): """ A validator that performs deep validation of an iterable. :param member_validator: Validator to apply to iterable members :param iterable_validator: Validator to apply to iterable itself (optional) .. versionadded:: 19.1.0 :raises TypeError: if any sub-validators fail """ return _DeepIterable(member_validator, iterable_validator) @attrs(repr=False, slots=True, hash=True) class _DeepMapping(object): key_validator = attrib(validator=is_callable()) value_validator = attrib(validator=is_callable()) mapping_validator = attrib(default=None, validator=optional(is_callable())) def __call__(self, inst, attr, value): """ We use a callable class to be able to change the ``__repr__``. """ if self.mapping_validator is not None: self.mapping_validator(inst, attr, value) for key in value: self.key_validator(inst, attr, key) self.value_validator(inst, attr, value[key]) def __repr__(self): return ( "" ).format(key=self.key_validator, value=self.value_validator) def deep_mapping(key_validator, value_validator, mapping_validator=None): """ A validator that performs deep validation of a dictionary. :param key_validator: Validator to apply to dictionary keys :param value_validator: Validator to apply to dictionary values :param mapping_validator: Validator to apply to top-level mapping attribute (optional) .. versionadded:: 19.1.0 :raises TypeError: if any sub-validators fail """ return _DeepMapping(key_validator, value_validator, mapping_validator) ================================================ FILE: openpype/hosts/fusion/vendor/attr/validators.pyi ================================================ from typing import ( Any, AnyStr, Callable, Container, Iterable, List, Mapping, Match, Optional, Tuple, Type, TypeVar, Union, overload, ) from . import _ValidatorType _T = TypeVar("_T") _T1 = TypeVar("_T1") _T2 = TypeVar("_T2") _T3 = TypeVar("_T3") _I = TypeVar("_I", bound=Iterable) _K = TypeVar("_K") _V = TypeVar("_V") _M = TypeVar("_M", bound=Mapping) # To be more precise on instance_of use some overloads. # If there are more than 3 items in the tuple then we fall back to Any @overload def instance_of(type: Type[_T]) -> _ValidatorType[_T]: ... @overload def instance_of(type: Tuple[Type[_T]]) -> _ValidatorType[_T]: ... @overload def instance_of( type: Tuple[Type[_T1], Type[_T2]] ) -> _ValidatorType[Union[_T1, _T2]]: ... @overload def instance_of( type: Tuple[Type[_T1], Type[_T2], Type[_T3]] ) -> _ValidatorType[Union[_T1, _T2, _T3]]: ... @overload def instance_of(type: Tuple[type, ...]) -> _ValidatorType[Any]: ... def provides(interface: Any) -> _ValidatorType[Any]: ... def optional( validator: Union[_ValidatorType[_T], List[_ValidatorType[_T]]] ) -> _ValidatorType[Optional[_T]]: ... def in_(options: Container[_T]) -> _ValidatorType[_T]: ... def and_(*validators: _ValidatorType[_T]) -> _ValidatorType[_T]: ... def matches_re( regex: AnyStr, flags: int = ..., func: Optional[ Callable[[AnyStr, AnyStr, int], Optional[Match[AnyStr]]] ] = ..., ) -> _ValidatorType[AnyStr]: ... def deep_iterable( member_validator: _ValidatorType[_T], iterable_validator: Optional[_ValidatorType[_I]] = ..., ) -> _ValidatorType[_I]: ... def deep_mapping( key_validator: _ValidatorType[_K], value_validator: _ValidatorType[_V], mapping_validator: Optional[_ValidatorType[_M]] = ..., ) -> _ValidatorType[_M]: ... def is_callable() -> _ValidatorType[_T]: ... ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/__init__.py ================================================ """ Python HTTP library with thread-safe connection pooling, file post support, user friendly, and more """ from __future__ import absolute_import # Set default logging handler to avoid "No handler found" warnings. import logging import warnings from logging import NullHandler from . import exceptions from ._version import __version__ from .connectionpool import HTTPConnectionPool, HTTPSConnectionPool, connection_from_url from .filepost import encode_multipart_formdata from .poolmanager import PoolManager, ProxyManager, proxy_from_url from .response import HTTPResponse from .util.request import make_headers from .util.retry import Retry from .util.timeout import Timeout from .util.url import get_host __author__ = "Andrey Petrov (andrey.petrov@shazow.net)" __license__ = "MIT" __version__ = __version__ __all__ = ( "HTTPConnectionPool", "HTTPSConnectionPool", "PoolManager", "ProxyManager", "HTTPResponse", "Retry", "Timeout", "add_stderr_logger", "connection_from_url", "disable_warnings", "encode_multipart_formdata", "get_host", "make_headers", "proxy_from_url", ) logging.getLogger(__name__).addHandler(NullHandler()) def add_stderr_logger(level=logging.DEBUG): """ Helper for quickly adding a StreamHandler to the logger. Useful for debugging. Returns the handler after adding it. """ # This method needs to be in this __init__.py to get the __name__ correct # even if urllib3 is vendored within another package. logger = logging.getLogger(__name__) handler = logging.StreamHandler() handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) logger.addHandler(handler) logger.setLevel(level) logger.debug("Added a stderr logging handler to logger: %s", __name__) return handler # ... Clean up. del NullHandler # All warning filters *must* be appended unless you're really certain that they # shouldn't be: otherwise, it's very hard for users to use most Python # mechanisms to silence them. # SecurityWarning's always go off by default. warnings.simplefilter("always", exceptions.SecurityWarning, append=True) # SubjectAltNameWarning's should go off once per host warnings.simplefilter("default", exceptions.SubjectAltNameWarning, append=True) # InsecurePlatformWarning's don't vary between requests, so we keep it default. warnings.simplefilter("default", exceptions.InsecurePlatformWarning, append=True) # SNIMissingWarnings should go off only once. warnings.simplefilter("default", exceptions.SNIMissingWarning, append=True) def disable_warnings(category=exceptions.HTTPWarning): """ Helper for quickly disabling all urllib3 warnings. """ warnings.simplefilter("ignore", category) ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/_collections.py ================================================ from __future__ import absolute_import try: from collections.abc import Mapping, MutableMapping except ImportError: from collections import Mapping, MutableMapping try: from threading import RLock except ImportError: # Platform-specific: No threads available class RLock: def __enter__(self): pass def __exit__(self, exc_type, exc_value, traceback): pass from collections import OrderedDict from .exceptions import InvalidHeader from .packages import six from .packages.six import iterkeys, itervalues __all__ = ["RecentlyUsedContainer", "HTTPHeaderDict"] _Null = object() class RecentlyUsedContainer(MutableMapping): """ Provides a thread-safe dict-like container which maintains up to ``maxsize`` keys while throwing away the least-recently-used keys beyond ``maxsize``. :param maxsize: Maximum number of recent elements to retain. :param dispose_func: Every time an item is evicted from the container, ``dispose_func(value)`` is called. Callback which will get called """ ContainerCls = OrderedDict def __init__(self, maxsize=10, dispose_func=None): self._maxsize = maxsize self.dispose_func = dispose_func self._container = self.ContainerCls() self.lock = RLock() def __getitem__(self, key): # Re-insert the item, moving it to the end of the eviction line. with self.lock: item = self._container.pop(key) self._container[key] = item return item def __setitem__(self, key, value): evicted_value = _Null with self.lock: # Possibly evict the existing value of 'key' evicted_value = self._container.get(key, _Null) self._container[key] = value # If we didn't evict an existing value, we might have to evict the # least recently used item from the beginning of the container. if len(self._container) > self._maxsize: _key, evicted_value = self._container.popitem(last=False) if self.dispose_func and evicted_value is not _Null: self.dispose_func(evicted_value) def __delitem__(self, key): with self.lock: value = self._container.pop(key) if self.dispose_func: self.dispose_func(value) def __len__(self): with self.lock: return len(self._container) def __iter__(self): raise NotImplementedError( "Iteration over this class is unlikely to be threadsafe." ) def clear(self): with self.lock: # Copy pointers to all values, then wipe the mapping values = list(itervalues(self._container)) self._container.clear() if self.dispose_func: for value in values: self.dispose_func(value) def keys(self): with self.lock: return list(iterkeys(self._container)) class HTTPHeaderDict(MutableMapping): """ :param headers: An iterable of field-value pairs. Must not contain multiple field names when compared case-insensitively. :param kwargs: Additional field-value pairs to pass in to ``dict.update``. A ``dict`` like container for storing HTTP Headers. Field names are stored and compared case-insensitively in compliance with RFC 7230. Iteration provides the first case-sensitive key seen for each case-insensitive pair. Using ``__setitem__`` syntax overwrites fields that compare equal case-insensitively in order to maintain ``dict``'s api. For fields that compare equal, instead create a new ``HTTPHeaderDict`` and use ``.add`` in a loop. If multiple fields that are equal case-insensitively are passed to the constructor or ``.update``, the behavior is undefined and some will be lost. >>> headers = HTTPHeaderDict() >>> headers.add('Set-Cookie', 'foo=bar') >>> headers.add('set-cookie', 'baz=quxx') >>> headers['content-length'] = '7' >>> headers['SET-cookie'] 'foo=bar, baz=quxx' >>> headers['Content-Length'] '7' """ def __init__(self, headers=None, **kwargs): super(HTTPHeaderDict, self).__init__() self._container = OrderedDict() if headers is not None: if isinstance(headers, HTTPHeaderDict): self._copy_from(headers) else: self.extend(headers) if kwargs: self.extend(kwargs) def __setitem__(self, key, val): self._container[key.lower()] = [key, val] return self._container[key.lower()] def __getitem__(self, key): val = self._container[key.lower()] return ", ".join(val[1:]) def __delitem__(self, key): del self._container[key.lower()] def __contains__(self, key): return key.lower() in self._container def __eq__(self, other): if not isinstance(other, Mapping) and not hasattr(other, "keys"): return False if not isinstance(other, type(self)): other = type(self)(other) return dict((k.lower(), v) for k, v in self.itermerged()) == dict( (k.lower(), v) for k, v in other.itermerged() ) def __ne__(self, other): return not self.__eq__(other) if six.PY2: # Python 2 iterkeys = MutableMapping.iterkeys itervalues = MutableMapping.itervalues __marker = object() def __len__(self): return len(self._container) def __iter__(self): # Only provide the originally cased names for vals in self._container.values(): yield vals[0] def pop(self, key, default=__marker): """D.pop(k[,d]) -> v, remove specified key and return the corresponding value. If key is not found, d is returned if given, otherwise KeyError is raised. """ # Using the MutableMapping function directly fails due to the private marker. # Using ordinary dict.pop would expose the internal structures. # So let's reinvent the wheel. try: value = self[key] except KeyError: if default is self.__marker: raise return default else: del self[key] return value def discard(self, key): try: del self[key] except KeyError: pass def add(self, key, val): """Adds a (name, value) pair, doesn't overwrite the value if it already exists. >>> headers = HTTPHeaderDict(foo='bar') >>> headers.add('Foo', 'baz') >>> headers['foo'] 'bar, baz' """ key_lower = key.lower() new_vals = [key, val] # Keep the common case aka no item present as fast as possible vals = self._container.setdefault(key_lower, new_vals) if new_vals is not vals: vals.append(val) def extend(self, *args, **kwargs): """Generic import function for any type of header-like object. Adapted version of MutableMapping.update in order to insert items with self.add instead of self.__setitem__ """ if len(args) > 1: raise TypeError( "extend() takes at most 1 positional " "arguments ({0} given)".format(len(args)) ) other = args[0] if len(args) >= 1 else () if isinstance(other, HTTPHeaderDict): for key, val in other.iteritems(): self.add(key, val) elif isinstance(other, Mapping): for key in other: self.add(key, other[key]) elif hasattr(other, "keys"): for key in other.keys(): self.add(key, other[key]) else: for key, value in other: self.add(key, value) for key, value in kwargs.items(): self.add(key, value) def getlist(self, key, default=__marker): """Returns a list of all the values for the named field. Returns an empty list if the key doesn't exist.""" try: vals = self._container[key.lower()] except KeyError: if default is self.__marker: return [] return default else: return vals[1:] # Backwards compatibility for httplib getheaders = getlist getallmatchingheaders = getlist iget = getlist # Backwards compatibility for http.cookiejar get_all = getlist def __repr__(self): return "%s(%s)" % (type(self).__name__, dict(self.itermerged())) def _copy_from(self, other): for key in other: val = other.getlist(key) if isinstance(val, list): # Don't need to convert tuples val = list(val) self._container[key.lower()] = [key] + val def copy(self): clone = type(self)() clone._copy_from(self) return clone def iteritems(self): """Iterate over all header lines, including duplicate ones.""" for key in self: vals = self._container[key.lower()] for val in vals[1:]: yield vals[0], val def itermerged(self): """Iterate over all headers, merging duplicate ones together.""" for key in self: val = self._container[key.lower()] yield val[0], ", ".join(val[1:]) def items(self): return list(self.iteritems()) @classmethod def from_httplib(cls, message): # Python 2 """Read headers from a Python 2 httplib message object.""" # python2.7 does not expose a proper API for exporting multiheaders # efficiently. This function re-reads raw lines from the message # object and extracts the multiheaders properly. obs_fold_continued_leaders = (" ", "\t") headers = [] for line in message.headers: if line.startswith(obs_fold_continued_leaders): if not headers: # We received a header line that starts with OWS as described # in RFC-7230 S3.2.4. This indicates a multiline header, but # there exists no previous header to which we can attach it. raise InvalidHeader( "Header continuation with no previous header: %s" % line ) else: key, value = headers[-1] headers[-1] = (key, value + " " + line.strip()) continue key, value = line.split(":", 1) headers.append((key, value.strip())) return cls(headers) ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/_version.py ================================================ # This file is protected via CODEOWNERS __version__ = "1.26.6" ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/connection.py ================================================ from __future__ import absolute_import import datetime import logging import os import re import socket import warnings from socket import error as SocketError from socket import timeout as SocketTimeout from .packages import six from .packages.six.moves.http_client import HTTPConnection as _HTTPConnection from .packages.six.moves.http_client import HTTPException # noqa: F401 from .util.proxy import create_proxy_ssl_context try: # Compiled with SSL? import ssl BaseSSLError = ssl.SSLError except (ImportError, AttributeError): # Platform-specific: No SSL. ssl = None class BaseSSLError(BaseException): pass try: # Python 3: not a no-op, we're adding this to the namespace so it can be imported. ConnectionError = ConnectionError except NameError: # Python 2 class ConnectionError(Exception): pass try: # Python 3: # Not a no-op, we're adding this to the namespace so it can be imported. BrokenPipeError = BrokenPipeError except NameError: # Python 2: class BrokenPipeError(Exception): pass from ._collections import HTTPHeaderDict # noqa (historical, removed in v2) from ._version import __version__ from .exceptions import ( ConnectTimeoutError, NewConnectionError, SubjectAltNameWarning, SystemTimeWarning, ) from .packages.ssl_match_hostname import CertificateError, match_hostname from .util import SKIP_HEADER, SKIPPABLE_HEADERS, connection from .util.ssl_ import ( assert_fingerprint, create_urllib3_context, resolve_cert_reqs, resolve_ssl_version, ssl_wrap_socket, ) log = logging.getLogger(__name__) port_by_scheme = {"http": 80, "https": 443} # When it comes time to update this value as a part of regular maintenance # (ie test_recent_date is failing) update it to ~6 months before the current date. RECENT_DATE = datetime.date(2020, 7, 1) _CONTAINS_CONTROL_CHAR_RE = re.compile(r"[^-!#$%&'*+.^_`|~0-9a-zA-Z]") class HTTPConnection(_HTTPConnection, object): """ Based on :class:`http.client.HTTPConnection` but provides an extra constructor backwards-compatibility layer between older and newer Pythons. Additional keyword parameters are used to configure attributes of the connection. Accepted parameters include: - ``strict``: See the documentation on :class:`urllib3.connectionpool.HTTPConnectionPool` - ``source_address``: Set the source address for the current connection. - ``socket_options``: Set specific options on the underlying socket. If not specified, then defaults are loaded from ``HTTPConnection.default_socket_options`` which includes disabling Nagle's algorithm (sets TCP_NODELAY to 1) unless the connection is behind a proxy. For example, if you wish to enable TCP Keep Alive in addition to the defaults, you might pass: .. code-block:: python HTTPConnection.default_socket_options + [ (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), ] Or you may want to disable the defaults by passing an empty list (e.g., ``[]``). """ default_port = port_by_scheme["http"] #: Disable Nagle's algorithm by default. #: ``[(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)]`` default_socket_options = [(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)] #: Whether this connection verifies the host's certificate. is_verified = False def __init__(self, *args, **kw): if not six.PY2: kw.pop("strict", None) # Pre-set source_address. self.source_address = kw.get("source_address") #: The socket options provided by the user. If no options are #: provided, we use the default options. self.socket_options = kw.pop("socket_options", self.default_socket_options) # Proxy options provided by the user. self.proxy = kw.pop("proxy", None) self.proxy_config = kw.pop("proxy_config", None) _HTTPConnection.__init__(self, *args, **kw) @property def host(self): """ Getter method to remove any trailing dots that indicate the hostname is an FQDN. In general, SSL certificates don't include the trailing dot indicating a fully-qualified domain name, and thus, they don't validate properly when checked against a domain name that includes the dot. In addition, some servers may not expect to receive the trailing dot when provided. However, the hostname with trailing dot is critical to DNS resolution; doing a lookup with the trailing dot will properly only resolve the appropriate FQDN, whereas a lookup without a trailing dot will search the system's search domain list. Thus, it's important to keep the original host around for use only in those cases where it's appropriate (i.e., when doing DNS lookup to establish the actual TCP connection across which we're going to send HTTP requests). """ return self._dns_host.rstrip(".") @host.setter def host(self, value): """ Setter for the `host` property. We assume that only urllib3 uses the _dns_host attribute; httplib itself only uses `host`, and it seems reasonable that other libraries follow suit. """ self._dns_host = value def _new_conn(self): """Establish a socket connection and set nodelay settings on it. :return: New socket connection. """ extra_kw = {} if self.source_address: extra_kw["source_address"] = self.source_address if self.socket_options: extra_kw["socket_options"] = self.socket_options try: conn = connection.create_connection( (self._dns_host, self.port), self.timeout, **extra_kw ) except SocketTimeout: raise ConnectTimeoutError( self, "Connection to %s timed out. (connect timeout=%s)" % (self.host, self.timeout), ) except SocketError as e: raise NewConnectionError( self, "Failed to establish a new connection: %s" % e ) return conn def _is_using_tunnel(self): # Google App Engine's httplib does not define _tunnel_host return getattr(self, "_tunnel_host", None) def _prepare_conn(self, conn): self.sock = conn if self._is_using_tunnel(): # TODO: Fix tunnel so it doesn't depend on self.sock state. self._tunnel() # Mark this connection as not reusable self.auto_open = 0 def connect(self): conn = self._new_conn() self._prepare_conn(conn) def putrequest(self, method, url, *args, **kwargs): """ """ # Empty docstring because the indentation of CPython's implementation # is broken but we don't want this method in our documentation. match = _CONTAINS_CONTROL_CHAR_RE.search(method) if match: raise ValueError( "Method cannot contain non-token characters %r (found at least %r)" % (method, match.group()) ) return _HTTPConnection.putrequest(self, method, url, *args, **kwargs) def putheader(self, header, *values): """ """ if not any(isinstance(v, str) and v == SKIP_HEADER for v in values): _HTTPConnection.putheader(self, header, *values) elif six.ensure_str(header.lower()) not in SKIPPABLE_HEADERS: raise ValueError( "urllib3.util.SKIP_HEADER only supports '%s'" % ("', '".join(map(str.title, sorted(SKIPPABLE_HEADERS))),) ) def request(self, method, url, body=None, headers=None): if headers is None: headers = {} else: # Avoid modifying the headers passed into .request() headers = headers.copy() if "user-agent" not in (six.ensure_str(k.lower()) for k in headers): headers["User-Agent"] = _get_default_user_agent() super(HTTPConnection, self).request(method, url, body=body, headers=headers) def request_chunked(self, method, url, body=None, headers=None): """ Alternative to the common request method, which sends the body with chunked encoding and not as one block """ headers = headers or {} header_keys = set([six.ensure_str(k.lower()) for k in headers]) skip_accept_encoding = "accept-encoding" in header_keys skip_host = "host" in header_keys self.putrequest( method, url, skip_accept_encoding=skip_accept_encoding, skip_host=skip_host ) if "user-agent" not in header_keys: self.putheader("User-Agent", _get_default_user_agent()) for header, value in headers.items(): self.putheader(header, value) if "transfer-encoding" not in header_keys: self.putheader("Transfer-Encoding", "chunked") self.endheaders() if body is not None: stringish_types = six.string_types + (bytes,) if isinstance(body, stringish_types): body = (body,) for chunk in body: if not chunk: continue if not isinstance(chunk, bytes): chunk = chunk.encode("utf8") len_str = hex(len(chunk))[2:] to_send = bytearray(len_str.encode()) to_send += b"\r\n" to_send += chunk to_send += b"\r\n" self.send(to_send) # After the if clause, to always have a closed body self.send(b"0\r\n\r\n") class HTTPSConnection(HTTPConnection): """ Many of the parameters to this constructor are passed to the underlying SSL socket by means of :py:func:`urllib3.util.ssl_wrap_socket`. """ default_port = port_by_scheme["https"] cert_reqs = None ca_certs = None ca_cert_dir = None ca_cert_data = None ssl_version = None assert_fingerprint = None tls_in_tls_required = False def __init__( self, host, port=None, key_file=None, cert_file=None, key_password=None, strict=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, ssl_context=None, server_hostname=None, **kw ): HTTPConnection.__init__(self, host, port, strict=strict, timeout=timeout, **kw) self.key_file = key_file self.cert_file = cert_file self.key_password = key_password self.ssl_context = ssl_context self.server_hostname = server_hostname # Required property for Google AppEngine 1.9.0 which otherwise causes # HTTPS requests to go out as HTTP. (See Issue #356) self._protocol = "https" def set_cert( self, key_file=None, cert_file=None, cert_reqs=None, key_password=None, ca_certs=None, assert_hostname=None, assert_fingerprint=None, ca_cert_dir=None, ca_cert_data=None, ): """ This method should only be called once, before the connection is used. """ # If cert_reqs is not provided we'll assume CERT_REQUIRED unless we also # have an SSLContext object in which case we'll use its verify_mode. if cert_reqs is None: if self.ssl_context is not None: cert_reqs = self.ssl_context.verify_mode else: cert_reqs = resolve_cert_reqs(None) self.key_file = key_file self.cert_file = cert_file self.cert_reqs = cert_reqs self.key_password = key_password self.assert_hostname = assert_hostname self.assert_fingerprint = assert_fingerprint self.ca_certs = ca_certs and os.path.expanduser(ca_certs) self.ca_cert_dir = ca_cert_dir and os.path.expanduser(ca_cert_dir) self.ca_cert_data = ca_cert_data def connect(self): # Add certificate verification conn = self._new_conn() hostname = self.host tls_in_tls = False if self._is_using_tunnel(): if self.tls_in_tls_required: conn = self._connect_tls_proxy(hostname, conn) tls_in_tls = True self.sock = conn # Calls self._set_hostport(), so self.host is # self._tunnel_host below. self._tunnel() # Mark this connection as not reusable self.auto_open = 0 # Override the host with the one we're requesting data from. hostname = self._tunnel_host server_hostname = hostname if self.server_hostname is not None: server_hostname = self.server_hostname is_time_off = datetime.date.today() < RECENT_DATE if is_time_off: warnings.warn( ( "System time is way off (before {0}). This will probably " "lead to SSL verification errors" ).format(RECENT_DATE), SystemTimeWarning, ) # Wrap socket using verification with the root certs in # trusted_root_certs default_ssl_context = False if self.ssl_context is None: default_ssl_context = True self.ssl_context = create_urllib3_context( ssl_version=resolve_ssl_version(self.ssl_version), cert_reqs=resolve_cert_reqs(self.cert_reqs), ) context = self.ssl_context context.verify_mode = resolve_cert_reqs(self.cert_reqs) # Try to load OS default certs if none are given. # Works well on Windows (requires Python3.4+) if ( not self.ca_certs and not self.ca_cert_dir and not self.ca_cert_data and default_ssl_context and hasattr(context, "load_default_certs") ): context.load_default_certs() self.sock = ssl_wrap_socket( sock=conn, keyfile=self.key_file, certfile=self.cert_file, key_password=self.key_password, ca_certs=self.ca_certs, ca_cert_dir=self.ca_cert_dir, ca_cert_data=self.ca_cert_data, server_hostname=server_hostname, ssl_context=context, tls_in_tls=tls_in_tls, ) # If we're using all defaults and the connection # is TLSv1 or TLSv1.1 we throw a DeprecationWarning # for the host. if ( default_ssl_context and self.ssl_version is None and hasattr(self.sock, "version") and self.sock.version() in {"TLSv1", "TLSv1.1"} ): warnings.warn( "Negotiating TLSv1/TLSv1.1 by default is deprecated " "and will be disabled in urllib3 v2.0.0. Connecting to " "'%s' with '%s' can be enabled by explicitly opting-in " "with 'ssl_version'" % (self.host, self.sock.version()), DeprecationWarning, ) if self.assert_fingerprint: assert_fingerprint( self.sock.getpeercert(binary_form=True), self.assert_fingerprint ) elif ( context.verify_mode != ssl.CERT_NONE and not getattr(context, "check_hostname", False) and self.assert_hostname is not False ): # While urllib3 attempts to always turn off hostname matching from # the TLS library, this cannot always be done. So we check whether # the TLS Library still thinks it's matching hostnames. cert = self.sock.getpeercert() if not cert.get("subjectAltName", ()): warnings.warn( ( "Certificate for {0} has no `subjectAltName`, falling back to check for a " "`commonName` for now. This feature is being removed by major browsers and " "deprecated by RFC 2818. (See https://github.com/urllib3/urllib3/issues/497 " "for details.)".format(hostname) ), SubjectAltNameWarning, ) _match_hostname(cert, self.assert_hostname or server_hostname) self.is_verified = ( context.verify_mode == ssl.CERT_REQUIRED or self.assert_fingerprint is not None ) def _connect_tls_proxy(self, hostname, conn): """ Establish a TLS connection to the proxy using the provided SSL context. """ proxy_config = self.proxy_config ssl_context = proxy_config.ssl_context if ssl_context: # If the user provided a proxy context, we assume CA and client # certificates have already been set return ssl_wrap_socket( sock=conn, server_hostname=hostname, ssl_context=ssl_context, ) ssl_context = create_proxy_ssl_context( self.ssl_version, self.cert_reqs, self.ca_certs, self.ca_cert_dir, self.ca_cert_data, ) # By default urllib3's SSLContext disables `check_hostname` and uses # a custom check. For proxies we're good with relying on the default # verification. ssl_context.check_hostname = True # If no cert was provided, use only the default options for server # certificate validation return ssl_wrap_socket( sock=conn, ca_certs=self.ca_certs, ca_cert_dir=self.ca_cert_dir, ca_cert_data=self.ca_cert_data, server_hostname=hostname, ssl_context=ssl_context, ) def _match_hostname(cert, asserted_hostname): try: match_hostname(cert, asserted_hostname) except CertificateError as e: log.warning( "Certificate did not match expected hostname: %s. Certificate: %s", asserted_hostname, cert, ) # Add cert to exception and reraise so client code can inspect # the cert when catching the exception, if they want to e._peer_cert = cert raise def _get_default_user_agent(): return "python-urllib3/%s" % __version__ class DummyConnection(object): """Used to detect a failed ConnectionCls import.""" pass if not ssl: HTTPSConnection = DummyConnection # noqa: F811 VerifiedHTTPSConnection = HTTPSConnection ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/connectionpool.py ================================================ from __future__ import absolute_import import errno import logging import socket import sys import warnings from socket import error as SocketError from socket import timeout as SocketTimeout from .connection import ( BaseSSLError, BrokenPipeError, DummyConnection, HTTPConnection, HTTPException, HTTPSConnection, VerifiedHTTPSConnection, port_by_scheme, ) from .exceptions import ( ClosedPoolError, EmptyPoolError, HeaderParsingError, HostChangedError, InsecureRequestWarning, LocationValueError, MaxRetryError, NewConnectionError, ProtocolError, ProxyError, ReadTimeoutError, SSLError, TimeoutError, ) from .packages import six from .packages.six.moves import queue from .packages.ssl_match_hostname import CertificateError from .request import RequestMethods from .response import HTTPResponse from .util.connection import is_connection_dropped from .util.proxy import connection_requires_http_tunnel from .util.queue import LifoQueue from .util.request import set_file_position from .util.response import assert_header_parsing from .util.retry import Retry from .util.timeout import Timeout from .util.url import Url, _encode_target from .util.url import _normalize_host as normalize_host from .util.url import get_host, parse_url xrange = six.moves.xrange log = logging.getLogger(__name__) _Default = object() # Pool objects class ConnectionPool(object): """ Base class for all connection pools, such as :class:`.HTTPConnectionPool` and :class:`.HTTPSConnectionPool`. .. note:: ConnectionPool.urlopen() does not normalize or percent-encode target URIs which is useful if your target server doesn't support percent-encoded target URIs. """ scheme = None QueueCls = LifoQueue def __init__(self, host, port=None): if not host: raise LocationValueError("No host specified.") self.host = _normalize_host(host, scheme=self.scheme) self._proxy_host = host.lower() self.port = port def __str__(self): return "%s(host=%r, port=%r)" % (type(self).__name__, self.host, self.port) def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.close() # Return False to re-raise any potential exceptions return False def close(self): """ Close all pooled connections and disable the pool. """ pass # This is taken from http://hg.python.org/cpython/file/7aaba721ebc0/Lib/socket.py#l252 _blocking_errnos = {errno.EAGAIN, errno.EWOULDBLOCK} class HTTPConnectionPool(ConnectionPool, RequestMethods): """ Thread-safe connection pool for one host. :param host: Host used for this HTTP Connection (e.g. "localhost"), passed into :class:`http.client.HTTPConnection`. :param port: Port used for this HTTP Connection (None is equivalent to 80), passed into :class:`http.client.HTTPConnection`. :param strict: Causes BadStatusLine to be raised if the status line can't be parsed as a valid HTTP/1.0 or 1.1 status line, passed into :class:`http.client.HTTPConnection`. .. note:: Only works in Python 2. This parameter is ignored in Python 3. :param timeout: Socket timeout in seconds for each individual connection. This can be a float or integer, which sets the timeout for the HTTP request, or an instance of :class:`urllib3.util.Timeout` which gives you more fine-grained control over request timeouts. After the constructor has been parsed, this is always a `urllib3.util.Timeout` object. :param maxsize: Number of connections to save that can be reused. More than 1 is useful in multithreaded situations. If ``block`` is set to False, more connections will be created but they will not be saved once they've been used. :param block: If set to True, no more than ``maxsize`` connections will be used at a time. When no free connections are available, the call will block until a connection has been released. This is a useful side effect for particular multithreaded situations where one does not want to use more than maxsize connections per host to prevent flooding. :param headers: Headers to include with all requests, unless other headers are given explicitly. :param retries: Retry configuration to use by default with requests in this pool. :param _proxy: Parsed proxy URL, should not be used directly, instead, see :class:`urllib3.ProxyManager` :param _proxy_headers: A dictionary with proxy headers, should not be used directly, instead, see :class:`urllib3.ProxyManager` :param \\**conn_kw: Additional parameters are used to create fresh :class:`urllib3.connection.HTTPConnection`, :class:`urllib3.connection.HTTPSConnection` instances. """ scheme = "http" ConnectionCls = HTTPConnection ResponseCls = HTTPResponse def __init__( self, host, port=None, strict=False, timeout=Timeout.DEFAULT_TIMEOUT, maxsize=1, block=False, headers=None, retries=None, _proxy=None, _proxy_headers=None, _proxy_config=None, **conn_kw ): ConnectionPool.__init__(self, host, port) RequestMethods.__init__(self, headers) self.strict = strict if not isinstance(timeout, Timeout): timeout = Timeout.from_float(timeout) if retries is None: retries = Retry.DEFAULT self.timeout = timeout self.retries = retries self.pool = self.QueueCls(maxsize) self.block = block self.proxy = _proxy self.proxy_headers = _proxy_headers or {} self.proxy_config = _proxy_config # Fill the queue up so that doing get() on it will block properly for _ in xrange(maxsize): self.pool.put(None) # These are mostly for testing and debugging purposes. self.num_connections = 0 self.num_requests = 0 self.conn_kw = conn_kw if self.proxy: # Enable Nagle's algorithm for proxies, to avoid packet fragmentation. # We cannot know if the user has added default socket options, so we cannot replace the # list. self.conn_kw.setdefault("socket_options", []) self.conn_kw["proxy"] = self.proxy self.conn_kw["proxy_config"] = self.proxy_config def _new_conn(self): """ Return a fresh :class:`HTTPConnection`. """ self.num_connections += 1 log.debug( "Starting new HTTP connection (%d): %s:%s", self.num_connections, self.host, self.port or "80", ) conn = self.ConnectionCls( host=self.host, port=self.port, timeout=self.timeout.connect_timeout, strict=self.strict, **self.conn_kw ) return conn def _get_conn(self, timeout=None): """ Get a connection. Will return a pooled connection if one is available. If no connections are available and :prop:`.block` is ``False``, then a fresh connection is returned. :param timeout: Seconds to wait before giving up and raising :class:`urllib3.exceptions.EmptyPoolError` if the pool is empty and :prop:`.block` is ``True``. """ conn = None try: conn = self.pool.get(block=self.block, timeout=timeout) except AttributeError: # self.pool is None raise ClosedPoolError(self, "Pool is closed.") except queue.Empty: if self.block: raise EmptyPoolError( self, "Pool reached maximum size and no more connections are allowed.", ) pass # Oh well, we'll create a new connection then # If this is a persistent connection, check if it got disconnected if conn and is_connection_dropped(conn): log.debug("Resetting dropped connection: %s", self.host) conn.close() if getattr(conn, "auto_open", 1) == 0: # This is a proxied connection that has been mutated by # http.client._tunnel() and cannot be reused (since it would # attempt to bypass the proxy) conn = None return conn or self._new_conn() def _put_conn(self, conn): """ Put a connection back into the pool. :param conn: Connection object for the current host and port as returned by :meth:`._new_conn` or :meth:`._get_conn`. If the pool is already full, the connection is closed and discarded because we exceeded maxsize. If connections are discarded frequently, then maxsize should be increased. If the pool is closed, then the connection will be closed and discarded. """ try: self.pool.put(conn, block=False) return # Everything is dandy, done. except AttributeError: # self.pool is None. pass except queue.Full: # This should never happen if self.block == True log.warning("Connection pool is full, discarding connection: %s", self.host) # Connection never got put back into the pool, close it. if conn: conn.close() def _validate_conn(self, conn): """ Called right before a request is made, after the socket is created. """ pass def _prepare_proxy(self, conn): # Nothing to do for HTTP connections. pass def _get_timeout(self, timeout): """Helper that always returns a :class:`urllib3.util.Timeout`""" if timeout is _Default: return self.timeout.clone() if isinstance(timeout, Timeout): return timeout.clone() else: # User passed us an int/float. This is for backwards compatibility, # can be removed later return Timeout.from_float(timeout) def _raise_timeout(self, err, url, timeout_value): """Is the error actually a timeout? Will raise a ReadTimeout or pass""" if isinstance(err, SocketTimeout): raise ReadTimeoutError( self, url, "Read timed out. (read timeout=%s)" % timeout_value ) # See the above comment about EAGAIN in Python 3. In Python 2 we have # to specifically catch it and throw the timeout error if hasattr(err, "errno") and err.errno in _blocking_errnos: raise ReadTimeoutError( self, url, "Read timed out. (read timeout=%s)" % timeout_value ) # Catch possible read timeouts thrown as SSL errors. If not the # case, rethrow the original. We need to do this because of: # http://bugs.python.org/issue10272 if "timed out" in str(err) or "did not complete (read)" in str( err ): # Python < 2.7.4 raise ReadTimeoutError( self, url, "Read timed out. (read timeout=%s)" % timeout_value ) def _make_request( self, conn, method, url, timeout=_Default, chunked=False, **httplib_request_kw ): """ Perform a request on a given urllib connection object taken from our pool. :param conn: a connection from one of our connection pools :param timeout: Socket timeout in seconds for the request. This can be a float or integer, which will set the same timeout value for the socket connect and the socket read, or an instance of :class:`urllib3.util.Timeout`, which gives you more fine-grained control over your timeouts. """ self.num_requests += 1 timeout_obj = self._get_timeout(timeout) timeout_obj.start_connect() conn.timeout = timeout_obj.connect_timeout # Trigger any extra validation we need to do. try: self._validate_conn(conn) except (SocketTimeout, BaseSSLError) as e: # Py2 raises this as a BaseSSLError, Py3 raises it as socket timeout. self._raise_timeout(err=e, url=url, timeout_value=conn.timeout) raise # conn.request() calls http.client.*.request, not the method in # urllib3.request. It also calls makefile (recv) on the socket. try: if chunked: conn.request_chunked(method, url, **httplib_request_kw) else: conn.request(method, url, **httplib_request_kw) # We are swallowing BrokenPipeError (errno.EPIPE) since the server is # legitimately able to close the connection after sending a valid response. # With this behaviour, the received response is still readable. except BrokenPipeError: # Python 3 pass except IOError as e: # Python 2 and macOS/Linux # EPIPE and ESHUTDOWN are BrokenPipeError on Python 2, and EPROTOTYPE is needed on macOS # https://erickt.github.io/blog/2014/11/19/adventures-in-debugging-a-potential-osx-kernel-bug/ if e.errno not in { errno.EPIPE, errno.ESHUTDOWN, errno.EPROTOTYPE, }: raise # Reset the timeout for the recv() on the socket read_timeout = timeout_obj.read_timeout # App Engine doesn't have a sock attr if getattr(conn, "sock", None): # In Python 3 socket.py will catch EAGAIN and return None when you # try and read into the file pointer created by http.client, which # instead raises a BadStatusLine exception. Instead of catching # the exception and assuming all BadStatusLine exceptions are read # timeouts, check for a zero timeout before making the request. if read_timeout == 0: raise ReadTimeoutError( self, url, "Read timed out. (read timeout=%s)" % read_timeout ) if read_timeout is Timeout.DEFAULT_TIMEOUT: conn.sock.settimeout(socket.getdefaulttimeout()) else: # None or a value conn.sock.settimeout(read_timeout) # Receive the response from the server try: try: # Python 2.7, use buffering of HTTP responses httplib_response = conn.getresponse(buffering=True) except TypeError: # Python 3 try: httplib_response = conn.getresponse() except BaseException as e: # Remove the TypeError from the exception chain in # Python 3 (including for exceptions like SystemExit). # Otherwise it looks like a bug in the code. six.raise_from(e, None) except (SocketTimeout, BaseSSLError, SocketError) as e: self._raise_timeout(err=e, url=url, timeout_value=read_timeout) raise # AppEngine doesn't have a version attr. http_version = getattr(conn, "_http_vsn_str", "HTTP/?") log.debug( '%s://%s:%s "%s %s %s" %s %s', self.scheme, self.host, self.port, method, url, http_version, httplib_response.status, httplib_response.length, ) try: assert_header_parsing(httplib_response.msg) except (HeaderParsingError, TypeError) as hpe: # Platform-specific: Python 3 log.warning( "Failed to parse headers (url=%s): %s", self._absolute_url(url), hpe, exc_info=True, ) return httplib_response def _absolute_url(self, path): return Url(scheme=self.scheme, host=self.host, port=self.port, path=path).url def close(self): """ Close all pooled connections and disable the pool. """ if self.pool is None: return # Disable access to the pool old_pool, self.pool = self.pool, None try: while True: conn = old_pool.get(block=False) if conn: conn.close() except queue.Empty: pass # Done. def is_same_host(self, url): """ Check if the given ``url`` is a member of the same host as this connection pool. """ if url.startswith("/"): return True # TODO: Add optional support for socket.gethostbyname checking. scheme, host, port = get_host(url) if host is not None: host = _normalize_host(host, scheme=scheme) # Use explicit default port for comparison when none is given if self.port and not port: port = port_by_scheme.get(scheme) elif not self.port and port == port_by_scheme.get(scheme): port = None return (scheme, host, port) == (self.scheme, self.host, self.port) def urlopen( self, method, url, body=None, headers=None, retries=None, redirect=True, assert_same_host=True, timeout=_Default, pool_timeout=None, release_conn=None, chunked=False, body_pos=None, **response_kw ): """ Get a connection from the pool and perform an HTTP request. This is the lowest level call for making a request, so you'll need to specify all the raw details. .. note:: More commonly, it's appropriate to use a convenience method provided by :class:`.RequestMethods`, such as :meth:`request`. .. note:: `release_conn` will only behave as expected if `preload_content=False` because we want to make `preload_content=False` the default behaviour someday soon without breaking backwards compatibility. :param method: HTTP request method (such as GET, POST, PUT, etc.) :param url: The URL to perform the request on. :param body: Data to send in the request body, either :class:`str`, :class:`bytes`, an iterable of :class:`str`/:class:`bytes`, or a file-like object. :param headers: Dictionary of custom headers to send, such as User-Agent, If-None-Match, etc. If None, pool headers are used. If provided, these headers completely replace any pool-specific headers. :param retries: Configure the number of retries to allow before raising a :class:`~urllib3.exceptions.MaxRetryError` exception. Pass ``None`` to retry until you receive a response. Pass a :class:`~urllib3.util.retry.Retry` object for fine-grained control over different types of retries. Pass an integer number to retry connection errors that many times, but no other types of errors. Pass zero to never retry. If ``False``, then retries are disabled and any exception is raised immediately. Also, instead of raising a MaxRetryError on redirects, the redirect response will be returned. :type retries: :class:`~urllib3.util.retry.Retry`, False, or an int. :param redirect: If True, automatically handle redirects (status codes 301, 302, 303, 307, 308). Each redirect counts as a retry. Disabling retries will disable redirect, too. :param assert_same_host: If ``True``, will make sure that the host of the pool requests is consistent else will raise HostChangedError. When ``False``, you can use the pool on an HTTP proxy and request foreign hosts. :param timeout: If specified, overrides the default timeout for this one request. It may be a float (in seconds) or an instance of :class:`urllib3.util.Timeout`. :param pool_timeout: If set and the pool is set to block=True, then this method will block for ``pool_timeout`` seconds and raise EmptyPoolError if no connection is available within the time period. :param release_conn: If False, then the urlopen call will not release the connection back into the pool once a response is received (but will release if you read the entire contents of the response such as when `preload_content=True`). This is useful if you're not preloading the response's content immediately. You will need to call ``r.release_conn()`` on the response ``r`` to return the connection back into the pool. If None, it takes the value of ``response_kw.get('preload_content', True)``. :param chunked: If True, urllib3 will send the body using chunked transfer encoding. Otherwise, urllib3 will send the body using the standard content-length form. Defaults to False. :param int body_pos: Position to seek to in file-like body in the event of a retry or redirect. Typically this won't need to be set because urllib3 will auto-populate the value when needed. :param \\**response_kw: Additional parameters are passed to :meth:`urllib3.response.HTTPResponse.from_httplib` """ parsed_url = parse_url(url) destination_scheme = parsed_url.scheme if headers is None: headers = self.headers if not isinstance(retries, Retry): retries = Retry.from_int(retries, redirect=redirect, default=self.retries) if release_conn is None: release_conn = response_kw.get("preload_content", True) # Check host if assert_same_host and not self.is_same_host(url): raise HostChangedError(self, url, retries) # Ensure that the URL we're connecting to is properly encoded if url.startswith("/"): url = six.ensure_str(_encode_target(url)) else: url = six.ensure_str(parsed_url.url) conn = None # Track whether `conn` needs to be released before # returning/raising/recursing. Update this variable if necessary, and # leave `release_conn` constant throughout the function. That way, if # the function recurses, the original value of `release_conn` will be # passed down into the recursive call, and its value will be respected. # # See issue #651 [1] for details. # # [1] release_this_conn = release_conn http_tunnel_required = connection_requires_http_tunnel( self.proxy, self.proxy_config, destination_scheme ) # Merge the proxy headers. Only done when not using HTTP CONNECT. We # have to copy the headers dict so we can safely change it without those # changes being reflected in anyone else's copy. if not http_tunnel_required: headers = headers.copy() headers.update(self.proxy_headers) # Must keep the exception bound to a separate variable or else Python 3 # complains about UnboundLocalError. err = None # Keep track of whether we cleanly exited the except block. This # ensures we do proper cleanup in finally. clean_exit = False # Rewind body position, if needed. Record current position # for future rewinds in the event of a redirect/retry. body_pos = set_file_position(body, body_pos) try: # Request a connection from the queue. timeout_obj = self._get_timeout(timeout) conn = self._get_conn(timeout=pool_timeout) conn.timeout = timeout_obj.connect_timeout is_new_proxy_conn = self.proxy is not None and not getattr( conn, "sock", None ) if is_new_proxy_conn and http_tunnel_required: self._prepare_proxy(conn) # Make the request on the httplib connection object. httplib_response = self._make_request( conn, method, url, timeout=timeout_obj, body=body, headers=headers, chunked=chunked, ) # If we're going to release the connection in ``finally:``, then # the response doesn't need to know about the connection. Otherwise # it will also try to release it and we'll have a double-release # mess. response_conn = conn if not release_conn else None # Pass method to Response for length checking response_kw["request_method"] = method # Import httplib's response into our own wrapper object response = self.ResponseCls.from_httplib( httplib_response, pool=self, connection=response_conn, retries=retries, **response_kw ) # Everything went great! clean_exit = True except EmptyPoolError: # Didn't get a connection from the pool, no need to clean up clean_exit = True release_this_conn = False raise except ( TimeoutError, HTTPException, SocketError, ProtocolError, BaseSSLError, SSLError, CertificateError, ) as e: # Discard the connection for these exceptions. It will be # replaced during the next _get_conn() call. clean_exit = False if isinstance(e, (BaseSSLError, CertificateError)): e = SSLError(e) elif isinstance(e, (SocketError, NewConnectionError)) and self.proxy: e = ProxyError("Cannot connect to proxy.", e) elif isinstance(e, (SocketError, HTTPException)): e = ProtocolError("Connection aborted.", e) retries = retries.increment( method, url, error=e, _pool=self, _stacktrace=sys.exc_info()[2] ) retries.sleep() # Keep track of the error for the retry warning. err = e finally: if not clean_exit: # We hit some kind of exception, handled or otherwise. We need # to throw the connection away unless explicitly told not to. # Close the connection, set the variable to None, and make sure # we put the None back in the pool to avoid leaking it. conn = conn and conn.close() release_this_conn = True if release_this_conn: # Put the connection back to be reused. If the connection is # expired then it will be None, which will get replaced with a # fresh connection during _get_conn. self._put_conn(conn) if not conn: # Try again log.warning( "Retrying (%r) after connection broken by '%r': %s", retries, err, url ) return self.urlopen( method, url, body, headers, retries, redirect, assert_same_host, timeout=timeout, pool_timeout=pool_timeout, release_conn=release_conn, chunked=chunked, body_pos=body_pos, **response_kw ) # Handle redirect? redirect_location = redirect and response.get_redirect_location() if redirect_location: if response.status == 303: method = "GET" try: retries = retries.increment(method, url, response=response, _pool=self) except MaxRetryError: if retries.raise_on_redirect: response.drain_conn() raise return response response.drain_conn() retries.sleep_for_retry(response) log.debug("Redirecting %s -> %s", url, redirect_location) return self.urlopen( method, redirect_location, body, headers, retries=retries, redirect=redirect, assert_same_host=assert_same_host, timeout=timeout, pool_timeout=pool_timeout, release_conn=release_conn, chunked=chunked, body_pos=body_pos, **response_kw ) # Check if we should retry the HTTP response. has_retry_after = bool(response.getheader("Retry-After")) if retries.is_retry(method, response.status, has_retry_after): try: retries = retries.increment(method, url, response=response, _pool=self) except MaxRetryError: if retries.raise_on_status: response.drain_conn() raise return response response.drain_conn() retries.sleep(response) log.debug("Retry: %s", url) return self.urlopen( method, url, body, headers, retries=retries, redirect=redirect, assert_same_host=assert_same_host, timeout=timeout, pool_timeout=pool_timeout, release_conn=release_conn, chunked=chunked, body_pos=body_pos, **response_kw ) return response class HTTPSConnectionPool(HTTPConnectionPool): """ Same as :class:`.HTTPConnectionPool`, but HTTPS. :class:`.HTTPSConnection` uses one of ``assert_fingerprint``, ``assert_hostname`` and ``host`` in this order to verify connections. If ``assert_hostname`` is False, no verification is done. The ``key_file``, ``cert_file``, ``cert_reqs``, ``ca_certs``, ``ca_cert_dir``, ``ssl_version``, ``key_password`` are only used if :mod:`ssl` is available and are fed into :meth:`urllib3.util.ssl_wrap_socket` to upgrade the connection socket into an SSL socket. """ scheme = "https" ConnectionCls = HTTPSConnection def __init__( self, host, port=None, strict=False, timeout=Timeout.DEFAULT_TIMEOUT, maxsize=1, block=False, headers=None, retries=None, _proxy=None, _proxy_headers=None, key_file=None, cert_file=None, cert_reqs=None, key_password=None, ca_certs=None, ssl_version=None, assert_hostname=None, assert_fingerprint=None, ca_cert_dir=None, **conn_kw ): HTTPConnectionPool.__init__( self, host, port, strict, timeout, maxsize, block, headers, retries, _proxy, _proxy_headers, **conn_kw ) self.key_file = key_file self.cert_file = cert_file self.cert_reqs = cert_reqs self.key_password = key_password self.ca_certs = ca_certs self.ca_cert_dir = ca_cert_dir self.ssl_version = ssl_version self.assert_hostname = assert_hostname self.assert_fingerprint = assert_fingerprint def _prepare_conn(self, conn): """ Prepare the ``connection`` for :meth:`urllib3.util.ssl_wrap_socket` and establish the tunnel if proxy is used. """ if isinstance(conn, VerifiedHTTPSConnection): conn.set_cert( key_file=self.key_file, key_password=self.key_password, cert_file=self.cert_file, cert_reqs=self.cert_reqs, ca_certs=self.ca_certs, ca_cert_dir=self.ca_cert_dir, assert_hostname=self.assert_hostname, assert_fingerprint=self.assert_fingerprint, ) conn.ssl_version = self.ssl_version return conn def _prepare_proxy(self, conn): """ Establishes a tunnel connection through HTTP CONNECT. Tunnel connection is established early because otherwise httplib would improperly set Host: header to proxy's IP:port. """ conn.set_tunnel(self._proxy_host, self.port, self.proxy_headers) if self.proxy.scheme == "https": conn.tls_in_tls_required = True conn.connect() def _new_conn(self): """ Return a fresh :class:`http.client.HTTPSConnection`. """ self.num_connections += 1 log.debug( "Starting new HTTPS connection (%d): %s:%s", self.num_connections, self.host, self.port or "443", ) if not self.ConnectionCls or self.ConnectionCls is DummyConnection: raise SSLError( "Can't connect to HTTPS URL because the SSL module is not available." ) actual_host = self.host actual_port = self.port if self.proxy is not None: actual_host = self.proxy.host actual_port = self.proxy.port conn = self.ConnectionCls( host=actual_host, port=actual_port, timeout=self.timeout.connect_timeout, strict=self.strict, cert_file=self.cert_file, key_file=self.key_file, key_password=self.key_password, **self.conn_kw ) return self._prepare_conn(conn) def _validate_conn(self, conn): """ Called right before a request is made, after the socket is created. """ super(HTTPSConnectionPool, self)._validate_conn(conn) # Force connect early to allow us to validate the connection. if not getattr(conn, "sock", None): # AppEngine might not have `.sock` conn.connect() if not conn.is_verified: warnings.warn( ( "Unverified HTTPS request is being made to host '%s'. " "Adding certificate verification is strongly advised. See: " "https://urllib3.readthedocs.io/en/1.26.x/advanced-usage.html" "#ssl-warnings" % conn.host ), InsecureRequestWarning, ) def connection_from_url(url, **kw): """ Given a url, return an :class:`.ConnectionPool` instance of its host. This is a shortcut for not having to parse out the scheme, host, and port of the url before creating an :class:`.ConnectionPool` instance. :param url: Absolute URL string that must include the scheme. Port is optional. :param \\**kw: Passes additional parameters to the constructor of the appropriate :class:`.ConnectionPool`. Useful for specifying things like timeout, maxsize, headers, etc. Example:: >>> conn = connection_from_url('http://google.com/') >>> r = conn.request('GET', '/') """ scheme, host, port = get_host(url) port = port or port_by_scheme.get(scheme, 80) if scheme == "https": return HTTPSConnectionPool(host, port=port, **kw) else: return HTTPConnectionPool(host, port=port, **kw) def _normalize_host(host, scheme): """ Normalize hosts for comparisons and use with sockets. """ host = normalize_host(host, scheme) # httplib doesn't like it when we include brackets in IPv6 addresses # Specifically, if we include brackets but also pass the port then # httplib crazily doubles up the square brackets on the Host header. # Instead, we need to make sure we never pass ``None`` as the port. # However, for backward compatibility reasons we can't actually # *assert* that. See http://bugs.python.org/issue28539 if host.startswith("[") and host.endswith("]"): host = host[1:-1] return host ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/contrib/__init__.py ================================================ ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/contrib/_appengine_environ.py ================================================ """ This module provides means to detect the App Engine environment. """ import os def is_appengine(): return is_local_appengine() or is_prod_appengine() def is_appengine_sandbox(): """Reports if the app is running in the first generation sandbox. The second generation runtimes are technically still in a sandbox, but it is much less restrictive, so generally you shouldn't need to check for it. see https://cloud.google.com/appengine/docs/standard/runtimes """ return is_appengine() and os.environ["APPENGINE_RUNTIME"] == "python27" def is_local_appengine(): return "APPENGINE_RUNTIME" in os.environ and os.environ.get( "SERVER_SOFTWARE", "" ).startswith("Development/") def is_prod_appengine(): return "APPENGINE_RUNTIME" in os.environ and os.environ.get( "SERVER_SOFTWARE", "" ).startswith("Google App Engine/") def is_prod_appengine_mvms(): """Deprecated.""" return False ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/__init__.py ================================================ ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/bindings.py ================================================ """ This module uses ctypes to bind a whole bunch of functions and constants from SecureTransport. The goal here is to provide the low-level API to SecureTransport. These are essentially the C-level functions and constants, and they're pretty gross to work with. This code is a bastardised version of the code found in Will Bond's oscrypto library. An enormous debt is owed to him for blazing this trail for us. For that reason, this code should be considered to be covered both by urllib3's license and by oscrypto's: Copyright (c) 2015-2016 Will Bond Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ from __future__ import absolute_import import platform from ctypes import ( CDLL, CFUNCTYPE, POINTER, c_bool, c_byte, c_char_p, c_int32, c_long, c_size_t, c_uint32, c_ulong, c_void_p, ) from ctypes.util import find_library from urllib3.packages.six import raise_from if platform.system() != "Darwin": raise ImportError("Only macOS is supported") version = platform.mac_ver()[0] version_info = tuple(map(int, version.split("."))) if version_info < (10, 8): raise OSError( "Only OS X 10.8 and newer are supported, not %s.%s" % (version_info[0], version_info[1]) ) def load_cdll(name, macos10_16_path): """Loads a CDLL by name, falling back to known path on 10.16+""" try: # Big Sur is technically 11 but we use 10.16 due to the Big Sur # beta being labeled as 10.16. if version_info >= (10, 16): path = macos10_16_path else: path = find_library(name) if not path: raise OSError # Caught and reraised as 'ImportError' return CDLL(path, use_errno=True) except OSError: raise_from(ImportError("The library %s failed to load" % name), None) Security = load_cdll( "Security", "/System/Library/Frameworks/Security.framework/Security" ) CoreFoundation = load_cdll( "CoreFoundation", "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", ) Boolean = c_bool CFIndex = c_long CFStringEncoding = c_uint32 CFData = c_void_p CFString = c_void_p CFArray = c_void_p CFMutableArray = c_void_p CFDictionary = c_void_p CFError = c_void_p CFType = c_void_p CFTypeID = c_ulong CFTypeRef = POINTER(CFType) CFAllocatorRef = c_void_p OSStatus = c_int32 CFDataRef = POINTER(CFData) CFStringRef = POINTER(CFString) CFArrayRef = POINTER(CFArray) CFMutableArrayRef = POINTER(CFMutableArray) CFDictionaryRef = POINTER(CFDictionary) CFArrayCallBacks = c_void_p CFDictionaryKeyCallBacks = c_void_p CFDictionaryValueCallBacks = c_void_p SecCertificateRef = POINTER(c_void_p) SecExternalFormat = c_uint32 SecExternalItemType = c_uint32 SecIdentityRef = POINTER(c_void_p) SecItemImportExportFlags = c_uint32 SecItemImportExportKeyParameters = c_void_p SecKeychainRef = POINTER(c_void_p) SSLProtocol = c_uint32 SSLCipherSuite = c_uint32 SSLContextRef = POINTER(c_void_p) SecTrustRef = POINTER(c_void_p) SSLConnectionRef = c_uint32 SecTrustResultType = c_uint32 SecTrustOptionFlags = c_uint32 SSLProtocolSide = c_uint32 SSLConnectionType = c_uint32 SSLSessionOption = c_uint32 try: Security.SecItemImport.argtypes = [ CFDataRef, CFStringRef, POINTER(SecExternalFormat), POINTER(SecExternalItemType), SecItemImportExportFlags, POINTER(SecItemImportExportKeyParameters), SecKeychainRef, POINTER(CFArrayRef), ] Security.SecItemImport.restype = OSStatus Security.SecCertificateGetTypeID.argtypes = [] Security.SecCertificateGetTypeID.restype = CFTypeID Security.SecIdentityGetTypeID.argtypes = [] Security.SecIdentityGetTypeID.restype = CFTypeID Security.SecKeyGetTypeID.argtypes = [] Security.SecKeyGetTypeID.restype = CFTypeID Security.SecCertificateCreateWithData.argtypes = [CFAllocatorRef, CFDataRef] Security.SecCertificateCreateWithData.restype = SecCertificateRef Security.SecCertificateCopyData.argtypes = [SecCertificateRef] Security.SecCertificateCopyData.restype = CFDataRef Security.SecCopyErrorMessageString.argtypes = [OSStatus, c_void_p] Security.SecCopyErrorMessageString.restype = CFStringRef Security.SecIdentityCreateWithCertificate.argtypes = [ CFTypeRef, SecCertificateRef, POINTER(SecIdentityRef), ] Security.SecIdentityCreateWithCertificate.restype = OSStatus Security.SecKeychainCreate.argtypes = [ c_char_p, c_uint32, c_void_p, Boolean, c_void_p, POINTER(SecKeychainRef), ] Security.SecKeychainCreate.restype = OSStatus Security.SecKeychainDelete.argtypes = [SecKeychainRef] Security.SecKeychainDelete.restype = OSStatus Security.SecPKCS12Import.argtypes = [ CFDataRef, CFDictionaryRef, POINTER(CFArrayRef), ] Security.SecPKCS12Import.restype = OSStatus SSLReadFunc = CFUNCTYPE(OSStatus, SSLConnectionRef, c_void_p, POINTER(c_size_t)) SSLWriteFunc = CFUNCTYPE( OSStatus, SSLConnectionRef, POINTER(c_byte), POINTER(c_size_t) ) Security.SSLSetIOFuncs.argtypes = [SSLContextRef, SSLReadFunc, SSLWriteFunc] Security.SSLSetIOFuncs.restype = OSStatus Security.SSLSetPeerID.argtypes = [SSLContextRef, c_char_p, c_size_t] Security.SSLSetPeerID.restype = OSStatus Security.SSLSetCertificate.argtypes = [SSLContextRef, CFArrayRef] Security.SSLSetCertificate.restype = OSStatus Security.SSLSetCertificateAuthorities.argtypes = [SSLContextRef, CFTypeRef, Boolean] Security.SSLSetCertificateAuthorities.restype = OSStatus Security.SSLSetConnection.argtypes = [SSLContextRef, SSLConnectionRef] Security.SSLSetConnection.restype = OSStatus Security.SSLSetPeerDomainName.argtypes = [SSLContextRef, c_char_p, c_size_t] Security.SSLSetPeerDomainName.restype = OSStatus Security.SSLHandshake.argtypes = [SSLContextRef] Security.SSLHandshake.restype = OSStatus Security.SSLRead.argtypes = [SSLContextRef, c_char_p, c_size_t, POINTER(c_size_t)] Security.SSLRead.restype = OSStatus Security.SSLWrite.argtypes = [SSLContextRef, c_char_p, c_size_t, POINTER(c_size_t)] Security.SSLWrite.restype = OSStatus Security.SSLClose.argtypes = [SSLContextRef] Security.SSLClose.restype = OSStatus Security.SSLGetNumberSupportedCiphers.argtypes = [SSLContextRef, POINTER(c_size_t)] Security.SSLGetNumberSupportedCiphers.restype = OSStatus Security.SSLGetSupportedCiphers.argtypes = [ SSLContextRef, POINTER(SSLCipherSuite), POINTER(c_size_t), ] Security.SSLGetSupportedCiphers.restype = OSStatus Security.SSLSetEnabledCiphers.argtypes = [ SSLContextRef, POINTER(SSLCipherSuite), c_size_t, ] Security.SSLSetEnabledCiphers.restype = OSStatus Security.SSLGetNumberEnabledCiphers.argtype = [SSLContextRef, POINTER(c_size_t)] Security.SSLGetNumberEnabledCiphers.restype = OSStatus Security.SSLGetEnabledCiphers.argtypes = [ SSLContextRef, POINTER(SSLCipherSuite), POINTER(c_size_t), ] Security.SSLGetEnabledCiphers.restype = OSStatus Security.SSLGetNegotiatedCipher.argtypes = [SSLContextRef, POINTER(SSLCipherSuite)] Security.SSLGetNegotiatedCipher.restype = OSStatus Security.SSLGetNegotiatedProtocolVersion.argtypes = [ SSLContextRef, POINTER(SSLProtocol), ] Security.SSLGetNegotiatedProtocolVersion.restype = OSStatus Security.SSLCopyPeerTrust.argtypes = [SSLContextRef, POINTER(SecTrustRef)] Security.SSLCopyPeerTrust.restype = OSStatus Security.SecTrustSetAnchorCertificates.argtypes = [SecTrustRef, CFArrayRef] Security.SecTrustSetAnchorCertificates.restype = OSStatus Security.SecTrustSetAnchorCertificatesOnly.argstypes = [SecTrustRef, Boolean] Security.SecTrustSetAnchorCertificatesOnly.restype = OSStatus Security.SecTrustEvaluate.argtypes = [SecTrustRef, POINTER(SecTrustResultType)] Security.SecTrustEvaluate.restype = OSStatus Security.SecTrustGetCertificateCount.argtypes = [SecTrustRef] Security.SecTrustGetCertificateCount.restype = CFIndex Security.SecTrustGetCertificateAtIndex.argtypes = [SecTrustRef, CFIndex] Security.SecTrustGetCertificateAtIndex.restype = SecCertificateRef Security.SSLCreateContext.argtypes = [ CFAllocatorRef, SSLProtocolSide, SSLConnectionType, ] Security.SSLCreateContext.restype = SSLContextRef Security.SSLSetSessionOption.argtypes = [SSLContextRef, SSLSessionOption, Boolean] Security.SSLSetSessionOption.restype = OSStatus Security.SSLSetProtocolVersionMin.argtypes = [SSLContextRef, SSLProtocol] Security.SSLSetProtocolVersionMin.restype = OSStatus Security.SSLSetProtocolVersionMax.argtypes = [SSLContextRef, SSLProtocol] Security.SSLSetProtocolVersionMax.restype = OSStatus try: Security.SSLSetALPNProtocols.argtypes = [SSLContextRef, CFArrayRef] Security.SSLSetALPNProtocols.restype = OSStatus except AttributeError: # Supported only in 10.12+ pass Security.SecCopyErrorMessageString.argtypes = [OSStatus, c_void_p] Security.SecCopyErrorMessageString.restype = CFStringRef Security.SSLReadFunc = SSLReadFunc Security.SSLWriteFunc = SSLWriteFunc Security.SSLContextRef = SSLContextRef Security.SSLProtocol = SSLProtocol Security.SSLCipherSuite = SSLCipherSuite Security.SecIdentityRef = SecIdentityRef Security.SecKeychainRef = SecKeychainRef Security.SecTrustRef = SecTrustRef Security.SecTrustResultType = SecTrustResultType Security.SecExternalFormat = SecExternalFormat Security.OSStatus = OSStatus Security.kSecImportExportPassphrase = CFStringRef.in_dll( Security, "kSecImportExportPassphrase" ) Security.kSecImportItemIdentity = CFStringRef.in_dll( Security, "kSecImportItemIdentity" ) # CoreFoundation time! CoreFoundation.CFRetain.argtypes = [CFTypeRef] CoreFoundation.CFRetain.restype = CFTypeRef CoreFoundation.CFRelease.argtypes = [CFTypeRef] CoreFoundation.CFRelease.restype = None CoreFoundation.CFGetTypeID.argtypes = [CFTypeRef] CoreFoundation.CFGetTypeID.restype = CFTypeID CoreFoundation.CFStringCreateWithCString.argtypes = [ CFAllocatorRef, c_char_p, CFStringEncoding, ] CoreFoundation.CFStringCreateWithCString.restype = CFStringRef CoreFoundation.CFStringGetCStringPtr.argtypes = [CFStringRef, CFStringEncoding] CoreFoundation.CFStringGetCStringPtr.restype = c_char_p CoreFoundation.CFStringGetCString.argtypes = [ CFStringRef, c_char_p, CFIndex, CFStringEncoding, ] CoreFoundation.CFStringGetCString.restype = c_bool CoreFoundation.CFDataCreate.argtypes = [CFAllocatorRef, c_char_p, CFIndex] CoreFoundation.CFDataCreate.restype = CFDataRef CoreFoundation.CFDataGetLength.argtypes = [CFDataRef] CoreFoundation.CFDataGetLength.restype = CFIndex CoreFoundation.CFDataGetBytePtr.argtypes = [CFDataRef] CoreFoundation.CFDataGetBytePtr.restype = c_void_p CoreFoundation.CFDictionaryCreate.argtypes = [ CFAllocatorRef, POINTER(CFTypeRef), POINTER(CFTypeRef), CFIndex, CFDictionaryKeyCallBacks, CFDictionaryValueCallBacks, ] CoreFoundation.CFDictionaryCreate.restype = CFDictionaryRef CoreFoundation.CFDictionaryGetValue.argtypes = [CFDictionaryRef, CFTypeRef] CoreFoundation.CFDictionaryGetValue.restype = CFTypeRef CoreFoundation.CFArrayCreate.argtypes = [ CFAllocatorRef, POINTER(CFTypeRef), CFIndex, CFArrayCallBacks, ] CoreFoundation.CFArrayCreate.restype = CFArrayRef CoreFoundation.CFArrayCreateMutable.argtypes = [ CFAllocatorRef, CFIndex, CFArrayCallBacks, ] CoreFoundation.CFArrayCreateMutable.restype = CFMutableArrayRef CoreFoundation.CFArrayAppendValue.argtypes = [CFMutableArrayRef, c_void_p] CoreFoundation.CFArrayAppendValue.restype = None CoreFoundation.CFArrayGetCount.argtypes = [CFArrayRef] CoreFoundation.CFArrayGetCount.restype = CFIndex CoreFoundation.CFArrayGetValueAtIndex.argtypes = [CFArrayRef, CFIndex] CoreFoundation.CFArrayGetValueAtIndex.restype = c_void_p CoreFoundation.kCFAllocatorDefault = CFAllocatorRef.in_dll( CoreFoundation, "kCFAllocatorDefault" ) CoreFoundation.kCFTypeArrayCallBacks = c_void_p.in_dll( CoreFoundation, "kCFTypeArrayCallBacks" ) CoreFoundation.kCFTypeDictionaryKeyCallBacks = c_void_p.in_dll( CoreFoundation, "kCFTypeDictionaryKeyCallBacks" ) CoreFoundation.kCFTypeDictionaryValueCallBacks = c_void_p.in_dll( CoreFoundation, "kCFTypeDictionaryValueCallBacks" ) CoreFoundation.CFTypeRef = CFTypeRef CoreFoundation.CFArrayRef = CFArrayRef CoreFoundation.CFStringRef = CFStringRef CoreFoundation.CFDictionaryRef = CFDictionaryRef except (AttributeError): raise ImportError("Error initializing ctypes") class CFConst(object): """ A class object that acts as essentially a namespace for CoreFoundation constants. """ kCFStringEncodingUTF8 = CFStringEncoding(0x08000100) class SecurityConst(object): """ A class object that acts as essentially a namespace for Security constants. """ kSSLSessionOptionBreakOnServerAuth = 0 kSSLProtocol2 = 1 kSSLProtocol3 = 2 kTLSProtocol1 = 4 kTLSProtocol11 = 7 kTLSProtocol12 = 8 # SecureTransport does not support TLS 1.3 even if there's a constant for it kTLSProtocol13 = 10 kTLSProtocolMaxSupported = 999 kSSLClientSide = 1 kSSLStreamType = 0 kSecFormatPEMSequence = 10 kSecTrustResultInvalid = 0 kSecTrustResultProceed = 1 # This gap is present on purpose: this was kSecTrustResultConfirm, which # is deprecated. kSecTrustResultDeny = 3 kSecTrustResultUnspecified = 4 kSecTrustResultRecoverableTrustFailure = 5 kSecTrustResultFatalTrustFailure = 6 kSecTrustResultOtherError = 7 errSSLProtocol = -9800 errSSLWouldBlock = -9803 errSSLClosedGraceful = -9805 errSSLClosedNoNotify = -9816 errSSLClosedAbort = -9806 errSSLXCertChainInvalid = -9807 errSSLCrypto = -9809 errSSLInternal = -9810 errSSLCertExpired = -9814 errSSLCertNotYetValid = -9815 errSSLUnknownRootCert = -9812 errSSLNoRootCert = -9813 errSSLHostNameMismatch = -9843 errSSLPeerHandshakeFail = -9824 errSSLPeerUserCancelled = -9839 errSSLWeakPeerEphemeralDHKey = -9850 errSSLServerAuthCompleted = -9841 errSSLRecordOverflow = -9847 errSecVerifyFailed = -67808 errSecNoTrustSettings = -25263 errSecItemNotFound = -25300 errSecInvalidTrustSettings = -25262 # Cipher suites. We only pick the ones our default cipher string allows. # Source: https://developer.apple.com/documentation/security/1550981-ssl_cipher_suite_values TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 = 0xC02C TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 = 0xC030 TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 = 0xC02B TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 = 0xC02F TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 = 0xCCA9 TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 = 0xCCA8 TLS_DHE_RSA_WITH_AES_256_GCM_SHA384 = 0x009F TLS_DHE_RSA_WITH_AES_128_GCM_SHA256 = 0x009E TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384 = 0xC024 TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384 = 0xC028 TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA = 0xC00A TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA = 0xC014 TLS_DHE_RSA_WITH_AES_256_CBC_SHA256 = 0x006B TLS_DHE_RSA_WITH_AES_256_CBC_SHA = 0x0039 TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 = 0xC023 TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 = 0xC027 TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA = 0xC009 TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA = 0xC013 TLS_DHE_RSA_WITH_AES_128_CBC_SHA256 = 0x0067 TLS_DHE_RSA_WITH_AES_128_CBC_SHA = 0x0033 TLS_RSA_WITH_AES_256_GCM_SHA384 = 0x009D TLS_RSA_WITH_AES_128_GCM_SHA256 = 0x009C TLS_RSA_WITH_AES_256_CBC_SHA256 = 0x003D TLS_RSA_WITH_AES_128_CBC_SHA256 = 0x003C TLS_RSA_WITH_AES_256_CBC_SHA = 0x0035 TLS_RSA_WITH_AES_128_CBC_SHA = 0x002F TLS_AES_128_GCM_SHA256 = 0x1301 TLS_AES_256_GCM_SHA384 = 0x1302 TLS_AES_128_CCM_8_SHA256 = 0x1305 TLS_AES_128_CCM_SHA256 = 0x1304 ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/contrib/_securetransport/low_level.py ================================================ """ Low-level helpers for the SecureTransport bindings. These are Python functions that are not directly related to the high-level APIs but are necessary to get them to work. They include a whole bunch of low-level CoreFoundation messing about and memory management. The concerns in this module are almost entirely about trying to avoid memory leaks and providing appropriate and useful assistance to the higher-level code. """ import base64 import ctypes import itertools import os import re import ssl import struct import tempfile from .bindings import CFConst, CoreFoundation, Security # This regular expression is used to grab PEM data out of a PEM bundle. _PEM_CERTS_RE = re.compile( b"-----BEGIN CERTIFICATE-----\n(.*?)\n-----END CERTIFICATE-----", re.DOTALL ) def _cf_data_from_bytes(bytestring): """ Given a bytestring, create a CFData object from it. This CFData object must be CFReleased by the caller. """ return CoreFoundation.CFDataCreate( CoreFoundation.kCFAllocatorDefault, bytestring, len(bytestring) ) def _cf_dictionary_from_tuples(tuples): """ Given a list of Python tuples, create an associated CFDictionary. """ dictionary_size = len(tuples) # We need to get the dictionary keys and values out in the same order. keys = (t[0] for t in tuples) values = (t[1] for t in tuples) cf_keys = (CoreFoundation.CFTypeRef * dictionary_size)(*keys) cf_values = (CoreFoundation.CFTypeRef * dictionary_size)(*values) return CoreFoundation.CFDictionaryCreate( CoreFoundation.kCFAllocatorDefault, cf_keys, cf_values, dictionary_size, CoreFoundation.kCFTypeDictionaryKeyCallBacks, CoreFoundation.kCFTypeDictionaryValueCallBacks, ) def _cfstr(py_bstr): """ Given a Python binary data, create a CFString. The string must be CFReleased by the caller. """ c_str = ctypes.c_char_p(py_bstr) cf_str = CoreFoundation.CFStringCreateWithCString( CoreFoundation.kCFAllocatorDefault, c_str, CFConst.kCFStringEncodingUTF8, ) return cf_str def _create_cfstring_array(lst): """ Given a list of Python binary data, create an associated CFMutableArray. The array must be CFReleased by the caller. Raises an ssl.SSLError on failure. """ cf_arr = None try: cf_arr = CoreFoundation.CFArrayCreateMutable( CoreFoundation.kCFAllocatorDefault, 0, ctypes.byref(CoreFoundation.kCFTypeArrayCallBacks), ) if not cf_arr: raise MemoryError("Unable to allocate memory!") for item in lst: cf_str = _cfstr(item) if not cf_str: raise MemoryError("Unable to allocate memory!") try: CoreFoundation.CFArrayAppendValue(cf_arr, cf_str) finally: CoreFoundation.CFRelease(cf_str) except BaseException as e: if cf_arr: CoreFoundation.CFRelease(cf_arr) raise ssl.SSLError("Unable to allocate array: %s" % (e,)) return cf_arr def _cf_string_to_unicode(value): """ Creates a Unicode string from a CFString object. Used entirely for error reporting. Yes, it annoys me quite a lot that this function is this complex. """ value_as_void_p = ctypes.cast(value, ctypes.POINTER(ctypes.c_void_p)) string = CoreFoundation.CFStringGetCStringPtr( value_as_void_p, CFConst.kCFStringEncodingUTF8 ) if string is None: buffer = ctypes.create_string_buffer(1024) result = CoreFoundation.CFStringGetCString( value_as_void_p, buffer, 1024, CFConst.kCFStringEncodingUTF8 ) if not result: raise OSError("Error copying C string from CFStringRef") string = buffer.value if string is not None: string = string.decode("utf-8") return string def _assert_no_error(error, exception_class=None): """ Checks the return code and throws an exception if there is an error to report """ if error == 0: return cf_error_string = Security.SecCopyErrorMessageString(error, None) output = _cf_string_to_unicode(cf_error_string) CoreFoundation.CFRelease(cf_error_string) if output is None or output == u"": output = u"OSStatus %s" % error if exception_class is None: exception_class = ssl.SSLError raise exception_class(output) def _cert_array_from_pem(pem_bundle): """ Given a bundle of certs in PEM format, turns them into a CFArray of certs that can be used to validate a cert chain. """ # Normalize the PEM bundle's line endings. pem_bundle = pem_bundle.replace(b"\r\n", b"\n") der_certs = [ base64.b64decode(match.group(1)) for match in _PEM_CERTS_RE.finditer(pem_bundle) ] if not der_certs: raise ssl.SSLError("No root certificates specified") cert_array = CoreFoundation.CFArrayCreateMutable( CoreFoundation.kCFAllocatorDefault, 0, ctypes.byref(CoreFoundation.kCFTypeArrayCallBacks), ) if not cert_array: raise ssl.SSLError("Unable to allocate memory!") try: for der_bytes in der_certs: certdata = _cf_data_from_bytes(der_bytes) if not certdata: raise ssl.SSLError("Unable to allocate memory!") cert = Security.SecCertificateCreateWithData( CoreFoundation.kCFAllocatorDefault, certdata ) CoreFoundation.CFRelease(certdata) if not cert: raise ssl.SSLError("Unable to build cert object!") CoreFoundation.CFArrayAppendValue(cert_array, cert) CoreFoundation.CFRelease(cert) except Exception: # We need to free the array before the exception bubbles further. # We only want to do that if an error occurs: otherwise, the caller # should free. CoreFoundation.CFRelease(cert_array) return cert_array def _is_cert(item): """ Returns True if a given CFTypeRef is a certificate. """ expected = Security.SecCertificateGetTypeID() return CoreFoundation.CFGetTypeID(item) == expected def _is_identity(item): """ Returns True if a given CFTypeRef is an identity. """ expected = Security.SecIdentityGetTypeID() return CoreFoundation.CFGetTypeID(item) == expected def _temporary_keychain(): """ This function creates a temporary Mac keychain that we can use to work with credentials. This keychain uses a one-time password and a temporary file to store the data. We expect to have one keychain per socket. The returned SecKeychainRef must be freed by the caller, including calling SecKeychainDelete. Returns a tuple of the SecKeychainRef and the path to the temporary directory that contains it. """ # Unfortunately, SecKeychainCreate requires a path to a keychain. This # means we cannot use mkstemp to use a generic temporary file. Instead, # we're going to create a temporary directory and a filename to use there. # This filename will be 8 random bytes expanded into base64. We also need # some random bytes to password-protect the keychain we're creating, so we # ask for 40 random bytes. random_bytes = os.urandom(40) filename = base64.b16encode(random_bytes[:8]).decode("utf-8") password = base64.b16encode(random_bytes[8:]) # Must be valid UTF-8 tempdirectory = tempfile.mkdtemp() keychain_path = os.path.join(tempdirectory, filename).encode("utf-8") # We now want to create the keychain itself. keychain = Security.SecKeychainRef() status = Security.SecKeychainCreate( keychain_path, len(password), password, False, None, ctypes.byref(keychain) ) _assert_no_error(status) # Having created the keychain, we want to pass it off to the caller. return keychain, tempdirectory def _load_items_from_file(keychain, path): """ Given a single file, loads all the trust objects from it into arrays and the keychain. Returns a tuple of lists: the first list is a list of identities, the second a list of certs. """ certificates = [] identities = [] result_array = None with open(path, "rb") as f: raw_filedata = f.read() try: filedata = CoreFoundation.CFDataCreate( CoreFoundation.kCFAllocatorDefault, raw_filedata, len(raw_filedata) ) result_array = CoreFoundation.CFArrayRef() result = Security.SecItemImport( filedata, # cert data None, # Filename, leaving it out for now None, # What the type of the file is, we don't care None, # what's in the file, we don't care 0, # import flags None, # key params, can include passphrase in the future keychain, # The keychain to insert into ctypes.byref(result_array), # Results ) _assert_no_error(result) # A CFArray is not very useful to us as an intermediary # representation, so we are going to extract the objects we want # and then free the array. We don't need to keep hold of keys: the # keychain already has them! result_count = CoreFoundation.CFArrayGetCount(result_array) for index in range(result_count): item = CoreFoundation.CFArrayGetValueAtIndex(result_array, index) item = ctypes.cast(item, CoreFoundation.CFTypeRef) if _is_cert(item): CoreFoundation.CFRetain(item) certificates.append(item) elif _is_identity(item): CoreFoundation.CFRetain(item) identities.append(item) finally: if result_array: CoreFoundation.CFRelease(result_array) CoreFoundation.CFRelease(filedata) return (identities, certificates) def _load_client_cert_chain(keychain, *paths): """ Load certificates and maybe keys from a number of files. Has the end goal of returning a CFArray containing one SecIdentityRef, and then zero or more SecCertificateRef objects, suitable for use as a client certificate trust chain. """ # Ok, the strategy. # # This relies on knowing that macOS will not give you a SecIdentityRef # unless you have imported a key into a keychain. This is a somewhat # artificial limitation of macOS (for example, it doesn't necessarily # affect iOS), but there is nothing inside Security.framework that lets you # get a SecIdentityRef without having a key in a keychain. # # So the policy here is we take all the files and iterate them in order. # Each one will use SecItemImport to have one or more objects loaded from # it. We will also point at a keychain that macOS can use to work with the # private key. # # Once we have all the objects, we'll check what we actually have. If we # already have a SecIdentityRef in hand, fab: we'll use that. Otherwise, # we'll take the first certificate (which we assume to be our leaf) and # ask the keychain to give us a SecIdentityRef with that cert's associated # key. # # We'll then return a CFArray containing the trust chain: one # SecIdentityRef and then zero-or-more SecCertificateRef objects. The # responsibility for freeing this CFArray will be with the caller. This # CFArray must remain alive for the entire connection, so in practice it # will be stored with a single SSLSocket, along with the reference to the # keychain. certificates = [] identities = [] # Filter out bad paths. paths = (path for path in paths if path) try: for file_path in paths: new_identities, new_certs = _load_items_from_file(keychain, file_path) identities.extend(new_identities) certificates.extend(new_certs) # Ok, we have everything. The question is: do we have an identity? If # not, we want to grab one from the first cert we have. if not identities: new_identity = Security.SecIdentityRef() status = Security.SecIdentityCreateWithCertificate( keychain, certificates[0], ctypes.byref(new_identity) ) _assert_no_error(status) identities.append(new_identity) # We now want to release the original certificate, as we no longer # need it. CoreFoundation.CFRelease(certificates.pop(0)) # We now need to build a new CFArray that holds the trust chain. trust_chain = CoreFoundation.CFArrayCreateMutable( CoreFoundation.kCFAllocatorDefault, 0, ctypes.byref(CoreFoundation.kCFTypeArrayCallBacks), ) for item in itertools.chain(identities, certificates): # ArrayAppendValue does a CFRetain on the item. That's fine, # because the finally block will release our other refs to them. CoreFoundation.CFArrayAppendValue(trust_chain, item) return trust_chain finally: for obj in itertools.chain(identities, certificates): CoreFoundation.CFRelease(obj) TLS_PROTOCOL_VERSIONS = { "SSLv2": (0, 2), "SSLv3": (3, 0), "TLSv1": (3, 1), "TLSv1.1": (3, 2), "TLSv1.2": (3, 3), } def _build_tls_unknown_ca_alert(version): """ Builds a TLS alert record for an unknown CA. """ ver_maj, ver_min = TLS_PROTOCOL_VERSIONS[version] severity_fatal = 0x02 description_unknown_ca = 0x30 msg = struct.pack(">BB", severity_fatal, description_unknown_ca) msg_len = len(msg) record_type_alert = 0x15 record = struct.pack(">BBBH", record_type_alert, ver_maj, ver_min, msg_len) + msg return record ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/contrib/appengine.py ================================================ """ This module provides a pool manager that uses Google App Engine's `URLFetch Service `_. Example usage:: from urllib3 import PoolManager from urllib3.contrib.appengine import AppEngineManager, is_appengine_sandbox if is_appengine_sandbox(): # AppEngineManager uses AppEngine's URLFetch API behind the scenes http = AppEngineManager() else: # PoolManager uses a socket-level API behind the scenes http = PoolManager() r = http.request('GET', 'https://google.com/') There are `limitations `_ to the URLFetch service and it may not be the best choice for your application. There are three options for using urllib3 on Google App Engine: 1. You can use :class:`AppEngineManager` with URLFetch. URLFetch is cost-effective in many circumstances as long as your usage is within the limitations. 2. You can use a normal :class:`~urllib3.PoolManager` by enabling sockets. Sockets also have `limitations and restrictions `_ and have a lower free quota than URLFetch. To use sockets, be sure to specify the following in your ``app.yaml``:: env_variables: GAE_USE_SOCKETS_HTTPLIB : 'true' 3. If you are using `App Engine Flexible `_, you can use the standard :class:`PoolManager` without any configuration or special environment variables. """ from __future__ import absolute_import import io import logging import warnings from ..exceptions import ( HTTPError, HTTPWarning, MaxRetryError, ProtocolError, SSLError, TimeoutError, ) from ..packages.six.moves.urllib.parse import urljoin from ..request import RequestMethods from ..response import HTTPResponse from ..util.retry import Retry from ..util.timeout import Timeout from . import _appengine_environ try: from google.appengine.api import urlfetch except ImportError: urlfetch = None log = logging.getLogger(__name__) class AppEnginePlatformWarning(HTTPWarning): pass class AppEnginePlatformError(HTTPError): pass class AppEngineManager(RequestMethods): """ Connection manager for Google App Engine sandbox applications. This manager uses the URLFetch service directly instead of using the emulated httplib, and is subject to URLFetch limitations as described in the App Engine documentation `here `_. Notably it will raise an :class:`AppEnginePlatformError` if: * URLFetch is not available. * If you attempt to use this on App Engine Flexible, as full socket support is available. * If a request size is more than 10 megabytes. * If a response size is more than 32 megabytes. * If you use an unsupported request method such as OPTIONS. Beyond those cases, it will raise normal urllib3 errors. """ def __init__( self, headers=None, retries=None, validate_certificate=True, urlfetch_retries=True, ): if not urlfetch: raise AppEnginePlatformError( "URLFetch is not available in this environment." ) warnings.warn( "urllib3 is using URLFetch on Google App Engine sandbox instead " "of sockets. To use sockets directly instead of URLFetch see " "https://urllib3.readthedocs.io/en/1.26.x/reference/urllib3.contrib.html.", AppEnginePlatformWarning, ) RequestMethods.__init__(self, headers) self.validate_certificate = validate_certificate self.urlfetch_retries = urlfetch_retries self.retries = retries or Retry.DEFAULT def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): # Return False to re-raise any potential exceptions return False def urlopen( self, method, url, body=None, headers=None, retries=None, redirect=True, timeout=Timeout.DEFAULT_TIMEOUT, **response_kw ): retries = self._get_retries(retries, redirect) try: follow_redirects = redirect and retries.redirect != 0 and retries.total response = urlfetch.fetch( url, payload=body, method=method, headers=headers or {}, allow_truncated=False, follow_redirects=self.urlfetch_retries and follow_redirects, deadline=self._get_absolute_timeout(timeout), validate_certificate=self.validate_certificate, ) except urlfetch.DeadlineExceededError as e: raise TimeoutError(self, e) except urlfetch.InvalidURLError as e: if "too large" in str(e): raise AppEnginePlatformError( "URLFetch request too large, URLFetch only " "supports requests up to 10mb in size.", e, ) raise ProtocolError(e) except urlfetch.DownloadError as e: if "Too many redirects" in str(e): raise MaxRetryError(self, url, reason=e) raise ProtocolError(e) except urlfetch.ResponseTooLargeError as e: raise AppEnginePlatformError( "URLFetch response too large, URLFetch only supports" "responses up to 32mb in size.", e, ) except urlfetch.SSLCertificateError as e: raise SSLError(e) except urlfetch.InvalidMethodError as e: raise AppEnginePlatformError( "URLFetch does not support method: %s" % method, e ) http_response = self._urlfetch_response_to_http_response( response, retries=retries, **response_kw ) # Handle redirect? redirect_location = redirect and http_response.get_redirect_location() if redirect_location: # Check for redirect response if self.urlfetch_retries and retries.raise_on_redirect: raise MaxRetryError(self, url, "too many redirects") else: if http_response.status == 303: method = "GET" try: retries = retries.increment( method, url, response=http_response, _pool=self ) except MaxRetryError: if retries.raise_on_redirect: raise MaxRetryError(self, url, "too many redirects") return http_response retries.sleep_for_retry(http_response) log.debug("Redirecting %s -> %s", url, redirect_location) redirect_url = urljoin(url, redirect_location) return self.urlopen( method, redirect_url, body, headers, retries=retries, redirect=redirect, timeout=timeout, **response_kw ) # Check if we should retry the HTTP response. has_retry_after = bool(http_response.getheader("Retry-After")) if retries.is_retry(method, http_response.status, has_retry_after): retries = retries.increment(method, url, response=http_response, _pool=self) log.debug("Retry: %s", url) retries.sleep(http_response) return self.urlopen( method, url, body=body, headers=headers, retries=retries, redirect=redirect, timeout=timeout, **response_kw ) return http_response def _urlfetch_response_to_http_response(self, urlfetch_resp, **response_kw): if is_prod_appengine(): # Production GAE handles deflate encoding automatically, but does # not remove the encoding header. content_encoding = urlfetch_resp.headers.get("content-encoding") if content_encoding == "deflate": del urlfetch_resp.headers["content-encoding"] transfer_encoding = urlfetch_resp.headers.get("transfer-encoding") # We have a full response's content, # so let's make sure we don't report ourselves as chunked data. if transfer_encoding == "chunked": encodings = transfer_encoding.split(",") encodings.remove("chunked") urlfetch_resp.headers["transfer-encoding"] = ",".join(encodings) original_response = HTTPResponse( # In order for decoding to work, we must present the content as # a file-like object. body=io.BytesIO(urlfetch_resp.content), msg=urlfetch_resp.header_msg, headers=urlfetch_resp.headers, status=urlfetch_resp.status_code, **response_kw ) return HTTPResponse( body=io.BytesIO(urlfetch_resp.content), headers=urlfetch_resp.headers, status=urlfetch_resp.status_code, original_response=original_response, **response_kw ) def _get_absolute_timeout(self, timeout): if timeout is Timeout.DEFAULT_TIMEOUT: return None # Defer to URLFetch's default. if isinstance(timeout, Timeout): if timeout._read is not None or timeout._connect is not None: warnings.warn( "URLFetch does not support granular timeout settings, " "reverting to total or default URLFetch timeout.", AppEnginePlatformWarning, ) return timeout.total return timeout def _get_retries(self, retries, redirect): if not isinstance(retries, Retry): retries = Retry.from_int(retries, redirect=redirect, default=self.retries) if retries.connect or retries.read or retries.redirect: warnings.warn( "URLFetch only supports total retries and does not " "recognize connect, read, or redirect retry parameters.", AppEnginePlatformWarning, ) return retries # Alias methods from _appengine_environ to maintain public API interface. is_appengine = _appengine_environ.is_appengine is_appengine_sandbox = _appengine_environ.is_appengine_sandbox is_local_appengine = _appengine_environ.is_local_appengine is_prod_appengine = _appengine_environ.is_prod_appengine is_prod_appengine_mvms = _appengine_environ.is_prod_appengine_mvms ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/contrib/ntlmpool.py ================================================ """ NTLM authenticating pool, contributed by erikcederstran Issue #10, see: http://code.google.com/p/urllib3/issues/detail?id=10 """ from __future__ import absolute_import import warnings from logging import getLogger from ntlm import ntlm from .. import HTTPSConnectionPool from ..packages.six.moves.http_client import HTTPSConnection warnings.warn( "The 'urllib3.contrib.ntlmpool' module is deprecated and will be removed " "in urllib3 v2.0 release, urllib3 is not able to support it properly due " "to reasons listed in issue: https://github.com/urllib3/urllib3/issues/2282. " "If you are a user of this module please comment in the mentioned issue.", DeprecationWarning, ) log = getLogger(__name__) class NTLMConnectionPool(HTTPSConnectionPool): """ Implements an NTLM authentication version of an urllib3 connection pool """ scheme = "https" def __init__(self, user, pw, authurl, *args, **kwargs): """ authurl is a random URL on the server that is protected by NTLM. user is the Windows user, probably in the DOMAIN\\username format. pw is the password for the user. """ super(NTLMConnectionPool, self).__init__(*args, **kwargs) self.authurl = authurl self.rawuser = user user_parts = user.split("\\", 1) self.domain = user_parts[0].upper() self.user = user_parts[1] self.pw = pw def _new_conn(self): # Performs the NTLM handshake that secures the connection. The socket # must be kept open while requests are performed. self.num_connections += 1 log.debug( "Starting NTLM HTTPS connection no. %d: https://%s%s", self.num_connections, self.host, self.authurl, ) headers = {"Connection": "Keep-Alive"} req_header = "Authorization" resp_header = "www-authenticate" conn = HTTPSConnection(host=self.host, port=self.port) # Send negotiation message headers[req_header] = "NTLM %s" % ntlm.create_NTLM_NEGOTIATE_MESSAGE( self.rawuser ) log.debug("Request headers: %s", headers) conn.request("GET", self.authurl, None, headers) res = conn.getresponse() reshdr = dict(res.getheaders()) log.debug("Response status: %s %s", res.status, res.reason) log.debug("Response headers: %s", reshdr) log.debug("Response data: %s [...]", res.read(100)) # Remove the reference to the socket, so that it can not be closed by # the response object (we want to keep the socket open) res.fp = None # Server should respond with a challenge message auth_header_values = reshdr[resp_header].split(", ") auth_header_value = None for s in auth_header_values: if s[:5] == "NTLM ": auth_header_value = s[5:] if auth_header_value is None: raise Exception( "Unexpected %s response header: %s" % (resp_header, reshdr[resp_header]) ) # Send authentication message ServerChallenge, NegotiateFlags = ntlm.parse_NTLM_CHALLENGE_MESSAGE( auth_header_value ) auth_msg = ntlm.create_NTLM_AUTHENTICATE_MESSAGE( ServerChallenge, self.user, self.domain, self.pw, NegotiateFlags ) headers[req_header] = "NTLM %s" % auth_msg log.debug("Request headers: %s", headers) conn.request("GET", self.authurl, None, headers) res = conn.getresponse() log.debug("Response status: %s %s", res.status, res.reason) log.debug("Response headers: %s", dict(res.getheaders())) log.debug("Response data: %s [...]", res.read()[:100]) if res.status != 200: if res.status == 401: raise Exception("Server rejected request: wrong username or password") raise Exception("Wrong server response: %s %s" % (res.status, res.reason)) res.fp = None log.debug("Connection established") return conn def urlopen( self, method, url, body=None, headers=None, retries=3, redirect=True, assert_same_host=True, ): if headers is None: headers = {} headers["Connection"] = "Keep-Alive" return super(NTLMConnectionPool, self).urlopen( method, url, body, headers, retries, redirect, assert_same_host ) ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/contrib/pyopenssl.py ================================================ """ TLS with SNI_-support for Python 2. Follow these instructions if you would like to verify TLS certificates in Python 2. Note, the default libraries do *not* do certificate checking; you need to do additional work to validate certificates yourself. This needs the following packages installed: * `pyOpenSSL`_ (tested with 16.0.0) * `cryptography`_ (minimum 1.3.4, from pyopenssl) * `idna`_ (minimum 2.0, from cryptography) However, pyopenssl depends on cryptography, which depends on idna, so while we use all three directly here we end up having relatively few packages required. You can install them with the following command: .. code-block:: bash $ python -m pip install pyopenssl cryptography idna To activate certificate checking, call :func:`~urllib3.contrib.pyopenssl.inject_into_urllib3` from your Python code before you begin making HTTP requests. This can be done in a ``sitecustomize`` module, or at any other time before your application begins using ``urllib3``, like this: .. code-block:: python try: import urllib3.contrib.pyopenssl urllib3.contrib.pyopenssl.inject_into_urllib3() except ImportError: pass Now you can use :mod:`urllib3` as you normally would, and it will support SNI when the required modules are installed. Activating this module also has the positive side effect of disabling SSL/TLS compression in Python 2 (see `CRIME attack`_). .. _sni: https://en.wikipedia.org/wiki/Server_Name_Indication .. _crime attack: https://en.wikipedia.org/wiki/CRIME_(security_exploit) .. _pyopenssl: https://www.pyopenssl.org .. _cryptography: https://cryptography.io .. _idna: https://github.com/kjd/idna """ from __future__ import absolute_import import OpenSSL.SSL from cryptography import x509 from cryptography.hazmat.backends.openssl import backend as openssl_backend from cryptography.hazmat.backends.openssl.x509 import _Certificate try: from cryptography.x509 import UnsupportedExtension except ImportError: # UnsupportedExtension is gone in cryptography >= 2.1.0 class UnsupportedExtension(Exception): pass from io import BytesIO from socket import error as SocketError from socket import timeout try: # Platform-specific: Python 2 from socket import _fileobject except ImportError: # Platform-specific: Python 3 _fileobject = None from ..packages.backports.makefile import backport_makefile import logging import ssl import sys from .. import util from ..packages import six from ..util.ssl_ import PROTOCOL_TLS_CLIENT __all__ = ["inject_into_urllib3", "extract_from_urllib3"] # SNI always works. HAS_SNI = True # Map from urllib3 to PyOpenSSL compatible parameter-values. _openssl_versions = { util.PROTOCOL_TLS: OpenSSL.SSL.SSLv23_METHOD, PROTOCOL_TLS_CLIENT: OpenSSL.SSL.SSLv23_METHOD, ssl.PROTOCOL_TLSv1: OpenSSL.SSL.TLSv1_METHOD, } if hasattr(ssl, "PROTOCOL_SSLv3") and hasattr(OpenSSL.SSL, "SSLv3_METHOD"): _openssl_versions[ssl.PROTOCOL_SSLv3] = OpenSSL.SSL.SSLv3_METHOD if hasattr(ssl, "PROTOCOL_TLSv1_1") and hasattr(OpenSSL.SSL, "TLSv1_1_METHOD"): _openssl_versions[ssl.PROTOCOL_TLSv1_1] = OpenSSL.SSL.TLSv1_1_METHOD if hasattr(ssl, "PROTOCOL_TLSv1_2") and hasattr(OpenSSL.SSL, "TLSv1_2_METHOD"): _openssl_versions[ssl.PROTOCOL_TLSv1_2] = OpenSSL.SSL.TLSv1_2_METHOD _stdlib_to_openssl_verify = { ssl.CERT_NONE: OpenSSL.SSL.VERIFY_NONE, ssl.CERT_OPTIONAL: OpenSSL.SSL.VERIFY_PEER, ssl.CERT_REQUIRED: OpenSSL.SSL.VERIFY_PEER + OpenSSL.SSL.VERIFY_FAIL_IF_NO_PEER_CERT, } _openssl_to_stdlib_verify = dict((v, k) for k, v in _stdlib_to_openssl_verify.items()) # OpenSSL will only write 16K at a time SSL_WRITE_BLOCKSIZE = 16384 orig_util_HAS_SNI = util.HAS_SNI orig_util_SSLContext = util.ssl_.SSLContext log = logging.getLogger(__name__) def inject_into_urllib3(): "Monkey-patch urllib3 with PyOpenSSL-backed SSL-support." _validate_dependencies_met() util.SSLContext = PyOpenSSLContext util.ssl_.SSLContext = PyOpenSSLContext util.HAS_SNI = HAS_SNI util.ssl_.HAS_SNI = HAS_SNI util.IS_PYOPENSSL = True util.ssl_.IS_PYOPENSSL = True def extract_from_urllib3(): "Undo monkey-patching by :func:`inject_into_urllib3`." util.SSLContext = orig_util_SSLContext util.ssl_.SSLContext = orig_util_SSLContext util.HAS_SNI = orig_util_HAS_SNI util.ssl_.HAS_SNI = orig_util_HAS_SNI util.IS_PYOPENSSL = False util.ssl_.IS_PYOPENSSL = False def _validate_dependencies_met(): """ Verifies that PyOpenSSL's package-level dependencies have been met. Throws `ImportError` if they are not met. """ # Method added in `cryptography==1.1`; not available in older versions from cryptography.x509.extensions import Extensions if getattr(Extensions, "get_extension_for_class", None) is None: raise ImportError( "'cryptography' module missing required functionality. " "Try upgrading to v1.3.4 or newer." ) # pyOpenSSL 0.14 and above use cryptography for OpenSSL bindings. The _x509 # attribute is only present on those versions. from OpenSSL.crypto import X509 x509 = X509() if getattr(x509, "_x509", None) is None: raise ImportError( "'pyOpenSSL' module missing required functionality. " "Try upgrading to v0.14 or newer." ) def _dnsname_to_stdlib(name): """ Converts a dNSName SubjectAlternativeName field to the form used by the standard library on the given Python version. Cryptography produces a dNSName as a unicode string that was idna-decoded from ASCII bytes. We need to idna-encode that string to get it back, and then on Python 3 we also need to convert to unicode via UTF-8 (the stdlib uses PyUnicode_FromStringAndSize on it, which decodes via UTF-8). If the name cannot be idna-encoded then we return None signalling that the name given should be skipped. """ def idna_encode(name): """ Borrowed wholesale from the Python Cryptography Project. It turns out that we can't just safely call `idna.encode`: it can explode for wildcard names. This avoids that problem. """ import idna try: for prefix in [u"*.", u"."]: if name.startswith(prefix): name = name[len(prefix) :] return prefix.encode("ascii") + idna.encode(name) return idna.encode(name) except idna.core.IDNAError: return None # Don't send IPv6 addresses through the IDNA encoder. if ":" in name: return name name = idna_encode(name) if name is None: return None elif sys.version_info >= (3, 0): name = name.decode("utf-8") return name def get_subj_alt_name(peer_cert): """ Given an PyOpenSSL certificate, provides all the subject alternative names. """ # Pass the cert to cryptography, which has much better APIs for this. if hasattr(peer_cert, "to_cryptography"): cert = peer_cert.to_cryptography() else: # This is technically using private APIs, but should work across all # relevant versions before PyOpenSSL got a proper API for this. cert = _Certificate(openssl_backend, peer_cert._x509) # We want to find the SAN extension. Ask Cryptography to locate it (it's # faster than looping in Python) try: ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value except x509.ExtensionNotFound: # No such extension, return the empty list. return [] except ( x509.DuplicateExtension, UnsupportedExtension, x509.UnsupportedGeneralNameType, UnicodeError, ) as e: # A problem has been found with the quality of the certificate. Assume # no SAN field is present. log.warning( "A problem was encountered with the certificate that prevented " "urllib3 from finding the SubjectAlternativeName field. This can " "affect certificate validation. The error was %s", e, ) return [] # We want to return dNSName and iPAddress fields. We need to cast the IPs # back to strings because the match_hostname function wants them as # strings. # Sadly the DNS names need to be idna encoded and then, on Python 3, UTF-8 # decoded. This is pretty frustrating, but that's what the standard library # does with certificates, and so we need to attempt to do the same. # We also want to skip over names which cannot be idna encoded. names = [ ("DNS", name) for name in map(_dnsname_to_stdlib, ext.get_values_for_type(x509.DNSName)) if name is not None ] names.extend( ("IP Address", str(name)) for name in ext.get_values_for_type(x509.IPAddress) ) return names class WrappedSocket(object): """API-compatibility wrapper for Python OpenSSL's Connection-class. Note: _makefile_refs, _drop() and _reuse() are needed for the garbage collector of pypy. """ def __init__(self, connection, socket, suppress_ragged_eofs=True): self.connection = connection self.socket = socket self.suppress_ragged_eofs = suppress_ragged_eofs self._makefile_refs = 0 self._closed = False def fileno(self): return self.socket.fileno() # Copy-pasted from Python 3.5 source code def _decref_socketios(self): if self._makefile_refs > 0: self._makefile_refs -= 1 if self._closed: self.close() def recv(self, *args, **kwargs): try: data = self.connection.recv(*args, **kwargs) except OpenSSL.SSL.SysCallError as e: if self.suppress_ragged_eofs and e.args == (-1, "Unexpected EOF"): return b"" else: raise SocketError(str(e)) except OpenSSL.SSL.ZeroReturnError: if self.connection.get_shutdown() == OpenSSL.SSL.RECEIVED_SHUTDOWN: return b"" else: raise except OpenSSL.SSL.WantReadError: if not util.wait_for_read(self.socket, self.socket.gettimeout()): raise timeout("The read operation timed out") else: return self.recv(*args, **kwargs) # TLS 1.3 post-handshake authentication except OpenSSL.SSL.Error as e: raise ssl.SSLError("read error: %r" % e) else: return data def recv_into(self, *args, **kwargs): try: return self.connection.recv_into(*args, **kwargs) except OpenSSL.SSL.SysCallError as e: if self.suppress_ragged_eofs and e.args == (-1, "Unexpected EOF"): return 0 else: raise SocketError(str(e)) except OpenSSL.SSL.ZeroReturnError: if self.connection.get_shutdown() == OpenSSL.SSL.RECEIVED_SHUTDOWN: return 0 else: raise except OpenSSL.SSL.WantReadError: if not util.wait_for_read(self.socket, self.socket.gettimeout()): raise timeout("The read operation timed out") else: return self.recv_into(*args, **kwargs) # TLS 1.3 post-handshake authentication except OpenSSL.SSL.Error as e: raise ssl.SSLError("read error: %r" % e) def settimeout(self, timeout): return self.socket.settimeout(timeout) def _send_until_done(self, data): while True: try: return self.connection.send(data) except OpenSSL.SSL.WantWriteError: if not util.wait_for_write(self.socket, self.socket.gettimeout()): raise timeout() continue except OpenSSL.SSL.SysCallError as e: raise SocketError(str(e)) def sendall(self, data): total_sent = 0 while total_sent < len(data): sent = self._send_until_done( data[total_sent : total_sent + SSL_WRITE_BLOCKSIZE] ) total_sent += sent def shutdown(self): # FIXME rethrow compatible exceptions should we ever use this self.connection.shutdown() def close(self): if self._makefile_refs < 1: try: self._closed = True return self.connection.close() except OpenSSL.SSL.Error: return else: self._makefile_refs -= 1 def getpeercert(self, binary_form=False): x509 = self.connection.get_peer_certificate() if not x509: return x509 if binary_form: return OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_ASN1, x509) return { "subject": ((("commonName", x509.get_subject().CN),),), "subjectAltName": get_subj_alt_name(x509), } def version(self): return self.connection.get_protocol_version_name() def _reuse(self): self._makefile_refs += 1 def _drop(self): if self._makefile_refs < 1: self.close() else: self._makefile_refs -= 1 if _fileobject: # Platform-specific: Python 2 def makefile(self, mode, bufsize=-1): self._makefile_refs += 1 return _fileobject(self, mode, bufsize, close=True) else: # Platform-specific: Python 3 makefile = backport_makefile WrappedSocket.makefile = makefile class PyOpenSSLContext(object): """ I am a wrapper class for the PyOpenSSL ``Context`` object. I am responsible for translating the interface of the standard library ``SSLContext`` object to calls into PyOpenSSL. """ def __init__(self, protocol): self.protocol = _openssl_versions[protocol] self._ctx = OpenSSL.SSL.Context(self.protocol) self._options = 0 self.check_hostname = False @property def options(self): return self._options @options.setter def options(self, value): self._options = value self._ctx.set_options(value) @property def verify_mode(self): return _openssl_to_stdlib_verify[self._ctx.get_verify_mode()] @verify_mode.setter def verify_mode(self, value): self._ctx.set_verify(_stdlib_to_openssl_verify[value], _verify_callback) def set_default_verify_paths(self): self._ctx.set_default_verify_paths() def set_ciphers(self, ciphers): if isinstance(ciphers, six.text_type): ciphers = ciphers.encode("utf-8") self._ctx.set_cipher_list(ciphers) def load_verify_locations(self, cafile=None, capath=None, cadata=None): if cafile is not None: cafile = cafile.encode("utf-8") if capath is not None: capath = capath.encode("utf-8") try: self._ctx.load_verify_locations(cafile, capath) if cadata is not None: self._ctx.load_verify_locations(BytesIO(cadata)) except OpenSSL.SSL.Error as e: raise ssl.SSLError("unable to load trusted certificates: %r" % e) def load_cert_chain(self, certfile, keyfile=None, password=None): self._ctx.use_certificate_chain_file(certfile) if password is not None: if not isinstance(password, six.binary_type): password = password.encode("utf-8") self._ctx.set_passwd_cb(lambda *_: password) self._ctx.use_privatekey_file(keyfile or certfile) def set_alpn_protocols(self, protocols): protocols = [six.ensure_binary(p) for p in protocols] return self._ctx.set_alpn_protos(protocols) def wrap_socket( self, sock, server_side=False, do_handshake_on_connect=True, suppress_ragged_eofs=True, server_hostname=None, ): cnx = OpenSSL.SSL.Connection(self._ctx, sock) if isinstance(server_hostname, six.text_type): # Platform-specific: Python 3 server_hostname = server_hostname.encode("utf-8") if server_hostname is not None: cnx.set_tlsext_host_name(server_hostname) cnx.set_connect_state() while True: try: cnx.do_handshake() except OpenSSL.SSL.WantReadError: if not util.wait_for_read(sock, sock.gettimeout()): raise timeout("select timed out") continue except OpenSSL.SSL.Error as e: raise ssl.SSLError("bad handshake: %r" % e) break return WrappedSocket(cnx, sock) def _verify_callback(cnx, x509, err_no, err_depth, return_code): return err_no == 0 ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/contrib/securetransport.py ================================================ """ SecureTranport support for urllib3 via ctypes. This makes platform-native TLS available to urllib3 users on macOS without the use of a compiler. This is an important feature because the Python Package Index is moving to become a TLSv1.2-or-higher server, and the default OpenSSL that ships with macOS is not capable of doing TLSv1.2. The only way to resolve this is to give macOS users an alternative solution to the problem, and that solution is to use SecureTransport. We use ctypes here because this solution must not require a compiler. That's because pip is not allowed to require a compiler either. This is not intended to be a seriously long-term solution to this problem. The hope is that PEP 543 will eventually solve this issue for us, at which point we can retire this contrib module. But in the short term, we need to solve the impending tire fire that is Python on Mac without this kind of contrib module. So...here we are. To use this module, simply import and inject it:: import urllib3.contrib.securetransport urllib3.contrib.securetransport.inject_into_urllib3() Happy TLSing! This code is a bastardised version of the code found in Will Bond's oscrypto library. An enormous debt is owed to him for blazing this trail for us. For that reason, this code should be considered to be covered both by urllib3's license and by oscrypto's: .. code-block:: Copyright (c) 2015-2016 Will Bond Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ from __future__ import absolute_import import contextlib import ctypes import errno import os.path import shutil import socket import ssl import struct import threading import weakref import six from .. import util from ..util.ssl_ import PROTOCOL_TLS_CLIENT from ._securetransport.bindings import CoreFoundation, Security, SecurityConst from ._securetransport.low_level import ( _assert_no_error, _build_tls_unknown_ca_alert, _cert_array_from_pem, _create_cfstring_array, _load_client_cert_chain, _temporary_keychain, ) try: # Platform-specific: Python 2 from socket import _fileobject except ImportError: # Platform-specific: Python 3 _fileobject = None from ..packages.backports.makefile import backport_makefile __all__ = ["inject_into_urllib3", "extract_from_urllib3"] # SNI always works HAS_SNI = True orig_util_HAS_SNI = util.HAS_SNI orig_util_SSLContext = util.ssl_.SSLContext # This dictionary is used by the read callback to obtain a handle to the # calling wrapped socket. This is a pretty silly approach, but for now it'll # do. I feel like I should be able to smuggle a handle to the wrapped socket # directly in the SSLConnectionRef, but for now this approach will work I # guess. # # We need to lock around this structure for inserts, but we don't do it for # reads/writes in the callbacks. The reasoning here goes as follows: # # 1. It is not possible to call into the callbacks before the dictionary is # populated, so once in the callback the id must be in the dictionary. # 2. The callbacks don't mutate the dictionary, they only read from it, and # so cannot conflict with any of the insertions. # # This is good: if we had to lock in the callbacks we'd drastically slow down # the performance of this code. _connection_refs = weakref.WeakValueDictionary() _connection_ref_lock = threading.Lock() # Limit writes to 16kB. This is OpenSSL's limit, but we'll cargo-cult it over # for no better reason than we need *a* limit, and this one is right there. SSL_WRITE_BLOCKSIZE = 16384 # This is our equivalent of util.ssl_.DEFAULT_CIPHERS, but expanded out to # individual cipher suites. We need to do this because this is how # SecureTransport wants them. CIPHER_SUITES = [ SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, SecurityConst.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, SecurityConst.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, SecurityConst.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, SecurityConst.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, SecurityConst.TLS_DHE_RSA_WITH_AES_256_GCM_SHA384, SecurityConst.TLS_DHE_RSA_WITH_AES_128_GCM_SHA256, SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384, SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, SecurityConst.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, SecurityConst.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384, SecurityConst.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, SecurityConst.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, SecurityConst.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, SecurityConst.TLS_DHE_RSA_WITH_AES_256_CBC_SHA256, SecurityConst.TLS_DHE_RSA_WITH_AES_256_CBC_SHA, SecurityConst.TLS_DHE_RSA_WITH_AES_128_CBC_SHA256, SecurityConst.TLS_DHE_RSA_WITH_AES_128_CBC_SHA, SecurityConst.TLS_AES_256_GCM_SHA384, SecurityConst.TLS_AES_128_GCM_SHA256, SecurityConst.TLS_RSA_WITH_AES_256_GCM_SHA384, SecurityConst.TLS_RSA_WITH_AES_128_GCM_SHA256, SecurityConst.TLS_AES_128_CCM_8_SHA256, SecurityConst.TLS_AES_128_CCM_SHA256, SecurityConst.TLS_RSA_WITH_AES_256_CBC_SHA256, SecurityConst.TLS_RSA_WITH_AES_128_CBC_SHA256, SecurityConst.TLS_RSA_WITH_AES_256_CBC_SHA, SecurityConst.TLS_RSA_WITH_AES_128_CBC_SHA, ] # Basically this is simple: for PROTOCOL_SSLv23 we turn it into a low of # TLSv1 and a high of TLSv1.2. For everything else, we pin to that version. # TLSv1 to 1.2 are supported on macOS 10.8+ _protocol_to_min_max = { util.PROTOCOL_TLS: (SecurityConst.kTLSProtocol1, SecurityConst.kTLSProtocol12), PROTOCOL_TLS_CLIENT: (SecurityConst.kTLSProtocol1, SecurityConst.kTLSProtocol12), } if hasattr(ssl, "PROTOCOL_SSLv2"): _protocol_to_min_max[ssl.PROTOCOL_SSLv2] = ( SecurityConst.kSSLProtocol2, SecurityConst.kSSLProtocol2, ) if hasattr(ssl, "PROTOCOL_SSLv3"): _protocol_to_min_max[ssl.PROTOCOL_SSLv3] = ( SecurityConst.kSSLProtocol3, SecurityConst.kSSLProtocol3, ) if hasattr(ssl, "PROTOCOL_TLSv1"): _protocol_to_min_max[ssl.PROTOCOL_TLSv1] = ( SecurityConst.kTLSProtocol1, SecurityConst.kTLSProtocol1, ) if hasattr(ssl, "PROTOCOL_TLSv1_1"): _protocol_to_min_max[ssl.PROTOCOL_TLSv1_1] = ( SecurityConst.kTLSProtocol11, SecurityConst.kTLSProtocol11, ) if hasattr(ssl, "PROTOCOL_TLSv1_2"): _protocol_to_min_max[ssl.PROTOCOL_TLSv1_2] = ( SecurityConst.kTLSProtocol12, SecurityConst.kTLSProtocol12, ) def inject_into_urllib3(): """ Monkey-patch urllib3 with SecureTransport-backed SSL-support. """ util.SSLContext = SecureTransportContext util.ssl_.SSLContext = SecureTransportContext util.HAS_SNI = HAS_SNI util.ssl_.HAS_SNI = HAS_SNI util.IS_SECURETRANSPORT = True util.ssl_.IS_SECURETRANSPORT = True def extract_from_urllib3(): """ Undo monkey-patching by :func:`inject_into_urllib3`. """ util.SSLContext = orig_util_SSLContext util.ssl_.SSLContext = orig_util_SSLContext util.HAS_SNI = orig_util_HAS_SNI util.ssl_.HAS_SNI = orig_util_HAS_SNI util.IS_SECURETRANSPORT = False util.ssl_.IS_SECURETRANSPORT = False def _read_callback(connection_id, data_buffer, data_length_pointer): """ SecureTransport read callback. This is called by ST to request that data be returned from the socket. """ wrapped_socket = None try: wrapped_socket = _connection_refs.get(connection_id) if wrapped_socket is None: return SecurityConst.errSSLInternal base_socket = wrapped_socket.socket requested_length = data_length_pointer[0] timeout = wrapped_socket.gettimeout() error = None read_count = 0 try: while read_count < requested_length: if timeout is None or timeout >= 0: if not util.wait_for_read(base_socket, timeout): raise socket.error(errno.EAGAIN, "timed out") remaining = requested_length - read_count buffer = (ctypes.c_char * remaining).from_address( data_buffer + read_count ) chunk_size = base_socket.recv_into(buffer, remaining) read_count += chunk_size if not chunk_size: if not read_count: return SecurityConst.errSSLClosedGraceful break except (socket.error) as e: error = e.errno if error is not None and error != errno.EAGAIN: data_length_pointer[0] = read_count if error == errno.ECONNRESET or error == errno.EPIPE: return SecurityConst.errSSLClosedAbort raise data_length_pointer[0] = read_count if read_count != requested_length: return SecurityConst.errSSLWouldBlock return 0 except Exception as e: if wrapped_socket is not None: wrapped_socket._exception = e return SecurityConst.errSSLInternal def _write_callback(connection_id, data_buffer, data_length_pointer): """ SecureTransport write callback. This is called by ST to request that data actually be sent on the network. """ wrapped_socket = None try: wrapped_socket = _connection_refs.get(connection_id) if wrapped_socket is None: return SecurityConst.errSSLInternal base_socket = wrapped_socket.socket bytes_to_write = data_length_pointer[0] data = ctypes.string_at(data_buffer, bytes_to_write) timeout = wrapped_socket.gettimeout() error = None sent = 0 try: while sent < bytes_to_write: if timeout is None or timeout >= 0: if not util.wait_for_write(base_socket, timeout): raise socket.error(errno.EAGAIN, "timed out") chunk_sent = base_socket.send(data) sent += chunk_sent # This has some needless copying here, but I'm not sure there's # much value in optimising this data path. data = data[chunk_sent:] except (socket.error) as e: error = e.errno if error is not None and error != errno.EAGAIN: data_length_pointer[0] = sent if error == errno.ECONNRESET or error == errno.EPIPE: return SecurityConst.errSSLClosedAbort raise data_length_pointer[0] = sent if sent != bytes_to_write: return SecurityConst.errSSLWouldBlock return 0 except Exception as e: if wrapped_socket is not None: wrapped_socket._exception = e return SecurityConst.errSSLInternal # We need to keep these two objects references alive: if they get GC'd while # in use then SecureTransport could attempt to call a function that is in freed # memory. That would be...uh...bad. Yeah, that's the word. Bad. _read_callback_pointer = Security.SSLReadFunc(_read_callback) _write_callback_pointer = Security.SSLWriteFunc(_write_callback) class WrappedSocket(object): """ API-compatibility wrapper for Python's OpenSSL wrapped socket object. Note: _makefile_refs, _drop(), and _reuse() are needed for the garbage collector of PyPy. """ def __init__(self, socket): self.socket = socket self.context = None self._makefile_refs = 0 self._closed = False self._exception = None self._keychain = None self._keychain_dir = None self._client_cert_chain = None # We save off the previously-configured timeout and then set it to # zero. This is done because we use select and friends to handle the # timeouts, but if we leave the timeout set on the lower socket then # Python will "kindly" call select on that socket again for us. Avoid # that by forcing the timeout to zero. self._timeout = self.socket.gettimeout() self.socket.settimeout(0) @contextlib.contextmanager def _raise_on_error(self): """ A context manager that can be used to wrap calls that do I/O from SecureTransport. If any of the I/O callbacks hit an exception, this context manager will correctly propagate the exception after the fact. This avoids silently swallowing those exceptions. It also correctly forces the socket closed. """ self._exception = None # We explicitly don't catch around this yield because in the unlikely # event that an exception was hit in the block we don't want to swallow # it. yield if self._exception is not None: exception, self._exception = self._exception, None self.close() raise exception def _set_ciphers(self): """ Sets up the allowed ciphers. By default this matches the set in util.ssl_.DEFAULT_CIPHERS, at least as supported by macOS. This is done custom and doesn't allow changing at this time, mostly because parsing OpenSSL cipher strings is going to be a freaking nightmare. """ ciphers = (Security.SSLCipherSuite * len(CIPHER_SUITES))(*CIPHER_SUITES) result = Security.SSLSetEnabledCiphers( self.context, ciphers, len(CIPHER_SUITES) ) _assert_no_error(result) def _set_alpn_protocols(self, protocols): """ Sets up the ALPN protocols on the context. """ if not protocols: return protocols_arr = _create_cfstring_array(protocols) try: result = Security.SSLSetALPNProtocols(self.context, protocols_arr) _assert_no_error(result) finally: CoreFoundation.CFRelease(protocols_arr) def _custom_validate(self, verify, trust_bundle): """ Called when we have set custom validation. We do this in two cases: first, when cert validation is entirely disabled; and second, when using a custom trust DB. Raises an SSLError if the connection is not trusted. """ # If we disabled cert validation, just say: cool. if not verify: return successes = ( SecurityConst.kSecTrustResultUnspecified, SecurityConst.kSecTrustResultProceed, ) try: trust_result = self._evaluate_trust(trust_bundle) if trust_result in successes: return reason = "error code: %d" % (trust_result,) except Exception as e: # Do not trust on error reason = "exception: %r" % (e,) # SecureTransport does not send an alert nor shuts down the connection. rec = _build_tls_unknown_ca_alert(self.version()) self.socket.sendall(rec) # close the connection immediately # l_onoff = 1, activate linger # l_linger = 0, linger for 0 seoncds opts = struct.pack("ii", 1, 0) self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, opts) self.close() raise ssl.SSLError("certificate verify failed, %s" % reason) def _evaluate_trust(self, trust_bundle): # We want data in memory, so load it up. if os.path.isfile(trust_bundle): with open(trust_bundle, "rb") as f: trust_bundle = f.read() cert_array = None trust = Security.SecTrustRef() try: # Get a CFArray that contains the certs we want. cert_array = _cert_array_from_pem(trust_bundle) # Ok, now the hard part. We want to get the SecTrustRef that ST has # created for this connection, shove our CAs into it, tell ST to # ignore everything else it knows, and then ask if it can build a # chain. This is a buuuunch of code. result = Security.SSLCopyPeerTrust(self.context, ctypes.byref(trust)) _assert_no_error(result) if not trust: raise ssl.SSLError("Failed to copy trust reference") result = Security.SecTrustSetAnchorCertificates(trust, cert_array) _assert_no_error(result) result = Security.SecTrustSetAnchorCertificatesOnly(trust, True) _assert_no_error(result) trust_result = Security.SecTrustResultType() result = Security.SecTrustEvaluate(trust, ctypes.byref(trust_result)) _assert_no_error(result) finally: if trust: CoreFoundation.CFRelease(trust) if cert_array is not None: CoreFoundation.CFRelease(cert_array) return trust_result.value def handshake( self, server_hostname, verify, trust_bundle, min_version, max_version, client_cert, client_key, client_key_passphrase, alpn_protocols, ): """ Actually performs the TLS handshake. This is run automatically by wrapped socket, and shouldn't be needed in user code. """ # First, we do the initial bits of connection setup. We need to create # a context, set its I/O funcs, and set the connection reference. self.context = Security.SSLCreateContext( None, SecurityConst.kSSLClientSide, SecurityConst.kSSLStreamType ) result = Security.SSLSetIOFuncs( self.context, _read_callback_pointer, _write_callback_pointer ) _assert_no_error(result) # Here we need to compute the handle to use. We do this by taking the # id of self modulo 2**31 - 1. If this is already in the dictionary, we # just keep incrementing by one until we find a free space. with _connection_ref_lock: handle = id(self) % 2147483647 while handle in _connection_refs: handle = (handle + 1) % 2147483647 _connection_refs[handle] = self result = Security.SSLSetConnection(self.context, handle) _assert_no_error(result) # If we have a server hostname, we should set that too. if server_hostname: if not isinstance(server_hostname, bytes): server_hostname = server_hostname.encode("utf-8") result = Security.SSLSetPeerDomainName( self.context, server_hostname, len(server_hostname) ) _assert_no_error(result) # Setup the ciphers. self._set_ciphers() # Setup the ALPN protocols. self._set_alpn_protocols(alpn_protocols) # Set the minimum and maximum TLS versions. result = Security.SSLSetProtocolVersionMin(self.context, min_version) _assert_no_error(result) result = Security.SSLSetProtocolVersionMax(self.context, max_version) _assert_no_error(result) # If there's a trust DB, we need to use it. We do that by telling # SecureTransport to break on server auth. We also do that if we don't # want to validate the certs at all: we just won't actually do any # authing in that case. if not verify or trust_bundle is not None: result = Security.SSLSetSessionOption( self.context, SecurityConst.kSSLSessionOptionBreakOnServerAuth, True ) _assert_no_error(result) # If there's a client cert, we need to use it. if client_cert: self._keychain, self._keychain_dir = _temporary_keychain() self._client_cert_chain = _load_client_cert_chain( self._keychain, client_cert, client_key ) result = Security.SSLSetCertificate(self.context, self._client_cert_chain) _assert_no_error(result) while True: with self._raise_on_error(): result = Security.SSLHandshake(self.context) if result == SecurityConst.errSSLWouldBlock: raise socket.timeout("handshake timed out") elif result == SecurityConst.errSSLServerAuthCompleted: self._custom_validate(verify, trust_bundle) continue else: _assert_no_error(result) break def fileno(self): return self.socket.fileno() # Copy-pasted from Python 3.5 source code def _decref_socketios(self): if self._makefile_refs > 0: self._makefile_refs -= 1 if self._closed: self.close() def recv(self, bufsiz): buffer = ctypes.create_string_buffer(bufsiz) bytes_read = self.recv_into(buffer, bufsiz) data = buffer[:bytes_read] return data def recv_into(self, buffer, nbytes=None): # Read short on EOF. if self._closed: return 0 if nbytes is None: nbytes = len(buffer) buffer = (ctypes.c_char * nbytes).from_buffer(buffer) processed_bytes = ctypes.c_size_t(0) with self._raise_on_error(): result = Security.SSLRead( self.context, buffer, nbytes, ctypes.byref(processed_bytes) ) # There are some result codes that we want to treat as "not always # errors". Specifically, those are errSSLWouldBlock, # errSSLClosedGraceful, and errSSLClosedNoNotify. if result == SecurityConst.errSSLWouldBlock: # If we didn't process any bytes, then this was just a time out. # However, we can get errSSLWouldBlock in situations when we *did* # read some data, and in those cases we should just read "short" # and return. if processed_bytes.value == 0: # Timed out, no data read. raise socket.timeout("recv timed out") elif result in ( SecurityConst.errSSLClosedGraceful, SecurityConst.errSSLClosedNoNotify, ): # The remote peer has closed this connection. We should do so as # well. Note that we don't actually return here because in # principle this could actually be fired along with return data. # It's unlikely though. self.close() else: _assert_no_error(result) # Ok, we read and probably succeeded. We should return whatever data # was actually read. return processed_bytes.value def settimeout(self, timeout): self._timeout = timeout def gettimeout(self): return self._timeout def send(self, data): processed_bytes = ctypes.c_size_t(0) with self._raise_on_error(): result = Security.SSLWrite( self.context, data, len(data), ctypes.byref(processed_bytes) ) if result == SecurityConst.errSSLWouldBlock and processed_bytes.value == 0: # Timed out raise socket.timeout("send timed out") else: _assert_no_error(result) # We sent, and probably succeeded. Tell them how much we sent. return processed_bytes.value def sendall(self, data): total_sent = 0 while total_sent < len(data): sent = self.send(data[total_sent : total_sent + SSL_WRITE_BLOCKSIZE]) total_sent += sent def shutdown(self): with self._raise_on_error(): Security.SSLClose(self.context) def close(self): # TODO: should I do clean shutdown here? Do I have to? if self._makefile_refs < 1: self._closed = True if self.context: CoreFoundation.CFRelease(self.context) self.context = None if self._client_cert_chain: CoreFoundation.CFRelease(self._client_cert_chain) self._client_cert_chain = None if self._keychain: Security.SecKeychainDelete(self._keychain) CoreFoundation.CFRelease(self._keychain) shutil.rmtree(self._keychain_dir) self._keychain = self._keychain_dir = None return self.socket.close() else: self._makefile_refs -= 1 def getpeercert(self, binary_form=False): # Urgh, annoying. # # Here's how we do this: # # 1. Call SSLCopyPeerTrust to get hold of the trust object for this # connection. # 2. Call SecTrustGetCertificateAtIndex for index 0 to get the leaf. # 3. To get the CN, call SecCertificateCopyCommonName and process that # string so that it's of the appropriate type. # 4. To get the SAN, we need to do something a bit more complex: # a. Call SecCertificateCopyValues to get the data, requesting # kSecOIDSubjectAltName. # b. Mess about with this dictionary to try to get the SANs out. # # This is gross. Really gross. It's going to be a few hundred LoC extra # just to repeat something that SecureTransport can *already do*. So my # operating assumption at this time is that what we want to do is # instead to just flag to urllib3 that it shouldn't do its own hostname # validation when using SecureTransport. if not binary_form: raise ValueError("SecureTransport only supports dumping binary certs") trust = Security.SecTrustRef() certdata = None der_bytes = None try: # Grab the trust store. result = Security.SSLCopyPeerTrust(self.context, ctypes.byref(trust)) _assert_no_error(result) if not trust: # Probably we haven't done the handshake yet. No biggie. return None cert_count = Security.SecTrustGetCertificateCount(trust) if not cert_count: # Also a case that might happen if we haven't handshaked. # Handshook? Handshaken? return None leaf = Security.SecTrustGetCertificateAtIndex(trust, 0) assert leaf # Ok, now we want the DER bytes. certdata = Security.SecCertificateCopyData(leaf) assert certdata data_length = CoreFoundation.CFDataGetLength(certdata) data_buffer = CoreFoundation.CFDataGetBytePtr(certdata) der_bytes = ctypes.string_at(data_buffer, data_length) finally: if certdata: CoreFoundation.CFRelease(certdata) if trust: CoreFoundation.CFRelease(trust) return der_bytes def version(self): protocol = Security.SSLProtocol() result = Security.SSLGetNegotiatedProtocolVersion( self.context, ctypes.byref(protocol) ) _assert_no_error(result) if protocol.value == SecurityConst.kTLSProtocol13: raise ssl.SSLError("SecureTransport does not support TLS 1.3") elif protocol.value == SecurityConst.kTLSProtocol12: return "TLSv1.2" elif protocol.value == SecurityConst.kTLSProtocol11: return "TLSv1.1" elif protocol.value == SecurityConst.kTLSProtocol1: return "TLSv1" elif protocol.value == SecurityConst.kSSLProtocol3: return "SSLv3" elif protocol.value == SecurityConst.kSSLProtocol2: return "SSLv2" else: raise ssl.SSLError("Unknown TLS version: %r" % protocol) def _reuse(self): self._makefile_refs += 1 def _drop(self): if self._makefile_refs < 1: self.close() else: self._makefile_refs -= 1 if _fileobject: # Platform-specific: Python 2 def makefile(self, mode, bufsize=-1): self._makefile_refs += 1 return _fileobject(self, mode, bufsize, close=True) else: # Platform-specific: Python 3 def makefile(self, mode="r", buffering=None, *args, **kwargs): # We disable buffering with SecureTransport because it conflicts with # the buffering that ST does internally (see issue #1153 for more). buffering = 0 return backport_makefile(self, mode, buffering, *args, **kwargs) WrappedSocket.makefile = makefile class SecureTransportContext(object): """ I am a wrapper class for the SecureTransport library, to translate the interface of the standard library ``SSLContext`` object to calls into SecureTransport. """ def __init__(self, protocol): self._min_version, self._max_version = _protocol_to_min_max[protocol] self._options = 0 self._verify = False self._trust_bundle = None self._client_cert = None self._client_key = None self._client_key_passphrase = None self._alpn_protocols = None @property def check_hostname(self): """ SecureTransport cannot have its hostname checking disabled. For more, see the comment on getpeercert() in this file. """ return True @check_hostname.setter def check_hostname(self, value): """ SecureTransport cannot have its hostname checking disabled. For more, see the comment on getpeercert() in this file. """ pass @property def options(self): # TODO: Well, crap. # # So this is the bit of the code that is the most likely to cause us # trouble. Essentially we need to enumerate all of the SSL options that # users might want to use and try to see if we can sensibly translate # them, or whether we should just ignore them. return self._options @options.setter def options(self, value): # TODO: Update in line with above. self._options = value @property def verify_mode(self): return ssl.CERT_REQUIRED if self._verify else ssl.CERT_NONE @verify_mode.setter def verify_mode(self, value): self._verify = True if value == ssl.CERT_REQUIRED else False def set_default_verify_paths(self): # So, this has to do something a bit weird. Specifically, what it does # is nothing. # # This means that, if we had previously had load_verify_locations # called, this does not undo that. We need to do that because it turns # out that the rest of the urllib3 code will attempt to load the # default verify paths if it hasn't been told about any paths, even if # the context itself was sometime earlier. We resolve that by just # ignoring it. pass def load_default_certs(self): return self.set_default_verify_paths() def set_ciphers(self, ciphers): # For now, we just require the default cipher string. if ciphers != util.ssl_.DEFAULT_CIPHERS: raise ValueError("SecureTransport doesn't support custom cipher strings") def load_verify_locations(self, cafile=None, capath=None, cadata=None): # OK, we only really support cadata and cafile. if capath is not None: raise ValueError("SecureTransport does not support cert directories") # Raise if cafile does not exist. if cafile is not None: with open(cafile): pass self._trust_bundle = cafile or cadata def load_cert_chain(self, certfile, keyfile=None, password=None): self._client_cert = certfile self._client_key = keyfile self._client_cert_passphrase = password def set_alpn_protocols(self, protocols): """ Sets the ALPN protocols that will later be set on the context. Raises a NotImplementedError if ALPN is not supported. """ if not hasattr(Security, "SSLSetALPNProtocols"): raise NotImplementedError( "SecureTransport supports ALPN only in macOS 10.12+" ) self._alpn_protocols = [six.ensure_binary(p) for p in protocols] def wrap_socket( self, sock, server_side=False, do_handshake_on_connect=True, suppress_ragged_eofs=True, server_hostname=None, ): # So, what do we do here? Firstly, we assert some properties. This is a # stripped down shim, so there is some functionality we don't support. # See PEP 543 for the real deal. assert not server_side assert do_handshake_on_connect assert suppress_ragged_eofs # Ok, we're good to go. Now we want to create the wrapped socket object # and store it in the appropriate place. wrapped_socket = WrappedSocket(sock) # Now we can handshake wrapped_socket.handshake( server_hostname, self._verify, self._trust_bundle, self._min_version, self._max_version, self._client_cert, self._client_key, self._client_key_passphrase, self._alpn_protocols, ) return wrapped_socket ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/contrib/socks.py ================================================ # -*- coding: utf-8 -*- """ This module contains provisional support for SOCKS proxies from within urllib3. This module supports SOCKS4, SOCKS4A (an extension of SOCKS4), and SOCKS5. To enable its functionality, either install PySocks or install this module with the ``socks`` extra. The SOCKS implementation supports the full range of urllib3 features. It also supports the following SOCKS features: - SOCKS4A (``proxy_url='socks4a://...``) - SOCKS4 (``proxy_url='socks4://...``) - SOCKS5 with remote DNS (``proxy_url='socks5h://...``) - SOCKS5 with local DNS (``proxy_url='socks5://...``) - Usernames and passwords for the SOCKS proxy .. note:: It is recommended to use ``socks5h://`` or ``socks4a://`` schemes in your ``proxy_url`` to ensure that DNS resolution is done from the remote server instead of client-side when connecting to a domain name. SOCKS4 supports IPv4 and domain names with the SOCKS4A extension. SOCKS5 supports IPv4, IPv6, and domain names. When connecting to a SOCKS4 proxy the ``username`` portion of the ``proxy_url`` will be sent as the ``userid`` section of the SOCKS request: .. code-block:: python proxy_url="socks4a://@proxy-host" When connecting to a SOCKS5 proxy the ``username`` and ``password`` portion of the ``proxy_url`` will be sent as the username/password to authenticate with the proxy: .. code-block:: python proxy_url="socks5h://:@proxy-host" """ from __future__ import absolute_import try: import socks except ImportError: import warnings from ..exceptions import DependencyWarning warnings.warn( ( "SOCKS support in urllib3 requires the installation of optional " "dependencies: specifically, PySocks. For more information, see " "https://urllib3.readthedocs.io/en/1.26.x/contrib.html#socks-proxies" ), DependencyWarning, ) raise from socket import error as SocketError from socket import timeout as SocketTimeout from ..connection import HTTPConnection, HTTPSConnection from ..connectionpool import HTTPConnectionPool, HTTPSConnectionPool from ..exceptions import ConnectTimeoutError, NewConnectionError from ..poolmanager import PoolManager from ..util.url import parse_url try: import ssl except ImportError: ssl = None class SOCKSConnection(HTTPConnection): """ A plain-text HTTP connection that connects via a SOCKS proxy. """ def __init__(self, *args, **kwargs): self._socks_options = kwargs.pop("_socks_options") super(SOCKSConnection, self).__init__(*args, **kwargs) def _new_conn(self): """ Establish a new connection via the SOCKS proxy. """ extra_kw = {} if self.source_address: extra_kw["source_address"] = self.source_address if self.socket_options: extra_kw["socket_options"] = self.socket_options try: conn = socks.create_connection( (self.host, self.port), proxy_type=self._socks_options["socks_version"], proxy_addr=self._socks_options["proxy_host"], proxy_port=self._socks_options["proxy_port"], proxy_username=self._socks_options["username"], proxy_password=self._socks_options["password"], proxy_rdns=self._socks_options["rdns"], timeout=self.timeout, **extra_kw ) except SocketTimeout: raise ConnectTimeoutError( self, "Connection to %s timed out. (connect timeout=%s)" % (self.host, self.timeout), ) except socks.ProxyError as e: # This is fragile as hell, but it seems to be the only way to raise # useful errors here. if e.socket_err: error = e.socket_err if isinstance(error, SocketTimeout): raise ConnectTimeoutError( self, "Connection to %s timed out. (connect timeout=%s)" % (self.host, self.timeout), ) else: raise NewConnectionError( self, "Failed to establish a new connection: %s" % error ) else: raise NewConnectionError( self, "Failed to establish a new connection: %s" % e ) except SocketError as e: # Defensive: PySocks should catch all these. raise NewConnectionError( self, "Failed to establish a new connection: %s" % e ) return conn # We don't need to duplicate the Verified/Unverified distinction from # urllib3/connection.py here because the HTTPSConnection will already have been # correctly set to either the Verified or Unverified form by that module. This # means the SOCKSHTTPSConnection will automatically be the correct type. class SOCKSHTTPSConnection(SOCKSConnection, HTTPSConnection): pass class SOCKSHTTPConnectionPool(HTTPConnectionPool): ConnectionCls = SOCKSConnection class SOCKSHTTPSConnectionPool(HTTPSConnectionPool): ConnectionCls = SOCKSHTTPSConnection class SOCKSProxyManager(PoolManager): """ A version of the urllib3 ProxyManager that routes connections via the defined SOCKS proxy. """ pool_classes_by_scheme = { "http": SOCKSHTTPConnectionPool, "https": SOCKSHTTPSConnectionPool, } def __init__( self, proxy_url, username=None, password=None, num_pools=10, headers=None, **connection_pool_kw ): parsed = parse_url(proxy_url) if username is None and password is None and parsed.auth is not None: split = parsed.auth.split(":") if len(split) == 2: username, password = split if parsed.scheme == "socks5": socks_version = socks.PROXY_TYPE_SOCKS5 rdns = False elif parsed.scheme == "socks5h": socks_version = socks.PROXY_TYPE_SOCKS5 rdns = True elif parsed.scheme == "socks4": socks_version = socks.PROXY_TYPE_SOCKS4 rdns = False elif parsed.scheme == "socks4a": socks_version = socks.PROXY_TYPE_SOCKS4 rdns = True else: raise ValueError("Unable to determine SOCKS version from %s" % proxy_url) self.proxy_url = proxy_url socks_options = { "socks_version": socks_version, "proxy_host": parsed.host, "proxy_port": parsed.port, "username": username, "password": password, "rdns": rdns, } connection_pool_kw["_socks_options"] = socks_options super(SOCKSProxyManager, self).__init__( num_pools, headers, **connection_pool_kw ) self.pool_classes_by_scheme = SOCKSProxyManager.pool_classes_by_scheme ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/exceptions.py ================================================ from __future__ import absolute_import from .packages.six.moves.http_client import IncompleteRead as httplib_IncompleteRead # Base Exceptions class HTTPError(Exception): """Base exception used by this module.""" pass class HTTPWarning(Warning): """Base warning used by this module.""" pass class PoolError(HTTPError): """Base exception for errors caused within a pool.""" def __init__(self, pool, message): self.pool = pool HTTPError.__init__(self, "%s: %s" % (pool, message)) def __reduce__(self): # For pickling purposes. return self.__class__, (None, None) class RequestError(PoolError): """Base exception for PoolErrors that have associated URLs.""" def __init__(self, pool, url, message): self.url = url PoolError.__init__(self, pool, message) def __reduce__(self): # For pickling purposes. return self.__class__, (None, self.url, None) class SSLError(HTTPError): """Raised when SSL certificate fails in an HTTPS connection.""" pass class ProxyError(HTTPError): """Raised when the connection to a proxy fails.""" def __init__(self, message, error, *args): super(ProxyError, self).__init__(message, error, *args) self.original_error = error class DecodeError(HTTPError): """Raised when automatic decoding based on Content-Type fails.""" pass class ProtocolError(HTTPError): """Raised when something unexpected happens mid-request/response.""" pass #: Renamed to ProtocolError but aliased for backwards compatibility. ConnectionError = ProtocolError # Leaf Exceptions class MaxRetryError(RequestError): """Raised when the maximum number of retries is exceeded. :param pool: The connection pool :type pool: :class:`~urllib3.connectionpool.HTTPConnectionPool` :param string url: The requested Url :param exceptions.Exception reason: The underlying error """ def __init__(self, pool, url, reason=None): self.reason = reason message = "Max retries exceeded with url: %s (Caused by %r)" % (url, reason) RequestError.__init__(self, pool, url, message) class HostChangedError(RequestError): """Raised when an existing pool gets a request for a foreign host.""" def __init__(self, pool, url, retries=3): message = "Tried to open a foreign host with url: %s" % url RequestError.__init__(self, pool, url, message) self.retries = retries class TimeoutStateError(HTTPError): """Raised when passing an invalid state to a timeout""" pass class TimeoutError(HTTPError): """Raised when a socket timeout error occurs. Catching this error will catch both :exc:`ReadTimeoutErrors ` and :exc:`ConnectTimeoutErrors `. """ pass class ReadTimeoutError(TimeoutError, RequestError): """Raised when a socket timeout occurs while receiving data from a server""" pass # This timeout error does not have a URL attached and needs to inherit from the # base HTTPError class ConnectTimeoutError(TimeoutError): """Raised when a socket timeout occurs while connecting to a server""" pass class NewConnectionError(ConnectTimeoutError, PoolError): """Raised when we fail to establish a new connection. Usually ECONNREFUSED.""" pass class EmptyPoolError(PoolError): """Raised when a pool runs out of connections and no more are allowed.""" pass class ClosedPoolError(PoolError): """Raised when a request enters a pool after the pool has been closed.""" pass class LocationValueError(ValueError, HTTPError): """Raised when there is something wrong with a given URL input.""" pass class LocationParseError(LocationValueError): """Raised when get_host or similar fails to parse the URL input.""" def __init__(self, location): message = "Failed to parse: %s" % location HTTPError.__init__(self, message) self.location = location class URLSchemeUnknown(LocationValueError): """Raised when a URL input has an unsupported scheme.""" def __init__(self, scheme): message = "Not supported URL scheme %s" % scheme super(URLSchemeUnknown, self).__init__(message) self.scheme = scheme class ResponseError(HTTPError): """Used as a container for an error reason supplied in a MaxRetryError.""" GENERIC_ERROR = "too many error responses" SPECIFIC_ERROR = "too many {status_code} error responses" class SecurityWarning(HTTPWarning): """Warned when performing security reducing actions""" pass class SubjectAltNameWarning(SecurityWarning): """Warned when connecting to a host with a certificate missing a SAN.""" pass class InsecureRequestWarning(SecurityWarning): """Warned when making an unverified HTTPS request.""" pass class SystemTimeWarning(SecurityWarning): """Warned when system time is suspected to be wrong""" pass class InsecurePlatformWarning(SecurityWarning): """Warned when certain TLS/SSL configuration is not available on a platform.""" pass class SNIMissingWarning(HTTPWarning): """Warned when making a HTTPS request without SNI available.""" pass class DependencyWarning(HTTPWarning): """ Warned when an attempt is made to import a module with missing optional dependencies. """ pass class ResponseNotChunked(ProtocolError, ValueError): """Response needs to be chunked in order to read it as chunks.""" pass class BodyNotHttplibCompatible(HTTPError): """ Body should be :class:`http.client.HTTPResponse` like (have an fp attribute which returns raw chunks) for read_chunked(). """ pass class IncompleteRead(HTTPError, httplib_IncompleteRead): """ Response length doesn't match expected Content-Length Subclass of :class:`http.client.IncompleteRead` to allow int value for ``partial`` to avoid creating large objects on streamed reads. """ def __init__(self, partial, expected): super(IncompleteRead, self).__init__(partial, expected) def __repr__(self): return "IncompleteRead(%i bytes read, %i more expected)" % ( self.partial, self.expected, ) class InvalidChunkLength(HTTPError, httplib_IncompleteRead): """Invalid chunk length in a chunked response.""" def __init__(self, response, length): super(InvalidChunkLength, self).__init__( response.tell(), response.length_remaining ) self.response = response self.length = length def __repr__(self): return "InvalidChunkLength(got length %r, %i bytes read)" % ( self.length, self.partial, ) class InvalidHeader(HTTPError): """The header provided was somehow invalid.""" pass class ProxySchemeUnknown(AssertionError, URLSchemeUnknown): """ProxyManager does not support the supplied scheme""" # TODO(t-8ch): Stop inheriting from AssertionError in v2.0. def __init__(self, scheme): # 'localhost' is here because our URL parser parses # localhost:8080 -> scheme=localhost, remove if we fix this. if scheme == "localhost": scheme = None if scheme is None: message = "Proxy URL had no scheme, should start with http:// or https://" else: message = ( "Proxy URL had unsupported scheme %s, should use http:// or https://" % scheme ) super(ProxySchemeUnknown, self).__init__(message) class ProxySchemeUnsupported(ValueError): """Fetching HTTPS resources through HTTPS proxies is unsupported""" pass class HeaderParsingError(HTTPError): """Raised by assert_header_parsing, but we convert it to a log.warning statement.""" def __init__(self, defects, unparsed_data): message = "%s, unparsed data: %r" % (defects or "Unknown", unparsed_data) super(HeaderParsingError, self).__init__(message) class UnrewindableBodyError(HTTPError): """urllib3 encountered an error when trying to rewind a body""" pass ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/fields.py ================================================ from __future__ import absolute_import import email.utils import mimetypes import re from .packages import six def guess_content_type(filename, default="application/octet-stream"): """ Guess the "Content-Type" of a file. :param filename: The filename to guess the "Content-Type" of using :mod:`mimetypes`. :param default: If no "Content-Type" can be guessed, default to `default`. """ if filename: return mimetypes.guess_type(filename)[0] or default return default def format_header_param_rfc2231(name, value): """ Helper function to format and quote a single header parameter using the strategy defined in RFC 2231. Particularly useful for header parameters which might contain non-ASCII values, like file names. This follows `RFC 2388 Section 4.4 `_. :param name: The name of the parameter, a string expected to be ASCII only. :param value: The value of the parameter, provided as ``bytes`` or `str``. :ret: An RFC-2231-formatted unicode string. """ if isinstance(value, six.binary_type): value = value.decode("utf-8") if not any(ch in value for ch in '"\\\r\n'): result = u'%s="%s"' % (name, value) try: result.encode("ascii") except (UnicodeEncodeError, UnicodeDecodeError): pass else: return result if six.PY2: # Python 2: value = value.encode("utf-8") # encode_rfc2231 accepts an encoded string and returns an ascii-encoded # string in Python 2 but accepts and returns unicode strings in Python 3 value = email.utils.encode_rfc2231(value, "utf-8") value = "%s*=%s" % (name, value) if six.PY2: # Python 2: value = value.decode("utf-8") return value _HTML5_REPLACEMENTS = { u"\u0022": u"%22", # Replace "\" with "\\". u"\u005C": u"\u005C\u005C", } # All control characters from 0x00 to 0x1F *except* 0x1B. _HTML5_REPLACEMENTS.update( { six.unichr(cc): u"%{:02X}".format(cc) for cc in range(0x00, 0x1F + 1) if cc not in (0x1B,) } ) def _replace_multiple(value, needles_and_replacements): def replacer(match): return needles_and_replacements[match.group(0)] pattern = re.compile( r"|".join([re.escape(needle) for needle in needles_and_replacements.keys()]) ) result = pattern.sub(replacer, value) return result def format_header_param_html5(name, value): """ Helper function to format and quote a single header parameter using the HTML5 strategy. Particularly useful for header parameters which might contain non-ASCII values, like file names. This follows the `HTML5 Working Draft Section 4.10.22.7`_ and matches the behavior of curl and modern browsers. .. _HTML5 Working Draft Section 4.10.22.7: https://w3c.github.io/html/sec-forms.html#multipart-form-data :param name: The name of the parameter, a string expected to be ASCII only. :param value: The value of the parameter, provided as ``bytes`` or `str``. :ret: A unicode string, stripped of troublesome characters. """ if isinstance(value, six.binary_type): value = value.decode("utf-8") value = _replace_multiple(value, _HTML5_REPLACEMENTS) return u'%s="%s"' % (name, value) # For backwards-compatibility. format_header_param = format_header_param_html5 class RequestField(object): """ A data container for request body parameters. :param name: The name of this request field. Must be unicode. :param data: The data/value body. :param filename: An optional filename of the request field. Must be unicode. :param headers: An optional dict-like object of headers to initially use for the field. :param header_formatter: An optional callable that is used to encode and format the headers. By default, this is :func:`format_header_param_html5`. """ def __init__( self, name, data, filename=None, headers=None, header_formatter=format_header_param_html5, ): self._name = name self._filename = filename self.data = data self.headers = {} if headers: self.headers = dict(headers) self.header_formatter = header_formatter @classmethod def from_tuples(cls, fieldname, value, header_formatter=format_header_param_html5): """ A :class:`~urllib3.fields.RequestField` factory from old-style tuple parameters. Supports constructing :class:`~urllib3.fields.RequestField` from parameter of key/value strings AND key/filetuple. A filetuple is a (filename, data, MIME type) tuple where the MIME type is optional. For example:: 'foo': 'bar', 'fakefile': ('foofile.txt', 'contents of foofile'), 'realfile': ('barfile.txt', open('realfile').read()), 'typedfile': ('bazfile.bin', open('bazfile').read(), 'image/jpeg'), 'nonamefile': 'contents of nonamefile field', Field names and filenames must be unicode. """ if isinstance(value, tuple): if len(value) == 3: filename, data, content_type = value else: filename, data = value content_type = guess_content_type(filename) else: filename = None content_type = None data = value request_param = cls( fieldname, data, filename=filename, header_formatter=header_formatter ) request_param.make_multipart(content_type=content_type) return request_param def _render_part(self, name, value): """ Overridable helper function to format a single header parameter. By default, this calls ``self.header_formatter``. :param name: The name of the parameter, a string expected to be ASCII only. :param value: The value of the parameter, provided as a unicode string. """ return self.header_formatter(name, value) def _render_parts(self, header_parts): """ Helper function to format and quote a single header. Useful for single headers that are composed of multiple items. E.g., 'Content-Disposition' fields. :param header_parts: A sequence of (k, v) tuples or a :class:`dict` of (k, v) to format as `k1="v1"; k2="v2"; ...`. """ parts = [] iterable = header_parts if isinstance(header_parts, dict): iterable = header_parts.items() for name, value in iterable: if value is not None: parts.append(self._render_part(name, value)) return u"; ".join(parts) def render_headers(self): """ Renders the headers for this request field. """ lines = [] sort_keys = ["Content-Disposition", "Content-Type", "Content-Location"] for sort_key in sort_keys: if self.headers.get(sort_key, False): lines.append(u"%s: %s" % (sort_key, self.headers[sort_key])) for header_name, header_value in self.headers.items(): if header_name not in sort_keys: if header_value: lines.append(u"%s: %s" % (header_name, header_value)) lines.append(u"\r\n") return u"\r\n".join(lines) def make_multipart( self, content_disposition=None, content_type=None, content_location=None ): """ Makes this request field into a multipart request field. This method overrides "Content-Disposition", "Content-Type" and "Content-Location" headers to the request parameter. :param content_type: The 'Content-Type' of the request body. :param content_location: The 'Content-Location' of the request body. """ self.headers["Content-Disposition"] = content_disposition or u"form-data" self.headers["Content-Disposition"] += u"; ".join( [ u"", self._render_parts( ((u"name", self._name), (u"filename", self._filename)) ), ] ) self.headers["Content-Type"] = content_type self.headers["Content-Location"] = content_location ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/filepost.py ================================================ from __future__ import absolute_import import binascii import codecs import os from io import BytesIO from .fields import RequestField from .packages import six from .packages.six import b writer = codecs.lookup("utf-8")[3] def choose_boundary(): """ Our embarrassingly-simple replacement for mimetools.choose_boundary. """ boundary = binascii.hexlify(os.urandom(16)) if not six.PY2: boundary = boundary.decode("ascii") return boundary def iter_field_objects(fields): """ Iterate over fields. Supports list of (k, v) tuples and dicts, and lists of :class:`~urllib3.fields.RequestField`. """ if isinstance(fields, dict): i = six.iteritems(fields) else: i = iter(fields) for field in i: if isinstance(field, RequestField): yield field else: yield RequestField.from_tuples(*field) def iter_fields(fields): """ .. deprecated:: 1.6 Iterate over fields. The addition of :class:`~urllib3.fields.RequestField` makes this function obsolete. Instead, use :func:`iter_field_objects`, which returns :class:`~urllib3.fields.RequestField` objects. Supports list of (k, v) tuples and dicts. """ if isinstance(fields, dict): return ((k, v) for k, v in six.iteritems(fields)) return ((k, v) for k, v in fields) def encode_multipart_formdata(fields, boundary=None): """ Encode a dictionary of ``fields`` using the multipart/form-data MIME format. :param fields: Dictionary of fields or list of (key, :class:`~urllib3.fields.RequestField`). :param boundary: If not specified, then a random boundary will be generated using :func:`urllib3.filepost.choose_boundary`. """ body = BytesIO() if boundary is None: boundary = choose_boundary() for field in iter_field_objects(fields): body.write(b("--%s\r\n" % (boundary))) writer(body).write(field.render_headers()) data = field.data if isinstance(data, int): data = str(data) # Backwards compatibility if isinstance(data, six.text_type): writer(body).write(data) else: body.write(data) body.write(b"\r\n") body.write(b("--%s--\r\n" % (boundary))) content_type = str("multipart/form-data; boundary=%s" % boundary) return body.getvalue(), content_type ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/packages/__init__.py ================================================ from __future__ import absolute_import from . import ssl_match_hostname __all__ = ("ssl_match_hostname",) ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/packages/backports/__init__.py ================================================ ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/packages/backports/makefile.py ================================================ # -*- coding: utf-8 -*- """ backports.makefile ~~~~~~~~~~~~~~~~~~ Backports the Python 3 ``socket.makefile`` method for use with anything that wants to create a "fake" socket object. """ import io from socket import SocketIO def backport_makefile( self, mode="r", buffering=None, encoding=None, errors=None, newline=None ): """ Backport of ``socket.makefile`` from Python 3.5. """ if not set(mode) <= {"r", "w", "b"}: raise ValueError("invalid mode %r (only r, w, b allowed)" % (mode,)) writing = "w" in mode reading = "r" in mode or not writing assert reading or writing binary = "b" in mode rawmode = "" if reading: rawmode += "r" if writing: rawmode += "w" raw = SocketIO(self, rawmode) self._makefile_refs += 1 if buffering is None: buffering = -1 if buffering < 0: buffering = io.DEFAULT_BUFFER_SIZE if buffering == 0: if not binary: raise ValueError("unbuffered streams must be binary") return raw if reading and writing: buffer = io.BufferedRWPair(raw, raw, buffering) elif reading: buffer = io.BufferedReader(raw, buffering) else: assert writing buffer = io.BufferedWriter(raw, buffering) if binary: return buffer text = io.TextIOWrapper(buffer, encoding, errors, newline) text.mode = mode return text ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/packages/six.py ================================================ # Copyright (c) 2010-2020 Benjamin Peterson # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. """Utilities for writing code that runs on Python 2 and 3""" from __future__ import absolute_import import functools import itertools import operator import sys import types __author__ = "Benjamin Peterson " __version__ = "1.16.0" # Useful for very coarse version differentiation. PY2 = sys.version_info[0] == 2 PY3 = sys.version_info[0] == 3 PY34 = sys.version_info[0:2] >= (3, 4) if PY3: string_types = (str,) integer_types = (int,) class_types = (type,) text_type = str binary_type = bytes MAXSIZE = sys.maxsize else: string_types = (basestring,) integer_types = (int, long) class_types = (type, types.ClassType) text_type = unicode binary_type = str if sys.platform.startswith("java"): # Jython always uses 32 bits. MAXSIZE = int((1 << 31) - 1) else: # It's possible to have sizeof(long) != sizeof(Py_ssize_t). class X(object): def __len__(self): return 1 << 31 try: len(X()) except OverflowError: # 32-bit MAXSIZE = int((1 << 31) - 1) else: # 64-bit MAXSIZE = int((1 << 63) - 1) del X if PY34: from importlib.util import spec_from_loader else: spec_from_loader = None def _add_doc(func, doc): """Add documentation to a function.""" func.__doc__ = doc def _import_module(name): """Import module, returning the module after the last dot.""" __import__(name) return sys.modules[name] class _LazyDescr(object): def __init__(self, name): self.name = name def __get__(self, obj, tp): result = self._resolve() setattr(obj, self.name, result) # Invokes __set__. try: # This is a bit ugly, but it avoids running this again by # removing this descriptor. delattr(obj.__class__, self.name) except AttributeError: pass return result class MovedModule(_LazyDescr): def __init__(self, name, old, new=None): super(MovedModule, self).__init__(name) if PY3: if new is None: new = name self.mod = new else: self.mod = old def _resolve(self): return _import_module(self.mod) def __getattr__(self, attr): _module = self._resolve() value = getattr(_module, attr) setattr(self, attr, value) return value class _LazyModule(types.ModuleType): def __init__(self, name): super(_LazyModule, self).__init__(name) self.__doc__ = self.__class__.__doc__ def __dir__(self): attrs = ["__doc__", "__name__"] attrs += [attr.name for attr in self._moved_attributes] return attrs # Subclasses should override this _moved_attributes = [] class MovedAttribute(_LazyDescr): def __init__(self, name, old_mod, new_mod, old_attr=None, new_attr=None): super(MovedAttribute, self).__init__(name) if PY3: if new_mod is None: new_mod = name self.mod = new_mod if new_attr is None: if old_attr is None: new_attr = name else: new_attr = old_attr self.attr = new_attr else: self.mod = old_mod if old_attr is None: old_attr = name self.attr = old_attr def _resolve(self): module = _import_module(self.mod) return getattr(module, self.attr) class _SixMetaPathImporter(object): """ A meta path importer to import six.moves and its submodules. This class implements a PEP302 finder and loader. It should be compatible with Python 2.5 and all existing versions of Python3 """ def __init__(self, six_module_name): self.name = six_module_name self.known_modules = {} def _add_module(self, mod, *fullnames): for fullname in fullnames: self.known_modules[self.name + "." + fullname] = mod def _get_module(self, fullname): return self.known_modules[self.name + "." + fullname] def find_module(self, fullname, path=None): if fullname in self.known_modules: return self return None def find_spec(self, fullname, path, target=None): if fullname in self.known_modules: return spec_from_loader(fullname, self) return None def __get_module(self, fullname): try: return self.known_modules[fullname] except KeyError: raise ImportError("This loader does not know module " + fullname) def load_module(self, fullname): try: # in case of a reload return sys.modules[fullname] except KeyError: pass mod = self.__get_module(fullname) if isinstance(mod, MovedModule): mod = mod._resolve() else: mod.__loader__ = self sys.modules[fullname] = mod return mod def is_package(self, fullname): """ Return true, if the named module is a package. We need this method to get correct spec objects with Python 3.4 (see PEP451) """ return hasattr(self.__get_module(fullname), "__path__") def get_code(self, fullname): """Return None Required, if is_package is implemented""" self.__get_module(fullname) # eventually raises ImportError return None get_source = get_code # same as get_code def create_module(self, spec): return self.load_module(spec.name) def exec_module(self, module): pass _importer = _SixMetaPathImporter(__name__) class _MovedItems(_LazyModule): """Lazy loading of moved objects""" __path__ = [] # mark as package _moved_attributes = [ MovedAttribute("cStringIO", "cStringIO", "io", "StringIO"), MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"), MovedAttribute( "filterfalse", "itertools", "itertools", "ifilterfalse", "filterfalse" ), MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"), MovedAttribute("intern", "__builtin__", "sys"), MovedAttribute("map", "itertools", "builtins", "imap", "map"), MovedAttribute("getcwd", "os", "os", "getcwdu", "getcwd"), MovedAttribute("getcwdb", "os", "os", "getcwd", "getcwdb"), MovedAttribute("getoutput", "commands", "subprocess"), MovedAttribute("range", "__builtin__", "builtins", "xrange", "range"), MovedAttribute( "reload_module", "__builtin__", "importlib" if PY34 else "imp", "reload" ), MovedAttribute("reduce", "__builtin__", "functools"), MovedAttribute("shlex_quote", "pipes", "shlex", "quote"), MovedAttribute("StringIO", "StringIO", "io"), MovedAttribute("UserDict", "UserDict", "collections"), MovedAttribute("UserList", "UserList", "collections"), MovedAttribute("UserString", "UserString", "collections"), MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"), MovedAttribute("zip", "itertools", "builtins", "izip", "zip"), MovedAttribute( "zip_longest", "itertools", "itertools", "izip_longest", "zip_longest" ), MovedModule("builtins", "__builtin__"), MovedModule("configparser", "ConfigParser"), MovedModule( "collections_abc", "collections", "collections.abc" if sys.version_info >= (3, 3) else "collections", ), MovedModule("copyreg", "copy_reg"), MovedModule("dbm_gnu", "gdbm", "dbm.gnu"), MovedModule("dbm_ndbm", "dbm", "dbm.ndbm"), MovedModule( "_dummy_thread", "dummy_thread", "_dummy_thread" if sys.version_info < (3, 9) else "_thread", ), MovedModule("http_cookiejar", "cookielib", "http.cookiejar"), MovedModule("http_cookies", "Cookie", "http.cookies"), MovedModule("html_entities", "htmlentitydefs", "html.entities"), MovedModule("html_parser", "HTMLParser", "html.parser"), MovedModule("http_client", "httplib", "http.client"), MovedModule("email_mime_base", "email.MIMEBase", "email.mime.base"), MovedModule("email_mime_image", "email.MIMEImage", "email.mime.image"), MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"), MovedModule( "email_mime_nonmultipart", "email.MIMENonMultipart", "email.mime.nonmultipart" ), MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"), MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"), MovedModule("CGIHTTPServer", "CGIHTTPServer", "http.server"), MovedModule("SimpleHTTPServer", "SimpleHTTPServer", "http.server"), MovedModule("cPickle", "cPickle", "pickle"), MovedModule("queue", "Queue"), MovedModule("reprlib", "repr"), MovedModule("socketserver", "SocketServer"), MovedModule("_thread", "thread", "_thread"), MovedModule("tkinter", "Tkinter"), MovedModule("tkinter_dialog", "Dialog", "tkinter.dialog"), MovedModule("tkinter_filedialog", "FileDialog", "tkinter.filedialog"), MovedModule("tkinter_scrolledtext", "ScrolledText", "tkinter.scrolledtext"), MovedModule("tkinter_simpledialog", "SimpleDialog", "tkinter.simpledialog"), MovedModule("tkinter_tix", "Tix", "tkinter.tix"), MovedModule("tkinter_ttk", "ttk", "tkinter.ttk"), MovedModule("tkinter_constants", "Tkconstants", "tkinter.constants"), MovedModule("tkinter_dnd", "Tkdnd", "tkinter.dnd"), MovedModule("tkinter_colorchooser", "tkColorChooser", "tkinter.colorchooser"), MovedModule("tkinter_commondialog", "tkCommonDialog", "tkinter.commondialog"), MovedModule("tkinter_tkfiledialog", "tkFileDialog", "tkinter.filedialog"), MovedModule("tkinter_font", "tkFont", "tkinter.font"), MovedModule("tkinter_messagebox", "tkMessageBox", "tkinter.messagebox"), MovedModule("tkinter_tksimpledialog", "tkSimpleDialog", "tkinter.simpledialog"), MovedModule("urllib_parse", __name__ + ".moves.urllib_parse", "urllib.parse"), MovedModule("urllib_error", __name__ + ".moves.urllib_error", "urllib.error"), MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"), MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"), MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"), MovedModule("xmlrpc_server", "SimpleXMLRPCServer", "xmlrpc.server"), ] # Add windows specific modules. if sys.platform == "win32": _moved_attributes += [ MovedModule("winreg", "_winreg"), ] for attr in _moved_attributes: setattr(_MovedItems, attr.name, attr) if isinstance(attr, MovedModule): _importer._add_module(attr, "moves." + attr.name) del attr _MovedItems._moved_attributes = _moved_attributes moves = _MovedItems(__name__ + ".moves") _importer._add_module(moves, "moves") class Module_six_moves_urllib_parse(_LazyModule): """Lazy loading of moved objects in six.moves.urllib_parse""" _urllib_parse_moved_attributes = [ MovedAttribute("ParseResult", "urlparse", "urllib.parse"), MovedAttribute("SplitResult", "urlparse", "urllib.parse"), MovedAttribute("parse_qs", "urlparse", "urllib.parse"), MovedAttribute("parse_qsl", "urlparse", "urllib.parse"), MovedAttribute("urldefrag", "urlparse", "urllib.parse"), MovedAttribute("urljoin", "urlparse", "urllib.parse"), MovedAttribute("urlparse", "urlparse", "urllib.parse"), MovedAttribute("urlsplit", "urlparse", "urllib.parse"), MovedAttribute("urlunparse", "urlparse", "urllib.parse"), MovedAttribute("urlunsplit", "urlparse", "urllib.parse"), MovedAttribute("quote", "urllib", "urllib.parse"), MovedAttribute("quote_plus", "urllib", "urllib.parse"), MovedAttribute("unquote", "urllib", "urllib.parse"), MovedAttribute("unquote_plus", "urllib", "urllib.parse"), MovedAttribute( "unquote_to_bytes", "urllib", "urllib.parse", "unquote", "unquote_to_bytes" ), MovedAttribute("urlencode", "urllib", "urllib.parse"), MovedAttribute("splitquery", "urllib", "urllib.parse"), MovedAttribute("splittag", "urllib", "urllib.parse"), MovedAttribute("splituser", "urllib", "urllib.parse"), MovedAttribute("splitvalue", "urllib", "urllib.parse"), MovedAttribute("uses_fragment", "urlparse", "urllib.parse"), MovedAttribute("uses_netloc", "urlparse", "urllib.parse"), MovedAttribute("uses_params", "urlparse", "urllib.parse"), MovedAttribute("uses_query", "urlparse", "urllib.parse"), MovedAttribute("uses_relative", "urlparse", "urllib.parse"), ] for attr in _urllib_parse_moved_attributes: setattr(Module_six_moves_urllib_parse, attr.name, attr) del attr Module_six_moves_urllib_parse._moved_attributes = _urllib_parse_moved_attributes _importer._add_module( Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse"), "moves.urllib_parse", "moves.urllib.parse", ) class Module_six_moves_urllib_error(_LazyModule): """Lazy loading of moved objects in six.moves.urllib_error""" _urllib_error_moved_attributes = [ MovedAttribute("URLError", "urllib2", "urllib.error"), MovedAttribute("HTTPError", "urllib2", "urllib.error"), MovedAttribute("ContentTooShortError", "urllib", "urllib.error"), ] for attr in _urllib_error_moved_attributes: setattr(Module_six_moves_urllib_error, attr.name, attr) del attr Module_six_moves_urllib_error._moved_attributes = _urllib_error_moved_attributes _importer._add_module( Module_six_moves_urllib_error(__name__ + ".moves.urllib.error"), "moves.urllib_error", "moves.urllib.error", ) class Module_six_moves_urllib_request(_LazyModule): """Lazy loading of moved objects in six.moves.urllib_request""" _urllib_request_moved_attributes = [ MovedAttribute("urlopen", "urllib2", "urllib.request"), MovedAttribute("install_opener", "urllib2", "urllib.request"), MovedAttribute("build_opener", "urllib2", "urllib.request"), MovedAttribute("pathname2url", "urllib", "urllib.request"), MovedAttribute("url2pathname", "urllib", "urllib.request"), MovedAttribute("getproxies", "urllib", "urllib.request"), MovedAttribute("Request", "urllib2", "urllib.request"), MovedAttribute("OpenerDirector", "urllib2", "urllib.request"), MovedAttribute("HTTPDefaultErrorHandler", "urllib2", "urllib.request"), MovedAttribute("HTTPRedirectHandler", "urllib2", "urllib.request"), MovedAttribute("HTTPCookieProcessor", "urllib2", "urllib.request"), MovedAttribute("ProxyHandler", "urllib2", "urllib.request"), MovedAttribute("BaseHandler", "urllib2", "urllib.request"), MovedAttribute("HTTPPasswordMgr", "urllib2", "urllib.request"), MovedAttribute("HTTPPasswordMgrWithDefaultRealm", "urllib2", "urllib.request"), MovedAttribute("AbstractBasicAuthHandler", "urllib2", "urllib.request"), MovedAttribute("HTTPBasicAuthHandler", "urllib2", "urllib.request"), MovedAttribute("ProxyBasicAuthHandler", "urllib2", "urllib.request"), MovedAttribute("AbstractDigestAuthHandler", "urllib2", "urllib.request"), MovedAttribute("HTTPDigestAuthHandler", "urllib2", "urllib.request"), MovedAttribute("ProxyDigestAuthHandler", "urllib2", "urllib.request"), MovedAttribute("HTTPHandler", "urllib2", "urllib.request"), MovedAttribute("HTTPSHandler", "urllib2", "urllib.request"), MovedAttribute("FileHandler", "urllib2", "urllib.request"), MovedAttribute("FTPHandler", "urllib2", "urllib.request"), MovedAttribute("CacheFTPHandler", "urllib2", "urllib.request"), MovedAttribute("UnknownHandler", "urllib2", "urllib.request"), MovedAttribute("HTTPErrorProcessor", "urllib2", "urllib.request"), MovedAttribute("urlretrieve", "urllib", "urllib.request"), MovedAttribute("urlcleanup", "urllib", "urllib.request"), MovedAttribute("URLopener", "urllib", "urllib.request"), MovedAttribute("FancyURLopener", "urllib", "urllib.request"), MovedAttribute("proxy_bypass", "urllib", "urllib.request"), MovedAttribute("parse_http_list", "urllib2", "urllib.request"), MovedAttribute("parse_keqv_list", "urllib2", "urllib.request"), ] for attr in _urllib_request_moved_attributes: setattr(Module_six_moves_urllib_request, attr.name, attr) del attr Module_six_moves_urllib_request._moved_attributes = _urllib_request_moved_attributes _importer._add_module( Module_six_moves_urllib_request(__name__ + ".moves.urllib.request"), "moves.urllib_request", "moves.urllib.request", ) class Module_six_moves_urllib_response(_LazyModule): """Lazy loading of moved objects in six.moves.urllib_response""" _urllib_response_moved_attributes = [ MovedAttribute("addbase", "urllib", "urllib.response"), MovedAttribute("addclosehook", "urllib", "urllib.response"), MovedAttribute("addinfo", "urllib", "urllib.response"), MovedAttribute("addinfourl", "urllib", "urllib.response"), ] for attr in _urllib_response_moved_attributes: setattr(Module_six_moves_urllib_response, attr.name, attr) del attr Module_six_moves_urllib_response._moved_attributes = _urllib_response_moved_attributes _importer._add_module( Module_six_moves_urllib_response(__name__ + ".moves.urllib.response"), "moves.urllib_response", "moves.urllib.response", ) class Module_six_moves_urllib_robotparser(_LazyModule): """Lazy loading of moved objects in six.moves.urllib_robotparser""" _urllib_robotparser_moved_attributes = [ MovedAttribute("RobotFileParser", "robotparser", "urllib.robotparser"), ] for attr in _urllib_robotparser_moved_attributes: setattr(Module_six_moves_urllib_robotparser, attr.name, attr) del attr Module_six_moves_urllib_robotparser._moved_attributes = ( _urllib_robotparser_moved_attributes ) _importer._add_module( Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser"), "moves.urllib_robotparser", "moves.urllib.robotparser", ) class Module_six_moves_urllib(types.ModuleType): """Create a six.moves.urllib namespace that resembles the Python 3 namespace""" __path__ = [] # mark as package parse = _importer._get_module("moves.urllib_parse") error = _importer._get_module("moves.urllib_error") request = _importer._get_module("moves.urllib_request") response = _importer._get_module("moves.urllib_response") robotparser = _importer._get_module("moves.urllib_robotparser") def __dir__(self): return ["parse", "error", "request", "response", "robotparser"] _importer._add_module( Module_six_moves_urllib(__name__ + ".moves.urllib"), "moves.urllib" ) def add_move(move): """Add an item to six.moves.""" setattr(_MovedItems, move.name, move) def remove_move(name): """Remove item from six.moves.""" try: delattr(_MovedItems, name) except AttributeError: try: del moves.__dict__[name] except KeyError: raise AttributeError("no such move, %r" % (name,)) if PY3: _meth_func = "__func__" _meth_self = "__self__" _func_closure = "__closure__" _func_code = "__code__" _func_defaults = "__defaults__" _func_globals = "__globals__" else: _meth_func = "im_func" _meth_self = "im_self" _func_closure = "func_closure" _func_code = "func_code" _func_defaults = "func_defaults" _func_globals = "func_globals" try: advance_iterator = next except NameError: def advance_iterator(it): return it.next() next = advance_iterator try: callable = callable except NameError: def callable(obj): return any("__call__" in klass.__dict__ for klass in type(obj).__mro__) if PY3: def get_unbound_function(unbound): return unbound create_bound_method = types.MethodType def create_unbound_method(func, cls): return func Iterator = object else: def get_unbound_function(unbound): return unbound.im_func def create_bound_method(func, obj): return types.MethodType(func, obj, obj.__class__) def create_unbound_method(func, cls): return types.MethodType(func, None, cls) class Iterator(object): def next(self): return type(self).__next__(self) callable = callable _add_doc( get_unbound_function, """Get the function out of a possibly unbound function""" ) get_method_function = operator.attrgetter(_meth_func) get_method_self = operator.attrgetter(_meth_self) get_function_closure = operator.attrgetter(_func_closure) get_function_code = operator.attrgetter(_func_code) get_function_defaults = operator.attrgetter(_func_defaults) get_function_globals = operator.attrgetter(_func_globals) if PY3: def iterkeys(d, **kw): return iter(d.keys(**kw)) def itervalues(d, **kw): return iter(d.values(**kw)) def iteritems(d, **kw): return iter(d.items(**kw)) def iterlists(d, **kw): return iter(d.lists(**kw)) viewkeys = operator.methodcaller("keys") viewvalues = operator.methodcaller("values") viewitems = operator.methodcaller("items") else: def iterkeys(d, **kw): return d.iterkeys(**kw) def itervalues(d, **kw): return d.itervalues(**kw) def iteritems(d, **kw): return d.iteritems(**kw) def iterlists(d, **kw): return d.iterlists(**kw) viewkeys = operator.methodcaller("viewkeys") viewvalues = operator.methodcaller("viewvalues") viewitems = operator.methodcaller("viewitems") _add_doc(iterkeys, "Return an iterator over the keys of a dictionary.") _add_doc(itervalues, "Return an iterator over the values of a dictionary.") _add_doc(iteritems, "Return an iterator over the (key, value) pairs of a dictionary.") _add_doc( iterlists, "Return an iterator over the (key, [values]) pairs of a dictionary." ) if PY3: def b(s): return s.encode("latin-1") def u(s): return s unichr = chr import struct int2byte = struct.Struct(">B").pack del struct byte2int = operator.itemgetter(0) indexbytes = operator.getitem iterbytes = iter import io StringIO = io.StringIO BytesIO = io.BytesIO del io _assertCountEqual = "assertCountEqual" if sys.version_info[1] <= 1: _assertRaisesRegex = "assertRaisesRegexp" _assertRegex = "assertRegexpMatches" _assertNotRegex = "assertNotRegexpMatches" else: _assertRaisesRegex = "assertRaisesRegex" _assertRegex = "assertRegex" _assertNotRegex = "assertNotRegex" else: def b(s): return s # Workaround for standalone backslash def u(s): return unicode(s.replace(r"\\", r"\\\\"), "unicode_escape") unichr = unichr int2byte = chr def byte2int(bs): return ord(bs[0]) def indexbytes(buf, i): return ord(buf[i]) iterbytes = functools.partial(itertools.imap, ord) import StringIO StringIO = BytesIO = StringIO.StringIO _assertCountEqual = "assertItemsEqual" _assertRaisesRegex = "assertRaisesRegexp" _assertRegex = "assertRegexpMatches" _assertNotRegex = "assertNotRegexpMatches" _add_doc(b, """Byte literal""") _add_doc(u, """Text literal""") def assertCountEqual(self, *args, **kwargs): return getattr(self, _assertCountEqual)(*args, **kwargs) def assertRaisesRegex(self, *args, **kwargs): return getattr(self, _assertRaisesRegex)(*args, **kwargs) def assertRegex(self, *args, **kwargs): return getattr(self, _assertRegex)(*args, **kwargs) def assertNotRegex(self, *args, **kwargs): return getattr(self, _assertNotRegex)(*args, **kwargs) if PY3: exec_ = getattr(moves.builtins, "exec") def reraise(tp, value, tb=None): try: if value is None: value = tp() if value.__traceback__ is not tb: raise value.with_traceback(tb) raise value finally: value = None tb = None else: def exec_(_code_, _globs_=None, _locs_=None): """Execute code in a namespace.""" if _globs_ is None: frame = sys._getframe(1) _globs_ = frame.f_globals if _locs_ is None: _locs_ = frame.f_locals del frame elif _locs_ is None: _locs_ = _globs_ exec ("""exec _code_ in _globs_, _locs_""") exec_( """def reraise(tp, value, tb=None): try: raise tp, value, tb finally: tb = None """ ) if sys.version_info[:2] > (3,): exec_( """def raise_from(value, from_value): try: raise value from from_value finally: value = None """ ) else: def raise_from(value, from_value): raise value print_ = getattr(moves.builtins, "print", None) if print_ is None: def print_(*args, **kwargs): """The new-style print function for Python 2.4 and 2.5.""" fp = kwargs.pop("file", sys.stdout) if fp is None: return def write(data): if not isinstance(data, basestring): data = str(data) # If the file has an encoding, encode unicode with it. if ( isinstance(fp, file) and isinstance(data, unicode) and fp.encoding is not None ): errors = getattr(fp, "errors", None) if errors is None: errors = "strict" data = data.encode(fp.encoding, errors) fp.write(data) want_unicode = False sep = kwargs.pop("sep", None) if sep is not None: if isinstance(sep, unicode): want_unicode = True elif not isinstance(sep, str): raise TypeError("sep must be None or a string") end = kwargs.pop("end", None) if end is not None: if isinstance(end, unicode): want_unicode = True elif not isinstance(end, str): raise TypeError("end must be None or a string") if kwargs: raise TypeError("invalid keyword arguments to print()") if not want_unicode: for arg in args: if isinstance(arg, unicode): want_unicode = True break if want_unicode: newline = unicode("\n") space = unicode(" ") else: newline = "\n" space = " " if sep is None: sep = space if end is None: end = newline for i, arg in enumerate(args): if i: write(sep) write(arg) write(end) if sys.version_info[:2] < (3, 3): _print = print_ def print_(*args, **kwargs): fp = kwargs.get("file", sys.stdout) flush = kwargs.pop("flush", False) _print(*args, **kwargs) if flush and fp is not None: fp.flush() _add_doc(reraise, """Reraise an exception.""") if sys.version_info[0:2] < (3, 4): # This does exactly the same what the :func:`py3:functools.update_wrapper` # function does on Python versions after 3.2. It sets the ``__wrapped__`` # attribute on ``wrapper`` object and it doesn't raise an error if any of # the attributes mentioned in ``assigned`` and ``updated`` are missing on # ``wrapped`` object. def _update_wrapper( wrapper, wrapped, assigned=functools.WRAPPER_ASSIGNMENTS, updated=functools.WRAPPER_UPDATES, ): for attr in assigned: try: value = getattr(wrapped, attr) except AttributeError: continue else: setattr(wrapper, attr, value) for attr in updated: getattr(wrapper, attr).update(getattr(wrapped, attr, {})) wrapper.__wrapped__ = wrapped return wrapper _update_wrapper.__doc__ = functools.update_wrapper.__doc__ def wraps( wrapped, assigned=functools.WRAPPER_ASSIGNMENTS, updated=functools.WRAPPER_UPDATES, ): return functools.partial( _update_wrapper, wrapped=wrapped, assigned=assigned, updated=updated ) wraps.__doc__ = functools.wraps.__doc__ else: wraps = functools.wraps def with_metaclass(meta, *bases): """Create a base class with a metaclass.""" # This requires a bit of explanation: the basic idea is to make a dummy # metaclass for one level of class instantiation that replaces itself with # the actual metaclass. class metaclass(type): def __new__(cls, name, this_bases, d): if sys.version_info[:2] >= (3, 7): # This version introduced PEP 560 that requires a bit # of extra care (we mimic what is done by __build_class__). resolved_bases = types.resolve_bases(bases) if resolved_bases is not bases: d["__orig_bases__"] = bases else: resolved_bases = bases return meta(name, resolved_bases, d) @classmethod def __prepare__(cls, name, this_bases): return meta.__prepare__(name, bases) return type.__new__(metaclass, "temporary_class", (), {}) def add_metaclass(metaclass): """Class decorator for creating a class with a metaclass.""" def wrapper(cls): orig_vars = cls.__dict__.copy() slots = orig_vars.get("__slots__") if slots is not None: if isinstance(slots, str): slots = [slots] for slots_var in slots: orig_vars.pop(slots_var) orig_vars.pop("__dict__", None) orig_vars.pop("__weakref__", None) if hasattr(cls, "__qualname__"): orig_vars["__qualname__"] = cls.__qualname__ return metaclass(cls.__name__, cls.__bases__, orig_vars) return wrapper def ensure_binary(s, encoding="utf-8", errors="strict"): """Coerce **s** to six.binary_type. For Python 2: - `unicode` -> encoded to `str` - `str` -> `str` For Python 3: - `str` -> encoded to `bytes` - `bytes` -> `bytes` """ if isinstance(s, binary_type): return s if isinstance(s, text_type): return s.encode(encoding, errors) raise TypeError("not expecting type '%s'" % type(s)) def ensure_str(s, encoding="utf-8", errors="strict"): """Coerce *s* to `str`. For Python 2: - `unicode` -> encoded to `str` - `str` -> `str` For Python 3: - `str` -> `str` - `bytes` -> decoded to `str` """ # Optimization: Fast return for the common case. if type(s) is str: return s if PY2 and isinstance(s, text_type): return s.encode(encoding, errors) elif PY3 and isinstance(s, binary_type): return s.decode(encoding, errors) elif not isinstance(s, (text_type, binary_type)): raise TypeError("not expecting type '%s'" % type(s)) return s def ensure_text(s, encoding="utf-8", errors="strict"): """Coerce *s* to six.text_type. For Python 2: - `unicode` -> `unicode` - `str` -> `unicode` For Python 3: - `str` -> `str` - `bytes` -> decoded to `str` """ if isinstance(s, binary_type): return s.decode(encoding, errors) elif isinstance(s, text_type): return s else: raise TypeError("not expecting type '%s'" % type(s)) def python_2_unicode_compatible(klass): """ A class decorator that defines __unicode__ and __str__ methods under Python 2. Under Python 3 it does nothing. To support Python 2 and 3 with a single code base, define a __str__ method returning text and apply this decorator to the class. """ if PY2: if "__str__" not in klass.__dict__: raise ValueError( "@python_2_unicode_compatible cannot be applied " "to %s because it doesn't define __str__()." % klass.__name__ ) klass.__unicode__ = klass.__str__ klass.__str__ = lambda self: self.__unicode__().encode("utf-8") return klass # Complete the moves implementation. # This code is at the end of this module to speed up module loading. # Turn this module into a package. __path__ = [] # required for PEP 302 and PEP 451 __package__ = __name__ # see PEP 366 @ReservedAssignment if globals().get("__spec__") is not None: __spec__.submodule_search_locations = [] # PEP 451 @UndefinedVariable # Remove other six meta path importers, since they cause problems. This can # happen if six is removed from sys.modules and then reloaded. (Setuptools does # this for some reason.) if sys.meta_path: for i, importer in enumerate(sys.meta_path): # Here's some real nastiness: Another "instance" of the six module might # be floating around. Therefore, we can't use isinstance() to check for # the six meta path importer, since the other six instance will have # inserted an importer with different class. if ( type(importer).__name__ == "_SixMetaPathImporter" and importer.name == __name__ ): del sys.meta_path[i] break del i, importer # Finally, add the importer to the meta path import hook. sys.meta_path.append(_importer) ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/__init__.py ================================================ import sys try: # Our match_hostname function is the same as 3.10's, so we only want to # import the match_hostname function if it's at least that good. # We also fallback on Python 3.10+ because our code doesn't emit # deprecation warnings and is the same as Python 3.10 otherwise. if sys.version_info < (3, 5) or sys.version_info >= (3, 10): raise ImportError("Fallback to vendored code") from ssl import CertificateError, match_hostname except ImportError: try: # Backport of the function from a pypi module from backports.ssl_match_hostname import ( # type: ignore CertificateError, match_hostname, ) except ImportError: # Our vendored copy from ._implementation import CertificateError, match_hostname # type: ignore # Not needed, but documenting what we provide. __all__ = ("CertificateError", "match_hostname") ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/packages/ssl_match_hostname/_implementation.py ================================================ """The match_hostname() function from Python 3.3.3, essential when using SSL.""" # Note: This file is under the PSF license as the code comes from the python # stdlib. http://docs.python.org/3/license.html import re import sys # ipaddress has been backported to 2.6+ in pypi. If it is installed on the # system, use it to handle IPAddress ServerAltnames (this was added in # python-3.5) otherwise only do DNS matching. This allows # backports.ssl_match_hostname to continue to be used in Python 2.7. try: import ipaddress except ImportError: ipaddress = None __version__ = "3.5.0.1" class CertificateError(ValueError): pass def _dnsname_match(dn, hostname, max_wildcards=1): """Matching according to RFC 6125, section 6.4.3 http://tools.ietf.org/html/rfc6125#section-6.4.3 """ pats = [] if not dn: return False # Ported from python3-syntax: # leftmost, *remainder = dn.split(r'.') parts = dn.split(r".") leftmost = parts[0] remainder = parts[1:] wildcards = leftmost.count("*") if wildcards > max_wildcards: # Issue #17980: avoid denials of service by refusing more # than one wildcard per fragment. A survey of established # policy among SSL implementations showed it to be a # reasonable choice. raise CertificateError( "too many wildcards in certificate DNS name: " + repr(dn) ) # speed up common case w/o wildcards if not wildcards: return dn.lower() == hostname.lower() # RFC 6125, section 6.4.3, subitem 1. # The client SHOULD NOT attempt to match a presented identifier in which # the wildcard character comprises a label other than the left-most label. if leftmost == "*": # When '*' is a fragment by itself, it matches a non-empty dotless # fragment. pats.append("[^.]+") elif leftmost.startswith("xn--") or hostname.startswith("xn--"): # RFC 6125, section 6.4.3, subitem 3. # The client SHOULD NOT attempt to match a presented identifier # where the wildcard character is embedded within an A-label or # U-label of an internationalized domain name. pats.append(re.escape(leftmost)) else: # Otherwise, '*' matches any dotless string, e.g. www* pats.append(re.escape(leftmost).replace(r"\*", "[^.]*")) # add the remaining fragments, ignore any wildcards for frag in remainder: pats.append(re.escape(frag)) pat = re.compile(r"\A" + r"\.".join(pats) + r"\Z", re.IGNORECASE) return pat.match(hostname) def _to_unicode(obj): if isinstance(obj, str) and sys.version_info < (3,): obj = unicode(obj, encoding="ascii", errors="strict") return obj def _ipaddress_match(ipname, host_ip): """Exact matching of IP addresses. RFC 6125 explicitly doesn't define an algorithm for this (section 1.7.2 - "Out of Scope"). """ # OpenSSL may add a trailing newline to a subjectAltName's IP address # Divergence from upstream: ipaddress can't handle byte str ip = ipaddress.ip_address(_to_unicode(ipname).rstrip()) return ip == host_ip def match_hostname(cert, hostname): """Verify that *cert* (in decoded format as returned by SSLSocket.getpeercert()) matches the *hostname*. RFC 2818 and RFC 6125 rules are followed, but IP addresses are not accepted for *hostname*. CertificateError is raised on failure. On success, the function returns nothing. """ if not cert: raise ValueError( "empty or no certificate, match_hostname needs a " "SSL socket or SSL context with either " "CERT_OPTIONAL or CERT_REQUIRED" ) try: # Divergence from upstream: ipaddress can't handle byte str host_ip = ipaddress.ip_address(_to_unicode(hostname)) except ValueError: # Not an IP address (common case) host_ip = None except UnicodeError: # Divergence from upstream: Have to deal with ipaddress not taking # byte strings. addresses should be all ascii, so we consider it not # an ipaddress in this case host_ip = None except AttributeError: # Divergence from upstream: Make ipaddress library optional if ipaddress is None: host_ip = None else: raise dnsnames = [] san = cert.get("subjectAltName", ()) for key, value in san: if key == "DNS": if host_ip is None and _dnsname_match(value, hostname): return dnsnames.append(value) elif key == "IP Address": if host_ip is not None and _ipaddress_match(value, host_ip): return dnsnames.append(value) if not dnsnames: # The subject is only checked when there is no dNSName entry # in subjectAltName for sub in cert.get("subject", ()): for key, value in sub: # XXX according to RFC 2818, the most specific Common Name # must be used. if key == "commonName": if _dnsname_match(value, hostname): return dnsnames.append(value) if len(dnsnames) > 1: raise CertificateError( "hostname %r " "doesn't match either of %s" % (hostname, ", ".join(map(repr, dnsnames))) ) elif len(dnsnames) == 1: raise CertificateError("hostname %r doesn't match %r" % (hostname, dnsnames[0])) else: raise CertificateError( "no appropriate commonName or subjectAltName fields were found" ) ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/poolmanager.py ================================================ from __future__ import absolute_import import collections import functools import logging from ._collections import RecentlyUsedContainer from .connectionpool import HTTPConnectionPool, HTTPSConnectionPool, port_by_scheme from .exceptions import ( LocationValueError, MaxRetryError, ProxySchemeUnknown, ProxySchemeUnsupported, URLSchemeUnknown, ) from .packages import six from .packages.six.moves.urllib.parse import urljoin from .request import RequestMethods from .util.proxy import connection_requires_http_tunnel from .util.retry import Retry from .util.url import parse_url __all__ = ["PoolManager", "ProxyManager", "proxy_from_url"] log = logging.getLogger(__name__) SSL_KEYWORDS = ( "key_file", "cert_file", "cert_reqs", "ca_certs", "ssl_version", "ca_cert_dir", "ssl_context", "key_password", ) # All known keyword arguments that could be provided to the pool manager, its # pools, or the underlying connections. This is used to construct a pool key. _key_fields = ( "key_scheme", # str "key_host", # str "key_port", # int "key_timeout", # int or float or Timeout "key_retries", # int or Retry "key_strict", # bool "key_block", # bool "key_source_address", # str "key_key_file", # str "key_key_password", # str "key_cert_file", # str "key_cert_reqs", # str "key_ca_certs", # str "key_ssl_version", # str "key_ca_cert_dir", # str "key_ssl_context", # instance of ssl.SSLContext or urllib3.util.ssl_.SSLContext "key_maxsize", # int "key_headers", # dict "key__proxy", # parsed proxy url "key__proxy_headers", # dict "key__proxy_config", # class "key_socket_options", # list of (level (int), optname (int), value (int or str)) tuples "key__socks_options", # dict "key_assert_hostname", # bool or string "key_assert_fingerprint", # str "key_server_hostname", # str ) #: The namedtuple class used to construct keys for the connection pool. #: All custom key schemes should include the fields in this key at a minimum. PoolKey = collections.namedtuple("PoolKey", _key_fields) _proxy_config_fields = ("ssl_context", "use_forwarding_for_https") ProxyConfig = collections.namedtuple("ProxyConfig", _proxy_config_fields) def _default_key_normalizer(key_class, request_context): """ Create a pool key out of a request context dictionary. According to RFC 3986, both the scheme and host are case-insensitive. Therefore, this function normalizes both before constructing the pool key for an HTTPS request. If you wish to change this behaviour, provide alternate callables to ``key_fn_by_scheme``. :param key_class: The class to use when constructing the key. This should be a namedtuple with the ``scheme`` and ``host`` keys at a minimum. :type key_class: namedtuple :param request_context: A dictionary-like object that contain the context for a request. :type request_context: dict :return: A namedtuple that can be used as a connection pool key. :rtype: PoolKey """ # Since we mutate the dictionary, make a copy first context = request_context.copy() context["scheme"] = context["scheme"].lower() context["host"] = context["host"].lower() # These are both dictionaries and need to be transformed into frozensets for key in ("headers", "_proxy_headers", "_socks_options"): if key in context and context[key] is not None: context[key] = frozenset(context[key].items()) # The socket_options key may be a list and needs to be transformed into a # tuple. socket_opts = context.get("socket_options") if socket_opts is not None: context["socket_options"] = tuple(socket_opts) # Map the kwargs to the names in the namedtuple - this is necessary since # namedtuples can't have fields starting with '_'. for key in list(context.keys()): context["key_" + key] = context.pop(key) # Default to ``None`` for keys missing from the context for field in key_class._fields: if field not in context: context[field] = None return key_class(**context) #: A dictionary that maps a scheme to a callable that creates a pool key. #: This can be used to alter the way pool keys are constructed, if desired. #: Each PoolManager makes a copy of this dictionary so they can be configured #: globally here, or individually on the instance. key_fn_by_scheme = { "http": functools.partial(_default_key_normalizer, PoolKey), "https": functools.partial(_default_key_normalizer, PoolKey), } pool_classes_by_scheme = {"http": HTTPConnectionPool, "https": HTTPSConnectionPool} class PoolManager(RequestMethods): """ Allows for arbitrary requests while transparently keeping track of necessary connection pools for you. :param num_pools: Number of connection pools to cache before discarding the least recently used pool. :param headers: Headers to include with all requests, unless other headers are given explicitly. :param \\**connection_pool_kw: Additional parameters are used to create fresh :class:`urllib3.connectionpool.ConnectionPool` instances. Example:: >>> manager = PoolManager(num_pools=2) >>> r = manager.request('GET', 'http://google.com/') >>> r = manager.request('GET', 'http://google.com/mail') >>> r = manager.request('GET', 'http://yahoo.com/') >>> len(manager.pools) 2 """ proxy = None proxy_config = None def __init__(self, num_pools=10, headers=None, **connection_pool_kw): RequestMethods.__init__(self, headers) self.connection_pool_kw = connection_pool_kw self.pools = RecentlyUsedContainer(num_pools, dispose_func=lambda p: p.close()) # Locally set the pool classes and keys so other PoolManagers can # override them. self.pool_classes_by_scheme = pool_classes_by_scheme self.key_fn_by_scheme = key_fn_by_scheme.copy() def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.clear() # Return False to re-raise any potential exceptions return False def _new_pool(self, scheme, host, port, request_context=None): """ Create a new :class:`urllib3.connectionpool.ConnectionPool` based on host, port, scheme, and any additional pool keyword arguments. If ``request_context`` is provided, it is provided as keyword arguments to the pool class used. This method is used to actually create the connection pools handed out by :meth:`connection_from_url` and companion methods. It is intended to be overridden for customization. """ pool_cls = self.pool_classes_by_scheme[scheme] if request_context is None: request_context = self.connection_pool_kw.copy() # Although the context has everything necessary to create the pool, # this function has historically only used the scheme, host, and port # in the positional args. When an API change is acceptable these can # be removed. for key in ("scheme", "host", "port"): request_context.pop(key, None) if scheme == "http": for kw in SSL_KEYWORDS: request_context.pop(kw, None) return pool_cls(host, port, **request_context) def clear(self): """ Empty our store of pools and direct them all to close. This will not affect in-flight connections, but they will not be re-used after completion. """ self.pools.clear() def connection_from_host(self, host, port=None, scheme="http", pool_kwargs=None): """ Get a :class:`urllib3.connectionpool.ConnectionPool` based on the host, port, and scheme. If ``port`` isn't given, it will be derived from the ``scheme`` using ``urllib3.connectionpool.port_by_scheme``. If ``pool_kwargs`` is provided, it is merged with the instance's ``connection_pool_kw`` variable and used to create the new connection pool, if one is needed. """ if not host: raise LocationValueError("No host specified.") request_context = self._merge_pool_kwargs(pool_kwargs) request_context["scheme"] = scheme or "http" if not port: port = port_by_scheme.get(request_context["scheme"].lower(), 80) request_context["port"] = port request_context["host"] = host return self.connection_from_context(request_context) def connection_from_context(self, request_context): """ Get a :class:`urllib3.connectionpool.ConnectionPool` based on the request context. ``request_context`` must at least contain the ``scheme`` key and its value must be a key in ``key_fn_by_scheme`` instance variable. """ scheme = request_context["scheme"].lower() pool_key_constructor = self.key_fn_by_scheme.get(scheme) if not pool_key_constructor: raise URLSchemeUnknown(scheme) pool_key = pool_key_constructor(request_context) return self.connection_from_pool_key(pool_key, request_context=request_context) def connection_from_pool_key(self, pool_key, request_context=None): """ Get a :class:`urllib3.connectionpool.ConnectionPool` based on the provided pool key. ``pool_key`` should be a namedtuple that only contains immutable objects. At a minimum it must have the ``scheme``, ``host``, and ``port`` fields. """ with self.pools.lock: # If the scheme, host, or port doesn't match existing open # connections, open a new ConnectionPool. pool = self.pools.get(pool_key) if pool: return pool # Make a fresh ConnectionPool of the desired type scheme = request_context["scheme"] host = request_context["host"] port = request_context["port"] pool = self._new_pool(scheme, host, port, request_context=request_context) self.pools[pool_key] = pool return pool def connection_from_url(self, url, pool_kwargs=None): """ Similar to :func:`urllib3.connectionpool.connection_from_url`. If ``pool_kwargs`` is not provided and a new pool needs to be constructed, ``self.connection_pool_kw`` is used to initialize the :class:`urllib3.connectionpool.ConnectionPool`. If ``pool_kwargs`` is provided, it is used instead. Note that if a new pool does not need to be created for the request, the provided ``pool_kwargs`` are not used. """ u = parse_url(url) return self.connection_from_host( u.host, port=u.port, scheme=u.scheme, pool_kwargs=pool_kwargs ) def _merge_pool_kwargs(self, override): """ Merge a dictionary of override values for self.connection_pool_kw. This does not modify self.connection_pool_kw and returns a new dict. Any keys in the override dictionary with a value of ``None`` are removed from the merged dictionary. """ base_pool_kwargs = self.connection_pool_kw.copy() if override: for key, value in override.items(): if value is None: try: del base_pool_kwargs[key] except KeyError: pass else: base_pool_kwargs[key] = value return base_pool_kwargs def _proxy_requires_url_absolute_form(self, parsed_url): """ Indicates if the proxy requires the complete destination URL in the request. Normally this is only needed when not using an HTTP CONNECT tunnel. """ if self.proxy is None: return False return not connection_requires_http_tunnel( self.proxy, self.proxy_config, parsed_url.scheme ) def _validate_proxy_scheme_url_selection(self, url_scheme): """ Validates that were not attempting to do TLS in TLS connections on Python2 or with unsupported SSL implementations. """ if self.proxy is None or url_scheme != "https": return if self.proxy.scheme != "https": return if six.PY2 and not self.proxy_config.use_forwarding_for_https: raise ProxySchemeUnsupported( "Contacting HTTPS destinations through HTTPS proxies " "'via CONNECT tunnels' is not supported in Python 2" ) def urlopen(self, method, url, redirect=True, **kw): """ Same as :meth:`urllib3.HTTPConnectionPool.urlopen` with custom cross-host redirect logic and only sends the request-uri portion of the ``url``. The given ``url`` parameter must be absolute, such that an appropriate :class:`urllib3.connectionpool.ConnectionPool` can be chosen for it. """ u = parse_url(url) self._validate_proxy_scheme_url_selection(u.scheme) conn = self.connection_from_host(u.host, port=u.port, scheme=u.scheme) kw["assert_same_host"] = False kw["redirect"] = False if "headers" not in kw: kw["headers"] = self.headers.copy() if self._proxy_requires_url_absolute_form(u): response = conn.urlopen(method, url, **kw) else: response = conn.urlopen(method, u.request_uri, **kw) redirect_location = redirect and response.get_redirect_location() if not redirect_location: return response # Support relative URLs for redirecting. redirect_location = urljoin(url, redirect_location) # RFC 7231, Section 6.4.4 if response.status == 303: method = "GET" retries = kw.get("retries") if not isinstance(retries, Retry): retries = Retry.from_int(retries, redirect=redirect) # Strip headers marked as unsafe to forward to the redirected location. # Check remove_headers_on_redirect to avoid a potential network call within # conn.is_same_host() which may use socket.gethostbyname() in the future. if retries.remove_headers_on_redirect and not conn.is_same_host( redirect_location ): headers = list(six.iterkeys(kw["headers"])) for header in headers: if header.lower() in retries.remove_headers_on_redirect: kw["headers"].pop(header, None) try: retries = retries.increment(method, url, response=response, _pool=conn) except MaxRetryError: if retries.raise_on_redirect: response.drain_conn() raise return response kw["retries"] = retries kw["redirect"] = redirect log.info("Redirecting %s -> %s", url, redirect_location) response.drain_conn() return self.urlopen(method, redirect_location, **kw) class ProxyManager(PoolManager): """ Behaves just like :class:`PoolManager`, but sends all requests through the defined proxy, using the CONNECT method for HTTPS URLs. :param proxy_url: The URL of the proxy to be used. :param proxy_headers: A dictionary containing headers that will be sent to the proxy. In case of HTTP they are being sent with each request, while in the HTTPS/CONNECT case they are sent only once. Could be used for proxy authentication. :param proxy_ssl_context: The proxy SSL context is used to establish the TLS connection to the proxy when using HTTPS proxies. :param use_forwarding_for_https: (Defaults to False) If set to True will forward requests to the HTTPS proxy to be made on behalf of the client instead of creating a TLS tunnel via the CONNECT method. **Enabling this flag means that request and response headers and content will be visible from the HTTPS proxy** whereas tunneling keeps request and response headers and content private. IP address, target hostname, SNI, and port are always visible to an HTTPS proxy even when this flag is disabled. Example: >>> proxy = urllib3.ProxyManager('http://localhost:3128/') >>> r1 = proxy.request('GET', 'http://google.com/') >>> r2 = proxy.request('GET', 'http://httpbin.org/') >>> len(proxy.pools) 1 >>> r3 = proxy.request('GET', 'https://httpbin.org/') >>> r4 = proxy.request('GET', 'https://twitter.com/') >>> len(proxy.pools) 3 """ def __init__( self, proxy_url, num_pools=10, headers=None, proxy_headers=None, proxy_ssl_context=None, use_forwarding_for_https=False, **connection_pool_kw ): if isinstance(proxy_url, HTTPConnectionPool): proxy_url = "%s://%s:%i" % ( proxy_url.scheme, proxy_url.host, proxy_url.port, ) proxy = parse_url(proxy_url) if proxy.scheme not in ("http", "https"): raise ProxySchemeUnknown(proxy.scheme) if not proxy.port: port = port_by_scheme.get(proxy.scheme, 80) proxy = proxy._replace(port=port) self.proxy = proxy self.proxy_headers = proxy_headers or {} self.proxy_ssl_context = proxy_ssl_context self.proxy_config = ProxyConfig(proxy_ssl_context, use_forwarding_for_https) connection_pool_kw["_proxy"] = self.proxy connection_pool_kw["_proxy_headers"] = self.proxy_headers connection_pool_kw["_proxy_config"] = self.proxy_config super(ProxyManager, self).__init__(num_pools, headers, **connection_pool_kw) def connection_from_host(self, host, port=None, scheme="http", pool_kwargs=None): if scheme == "https": return super(ProxyManager, self).connection_from_host( host, port, scheme, pool_kwargs=pool_kwargs ) return super(ProxyManager, self).connection_from_host( self.proxy.host, self.proxy.port, self.proxy.scheme, pool_kwargs=pool_kwargs ) def _set_proxy_headers(self, url, headers=None): """ Sets headers needed by proxies: specifically, the Accept and Host headers. Only sets headers not provided by the user. """ headers_ = {"Accept": "*/*"} netloc = parse_url(url).netloc if netloc: headers_["Host"] = netloc if headers: headers_.update(headers) return headers_ def urlopen(self, method, url, redirect=True, **kw): "Same as HTTP(S)ConnectionPool.urlopen, ``url`` must be absolute." u = parse_url(url) if not connection_requires_http_tunnel(self.proxy, self.proxy_config, u.scheme): # For connections using HTTP CONNECT, httplib sets the necessary # headers on the CONNECT to the proxy. If we're not using CONNECT, # we'll definitely need to set 'Host' at the very least. headers = kw.get("headers", self.headers) kw["headers"] = self._set_proxy_headers(url, headers) return super(ProxyManager, self).urlopen(method, url, redirect=redirect, **kw) def proxy_from_url(url, **kw): return ProxyManager(proxy_url=url, **kw) ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/request.py ================================================ from __future__ import absolute_import from .filepost import encode_multipart_formdata from .packages.six.moves.urllib.parse import urlencode __all__ = ["RequestMethods"] class RequestMethods(object): """ Convenience mixin for classes who implement a :meth:`urlopen` method, such as :class:`urllib3.HTTPConnectionPool` and :class:`urllib3.PoolManager`. Provides behavior for making common types of HTTP request methods and decides which type of request field encoding to use. Specifically, :meth:`.request_encode_url` is for sending requests whose fields are encoded in the URL (such as GET, HEAD, DELETE). :meth:`.request_encode_body` is for sending requests whose fields are encoded in the *body* of the request using multipart or www-form-urlencoded (such as for POST, PUT, PATCH). :meth:`.request` is for making any kind of request, it will look up the appropriate encoding format and use one of the above two methods to make the request. Initializer parameters: :param headers: Headers to include with all requests, unless other headers are given explicitly. """ _encode_url_methods = {"DELETE", "GET", "HEAD", "OPTIONS"} def __init__(self, headers=None): self.headers = headers or {} def urlopen( self, method, url, body=None, headers=None, encode_multipart=True, multipart_boundary=None, **kw ): # Abstract raise NotImplementedError( "Classes extending RequestMethods must implement " "their own ``urlopen`` method." ) def request(self, method, url, fields=None, headers=None, **urlopen_kw): """ Make a request using :meth:`urlopen` with the appropriate encoding of ``fields`` based on the ``method`` used. This is a convenience method that requires the least amount of manual effort. It can be used in most situations, while still having the option to drop down to more specific methods when necessary, such as :meth:`request_encode_url`, :meth:`request_encode_body`, or even the lowest level :meth:`urlopen`. """ method = method.upper() urlopen_kw["request_url"] = url if method in self._encode_url_methods: return self.request_encode_url( method, url, fields=fields, headers=headers, **urlopen_kw ) else: return self.request_encode_body( method, url, fields=fields, headers=headers, **urlopen_kw ) def request_encode_url(self, method, url, fields=None, headers=None, **urlopen_kw): """ Make a request using :meth:`urlopen` with the ``fields`` encoded in the url. This is useful for request methods like GET, HEAD, DELETE, etc. """ if headers is None: headers = self.headers extra_kw = {"headers": headers} extra_kw.update(urlopen_kw) if fields: url += "?" + urlencode(fields) return self.urlopen(method, url, **extra_kw) def request_encode_body( self, method, url, fields=None, headers=None, encode_multipart=True, multipart_boundary=None, **urlopen_kw ): """ Make a request using :meth:`urlopen` with the ``fields`` encoded in the body. This is useful for request methods like POST, PUT, PATCH, etc. When ``encode_multipart=True`` (default), then :func:`urllib3.encode_multipart_formdata` is used to encode the payload with the appropriate content type. Otherwise :func:`urllib.parse.urlencode` is used with the 'application/x-www-form-urlencoded' content type. Multipart encoding must be used when posting files, and it's reasonably safe to use it in other times too. However, it may break request signing, such as with OAuth. Supports an optional ``fields`` parameter of key/value strings AND key/filetuple. A filetuple is a (filename, data, MIME type) tuple where the MIME type is optional. For example:: fields = { 'foo': 'bar', 'fakefile': ('foofile.txt', 'contents of foofile'), 'realfile': ('barfile.txt', open('realfile').read()), 'typedfile': ('bazfile.bin', open('bazfile').read(), 'image/jpeg'), 'nonamefile': 'contents of nonamefile field', } When uploading a file, providing a filename (the first parameter of the tuple) is optional but recommended to best mimic behavior of browsers. Note that if ``headers`` are supplied, the 'Content-Type' header will be overwritten because it depends on the dynamic random boundary string which is used to compose the body of the request. The random boundary string can be explicitly set with the ``multipart_boundary`` parameter. """ if headers is None: headers = self.headers extra_kw = {"headers": {}} if fields: if "body" in urlopen_kw: raise TypeError( "request got values for both 'fields' and 'body', can only specify one." ) if encode_multipart: body, content_type = encode_multipart_formdata( fields, boundary=multipart_boundary ) else: body, content_type = ( urlencode(fields), "application/x-www-form-urlencoded", ) extra_kw["body"] = body extra_kw["headers"] = {"Content-Type": content_type} extra_kw["headers"].update(headers) extra_kw.update(urlopen_kw) return self.urlopen(method, url, **extra_kw) ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/response.py ================================================ from __future__ import absolute_import import io import logging import zlib from contextlib import contextmanager from socket import error as SocketError from socket import timeout as SocketTimeout try: import brotli except ImportError: brotli = None from ._collections import HTTPHeaderDict from .connection import BaseSSLError, HTTPException from .exceptions import ( BodyNotHttplibCompatible, DecodeError, HTTPError, IncompleteRead, InvalidChunkLength, InvalidHeader, ProtocolError, ReadTimeoutError, ResponseNotChunked, SSLError, ) from .packages import six from .util.response import is_fp_closed, is_response_to_head log = logging.getLogger(__name__) class DeflateDecoder(object): def __init__(self): self._first_try = True self._data = b"" self._obj = zlib.decompressobj() def __getattr__(self, name): return getattr(self._obj, name) def decompress(self, data): if not data: return data if not self._first_try: return self._obj.decompress(data) self._data += data try: decompressed = self._obj.decompress(data) if decompressed: self._first_try = False self._data = None return decompressed except zlib.error: self._first_try = False self._obj = zlib.decompressobj(-zlib.MAX_WBITS) try: return self.decompress(self._data) finally: self._data = None class GzipDecoderState(object): FIRST_MEMBER = 0 OTHER_MEMBERS = 1 SWALLOW_DATA = 2 class GzipDecoder(object): def __init__(self): self._obj = zlib.decompressobj(16 + zlib.MAX_WBITS) self._state = GzipDecoderState.FIRST_MEMBER def __getattr__(self, name): return getattr(self._obj, name) def decompress(self, data): ret = bytearray() if self._state == GzipDecoderState.SWALLOW_DATA or not data: return bytes(ret) while True: try: ret += self._obj.decompress(data) except zlib.error: previous_state = self._state # Ignore data after the first error self._state = GzipDecoderState.SWALLOW_DATA if previous_state == GzipDecoderState.OTHER_MEMBERS: # Allow trailing garbage acceptable in other gzip clients return bytes(ret) raise data = self._obj.unused_data if not data: return bytes(ret) self._state = GzipDecoderState.OTHER_MEMBERS self._obj = zlib.decompressobj(16 + zlib.MAX_WBITS) if brotli is not None: class BrotliDecoder(object): # Supports both 'brotlipy' and 'Brotli' packages # since they share an import name. The top branches # are for 'brotlipy' and bottom branches for 'Brotli' def __init__(self): self._obj = brotli.Decompressor() if hasattr(self._obj, "decompress"): self.decompress = self._obj.decompress else: self.decompress = self._obj.process def flush(self): if hasattr(self._obj, "flush"): return self._obj.flush() return b"" class MultiDecoder(object): """ From RFC7231: If one or more encodings have been applied to a representation, the sender that applied the encodings MUST generate a Content-Encoding header field that lists the content codings in the order in which they were applied. """ def __init__(self, modes): self._decoders = [_get_decoder(m.strip()) for m in modes.split(",")] def flush(self): return self._decoders[0].flush() def decompress(self, data): for d in reversed(self._decoders): data = d.decompress(data) return data def _get_decoder(mode): if "," in mode: return MultiDecoder(mode) if mode == "gzip": return GzipDecoder() if brotli is not None and mode == "br": return BrotliDecoder() return DeflateDecoder() class HTTPResponse(io.IOBase): """ HTTP Response container. Backwards-compatible with :class:`http.client.HTTPResponse` but the response ``body`` is loaded and decoded on-demand when the ``data`` property is accessed. This class is also compatible with the Python standard library's :mod:`io` module, and can hence be treated as a readable object in the context of that framework. Extra parameters for behaviour not present in :class:`http.client.HTTPResponse`: :param preload_content: If True, the response's body will be preloaded during construction. :param decode_content: If True, will attempt to decode the body based on the 'content-encoding' header. :param original_response: When this HTTPResponse wrapper is generated from an :class:`http.client.HTTPResponse` object, it's convenient to include the original for debug purposes. It's otherwise unused. :param retries: The retries contains the last :class:`~urllib3.util.retry.Retry` that was used during the request. :param enforce_content_length: Enforce content length checking. Body returned by server must match value of Content-Length header, if present. Otherwise, raise error. """ CONTENT_DECODERS = ["gzip", "deflate"] if brotli is not None: CONTENT_DECODERS += ["br"] REDIRECT_STATUSES = [301, 302, 303, 307, 308] def __init__( self, body="", headers=None, status=0, version=0, reason=None, strict=0, preload_content=True, decode_content=True, original_response=None, pool=None, connection=None, msg=None, retries=None, enforce_content_length=False, request_method=None, request_url=None, auto_close=True, ): if isinstance(headers, HTTPHeaderDict): self.headers = headers else: self.headers = HTTPHeaderDict(headers) self.status = status self.version = version self.reason = reason self.strict = strict self.decode_content = decode_content self.retries = retries self.enforce_content_length = enforce_content_length self.auto_close = auto_close self._decoder = None self._body = None self._fp = None self._original_response = original_response self._fp_bytes_read = 0 self.msg = msg self._request_url = request_url if body and isinstance(body, (six.string_types, bytes)): self._body = body self._pool = pool self._connection = connection if hasattr(body, "read"): self._fp = body # Are we using the chunked-style of transfer encoding? self.chunked = False self.chunk_left = None tr_enc = self.headers.get("transfer-encoding", "").lower() # Don't incur the penalty of creating a list and then discarding it encodings = (enc.strip() for enc in tr_enc.split(",")) if "chunked" in encodings: self.chunked = True # Determine length of response self.length_remaining = self._init_length(request_method) # If requested, preload the body. if preload_content and not self._body: self._body = self.read(decode_content=decode_content) def get_redirect_location(self): """ Should we redirect and where to? :returns: Truthy redirect location string if we got a redirect status code and valid location. ``None`` if redirect status and no location. ``False`` if not a redirect status code. """ if self.status in self.REDIRECT_STATUSES: return self.headers.get("location") return False def release_conn(self): if not self._pool or not self._connection: return self._pool._put_conn(self._connection) self._connection = None def drain_conn(self): """ Read and discard any remaining HTTP response data in the response connection. Unread data in the HTTPResponse connection blocks the connection from being released back to the pool. """ try: self.read() except (HTTPError, SocketError, BaseSSLError, HTTPException): pass @property def data(self): # For backwards-compat with earlier urllib3 0.4 and earlier. if self._body: return self._body if self._fp: return self.read(cache_content=True) @property def connection(self): return self._connection def isclosed(self): return is_fp_closed(self._fp) def tell(self): """ Obtain the number of bytes pulled over the wire so far. May differ from the amount of content returned by :meth:``urllib3.response.HTTPResponse.read`` if bytes are encoded on the wire (e.g, compressed). """ return self._fp_bytes_read def _init_length(self, request_method): """ Set initial length value for Response content if available. """ length = self.headers.get("content-length") if length is not None: if self.chunked: # This Response will fail with an IncompleteRead if it can't be # received as chunked. This method falls back to attempt reading # the response before raising an exception. log.warning( "Received response with both Content-Length and " "Transfer-Encoding set. This is expressly forbidden " "by RFC 7230 sec 3.3.2. Ignoring Content-Length and " "attempting to process response as Transfer-Encoding: " "chunked." ) return None try: # RFC 7230 section 3.3.2 specifies multiple content lengths can # be sent in a single Content-Length header # (e.g. Content-Length: 42, 42). This line ensures the values # are all valid ints and that as long as the `set` length is 1, # all values are the same. Otherwise, the header is invalid. lengths = set([int(val) for val in length.split(",")]) if len(lengths) > 1: raise InvalidHeader( "Content-Length contained multiple " "unmatching values (%s)" % length ) length = lengths.pop() except ValueError: length = None else: if length < 0: length = None # Convert status to int for comparison # In some cases, httplib returns a status of "_UNKNOWN" try: status = int(self.status) except ValueError: status = 0 # Check for responses that shouldn't include a body if status in (204, 304) or 100 <= status < 200 or request_method == "HEAD": length = 0 return length def _init_decoder(self): """ Set-up the _decoder attribute if necessary. """ # Note: content-encoding value should be case-insensitive, per RFC 7230 # Section 3.2 content_encoding = self.headers.get("content-encoding", "").lower() if self._decoder is None: if content_encoding in self.CONTENT_DECODERS: self._decoder = _get_decoder(content_encoding) elif "," in content_encoding: encodings = [ e.strip() for e in content_encoding.split(",") if e.strip() in self.CONTENT_DECODERS ] if len(encodings): self._decoder = _get_decoder(content_encoding) DECODER_ERROR_CLASSES = (IOError, zlib.error) if brotli is not None: DECODER_ERROR_CLASSES += (brotli.error,) def _decode(self, data, decode_content, flush_decoder): """ Decode the data passed in and potentially flush the decoder. """ if not decode_content: return data try: if self._decoder: data = self._decoder.decompress(data) except self.DECODER_ERROR_CLASSES as e: content_encoding = self.headers.get("content-encoding", "").lower() raise DecodeError( "Received response with content-encoding: %s, but " "failed to decode it." % content_encoding, e, ) if flush_decoder: data += self._flush_decoder() return data def _flush_decoder(self): """ Flushes the decoder. Should only be called if the decoder is actually being used. """ if self._decoder: buf = self._decoder.decompress(b"") return buf + self._decoder.flush() return b"" @contextmanager def _error_catcher(self): """ Catch low-level python exceptions, instead re-raising urllib3 variants, so that low-level exceptions are not leaked in the high-level api. On exit, release the connection back to the pool. """ clean_exit = False try: try: yield except SocketTimeout: # FIXME: Ideally we'd like to include the url in the ReadTimeoutError but # there is yet no clean way to get at it from this context. raise ReadTimeoutError(self._pool, None, "Read timed out.") except BaseSSLError as e: # FIXME: Is there a better way to differentiate between SSLErrors? if "read operation timed out" not in str(e): # SSL errors related to framing/MAC get wrapped and reraised here raise SSLError(e) raise ReadTimeoutError(self._pool, None, "Read timed out.") except (HTTPException, SocketError) as e: # This includes IncompleteRead. raise ProtocolError("Connection broken: %r" % e, e) # If no exception is thrown, we should avoid cleaning up # unnecessarily. clean_exit = True finally: # If we didn't terminate cleanly, we need to throw away our # connection. if not clean_exit: # The response may not be closed but we're not going to use it # anymore so close it now to ensure that the connection is # released back to the pool. if self._original_response: self._original_response.close() # Closing the response may not actually be sufficient to close # everything, so if we have a hold of the connection close that # too. if self._connection: self._connection.close() # If we hold the original response but it's closed now, we should # return the connection back to the pool. if self._original_response and self._original_response.isclosed(): self.release_conn() def read(self, amt=None, decode_content=None, cache_content=False): """ Similar to :meth:`http.client.HTTPResponse.read`, but with two additional parameters: ``decode_content`` and ``cache_content``. :param amt: How much of the content to read. If specified, caching is skipped because it doesn't make sense to cache partial content as the full response. :param decode_content: If True, will attempt to decode the body based on the 'content-encoding' header. :param cache_content: If True, will save the returned data such that the same result is returned despite of the state of the underlying file object. This is useful if you want the ``.data`` property to continue working after having ``.read()`` the file object. (Overridden if ``amt`` is set.) """ self._init_decoder() if decode_content is None: decode_content = self.decode_content if self._fp is None: return flush_decoder = False fp_closed = getattr(self._fp, "closed", False) with self._error_catcher(): if amt is None: # cStringIO doesn't like amt=None data = self._fp.read() if not fp_closed else b"" flush_decoder = True else: cache_content = False data = self._fp.read(amt) if not fp_closed else b"" if ( amt != 0 and not data ): # Platform-specific: Buggy versions of Python. # Close the connection when no data is returned # # This is redundant to what httplib/http.client _should_ # already do. However, versions of python released before # December 15, 2012 (http://bugs.python.org/issue16298) do # not properly close the connection in all cases. There is # no harm in redundantly calling close. self._fp.close() flush_decoder = True if self.enforce_content_length and self.length_remaining not in ( 0, None, ): # This is an edge case that httplib failed to cover due # to concerns of backward compatibility. We're # addressing it here to make sure IncompleteRead is # raised during streaming, so all calls with incorrect # Content-Length are caught. raise IncompleteRead(self._fp_bytes_read, self.length_remaining) if data: self._fp_bytes_read += len(data) if self.length_remaining is not None: self.length_remaining -= len(data) data = self._decode(data, decode_content, flush_decoder) if cache_content: self._body = data return data def stream(self, amt=2 ** 16, decode_content=None): """ A generator wrapper for the read() method. A call will block until ``amt`` bytes have been read from the connection or until the connection is closed. :param amt: How much of the content to read. The generator will return up to much data per iteration, but may return less. This is particularly likely when using compressed data. However, the empty string will never be returned. :param decode_content: If True, will attempt to decode the body based on the 'content-encoding' header. """ if self.chunked and self.supports_chunked_reads(): for line in self.read_chunked(amt, decode_content=decode_content): yield line else: while not is_fp_closed(self._fp): data = self.read(amt=amt, decode_content=decode_content) if data: yield data @classmethod def from_httplib(ResponseCls, r, **response_kw): """ Given an :class:`http.client.HTTPResponse` instance ``r``, return a corresponding :class:`urllib3.response.HTTPResponse` object. Remaining parameters are passed to the HTTPResponse constructor, along with ``original_response=r``. """ headers = r.msg if not isinstance(headers, HTTPHeaderDict): if six.PY2: # Python 2.7 headers = HTTPHeaderDict.from_httplib(headers) else: headers = HTTPHeaderDict(headers.items()) # HTTPResponse objects in Python 3 don't have a .strict attribute strict = getattr(r, "strict", 0) resp = ResponseCls( body=r, headers=headers, status=r.status, version=r.version, reason=r.reason, strict=strict, original_response=r, **response_kw ) return resp # Backwards-compatibility methods for http.client.HTTPResponse def getheaders(self): return self.headers def getheader(self, name, default=None): return self.headers.get(name, default) # Backwards compatibility for http.cookiejar def info(self): return self.headers # Overrides from io.IOBase def close(self): if not self.closed: self._fp.close() if self._connection: self._connection.close() if not self.auto_close: io.IOBase.close(self) @property def closed(self): if not self.auto_close: return io.IOBase.closed.__get__(self) elif self._fp is None: return True elif hasattr(self._fp, "isclosed"): return self._fp.isclosed() elif hasattr(self._fp, "closed"): return self._fp.closed else: return True def fileno(self): if self._fp is None: raise IOError("HTTPResponse has no file to get a fileno from") elif hasattr(self._fp, "fileno"): return self._fp.fileno() else: raise IOError( "The file-like object this HTTPResponse is wrapped " "around has no file descriptor" ) def flush(self): if ( self._fp is not None and hasattr(self._fp, "flush") and not getattr(self._fp, "closed", False) ): return self._fp.flush() def readable(self): # This method is required for `io` module compatibility. return True def readinto(self, b): # This method is required for `io` module compatibility. temp = self.read(len(b)) if len(temp) == 0: return 0 else: b[: len(temp)] = temp return len(temp) def supports_chunked_reads(self): """ Checks if the underlying file-like object looks like a :class:`http.client.HTTPResponse` object. We do this by testing for the fp attribute. If it is present we assume it returns raw chunks as processed by read_chunked(). """ return hasattr(self._fp, "fp") def _update_chunk_length(self): # First, we'll figure out length of a chunk and then # we'll try to read it from socket. if self.chunk_left is not None: return line = self._fp.fp.readline() line = line.split(b";", 1)[0] try: self.chunk_left = int(line, 16) except ValueError: # Invalid chunked protocol response, abort. self.close() raise InvalidChunkLength(self, line) def _handle_chunk(self, amt): returned_chunk = None if amt is None: chunk = self._fp._safe_read(self.chunk_left) returned_chunk = chunk self._fp._safe_read(2) # Toss the CRLF at the end of the chunk. self.chunk_left = None elif amt < self.chunk_left: value = self._fp._safe_read(amt) self.chunk_left = self.chunk_left - amt returned_chunk = value elif amt == self.chunk_left: value = self._fp._safe_read(amt) self._fp._safe_read(2) # Toss the CRLF at the end of the chunk. self.chunk_left = None returned_chunk = value else: # amt > self.chunk_left returned_chunk = self._fp._safe_read(self.chunk_left) self._fp._safe_read(2) # Toss the CRLF at the end of the chunk. self.chunk_left = None return returned_chunk def read_chunked(self, amt=None, decode_content=None): """ Similar to :meth:`HTTPResponse.read`, but with an additional parameter: ``decode_content``. :param amt: How much of the content to read. If specified, caching is skipped because it doesn't make sense to cache partial content as the full response. :param decode_content: If True, will attempt to decode the body based on the 'content-encoding' header. """ self._init_decoder() # FIXME: Rewrite this method and make it a class with a better structured logic. if not self.chunked: raise ResponseNotChunked( "Response is not chunked. " "Header 'transfer-encoding: chunked' is missing." ) if not self.supports_chunked_reads(): raise BodyNotHttplibCompatible( "Body should be http.client.HTTPResponse like. " "It should have have an fp attribute which returns raw chunks." ) with self._error_catcher(): # Don't bother reading the body of a HEAD request. if self._original_response and is_response_to_head(self._original_response): self._original_response.close() return # If a response is already read and closed # then return immediately. if self._fp.fp is None: return while True: self._update_chunk_length() if self.chunk_left == 0: break chunk = self._handle_chunk(amt) decoded = self._decode( chunk, decode_content=decode_content, flush_decoder=False ) if decoded: yield decoded if decode_content: # On CPython and PyPy, we should never need to flush the # decoder. However, on Jython we *might* need to, so # lets defensively do it anyway. decoded = self._flush_decoder() if decoded: # Platform-specific: Jython. yield decoded # Chunk content ends with \r\n: discard it. while True: line = self._fp.fp.readline() if not line: # Some sites may not end with '\r\n'. break if line == b"\r\n": break # We read everything; close the "file". if self._original_response: self._original_response.close() def geturl(self): """ Returns the URL that was the source of this response. If the request that generated this response redirected, this method will return the final redirect location. """ if self.retries is not None and len(self.retries.history): return self.retries.history[-1].redirect_location else: return self._request_url def __iter__(self): buffer = [] for chunk in self.stream(decode_content=True): if b"\n" in chunk: chunk = chunk.split(b"\n") yield b"".join(buffer) + chunk[0] + b"\n" for x in chunk[1:-1]: yield x + b"\n" if chunk[-1]: buffer = [chunk[-1]] else: buffer = [] else: buffer.append(chunk) if buffer: yield b"".join(buffer) ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/util/__init__.py ================================================ from __future__ import absolute_import # For backwards compatibility, provide imports that used to be here. from .connection import is_connection_dropped from .request import SKIP_HEADER, SKIPPABLE_HEADERS, make_headers from .response import is_fp_closed from .retry import Retry from .ssl_ import ( ALPN_PROTOCOLS, HAS_SNI, IS_PYOPENSSL, IS_SECURETRANSPORT, PROTOCOL_TLS, SSLContext, assert_fingerprint, resolve_cert_reqs, resolve_ssl_version, ssl_wrap_socket, ) from .timeout import Timeout, current_time from .url import Url, get_host, parse_url, split_first from .wait import wait_for_read, wait_for_write __all__ = ( "HAS_SNI", "IS_PYOPENSSL", "IS_SECURETRANSPORT", "SSLContext", "PROTOCOL_TLS", "ALPN_PROTOCOLS", "Retry", "Timeout", "Url", "assert_fingerprint", "current_time", "is_connection_dropped", "is_fp_closed", "get_host", "parse_url", "make_headers", "resolve_cert_reqs", "resolve_ssl_version", "split_first", "ssl_wrap_socket", "wait_for_read", "wait_for_write", "SKIP_HEADER", "SKIPPABLE_HEADERS", ) ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/util/connection.py ================================================ from __future__ import absolute_import import socket from urllib3.exceptions import LocationParseError from ..contrib import _appengine_environ from ..packages import six from .wait import NoWayToWaitForSocketError, wait_for_read def is_connection_dropped(conn): # Platform-specific """ Returns True if the connection is dropped and should be closed. :param conn: :class:`http.client.HTTPConnection` object. Note: For platforms like AppEngine, this will always return ``False`` to let the platform handle connection recycling transparently for us. """ sock = getattr(conn, "sock", False) if sock is False: # Platform-specific: AppEngine return False if sock is None: # Connection already closed (such as by httplib). return True try: # Returns True if readable, which here means it's been dropped return wait_for_read(sock, timeout=0.0) except NoWayToWaitForSocketError: # Platform-specific: AppEngine return False # This function is copied from socket.py in the Python 2.7 standard # library test suite. Added to its signature is only `socket_options`. # One additional modification is that we avoid binding to IPv6 servers # discovered in DNS if the system doesn't have IPv6 functionality. def create_connection( address, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, source_address=None, socket_options=None, ): """Connect to *address* and return the socket object. Convenience function. Connect to *address* (a 2-tuple ``(host, port)``) and return the socket object. Passing the optional *timeout* parameter will set the timeout on the socket instance before attempting to connect. If no *timeout* is supplied, the global default timeout setting returned by :func:`socket.getdefaulttimeout` is used. If *source_address* is set it must be a tuple of (host, port) for the socket to bind as a source address before making the connection. An host of '' or port 0 tells the OS to use the default. """ host, port = address if host.startswith("["): host = host.strip("[]") err = None # Using the value from allowed_gai_family() in the context of getaddrinfo lets # us select whether to work with IPv4 DNS records, IPv6 records, or both. # The original create_connection function always returns all records. family = allowed_gai_family() try: host.encode("idna") except UnicodeError: return six.raise_from( LocationParseError(u"'%s', label empty or too long" % host), None ) for res in socket.getaddrinfo(host, port, family, socket.SOCK_STREAM): af, socktype, proto, canonname, sa = res sock = None try: sock = socket.socket(af, socktype, proto) # If provided, set socket level options before connecting. _set_socket_options(sock, socket_options) if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT: sock.settimeout(timeout) if source_address: sock.bind(source_address) sock.connect(sa) return sock except socket.error as e: err = e if sock is not None: sock.close() sock = None if err is not None: raise err raise socket.error("getaddrinfo returns an empty list") def _set_socket_options(sock, options): if options is None: return for opt in options: sock.setsockopt(*opt) def allowed_gai_family(): """This function is designed to work in the context of getaddrinfo, where family=socket.AF_UNSPEC is the default and will perform a DNS search for both IPv6 and IPv4 records.""" family = socket.AF_INET if HAS_IPV6: family = socket.AF_UNSPEC return family def _has_ipv6(host): """Returns True if the system can bind an IPv6 address.""" sock = None has_ipv6 = False # App Engine doesn't support IPV6 sockets and actually has a quota on the # number of sockets that can be used, so just early out here instead of # creating a socket needlessly. # See https://github.com/urllib3/urllib3/issues/1446 if _appengine_environ.is_appengine_sandbox(): return False if socket.has_ipv6: # has_ipv6 returns true if cPython was compiled with IPv6 support. # It does not tell us if the system has IPv6 support enabled. To # determine that we must bind to an IPv6 address. # https://github.com/urllib3/urllib3/pull/611 # https://bugs.python.org/issue658327 try: sock = socket.socket(socket.AF_INET6) sock.bind((host, 0)) has_ipv6 = True except Exception: pass if sock: sock.close() return has_ipv6 HAS_IPV6 = _has_ipv6("::1") ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/util/proxy.py ================================================ from .ssl_ import create_urllib3_context, resolve_cert_reqs, resolve_ssl_version def connection_requires_http_tunnel( proxy_url=None, proxy_config=None, destination_scheme=None ): """ Returns True if the connection requires an HTTP CONNECT through the proxy. :param URL proxy_url: URL of the proxy. :param ProxyConfig proxy_config: Proxy configuration from poolmanager.py :param str destination_scheme: The scheme of the destination. (i.e https, http, etc) """ # If we're not using a proxy, no way to use a tunnel. if proxy_url is None: return False # HTTP destinations never require tunneling, we always forward. if destination_scheme == "http": return False # Support for forwarding with HTTPS proxies and HTTPS destinations. if ( proxy_url.scheme == "https" and proxy_config and proxy_config.use_forwarding_for_https ): return False # Otherwise always use a tunnel. return True def create_proxy_ssl_context( ssl_version, cert_reqs, ca_certs=None, ca_cert_dir=None, ca_cert_data=None ): """ Generates a default proxy ssl context if one hasn't been provided by the user. """ ssl_context = create_urllib3_context( ssl_version=resolve_ssl_version(ssl_version), cert_reqs=resolve_cert_reqs(cert_reqs), ) if ( not ca_certs and not ca_cert_dir and not ca_cert_data and hasattr(ssl_context, "load_default_certs") ): ssl_context.load_default_certs() return ssl_context ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/util/queue.py ================================================ import collections from ..packages import six from ..packages.six.moves import queue if six.PY2: # Queue is imported for side effects on MS Windows. See issue #229. import Queue as _unused_module_Queue # noqa: F401 class LifoQueue(queue.Queue): def _init(self, _): self.queue = collections.deque() def _qsize(self, len=len): return len(self.queue) def _put(self, item): self.queue.append(item) def _get(self): return self.queue.pop() ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/util/request.py ================================================ from __future__ import absolute_import from base64 import b64encode from ..exceptions import UnrewindableBodyError from ..packages.six import b, integer_types # Pass as a value within ``headers`` to skip # emitting some HTTP headers that are added automatically. # The only headers that are supported are ``Accept-Encoding``, # ``Host``, and ``User-Agent``. SKIP_HEADER = "@@@SKIP_HEADER@@@" SKIPPABLE_HEADERS = frozenset(["accept-encoding", "host", "user-agent"]) ACCEPT_ENCODING = "gzip,deflate" try: import brotli as _unused_module_brotli # noqa: F401 except ImportError: pass else: ACCEPT_ENCODING += ",br" _FAILEDTELL = object() def make_headers( keep_alive=None, accept_encoding=None, user_agent=None, basic_auth=None, proxy_basic_auth=None, disable_cache=None, ): """ Shortcuts for generating request headers. :param keep_alive: If ``True``, adds 'connection: keep-alive' header. :param accept_encoding: Can be a boolean, list, or string. ``True`` translates to 'gzip,deflate'. List will get joined by comma. String will be used as provided. :param user_agent: String representing the user-agent you want, such as "python-urllib3/0.6" :param basic_auth: Colon-separated username:password string for 'authorization: basic ...' auth header. :param proxy_basic_auth: Colon-separated username:password string for 'proxy-authorization: basic ...' auth header. :param disable_cache: If ``True``, adds 'cache-control: no-cache' header. Example:: >>> make_headers(keep_alive=True, user_agent="Batman/1.0") {'connection': 'keep-alive', 'user-agent': 'Batman/1.0'} >>> make_headers(accept_encoding=True) {'accept-encoding': 'gzip,deflate'} """ headers = {} if accept_encoding: if isinstance(accept_encoding, str): pass elif isinstance(accept_encoding, list): accept_encoding = ",".join(accept_encoding) else: accept_encoding = ACCEPT_ENCODING headers["accept-encoding"] = accept_encoding if user_agent: headers["user-agent"] = user_agent if keep_alive: headers["connection"] = "keep-alive" if basic_auth: headers["authorization"] = "Basic " + b64encode(b(basic_auth)).decode("utf-8") if proxy_basic_auth: headers["proxy-authorization"] = "Basic " + b64encode( b(proxy_basic_auth) ).decode("utf-8") if disable_cache: headers["cache-control"] = "no-cache" return headers def set_file_position(body, pos): """ If a position is provided, move file to that point. Otherwise, we'll attempt to record a position for future use. """ if pos is not None: rewind_body(body, pos) elif getattr(body, "tell", None) is not None: try: pos = body.tell() except (IOError, OSError): # This differentiates from None, allowing us to catch # a failed `tell()` later when trying to rewind the body. pos = _FAILEDTELL return pos def rewind_body(body, body_pos): """ Attempt to rewind body to a certain position. Primarily used for request redirects and retries. :param body: File-like object that supports seek. :param int pos: Position to seek to in file. """ body_seek = getattr(body, "seek", None) if body_seek is not None and isinstance(body_pos, integer_types): try: body_seek(body_pos) except (IOError, OSError): raise UnrewindableBodyError( "An error occurred when rewinding request body for redirect/retry." ) elif body_pos is _FAILEDTELL: raise UnrewindableBodyError( "Unable to record file position for rewinding " "request body during a redirect/retry." ) else: raise ValueError( "body_pos must be of type integer, instead it was %s." % type(body_pos) ) ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/util/response.py ================================================ from __future__ import absolute_import from email.errors import MultipartInvariantViolationDefect, StartBoundaryNotFoundDefect from ..exceptions import HeaderParsingError from ..packages.six.moves import http_client as httplib def is_fp_closed(obj): """ Checks whether a given file-like object is closed. :param obj: The file-like object to check. """ try: # Check `isclosed()` first, in case Python3 doesn't set `closed`. # GH Issue #928 return obj.isclosed() except AttributeError: pass try: # Check via the official file-like-object way. return obj.closed except AttributeError: pass try: # Check if the object is a container for another file-like object that # gets released on exhaustion (e.g. HTTPResponse). return obj.fp is None except AttributeError: pass raise ValueError("Unable to determine whether fp is closed.") def assert_header_parsing(headers): """ Asserts whether all headers have been successfully parsed. Extracts encountered errors from the result of parsing headers. Only works on Python 3. :param http.client.HTTPMessage headers: Headers to verify. :raises urllib3.exceptions.HeaderParsingError: If parsing errors are found. """ # This will fail silently if we pass in the wrong kind of parameter. # To make debugging easier add an explicit check. if not isinstance(headers, httplib.HTTPMessage): raise TypeError("expected httplib.Message, got {0}.".format(type(headers))) defects = getattr(headers, "defects", None) get_payload = getattr(headers, "get_payload", None) unparsed_data = None if get_payload: # get_payload is actually email.message.Message.get_payload; # we're only interested in the result if it's not a multipart message if not headers.is_multipart(): payload = get_payload() if isinstance(payload, (bytes, str)): unparsed_data = payload if defects: # httplib is assuming a response body is available # when parsing headers even when httplib only sends # header data to parse_headers() This results in # defects on multipart responses in particular. # See: https://github.com/urllib3/urllib3/issues/800 # So we ignore the following defects: # - StartBoundaryNotFoundDefect: # The claimed start boundary was never found. # - MultipartInvariantViolationDefect: # A message claimed to be a multipart but no subparts were found. defects = [ defect for defect in defects if not isinstance( defect, (StartBoundaryNotFoundDefect, MultipartInvariantViolationDefect) ) ] if defects or unparsed_data: raise HeaderParsingError(defects=defects, unparsed_data=unparsed_data) def is_response_to_head(response): """ Checks whether the request of a response has been a HEAD-request. Handles the quirks of AppEngine. :param http.client.HTTPResponse response: Response to check if the originating request used 'HEAD' as a method. """ # FIXME: Can we do this somehow without accessing private httplib _method? method = response._method if isinstance(method, int): # Platform-specific: Appengine return method == 3 return method.upper() == "HEAD" ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/util/retry.py ================================================ from __future__ import absolute_import import email import logging import re import time import warnings from collections import namedtuple from itertools import takewhile from ..exceptions import ( ConnectTimeoutError, InvalidHeader, MaxRetryError, ProtocolError, ProxyError, ReadTimeoutError, ResponseError, ) from ..packages import six log = logging.getLogger(__name__) # Data structure for representing the metadata of requests that result in a retry. RequestHistory = namedtuple( "RequestHistory", ["method", "url", "error", "status", "redirect_location"] ) # TODO: In v2 we can remove this sentinel and metaclass with deprecated options. _Default = object() class _RetryMeta(type): @property def DEFAULT_METHOD_WHITELIST(cls): warnings.warn( "Using 'Retry.DEFAULT_METHOD_WHITELIST' is deprecated and " "will be removed in v2.0. Use 'Retry.DEFAULT_ALLOWED_METHODS' instead", DeprecationWarning, ) return cls.DEFAULT_ALLOWED_METHODS @DEFAULT_METHOD_WHITELIST.setter def DEFAULT_METHOD_WHITELIST(cls, value): warnings.warn( "Using 'Retry.DEFAULT_METHOD_WHITELIST' is deprecated and " "will be removed in v2.0. Use 'Retry.DEFAULT_ALLOWED_METHODS' instead", DeprecationWarning, ) cls.DEFAULT_ALLOWED_METHODS = value @property def DEFAULT_REDIRECT_HEADERS_BLACKLIST(cls): warnings.warn( "Using 'Retry.DEFAULT_REDIRECT_HEADERS_BLACKLIST' is deprecated and " "will be removed in v2.0. Use 'Retry.DEFAULT_REMOVE_HEADERS_ON_REDIRECT' instead", DeprecationWarning, ) return cls.DEFAULT_REMOVE_HEADERS_ON_REDIRECT @DEFAULT_REDIRECT_HEADERS_BLACKLIST.setter def DEFAULT_REDIRECT_HEADERS_BLACKLIST(cls, value): warnings.warn( "Using 'Retry.DEFAULT_REDIRECT_HEADERS_BLACKLIST' is deprecated and " "will be removed in v2.0. Use 'Retry.DEFAULT_REMOVE_HEADERS_ON_REDIRECT' instead", DeprecationWarning, ) cls.DEFAULT_REMOVE_HEADERS_ON_REDIRECT = value @six.add_metaclass(_RetryMeta) class Retry(object): """Retry configuration. Each retry attempt will create a new Retry object with updated values, so they can be safely reused. Retries can be defined as a default for a pool:: retries = Retry(connect=5, read=2, redirect=5) http = PoolManager(retries=retries) response = http.request('GET', 'http://example.com/') Or per-request (which overrides the default for the pool):: response = http.request('GET', 'http://example.com/', retries=Retry(10)) Retries can be disabled by passing ``False``:: response = http.request('GET', 'http://example.com/', retries=False) Errors will be wrapped in :class:`~urllib3.exceptions.MaxRetryError` unless retries are disabled, in which case the causing exception will be raised. :param int total: Total number of retries to allow. Takes precedence over other counts. Set to ``None`` to remove this constraint and fall back on other counts. Set to ``0`` to fail on the first retry. Set to ``False`` to disable and imply ``raise_on_redirect=False``. :param int connect: How many connection-related errors to retry on. These are errors raised before the request is sent to the remote server, which we assume has not triggered the server to process the request. Set to ``0`` to fail on the first retry of this type. :param int read: How many times to retry on read errors. These errors are raised after the request was sent to the server, so the request may have side-effects. Set to ``0`` to fail on the first retry of this type. :param int redirect: How many redirects to perform. Limit this to avoid infinite redirect loops. A redirect is a HTTP response with a status code 301, 302, 303, 307 or 308. Set to ``0`` to fail on the first retry of this type. Set to ``False`` to disable and imply ``raise_on_redirect=False``. :param int status: How many times to retry on bad status codes. These are retries made on responses, where status code matches ``status_forcelist``. Set to ``0`` to fail on the first retry of this type. :param int other: How many times to retry on other errors. Other errors are errors that are not connect, read, redirect or status errors. These errors might be raised after the request was sent to the server, so the request might have side-effects. Set to ``0`` to fail on the first retry of this type. If ``total`` is not set, it's a good idea to set this to 0 to account for unexpected edge cases and avoid infinite retry loops. :param iterable allowed_methods: Set of uppercased HTTP method verbs that we should retry on. By default, we only retry on methods which are considered to be idempotent (multiple requests with the same parameters end with the same state). See :attr:`Retry.DEFAULT_ALLOWED_METHODS`. Set to a ``False`` value to retry on any verb. .. warning:: Previously this parameter was named ``method_whitelist``, that usage is deprecated in v1.26.0 and will be removed in v2.0. :param iterable status_forcelist: A set of integer HTTP status codes that we should force a retry on. A retry is initiated if the request method is in ``allowed_methods`` and the response status code is in ``status_forcelist``. By default, this is disabled with ``None``. :param float backoff_factor: A backoff factor to apply between attempts after the second try (most errors are resolved immediately by a second try without a delay). urllib3 will sleep for:: {backoff factor} * (2 ** ({number of total retries} - 1)) seconds. If the backoff_factor is 0.1, then :func:`.sleep` will sleep for [0.0s, 0.2s, 0.4s, ...] between retries. It will never be longer than :attr:`Retry.BACKOFF_MAX`. By default, backoff is disabled (set to 0). :param bool raise_on_redirect: Whether, if the number of redirects is exhausted, to raise a MaxRetryError, or to return a response with a response code in the 3xx range. :param bool raise_on_status: Similar meaning to ``raise_on_redirect``: whether we should raise an exception, or return a response, if status falls in ``status_forcelist`` range and retries have been exhausted. :param tuple history: The history of the request encountered during each call to :meth:`~Retry.increment`. The list is in the order the requests occurred. Each list item is of class :class:`RequestHistory`. :param bool respect_retry_after_header: Whether to respect Retry-After header on status codes defined as :attr:`Retry.RETRY_AFTER_STATUS_CODES` or not. :param iterable remove_headers_on_redirect: Sequence of headers to remove from the request when a response indicating a redirect is returned before firing off the redirected request. """ #: Default methods to be used for ``allowed_methods`` DEFAULT_ALLOWED_METHODS = frozenset( ["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE"] ) #: Default status codes to be used for ``status_forcelist`` RETRY_AFTER_STATUS_CODES = frozenset([413, 429, 503]) #: Default headers to be used for ``remove_headers_on_redirect`` DEFAULT_REMOVE_HEADERS_ON_REDIRECT = frozenset(["Authorization"]) #: Maximum backoff time. BACKOFF_MAX = 120 def __init__( self, total=10, connect=None, read=None, redirect=None, status=None, other=None, allowed_methods=_Default, status_forcelist=None, backoff_factor=0, raise_on_redirect=True, raise_on_status=True, history=None, respect_retry_after_header=True, remove_headers_on_redirect=_Default, # TODO: Deprecated, remove in v2.0 method_whitelist=_Default, ): if method_whitelist is not _Default: if allowed_methods is not _Default: raise ValueError( "Using both 'allowed_methods' and " "'method_whitelist' together is not allowed. " "Instead only use 'allowed_methods'" ) warnings.warn( "Using 'method_whitelist' with Retry is deprecated and " "will be removed in v2.0. Use 'allowed_methods' instead", DeprecationWarning, stacklevel=2, ) allowed_methods = method_whitelist if allowed_methods is _Default: allowed_methods = self.DEFAULT_ALLOWED_METHODS if remove_headers_on_redirect is _Default: remove_headers_on_redirect = self.DEFAULT_REMOVE_HEADERS_ON_REDIRECT self.total = total self.connect = connect self.read = read self.status = status self.other = other if redirect is False or total is False: redirect = 0 raise_on_redirect = False self.redirect = redirect self.status_forcelist = status_forcelist or set() self.allowed_methods = allowed_methods self.backoff_factor = backoff_factor self.raise_on_redirect = raise_on_redirect self.raise_on_status = raise_on_status self.history = history or tuple() self.respect_retry_after_header = respect_retry_after_header self.remove_headers_on_redirect = frozenset( [h.lower() for h in remove_headers_on_redirect] ) def new(self, **kw): params = dict( total=self.total, connect=self.connect, read=self.read, redirect=self.redirect, status=self.status, other=self.other, status_forcelist=self.status_forcelist, backoff_factor=self.backoff_factor, raise_on_redirect=self.raise_on_redirect, raise_on_status=self.raise_on_status, history=self.history, remove_headers_on_redirect=self.remove_headers_on_redirect, respect_retry_after_header=self.respect_retry_after_header, ) # TODO: If already given in **kw we use what's given to us # If not given we need to figure out what to pass. We decide # based on whether our class has the 'method_whitelist' property # and if so we pass the deprecated 'method_whitelist' otherwise # we use 'allowed_methods'. Remove in v2.0 if "method_whitelist" not in kw and "allowed_methods" not in kw: if "method_whitelist" in self.__dict__: warnings.warn( "Using 'method_whitelist' with Retry is deprecated and " "will be removed in v2.0. Use 'allowed_methods' instead", DeprecationWarning, ) params["method_whitelist"] = self.allowed_methods else: params["allowed_methods"] = self.allowed_methods params.update(kw) return type(self)(**params) @classmethod def from_int(cls, retries, redirect=True, default=None): """Backwards-compatibility for the old retries format.""" if retries is None: retries = default if default is not None else cls.DEFAULT if isinstance(retries, Retry): return retries redirect = bool(redirect) and None new_retries = cls(retries, redirect=redirect) log.debug("Converted retries value: %r -> %r", retries, new_retries) return new_retries def get_backoff_time(self): """Formula for computing the current backoff :rtype: float """ # We want to consider only the last consecutive errors sequence (Ignore redirects). consecutive_errors_len = len( list( takewhile(lambda x: x.redirect_location is None, reversed(self.history)) ) ) if consecutive_errors_len <= 1: return 0 backoff_value = self.backoff_factor * (2 ** (consecutive_errors_len - 1)) return min(self.BACKOFF_MAX, backoff_value) def parse_retry_after(self, retry_after): # Whitespace: https://tools.ietf.org/html/rfc7230#section-3.2.4 if re.match(r"^\s*[0-9]+\s*$", retry_after): seconds = int(retry_after) else: retry_date_tuple = email.utils.parsedate_tz(retry_after) if retry_date_tuple is None: raise InvalidHeader("Invalid Retry-After header: %s" % retry_after) if retry_date_tuple[9] is None: # Python 2 # Assume UTC if no timezone was specified # On Python2.7, parsedate_tz returns None for a timezone offset # instead of 0 if no timezone is given, where mktime_tz treats # a None timezone offset as local time. retry_date_tuple = retry_date_tuple[:9] + (0,) + retry_date_tuple[10:] retry_date = email.utils.mktime_tz(retry_date_tuple) seconds = retry_date - time.time() if seconds < 0: seconds = 0 return seconds def get_retry_after(self, response): """Get the value of Retry-After in seconds.""" retry_after = response.getheader("Retry-After") if retry_after is None: return None return self.parse_retry_after(retry_after) def sleep_for_retry(self, response=None): retry_after = self.get_retry_after(response) if retry_after: time.sleep(retry_after) return True return False def _sleep_backoff(self): backoff = self.get_backoff_time() if backoff <= 0: return time.sleep(backoff) def sleep(self, response=None): """Sleep between retry attempts. This method will respect a server's ``Retry-After`` response header and sleep the duration of the time requested. If that is not present, it will use an exponential backoff. By default, the backoff factor is 0 and this method will return immediately. """ if self.respect_retry_after_header and response: slept = self.sleep_for_retry(response) if slept: return self._sleep_backoff() def _is_connection_error(self, err): """Errors when we're fairly sure that the server did not receive the request, so it should be safe to retry. """ if isinstance(err, ProxyError): err = err.original_error return isinstance(err, ConnectTimeoutError) def _is_read_error(self, err): """Errors that occur after the request has been started, so we should assume that the server began processing it. """ return isinstance(err, (ReadTimeoutError, ProtocolError)) def _is_method_retryable(self, method): """Checks if a given HTTP method should be retried upon, depending if it is included in the allowed_methods """ # TODO: For now favor if the Retry implementation sets its own method_whitelist # property outside of our constructor to avoid breaking custom implementations. if "method_whitelist" in self.__dict__: warnings.warn( "Using 'method_whitelist' with Retry is deprecated and " "will be removed in v2.0. Use 'allowed_methods' instead", DeprecationWarning, ) allowed_methods = self.method_whitelist else: allowed_methods = self.allowed_methods if allowed_methods and method.upper() not in allowed_methods: return False return True def is_retry(self, method, status_code, has_retry_after=False): """Is this method/status code retryable? (Based on allowlists and control variables such as the number of total retries to allow, whether to respect the Retry-After header, whether this header is present, and whether the returned status code is on the list of status codes to be retried upon on the presence of the aforementioned header) """ if not self._is_method_retryable(method): return False if self.status_forcelist and status_code in self.status_forcelist: return True return ( self.total and self.respect_retry_after_header and has_retry_after and (status_code in self.RETRY_AFTER_STATUS_CODES) ) def is_exhausted(self): """Are we out of retries?""" retry_counts = ( self.total, self.connect, self.read, self.redirect, self.status, self.other, ) retry_counts = list(filter(None, retry_counts)) if not retry_counts: return False return min(retry_counts) < 0 def increment( self, method=None, url=None, response=None, error=None, _pool=None, _stacktrace=None, ): """Return a new Retry object with incremented retry counters. :param response: A response object, or None, if the server did not return a response. :type response: :class:`~urllib3.response.HTTPResponse` :param Exception error: An error encountered during the request, or None if the response was received successfully. :return: A new ``Retry`` object. """ if self.total is False and error: # Disabled, indicate to re-raise the error. raise six.reraise(type(error), error, _stacktrace) total = self.total if total is not None: total -= 1 connect = self.connect read = self.read redirect = self.redirect status_count = self.status other = self.other cause = "unknown" status = None redirect_location = None if error and self._is_connection_error(error): # Connect retry? if connect is False: raise six.reraise(type(error), error, _stacktrace) elif connect is not None: connect -= 1 elif error and self._is_read_error(error): # Read retry? if read is False or not self._is_method_retryable(method): raise six.reraise(type(error), error, _stacktrace) elif read is not None: read -= 1 elif error: # Other retry? if other is not None: other -= 1 elif response and response.get_redirect_location(): # Redirect retry? if redirect is not None: redirect -= 1 cause = "too many redirects" redirect_location = response.get_redirect_location() status = response.status else: # Incrementing because of a server error like a 500 in # status_forcelist and the given method is in the allowed_methods cause = ResponseError.GENERIC_ERROR if response and response.status: if status_count is not None: status_count -= 1 cause = ResponseError.SPECIFIC_ERROR.format(status_code=response.status) status = response.status history = self.history + ( RequestHistory(method, url, error, status, redirect_location), ) new_retry = self.new( total=total, connect=connect, read=read, redirect=redirect, status=status_count, other=other, history=history, ) if new_retry.is_exhausted(): raise MaxRetryError(_pool, url, error or ResponseError(cause)) log.debug("Incremented Retry for (url='%s'): %r", url, new_retry) return new_retry def __repr__(self): return ( "{cls.__name__}(total={self.total}, connect={self.connect}, " "read={self.read}, redirect={self.redirect}, status={self.status})" ).format(cls=type(self), self=self) def __getattr__(self, item): if item == "method_whitelist": # TODO: Remove this deprecated alias in v2.0 warnings.warn( "Using 'method_whitelist' with Retry is deprecated and " "will be removed in v2.0. Use 'allowed_methods' instead", DeprecationWarning, ) return self.allowed_methods try: return getattr(super(Retry, self), item) except AttributeError: return getattr(Retry, item) # For backwards compatibility (equivalent to pre-v1.9): Retry.DEFAULT = Retry(3) ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/util/ssl_.py ================================================ from __future__ import absolute_import import hmac import os import sys import warnings from binascii import hexlify, unhexlify from hashlib import md5, sha1, sha256 from ..exceptions import ( InsecurePlatformWarning, ProxySchemeUnsupported, SNIMissingWarning, SSLError, ) from ..packages import six from .url import BRACELESS_IPV6_ADDRZ_RE, IPV4_RE SSLContext = None SSLTransport = None HAS_SNI = False IS_PYOPENSSL = False IS_SECURETRANSPORT = False ALPN_PROTOCOLS = ["http/1.1"] # Maps the length of a digest to a possible hash function producing this digest HASHFUNC_MAP = {32: md5, 40: sha1, 64: sha256} def _const_compare_digest_backport(a, b): """ Compare two digests of equal length in constant time. The digests must be of type str/bytes. Returns True if the digests match, and False otherwise. """ result = abs(len(a) - len(b)) for left, right in zip(bytearray(a), bytearray(b)): result |= left ^ right return result == 0 _const_compare_digest = getattr(hmac, "compare_digest", _const_compare_digest_backport) try: # Test for SSL features import ssl from ssl import CERT_REQUIRED, wrap_socket except ImportError: pass try: from ssl import HAS_SNI # Has SNI? except ImportError: pass try: from .ssltransport import SSLTransport except ImportError: pass try: # Platform-specific: Python 3.6 from ssl import PROTOCOL_TLS PROTOCOL_SSLv23 = PROTOCOL_TLS except ImportError: try: from ssl import PROTOCOL_SSLv23 as PROTOCOL_TLS PROTOCOL_SSLv23 = PROTOCOL_TLS except ImportError: PROTOCOL_SSLv23 = PROTOCOL_TLS = 2 try: from ssl import PROTOCOL_TLS_CLIENT except ImportError: PROTOCOL_TLS_CLIENT = PROTOCOL_TLS try: from ssl import OP_NO_COMPRESSION, OP_NO_SSLv2, OP_NO_SSLv3 except ImportError: OP_NO_SSLv2, OP_NO_SSLv3 = 0x1000000, 0x2000000 OP_NO_COMPRESSION = 0x20000 try: # OP_NO_TICKET was added in Python 3.6 from ssl import OP_NO_TICKET except ImportError: OP_NO_TICKET = 0x4000 # A secure default. # Sources for more information on TLS ciphers: # # - https://wiki.mozilla.org/Security/Server_Side_TLS # - https://www.ssllabs.com/projects/best-practices/index.html # - https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/ # # The general intent is: # - prefer cipher suites that offer perfect forward secrecy (DHE/ECDHE), # - prefer ECDHE over DHE for better performance, # - prefer any AES-GCM and ChaCha20 over any AES-CBC for better performance and # security, # - prefer AES-GCM over ChaCha20 because hardware-accelerated AES is common, # - disable NULL authentication, MD5 MACs, DSS, and other # insecure ciphers for security reasons. # - NOTE: TLS 1.3 cipher suites are managed through a different interface # not exposed by CPython (yet!) and are enabled by default if they're available. DEFAULT_CIPHERS = ":".join( [ "ECDHE+AESGCM", "ECDHE+CHACHA20", "DHE+AESGCM", "DHE+CHACHA20", "ECDH+AESGCM", "DH+AESGCM", "ECDH+AES", "DH+AES", "RSA+AESGCM", "RSA+AES", "!aNULL", "!eNULL", "!MD5", "!DSS", ] ) try: from ssl import SSLContext # Modern SSL? except ImportError: class SSLContext(object): # Platform-specific: Python 2 def __init__(self, protocol_version): self.protocol = protocol_version # Use default values from a real SSLContext self.check_hostname = False self.verify_mode = ssl.CERT_NONE self.ca_certs = None self.options = 0 self.certfile = None self.keyfile = None self.ciphers = None def load_cert_chain(self, certfile, keyfile): self.certfile = certfile self.keyfile = keyfile def load_verify_locations(self, cafile=None, capath=None, cadata=None): self.ca_certs = cafile if capath is not None: raise SSLError("CA directories not supported in older Pythons") if cadata is not None: raise SSLError("CA data not supported in older Pythons") def set_ciphers(self, cipher_suite): self.ciphers = cipher_suite def wrap_socket(self, socket, server_hostname=None, server_side=False): warnings.warn( "A true SSLContext object is not available. This prevents " "urllib3 from configuring SSL appropriately and may cause " "certain SSL connections to fail. You can upgrade to a newer " "version of Python to solve this. For more information, see " "https://urllib3.readthedocs.io/en/1.26.x/advanced-usage.html" "#ssl-warnings", InsecurePlatformWarning, ) kwargs = { "keyfile": self.keyfile, "certfile": self.certfile, "ca_certs": self.ca_certs, "cert_reqs": self.verify_mode, "ssl_version": self.protocol, "server_side": server_side, } return wrap_socket(socket, ciphers=self.ciphers, **kwargs) def assert_fingerprint(cert, fingerprint): """ Checks if given fingerprint matches the supplied certificate. :param cert: Certificate as bytes object. :param fingerprint: Fingerprint as string of hexdigits, can be interspersed by colons. """ fingerprint = fingerprint.replace(":", "").lower() digest_length = len(fingerprint) hashfunc = HASHFUNC_MAP.get(digest_length) if not hashfunc: raise SSLError("Fingerprint of invalid length: {0}".format(fingerprint)) # We need encode() here for py32; works on py2 and p33. fingerprint_bytes = unhexlify(fingerprint.encode()) cert_digest = hashfunc(cert).digest() if not _const_compare_digest(cert_digest, fingerprint_bytes): raise SSLError( 'Fingerprints did not match. Expected "{0}", got "{1}".'.format( fingerprint, hexlify(cert_digest) ) ) def resolve_cert_reqs(candidate): """ Resolves the argument to a numeric constant, which can be passed to the wrap_socket function/method from the ssl module. Defaults to :data:`ssl.CERT_REQUIRED`. If given a string it is assumed to be the name of the constant in the :mod:`ssl` module or its abbreviation. (So you can specify `REQUIRED` instead of `CERT_REQUIRED`. If it's neither `None` nor a string we assume it is already the numeric constant which can directly be passed to wrap_socket. """ if candidate is None: return CERT_REQUIRED if isinstance(candidate, str): res = getattr(ssl, candidate, None) if res is None: res = getattr(ssl, "CERT_" + candidate) return res return candidate def resolve_ssl_version(candidate): """ like resolve_cert_reqs """ if candidate is None: return PROTOCOL_TLS if isinstance(candidate, str): res = getattr(ssl, candidate, None) if res is None: res = getattr(ssl, "PROTOCOL_" + candidate) return res return candidate def create_urllib3_context( ssl_version=None, cert_reqs=None, options=None, ciphers=None ): """All arguments have the same meaning as ``ssl_wrap_socket``. By default, this function does a lot of the same work that ``ssl.create_default_context`` does on Python 3.4+. It: - Disables SSLv2, SSLv3, and compression - Sets a restricted set of server ciphers If you wish to enable SSLv3, you can do:: from urllib3.util import ssl_ context = ssl_.create_urllib3_context() context.options &= ~ssl_.OP_NO_SSLv3 You can do the same to enable compression (substituting ``COMPRESSION`` for ``SSLv3`` in the last line above). :param ssl_version: The desired protocol version to use. This will default to PROTOCOL_SSLv23 which will negotiate the highest protocol that both the server and your installation of OpenSSL support. :param cert_reqs: Whether to require the certificate verification. This defaults to ``ssl.CERT_REQUIRED``. :param options: Specific OpenSSL options. These default to ``ssl.OP_NO_SSLv2``, ``ssl.OP_NO_SSLv3``, ``ssl.OP_NO_COMPRESSION``, and ``ssl.OP_NO_TICKET``. :param ciphers: Which cipher suites to allow the server to select. :returns: Constructed SSLContext object with specified options :rtype: SSLContext """ # PROTOCOL_TLS is deprecated in Python 3.10 if not ssl_version or ssl_version == PROTOCOL_TLS: ssl_version = PROTOCOL_TLS_CLIENT context = SSLContext(ssl_version) context.set_ciphers(ciphers or DEFAULT_CIPHERS) # Setting the default here, as we may have no ssl module on import cert_reqs = ssl.CERT_REQUIRED if cert_reqs is None else cert_reqs if options is None: options = 0 # SSLv2 is easily broken and is considered harmful and dangerous options |= OP_NO_SSLv2 # SSLv3 has several problems and is now dangerous options |= OP_NO_SSLv3 # Disable compression to prevent CRIME attacks for OpenSSL 1.0+ # (issue #309) options |= OP_NO_COMPRESSION # TLSv1.2 only. Unless set explicitly, do not request tickets. # This may save some bandwidth on wire, and although the ticket is encrypted, # there is a risk associated with it being on wire, # if the server is not rotating its ticketing keys properly. options |= OP_NO_TICKET context.options |= options # Enable post-handshake authentication for TLS 1.3, see GH #1634. PHA is # necessary for conditional client cert authentication with TLS 1.3. # The attribute is None for OpenSSL <= 1.1.0 or does not exist in older # versions of Python. We only enable on Python 3.7.4+ or if certificate # verification is enabled to work around Python issue #37428 # See: https://bugs.python.org/issue37428 if (cert_reqs == ssl.CERT_REQUIRED or sys.version_info >= (3, 7, 4)) and getattr( context, "post_handshake_auth", None ) is not None: context.post_handshake_auth = True def disable_check_hostname(): if ( getattr(context, "check_hostname", None) is not None ): # Platform-specific: Python 3.2 # We do our own verification, including fingerprints and alternative # hostnames. So disable it here context.check_hostname = False # The order of the below lines setting verify_mode and check_hostname # matter due to safe-guards SSLContext has to prevent an SSLContext with # check_hostname=True, verify_mode=NONE/OPTIONAL. This is made even more # complex because we don't know whether PROTOCOL_TLS_CLIENT will be used # or not so we don't know the initial state of the freshly created SSLContext. if cert_reqs == ssl.CERT_REQUIRED: context.verify_mode = cert_reqs disable_check_hostname() else: disable_check_hostname() context.verify_mode = cert_reqs # Enable logging of TLS session keys via defacto standard environment variable # 'SSLKEYLOGFILE', if the feature is available (Python 3.8+). Skip empty values. if hasattr(context, "keylog_filename"): sslkeylogfile = os.environ.get("SSLKEYLOGFILE") if sslkeylogfile: context.keylog_filename = sslkeylogfile return context def ssl_wrap_socket( sock, keyfile=None, certfile=None, cert_reqs=None, ca_certs=None, server_hostname=None, ssl_version=None, ciphers=None, ssl_context=None, ca_cert_dir=None, key_password=None, ca_cert_data=None, tls_in_tls=False, ): """ All arguments except for server_hostname, ssl_context, and ca_cert_dir have the same meaning as they do when using :func:`ssl.wrap_socket`. :param server_hostname: When SNI is supported, the expected hostname of the certificate :param ssl_context: A pre-made :class:`SSLContext` object. If none is provided, one will be created using :func:`create_urllib3_context`. :param ciphers: A string of ciphers we wish the client to support. :param ca_cert_dir: A directory containing CA certificates in multiple separate files, as supported by OpenSSL's -CApath flag or the capath argument to SSLContext.load_verify_locations(). :param key_password: Optional password if the keyfile is encrypted. :param ca_cert_data: Optional string containing CA certificates in PEM format suitable for passing as the cadata parameter to SSLContext.load_verify_locations() :param tls_in_tls: Use SSLTransport to wrap the existing socket. """ context = ssl_context if context is None: # Note: This branch of code and all the variables in it are no longer # used by urllib3 itself. We should consider deprecating and removing # this code. context = create_urllib3_context(ssl_version, cert_reqs, ciphers=ciphers) if ca_certs or ca_cert_dir or ca_cert_data: try: context.load_verify_locations(ca_certs, ca_cert_dir, ca_cert_data) except (IOError, OSError) as e: raise SSLError(e) elif ssl_context is None and hasattr(context, "load_default_certs"): # try to load OS default certs; works well on Windows (require Python3.4+) context.load_default_certs() # Attempt to detect if we get the goofy behavior of the # keyfile being encrypted and OpenSSL asking for the # passphrase via the terminal and instead error out. if keyfile and key_password is None and _is_key_file_encrypted(keyfile): raise SSLError("Client private key is encrypted, password is required") if certfile: if key_password is None: context.load_cert_chain(certfile, keyfile) else: context.load_cert_chain(certfile, keyfile, key_password) try: if hasattr(context, "set_alpn_protocols"): context.set_alpn_protocols(ALPN_PROTOCOLS) except NotImplementedError: # Defensive: in CI, we always have set_alpn_protocols pass # If we detect server_hostname is an IP address then the SNI # extension should not be used according to RFC3546 Section 3.1 use_sni_hostname = server_hostname and not is_ipaddress(server_hostname) # SecureTransport uses server_hostname in certificate verification. send_sni = (use_sni_hostname and HAS_SNI) or ( IS_SECURETRANSPORT and server_hostname ) # Do not warn the user if server_hostname is an invalid SNI hostname. if not HAS_SNI and use_sni_hostname: warnings.warn( "An HTTPS request has been made, but the SNI (Server Name " "Indication) extension to TLS is not available on this platform. " "This may cause the server to present an incorrect TLS " "certificate, which can cause validation failures. You can upgrade to " "a newer version of Python to solve this. For more information, see " "https://urllib3.readthedocs.io/en/1.26.x/advanced-usage.html" "#ssl-warnings", SNIMissingWarning, ) if send_sni: ssl_sock = _ssl_wrap_socket_impl( sock, context, tls_in_tls, server_hostname=server_hostname ) else: ssl_sock = _ssl_wrap_socket_impl(sock, context, tls_in_tls) return ssl_sock def is_ipaddress(hostname): """Detects whether the hostname given is an IPv4 or IPv6 address. Also detects IPv6 addresses with Zone IDs. :param str hostname: Hostname to examine. :return: True if the hostname is an IP address, False otherwise. """ if not six.PY2 and isinstance(hostname, bytes): # IDN A-label bytes are ASCII compatible. hostname = hostname.decode("ascii") return bool(IPV4_RE.match(hostname) or BRACELESS_IPV6_ADDRZ_RE.match(hostname)) def _is_key_file_encrypted(key_file): """Detects if a key file is encrypted or not.""" with open(key_file, "r") as f: for line in f: # Look for Proc-Type: 4,ENCRYPTED if "ENCRYPTED" in line: return True return False def _ssl_wrap_socket_impl(sock, ssl_context, tls_in_tls, server_hostname=None): if tls_in_tls: if not SSLTransport: # Import error, ssl is not available. raise ProxySchemeUnsupported( "TLS in TLS requires support for the 'ssl' module" ) SSLTransport._validate_ssl_context_for_tls_in_tls(ssl_context) return SSLTransport(sock, ssl_context, server_hostname) if server_hostname: return ssl_context.wrap_socket(sock, server_hostname=server_hostname) else: return ssl_context.wrap_socket(sock) ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/util/ssltransport.py ================================================ import io import socket import ssl from urllib3.exceptions import ProxySchemeUnsupported from urllib3.packages import six SSL_BLOCKSIZE = 16384 class SSLTransport: """ The SSLTransport wraps an existing socket and establishes an SSL connection. Contrary to Python's implementation of SSLSocket, it allows you to chain multiple TLS connections together. It's particularly useful if you need to implement TLS within TLS. The class supports most of the socket API operations. """ @staticmethod def _validate_ssl_context_for_tls_in_tls(ssl_context): """ Raises a ProxySchemeUnsupported if the provided ssl_context can't be used for TLS in TLS. The only requirement is that the ssl_context provides the 'wrap_bio' methods. """ if not hasattr(ssl_context, "wrap_bio"): if six.PY2: raise ProxySchemeUnsupported( "TLS in TLS requires SSLContext.wrap_bio() which isn't " "supported on Python 2" ) else: raise ProxySchemeUnsupported( "TLS in TLS requires SSLContext.wrap_bio() which isn't " "available on non-native SSLContext" ) def __init__( self, socket, ssl_context, server_hostname=None, suppress_ragged_eofs=True ): """ Create an SSLTransport around socket using the provided ssl_context. """ self.incoming = ssl.MemoryBIO() self.outgoing = ssl.MemoryBIO() self.suppress_ragged_eofs = suppress_ragged_eofs self.socket = socket self.sslobj = ssl_context.wrap_bio( self.incoming, self.outgoing, server_hostname=server_hostname ) # Perform initial handshake. self._ssl_io_loop(self.sslobj.do_handshake) def __enter__(self): return self def __exit__(self, *_): self.close() def fileno(self): return self.socket.fileno() def read(self, len=1024, buffer=None): return self._wrap_ssl_read(len, buffer) def recv(self, len=1024, flags=0): if flags != 0: raise ValueError("non-zero flags not allowed in calls to recv") return self._wrap_ssl_read(len) def recv_into(self, buffer, nbytes=None, flags=0): if flags != 0: raise ValueError("non-zero flags not allowed in calls to recv_into") if buffer and (nbytes is None): nbytes = len(buffer) elif nbytes is None: nbytes = 1024 return self.read(nbytes, buffer) def sendall(self, data, flags=0): if flags != 0: raise ValueError("non-zero flags not allowed in calls to sendall") count = 0 with memoryview(data) as view, view.cast("B") as byte_view: amount = len(byte_view) while count < amount: v = self.send(byte_view[count:]) count += v def send(self, data, flags=0): if flags != 0: raise ValueError("non-zero flags not allowed in calls to send") response = self._ssl_io_loop(self.sslobj.write, data) return response def makefile( self, mode="r", buffering=None, encoding=None, errors=None, newline=None ): """ Python's httpclient uses makefile and buffered io when reading HTTP messages and we need to support it. This is unfortunately a copy and paste of socket.py makefile with small changes to point to the socket directly. """ if not set(mode) <= {"r", "w", "b"}: raise ValueError("invalid mode %r (only r, w, b allowed)" % (mode,)) writing = "w" in mode reading = "r" in mode or not writing assert reading or writing binary = "b" in mode rawmode = "" if reading: rawmode += "r" if writing: rawmode += "w" raw = socket.SocketIO(self, rawmode) self.socket._io_refs += 1 if buffering is None: buffering = -1 if buffering < 0: buffering = io.DEFAULT_BUFFER_SIZE if buffering == 0: if not binary: raise ValueError("unbuffered streams must be binary") return raw if reading and writing: buffer = io.BufferedRWPair(raw, raw, buffering) elif reading: buffer = io.BufferedReader(raw, buffering) else: assert writing buffer = io.BufferedWriter(raw, buffering) if binary: return buffer text = io.TextIOWrapper(buffer, encoding, errors, newline) text.mode = mode return text def unwrap(self): self._ssl_io_loop(self.sslobj.unwrap) def close(self): self.socket.close() def getpeercert(self, binary_form=False): return self.sslobj.getpeercert(binary_form) def version(self): return self.sslobj.version() def cipher(self): return self.sslobj.cipher() def selected_alpn_protocol(self): return self.sslobj.selected_alpn_protocol() def selected_npn_protocol(self): return self.sslobj.selected_npn_protocol() def shared_ciphers(self): return self.sslobj.shared_ciphers() def compression(self): return self.sslobj.compression() def settimeout(self, value): self.socket.settimeout(value) def gettimeout(self): return self.socket.gettimeout() def _decref_socketios(self): self.socket._decref_socketios() def _wrap_ssl_read(self, len, buffer=None): try: return self._ssl_io_loop(self.sslobj.read, len, buffer) except ssl.SSLError as e: if e.errno == ssl.SSL_ERROR_EOF and self.suppress_ragged_eofs: return 0 # eof, return 0. else: raise def _ssl_io_loop(self, func, *args): """Performs an I/O loop between incoming/outgoing and the socket.""" should_loop = True ret = None while should_loop: errno = None try: ret = func(*args) except ssl.SSLError as e: if e.errno not in (ssl.SSL_ERROR_WANT_READ, ssl.SSL_ERROR_WANT_WRITE): # WANT_READ, and WANT_WRITE are expected, others are not. raise e errno = e.errno buf = self.outgoing.read() self.socket.sendall(buf) if errno is None: should_loop = False elif errno == ssl.SSL_ERROR_WANT_READ: buf = self.socket.recv(SSL_BLOCKSIZE) if buf: self.incoming.write(buf) else: self.incoming.write_eof() return ret ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/util/timeout.py ================================================ from __future__ import absolute_import import time # The default socket timeout, used by httplib to indicate that no timeout was # specified by the user from socket import _GLOBAL_DEFAULT_TIMEOUT from ..exceptions import TimeoutStateError # A sentinel value to indicate that no timeout was specified by the user in # urllib3 _Default = object() # Use time.monotonic if available. current_time = getattr(time, "monotonic", time.time) class Timeout(object): """Timeout configuration. Timeouts can be defined as a default for a pool: .. code-block:: python timeout = Timeout(connect=2.0, read=7.0) http = PoolManager(timeout=timeout) response = http.request('GET', 'http://example.com/') Or per-request (which overrides the default for the pool): .. code-block:: python response = http.request('GET', 'http://example.com/', timeout=Timeout(10)) Timeouts can be disabled by setting all the parameters to ``None``: .. code-block:: python no_timeout = Timeout(connect=None, read=None) response = http.request('GET', 'http://example.com/, timeout=no_timeout) :param total: This combines the connect and read timeouts into one; the read timeout will be set to the time leftover from the connect attempt. In the event that both a connect timeout and a total are specified, or a read timeout and a total are specified, the shorter timeout will be applied. Defaults to None. :type total: int, float, or None :param connect: The maximum amount of time (in seconds) to wait for a connection attempt to a server to succeed. Omitting the parameter will default the connect timeout to the system default, probably `the global default timeout in socket.py `_. None will set an infinite timeout for connection attempts. :type connect: int, float, or None :param read: The maximum amount of time (in seconds) to wait between consecutive read operations for a response from the server. Omitting the parameter will default the read timeout to the system default, probably `the global default timeout in socket.py `_. None will set an infinite timeout. :type read: int, float, or None .. note:: Many factors can affect the total amount of time for urllib3 to return an HTTP response. For example, Python's DNS resolver does not obey the timeout specified on the socket. Other factors that can affect total request time include high CPU load, high swap, the program running at a low priority level, or other behaviors. In addition, the read and total timeouts only measure the time between read operations on the socket connecting the client and the server, not the total amount of time for the request to return a complete response. For most requests, the timeout is raised because the server has not sent the first byte in the specified time. This is not always the case; if a server streams one byte every fifteen seconds, a timeout of 20 seconds will not trigger, even though the request will take several minutes to complete. If your goal is to cut off any request after a set amount of wall clock time, consider having a second "watcher" thread to cut off a slow request. """ #: A sentinel object representing the default timeout value DEFAULT_TIMEOUT = _GLOBAL_DEFAULT_TIMEOUT def __init__(self, total=None, connect=_Default, read=_Default): self._connect = self._validate_timeout(connect, "connect") self._read = self._validate_timeout(read, "read") self.total = self._validate_timeout(total, "total") self._start_connect = None def __repr__(self): return "%s(connect=%r, read=%r, total=%r)" % ( type(self).__name__, self._connect, self._read, self.total, ) # __str__ provided for backwards compatibility __str__ = __repr__ @classmethod def _validate_timeout(cls, value, name): """Check that a timeout attribute is valid. :param value: The timeout value to validate :param name: The name of the timeout attribute to validate. This is used to specify in error messages. :return: The validated and casted version of the given value. :raises ValueError: If it is a numeric value less than or equal to zero, or the type is not an integer, float, or None. """ if value is _Default: return cls.DEFAULT_TIMEOUT if value is None or value is cls.DEFAULT_TIMEOUT: return value if isinstance(value, bool): raise ValueError( "Timeout cannot be a boolean value. It must " "be an int, float or None." ) try: float(value) except (TypeError, ValueError): raise ValueError( "Timeout value %s was %s, but it must be an " "int, float or None." % (name, value) ) try: if value <= 0: raise ValueError( "Attempted to set %s timeout to %s, but the " "timeout cannot be set to a value less " "than or equal to 0." % (name, value) ) except TypeError: # Python 3 raise ValueError( "Timeout value %s was %s, but it must be an " "int, float or None." % (name, value) ) return value @classmethod def from_float(cls, timeout): """Create a new Timeout from a legacy timeout value. The timeout value used by httplib.py sets the same timeout on the connect(), and recv() socket requests. This creates a :class:`Timeout` object that sets the individual timeouts to the ``timeout`` value passed to this function. :param timeout: The legacy timeout value. :type timeout: integer, float, sentinel default object, or None :return: Timeout object :rtype: :class:`Timeout` """ return Timeout(read=timeout, connect=timeout) def clone(self): """Create a copy of the timeout object Timeout properties are stored per-pool but each request needs a fresh Timeout object to ensure each one has its own start/stop configured. :return: a copy of the timeout object :rtype: :class:`Timeout` """ # We can't use copy.deepcopy because that will also create a new object # for _GLOBAL_DEFAULT_TIMEOUT, which socket.py uses as a sentinel to # detect the user default. return Timeout(connect=self._connect, read=self._read, total=self.total) def start_connect(self): """Start the timeout clock, used during a connect() attempt :raises urllib3.exceptions.TimeoutStateError: if you attempt to start a timer that has been started already. """ if self._start_connect is not None: raise TimeoutStateError("Timeout timer has already been started.") self._start_connect = current_time() return self._start_connect def get_connect_duration(self): """Gets the time elapsed since the call to :meth:`start_connect`. :return: Elapsed time in seconds. :rtype: float :raises urllib3.exceptions.TimeoutStateError: if you attempt to get duration for a timer that hasn't been started. """ if self._start_connect is None: raise TimeoutStateError( "Can't get connect duration for timer that has not started." ) return current_time() - self._start_connect @property def connect_timeout(self): """Get the value to use when setting a connection timeout. This will be a positive float or integer, the value None (never timeout), or the default system timeout. :return: Connect timeout. :rtype: int, float, :attr:`Timeout.DEFAULT_TIMEOUT` or None """ if self.total is None: return self._connect if self._connect is None or self._connect is self.DEFAULT_TIMEOUT: return self.total return min(self._connect, self.total) @property def read_timeout(self): """Get the value for the read timeout. This assumes some time has elapsed in the connection timeout and computes the read timeout appropriately. If self.total is set, the read timeout is dependent on the amount of time taken by the connect timeout. If the connection time has not been established, a :exc:`~urllib3.exceptions.TimeoutStateError` will be raised. :return: Value to use for the read timeout. :rtype: int, float, :attr:`Timeout.DEFAULT_TIMEOUT` or None :raises urllib3.exceptions.TimeoutStateError: If :meth:`start_connect` has not yet been called on this object. """ if ( self.total is not None and self.total is not self.DEFAULT_TIMEOUT and self._read is not None and self._read is not self.DEFAULT_TIMEOUT ): # In case the connect timeout has not yet been established. if self._start_connect is None: return self._read return max(0, min(self.total - self.get_connect_duration(), self._read)) elif self.total is not None and self.total is not self.DEFAULT_TIMEOUT: return max(0, self.total - self.get_connect_duration()) else: return self._read ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/util/url.py ================================================ from __future__ import absolute_import import re from collections import namedtuple from ..exceptions import LocationParseError from ..packages import six url_attrs = ["scheme", "auth", "host", "port", "path", "query", "fragment"] # We only want to normalize urls with an HTTP(S) scheme. # urllib3 infers URLs without a scheme (None) to be http. NORMALIZABLE_SCHEMES = ("http", "https", None) # Almost all of these patterns were derived from the # 'rfc3986' module: https://github.com/python-hyper/rfc3986 PERCENT_RE = re.compile(r"%[a-fA-F0-9]{2}") SCHEME_RE = re.compile(r"^(?:[a-zA-Z][a-zA-Z0-9+-]*:|/)") URI_RE = re.compile( r"^(?:([a-zA-Z][a-zA-Z0-9+.-]*):)?" r"(?://([^\\/?#]*))?" r"([^?#]*)" r"(?:\?([^#]*))?" r"(?:#(.*))?$", re.UNICODE | re.DOTALL, ) IPV4_PAT = r"(?:[0-9]{1,3}\.){3}[0-9]{1,3}" HEX_PAT = "[0-9A-Fa-f]{1,4}" LS32_PAT = "(?:{hex}:{hex}|{ipv4})".format(hex=HEX_PAT, ipv4=IPV4_PAT) _subs = {"hex": HEX_PAT, "ls32": LS32_PAT} _variations = [ # 6( h16 ":" ) ls32 "(?:%(hex)s:){6}%(ls32)s", # "::" 5( h16 ":" ) ls32 "::(?:%(hex)s:){5}%(ls32)s", # [ h16 ] "::" 4( h16 ":" ) ls32 "(?:%(hex)s)?::(?:%(hex)s:){4}%(ls32)s", # [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32 "(?:(?:%(hex)s:)?%(hex)s)?::(?:%(hex)s:){3}%(ls32)s", # [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32 "(?:(?:%(hex)s:){0,2}%(hex)s)?::(?:%(hex)s:){2}%(ls32)s", # [ *3( h16 ":" ) h16 ] "::" h16 ":" ls32 "(?:(?:%(hex)s:){0,3}%(hex)s)?::%(hex)s:%(ls32)s", # [ *4( h16 ":" ) h16 ] "::" ls32 "(?:(?:%(hex)s:){0,4}%(hex)s)?::%(ls32)s", # [ *5( h16 ":" ) h16 ] "::" h16 "(?:(?:%(hex)s:){0,5}%(hex)s)?::%(hex)s", # [ *6( h16 ":" ) h16 ] "::" "(?:(?:%(hex)s:){0,6}%(hex)s)?::", ] UNRESERVED_PAT = r"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._!\-~" IPV6_PAT = "(?:" + "|".join([x % _subs for x in _variations]) + ")" ZONE_ID_PAT = "(?:%25|%)(?:[" + UNRESERVED_PAT + "]|%[a-fA-F0-9]{2})+" IPV6_ADDRZ_PAT = r"\[" + IPV6_PAT + r"(?:" + ZONE_ID_PAT + r")?\]" REG_NAME_PAT = r"(?:[^\[\]%:/?#]|%[a-fA-F0-9]{2})*" TARGET_RE = re.compile(r"^(/[^?#]*)(?:\?([^#]*))?(?:#.*)?$") IPV4_RE = re.compile("^" + IPV4_PAT + "$") IPV6_RE = re.compile("^" + IPV6_PAT + "$") IPV6_ADDRZ_RE = re.compile("^" + IPV6_ADDRZ_PAT + "$") BRACELESS_IPV6_ADDRZ_RE = re.compile("^" + IPV6_ADDRZ_PAT[2:-2] + "$") ZONE_ID_RE = re.compile("(" + ZONE_ID_PAT + r")\]$") _HOST_PORT_PAT = ("^(%s|%s|%s)(?::([0-9]{0,5}))?$") % ( REG_NAME_PAT, IPV4_PAT, IPV6_ADDRZ_PAT, ) _HOST_PORT_RE = re.compile(_HOST_PORT_PAT, re.UNICODE | re.DOTALL) UNRESERVED_CHARS = set( "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-~" ) SUB_DELIM_CHARS = set("!$&'()*+,;=") USERINFO_CHARS = UNRESERVED_CHARS | SUB_DELIM_CHARS | {":"} PATH_CHARS = USERINFO_CHARS | {"@", "/"} QUERY_CHARS = FRAGMENT_CHARS = PATH_CHARS | {"?"} class Url(namedtuple("Url", url_attrs)): """ Data structure for representing an HTTP URL. Used as a return value for :func:`parse_url`. Both the scheme and host are normalized as they are both case-insensitive according to RFC 3986. """ __slots__ = () def __new__( cls, scheme=None, auth=None, host=None, port=None, path=None, query=None, fragment=None, ): if path and not path.startswith("/"): path = "/" + path if scheme is not None: scheme = scheme.lower() return super(Url, cls).__new__( cls, scheme, auth, host, port, path, query, fragment ) @property def hostname(self): """For backwards-compatibility with urlparse. We're nice like that.""" return self.host @property def request_uri(self): """Absolute path including the query string.""" uri = self.path or "/" if self.query is not None: uri += "?" + self.query return uri @property def netloc(self): """Network location including host and port""" if self.port: return "%s:%d" % (self.host, self.port) return self.host @property def url(self): """ Convert self into a url This function should more or less round-trip with :func:`.parse_url`. The returned url may not be exactly the same as the url inputted to :func:`.parse_url`, but it should be equivalent by the RFC (e.g., urls with a blank port will have : removed). Example: :: >>> U = parse_url('http://google.com/mail/') >>> U.url 'http://google.com/mail/' >>> Url('http', 'username:password', 'host.com', 80, ... '/path', 'query', 'fragment').url 'http://username:password@host.com:80/path?query#fragment' """ scheme, auth, host, port, path, query, fragment = self url = u"" # We use "is not None" we want things to happen with empty strings (or 0 port) if scheme is not None: url += scheme + u"://" if auth is not None: url += auth + u"@" if host is not None: url += host if port is not None: url += u":" + str(port) if path is not None: url += path if query is not None: url += u"?" + query if fragment is not None: url += u"#" + fragment return url def __str__(self): return self.url def split_first(s, delims): """ .. deprecated:: 1.25 Given a string and an iterable of delimiters, split on the first found delimiter. Return two split parts and the matched delimiter. If not found, then the first part is the full input string. Example:: >>> split_first('foo/bar?baz', '?/=') ('foo', 'bar?baz', '/') >>> split_first('foo/bar?baz', '123') ('foo/bar?baz', '', None) Scales linearly with number of delims. Not ideal for large number of delims. """ min_idx = None min_delim = None for d in delims: idx = s.find(d) if idx < 0: continue if min_idx is None or idx < min_idx: min_idx = idx min_delim = d if min_idx is None or min_idx < 0: return s, "", None return s[:min_idx], s[min_idx + 1 :], min_delim def _encode_invalid_chars(component, allowed_chars, encoding="utf-8"): """Percent-encodes a URI component without reapplying onto an already percent-encoded component. """ if component is None: return component component = six.ensure_text(component) # Normalize existing percent-encoded bytes. # Try to see if the component we're encoding is already percent-encoded # so we can skip all '%' characters but still encode all others. component, percent_encodings = PERCENT_RE.subn( lambda match: match.group(0).upper(), component ) uri_bytes = component.encode("utf-8", "surrogatepass") is_percent_encoded = percent_encodings == uri_bytes.count(b"%") encoded_component = bytearray() for i in range(0, len(uri_bytes)): # Will return a single character bytestring on both Python 2 & 3 byte = uri_bytes[i : i + 1] byte_ord = ord(byte) if (is_percent_encoded and byte == b"%") or ( byte_ord < 128 and byte.decode() in allowed_chars ): encoded_component += byte continue encoded_component.extend(b"%" + (hex(byte_ord)[2:].encode().zfill(2).upper())) return encoded_component.decode(encoding) def _remove_path_dot_segments(path): # See http://tools.ietf.org/html/rfc3986#section-5.2.4 for pseudo-code segments = path.split("/") # Turn the path into a list of segments output = [] # Initialize the variable to use to store output for segment in segments: # '.' is the current directory, so ignore it, it is superfluous if segment == ".": continue # Anything other than '..', should be appended to the output elif segment != "..": output.append(segment) # In this case segment == '..', if we can, we should pop the last # element elif output: output.pop() # If the path starts with '/' and the output is empty or the first string # is non-empty if path.startswith("/") and (not output or output[0]): output.insert(0, "") # If the path starts with '/.' or '/..' ensure we add one more empty # string to add a trailing '/' if path.endswith(("/.", "/..")): output.append("") return "/".join(output) def _normalize_host(host, scheme): if host: if isinstance(host, six.binary_type): host = six.ensure_str(host) if scheme in NORMALIZABLE_SCHEMES: is_ipv6 = IPV6_ADDRZ_RE.match(host) if is_ipv6: match = ZONE_ID_RE.search(host) if match: start, end = match.span(1) zone_id = host[start:end] if zone_id.startswith("%25") and zone_id != "%25": zone_id = zone_id[3:] else: zone_id = zone_id[1:] zone_id = "%" + _encode_invalid_chars(zone_id, UNRESERVED_CHARS) return host[:start].lower() + zone_id + host[end:] else: return host.lower() elif not IPV4_RE.match(host): return six.ensure_str( b".".join([_idna_encode(label) for label in host.split(".")]) ) return host def _idna_encode(name): if name and any([ord(x) > 128 for x in name]): try: import idna except ImportError: six.raise_from( LocationParseError("Unable to parse URL without the 'idna' module"), None, ) try: return idna.encode(name.lower(), strict=True, std3_rules=True) except idna.IDNAError: six.raise_from( LocationParseError(u"Name '%s' is not a valid IDNA label" % name), None ) return name.lower().encode("ascii") def _encode_target(target): """Percent-encodes a request target so that there are no invalid characters""" path, query = TARGET_RE.match(target).groups() target = _encode_invalid_chars(path, PATH_CHARS) query = _encode_invalid_chars(query, QUERY_CHARS) if query is not None: target += "?" + query return target def parse_url(url): """ Given a url, return a parsed :class:`.Url` namedtuple. Best-effort is performed to parse incomplete urls. Fields not provided will be None. This parser is RFC 3986 compliant. The parser logic and helper functions are based heavily on work done in the ``rfc3986`` module. :param str url: URL to parse into a :class:`.Url` namedtuple. Partly backwards-compatible with :mod:`urlparse`. Example:: >>> parse_url('http://google.com/mail/') Url(scheme='http', host='google.com', port=None, path='/mail/', ...) >>> parse_url('google.com:80') Url(scheme=None, host='google.com', port=80, path=None, ...) >>> parse_url('/foo?bar') Url(scheme=None, host=None, port=None, path='/foo', query='bar', ...) """ if not url: # Empty return Url() source_url = url if not SCHEME_RE.search(url): url = "//" + url try: scheme, authority, path, query, fragment = URI_RE.match(url).groups() normalize_uri = scheme is None or scheme.lower() in NORMALIZABLE_SCHEMES if scheme: scheme = scheme.lower() if authority: auth, _, host_port = authority.rpartition("@") auth = auth or None host, port = _HOST_PORT_RE.match(host_port).groups() if auth and normalize_uri: auth = _encode_invalid_chars(auth, USERINFO_CHARS) if port == "": port = None else: auth, host, port = None, None, None if port is not None: port = int(port) if not (0 <= port <= 65535): raise LocationParseError(url) host = _normalize_host(host, scheme) if normalize_uri and path: path = _remove_path_dot_segments(path) path = _encode_invalid_chars(path, PATH_CHARS) if normalize_uri and query: query = _encode_invalid_chars(query, QUERY_CHARS) if normalize_uri and fragment: fragment = _encode_invalid_chars(fragment, FRAGMENT_CHARS) except (ValueError, AttributeError): return six.raise_from(LocationParseError(source_url), None) # For the sake of backwards compatibility we put empty # string values for path if there are any defined values # beyond the path in the URL. # TODO: Remove this when we break backwards compatibility. if not path: if query is not None or fragment is not None: path = "" else: path = None # Ensure that each part of the URL is a `str` for # backwards compatibility. if isinstance(url, six.text_type): ensure_func = six.ensure_text else: ensure_func = six.ensure_str def ensure_type(x): return x if x is None else ensure_func(x) return Url( scheme=ensure_type(scheme), auth=ensure_type(auth), host=ensure_type(host), port=port, path=ensure_type(path), query=ensure_type(query), fragment=ensure_type(fragment), ) def get_host(url): """ Deprecated. Use :func:`parse_url` instead. """ p = parse_url(url) return p.scheme or "http", p.hostname, p.port ================================================ FILE: openpype/hosts/fusion/vendor/urllib3/util/wait.py ================================================ import errno import select import sys from functools import partial try: from time import monotonic except ImportError: from time import time as monotonic __all__ = ["NoWayToWaitForSocketError", "wait_for_read", "wait_for_write"] class NoWayToWaitForSocketError(Exception): pass # How should we wait on sockets? # # There are two types of APIs you can use for waiting on sockets: the fancy # modern stateful APIs like epoll/kqueue, and the older stateless APIs like # select/poll. The stateful APIs are more efficient when you have a lots of # sockets to keep track of, because you can set them up once and then use them # lots of times. But we only ever want to wait on a single socket at a time # and don't want to keep track of state, so the stateless APIs are actually # more efficient. So we want to use select() or poll(). # # Now, how do we choose between select() and poll()? On traditional Unixes, # select() has a strange calling convention that makes it slow, or fail # altogether, for high-numbered file descriptors. The point of poll() is to fix # that, so on Unixes, we prefer poll(). # # On Windows, there is no poll() (or at least Python doesn't provide a wrapper # for it), but that's OK, because on Windows, select() doesn't have this # strange calling convention; plain select() works fine. # # So: on Windows we use select(), and everywhere else we use poll(). We also # fall back to select() in case poll() is somehow broken or missing. if sys.version_info >= (3, 5): # Modern Python, that retries syscalls by default def _retry_on_intr(fn, timeout): return fn(timeout) else: # Old and broken Pythons. def _retry_on_intr(fn, timeout): if timeout is None: deadline = float("inf") else: deadline = monotonic() + timeout while True: try: return fn(timeout) # OSError for 3 <= pyver < 3.5, select.error for pyver <= 2.7 except (OSError, select.error) as e: # 'e.args[0]' incantation works for both OSError and select.error if e.args[0] != errno.EINTR: raise else: timeout = deadline - monotonic() if timeout < 0: timeout = 0 if timeout == float("inf"): timeout = None continue def select_wait_for_socket(sock, read=False, write=False, timeout=None): if not read and not write: raise RuntimeError("must specify at least one of read=True, write=True") rcheck = [] wcheck = [] if read: rcheck.append(sock) if write: wcheck.append(sock) # When doing a non-blocking connect, most systems signal success by # marking the socket writable. Windows, though, signals success by marked # it as "exceptional". We paper over the difference by checking the write # sockets for both conditions. (The stdlib selectors module does the same # thing.) fn = partial(select.select, rcheck, wcheck, wcheck) rready, wready, xready = _retry_on_intr(fn, timeout) return bool(rready or wready or xready) def poll_wait_for_socket(sock, read=False, write=False, timeout=None): if not read and not write: raise RuntimeError("must specify at least one of read=True, write=True") mask = 0 if read: mask |= select.POLLIN if write: mask |= select.POLLOUT poll_obj = select.poll() poll_obj.register(sock, mask) # For some reason, poll() takes timeout in milliseconds def do_poll(t): if t is not None: t *= 1000 return poll_obj.poll(t) return bool(_retry_on_intr(do_poll, timeout)) def null_wait_for_socket(*args, **kwargs): raise NoWayToWaitForSocketError("no select-equivalent available") def _have_working_poll(): # Apparently some systems have a select.poll that fails as soon as you try # to use it, either due to strange configuration or broken monkeypatching # from libraries like eventlet/greenlet. try: poll_obj = select.poll() _retry_on_intr(poll_obj.poll, 0) except (AttributeError, OSError): return False else: return True def wait_for_socket(*args, **kwargs): # We delay choosing which implementation to use until the first time we're # called. We could do it at import time, but then we might make the wrong # decision if someone goes wild with monkeypatching select.poll after # we're imported. global wait_for_socket if _have_working_poll(): wait_for_socket = poll_wait_for_socket elif hasattr(select, "select"): wait_for_socket = select_wait_for_socket else: # Platform-specific: Appengine. wait_for_socket = null_wait_for_socket return wait_for_socket(*args, **kwargs) def wait_for_read(sock, timeout=None): """Waits for reading to be available on a given socket. Returns True if the socket is readable, or False if the timeout expired. """ return wait_for_socket(sock, read=True, timeout=timeout) def wait_for_write(sock, timeout=None): """Waits for writing to be available on a given socket. Returns True if the socket is readable, or False if the timeout expired. """ return wait_for_socket(sock, write=True, timeout=timeout) ================================================ FILE: openpype/hosts/harmony/__init__.py ================================================ from .addon import ( HARMONY_HOST_DIR, HarmonyAddon, ) __all__ = ( "HARMONY_HOST_DIR", "HarmonyAddon", ) ================================================ FILE: openpype/hosts/harmony/addon.py ================================================ import os from openpype.modules import OpenPypeModule, IHostAddon HARMONY_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) class HarmonyAddon(OpenPypeModule, IHostAddon): name = "harmony" host_name = "harmony" def initialize(self, module_settings): self.enabled = True def add_implementation_envs(self, env, _app): """Modify environments to contain all required for implementation.""" openharmony_path = os.path.join( HARMONY_HOST_DIR, "vendor", "OpenHarmony" ) # TODO check if is already set? What to do if is already set? env["LIB_OPENHARMONY_PATH"] = openharmony_path def get_workfile_extensions(self): return [".zip"] ================================================ FILE: openpype/hosts/harmony/api/README.md ================================================ # Harmony Integration ## Setup The easiest way to setup for using Toon Boom Harmony is to use the built-in launch: ``` python -c "import openpype.hosts.harmony.api as harmony;harmony.launch("path/to/harmony/executable")" ``` Communication with Harmony happens with a server/client relationship where the server is in the Python process and the client is in the Harmony process. Messages between Python and Harmony are required to be dictionaries, which are serialized to strings: ``` +------------+ | | | Python | | Process | | | | +--------+ | | | | | | | Main | | | | Thread | | | | | | | +----^---+ | | || | | || | | +---v----+ | +---------+ | | | | | | | | Server +-------> Harmony | | | Thread <-------+ Process | | | | | | | | +--------+ | +---------+ +------------+ ``` Server/client now uses stricter protocol to handle communication. This is necessary because of precise control over data passed between server/client. Each message is prepended with 6 bytes: ``` | A | H | 0x00 | 0x00 | 0x00 | 0x00 | ... ``` First two bytes are *magic* bytes stands for **A**valon **H**armony. Next four bytes hold length of the message `...` encoded as 32bit unsigned integer. This way we know how many bytes to read from the socket and if we need more or we need to parse multiple messages. ## Usage The integration creates an `Openpype` menu entry where all related tools are located. **NOTE: Menu creation can be temperamental. The best way is to launch Harmony and do nothing else until Harmony is fully launched.** ### Work files Because Harmony projects are directories, this integration uses `.zip` as work file extension. Internally the project directories are stored under `[User]/.avalon/harmony`. Whenever the user saves the `.xstage` file, the integration zips up the project directory and moves it to the Avalon project path. Zipping and moving happens in the background. ### Show Workfiles on launch You can show the Workfiles app when Harmony launches by setting environment variable `AVALON_HARMONY_WORKFILES_ON_LAUNCH=1`. ## Developing ### Low level messaging To send from Python to Harmony you can use the exposed method: ```python import openpype.hosts.harmony.api as harmony from uuid import uuid4 func = """function %s_hello(person) { return ("Hello " + person + "!"); } %s_hello """ % (uuid4(), uuid4()) print(harmony.send({"function": func, "args": ["Python"]})["result"]) ``` **NOTE:** Its important to declare the function at the end of the function string. You can have multiple functions within your function string, but the function declared at the end is what gets executed. To send a function with multiple arguments its best to declare the arguments within the function: ```python import openpype.hosts.harmony.api as harmony from uuid import uuid4 signature = str(uuid4()).replace("-", "_") func = """function %s_hello(args) { var greeting = args[0]; var person = args[1]; return (greeting + " " + person + "!"); } %s_hello """ % (signature, signature) print(harmony.send({"function": func, "args": ["Hello", "Python"]})["result"]) ``` ### Caution When naming your functions be aware that they are executed in global scope. They can potentially clash with Harmony own function and object names. For example `func` is already existing Harmony object. When you call your function `func` it will overwrite in global scope the one from Harmony, causing erratic behavior of Harmony. Openpype is prefixing those function names with [UUID4](https://docs.python.org/3/library/uuid.html) making chance of such clash minimal. See above examples how that works. This will result in function named `38dfcef0_a6d7_4064_8069_51fe99ab276e_hello()`. You can find list of Harmony object and function in Harmony documentation. ### Higher level (recommended) Instead of sending functions directly to Harmony, it is more efficient and safe to just add your code to `js/PypeHarmony.js` or utilize `{"script": "..."}` method. #### Extending PypeHarmony.js Add your function to `PypeHarmony.js`. For example: ```javascript PypeHarmony.myAwesomeFunction = function() { someCoolStuff(); }; ``` Then you can call that javascript code from your Python like: ```Python import openpype.hosts.harmony.api as harmony harmony.send({"function": "PypeHarmony.myAwesomeFunction"}); ``` #### Using Script method You can also pass whole scripts into harmony and call their functions later as needed. For example, you have bunch of javascript files: ```javascript /* Master.js */ var Master = { Foo = {}; Boo = {}; }; /* FileA.js */ var Foo = function() {}; Foo.prototype.A = function() { someAStuff(); } // This will construct object Foo and add it to Master namespace. Master.Foo = new Foo(); /* FileB.js */ var Boo = function() {}; Boo.prototype.B = function() { someBStuff(); } // This will construct object Boo and add it to Master namespace. Master.Boo = new Boo(); ``` Now in python, just read all those files and send them to Harmony. ```python from pathlib import Path import openpype.hosts.harmony.api as harmony path_to_js = Path('/path/to/my/js') script_to_send = "" for file in path_to_js.iterdir(): if file.suffix == ".js": script_to_send += file.read_text() harmony.send({"script": script_to_send}) # and use your code in Harmony harmony.send({"function": "Master.Boo.B"}) ``` ### Scene Save Instead of sending a request to Harmony with `scene.saveAll` please use: ```python import openpype.hosts.harmony.api as harmony harmony.save_scene() ```
Click to expand for details on scene save. Because Openpype tools does not deal well with folders for a single entity like a Harmony scene, this integration has implemented to use zip files to encapsulate the Harmony scene folders. Saving scene in Harmony via menu or CTRL+S will not result in producing zip file, only saving it from Workfiles will. This is because zipping process can take some time in which we cannot block user from saving again. If xstage file is changed during zipping process it will produce corrupted zip archive.
### Plugin Examples These plugins were made with the [polly config](https://github.com/mindbender-studio/config). #### Creator Plugin ```python import openpype.hosts.harmony.api as harmony from uuid import uuid4 class CreateComposite(harmony.Creator): """Composite node for publish.""" name = "compositeDefault" label = "Composite" family = "mindbender.template" def __init__(self, *args, **kwargs): super(CreateComposite, self).__init__(*args, **kwargs) ``` The creator plugin can be configured to use other node types. For example here is a write node creator: ```python import openpype.hosts.harmony.api as harmony class CreateRender(harmony.Creator): """Composite node for publishing renders.""" name = "writeDefault" label = "Write" family = "mindbender.imagesequence" node_type = "WRITE" def __init__(self, *args, **kwargs): super(CreateRender, self).__init__(*args, **kwargs) def setup_node(self, node): signature = str(uuid4()).replace("-", "_") func = """function %s_func(args) { node.setTextAttr(args[0], "DRAWING_TYPE", 1, "PNG4"); } %s_func """ % (signature, signature) harmony.send( {"function": func, "args": [node]} ) ``` #### Collector Plugin ```python import pyblish.api import openpype.hosts.harmony.api as harmony class CollectInstances(pyblish.api.ContextPlugin): """Gather instances by nodes metadata. This collector takes into account assets that are associated with a composite node and marked with a unique identifier; Identifier: id (str): "pyblish.avalon.instance" """ label = "Instances" order = pyblish.api.CollectorOrder hosts = ["harmony"] def process(self, context): nodes = harmony.send( {"function": "node.getNodes", "args": [["COMPOSITE"]]} )["result"] for node in nodes: data = harmony.read(node) # Skip non-tagged nodes. if not data: continue # Skip containers. if "container" in data["id"]: continue instance = context.create_instance(node.split("/")[-1]) instance.append(node) instance.data.update(data) # Produce diagnostic message for any graphical # user interface interested in visualising it. self.log.info("Found: \"%s\" " % instance.data["name"]) ``` #### Extractor Plugin ```python import os import pyblish.api import openpype.hosts.harmony.api as harmony import clique class ExtractImage(pyblish.api.InstancePlugin): """Produce a flattened image file from instance. This plug-in only takes into account the nodes connected to the composite. """ label = "Extract Image Sequence" order = pyblish.api.ExtractorOrder hosts = ["harmony"] families = ["mindbender.imagesequence"] def process(self, instance): project_path = harmony.send( {"function": "scene.currentProjectPath"} )["result"] # Store reference for integration if "files" not in instance.data: instance.data["files"] = list() # Store display source node for later. display_node = "Top/Display" signature = str(uuid4()).replace("-", "_") func = """function %s_func(display_node) { var source_node = null; if (node.isLinked(display_node, 0)) { source_node = node.srcNode(display_node, 0); node.unlink(display_node, 0); } return source_node } %s_func """ % (signature, signature) display_source_node = harmony.send( {"function": func, "args": [display_node]} )["result"] # Perform extraction path = os.path.join( os.path.normpath( project_path ).replace("\\", "/"), instance.data["name"] ) if not os.path.exists(path): os.makedirs(path) render_func = """function frameReady(frame, celImage) {{ var path = "{path}/{filename}" + frame + ".png"; celImage.imageFileAs(path, "", "PNG4"); }} function %s_func(composite_node) {{ node.link(composite_node, 0, "{display_node}", 0); render.frameReady.connect(frameReady); render.setRenderDisplay("{display_node}"); render.renderSceneAll(); render.frameReady.disconnect(frameReady); }} %s_func """ % (signature, signature) restore_func = """function %s_func(args) { var display_node = args[0]; var display_source_node = args[1]; if (node.isLinked(display_node, 0)) { node.unlink(display_node, 0); } node.link(display_source_node, 0, display_node, 0); } %s_func """ % (signature, signature) with harmony.maintained_selection(): self.log.info("Extracting %s" % str(list(instance))) harmony.send( { "function": render_func.format( path=path.replace("\\", "/"), filename=os.path.basename(path), display_node=display_node ), "args": [instance[0]] } ) # Restore display. if display_source_node: harmony.send( { "function": restore_func, "args": [display_node, display_source_node] } ) files = os.listdir(path) collections, remainder = clique.assemble(files, minimum_items=1) assert not remainder, ( "There shouldn't have been a remainder for '%s': " "%s" % (instance[0], remainder) ) assert len(collections) == 1, ( "There should only be one image sequence in {}. Found: {}".format( path, len(collections) ) ) data = { "subset": collections[0].head, "isSeries": True, "stagingDir": path, "files": list(collections[0]), } instance.data.update(data) self.log.info("Extracted {instance} to {path}".format(**locals())) ``` #### Loader Plugin ```python import os import openpype.hosts.harmony.api as harmony signature = str(uuid4()).replace("-", "_") copy_files = """function copyFile(srcFilename, dstFilename) { var srcFile = new PermanentFile(srcFilename); var dstFile = new PermanentFile(dstFilename); srcFile.copy(dstFile); } """ import_files = """function %s_import_files() { var PNGTransparencyMode = 0; // Premultiplied with Black var TGATransparencyMode = 0; // Premultiplied with Black var SGITransparencyMode = 0; // Premultiplied with Black var LayeredPSDTransparencyMode = 1; // Straight var FlatPSDTransparencyMode = 2; // Premultiplied with White function getUniqueColumnName( column_prefix ) { var suffix = 0; // finds if unique name for a column var column_name = column_prefix; while(suffix < 2000) { if(!column.type(column_name)) break; suffix = suffix + 1; column_name = column_prefix + "_" + suffix; } return column_name; } function import_files(args) { var root = args[0]; var files = args[1]; var name = args[2]; var start_frame = args[3]; var vectorFormat = null; var extension = null; var filename = files[0]; var pos = filename.lastIndexOf("."); if( pos < 0 ) return null; extension = filename.substr(pos+1).toLowerCase(); if(extension == "jpeg") extension = "jpg"; if(extension == "tvg") { vectorFormat = "TVG" extension ="SCAN"; // element.add() will use this. } var elemId = element.add( name, "BW", scene.numberOfUnitsZ(), extension.toUpperCase(), vectorFormat ); if (elemId == -1) { // hum, unknown file type most likely -- let's skip it. return null; // no read to add. } var uniqueColumnName = getUniqueColumnName(name); column.add(uniqueColumnName , "DRAWING"); column.setElementIdOfDrawing(uniqueColumnName, elemId); var read = node.add(root, name, "READ", 0, 0, 0); var transparencyAttr = node.getAttr( read, frame.current(), "READ_TRANSPARENCY" ); var opacityAttr = node.getAttr(read, frame.current(), "OPACITY"); transparencyAttr.setValue(true); opacityAttr.setValue(true); var alignmentAttr = node.getAttr(read, frame.current(), "ALIGNMENT_RULE"); alignmentAttr.setValue("ASIS"); var transparencyModeAttr = node.getAttr( read, frame.current(), "applyMatteToColor" ); if (extension == "png") transparencyModeAttr.setValue(PNGTransparencyMode); if (extension == "tga") transparencyModeAttr.setValue(TGATransparencyMode); if (extension == "sgi") transparencyModeAttr.setValue(SGITransparencyMode); if (extension == "psd") transparencyModeAttr.setValue(FlatPSDTransparencyMode); node.linkAttr(read, "DRAWING.ELEMENT", uniqueColumnName); // Create a drawing for each file. for( var i =0; i <= files.length - 1; ++i) { timing = start_frame + i // Create a drawing drawing, 'true' indicate that the file exists. Drawing.create(elemId, timing, true); // Get the actual path, in tmp folder. var drawingFilePath = Drawing.filename(elemId, timing.toString()); copyFile( files[i], drawingFilePath ); column.setEntry(uniqueColumnName, 1, timing, timing.toString()); } return read; } import_files(); } %s_import_files """ % (signature, signature) replace_files = """function %s_replace_files(args) { var files = args[0]; var _node = args[1]; var start_frame = args[2]; var _column = node.linkedColumn(_node, "DRAWING.ELEMENT"); // Delete existing drawings. var timings = column.getDrawingTimings(_column); for( var i =0; i <= timings.length - 1; ++i) { column.deleteDrawingAt(_column, parseInt(timings[i])); } // Create new drawings. for( var i =0; i <= files.length - 1; ++i) { timing = start_frame + i // Create a drawing drawing, 'true' indicate that the file exists. Drawing.create(node.getElementId(_node), timing, true); // Get the actual path, in tmp folder. var drawingFilePath = Drawing.filename( node.getElementId(_node), timing.toString() ); copyFile( files[i], drawingFilePath ); column.setEntry(_column, 1, timing, timing.toString()); } } %s_replace_files """ % (signature, signature) class ImageSequenceLoader(load.LoaderPlugin): """Load images Stores the imported asset in a container named after the asset. """ families = ["mindbender.imagesequence"] representations = ["*"] def load(self, context, name=None, namespace=None, data=None): files = [] for f in context["version"]["data"]["files"]: files.append( os.path.join( context["version"]["data"]["stagingDir"], f ).replace("\\", "/") ) read_node = harmony.send( { "function": copy_files + import_files, "args": ["Top", files, context["version"]["data"]["subset"], 1] } )["result"] self[:] = [read_node] return harmony.containerise( name, namespace, read_node, context, self.__class__.__name__ ) def update(self, container, representation): node = container.pop("node") project_name = get_current_project_name() version = get_version_by_id(project_name, representation["parent"]) files = [] for f in version["data"]["files"]: files.append( os.path.join( version["data"]["stagingDir"], f ).replace("\\", "/") ) harmony.send( { "function": copy_files + replace_files, "args": [files, node, 1] } ) harmony.imprint( node, {"representation": str(representation["_id"])} ) def remove(self, container): node = container.pop("node") signature = str(uuid4()).replace("-", "_") func = """function %s_deleteNode(_node) { node.deleteNode(_node, true, true); } %_deleteNode """ % (signature, signature) harmony.send( {"function": func, "args": [node]} ) def switch(self, container, representation): self.update(container, representation) ``` ## Resources - https://github.com/diegogarciahuerta/tk-harmony - https://github.com/cfourney/OpenHarmony - [Toon Boom Discord](https://discord.gg/syAjy4H) - [Toon Boom TD](https://discord.gg/yAjyQtZ) ================================================ FILE: openpype/hosts/harmony/api/TB_sceneOpened.js ================================================ /* global QTcpSocket, QByteArray, QDataStream, QTimer, QTextCodec, QIODevice, QApplication, include */ /* global QTcpSocket, QByteArray, QDataStream, QTimer, QTextCodec, QIODevice, QApplication, include */ /* Avalon Harmony Integration - Client ----------------------------------- This script implements client communication with Avalon server to bridge gap between Python and QtScript. */ /* jshint proto: true */ var LD_OPENHARMONY_PATH = System.getenv('LIB_OPENHARMONY_PATH'); LD_OPENHARMONY_PATH = LD_OPENHARMONY_PATH + '/openHarmony.js'; LD_OPENHARMONY_PATH = LD_OPENHARMONY_PATH.replace(/\\/g, "/"); include(LD_OPENHARMONY_PATH); //this.__proto__['$'] = $; function Client() { var self = this; /** socket */ self.socket = new QTcpSocket(this); /** receiving data buffer */ self.received = ''; self.messageId = 1; self.buffer = new QByteArray(); self.waitingForData = 0; /** * pack number into bytes. * @function * @param {int} num 32 bit integer * @return {string} */ self.pack = function(num) { var ascii=''; for (var i = 3; i >= 0; i--) { var hex = ((num >> (8 * i)) & 255).toString(16); if (hex.length < 2){ ascii += "0"; } ascii += hex; } return ascii; }; /** * unpack number from string. * @function * @param {string} numString bytes to unpack * @return {int} 32bit unsigned integer. */ self.unpack = function(numString) { var result=0; for (var i = 3; i >= 0; i--) { result += numString.charCodeAt(3 - i) << (8 * i); } return result; }; /** * prettify json for easier debugging * @function * @param {object} json json to process * @return {string} prettified json string */ self.prettifyJson = function(json) { var jsonString = JSON.stringify(json); return JSON.stringify(JSON.parse(jsonString), null, 2); }; /** * Log message in debug level. * @function * @param {string} data - message */ self.logDebug = function(data) { var message = typeof(data.message) != 'undefined' ? data.message : data; MessageLog.trace('(DEBUG): ' + message.toString()); }; /** * Log message in info level. * @function * @param {string} data - message */ self.logInfo = function(data) { var message = typeof(data.message) != 'undefined' ? data.message : data; MessageLog.trace('(DEBUG): ' + message.toString()); }; /** * Log message in warning level. * @function * @param {string} data - message */ self.logWarning = function(data) { var message = typeof(data.message) != 'undefined' ? data.message : data; MessageLog.trace('(INFO): ' + message.toString()); }; /** * Log message in error level. * @function * @param {string} data - message */ self.logError = function(data) { var message = typeof(data.message) != 'undefined' ? data.message : data; MessageLog.trace('(ERROR): ' +message.toString()); }; /** * Show message in Harmony GUI as popup window. * @function * @param {string} msg - message */ self.showMessage = function(msg) { MessageBox.information(msg); }; /** * Implement missing setTimeout() in Harmony. * This calls once given function after some interval in milliseconds. * @function * @param {function} fc function to call. * @param {int} interval interval in milliseconds. * @param {boolean} single behave as setTimeout or setInterval. */ self.setTimeout = function(fc, interval, single) { var timer = new QTimer(); if (!single) { timer.singleShot = true; // in-case if setTimout and false in-case of setInterval } else { timer.singleShot = single; } timer.interval = interval; // set the time in milliseconds timer.singleShot = true; // in-case if setTimout and false in-case of setInterval timer.timeout.connect(this, function(){ fc.call(); }); timer.start(); }; /** * Process received request. This will eval received function and produce * results. * @function * @param {object} request - received request JSON * @return {object} result of evaled function. */ self.processRequest = function(request) { var mid = request.message_id; if (typeof request.reply !== 'undefined') { self.logDebug('['+ mid +'] *** received reply.'); return; } self.logDebug('['+ mid +'] - Processing: ' + self.prettifyJson(request)); var result = null; if (typeof request.script !== 'undefined') { self.logDebug('[' + mid + '] Injecting script.'); try { eval.call(null, request.script); } catch (error) { self.logError(error); } } else if (typeof request["function"] !== 'undefined') { try { var _func = eval.call(null, request["function"]); if (request.args == null) { result = _func(); } else { result = _func(request.args); } } catch (error) { result = 'Error processing request.\n' + 'Request:\n' + self.prettifyJson(request) + '\n' + 'Error:\n' + error; } } else { self.logError('Command type not implemented.'); } return result; }; /** * This gets called when socket received new data. * @function */ self.onReadyRead = function() { var currentSize = self.buffer.size(); self.logDebug('--- Receiving data ( '+ currentSize + ' )...'); var newData = self.socket.readAll(); var newSize = newData.size(); self.buffer.append(newData); self.logDebug(' - got ' + newSize + ' bytes of new data.'); self.processBuffer(); }; /** * Process data received in buffer. * This detects messages by looking for header and message length. * @function */ self.processBuffer = function() { var length = self.waitingForData; if (self.waitingForData == 0) { // read header from the buffer and remove it var header_data = self.buffer.mid(0, 6); self.buffer = self.buffer.remove(0, 6); // convert header to string var header = ''; for (var i = 0; i < header_data.size(); ++i) { // data in QByteArray come out as signed bytes. var unsigned = header_data.at(i) & 0xff; header = header.concat(String.fromCharCode(unsigned)); } // skip 'AH' and read only length, unpack it to integer header = header.substr(2); length = self.unpack(header); } var data = self.buffer.mid(0, length); self.logDebug('--- Expected: ' + length + ' | Got: ' + data.size()); if (length > data.size()) { // we didn't received whole message. self.waitingForData = length; self.logDebug('... waiting for more data (' + length + ') ...'); return; } self.waitingForData = 0; self.buffer.remove(0, length); for (var j = 0; j < data.size(); ++j) { self.received = self.received.concat(String.fromCharCode(data.at(j))); } // self.logDebug('--- Received: ' + self.received); var to_parse = self.received; var request = JSON.parse(to_parse); var mid = request.message_id; // self.logDebug('[' + mid + '] - Request: ' + '\n' + JSON.stringify(request)); self.logDebug('[' + mid + '] Received.'); request.result = self.processRequest(request); self.logDebug('[' + mid + '] Processing done.'); self.received = ''; if (request.reply !== true) { request.reply = true; self.logDebug('[' + mid + '] Replying.'); self._send(JSON.stringify(request)); } if ((length < data.size()) || (length < self.buffer.size())) { // we've received more data. self.logDebug('--- Got more data to process ...'); self.processBuffer(); } }; /** * Run when Harmony connects to server. * @function */ self.onConnected = function() { self.logDebug('Connected to server ...'); self.lock = false; self.socket.readyRead.connect(self.onReadyRead); var app = QCoreApplication.instance(); app.avalonClient.send( { 'module': 'openpype.lib', 'method': 'emit_event', 'args': ['application.launched'] }, false); }; self._send = function(message) { /** Harmony 21.1 doesn't have QDataStream anymore. This means we aren't able to write bytes into QByteArray so we had modify how content length is sent do the server. Content length is sent as string of 8 char convertible into integer (instead of 0x00000001[4 bytes] > "000000001"[8 bytes]) */ var codec_name = new QByteArray().append("UTF-8"); var codec = QTextCodec.codecForName(codec_name); var msg = codec.fromUnicode(message); var l = msg.size(); var header = new QByteArray().append('AH').append(self.pack(l)); var coded = msg.prepend(header); self.socket.write(coded); self.logDebug('Sent.'); }; self.waitForLock = function() { if (self._lock === false) { self.logDebug('>>> Unlocking ...'); return; } else { self.logDebug('Setting timer.'); self.setTimeout(self.waitForLock, 300); } }; /** * Send request to server. * @param {object} request - json encoded request. */ self.send = function(request) { request.message_id = self.messageId; if (typeof request.reply == 'undefined') { self.logDebug("[" + self.messageId + "] sending:\n" + self.prettifyJson(request)); } self._send(JSON.stringify(request)); }; /** * Executed on disconnection. */ self.onDisconnected = function() { self.socket.close(); }; /** * Disconnect from server. */ self.disconnect = function() { self.socket.close(); }; self.socket.connected.connect(self.onConnected); self.socket.disconnected.connect(self.onDisconnected); } /** * Entry point, creating Avalon Client. */ function start() { var self = this; /** hostname or ip of server - should be localhost */ var host = '127.0.0.1'; /** port of the server */ var port = parseInt(System.getenv('AVALON_HARMONY_PORT')); // Attach the client to the QApplication to preserve. var app = QCoreApplication.instance(); if (app.avalonClient == null) { app.avalonClient = new Client(); app.avalonClient.socket.connectToHost(host, port); } var mainWindow = null; var widgets = QApplication.topLevelWidgets(); for (var i = 0 ; i < widgets.length; i++) { if (widgets[i] instanceof QMainWindow){ mainWindow = widgets[i]; } } var menuBar = mainWindow.menuBar(); var actions = menuBar.actions(); app.avalonMenu = null; for (var i = 0 ; i < actions.length; i++) { label = System.getenv('AVALON_LABEL'); if (actions[i].text == label) { app.avalonMenu = true; } } var menu = null; if (app.avalonMenu == null) { menu = menuBar.addMenu(System.getenv('AVALON_LABEL')); } // menu = menuBar.addMenu('Avalon'); /** * Show creator */ self.onCreator = function() { app.avalonClient.send({ 'module': 'openpype.hosts.harmony.api.lib', 'method': 'show', 'args': ['creator'] }, false); }; var action = menu.addAction('Create...'); action.triggered.connect(self.onCreator); /** * Show Workfiles */ self.onWorkfiles = function() { app.avalonClient.send({ 'module': 'openpype.hosts.harmony.api.lib', 'method': 'show', 'args': ['workfiles'] }, false); }; if (app.avalonMenu == null) { action = menu.addAction('Workfiles...'); action.triggered.connect(self.onWorkfiles); } /** * Show Loader */ self.onLoad = function() { app.avalonClient.send({ 'module': 'openpype.hosts.harmony.api.lib', 'method': 'show', 'args': ['loader'] }, false); }; // add Loader item to menu if (app.avalonMenu == null) { action = menu.addAction('Load...'); action.triggered.connect(self.onLoad); } /** * Show Publisher */ self.onPublish = function() { app.avalonClient.send({ 'module': 'openpype.hosts.harmony.api.lib', 'method': 'show', 'args': ['publish'] }, false); }; // add Publisher item to menu if (app.avalonMenu == null) { action = menu.addAction('Publish...'); action.triggered.connect(self.onPublish); } /** * Show Scene Manager */ self.onManage = function() { app.avalonClient.send({ 'module': 'openpype.hosts.harmony.api.lib', 'method': 'show', 'args': ['sceneinventory'] }, false); }; // add Scene Manager item to menu if (app.avalonMenu == null) { action = menu.addAction('Manage...'); action.triggered.connect(self.onManage); } /** * Show Subset Manager */ self.onSubsetManage = function() { app.avalonClient.send({ 'module': 'openpype.hosts.harmony.api.lib', 'method': 'show', 'args': ['subsetmanager'] }, false); }; // add Subset Manager item to menu if (app.avalonMenu == null) { action = menu.addAction('Subset Manager...'); action.triggered.connect(self.onSubsetManage); } /** * Set scene settings from DB to the scene */ self.onSetSceneSettings = function() { app.avalonClient.send( { "module": "openpype.hosts.harmony.api", "method": "ensure_scene_settings", "args": [] }, false ); }; // add Set Scene Settings if (app.avalonMenu == null) { action = menu.addAction('Set Scene Settings...'); action.triggered.connect(self.onSetSceneSettings); } /** * Show Experimental dialog */ self.onExperimentalTools = function() { app.avalonClient.send({ 'module': 'openpype.hosts.harmony.api.lib', 'method': 'show', 'args': ['experimental_tools'] }, false); }; // add Subset Manager item to menu if (app.avalonMenu == null) { action = menu.addAction('Experimental Tools...'); action.triggered.connect(self.onExperimentalTools); } // FIXME(antirotor): We need to disable `on_file_changed` now as is wreak // havoc when "Save" is called multiple times and zipping didn't finished yet /* // Watch scene file for changes. app.onFileChanged = function(path) { var app = QCoreApplication.instance(); if (app.avalonOnFileChanged){ app.avalonClient.send( { 'module': 'avalon.harmony.lib', 'method': 'on_file_changed', 'args': [path] }, false ); } app.watcher.addPath(path); }; app.watcher = new QFileSystemWatcher(); scene_path = scene.currentProjectPath() +"/" + scene.currentVersionName() + ".xstage"; app.watcher.addPath(scenePath); app.watcher.fileChanged.connect(app.onFileChanged); app.avalonOnFileChanged = true; */ app.onFileChanged = function(path) { // empty stub return path; }; } function ensureSceneSettings() { var app = QCoreApplication.instance(); app.avalonClient.send( { "module": "openpype.hosts.harmony.api", "method": "ensure_scene_settings", "args": [] }, false ); } function TB_sceneOpened() { start(); } ================================================ FILE: openpype/hosts/harmony/api/__init__.py ================================================ """Public API Anything that isn't defined here is INTERNAL and unreliable for external use. """ from .pipeline import ( ls, install, list_instances, remove_instance, select_instance, containerise, set_scene_settings, get_asset_settings, ensure_scene_settings, check_inventory, application_launch, export_template, on_pyblish_instance_toggled, inject_avalon_js, ) from .lib import ( launch, maintained_selection, imprint, read, send, maintained_nodes_state, save_scene, save_scene_as, remove, delete_node, find_node_by_name, signature, select_nodes, get_scene_data ) from .workio import ( open_file, save_file, current_file, has_unsaved_changes, file_extensions, work_root ) __all__ = [ # pipeline "ls", "install", "list_instances", "remove_instance", "select_instance", "containerise", "set_scene_settings", "get_asset_settings", "ensure_scene_settings", "check_inventory", "application_launch", "export_template", "on_pyblish_instance_toggled", "inject_avalon_js", # lib "launch", "maintained_selection", "imprint", "read", "send", "maintained_nodes_state", "save_scene", "save_scene_as", "remove", "delete_node", "find_node_by_name", "signature", "select_nodes", "get_scene_data", # Workfiles API "open_file", "save_file", "current_file", "has_unsaved_changes", "file_extensions", "work_root", ] ================================================ FILE: openpype/hosts/harmony/api/js/.eslintrc.json ================================================ { "env": { "browser": true }, "extends": "eslint:recommended", "parserOptions": { "ecmaVersion": 3 }, "rules": { "indent": [ "error", 4 ], "linebreak-style": [ "error", "unix" ], "quotes": [ "error", "single" ], "semi": [ "error", "always" ] }, "globals": { "Action": "readonly", "Backdrop": "readonly", "Button": "readonly", "Cel": "readonly", "Cel3d": "readonly", "CheckBox": "readonly", "ColorRGBA": "readonly", "ComboBox": "readonly", "DateEdit": "readonly", "DateEditEnum": "readonly", "Dialog": "readonly", "Dir": "readonly", "DirSpec": "readonly", "Drawing": "readonly", "DrawingToolParams": "readonly", "DrawingTools": "readonly", "EnvelopeCreator": "readonly", "ExportVideoDlg": "readonly", "File": "readonly", "FileAccess": "readonly", "FileDialog": "readonly", "GroupBox": "readonly", "ImportDrawingDlg": "readonly", "Input": "readonly", "KeyModifiers": "readonly", "Label": "readonly", "LayoutExports": "readonly", "LayoutExportsParams": "readonly", "LineEdit": "readonly", "Matrix4x4": "readonly", "MessageBox": "readonly", "MessageLog": "readonly", "Model3d": "readonly", "MovieImport": "readonly", "NumberEdit": "readonly", "PaletteManager": "readonly", "PaletteObjectManager": "readonly", "PermanentFile": "readonly", "Point2d": "readonly", "Point3d": "readonly", "Process": "readonly", "Process2": "readonly", "Quaternion": "readonly", "QuicktimeExporter": "readonly", "RadioButton": "readonly", "RemoteCmd": "readonly", "Scene": "readonly", "Settings": "readonly", "Slider": "readonly", "SpinBox": "readonly", "SubnodeData": "readonly", "System": "readonly", "TemporaryFile": "readonly", "TextEdit": "readonly", "TimeEdit": "readonly", "Timeline": "readonly", "ToolProperties": "readonly", "UiLoader": "readonly", "Vector2d": "readonly", "Vector3d": "readonly", "WebCCExporter": "readonly", "Workspaces": "readonly", "__scriptManager__": "readonly", "__temporaryFileContext__": "readonly", "about": "readonly", "column": "readonly", "compositionOrder": "readonly", "copyPaste": "readonly", "deformation": "readonly", "drawingExport": "readonly", "element": "readonly", "exporter": "readonly", "fileMapper": "readonly", "frame": "readonly", "func": "readonly", "library": "readonly", "node": "readonly", "preferences": "readonly", "render": "readonly", "scene": "readonly", "selection": "readonly", "sound": "readonly", "specialFolders": "readonly", "translator": "readonly", "view": "readonly", "waypoint": "readonly", "xsheet": "readonly", "QCoreApplication": "readonly" } } ================================================ FILE: openpype/hosts/harmony/api/js/AvalonHarmony.js ================================================ // *************************************************************************** // * Avalon Harmony Host * // *************************************************************************** /** * @namespace * @classdesc AvalonHarmony encapsulate all Avalon related functions. */ var AvalonHarmony = {}; /** * Get scene metadata from Harmony. * @function * @return {object} Scene metadata. */ AvalonHarmony.getSceneData = function() { var metadata = scene.metadata('avalon'); if (metadata){ return JSON.parse(metadata.value); }else { return {}; } }; /** * Set scene metadata to Harmony. * @function * @param {object} metadata Object containing metadata. */ AvalonHarmony.setSceneData = function(metadata) { scene.setMetadata({ 'name' : 'avalon', 'type' : 'string', 'creator' : 'Avalon', 'version' : '1.0', 'value' : JSON.stringify(metadata) }); }; /** * Get selected nodes in Harmony. * @function * @return {array} Selected nodes paths. */ AvalonHarmony.getSelectedNodes = function () { var selectionLength = selection.numberOfNodesSelected(); var selectedNodes = []; for (var i = 0 ; i < selectionLength; i++) { selectedNodes.push(selection.selectedNode(i)); } return selectedNodes; }; /** * Set selection of nodes. * @function * @param {array} nodes Arrya containing node paths to add to selection. */ AvalonHarmony.selectNodes = function(nodes) { selection.clearSelection(); for (var i = 0 ; i < nodes.length; i++) { selection.addNodeToSelection(nodes[i]); } }; /** * Is node enabled? * @function * @param {string} node Node path. * @return {boolean} state */ AvalonHarmony.isEnabled = function(node) { return node.getEnable(node); }; /** * Are nodes enabled? * @function * @param {array} nodes Array of node paths. * @return {array} array of boolean states. */ AvalonHarmony.areEnabled = function(nodes) { var states = []; for (var i = 0 ; i < nodes.length; i++) { states.push(node.getEnable(nodes[i])); } return states; }; /** * Set state on nodes. * @function * @param {array} args Array of nodes array and states array. */ AvalonHarmony.setState = function(args) { var nodes = args[0]; var states = args[1]; // length of both arrays must be equal. if (nodes.length !== states.length) { return false; } for (var i = 0 ; i < nodes.length; i++) { node.setEnable(nodes[i], states[i]); } return true; }; /** * Disable specified nodes. * @function * @param {array} nodes Array of nodes. */ AvalonHarmony.disableNodes = function(nodes) { for (var i = 0 ; i < nodes.length; i++) { node.setEnable(nodes[i], false); } }; /** * Save scene in Harmony. * @function * @return {string} Scene path. */ AvalonHarmony.saveScene = function() { var app = QCoreApplication.instance(); app.avalon_on_file_changed = false; scene.saveAll(); return ( scene.currentProjectPath() + '/' + scene.currentVersionName() + '.xstage' ); }; /** * Enable Harmony file-watcher. * @function */ AvalonHarmony.enableFileWather = function() { var app = QCoreApplication.instance(); app.avalon_on_file_changed = true; }; /** * Add path to file-watcher. * @function * @param {string} path Path to watch. */ AvalonHarmony.addPathToWatcher = function(path) { var app = QCoreApplication.instance(); app.watcher.addPath(path); }; /** * Setup node for Creator. * @function * @param {string} node Node path. */ AvalonHarmony.setupNodeForCreator = function(node) { node.setTextAttr(node, 'COMPOSITE_MODE', 1, 'Pass Through'); }; /** * Get node names for specified node type. * @function * @param {string} nodeType Node type. * @return {array} Node names. */ AvalonHarmony.getNodesNamesByType = function(nodeType) { var nodes = node.getNodes(nodeType); var nodeNames = []; for (var i = 0; i < nodes.length; ++i) { nodeNames.push(node.getName(nodes[i])); } return nodeNames; }; /** * Create container node in Harmony. * @function * @param {array} args Arguments, see example. * @return {string} Resulting node. * * @example * // arguments are in following order: * var args = [ * nodeName, * nodeType, * selection * ]; */ AvalonHarmony.createContainer = function(args) { var resultNode = node.add('Top', args[0], args[1], 0, 0, 0); if (args.length > 2) { node.link(args[2], 0, resultNode, 0, false, true); node.setCoord(resultNode, node.coordX(args[2]), node.coordY(args[2]) + 70); } return resultNode; }; /** * Delete node. * @function * @param {string} node Node path. */ AvalonHarmony.deleteNode = function(_node) { node.deleteNode(_node, true, true); }; ================================================ FILE: openpype/hosts/harmony/api/lib.py ================================================ # -*- coding: utf-8 -*- """Utility functions used for Avalon - Harmony integration.""" import subprocess import threading import os import random import zipfile import sys import filecmp import shutil import logging import contextlib import json import signal import time from uuid import uuid4 from qtpy import QtWidgets, QtCore, QtGui import collections from .server import Server from openpype.tools.stdout_broker.app import StdOutBroker from openpype.tools.utils import host_tools from openpype import style from openpype.lib.applications import get_non_python_host_kwargs # Setup logging. log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) class ProcessContext: server = None pid = None process = None application_path = None callback_queue = collections.deque() workfile_path = None port = None stdout_broker = None workfile_tool = None @classmethod def execute_in_main_thread(cls, func_to_call_from_main_thread): cls.callback_queue.append(func_to_call_from_main_thread) @classmethod def main_thread_listen(cls): if cls.callback_queue: callback = cls.callback_queue.popleft() callback() if cls.process is not None and cls.process.poll() is not None: log.info("Server is not running, closing") ProcessContext.stdout_broker.stop() QtWidgets.QApplication.quit() def signature(postfix="func") -> str: """Return random ECMA6 compatible function name. Args: postfix (str): name to append to random string. Returns: str: random function name. """ return "f{}_{}".format(str(uuid4()).replace("-", "_"), postfix) class _ZipFile(zipfile.ZipFile): """Extended check for windows invalid characters.""" # this is extending default zipfile table for few invalid characters # that can come from Mac _windows_illegal_characters = ":<>|\"?*\r\n\x00" _windows_illegal_name_trans_table = str.maketrans( _windows_illegal_characters, "_" * len(_windows_illegal_characters) ) def main(*subprocess_args): # coloring in StdOutBroker os.environ["OPENPYPE_LOG_NO_COLORS"] = "False" app = QtWidgets.QApplication([]) app.setQuitOnLastWindowClosed(False) icon = QtGui.QIcon(style.get_app_icon_path()) app.setWindowIcon(icon) ProcessContext.stdout_broker = StdOutBroker('harmony') ProcessContext.stdout_broker.start() launch(*subprocess_args) loop_timer = QtCore.QTimer() loop_timer.setInterval(20) loop_timer.timeout.connect(ProcessContext.main_thread_listen) loop_timer.start() sys.exit(app.exec_()) def setup_startup_scripts(): """Manages installation of avalon's TB_sceneOpened.js for Harmony launch. If a studio already has defined "TOONBOOM_GLOBAL_SCRIPT_LOCATION", copies the TB_sceneOpened.js to that location if the file is different. Otherwise, will set the env var to point to the avalon/harmony folder. Admins should be aware that this will overwrite TB_sceneOpened in the "TOONBOOM_GLOBAL_SCRIPT_LOCATION", and that if they want to have additional logic, they will need to one of the following: * Create a Harmony package to manage startup logic * Use TB_sceneOpenedUI.js instead to manage startup logic * Add their startup logic to avalon/harmony/TB_sceneOpened.js """ avalon_dcc_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "api") startup_js = "TB_sceneOpened.js" if os.getenv("TOONBOOM_GLOBAL_SCRIPT_LOCATION"): avalon_harmony_startup = os.path.join(avalon_dcc_dir, startup_js) env_harmony_startup = os.path.join( os.getenv("TOONBOOM_GLOBAL_SCRIPT_LOCATION"), startup_js) if not filecmp.cmp(avalon_harmony_startup, env_harmony_startup): try: shutil.copy(avalon_harmony_startup, env_harmony_startup) except Exception as e: log.error(e) log.warning( "Failed to copy {0} to {1}! " "Defaulting to Avalon TOONBOOM_GLOBAL_SCRIPT_LOCATION." .format(avalon_harmony_startup, env_harmony_startup)) os.environ["TOONBOOM_GLOBAL_SCRIPT_LOCATION"] = avalon_dcc_dir else: os.environ["TOONBOOM_GLOBAL_SCRIPT_LOCATION"] = avalon_dcc_dir def check_libs(): """Check if `OpenHarmony`_ is available. Avalon expects either path in `LIB_OPENHARMONY_PATH` or `openHarmony.js` present in `TOONBOOM_GLOBAL_SCRIPT_LOCATION`. Throws: RuntimeError: If openHarmony is not found. .. _OpenHarmony: https://github.com/cfourney/OpenHarmony """ if not os.getenv("LIB_OPENHARMONY_PATH"): if os.getenv("TOONBOOM_GLOBAL_SCRIPT_LOCATION"): if os.path.exists( os.path.join( os.getenv("TOONBOOM_GLOBAL_SCRIPT_LOCATION"), "openHarmony.js")): os.environ["LIB_OPENHARMONY_PATH"] = \ os.getenv("TOONBOOM_GLOBAL_SCRIPT_LOCATION") return else: log.error(("Cannot find OpenHarmony library. " "Please set path to it in LIB_OPENHARMONY_PATH " "environment variable.")) raise RuntimeError("Missing OpenHarmony library.") def launch(application_path, *args): """Set Harmony for launch. Launches Harmony and the server, then starts listening on the main thread for callbacks from the server. This is to have Qt applications run in the main thread. Args: application_path (str): Path to Harmony. """ from openpype.pipeline import install_host from openpype.hosts.harmony import api as harmony install_host(harmony) ProcessContext.port = random.randrange(49152, 65535) os.environ["AVALON_HARMONY_PORT"] = str(ProcessContext.port) ProcessContext.application_path = application_path # Launch Harmony. setup_startup_scripts() check_libs() if not os.environ.get("AVALON_HARMONY_WORKFILES_ON_LAUNCH", False): open_empty_workfile() return ProcessContext.workfile_tool = host_tools.get_tool_by_name("workfiles") host_tools.show_workfiles(save=False) ProcessContext.execute_in_main_thread(check_workfiles_tool) def check_workfiles_tool(): if ProcessContext.workfile_tool.isVisible(): ProcessContext.execute_in_main_thread(check_workfiles_tool) elif not ProcessContext.workfile_path: open_empty_workfile() def open_empty_workfile(): zip_file = os.path.join(os.path.dirname(__file__), "temp.zip") temp_path = get_local_harmony_path(zip_file) if os.path.exists(temp_path): log.info(f"removing existing {temp_path}") try: shutil.rmtree(temp_path) except Exception as e: log.critical(f"cannot clear {temp_path}") raise Exception(f"cannot clear {temp_path}") from e launch_zip_file(zip_file) def get_local_harmony_path(filepath): """From the provided path get the equivalent local Harmony path.""" basename = os.path.splitext(os.path.basename(filepath))[0] harmony_path = os.path.join(os.path.expanduser("~"), ".avalon", "harmony") return os.path.join(harmony_path, basename) def launch_zip_file(filepath): """Launch a Harmony application instance with the provided zip file. Args: filepath (str): Path to file. """ print(f"Localizing {filepath}") temp_path = get_local_harmony_path(filepath) scene_name = os.path.basename(temp_path) if os.path.exists(os.path.join(temp_path, scene_name)): # unzipped with duplicated scene_name temp_path = os.path.join(temp_path, scene_name) scene_path = os.path.join( temp_path, scene_name + ".xstage" ) unzip = False if os.path.exists(scene_path): # Check remote scene is newer than local. if os.path.getmtime(scene_path) < os.path.getmtime(filepath): try: shutil.rmtree(temp_path) except Exception as e: log.error(e) raise Exception("Cannot delete working folder") from e unzip = True else: unzip = True if unzip: with _ZipFile(filepath, "r") as zip_ref: zip_ref.extractall(temp_path) if os.path.exists(os.path.join(temp_path, scene_name)): # unzipped with duplicated scene_name temp_path = os.path.join(temp_path, scene_name) # Close existing scene. if ProcessContext.pid: os.kill(ProcessContext.pid, signal.SIGTERM) # Stop server. if ProcessContext.server: ProcessContext.server.stop() # Launch Avalon server. ProcessContext.server = Server(ProcessContext.port) ProcessContext.server.start() # thread = threading.Thread(target=self.server.start) # thread.daemon = True # thread.start() # Save workfile path for later. ProcessContext.workfile_path = filepath # find any xstage files is directory, prefer the one with the same name # as directory (plus extension) xstage_files = [] for _, _, files in os.walk(temp_path): for file in files: if os.path.splitext(file)[1] == ".xstage": xstage_files.append(file) if not os.path.basename("temp.zip"): if not xstage_files: ProcessContext.server.stop() print("no xstage file was found") return # try to use first available scene_path = os.path.join( temp_path, xstage_files[0] ) # prefer the one named as zip file zip_based_name = "{}.xstage".format( os.path.splitext(os.path.basename(filepath))[0]) if zip_based_name in xstage_files: scene_path = os.path.join( temp_path, zip_based_name ) if not os.path.exists(scene_path): print("error: cannot determine scene file {}".format(scene_path)) ProcessContext.server.stop() return print("Launching {}".format(scene_path)) kwargs = get_non_python_host_kwargs({}, False) process = subprocess.Popen( [ProcessContext.application_path, scene_path], **kwargs ) ProcessContext.pid = process.pid ProcessContext.process = process ProcessContext.stdout_broker.host_connected() def on_file_changed(path, threaded=True): """Threaded zipping and move of the project directory. This method is called when the `.xstage` file is changed. """ log.debug("File changed: " + path) if ProcessContext.workfile_path is None: return if threaded: thread = threading.Thread( target=zip_and_move, args=(os.path.dirname(path), ProcessContext.workfile_path) ) thread.start() else: zip_and_move(os.path.dirname(path), ProcessContext.workfile_path) def zip_and_move(source, destination): """Zip a directory and move to `destination`. Args: source (str): Directory to zip and move to destination. destination (str): Destination file path to zip file. """ os.chdir(os.path.dirname(source)) shutil.make_archive(os.path.basename(source), "zip", source) with _ZipFile(os.path.basename(source) + ".zip") as zr: if zr.testzip() is not None: raise Exception("File archive is corrupted.") shutil.move(os.path.basename(source) + ".zip", destination) log.debug(f"Saved '{source}' to '{destination}'") def show(tool_name): """Call show on "module_name". This allows to make a QApplication ahead of time and always "exec_" to prevent crashing. Args: module_name (str): Name of module to call "show" on. """ # Requests often get doubled up when showing tools, so we wait a second for # requests to be received properly. time.sleep(1) kwargs = {} if tool_name == "loader": kwargs["use_context"] = True ProcessContext.execute_in_main_thread( lambda: host_tools.show_tool_by_name(tool_name, **kwargs) ) # Required return statement. return "nothing" def get_scene_data(): try: return send( { "function": "AvalonHarmony.getSceneData" })["result"] except json.decoder.JSONDecodeError: # Means no scene metadata has been made before. return {} except KeyError: # Means no existing scene metadata has been made. return {} def set_scene_data(data): """Write scene data to metadata. Args: data (dict): Data to write. """ # Write scene data. send( { "function": "AvalonHarmony.setSceneData", "args": data }) def read(node_id): """Read object metadata in to a dictionary. Args: node_id (str): Path to node or id of object. Returns: dict """ scene_data = get_scene_data() if node_id in scene_data: return scene_data[node_id] return {} def remove(node_id): """ Remove node data from scene metadata. Args: node_id (str): full name (eg. 'Top/renderAnimation') """ data = get_scene_data() del data[node_id] set_scene_data(data) def delete_node(node): """ Physically delete node from scene. """ send( { "function": "AvalonHarmony.deleteNode", "args": node } ) def imprint(node_id, data, remove=False): """Write `data` to the `node` as json. Arguments: node_id (str): Path to node or id of object. data (dict): Dictionary of key/value pairs. remove (bool): Removes the data from the scene. Example: >>> from openpype.hosts.harmony.api import lib >>> node = "Top/Display" >>> data = {"str": "something", "int": 1, "float": 0.32, "bool": True} >>> lib.imprint(layer, data) """ scene_data = get_scene_data() if remove and (node_id in scene_data): scene_data.pop(node_id, None) else: if node_id in scene_data: scene_data[node_id].update(data) else: scene_data[node_id] = data set_scene_data(scene_data) @contextlib.contextmanager def maintained_selection(): """Maintain selection during context.""" selected_nodes = send( { "function": "AvalonHarmony.getSelectedNodes" })["result"] try: yield selected_nodes finally: selected_nodes = send( { "function": "AvalonHarmony.selectNodes", "args": selected_nodes } ) def send(request): """Public method for sending requests to Harmony.""" return ProcessContext.server.send(request) def select_nodes(nodes): """ Selects nodes in Node View """ _ = send( { "function": "AvalonHarmony.selectNodes", "args": nodes } ) @contextlib.contextmanager def maintained_nodes_state(nodes): """Maintain nodes states during context.""" # Collect current state. states = send( { "function": "AvalonHarmony.areEnabled", "args": nodes })["result"] # Disable all nodes. send( { "function": "AvalonHarmony.disableNodes", "args": nodes }) try: yield finally: send( { "function": "AvalonHarmony.setState", "args": [nodes, states] }) def save_scene(): """Save the Harmony scene safely. The built-in (to Avalon) background zip and moving of the Harmony scene folder, interfers with server/client communication by sending two requests at the same time. This only happens when sending "scene.saveAll()". This method prevents this double request and safely saves the scene. """ # Need to turn off the background watcher else the communication with # the server gets spammed with two requests at the same time. scene_path = send( {"function": "AvalonHarmony.saveScene"})["result"] # Manually update the remote file. on_file_changed(scene_path, threaded=False) # Re-enable the background watcher. send({"function": "AvalonHarmony.enableFileWather"}) def save_scene_as(filepath): """Save Harmony scene as `filepath`.""" scene_dir = os.path.dirname(filepath) destination = os.path.join( os.path.dirname(ProcessContext.workfile_path), os.path.splitext(os.path.basename(filepath))[0] + ".zip" ) if os.path.exists(scene_dir): try: shutil.rmtree(scene_dir) except Exception as e: log.error(f"Cannot remove {scene_dir}") raise Exception(f"Cannot remove {scene_dir}") from e send( {"function": "scene.saveAs", "args": [scene_dir]} )["result"] zip_and_move(scene_dir, destination) ProcessContext.workfile_path = destination send( {"function": "AvalonHarmony.addPathToWatcher", "args": filepath} ) def find_node_by_name(name, node_type): """Find node by its name. Args: name (str): Name of the Node. (without part before '/') node_type (str): Type of the Node. 'READ' - for loaded data with Loaders (background) 'GROUP' - for loaded data with Loaders (templates) 'WRITE' - render nodes Returns: str: FQ Node name. """ nodes = send( {"function": "node.getNodes", "args": [[node_type]]} )["result"] for node in nodes: node_name = node.split("/")[-1] if name == node_name: return node return None ================================================ FILE: openpype/hosts/harmony/api/pipeline.py ================================================ import os from pathlib import Path import logging import pyblish.api from openpype.lib import register_event_callback from openpype.pipeline import ( register_loader_plugin_path, register_creator_plugin_path, deregister_loader_plugin_path, deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) from openpype.pipeline.load import get_outdated_containers from openpype.pipeline.context_tools import get_current_project_asset from openpype.hosts.harmony import HARMONY_HOST_DIR import openpype.hosts.harmony.api as harmony log = logging.getLogger("openpype.hosts.harmony") PLUGINS_DIR = os.path.join(HARMONY_HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") def set_scene_settings(settings): """Set correct scene settings in Harmony. Args: settings (dict): Scene settings. Returns: dict: Dictionary of settings to set. """ harmony.send( {"function": "PypeHarmony.setSceneSettings", "args": settings}) def get_asset_settings(): """Get settings on current asset from database. Returns: dict: Scene data. """ asset_doc = get_current_project_asset() asset_data = asset_doc["data"] fps = asset_data.get("fps") frame_start = asset_data.get("frameStart") frame_end = asset_data.get("frameEnd") handle_start = asset_data.get("handleStart") handle_end = asset_data.get("handleEnd") resolution_width = asset_data.get("resolutionWidth") resolution_height = asset_data.get("resolutionHeight") entity_type = asset_data.get("entityType") scene_data = { "fps": fps, "frameStart": frame_start, "frameEnd": frame_end, "handleStart": handle_start, "handleEnd": handle_end, "resolutionWidth": resolution_width, "resolutionHeight": resolution_height, "entityType": entity_type } return scene_data def ensure_scene_settings(): """Validate if Harmony scene has valid settings.""" settings = get_asset_settings() invalid_settings = [] valid_settings = {} for key, value in settings.items(): if value is None: invalid_settings.append(key) else: valid_settings[key] = value # Warn about missing attributes. if invalid_settings: msg = "Missing attributes:" for item in invalid_settings: msg += f"\n{item}" harmony.send( {"function": "PypeHarmony.message", "args": msg}) set_scene_settings(valid_settings) def check_inventory(): """Check is scene contains outdated containers. If it does it will colorize outdated nodes and display warning message in Harmony. """ outdated_containers = get_outdated_containers() if not outdated_containers: return # Colour nodes. outdated_nodes = [] for container in outdated_containers: if container["loader"] == "ImageSequenceLoader": outdated_nodes.append( harmony.find_node_by_name(container["name"], "READ") ) harmony.send({"function": "PypeHarmony.setColor", "args": outdated_nodes}) # Warn about outdated containers. msg = "There are outdated containers in the scene." harmony.send({"function": "PypeHarmony.message", "args": msg}) def application_launch(event): """Event that is executed after Harmony is launched.""" # fills OPENPYPE_HARMONY_JS pype_harmony_path = Path(__file__).parent.parent / "js" / "PypeHarmony.js" pype_harmony_js = pype_harmony_path.read_text() # go through js/creators, loaders and publish folders and load all scripts script = "" for item in ["creators", "loaders", "publish"]: dir_to_scan = Path(__file__).parent.parent / "js" / item for child in dir_to_scan.iterdir(): script += child.read_text() # send scripts to Harmony harmony.send({"script": pype_harmony_js}) harmony.send({"script": script}) inject_avalon_js() # ensure_scene_settings() check_inventory() def export_template(backdrops, nodes, filepath): """Export Template to file. Args: backdrops (list): List of backdrops to export. nodes (list): List of nodes to export. filepath (str): Path where to save Template. """ harmony.send({ "function": "PypeHarmony.exportTemplate", "args": [ backdrops, nodes, os.path.basename(filepath), os.path.dirname(filepath) ] }) def install(): """Install Pype as host config.""" print("Installing Pype config ...") pyblish.api.register_host("harmony") pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) log.info(PUBLISH_PATH) # Register callbacks. pyblish.api.register_callback( "instanceToggled", on_pyblish_instance_toggled ) register_event_callback("application.launched", application_launch) def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) deregister_loader_plugin_path(LOAD_PATH) deregister_creator_plugin_path(CREATE_PATH) def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle node enabling on instance toggles.""" node = None if instance.data.get("setMembers"): node = instance.data["setMembers"][0] if node: harmony.send( { "function": "PypeHarmony.toggleInstance", "args": [node, new_value] } ) def inject_avalon_js(): """Inject AvalonHarmony.js into Harmony.""" avalon_harmony_js = Path(__file__).parent.joinpath("js/AvalonHarmony.js") script = avalon_harmony_js.read_text() # send AvalonHarmony.js to Harmony harmony.send({"script": script}) def ls(): """Yields containers from Harmony scene. This is the host-equivalent of api.ls(), but instead of listing assets on disk, it lists assets already loaded in Harmony; once loaded they are called 'containers'. Yields: dict: container """ objects = harmony.get_scene_data() or {} for _, data in objects.items(): # Skip non-tagged objects. if not data: continue # Filter to only containers. if "container" not in data.get("id"): continue if not data.get("objectName"): # backward compatibility data["objectName"] = data["name"] yield data def list_instances(remove_orphaned=True): """ List all created instances from current workfile which will be published. Pulls from File > File Info For SubsetManager, by default it check if instance has matching node in the scene, if not, instance gets deleted from metadata. Returns: (list) of dictionaries matching instances format """ objects = harmony.get_scene_data() or {} instances = [] for key, data in objects.items(): # Skip non-tagged objects. if not data: continue # Filter out containers. if "container" in data.get("id"): continue data['uuid'] = key if remove_orphaned: node_name = key.split("/")[-1] located_node = harmony.find_node_by_name(node_name, 'WRITE') if not located_node: print("Removing orphaned instance {}".format(key)) harmony.remove(key) continue instances.append(data) return instances def remove_instance(instance): """ Remove instance from current workfile metadata and from scene! Updates metadata of current file in File > File Info and removes icon highlight on group layer. For SubsetManager Args: instance (dict): instance representation from subsetmanager model """ node = instance.get("uuid") harmony.remove(node) harmony.delete_node(node) def select_instance(instance): """ Select instance in Node View Args: instance (dict): instance representation from subsetmanager model """ harmony.select_nodes([instance.get("uuid")]) def containerise(name, namespace, node, context, loader=None, suffix=None, nodes=None): """Imprint node with metadata. Containerisation enables a tracking of version, author and origin for loaded assets. Arguments: name (str): Name of resulting assembly. namespace (str): Namespace under which to host container. node (str): Node to containerise. context (dict): Asset information. loader (str, optional): Name of loader used to produce this container. suffix (str, optional): Suffix of container, defaults to `_CON`. Returns: container (str): Path of container assembly. """ if not nodes: nodes = [] data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace, "loader": str(loader), "representation": str(context["representation"]["_id"]), "nodes": nodes } harmony.imprint(node, data) return node ================================================ FILE: openpype/hosts/harmony/api/plugin.py ================================================ from openpype.pipeline import LegacyCreator import openpype.hosts.harmony.api as harmony class Creator(LegacyCreator): """Creator plugin to create instances in Harmony. By default a Composite node is created to support any number of nodes in an instance, but any node type is supported. If the selection is used, the selected nodes will be connected to the created node. """ defaults = ["Main"] node_type = "COMPOSITE" def setup_node(self, node): """Prepare node as container. Args: node (str): Path to node. """ harmony.send( { "function": "AvalonHarmony.setupNodeForCreator", "args": node } ) def process(self): """Plugin entry point.""" existing_node_names = harmony.send( { "function": "AvalonHarmony.getNodesNamesByType", "args": self.node_type })["result"] # Dont allow instances with the same name. msg = "Instance with name \"{}\" already exists.".format(self.name) for name in existing_node_names: if self.name.lower() == name.lower(): harmony.send( { "function": "AvalonHarmony.message", "args": msg } ) return False with harmony.maintained_selection() as selection: node = None if (self.options or {}).get("useSelection") and selection: node = harmony.send( { "function": "AvalonHarmony.createContainer", "args": [self.name, self.node_type, selection[-1]] } )["result"] else: node = harmony.send( { "function": "AvalonHarmony.createContainer", "args": [self.name, self.node_type] } )["result"] harmony.imprint(node, self.data) self.setup_node(node) return node ================================================ FILE: openpype/hosts/harmony/api/server.py ================================================ # -*- coding: utf-8 -*- """Server-side implementation of Toon Boon Harmony communication.""" import socket import logging import json import traceback import importlib import functools import time import struct from datetime import datetime import threading from . import lib class Server(threading.Thread): """Class for communication with Toon Boon Harmony. Attributes: connection (Socket): connection holding object. received (str): received data buffer.any(iterable) port (int): port number. message_id (int): index of last message going out. queue (dict): dictionary holding queue of incoming messages. """ def __init__(self, port): """Constructor.""" super(Server, self).__init__() self.daemon = True self.connection = None self.received = "" self.port = port self.message_id = 1 # Setup logging. self.log = logging.getLogger(__name__) self.log.setLevel(logging.DEBUG) # Create a TCP/IP socket self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Bind the socket to the port server_address = ("127.0.0.1", port) self.log.debug( f"[{self.timestamp()}] Starting up on " f"{server_address[0]}:{server_address[1]}") self.socket.bind(server_address) # Listen for incoming connections self.socket.listen(1) self.queue = {} def process_request(self, request): """Process incoming request. Args: request (dict): { "module": (str), # Module of method. "method" (str), # Name of method in module. "args" (list), # Arguments to pass to method. "kwargs" (dict), # Keyword arguments to pass to method. "reply" (bool), # Optional wait for method completion. } """ pretty = self._pretty(request) self.log.debug( f"[{self.timestamp()}] Processing request:\n{pretty}") try: module = importlib.import_module(request["module"]) method = getattr(module, request["method"]) args = request.get("args", []) kwargs = request.get("kwargs", {}) partial_method = functools.partial(method, *args, **kwargs) lib.ProcessContext.execute_in_main_thread(partial_method) except Exception: self.log.error(traceback.format_exc()) def receive(self): """Receives data from `self.connection`. When the data is a json serializable string, a reply is sent then processing of the request. """ current_time = time.time() while True: self.log.info("wait ttt") # Receive the data in small chunks and retransmit it request = None try: header = self.connection.recv(10) except OSError: # could happen on MacOS self.log.info("") break if len(header) == 0: # null data received, socket is closing. self.log.info(f"[{self.timestamp()}] Connection closing.") break if header[0:2] != b"AH": self.log.error("INVALID HEADER") content_length_str = header[2:].decode() length = int(content_length_str, 16) data = self.connection.recv(length) while (len(data) < length): # we didn't received everything in first try, lets wait for # all data. self.log.info("loop") time.sleep(0.1) if self.connection is None: self.log.error(f"[{self.timestamp()}] " "Connection is broken") break if time.time() > current_time + 30: self.log.error(f"[{self.timestamp()}] Connection timeout.") break data += self.connection.recv(length - len(data)) self.log.debug("data:: {} {}".format(data, type(data))) self.received += data.decode("utf-8") pretty = self._pretty(self.received) self.log.debug( f"[{self.timestamp()}] Received:\n{pretty}") try: request = json.loads(self.received) except json.decoder.JSONDecodeError as e: self.log.error(f"[{self.timestamp()}] " f"Invalid message received.\n{e}", exc_info=True) self.received = "" if request is None: continue if "message_id" in request.keys(): message_id = request["message_id"] self.message_id = message_id + 1 self.log.debug(f"--- storing request as {message_id}") self.queue[message_id] = request if "reply" not in request.keys(): request["reply"] = True self.send(request) self.process_request(request) if "message_id" in request.keys(): try: self.log.debug(f"[{self.timestamp()}] " f"Removing from the queue {message_id}") del self.queue[message_id] except IndexError: self.log.debug(f"[{self.timestamp()}] " f"{message_id} is no longer in queue") else: self.log.debug(f"[{self.timestamp()}] " "received data was just a reply.") def run(self): """Entry method for server. Waits for a connection on `self.port` before going into listen mode. """ # Wait for a connection timestamp = datetime.now().strftime("%H:%M:%S.%f") self.log.debug(f"[{timestamp}] Waiting for a connection.") self.connection, client_address = self.socket.accept() timestamp = datetime.now().strftime("%H:%M:%S.%f") self.log.debug(f"[{timestamp}] Connection from: {client_address}") self.receive() def stop(self): """Shutdown socket server gracefully.""" timestamp = datetime.now().strftime("%H:%M:%S.%f") self.log.debug(f"[{timestamp}] Shutting down server.") if self.connection is None: self.log.debug("Connect to shutdown.") socket.socket( socket.AF_INET, socket.SOCK_STREAM ).connect(("localhost", self.port)) self.connection.close() self.connection = None self.socket.close() def _send(self, message): """Send a message to Harmony. Args: message (str): Data to send to Harmony. """ # Wait for a connection. while not self.connection: pass timestamp = datetime.now().strftime("%H:%M:%S.%f") encoded = message.encode("utf-8") coded_message = b"AH" + struct.pack('>I', len(encoded)) + encoded pretty = self._pretty(coded_message) self.log.debug( f"[{timestamp}] Sending [{self.message_id}]:\n{pretty}") self.log.debug(f"--- Message length: {len(encoded)}") self.connection.sendall(coded_message) self.message_id += 1 def send(self, request): """Send a request in dictionary to Harmony. Waits for a reply from Harmony. Args: request (dict): Data to send to Harmony. """ request["message_id"] = self.message_id self._send(json.dumps(request)) if request.get("reply"): timestamp = datetime.now().strftime("%H:%M:%S.%f") self.log.debug( f"[{timestamp}] sent reply, not waiting for anything.") return None result = None current_time = time.time() try_index = 1 while True: time.sleep(0.1) if time.time() > current_time + 30: timestamp = datetime.now().strftime("%H:%M:%S.%f") self.log.error((f"[{timestamp}][{self.message_id}] " "No reply from Harmony in 30s. " f"Retrying {try_index}")) try_index += 1 current_time = time.time() if try_index > 30: break try: result = self.queue[request["message_id"]] timestamp = datetime.now().strftime("%H:%M:%S.%f") self.log.debug((f"[{timestamp}] Got request " f"id {self.message_id}, " "removing from queue")) del self.queue[request["message_id"]] break except KeyError: # response not in received queue yey pass try: result = json.loads(self.received) break except json.decoder.JSONDecodeError: pass self.received = "" return result def _pretty(self, message) -> str: # result = pformat(message, indent=2) # return result.replace("\\n", "\n") return "{}{}".format(4 * " ", message) def timestamp(self): """Return current timestamp as a string. Returns: str: current timestamp. """ return datetime.now().strftime("%H:%M:%S.%f") ================================================ FILE: openpype/hosts/harmony/api/workio.py ================================================ """Host API required Work Files tool""" import os import shutil from .lib import ( ProcessContext, get_local_harmony_path, zip_and_move, launch_zip_file ) # used to lock saving until previous save is done. save_disabled = False def file_extensions(): return [".zip"] def has_unsaved_changes(): if ProcessContext.server: return ProcessContext.server.send( {"function": "scene.isDirty"})["result"] return False def save_file(filepath): global save_disabled if save_disabled: return ProcessContext.server.send( { "function": "show_message", "args": "Saving in progress, please wait until it finishes." })["result"] save_disabled = True temp_path = get_local_harmony_path(filepath) if ProcessContext.server: if os.path.exists(temp_path): try: shutil.rmtree(temp_path) except Exception as e: raise Exception(f"cannot delete {temp_path}") from e ProcessContext.server.send( {"function": "scene.saveAs", "args": [temp_path]} )["result"] zip_and_move(temp_path, filepath) ProcessContext.workfile_path = filepath scene_path = os.path.join( temp_path, os.path.basename(temp_path) + ".xstage" ) ProcessContext.server.send( {"function": "AvalonHarmony.addPathToWatcher", "args": scene_path} ) else: os.environ["HARMONY_NEW_WORKFILE_PATH"] = filepath.replace("\\", "/") save_disabled = False def open_file(filepath): launch_zip_file(filepath) def current_file(): """Returning None to make Workfiles app look at first file extension.""" return None def work_root(session): return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") ================================================ FILE: openpype/hosts/harmony/js/.eslintrc.json ================================================ { "env": { "browser": true }, "extends": "eslint:recommended", "parserOptions": { "ecmaVersion": 3 }, "rules": { "indent": [ "error", 4 ], "linebreak-style": [ "error", "unix" ], "quotes": [ "error", "single" ], "semi": [ "error", "always" ] }, "globals": { "$": "readonly", "Action": "readonly", "Backdrop": "readonly", "Button": "readonly", "Cel": "readonly", "Cel3d": "readonly", "CheckBox": "readonly", "ColorRGBA": "readonly", "ComboBox": "readonly", "DateEdit": "readonly", "DateEditEnum": "readonly", "Dialog": "readonly", "Dir": "readonly", "DirSpec": "readonly", "Drawing": "readonly", "DrawingToolParams": "readonly", "DrawingTools": "readonly", "EnvelopeCreator": "readonly", "ExportVideoDlg": "readonly", "File": "readonly", "FileAccess": "readonly", "FileDialog": "readonly", "GroupBox": "readonly", "ImportDrawingDlg": "readonly", "Input": "readonly", "KeyModifiers": "readonly", "Label": "readonly", "LayoutExports": "readonly", "LayoutExportsParams": "readonly", "LineEdit": "readonly", "Matrix4x4": "readonly", "MessageBox": "readonly", "MessageLog": "readonly", "Model3d": "readonly", "MovieImport": "readonly", "NumberEdit": "readonly", "PaletteManager": "readonly", "PaletteObjectManager": "readonly", "PermanentFile": "readonly", "Point2d": "readonly", "Point3d": "readonly", "Process": "readonly", "Process2": "readonly", "Quaternion": "readonly", "QuicktimeExporter": "readonly", "RadioButton": "readonly", "RemoteCmd": "readonly", "Scene": "readonly", "Settings": "readonly", "Slider": "readonly", "SpinBox": "readonly", "SubnodeData": "readonly", "System": "readonly", "TemporaryFile": "readonly", "TextEdit": "readonly", "TimeEdit": "readonly", "Timeline": "readonly", "ToolProperties": "readonly", "UiLoader": "readonly", "Vector2d": "readonly", "Vector3d": "readonly", "WebCCExporter": "readonly", "Workspaces": "readonly", "__scriptManager__": "readonly", "__temporaryFileContext__": "readonly", "about": "readonly", "column": "readonly", "compositionOrder": "readonly", "copyPaste": "readonly", "deformation": "readonly", "drawingExport": "readonly", "element": "readonly", "exporter": "readonly", "fileMapper": "readonly", "frame": "readonly", "func": "readonly", "library": "readonly", "node": "readonly", "preferences": "readonly", "render": "readonly", "scene": "readonly", "selection": "readonly", "sound": "readonly", "specialFolders": "readonly", "translator": "readonly", "view": "readonly", "waypoint": "readonly", "xsheet": "readonly" } } ================================================ FILE: openpype/hosts/harmony/js/PypeHarmony.js ================================================ /* global include */ // *************************************************************************** // * Pype Harmony Host * // *************************************************************************** var LD_OPENHARMONY_PATH = System.getenv('LIB_OPENHARMONY_PATH'); LD_OPENHARMONY_PATH = LD_OPENHARMONY_PATH + '/openHarmony.js'; LD_OPENHARMONY_PATH = LD_OPENHARMONY_PATH.replace(/\\/g, "/"); /** * @namespace * @classdesc PypeHarmony encapsulate all Pype related functions. * @property {Object} loaders Namespace for Loaders JS code. * @property {Object} Creators Namespace for Creators JS code. * @property {Object} Publish Namespace for Publish plugins JS code. */ var PypeHarmony = { Loaders: {}, Creators: {}, Publish: {} }; /** * Show message in Harmony. * @function * @param {string} message Argument containing message. */ PypeHarmony.message = function(message) { MessageBox.information(message); }; /** * Set scene setting based on shot/asset settngs. * @function * @param {obj} settings Scene settings. */ PypeHarmony.setSceneSettings = function(settings) { if (settings.fps) { scene.setFrameRate(settings.fps); } if (settings.frameStart && settings.frameEnd) { var duration = settings.frameEnd - settings.frameStart + 1; if (frame.numberOf() > duration) { frame.remove(duration, frame.numberOf() - duration); } if (frame.numberOf() < duration) { frame.insert(duration, duration - frame.numberOf()); } scene.setStartFrame(1); scene.setStopFrame(duration); } if (settings.resolutionWidth && settings.resolutionHeight) { scene.setDefaultResolution( settings.resolutionWidth, settings.resolutionHeight, 41.112 ); } }; /** * Get scene settings. * @function * @return {array} Scene settings. */ PypeHarmony.getSceneSettings = function() { return [ about.getApplicationPath(), scene.currentProjectPath(), scene.currentScene(), scene.getFrameRate(), scene.getStartFrame(), scene.getStopFrame(), sound.getSoundtrackAll().path(), scene.defaultResolutionX(), scene.defaultResolutionY(), scene.defaultResolutionFOV() ]; }; /** * Set color of nodes. * @function * @param {array} nodes List of nodes. * @param {array} rgba array of RGBA components of color. */ PypeHarmony.setColor = function(nodes, rgba) { for (var i =0; i <= nodes.length - 1; ++i) { var color = PypeHarmony.color(rgba); node.setColor(nodes[i], color); } }; /** * Extract Template into file. * @function * @param {array} args Arguments for template extraction. * * @example * // arguments are in this order: * var args = [backdrops, nodes, templateFilename, templateDir]; * */ PypeHarmony.exportTemplate = function(args) { var tempNode = node.add('Top', 'temp_note', 'NOTE', 0, 0, 0); var templateGroup = node.createGroup(tempNode, 'temp_group'); node.deleteNode( templateGroup + '/temp_note' ); selection.clearSelection(); for (var f = 0; f < args[1].length; f++) { selection.addNodeToSelection(args[1][f]); } Action.perform('copy()', 'Node View'); selection.clearSelection(); selection.addNodeToSelection(templateGroup); Action.perform('onActionEnterGroup()', 'Node View'); Action.perform('paste()', 'Node View'); // Recreate backdrops in group. for (var i = 0; i < args[0].length; i++) { MessageLog.trace(args[0][i]); Backdrop.addBackdrop(templateGroup, args[0][i]); } Action.perform('selectAll()', 'Node View' ); copyPaste.createTemplateFromSelection(args[2], args[3]); // Unfocus the group in Node view, delete all nodes and backdrops // created during the process. Action.perform('onActionUpToParent()', 'Node View'); node.deleteNode(templateGroup, true, true); }; /** * Toggle instance in Harmony. * @function * @param {array} args Instance name and value. */ PypeHarmony.toggleInstance = function(args) { node.setEnable(args[0], args[1]); }; /** * Delete node in Harmony. * @function * @param {string} _node Node name. */ PypeHarmony.deleteNode = function(_node) { node.deleteNode(_node, true, true); }; /** * Copy file. * @function * @param {string} src Source file name. * @param {string} dst Destination file name. */ PypeHarmony.copyFile = function(src, dst) { var srcFile = new PermanentFile(src); var dstFile = new PermanentFile(dst); srcFile.copy(dstFile); }; /** * create RGBA color from array. * @function * @param {array} rgba array of rgba values. * @return {ColorRGBA} ColorRGBA Harmony class. */ PypeHarmony.color = function(rgba) { return new ColorRGBA(rgba[0], rgba[1], rgba[2], rgba[3]); }; /** * get all dependencies for given node. * @function * @param {string} _node node path. * @return {array} List of dependent nodes. */ PypeHarmony.getDependencies = function(_node) { var target_node = _node; var numInput = node.numberOfInputPorts(target_node); var dependencies = []; for (var i = 0 ; i < numInput; i++) { dependencies.push(node.srcNode(target_node, i)); } return dependencies; }; /** * return version of running Harmony instance. * @function * @return {array} [major_version, minor_version] */ PypeHarmony.getVersion = function() { return [ about.getMajorVersion(), about.getMinorVersion() ]; }; ================================================ FILE: openpype/hosts/harmony/js/README.md ================================================ ## Pype - ToonBoom Harmony integration ### Development #### Setting up ESLint as linter for javascript code You nee [node.js](https://nodejs.org/en/) installed. All you need to do then is to run: ```sh npm install ``` in **js** directory. This will install eslint and all requirements locally. In [Atom](https://atom.io/) it is enough to install [linter-eslint](https://atom.io/packages/lintecr-eslint) and set global *npm* prefix in its settings. ================================================ FILE: openpype/hosts/harmony/js/creators/CreateRender.js ================================================ /* global PypeHarmony:writable, include */ // *************************************************************************** // * CreateRender * // *************************************************************************** // check if PypeHarmony is defined and if not, load it. if (typeof PypeHarmony === 'undefined') { var OPENPYPE_HARMONY_JS = System.getenv('OPENPYPE_HARMONY_JS') + '/PypeHarmony.js'; include(OPENPYPE_HARMONY_JS.replace(/\\/g, "/")); } /** * @namespace * @classdesc Code creating render containers in Harmony. */ var CreateRender = function() {}; /** * Create render instance. * @function * @param {array} args Arguments for instance. */ CreateRender.prototype.create = function(args) { node.setTextAttr(args[0], 'DRAWING_TYPE', 1, 'PNG4'); node.setTextAttr(args[0], 'DRAWING_NAME', 1, args[1]); node.setTextAttr(args[0], 'MOVIE_PATH', 1, args[1]); }; // add self to Pype Loaders PypeHarmony.Creators.CreateRender = new CreateRender(); ================================================ FILE: openpype/hosts/harmony/js/loaders/ImageSequenceLoader.js ================================================ /* global PypeHarmony:writable, include */ // *************************************************************************** // * ImageSequenceLoader * // *************************************************************************** // check if PypeHarmony is defined and if not, load it. if (typeof PypeHarmony === 'undefined') { var OPENPYPE_HARMONY_JS = System.getenv('OPENPYPE_HARMONY_JS') + '/PypeHarmony.js'; include(OPENPYPE_HARMONY_JS.replace(/\\/g, "/")); } if (typeof $ === 'undefined'){ $ = this.__proto__['$']; } /** * @namespace * @classdesc Image Sequence loader JS code. */ var ImageSequenceLoader = function() { this.PNGTransparencyMode = 0; // Premultiplied with Black this.TGATransparencyMode = 0; // Premultiplied with Black this.SGITransparencyMode = 0; // Premultiplied with Black this.LayeredPSDTransparencyMode = 1; // Straight this.FlatPSDTransparencyMode = 2; // Premultiplied with White }; ImageSequenceLoader.getCurrentGroup = function () { var doc = $.scn; var nodeView = ''; for (var i = 0; i < 200; i++) { nodeView = 'View' + (i); if (view.type(nodeView) == 'Node View') { break; } } if (!nodeView) { $.alert('You must have a Node View open!', 'No Node View is currently open!\n' + 'Open a Node View and Try Again.', 'OK!'); return; } var currentGroup; if (!nodeView) { currentGroup = doc.root; } else { currentGroup = doc.$node(view.group(nodeView)); } return currentGroup.path; }; /** * Get unique column name. * @function * @param {string} columnPrefix Column name. * @return {string} Unique column name. */ ImageSequenceLoader.getUniqueColumnName = function(columnPrefix) { var suffix = 0; // finds if unique name for a column var columnName = columnPrefix; while (suffix < 2000) { if (!column.type(columnName)) { break; } suffix = suffix + 1; columnName = columnPrefix + '_' + suffix; } return columnName; }; /** * Import file sequences into Harmony. * @function * @param {object} args Arguments for import, see Example. * @return {string} Read node name * * @example * // Arguments are in following order: * var args = [ * files, // Files in file sequences. * asset, // Asset name. * subset, // Subset name. * startFrame, // Sequence starting frame. * groupId // Unique group ID (uuid4). * ]; */ ImageSequenceLoader.prototype.importFiles = function(args) { MessageLog.trace("ImageSequence:: " + typeof PypeHarmony); MessageLog.trace("ImageSequence $:: " + typeof $); MessageLog.trace("ImageSequence OH:: " + typeof PypeHarmony.OpenHarmony); var PNGTransparencyMode = 0; // Premultiplied with Black var TGATransparencyMode = 0; // Premultiplied with Black var SGITransparencyMode = 0; // Premultiplied with Black var LayeredPSDTransparencyMode = 1; // Straight var FlatPSDTransparencyMode = 2; // Premultiplied with White var doc = $.scn; var files = args[0]; var asset = args[1]; var subset = args[2]; var startFrame = args[3]; var groupId = args[4]; var vectorFormat = null; var extension = null; var filename = files[0]; var pos = filename.lastIndexOf('.'); if (pos < 0) { return null; } // Get the current group var currentGroup = doc.$node(ImageSequenceLoader.getCurrentGroup()); // Get a unique iterative name for the container read node var num = 0; var name = ''; do { name = asset + '_' + (num++) + '_' + subset; } while (currentGroup.getNodeByName(name) != null); extension = filename.substr(pos+1).toLowerCase(); if (extension == 'jpeg') { extension = 'jpg'; } if (extension == 'tvg') { vectorFormat = 'TVG'; extension ='SCAN'; // element.add() will use this. } var elemId = element.add( name, 'BW', scene.numberOfUnitsZ(), extension.toUpperCase(), vectorFormat ); if (elemId == -1) { // hum, unknown file type most likely -- let's skip it. return null; // no read to add. } var uniqueColumnName = ImageSequenceLoader.getUniqueColumnName(name); column.add(uniqueColumnName, 'DRAWING'); column.setElementIdOfDrawing(uniqueColumnName, elemId); var read = node.add(currentGroup, name, 'READ', 0, 0, 0); var transparencyAttr = node.getAttr( read, frame.current(), 'READ_TRANSPARENCY' ); var opacityAttr = node.getAttr(read, frame.current(), 'OPACITY'); transparencyAttr.setValue(true); opacityAttr.setValue(true); var alignmentAttr = node.getAttr(read, frame.current(), 'ALIGNMENT_RULE'); alignmentAttr.setValue('ASIS'); var transparencyModeAttr = node.getAttr( read, frame.current(), 'applyMatteToColor' ); if (extension === 'png') { transparencyModeAttr.setValue(PNGTransparencyMode); } if (extension === 'tga') { transparencyModeAttr.setValue(TGATransparencyMode); } if (extension === 'sgi') { transparencyModeAttr.setValue(SGITransparencyMode); } if (extension === 'psd') { transparencyModeAttr.setValue(FlatPSDTransparencyMode); } if (extension === 'jpg') { transparencyModeAttr.setValue(LayeredPSDTransparencyMode); } var drawingFilePath; var timing; node.linkAttr(read, 'DRAWING.ELEMENT', uniqueColumnName); if (files.length === 1) { // Create a drawing drawing, 'true' indicate that the file exists. Drawing.create(elemId, 1, true); // Get the actual path, in tmp folder. drawingFilePath = Drawing.filename(elemId, '1'); PypeHarmony.copyFile(files[0], drawingFilePath); // Expose the image for the entire frame range. for (var i =0; i <= frame.numberOf() - 1; ++i) { timing = startFrame + i; column.setEntry(uniqueColumnName, 1, timing, '1'); } } else { // Create a drawing for each file. for (var j =0; j <= files.length - 1; ++j) { timing = startFrame + j; // Create a drawing drawing, 'true' indicate that the file exists. Drawing.create(elemId, timing, true); // Get the actual path, in tmp folder. drawingFilePath = Drawing.filename(elemId, timing.toString()); PypeHarmony.copyFile(files[j], drawingFilePath); column.setEntry(uniqueColumnName, 1, timing, timing.toString()); } } var greenColor = new ColorRGBA(0, 255, 0, 255); node.setColor(read, greenColor); // Add uuid to attribute of the container read node node.createDynamicAttr(read, 'STRING', 'uuid', 'uuid', false); node.setTextAttr(read, 'uuid', 1.0, groupId); return read; }; /** * Replace files sequences in Harmony. * @function * @param {object} args Arguments for import, see Example. * @return {string} Read node name * * @example * // Arguments are in following order: * var args = [ * files, // Files in file sequences * name, // Node name * startFrame // Sequence starting frame * ]; */ ImageSequenceLoader.prototype.replaceFiles = function(args) { var files = args[0]; MessageLog.trace(files); MessageLog.trace(files.length); var _node = args[1]; var startFrame = args[2]; var _column = node.linkedColumn(_node, 'DRAWING.ELEMENT'); var elemId = column.getElementIdOfDrawing(_column); // Delete existing drawings. var timings = column.getDrawingTimings(_column); for ( var i =0; i <= timings.length - 1; ++i) { column.deleteDrawingAt(_column, parseInt(timings[i])); } var filename = files[0]; var pos = filename.lastIndexOf('.'); if (pos < 0) { return null; } var extension = filename.substr(pos+1).toLowerCase(); if (extension === 'jpeg') { extension = 'jpg'; } var transparencyModeAttr = node.getAttr( _node, frame.current(), 'applyMatteToColor' ); if (extension === 'png') { transparencyModeAttr.setValue(this.PNGTransparencyMode); } if (extension === 'tga') { transparencyModeAttr.setValue(this.TGATransparencyMode); } if (extension === 'sgi') { transparencyModeAttr.setValue(this.SGITransparencyMode); } if (extension == 'psd') { transparencyModeAttr.setValue(this.FlatPSDTransparencyMode); } if (extension === 'jpg') { transparencyModeAttr.setValue(this.LayeredPSDTransparencyMode); } var drawingFilePath; var timing; if (files.length == 1) { // Create a drawing drawing, 'true' indicate that the file exists. Drawing.create(elemId, 1, true); // Get the actual path, in tmp folder. drawingFilePath = Drawing.filename(elemId, '1'); PypeHarmony.copyFile(files[0], drawingFilePath); MessageLog.trace(files[0]); MessageLog.trace(drawingFilePath); // Expose the image for the entire frame range. for (var k =0; k <= frame.numberOf() - 1; ++k) { timing = startFrame + k; column.setEntry(_column, 1, timing, '1'); } } else { // Create a drawing for each file. for (var l =0; l <= files.length - 1; ++l) { timing = startFrame + l; // Create a drawing drawing, 'true' indicate that the file exists. Drawing.create(elemId, timing, true); // Get the actual path, in tmp folder. drawingFilePath = Drawing.filename(elemId, timing.toString()); PypeHarmony.copyFile( files[l], drawingFilePath ); column.setEntry(_column, 1, timing, timing.toString()); } } var greenColor = new ColorRGBA(0, 255, 0, 255); node.setColor(_node, greenColor); }; // add self to Pype Loaders PypeHarmony.Loaders.ImageSequenceLoader = new ImageSequenceLoader(); ================================================ FILE: openpype/hosts/harmony/js/loaders/TemplateLoader.js ================================================ /* global PypeHarmony:writable, include */ // *************************************************************************** // * TemplateLoader * // *************************************************************************** // check if PypeHarmony is defined and if not, load it. if (typeof PypeHarmony === 'undefined') { var OPENPYPE_HARMONY_JS = System.getenv('OPENPYPE_HARMONY_JS') + '/PypeHarmony.js'; include(OPENPYPE_HARMONY_JS.replace(/\\/g, "/")); } if (typeof $ === 'undefined'){ $ = this.__proto__['$']; } /** * @namespace * @classdesc Image Sequence loader JS code. */ var TemplateLoader = function() {}; /** * Load template as container. * @function * @param {array} args Arguments, see example. * @return {string} Name of container. * * @example * // arguments are in following order: * var args = [ * templatePath, // Path to tpl file. * assetName, // Asset name. * subsetName, // Subset name. * groupId // unique ID (uuid4) * ]; */ TemplateLoader.prototype.loadContainer = function(args) { var doc = $.scn; var templatePath = args[0]; var assetName = args[1]; var subset = args[2]; var groupId = args[3]; // Get the current group var nodeViewWidget = $.app.getWidgetByName('Node View'); if (!nodeViewWidget) { $.alert('You must have a Node View open!', 'No Node View!', 'OK!'); return; } nodeViewWidget.setFocus(); var currentGroup; var nodeView = view.currentView(); if (!nodeView) { currentGroup = doc.root; } else { currentGroup = doc.$node(view.group(nodeView)); } // Get a unique iterative name for the container group var num = 0; var containerGroupName = ''; do { containerGroupName = assetName + '_' + (num++) + '_' + subset; } while (currentGroup.getNodeByName(containerGroupName) != null); // import the template var tplNodes = currentGroup.importTemplate(templatePath); MessageLog.trace(tplNodes); // Create the container group var groupNode = currentGroup.addGroup( containerGroupName, false, false, tplNodes); // Add uuid to attribute of the container group node.createDynamicAttr(groupNode, 'STRING', 'uuid', 'uuid', false); node.setTextAttr(groupNode, 'uuid', 1.0, groupId); return String(groupNode); }; /** * Replace existing node container. * @function * @param {string} dstNodePath Harmony path to destination Node. * @param {string} srcNodePath Harmony path to source Node. * @param {string} renameSrc ... * @param {boolean} cloneSrc ... * @return {boolean} Success * @todo This is work in progress. */ TemplateLoader.prototype.replaceNode = function( dstNodePath, srcNodePath, renameSrc, cloneSrc) { var doc = $.scn; var srcNode = doc.$node(srcNodePath); var dstNode = doc.$node(dstNodePath); // var dstNodeName = dstNode.name; var replacementNode = srcNode; // var dstGroup = dstNode.group; $.beginUndo(); if (cloneSrc) { replacementNode = doc.$node( $.nodeTools.copy_paste_node( srcNodePath, dstNode.name + '_CLONE', dstNode.group.path)); } else { if (replacementNode.group.path != srcNode.group.path) { replacementNode.moveToGroup(dstNode); } } var inLinks = dstNode.getInLinks(); var link, inNode, inPort, outPort, outNode, success; for (var l in inLinks) { if (Object.prototype.hasOwnProperty.call(inLinks, l)) { link = inLinks[l]; inPort = Number(link.inPort); outPort = Number(link.outPort); outNode = link.outNode; success = replacementNode.linkInNode(outNode, inPort, outPort); if (success) { $.log('Successfully connected ' + outNode + ' : ' + outPort + ' -> ' + replacementNode + ' : ' + inPort); } else { $.alert('Failed to connect ' + outNode + ' : ' + outPort + ' -> ' + replacementNode + ' : ' + inPort); } } } var outLinks = dstNode.getOutLinks(); for (l in outLinks) { if (Object.prototype.hasOwnProperty.call(outLinks, l)) { link = outLinks[l]; inPort = Number(link.inPort); outPort = Number(link.outPort); inNode = link.inNode; // first we must disconnect the port from the node being // replaced to this links inNode port inNode.unlinkInPort(inPort); success = replacementNode.linkOutNode(inNode, outPort, inPort); if (success) { $.log('Successfully connected ' + inNode + ' : ' + inPort + ' <- ' + replacementNode + ' : ' + outPort); } else { if (inNode.type == 'MultiLayerWrite') { $.log('Attempting standard api to connect the nodes...'); success = node.link( replacementNode, outPort, inNode, inPort, node.numberOfInputPorts(inNode) + 1); if (success) { $.log('Successfully connected ' + inNode + ' : ' + inPort + ' <- ' + replacementNode + ' : ' + outPort); } } } if (!success) { $.alert('Failed to connect ' + inNode + ' : ' + inPort + ' <- ' + replacementNode + ' : ' + outPort); return false; } } } }; TemplateLoader.prototype.askForColumnsUpdate = function() { // Ask user if they want to also update columns and // linked attributes here return ($.confirm( 'Would you like to update in place and reconnect all \n' + 'ins/outs, attributes, and columns?', 'Update & Replace?\n' + 'If you choose No, the version will only be loaded.', 'Yes', 'No')); }; // add self to Pype Loaders PypeHarmony.Loaders.TemplateLoader = new TemplateLoader(); ================================================ FILE: openpype/hosts/harmony/js/publish/CollectCurrentFile.js ================================================ /* global PypeHarmony:writable, include */ // *************************************************************************** // * CollectCurrentFile * // *************************************************************************** // check if PypeHarmony is defined and if not, load it. if (typeof PypeHarmony === 'undefined') { var OPENPYPE_HARMONY_JS = System.getenv('OPENPYPE_HARMONY_JS') + '/PypeHarmony.js'; include(OPENPYPE_HARMONY_JS.replace(/\\/g, "/")); } /** * @namespace * @classdesc Collect Current file */ var CollectCurrentFile = function() {}; CollectCurrentFile.prototype.collect = function() { return ( scene.currentProjectPath() + '/' + scene.currentVersionName() + '.xstage' ); }; // add self to Pype Loaders PypeHarmony.Publish.CollectCurrentFile = new CollectCurrentFile(); ================================================ FILE: openpype/hosts/harmony/js/publish/CollectFarmRender.js ================================================ /* global PypeHarmony:writable, include */ // *************************************************************************** // * CollectFarmRender * // *************************************************************************** // check if PypeHarmony is defined and if not, load it. if (typeof PypeHarmony === 'undefined') { var OPENPYPE_HARMONY_JS = System.getenv('OPENPYPE_HARMONY_JS') + '/PypeHarmony.js'; include(OPENPYPE_HARMONY_JS.replace(/\\/g, "/")); } /** * @namespace * @classdesc Image Sequence loader JS code. */ var CollectFarmRender = function() {}; /** * Get information important for render output. * @function * @param node {String} node name. * @return {array} array of render info. * * @example * * var ret = [ * file_prefix, // like foo/bar- * type, // PNG4, ... * leading_zeros, // 3 - for 0001 * start // start frame * ] */ CollectFarmRender.prototype.getRenderNodeSettings = function(n) { // this will return var output = [ node.getTextAttr( n, frame.current(), 'DRAWING_NAME'), node.getTextAttr( n, frame.current(), 'DRAWING_TYPE'), node.getTextAttr( n, frame.current(), 'LEADING_ZEROS'), node.getTextAttr(n, frame.current(), 'START'), node.getEnable(n) ]; return output; }; // add self to Pype Loaders PypeHarmony.Publish.CollectFarmRender = new CollectFarmRender(); ================================================ FILE: openpype/hosts/harmony/js/publish/CollectPalettes.js ================================================ /* global PypeHarmony:writable, include */ // *************************************************************************** // * CollectPalettes * // *************************************************************************** // check if PypeHarmony is defined and if not, load it. if (typeof PypeHarmony === 'undefined') { var OPENPYPE_HARMONY_JS = System.getenv('OPENPYPE_HARMONY_JS') + '/PypeHarmony.js'; include(OPENPYPE_HARMONY_JS.replace(/\\/g, "/")); } /** * @namespace * @classdesc Image Sequence loader JS code. */ var CollectPalettes = function() {}; CollectPalettes.prototype.getPalettes = function() { var palette_list = PaletteObjectManager.getScenePaletteList(); var palettes = {}; for(var i=0; i < palette_list.numPalettes; ++i) { var palette = palette_list.getPaletteByIndex(i); palettes[palette.getName()] = palette.id; } return palettes; }; // add self to Pype Loaders PypeHarmony.Publish.CollectPalettes = new CollectPalettes(); ================================================ FILE: openpype/hosts/harmony/js/publish/ExtractPalette.js ================================================ /* global PypeHarmony:writable, include */ // *************************************************************************** // * ExtractPalette * // *************************************************************************** // check if PypeHarmony is defined and if not, load it. if (typeof PypeHarmony === 'undefined') { var OPENPYPE_HARMONY_JS = System.getenv('OPENPYPE_HARMONY_JS') + '/PypeHarmony.js'; include(OPENPYPE_HARMONY_JS.replace(/\\/g, "/")); } /** * @namespace * @classdesc Code for extracting palettes. */ var ExtractPalette = function() {}; /** * Get palette from Harmony. * @function * @param {string} paletteId ID of palette to get. * @return {array} [paletteName, palettePath] */ ExtractPalette.prototype.getPalette = function(paletteId) { var palette_list = PaletteObjectManager.getScenePaletteList(); var palette = palette_list.getPaletteById(paletteId); var palette_name = palette.getName(); return [ palette_name, (palette.getPath() + '/' + palette.getName() + '.plt') ]; }; // add self to Pype Loaders PypeHarmony.Publish.ExtractPalette = new ExtractPalette(); ================================================ FILE: openpype/hosts/harmony/js/publish/ExtractTemplate.js ================================================ /* global PypeHarmony:writable, include */ // *************************************************************************** // * ExtractTemplate * // *************************************************************************** // check if PypeHarmony is defined and if not, load it. if (typeof PypeHarmony === 'undefined') { var OPENPYPE_HARMONY_JS = System.getenv('OPENPYPE_HARMONY_JS') + '/PypeHarmony.js'; include(OPENPYPE_HARMONY_JS.replace(/\\/g, "/")); } /** * @namespace * @classdesc Code for extracting palettes. */ var ExtractTemplate = function() {}; /** * Get backdrops for given node. * @function * @param {string} probeNode Node path to probe for backdrops. * @return {array} list of backdrops. */ ExtractTemplate.prototype.getBackdropsByNode = function(probeNode) { var backdrops = Backdrop.backdrops('Top'); var valid_backdrops = []; for(var i=0; i> 1 - 10 # frameStart, frameEnd already collected by global plugin offset = context.data["frameStart"] - 1 frame_start = context.data["frameStart"] - offset frames_count = context.data["frameEnd"] - \ context.data["frameStart"] + 1 # increase by handleStart - real frame range # frameStart != frameStartHandle with handle presence context.data["frameStart"] = int(frame_start) + \ context.data["handleStart"] context.data["frameEnd"] = int(frames_count) + \ context.data["frameStart"] - 1 all_nodes = harmony.send( {"function": "node.subNodes", "args": ["Top"]} )["result"] context.data["allNodes"] = all_nodes # collect all write nodes to be able disable them in Deadline all_write_nodes = harmony.send( {"function": "node.getNodes", "args": ["WRITE"]} )["result"] context.data["all_write_nodes"] = all_write_nodes result = harmony.send( { f"function": "PypeHarmony.getVersion", "args": []} )["result"] context.data["harmonyVersion"] = "{}.{}".format(result[0], result[1]) ================================================ FILE: openpype/hosts/harmony/plugins/publish/collect_workfile.py ================================================ # -*- coding: utf-8 -*- """Collect current workfile from Harmony.""" import os import pyblish.api from openpype.pipeline.create import get_subset_name class CollectWorkfile(pyblish.api.ContextPlugin): """Collect current script for publish.""" order = pyblish.api.CollectorOrder + 0.1 label = "Collect Workfile" hosts = ["harmony"] def process(self, context): """Plugin entry point.""" family = "workfile" basename = os.path.basename(context.data["currentFile"]) subset = get_subset_name( family, "", context.data["anatomyData"]["task"]["name"], context.data["assetEntity"], context.data["anatomyData"]["project"]["name"], host_name=context.data["hostName"], project_settings=context.data["project_settings"] ) # Create instance instance = context.create_instance(subset) instance.data.update({ "subset": subset, "label": basename, "name": basename, "family": family, "families": [family], "representations": [], "asset": context.data["asset"] }) ================================================ FILE: openpype/hosts/harmony/plugins/publish/extract_palette.py ================================================ # -*- coding: utf-8 -*- """Extract palette from Harmony.""" import os import csv from PIL import Image, ImageDraw, ImageFont import openpype.hosts.harmony.api as harmony from openpype.pipeline import publish class ExtractPalette(publish.Extractor): """Extract palette.""" label = "Extract Palette" hosts = ["harmony"] families = ["harmony.palette"] def process(self, instance): """Plugin entry point.""" self_name = self.__class__.__name__ result = harmony.send( { "function": f"PypeHarmony.Publish.{self_name}.getPalette", "args": instance.data["id"] })["result"] if not isinstance(result, list): self.log.error(f"Invalid reply: {result}") raise AssertionError("Invalid reply from server.") palette_name = result[0] palette_file = result[1] self.log.info(f"Got palette named {palette_name} " f"and file {palette_file}.") tmp_thumb_path = os.path.join(os.path.dirname(palette_file), os.path.basename(palette_file) .split(".plt")[0] + "_swatches.png" ) self.log.info(f"Temporary thumbnail path {tmp_thumb_path}") palette_version = str(instance.data.get("version")).zfill(3) self.log.info(f"Palette version {palette_version}") if not instance.data.get("representations"): instance.data["representations"] = [] try: thumbnail_path = self.create_palette_thumbnail(palette_name, palette_version, palette_file, tmp_thumb_path) except OSError as e: # FIXME: this happens on Mac where PIL cannot access fonts # for some reason. self.log.warning("Thumbnail generation failed") self.log.warning(e) except ValueError: self.log.error("Unsupported palette type for thumbnail.") else: thumbnail = { "name": "thumbnail", "ext": "png", "files": os.path.basename(thumbnail_path), "stagingDir": os.path.dirname(thumbnail_path), "tags": ["thumbnail"] } instance.data["representations"].append(thumbnail) representation = { "name": "plt", "ext": "plt", "files": os.path.basename(palette_file), "stagingDir": os.path.dirname(palette_file) } instance.data["representations"].append(representation) def create_palette_thumbnail(self, palette_name, palette_version, palette_path, dst_path): """Create thumbnail for palette file. Args: palette_name (str): Name of palette. palette_version (str): Version of palette. palette_path (str): Path to palette file. dst_path (str): Thumbnail path. Returns: str: Thumbnail path. """ colors = {} with open(palette_path, newline='') as plt: plt_parser = csv.reader(plt, delimiter=" ") for i, line in enumerate(plt_parser): if i == 0: continue while ("" in line): line.remove("") # self.log.debug(line) if line[0] not in ["Solid"]: raise ValueError("Unsupported palette type.") color_name = line[1].strip('"') colors[color_name] = {"type": line[0], "uuid": line[2], "rgba": (int(line[3]), int(line[4]), int(line[5]), int(line[6])), } plt.close() img_pad_top = 80 label_pad_name = 30 label_pad_rgb = 580 swatch_pad_left = 300 swatch_pad_top = 10 swatch_w = 120 swatch_h = 50 image_w = 800 image_h = (img_pad_top + (len(colors.keys()) * swatch_h) + (swatch_pad_top * len(colors.keys())) ) img = Image.new("RGBA", (image_w, image_h), "white") # For bg of colors with alpha, create checkerboard image checkers = Image.new("RGB", (swatch_w, swatch_h)) pixels = checkers.load() # Make pixels white where (row+col) is odd for i in range(swatch_w): for j in range(swatch_h): if (i + j) % 2: pixels[i, j] = (255, 255, 255) draw = ImageDraw.Draw(img) # TODO: This needs to be font included with Pype because # arial is not available on other platforms then Windows. title_font = ImageFont.truetype("arial.ttf", 28) label_font = ImageFont.truetype("arial.ttf", 20) draw.text((label_pad_name, 20), "{} (v{})".format(palette_name, palette_version), "black", font=title_font) for i, name in enumerate(colors): rgba = colors[name]["rgba"] # @TODO: Fix this so alpha colors are displayed with checkboard # if not rgba[3] == "255": # img.paste(checkers, # (swatch_pad_left, # img_pad_top + swatch_pad_top + (i * swatch_h)) # ) # # half_y = (img_pad_top + swatch_pad_top + (i * swatch_h))/2 # # draw.rectangle(( # swatch_pad_left, # upper LX # img_pad_top + swatch_pad_top + (i * swatch_h), # upper LY # swatch_pad_left + (swatch_w * 2), # lower RX # half_y), # lower RY # fill=rgba[:-1], outline=(0, 0, 0), width=2) # draw.rectangle(( # swatch_pad_left, # upper LX # half_y, # upper LY # swatch_pad_left + (swatch_w * 2), # lower RX # img_pad_top + swatch_h + (i * swatch_h)), # lower RY # fill=rgba, outline=(0, 0, 0), width=2) # else: draw.rectangle(( swatch_pad_left, # upper left x img_pad_top + swatch_pad_top + (i * swatch_h), # upper left y swatch_pad_left + (swatch_w * 2), # lower right x img_pad_top + swatch_h + (i * swatch_h)), # lower right y fill=rgba, outline=(0, 0, 0), width=2) draw.text((label_pad_name, img_pad_top + (i * swatch_h) + swatch_pad_top + (swatch_h / 4)), # noqa: E501 name, "black", font=label_font) draw.text((label_pad_rgb, img_pad_top + (i * swatch_h) + swatch_pad_top + (swatch_h / 4)), # noqa: E501 str(rgba), "black", font=label_font) draw = ImageDraw.Draw(img) img.save(dst_path) return dst_path ================================================ FILE: openpype/hosts/harmony/plugins/publish/extract_render.py ================================================ import os import tempfile import subprocess import pyblish.api import openpype.hosts.harmony.api as harmony import openpype.lib import clique class ExtractRender(pyblish.api.InstancePlugin): """Produce a flattened image file from instance. This plug-in only takes into account the nodes connected to the composite. """ label = "Extract Render" order = pyblish.api.ExtractorOrder hosts = ["harmony"] families = ["render"] def process(self, instance): # Collect scene data. application_path = instance.context.data.get("applicationPath") scene_path = instance.context.data.get("scenePath") frame_rate = instance.context.data.get("frameRate") # real value from timeline frame_start = instance.context.data.get("frameStartHandle") frame_end = instance.context.data.get("frameEndHandle") audio_path = instance.context.data.get("audioPath") if audio_path and os.path.exists(audio_path): self.log.info(f"Using audio from {audio_path}") instance.data["audio"] = [{"filename": audio_path}] instance.data["fps"] = frame_rate # Set output path to temp folder. path = tempfile.mkdtemp() sig = harmony.signature() func = """function %s(args) { node.setTextAttr(args[0], "DRAWING_NAME", 1, args[1]); } %s """ % (sig, sig) harmony.send( { "function": func, "args": [instance.data["setMembers"][0], path + "/" + instance.data["name"]] } ) harmony.save_scene() # Execute rendering. Ignoring error cause Harmony returns error code # always. args = [application_path, "-batch", "-frames", str(frame_start), str(frame_end), scene_path] self.log.info(f"running: {' '.join(args)}") proc = subprocess.Popen( args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.PIPE ) output, error = proc.communicate() self.log.info("Click on the line below to see more details.") self.log.info(output.decode("utf-8")) # Collect rendered files. self.log.debug(f"collecting from: {path}") files = os.listdir(path) assert files, ( "No rendered files found, render failed." ) self.log.debug(f"files there: {files}") collections, remainder = clique.assemble(files, minimum_items=1) assert not remainder, ( "There should not be a remainder for {0}: {1}".format( instance.data["setMembers"][0], remainder ) ) self.log.debug(collections) if len(collections) > 1: for col in collections: if len(list(col)) > 1: collection = col else: collection = collections[0] # Generate thumbnail. thumbnail_path = os.path.join(path, "thumbnail.png") args = openpype.lib.get_ffmpeg_tool_args( "ffmpeg", "-y", "-i", os.path.join(path, list(collections[0])[0]), "-vf", "scale=300:-1", "-vframes", "1", thumbnail_path ) process = subprocess.Popen( args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.PIPE ) output = process.communicate()[0] if process.returncode != 0: raise ValueError(output.decode("utf-8", errors="backslashreplace")) self.log.debug(output.decode("utf-8", errors="backslashreplace")) # Generate representations. extension = collection.tail[1:] representation = { "name": extension, "ext": extension, "files": list(collection), "stagingDir": path, "tags": ["review"], "fps": frame_rate } thumbnail = { "name": "thumbnail", "ext": "png", "files": os.path.basename(thumbnail_path), "stagingDir": path, "tags": ["thumbnail"] } instance.data["representations"] = [representation, thumbnail] if audio_path and os.path.exists(audio_path): instance.data["audio"] = [{"filename": audio_path}] # Required for extract_review plugin (L222 onwards). instance.data["frameStart"] = frame_start instance.data["frameEnd"] = frame_end instance.data["fps"] = frame_rate self.log.info(f"Extracted {instance} to {path}") ================================================ FILE: openpype/hosts/harmony/plugins/publish/extract_save_scene.py ================================================ import pyblish.api import openpype.hosts.harmony.api as harmony class ExtractSaveScene(pyblish.api.ContextPlugin): """Save scene for extraction.""" label = "Extract Save Scene" order = pyblish.api.ExtractorOrder - 0.49 hosts = ["harmony"] def process(self, context): harmony.save_scene() ================================================ FILE: openpype/hosts/harmony/plugins/publish/extract_template.py ================================================ # -*- coding: utf-8 -*- """Extract template.""" import os import shutil from openpype.pipeline import publish import openpype.hosts.harmony.api as harmony class ExtractTemplate(publish.Extractor): """Extract the connected nodes to the composite instance.""" label = "Extract Template" hosts = ["harmony"] families = ["harmony.template"] def process(self, instance): """Plugin entry point.""" staging_dir = self.staging_dir(instance) filepath = os.path.join(staging_dir, f"{instance.name}.tpl") self.log.info(f"Outputting template to {staging_dir}") dependencies = [] self.get_dependencies(instance.data["setMembers"][0], dependencies) # Get backdrops. backdrops = {} for dependency in dependencies: for backdrop in self.get_backdrops(dependency): backdrops[backdrop["title"]["text"]] = backdrop unique_backdrops = [backdrops[x] for x in set(backdrops.keys())] if not unique_backdrops: self.log.error(("No backdrops detected for template. " "Please move template instance node onto " "some backdrop and try again.")) raise AssertionError("No backdrop detected") # Get non-connected nodes within backdrops. all_nodes = instance.context.data.get("allNodes") for node in [x for x in all_nodes if x not in dependencies]: within_unique_backdrops = bool( [x for x in self.get_backdrops(node) if x in unique_backdrops] ) if within_unique_backdrops: dependencies.append(node) # Make sure we dont export the instance node. if instance.data["setMembers"][0] in dependencies: dependencies.remove(instance.data["setMembers"][0]) # Export template. harmony.export_template( unique_backdrops, dependencies, filepath ) # Prep representation. os.chdir(staging_dir) shutil.make_archive( f"{instance.name}", "zip", os.path.join(staging_dir, f"{instance.name}.tpl") ) representation = { "name": "tpl", "ext": "zip", "files": f"{instance.name}.zip", "stagingDir": staging_dir } self.log.info(instance.data.get("representations")) if instance.data.get("representations"): instance.data["representations"].extend([representation]) else: instance.data["representations"] = [representation] instance.data["version_name"] = "{}_{}".format( instance.data["subset"], instance.context.data["task"]) def get_backdrops(self, node: str) -> list: """Get backdrops for the node. Args: node (str): Node path. Returns: list: list of Backdrops. """ self_name = self.__class__.__name__ return harmony.send({ "function": f"PypeHarmony.Publish.{self_name}.getBackdropsByNode", "args": node})["result"] def get_dependencies( self, node: str, dependencies: list = None) -> list: """Get node dependencies. This will return recursive dependency list of given node. Args: node (str): Path to the node. dependencies (list, optional): existing dependency list. Returns: list: List of dependent nodes. """ current_dependencies = harmony.send( { "function": "PypeHarmony.getDependencies", "args": node} )["result"] for dependency in current_dependencies: if not dependency: continue if dependency in dependencies: continue dependencies.append(dependency) self.get_dependencies(dependency, dependencies) ================================================ FILE: openpype/hosts/harmony/plugins/publish/extract_workfile.py ================================================ # -*- coding: utf-8 -*- """Extract work file.""" import os import shutil from zipfile import ZipFile from openpype.pipeline import publish class ExtractWorkfile(publish.Extractor): """Extract and zip complete workfile folder into zip.""" label = "Extract Workfile" hosts = ["harmony"] families = ["workfile"] def process(self, instance): """Plugin entry point.""" staging_dir = self.staging_dir(instance) filepath = os.path.join(staging_dir, "{}.tpl".format(instance.name)) src = os.path.dirname(instance.context.data["currentFile"]) self.log.info("Copying to {}".format(filepath)) shutil.copytree(src, filepath) # Prep representation. os.chdir(staging_dir) shutil.make_archive( f"{instance.name}", "zip", os.path.join(staging_dir, f"{instance.name}.tpl") ) # Check if archive is ok with ZipFile(os.path.basename(f"{instance.name}.zip")) as zr: if zr.testzip() is not None: raise Exception("File archive is corrupted.") representation = { "name": "tpl", "ext": "zip", "files": f"{instance.name}.zip", "stagingDir": staging_dir } instance.data["representations"] = [representation] ================================================ FILE: openpype/hosts/harmony/plugins/publish/help/validate_audio.xml ================================================ Missing audio file ## Cannot locate linked audio file Audio file at {audio_url} cannot be found. ### How to repair? Copy audio file to the highlighted location or remove audio link in the workfile. ================================================ FILE: openpype/hosts/harmony/plugins/publish/help/validate_instances.xml ================================================ Subset context ## Invalid subset context Asset name found '{found}' in subsets, expected '{expected}'. ### How to repair? You can fix this with `Repair` button on the right. This will use '{expected}' asset name and overwrite '{found}' asset name in scene metadata. After that restart `Publish` with a `Reload button`. If this is unwanted, close workfile and open again, that way different asset value would be used for context information. ### __Detailed Info__ (optional) This might happen if you are reuse old workfile and open it in different context. (Eg. you created subset "renderCompositingDefault" from asset "Robot' in "your_project_Robot_compositing.aep", now you opened this workfile in a context "Sloth" but existing subset for "Robot" asset stayed in the workfile.) ================================================ FILE: openpype/hosts/harmony/plugins/publish/help/validate_scene_settings.xml ================================================ Scene setting ## Invalid scene setting found One of the settings in a scene doesn't match to asset settings in database. {invalid_setting_str} ### How to repair? Change values for {invalid_keys_str} in the scene OR change them in the asset database if they are wrong there. ### __Detailed Info__ (optional) This error is shown when for example resolution in the scene doesn't match to resolution set on the asset in the database. Either value in the database or in the scene is wrong. Scene file doesn't exist ## Scene file doesn't exist Collected scene {scene_url} doesn't exist. ### How to repair? Re-save file, start publish from the beginning again. ================================================ FILE: openpype/hosts/harmony/plugins/publish/increment_workfile.py ================================================ import os import pyblish.api from openpype.pipeline.publish import get_errored_plugins_from_context from openpype.lib import version_up import openpype.hosts.harmony.api as harmony class IncrementWorkfile(pyblish.api.InstancePlugin): """Increment the current workfile. Saves the current scene with an increased version number. """ label = "Increment Workfile" order = pyblish.api.IntegratorOrder + 9.0 hosts = ["harmony"] families = ["workfile"] optional = True def process(self, instance): errored_plugins = get_errored_plugins_from_context(instance.context) if errored_plugins: raise RuntimeError( "Skipping incrementing current file because publishing failed." ) scene_dir = version_up( os.path.dirname(instance.context.data["currentFile"]) ) scene_path = os.path.join( scene_dir, os.path.basename(scene_dir) + ".xstage" ) harmony.save_scene_as(scene_path) self.log.info("Incremented workfile to: {}".format(scene_path)) ================================================ FILE: openpype/hosts/harmony/plugins/publish/validate_audio.py ================================================ import os import pyblish.api import openpype.hosts.harmony.api as harmony from openpype.pipeline import PublishXmlValidationError class ValidateAudio(pyblish.api.InstancePlugin): """Ensures that there is an audio file in the scene. If you are sure that you want to send render without audio, you can disable this validator before clicking on "publish" """ order = pyblish.api.ValidatorOrder label = "Validate Audio" families = ["render"] hosts = ["harmony"] optional = True def process(self, instance): node = None if instance.data.get("setMembers"): node = instance.data["setMembers"][0] if not node: return # Collect scene data. func = """function func(write_node) { return [ sound.getSoundtrackAll().path() ] } func """ result = harmony.send( {"function": func, "args": [node]} )["result"] audio_path = result[0] msg = "You are missing audio file:\n{}".format(audio_path) formatting_data = { "audio_url": audio_path } if not os.path.isfile(audio_path): raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) ================================================ FILE: openpype/hosts/harmony/plugins/publish/validate_instances.py ================================================ import pyblish.api import openpype.hosts.harmony.api as harmony from openpype.pipeline import get_current_asset_name from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, ) class ValidateInstanceRepair(pyblish.api.Action): """Repair the instance.""" label = "Repair" icon = "wrench" on = "failed" def process(self, context, plugin): # Get the errored instances failed = [] for result in context.data["results"]: if (result["error"] is not None and result["instance"] is not None and result["instance"] not in failed): failed.append(result["instance"]) # Apply pyblish.logic to get the instances for the plug-in instances = pyblish.api.instances_by_plugin(failed, plugin) for instance in instances: data = harmony.read(instance.data["setMembers"][0]) data["asset"] = get_current_asset_name() harmony.imprint(instance.data["setMembers"][0], data) class ValidateInstance(pyblish.api.InstancePlugin): """Validate the instance asset is the current asset.""" label = "Validate Instance" hosts = ["harmony"] actions = [ValidateInstanceRepair] order = ValidateContentsOrder def process(self, instance): instance_asset = instance.data["asset"] current_asset = get_current_asset_name() msg = ( "Instance asset is not the same as current asset:" f"\nInstance: {instance_asset}\nCurrent: {current_asset}" ) formatting_data = { "found": instance_asset, "expected": current_asset } if instance_asset != current_asset: raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) ================================================ FILE: openpype/hosts/harmony/plugins/publish/validate_scene_settings.py ================================================ # -*- coding: utf-8 -*- """Validate scene settings.""" import os import json import re import pyblish.api import openpype.hosts.harmony.api as harmony from openpype.pipeline import PublishXmlValidationError class ValidateSceneSettingsRepair(pyblish.api.Action): """Repair the instance.""" label = "Repair" icon = "wrench" on = "failed" def process(self, context, plugin): """Repair action entry point.""" expected = harmony.get_asset_settings() asset_settings = _update_frames(dict.copy(expected)) asset_settings["frameStart"] = 1 asset_settings["frameEnd"] = asset_settings["frameEnd"] + \ asset_settings["handleEnd"] harmony.set_scene_settings(asset_settings) if not os.path.exists(context.data["scenePath"]): self.log.info("correcting scene name") scene_dir = os.path.dirname(context.data["currentFile"]) scene_path = os.path.join( scene_dir, os.path.basename(scene_dir) + ".xstage" ) harmony.save_scene_as(scene_path) class ValidateSceneSettings(pyblish.api.InstancePlugin): """Ensure the scene settings are in sync with database.""" order = pyblish.api.ValidatorOrder label = "Validate Scene Settings" families = ["workfile"] hosts = ["harmony"] actions = [ValidateSceneSettingsRepair] optional = True # skip frameEnd check if asset contains any of: frame_check_filter = ["_ch_", "_pr_", "_intd_", "_extd_"] # regex # skip resolution check if Task name matches any of regex patterns skip_resolution_check = ["render", "Render"] # regex # skip frameStart, frameEnd check if Task name matches any of regex patt. skip_timelines_check = [] # regex def process(self, instance): """Plugin entry point.""" # TODO 'get_asset_settings' could expect asset document as argument # which is available on 'context.data["assetEntity"]' # - the same approach can be used in 'ValidateSceneSettingsRepair' expected_settings = harmony.get_asset_settings() self.log.info("scene settings from DB:{}".format(expected_settings)) expected_settings.pop("entityType") # not useful for the validation expected_settings = _update_frames(dict.copy(expected_settings)) expected_settings["frameEndHandle"] = expected_settings["frameEnd"] +\ expected_settings["handleEnd"] task_name = instance.context.data["task"] if (any(re.search(pattern, task_name) for pattern in self.skip_resolution_check)): self.log.info("Skipping resolution check because of " "task name and pattern {}".format( self.skip_resolution_check)) expected_settings.pop("resolutionWidth") expected_settings.pop("resolutionHeight") if (any(re.search(pattern, os.getenv('AVALON_TASK')) for pattern in self.skip_timelines_check)): self.log.info("Skipping frames check because of " "task name and pattern {}".format( self.skip_timelines_check)) expected_settings.pop('frameStart', None) expected_settings.pop('frameEnd', None) expected_settings.pop('frameStartHandle', None) expected_settings.pop('frameEndHandle', None) asset_name = instance.context.data['anatomyData']['asset'] if any(re.search(pattern, asset_name) for pattern in self.frame_check_filter): self.log.info("Skipping frames check because of " "task name and pattern {}".format( self.frame_check_filter)) expected_settings.pop('frameStart', None) expected_settings.pop('frameEnd', None) expected_settings.pop('frameStartHandle', None) expected_settings.pop('frameEndHandle', None) # handle case where ftrack uses only two decimal places # 23.976023976023978 vs. 23.98 fps = instance.context.data.get("frameRate") if isinstance(instance.context.data.get("frameRate"), float): fps = float( "{:.2f}".format(instance.context.data.get("frameRate"))) self.log.debug("filtered settings: {}".format(expected_settings)) current_settings = { "fps": fps, "frameStart": instance.context.data["frameStart"], "frameEnd": instance.context.data["frameEnd"], "handleStart": instance.context.data.get("handleStart"), "handleEnd": instance.context.data.get("handleEnd"), "frameStartHandle": instance.context.data.get("frameStartHandle"), "frameEndHandle": instance.context.data.get("frameEndHandle"), "resolutionWidth": instance.context.data.get("resolutionWidth"), "resolutionHeight": instance.context.data.get("resolutionHeight"), } self.log.debug("current scene settings {}".format(current_settings)) invalid_settings = [] invalid_keys = set() for key, value in expected_settings.items(): if value != current_settings[key]: invalid_settings.append( "{} expected: {} found: {}".format(key, value, current_settings[key])) invalid_keys.add(key) if ((expected_settings["handleStart"] or expected_settings["handleEnd"]) and invalid_settings): msg = "Handles included in calculation. Remove handles in DB " +\ "or extend frame range in timeline." invalid_settings[-1]["reason"] = msg msg = "Found invalid settings:\n{}".format( json.dumps(invalid_settings, sort_keys=True, indent=4) ) if invalid_settings: invalid_keys_str = ",".join(invalid_keys) break_str = "
" invalid_setting_str = "Found invalid settings:
{}".\ format(break_str.join(invalid_settings)) formatting_data = { "invalid_setting_str": invalid_setting_str, "invalid_keys_str": invalid_keys_str } raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) scene_url = instance.context.data.get("scenePath") if not os.path.exists(scene_url): msg = "Scene file {} not found (saved under wrong name)".format( scene_url ) formatting_data = { "scene_url": scene_url } raise PublishXmlValidationError(self, msg, key="file_not_found", formatting_data=formatting_data) def _update_frames(expected_settings): """ Calculate proper frame range including handles set in DB. Harmony requires rendering from 1, so frame range is always moved to 1. Args: expected_settings (dict): pulled from DB Returns: modified expected_setting (dict) """ frames_count = expected_settings["frameEnd"] - \ expected_settings["frameStart"] + 1 expected_settings["frameStart"] = 1.0 + expected_settings["handleStart"] expected_settings["frameEnd"] = \ expected_settings["frameStart"] + frames_count - 1 return expected_settings ================================================ FILE: openpype/hosts/harmony/vendor/.eslintrc.json ================================================ { "env": { "browser": true }, "extends": "eslint:recommended", "ignorePatterns": ["**/*.js"] } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/.gitattributes ================================================ $.html merge=ours $.oAttribute.html merge=ours $.oBackdrop.html merge=ours $.oBox.html merge=ours $.oColor.html merge=ours $.oColorValue.html merge=ours $.oColumn.html merge=ours $.oDialog.html merge=ours $.oDialog.Progress.html merge=ours $.oDrawing.html merge=ours $.oDrawingColumn.html merge=ours $.oDrawingNode.html merge=ours $.oElement.html merge=ours $.oFile.html merge=ours $.oFolder.html merge=ours $.oFrame.html merge=ours $.oGroupNode.html merge=ours $.oList.html merge=ours $.oNetwork.html merge=ours $.oNode.html merge=ours $.oNodeLink.html merge=ours $.oPalette.html merge=ours $.oPathPoint.html merge=ours $.oPegNode.html merge=ours $.oPoint.html merge=ours $.oScene.html merge=ours $.oThread.html merge=ours $.oTimeline.html merge=ours $.oTimelineLayer.html merge=ours $.oUtils.html merge=ours $.index.html merge=ours $.global.html merge=ours $.oDatabase.html merge=ours $.oProgressDialog.html merge=ours $.oProcess.html merge=ours NodeTypes.html merge=ours ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/.gitignore ================================================ node_modules/* ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/Install.bat ================================================ @echo off SETLOCAL ENABLEDELAYEDEXPANSION SET dlPath=%~dp0 set harmonyPrefsDir=%appdata%\Toon Boom Animation SETX LIB_OPENHARMONY_PATH %dlPath% echo ------------------------------------------------------------------- echo -- Starting install of openHarmony open source scripting library -- echo ------------------------------------------------------------------- echo OpenHarmony will be installed to the folder : echo %dlpath% echo Do not delete the contents of this folder. REM Check Harmony Versions and make a list for /d %%D in ("%harmonyPrefsDir%\*Harmony*") do ( set harmonyVersionDir=%%~fD for /d %%V in ("!harmonyVersionDir!\*-layouts*") do ( set "folderName=%%~nD" set "versionName=%%~nV" set "harmonyFolder=!folderName:~-7!" set "harmonyVersions=!versionName:~0,2!" echo Found Toonboom Harmony !harmonyFolder! !harmonyVersions! - installing openHarmony for this version. set "installDir=!harmonyPrefsDir!\Toon Boom Harmony !harmonyFolder!\!harmonyVersions!00-scripts\" if not "!installDir!" == "!dlPath!" ( REM creating a "openHarmony.js" file in script folders if not exist "!installDir!" mkdir "!installDir!" cd !installDir! set "script=include(System.getenv('LIB_OPENHARMONY_PATH')+'openHarmony.js');" echo !script!> openHarmony.js ) echo ---- done. ---- ) ) echo - Install Complete - pause ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/LICENSE ================================================ Mozilla Public License Version 2.0 ================================== 1. Definitions -------------- 1.1. "Contributor" means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. "Contributor Version" means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor's Contribution. 1.3. "Contribution" means Covered Software of a particular Contributor. 1.4. "Covered Software" means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. "Incompatible With Secondary Licenses" means (a) that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or (b) that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. "Executable Form" means any form of the work other than Source Code Form. 1.7. "Larger Work" means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" means this document. 1.9. "Licensable" means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. "Modifications" means any of the following: (a) any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or (b) any new file in Source Code Form that contains any Covered Software. 1.11. "Patent Claims" of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. "Secondary License" means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. "Source Code Form" means the form of the work preferred for making modifications. 1.14. "You" (or "Your") means an individual or a legal entity exercising rights under this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, "control" means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions -------------------------------- 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: (a) under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and (b) under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: (a) for any code that a Contributor has removed from Covered Software; or (b) for infringements caused by: (i) Your and any other third party's modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or (c) under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities ------------------- 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients' rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: (a) such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and (b) You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients' rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation --------------------------------------------------- If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination -------------- 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. ************************************************************************ * * * 6. Disclaimer of Warranty * * ------------------------- * * * * Covered Software is provided under this License on an "as is" * * basis, without warranty of any kind, either expressed, implied, or * * statutory, including, without limitation, warranties that the * * Covered Software is free of defects, merchantable, fit for a * * particular purpose or non-infringing. The entire risk as to the * * quality and performance of the Covered Software is with You. * * Should any Covered Software prove defective in any respect, You * * (not any Contributor) assume the cost of any necessary servicing, * * repair, or correction. This disclaimer of warranty constitutes an * * essential part of this License. No use of any Covered Software is * * authorized under this License except under this disclaimer. * * * ************************************************************************ ************************************************************************ * * * 7. Limitation of Liability * * -------------------------- * * * * Under no circumstances and under no legal theory, whether tort * * (including negligence), contract, or otherwise, shall any * * Contributor, or anyone who distributes Covered Software as * * permitted above, be liable to You for any direct, indirect, * * special, incidental, or consequential damages of any character * * including, without limitation, damages for lost profits, loss of * * goodwill, work stoppage, computer failure or malfunction, or any * * and all other commercial damages or losses, even if such party * * shall have been informed of the possibility of such damages. This * * limitation of liability shall not apply to liability for death or * * personal injury resulting from such party's negligence to the * * extent applicable law prohibits such limitation. Some * * jurisdictions do not allow the exclusion or limitation of * * incidental or consequential damages, so this exclusion and * * limitation may not apply to You. * * * ************************************************************************ 8. Litigation ------------- Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party's ability to bring cross-claims or counter-claims. 9. Miscellaneous ---------------- This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License --------------------------- 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice ------------------------------------------- This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - "Incompatible With Secondary Licenses" Notice --------------------------------------------------------- This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/README.md ================================================ # OpenHarmony - The Toonboom Harmony Open Source DOM Library ## Why did we make this library ? Ever tried to make a simple script for toonboom Harmony, then got stumped by the numerous amount of steps required to execute the simplest action? Or bored of coding the same helper routines again and again for every studio you work for? Toonboom Harmony is a very powerful software, with hundreds of functions and tools, and it unlocks a great amount of possibilities for animation studios around the globe. And... being the produce of the hard work of a small team forced to prioritise, it can also be a bit rustic at times! We are users at heart, animators and riggers, who just want to interact with the software as simply as possible. Simplicity is at the heart of the design of openHarmony. But we also are developers, and we made the library for people like us who can't resist tweaking the software and bend it in all possible ways, and are looking for powerful functions to help them do it. This library's aim is to create a more direct way to interact with Toonboom through scripts, by providing a more intuitive way to access its elements, and help with the cumbersome and repetitive tasks as well as help unlock untapped potential in its many available systems. So we can go from having to do things like this: ```javascript // adding a Drawing to the scene with the official API var myNodeName = "Drawing"; var myColumnName = myNodeName; var myNode = node.add("Top", myNodeName, "READ",0,0,0); var myColumn = column.add(myColumnName, "DRAWING", "BOTTOM"); var myElement = element.add (myNodeName, "COLOR", 12, "SCAN", "TVG"); column.setElementIdOfDrawing(myColumnName, myElement); node.linkAttr (myNode, "DRAWING.ELEMENT", myColumnName); drawing.create (myElement, "1", false, false); column.setEntry (myColumnName, 0, 1, "1"); ``` to simply writing : ```javascript // with openHarmony var myNode = $.scene.root.addDrawingNode("Drawing"); myNode.element.addDrawing(1); ``` Less time spent coding, more time spent having ideas! ----- ## Do I need any knowledge of toonboom scripting to use openHarmony? OpenHarmony aims to be self contained and to reimplement all the basic functions of the Harmony API. So, while it might help to have prior experience to understand what goes on under the hood, knowledge of the official API is not required. However, should you reach the limits of what openHarmony can offer at this time, you can always access the official API at any moment. Maybe you can submit a request and the missing parts will be added eventually, or you can even delve into the code and add the necessary functions yourself if you feel like it! You can access a list of all the functions, how to use them, as well as examples, from the online documentation: [https://cfourney.github.io/OpenHarmony/$.html](https://cfourney.github.io/OpenHarmony/$.html) To help you get started, here is a full example using the library to make and animate a small car, covering most of the basic features. [https://github.com/cfourney/OpenHarmony/blob/master/examples/openHarmonyExample.js](https://github.com/cfourney/OpenHarmony/blob/master/examples/openHarmonyExample.js) ----- ## The OpenHarmony Document Object Model or DOM OpenHarmony is based around the four principles of Object Oriented Programming: *Abstraction*, *Encapsulation*, *Inheritance*, *Polymorphism*. This means every element of the Harmony scene has a corresponding abstraction existing in the code as a class. We have oNode, oScene, oColumn, etc. Unlike in the official API, each class is designed to create objects that are instances of these classes and encapsulate them and all their actions. It means no more storing the path of nodes, column abstract names and element ids to interact with them; if you can create or call it, you can access all of its functionalities. Nodes are declined as DrawingNodes and PegNodes, which inherint from the Node Class, and so on. The openHarmony library doesn't merely provide *access* to the elements of a Toonboom Harmony file, it *models* them and their relationship to each others. The Document ObjectModel The *Document Object Model* is a way to organise the elements of the Toonboom scene by highlighting the way they interact with each other. The Scene object has a root group, which contains Nodes, which have Attributes which can be linked to Columns which contain Frames, etc. This way it's always easy to find and access the content you are looking for. The attribute system has also been streamlined and you can now set values of node properties with a simple attribution synthax. We implemented a global access to all elements and functions through the standard **dot notation** for the hierarchy, for ease of use, and clarity of code. Functions and methods also make extensive use of **optional parameters** so no more need to fill in all arguments when calling functions when the default behavior is all that's needed. On the other hand, the "o" naming scheme allows us to retain full access to the official API at all times. This means you can use it only when it really makes your life better. ----- ## Adopting openHarmony for your project This library is made available under the [Mozilla Public license 2.0](https://www.mozilla.org/en-US/MPL/2.0/). OpenHarmony can be downloaded from [this repository](https://github.com/cfourney/OpenHarmony/releases/) directly. In order to make use of its functions, it needs to be unzipped next to the scripts you will be writing. All you have to do is call : ```javascript include("openHarmony.js"); ``` at the beginning of your script. You can ask your users to download their copy of the library and store it alongside, or bundle it as you wish as long as you include the license file provided on this repository. The entire library is documented at the address : https://cfourney.github.io/OpenHarmony/$.html This include a list of all the available functions as well as examples and references (such as the list of all available node attributes). As time goes by, more functions will be added and the documentation will also get richer as more examples get created. ----- ## Installation #### simple install: - download the zip from [the releases page](https://github.com/cfourney/OpenHarmony/releases/), - unzip the contents to [your scripts folder](https://docs.toonboom.com/help/harmony-17/advanced/scripting/import-script.html). #### advanced install (for developers): - clone the repository to the location of your choice -- or -- - download the zip from [the releases page](https://github.com/cfourney/OpenHarmony/releases/) - unzip the contents where you want to store the library, -- then -- - run `install.bat`. This last step will tell Harmony where to look to load the library, by setting the environment variable `LIB_OPENHARMONY_PATH` to the current folder. It will then create a `openHarmony.js` file into the user scripts folder which calls the files from the folder from the `LIB_OPENHARMONY_PATH` variable, so that scripts can make direct use of it without having to worry about where openHarmony is stored. ##### Troubleshooting: - to test if the library is correctly installed, open the `Script Editor` window and type: ```javascript include ("openHarmony.js"); $.alert("hello world"); ``` Run the script, and if there is an error (for ex `MAX_REENTRENCY `), check that the file `openHarmony.js` exists in the script folder, and contains only the line: ```javascript include(System.getenv('LIB_OPENHARMONY_PATH')+'openHarmony.js'); ``` Check that the environment variable `LIB_OPENHARMONY_PATH` is set correctly to the remote folder. ----- ## How to add openHarmony to vscode intellisense for autocompletion Although not fully supported, you can get most of the autocompletion features to work by adding the following lines to a `jsconfig.json` file placed at the root of your working folder. The paths need to be relative which means the openHarmony source code must be placed directly in your developping environment. For example, if your working folder contains the openHarmony source in a folder called `OpenHarmony` and your working scripts in a folder called `myScripts`, place the `jsconfig.json` file at the root of the folder and add these lines to the file: ```javascript { include : [ "OpenHarmony/*", "OpenHarmony/openHarmony/*", "myScripts/*", "*" ] } ``` [More information on vs code and jsconfig.json.](https://code.visualstudio.com/docs/nodejs/working-with-javascript) ----- ## Let's get technical. I can code, and want to contribute, where do I start? Reading and understanding the existing code, or at least the structure of the lib, is a great start, but not a requirement. You can simply start adding your classes to the $ object that is the root of the harmony lib, and start implementing. However, try to follow these guidelines as they are the underlying principles that make the library consistent: * There is a $ global object, which contains all the class declarations, and can be passed from one context to another to access the functions. * Each class is an abstract representation of a core concept of Harmony, so naming and consistency (within the lib) is essential. But we are not bound by the structure or naming of Harmony if we find a better way, for example to make nomenclatures more consistent between the scripting interface and the UI. * Each class defines a bunch of class properties with getter/setters for the values that are directly related to an entity of the scene. If you're thinking of making a getter function that doesn't require arguments, use a getter setter instead! * Each class also defines methods which can be called on the class instances to affect its contents, or its children's contents. For example, you'd go to the scene class to add the things that live in the scene, such as elements, columns and palettes. You wouldn't go to the column class or palette class to add one, because then what are you adding it *to*? * We use encapsulation over having to pass a function arguments every time we can. Instead of adding a node to the scene, and having to pass a group as argument, adding a node is done directly by calling a method of the parent group. This way the parent/child relationship is always clear and the arguments list kept to a minimum. * The goal is to make the most useful set of functions we can. Instead of making a large function that does a lot, consider extracting the small useful subroutines you need in your function into the existing classes directly. * Each method argument besides the core one (for example, for adding nodes, we have to specify the type of the new node we create) must have a default fallback to make the argument optional. * Don't use globals ever, but maybe use a class property if you need an enum for example. * Don't use the official API namespace, any function that exists in the official API must remain accessible otherwise things will break. Prefix your class names with "o" to avoid this and to signify this function is part of openHarmony. * We use the official API as little as we can in the code, so that if the implementation changes, we can easily fix it in a minimal amount of places. Wrap it, then use the wrapper. (ex: oScene.name) * Users of the lib should almost never have to use "new" to create instances of their classes. Create accessors/factories that will do that for them. For example, $.scn creates and return a oScene instance, and $.scn.nodes returns new oNodes instances, but users don't have to create them themselves, so it's like they were always there, contained within. It also lets you create different subclasses for one factory. For example, $.scn.$node("Top/myNode") will either return a oNode, oDrawingNode, oPegNode or oGroupNode object depending on the node type of the node represented by the object. Exceptions are small useful value containing objects that don't belong to the Harmony hierarchy like oPoint, oBox, oColorValue, etc. * It's a JS Library, so use camelCase naming and try to follow the google style guide for JS : https://google.github.io/styleguide/jsguide.html * Document your new functions using the JSDocs synthax : https://devdocs.io/jsdoc/howto-es2015-classes * Make a branch, create a merge request when you're done, and we'll add the new stuff you added to the lib :) ----- ## Credits This library was created by Mathieu Chaptel and Chris Fourney. If you're using openHarmony, and are noticing things that you would like to see in the library, please feel free to contribute to the code directly, or send us feedback through Github. This project will only be as good as people working together can make it, and we need every piece of code and feedback we can get, and would love to hear from you! ----- ## Community Join the discord community for help with the library and to contribute: https://discord.gg/kgT38MG ----- ## Acknowledgements * [Yu Ueda](https://github.com/yueda1984) for his help to understand Harmony coordinate systems * [Dash](https://github.com/35743) for his help to debug, test and develop the Pie Menus widgets * [All the contributors](https://github.com/cfourney/OpenHarmony/graphs/contributors) for their precious help. ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/build_doc.bat ================================================ jsdoc -c ./documentation.json -t ../node_modules/jaguarjs-jsdoc pause ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/documentation.json ================================================ { "plugins": [], "recurseDepth": 10, "source": { "include": ["."], "includePattern": ".+\\.js(doc|x)?$", "exclude": [ "./openHarmony_tools.js" ] }, "sourceType": "module", "tags": { "allowUnknownTags": true, "dictionaries": ["jsdoc","closure"] }, "templates": { "cleverLinks": false, "monospaceLinks": false }, "opts": { "encoding": "utf8", "destination": "./docs/", "recurse": true } } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/install.sh ================================================ #!/bin/bash set dlPath=pwd set harmonyPrefsDir=~/Library/Preferences/Toon Boom Animation/ echo ------------------------------------------------------------------- echo -- Starting install of openHarmony open source scripting library -- echo ------------------------------------------------------------------- echo OpenHarmony will be installed to the folder : echo $dlpath echo Do not delete the contents of this folder. REM Check Harmony Versions and make a list for /d %%D in ("%harmonyPrefsDir%\*Harmony*") do ( set harmonyVersionDir=%%~fD for /d %%V in ("!harmonyVersionDir!\*-layouts*") do ( set "folderName=%%~nD" set "versionName=%%~nV" set "harmonyFolder=!folderName:~-7!" set "harmonyVersions=!versionName:~0,2!" echo Found Toonboom Harmony !harmonyFolder! !harmonyVersions! - installing openHarmony for this version. set "installDir=!harmonyPrefsDir!\Toon Boom Harmony !harmonyFolder!\!harmonyVersions!00-scripts\" ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_actions.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library // // // Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the Mozilla Public license 2.0. // https://www.mozilla.org/en-US/MPL/2.0/ // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // This document was outputted by this function. // // // printOutDoc(); // // function printOutDoc(){ // var doc = ""; // var responders = Action.getResponderList() // for (var i in responders){ // var docString = ["\n\n/\**\n * Actions available in the "+responders[i]+" responder.", " @name actions#"+responders[i]]; // var actions = Action.getActionList(responders[i]); // for (var j in actions){ // docString.push(" @property {QAction} "+actions[j]); // } // docString.push ("/"); // doc += docString.join("\n *"); // } // // MessageLog.trace(doc); // } /** * Actions are used by the Harmony interface to represent something the user can ask the software to do. This is a list of all the available names and responders available. * @class actions * @hideconstructor * @namespace * @example * // To check whether an action is available, call the synthax: * Action.validate (, ); * * // To launch an action, call the synthax: * Action.perform (, , parameters); */ /** * Actions available in the ExportGifResponder responder. * @name actions#ExportGifResponder * @property {QAction} onActionExportGif() */ /** * Actions available in the ExposureFillResponder responder. * @name actions#ExposureFillResponder * @property {QAction} onActionExposureFillUsingRenderChange() */ /** * Actions available in the GameSkinResponder responder. * @name actions#GameSkinResponder */ /** * Actions available in the GuideToolResponder responder. * @name actions#GuideToolResponder */ /** * Actions available in the MatteGeneratorResponder responder. * @name actions#MatteGeneratorResponder * @property {QAction} onActionNull() * @property {QAction} onActionNullAddHint() * @property {QAction} onActionGenerateMatte() * @property {QAction} onActionGenerateHints() * @property {QAction} onActionMakeMatteVerticesAnimatable() * @property {QAction} onActionMergeInnerOuterContours() * @property {QAction} onActionRemoveUnusedMatteDisplacements() * @property {QAction} onActionShowOptionsInCameraView() * @property {QAction} onActionRemoveSelectedHints() * @property {QAction} onActionRemoveSelectedVertices() * @property {QAction} onActionRemoveSelectedVertexAnimation() * @property {QAction} onActionEnableNormalMode() * @property {QAction} onActionEnableSetupMode() * @property {QAction} onActionEnableAddVertexMode() * @property {QAction} onActionEnableAddHintMode() * @property {QAction} onActionToggleShowOuterContour() * @property {QAction} onActionToggleShowInnerContour() * @property {QAction} onActionToggleShowPointId() * @property {QAction} onActionToggleShowHints() * @property {QAction} onActionToggleShowOverlayAnnotation() * @property {QAction} onActionToggleShowInflate() * @property {QAction} onActionAlignMatteHandles() * @property {QAction} onActionShortenMatteHandles() * @property {QAction} onActionExpandOuterContour() * @property {QAction} onActionExpandInnerContour() * @property {QAction} onActionReduceOuterContour() * @property {QAction} onActionReduceInnerContour() * @property {QAction} onActionToggleAutoKey() * @property {QAction} onActionToggleFixAdjacentKeyframes() * @property {QAction} onActionReloadView() * @property {QAction} onActionDefineResourceFolder() * @property {QAction} onActionExportResourceFolder() * @property {QAction} onActionResetResourceFolder() */ /** * Actions available in the ModuleLibraryIconView responder. * @name actions#ModuleLibraryIconView */ /** * Actions available in the ModuleLibraryListView responder. * @name actions#ModuleLibraryListView */ /** * Actions available in the ModuleLibraryTemplatesResponder responder. * @name actions#ModuleLibraryTemplatesResponder */ /** * Actions available in the Node View responder. * @name actions#Node View * @property {QAction} onActionResetView() * @property {QAction} onActionTag() * @property {QAction} onActionUntag() * @property {QAction} onActionUntagAll() * @property {QAction} onActionUntagAllOthers() * @property {QAction} onActionZoomIn() * @property {QAction} onActionZoomOut() * @property {QAction} onActionResetZoom() * @property {QAction} onActionResetPan() * @property {QAction} onActionShowAllModules() * @property {QAction} onActionPasteSpecial() * @property {QAction} onActionPasteSpecialAgain() * @property {QAction} onActionCloneElement() * @property {QAction} onActionCloneElement_DrawingsOnly() * @property {QAction} onActionCopyQualifiedName() * @property {QAction} onActionDuplicateElement() * @property {QAction} onActionSelEnable() * @property {QAction} onActionEnableAll() * @property {QAction} onActionSelDisable() * @property {QAction} onActionDisableAllUnselected() * @property {QAction} onActionRecomputeAll() * @property {QAction} onActionRecomputeSelected() * @property {QAction} onActionSelCreateGroup() * @property {QAction} onActionSelCreateGroupWithComposite() * @property {QAction} onActionSelMoveToParentGroup() * @property {QAction} onActionSelMergeInto() * @property {QAction} onActionClearPublishedAttributes() * @property {QAction} onActionPrintNetwork() * @property {QAction} onActionUpToParent() * @property {QAction} onActionShowHideWorldView() * @property {QAction} onActionMoveWorldNE() * @property {QAction} onActionMoveWorldNW() * @property {QAction} onActionMoveWorldSE() * @property {QAction} onActionMoveWorldSW() * @property {QAction} onActionEnterGroup() * @property {QAction} onActionCreateGroup() * @property {QAction} onActionCreatePeg() * @property {QAction} onActionCreateParentPeg() * @property {QAction} onActionCreateDisplay() * @property {QAction} onActionCreateRead() * @property {QAction} onActionCreateComposite() * @property {QAction} onActionCreateBackdrop() * @property {QAction} onActionToggleDefinePublishMode() * @property {QAction} onActionNavigateGroup() * @property {QAction} onActionGotoPortAbove() * @property {QAction} onActionGotoPortUnder() * @property {QAction} onActionGotoPortLeft() * @property {QAction} onActionGotoPortRight() * @property {QAction} onActionToggleDefineAttributeInfo() * @property {QAction} onActionCreateFavorite(QString) * @property {QAction} onActionCreateModule(QString) * @property {QAction} onActionCableLine() * @property {QAction} onActionCableStraight() * @property {QAction} onActionCableBezier() * @property {QAction} onActionRecenter() * @property {QAction} onActionFocusOnSelectionNV() * @property {QAction} onActionFocusOnParentNodeNV() * @property {QAction} onActionFocusOnChildNodeNV() * @property {QAction} onActionToggleSelectedThumbNail() * @property {QAction} onActionShowAllThumbNail() * @property {QAction} onActionHideAllThumbNails() * @property {QAction} onActionShowSelectedThumbNail() * @property {QAction} onActionHideSelectedThumbNail() * @property {QAction} onActionNaviSelectChild() * @property {QAction} onActionNaviSelectChilds() * @property {QAction} onActionNaviSelectParent() * @property {QAction} onActionNaviSelectPreviousBrother() * @property {QAction} onActionNaviSelectNextBrother() * @property {QAction} onActionNaviSelectInnerChildren() * @property {QAction} onActionNaviSelectParentWithEffects() * @property {QAction} onActionNaviSelectChildWithEffects() * @property {QAction} onActionSelectLinkedLayers() * @property {QAction} onActionSetDrawingAsSubLayer() * @property {QAction} onActionUnlinkSubLayer() * @property {QAction} onActionAddSubLayer() * @property {QAction} onActionRenameWaypoint() * @property {QAction} onActionCreateWaypointFromContextMenu() * @property {QAction} onActionCreateWaypointFromShortcut() */ /** * Actions available in the ParticleCoreGuiResponder responder. * @name actions#ParticleCoreGuiResponder * @property {QAction} onActionInsertParticleTemplate(QString) * @property {QAction} onActionToggleShowAsDots() */ /** * Actions available in the PluginHelpViewResponder responder. * @name actions#PluginHelpViewResponder * @property {QAction} onActionShowShortcuts() */ /** * Actions available in the Script responder. * @name actions#Script */ /** * Actions available in the ScriptManagerResponder responder. * @name actions#ScriptManagerResponder */ /** * Actions available in the ScriptViewResponder responder. * @name actions#ScriptViewResponder * @property {QAction} onActionNewScriptFile() * @property {QAction} onActionImportScript() * @property {QAction} onActionDeleteScript() * @property {QAction} onActionRefreshScripts() * @property {QAction} onActionRun() * @property {QAction} onActionDebug() * @property {QAction} onActionStopExecution() * @property {QAction} onActionOpenHelp() * @property {QAction} onActionSetTarget() * @property {QAction} onActionSetExternalEditor() * @property {QAction} onActionCallExternalEditor() */ /** * Actions available in the ShiftAndTraceToolResponder responder. * @name actions#ShiftAndTraceToolResponder * @property {QAction} onActionEnableShiftAndTrace() * @property {QAction} onActionSelectShiftAndTraceTool() * @property {QAction} onActionToggleShowManipulator() * @property {QAction} onActionToggleShowPegs() * @property {QAction} onActionToggleShowOutline() * @property {QAction} onActionSetPegPosition(int) * @property {QAction} onActionShiftAndTraceToolRotateOverride() * @property {QAction} onActionShiftAndTraceToolScaleOverride() * @property {QAction} onActionShiftAndTraceToolResetCurrentPosition() * @property {QAction} onActionShiftAndTraceToolResetAllPositions() * @property {QAction} onActionResetCurrentShiftPosition() * @property {QAction} onActionResetAllShiftPositions() * @property {QAction} onActionShowCrossHair() * @property {QAction} onActionAddCrossHairMode() * @property {QAction} onActionRemoveCrossHair() */ /** * Actions available in the artLayerResponder responder. * @name actions#artLayerResponder * @property {QAction} onActionPreviewModeToggle() * @property {QAction} onActionOverlayArtSelected() * @property {QAction} onActionLineArtSelected() * @property {QAction} onActionColorArtSelected() * @property {QAction} onActionUnderlayArtSelected() * @property {QAction} onActionToggleLineColorArt() * @property {QAction} onActionToggleOverlayUnderlayArt() */ /** * Actions available in the brushSettingsResponder responder. * @name actions#brushSettingsResponder */ /** * Actions available in the cameraView responder. * @name actions#cameraView * @property {QAction} onActionRenameDrawing() * @property {QAction} onActionRenameDrawingWithPrefix() * @property {QAction} onActionDeleteDrawings() * @property {QAction} onActionToggleShowSymbolPivot() * @property {QAction} onActionShowGrid() * @property {QAction} onActionNormalGrid() * @property {QAction} onAction12FieldGrid() * @property {QAction} onAction16FieldGrid() * @property {QAction} onActionWorldGrid() * @property {QAction} onActionGridUnderlay() * @property {QAction} onActionGridOverlay() * @property {QAction} onActionFieldGridBox() * @property {QAction} onActionHideLineTexture() * @property {QAction} onActionAutoLightTable() * @property {QAction} onActionGetRightToModifyDrawings() * @property {QAction} onActionReleaseRightToModifyDrawings() * @property {QAction} onActionChooseSelectToolOverride() * @property {QAction} onActionChooseContourEditorToolOverride() * @property {QAction} onActionChooseCenterlineEditorToolOverride() * @property {QAction} onActionChooseDeformToolOverride() * @property {QAction} onActionChoosePerspectiveToolOverride() * @property {QAction} onActionChooseCutterToolOverride() * @property {QAction} onActionChooseMorphToolOverride() * @property {QAction} onActionChooseBrushToolOverride() * @property {QAction} onActionChooseRepositionAllDrawingsToolOverride() * @property {QAction} onActionChooseEraserToolOverride() * @property {QAction} onActionChooseRepaintBrushToolOverride() * @property {QAction} onActionChoosePencilToolOverride() * @property {QAction} onActionChooseLineToolOverride() * @property {QAction} onActionChoosePolylineToolOverride() * @property {QAction} onActionChooseRectangleToolOverride() * @property {QAction} onActionChooseEllipseToolOverride() * @property {QAction} onActionChoosePaintToolOverride() * @property {QAction} onActionChooseInkToolOverride() * @property {QAction} onActionChoosePaintUnpaintedToolOverride() * @property {QAction} onActionChooseRepaintToolOverride() * @property {QAction} onActionChooseStrokeToolOverride() * @property {QAction} onActionChooseCloseGapToolOverride() * @property {QAction} onActionChooseUnpaintToolOverride() * @property {QAction} onActionChooseDropperToolOverride() * @property {QAction} onActionChooseEditTransformToolOverride() * @property {QAction} onActionChooseGrabberToolOverride() * @property {QAction} onActionChooseZoomToolOverride() * @property {QAction} onActionChooseRotateToolOverride() * @property {QAction} onActionChooseThirdPersonNavigation3dToolOverride() * @property {QAction} onActionChooseFirstPersonNavigation3dToolOverride() * @property {QAction} onActionChooseShiftAndTraceToolOverride() * @property {QAction} onActionChooseNoToolOverride() * @property {QAction} onActionChooseResizePenStyleToolOverride() * @property {QAction} onActionZoomIn() * @property {QAction} onActionZoomOut() * @property {QAction} onActionRotateCW() * @property {QAction} onActionRotateCCW() * @property {QAction} onActionToggleQuickCloseUp() * @property {QAction} onActionResetZoom() * @property {QAction} onActionResetRotation() * @property {QAction} onActionResetPan() * @property {QAction} onActionResetView() * @property {QAction} onActionRecenter() * @property {QAction} onActionMorphSwitchKeyDrawing() * @property {QAction} onActionShowPaletteManager() * @property {QAction} onActionShowColorEditor() * @property {QAction} onActionShowColorPicker() * @property {QAction} onActionShowColorModel() * @property {QAction} onActionShowThumbnailPanel() * @property {QAction} onActionPlayByFrame() * @property {QAction} onActionPreviousDrawing() * @property {QAction} onActionNextDrawing() * @property {QAction} onActionPreviousColumn() * @property {QAction} onActionNextColumn() * @property {QAction} onActionCreateEmptyDrawing() * @property {QAction} onActionDuplicateDrawing() * @property {QAction} onActionShowScanInfo() * @property {QAction} onActionSetThumbnailSize(int) * @property {QAction} onActionSelectedElementSwapToNextDrawing() * @property {QAction} onActionSelectedElementSwapToPrevDrawing() * @property {QAction} onActionToggleShiftAndTracePegView() * @property {QAction} onActionToggleShiftAndTraceManipulator() * @property {QAction} onActionShowMorphingInspector() * @property {QAction} onActionRemoveFromDrawingList() * @property {QAction} onActionResetDrawingPosition() * @property {QAction} onActionToggleDrawingOnPeg() * @property {QAction} onActionToggleDrawingOnPeg(VL_DrawingListWidget*) * @property {QAction} onActionToggleDrawingVisibility() * @property {QAction} onActionToggleDrawingVisibility(VL_DrawingListWidget*) * @property {QAction} onActionMoveDrawingUp() * @property {QAction} onActionMoveDrawingDown() * @property {QAction} onActionReturnToNormalMode() * @property {QAction} onActionLinkSelectedDrawings() * @property {QAction} onActionMainGotoNextFrame() * @property {QAction} onActionMainGotoPreviousFrame() * @property {QAction} onActionMainGotoFirstFrame() * @property {QAction} onActionMainGotoLastFrame() * @property {QAction} onActionRenameDrawing() * @property {QAction} onActionRenameDrawingWithPrefix() * @property {QAction} onActionNaviSelectChild() * @property {QAction} onActionNaviSelectChilds() * @property {QAction} onActionInsertControlPoint() * @property {QAction} onActionSelectControlPoint() * @property {QAction} onActionNaviSelectNextBrother() * @property {QAction} onActionNaviSelectParent() * @property {QAction} onActionNaviSelectPreviousBrother() * @property {QAction} onActionNaviSelectParentWithEffects() * @property {QAction} onActionNaviSelectChildWithEffects() * @property {QAction} onActionAutoLightTable() * @property {QAction} onActionSetSmallFilesResolution() * @property {QAction} onActionInvalidateCanvas() * @property {QAction} onActionChooseSpSelectToolOverride() * @property {QAction} onActionChooseSpTranslateToolOverride() * @property {QAction} onActionChooseSpRotateToolOverride() * @property {QAction} onActionChooseSpScaleToolOverride() * @property {QAction} onActionChooseSpSkewToolOverride() * @property {QAction} onActionChooseSpMaintainSizeToolOverride() * @property {QAction} onActionChooseSpTransformToolOverride() * @property {QAction} onActionChooseSpInverseKinematicsToolOverride() * @property {QAction} onActionChooseSpOffsetZToolOverride() * @property {QAction} onActionChooseSpSplineOffsetToolOverride() * @property {QAction} onActionChooseSpSmoothEditingToolOverride() * @property {QAction} onActionUnlockAll() * @property {QAction} onActionUnlock() * @property {QAction} onActionLockAll() * @property {QAction} onActionLockAllOthers() * @property {QAction} onActionLock() * @property {QAction} onActionTag() * @property {QAction} onActionUntag() * @property {QAction} onActionToggleCameraCone() * @property {QAction} onActionToggleCameraMask() * @property {QAction} onActionTogglePreventFromDrawing() * @property {QAction} onActionFocusOnSelectionCV() * @property {QAction} onActionTogglePlayback() * @property {QAction} onActionOnionOnSelection() * @property {QAction} onActionOnionOffSelection() * @property {QAction} onActionOnionOffAllOther() * @property {QAction} onActionOnionOnAll() * @property {QAction} onActionOnionOffAll() * @property {QAction} onActionOpenGLView() * @property {QAction} onActionRenderView() * @property {QAction} onActionMatteView() * @property {QAction} onActionDepthView() * @property {QAction} onActionNodeCacheEnable() * @property {QAction} onActionNodeCacheQuality() * @property {QAction} onActionNodeCacheHide() * @property {QAction} onActionNodeCacheDisable() * @property {QAction} onActionMorphSwitchKeyDrawing() * @property {QAction} onActionRender() * @property {QAction} onActionAutoRender() * @property {QAction} onActionEnterSymbol() * @property {QAction} onActionLeaveSymbol() */ /** * Actions available in the colorOperationsResponder responder. * @name actions#colorOperationsResponder * @property {QAction} onActionRepaintColorInDrawing() */ /** * Actions available in the coordControlView responder. * @name actions#coordControlView */ /** * Actions available in the drawingSelectionResponder responder. * @name actions#drawingSelectionResponder */ /** * Actions available in the drawingView responder. * @name actions#drawingView * @property {QAction} onActionRenameDrawing() * @property {QAction} onActionRenameDrawingWithPrefix() * @property {QAction} onActionDeleteDrawings() * @property {QAction} onActionToggleShowSymbolPivot() * @property {QAction} onActionShowGrid() * @property {QAction} onActionNormalGrid() * @property {QAction} onAction12FieldGrid() * @property {QAction} onAction16FieldGrid() * @property {QAction} onActionWorldGrid() * @property {QAction} onActionGridUnderlay() * @property {QAction} onActionGridOverlay() * @property {QAction} onActionFieldGridBox() * @property {QAction} onActionHideLineTexture() * @property {QAction} onActionAutoLightTable() * @property {QAction} onActionGetRightToModifyDrawings() * @property {QAction} onActionReleaseRightToModifyDrawings() * @property {QAction} onActionChooseSelectToolOverride() * @property {QAction} onActionChooseContourEditorToolOverride() * @property {QAction} onActionChooseCenterlineEditorToolOverride() * @property {QAction} onActionChooseDeformToolOverride() * @property {QAction} onActionChoosePerspectiveToolOverride() * @property {QAction} onActionChooseCutterToolOverride() * @property {QAction} onActionChooseMorphToolOverride() * @property {QAction} onActionChooseBrushToolOverride() * @property {QAction} onActionChooseRepositionAllDrawingsToolOverride() * @property {QAction} onActionChooseEraserToolOverride() * @property {QAction} onActionChooseRepaintBrushToolOverride() * @property {QAction} onActionChoosePencilToolOverride() * @property {QAction} onActionChooseLineToolOverride() * @property {QAction} onActionChoosePolylineToolOverride() * @property {QAction} onActionChooseRectangleToolOverride() * @property {QAction} onActionChooseEllipseToolOverride() * @property {QAction} onActionChoosePaintToolOverride() * @property {QAction} onActionChooseInkToolOverride() * @property {QAction} onActionChoosePaintUnpaintedToolOverride() * @property {QAction} onActionChooseRepaintToolOverride() * @property {QAction} onActionChooseStrokeToolOverride() * @property {QAction} onActionChooseCloseGapToolOverride() * @property {QAction} onActionChooseUnpaintToolOverride() * @property {QAction} onActionChooseDropperToolOverride() * @property {QAction} onActionChooseEditTransformToolOverride() * @property {QAction} onActionChooseGrabberToolOverride() * @property {QAction} onActionChooseZoomToolOverride() * @property {QAction} onActionChooseRotateToolOverride() * @property {QAction} onActionChooseThirdPersonNavigation3dToolOverride() * @property {QAction} onActionChooseFirstPersonNavigation3dToolOverride() * @property {QAction} onActionChooseShiftAndTraceToolOverride() * @property {QAction} onActionChooseNoToolOverride() * @property {QAction} onActionChooseResizePenStyleToolOverride() * @property {QAction} onActionZoomIn() * @property {QAction} onActionZoomOut() * @property {QAction} onActionRotateCW() * @property {QAction} onActionRotateCCW() * @property {QAction} onActionToggleQuickCloseUp() * @property {QAction} onActionResetZoom() * @property {QAction} onActionResetRotation() * @property {QAction} onActionResetPan() * @property {QAction} onActionResetView() * @property {QAction} onActionRecenter() * @property {QAction} onActionMorphSwitchKeyDrawing() * @property {QAction} onActionShowPaletteManager() * @property {QAction} onActionShowColorEditor() * @property {QAction} onActionShowColorPicker() * @property {QAction} onActionShowColorModel() * @property {QAction} onActionShowThumbnailPanel() * @property {QAction} onActionPlayByFrame() * @property {QAction} onActionPreviousDrawing() * @property {QAction} onActionNextDrawing() * @property {QAction} onActionPreviousColumn() * @property {QAction} onActionNextColumn() * @property {QAction} onActionCreateEmptyDrawing() * @property {QAction} onActionDuplicateDrawing() * @property {QAction} onActionShowScanInfo() * @property {QAction} onActionSetThumbnailSize(int) * @property {QAction} onActionSelectedElementSwapToNextDrawing() * @property {QAction} onActionSelectedElementSwapToPrevDrawing() * @property {QAction} onActionToggleShiftAndTracePegView() * @property {QAction} onActionToggleShiftAndTraceManipulator() * @property {QAction} onActionShowMorphingInspector() * @property {QAction} onActionRemoveFromDrawingList() * @property {QAction} onActionResetDrawingPosition() * @property {QAction} onActionToggleDrawingOnPeg() * @property {QAction} onActionToggleDrawingOnPeg(VL_DrawingListWidget*) * @property {QAction} onActionToggleDrawingVisibility() * @property {QAction} onActionToggleDrawingVisibility(VL_DrawingListWidget*) * @property {QAction} onActionMoveDrawingUp() * @property {QAction} onActionMoveDrawingDown() * @property {QAction} onActionReturnToNormalMode() * @property {QAction} onActionLinkSelectedDrawings() */ /** * Actions available in the exportCoreResponder responder. * @name actions#exportCoreResponder * @property {QAction} onActionGenerateLayoutImage() */ /** * Actions available in the graph3dresponder responder. * @name actions#graph3dresponder * @property {QAction} onActionShowSubnodeShape() * @property {QAction} onActionHideSubnodeShape() * @property {QAction} onActionEnableSubnode() * @property {QAction} onActionDisableSubnode() * @property {QAction} onActionCreateSubNodeTransformation() * @property {QAction} onActionAddSubTransformationFilter() * @property {QAction} onActionSelectParent() * @property {QAction} onActionSelectChild() * @property {QAction} onActionSelectNextSibling() * @property {QAction} onActionSelectPreviousSibling() * @property {QAction} onActionDumpSceneGraphInformation() */ /** * Actions available in the ikResponder responder. * @name actions#ikResponder * @property {QAction} onActionSetIKNail() * @property {QAction} onActionSetIKHoldOrientation() * @property {QAction} onActionSetIKHoldX() * @property {QAction} onActionSetIKHoldY() * @property {QAction} onActionSetIKMinAngle() * @property {QAction} onActionSetIKMaxAngle() * @property {QAction} onActionRemoveAllConstraints() */ /** * Actions available in the libraryView responder. * @name actions#libraryView */ /** * Actions available in the logView responder. * @name actions#logView */ /** * Actions available in the miniPegModuleResponder responder. * @name actions#miniPegModuleResponder * @property {QAction} onActionResetDeform() * @property {QAction} onActionCopyRestingPositionToCurrentPosition() * @property {QAction} onActionConvertEllipseToShape() * @property {QAction} onActionSelectRigTool() * @property {QAction} onActionInsertDeformationAbove() * @property {QAction} onActionInsertDeformationUnder() * @property {QAction} onActionToggleEnableDeformation() * @property {QAction} onActionToggleShowAllManipulators() * @property {QAction} onActionToggleShowAllROI() * @property {QAction} onActionToggleShowSimpleManipulators() * @property {QAction} onActionConvertSelectionToCurve() * @property {QAction} onActionStraightenSelection() * @property {QAction} onActionShowSelectedDeformers() * @property {QAction} onActionShowDeformer(QString) * @property {QAction} onActionHideDeformer(QString) * @property {QAction} onActionCreateKinematicOutput() * @property {QAction} onActionConvertDeformedDrawingsToDrawings() * @property {QAction} onActionConvertDeformedDrawingsAndCreateDeformation() * @property {QAction} onActionUnsetLocalFlag() * @property {QAction} onActionCopyCurvePositionToOffset() * @property {QAction} onActionAddDeformationModuleByName(QString) * @property {QAction} onActionCreateNewDeformationChain() * @property {QAction} onActionRenameTransformation() * @property {QAction} onActionSetTransformation() * @property {QAction} onActionSetMasterElementModule() * @property {QAction} onActionToggleShowManipulator() */ /** * Actions available in the moduleLibraryView responder. * @name actions#moduleLibraryView * @property {QAction} onActionReceiveFocus() * @property {QAction} onActionNewCategory() * @property {QAction} onActionRenameCategory() * @property {QAction} onActionRemoveCategory() * @property {QAction} onActionRemoveUserModule() * @property {QAction} onActionRefresh() */ /** * Actions available in the moduleResponder responder. * @name actions#moduleResponder * @property {QAction} onActionAddModuleByName(QString) * @property {QAction} onActionAddModule(int,int) */ /** * Actions available in the onionSkinResponder responder. * @name actions#onionSkinResponder * @property {QAction} onActionOnionSkinToggle() * @property {QAction} onActionOnionSkinToggleCenterline() * @property {QAction} onActionOnionSkinToggleFramesToDrawingsMode() * @property {QAction} onActionOnionSkinNoPrevDrawings() * @property {QAction} onActionOnionSkin1PrevDrawing() * @property {QAction} onActionOnionSkin2PrevDrawings() * @property {QAction} onActionOnionSkin3PrevDrawings() * @property {QAction} onActionOnionSkinNoNextDrawings() * @property {QAction} onActionOnionSkin1NextDrawing() * @property {QAction} onActionOnionSkin2NextDrawings() * @property {QAction} onActionOnionSkin3NextDrawings() * @property {QAction} onActionSetMarksOnionSkinInBetween() * @property {QAction} onActionSetMarksOnionSkinInBreakdown() * @property {QAction} onActionSetMarksOnionSkinInKey() * @property {QAction} onActionSetDrawingEnhancedOnionSkin() * @property {QAction} onActionOnionSkinReduceNextDrawing() * @property {QAction} onActionOnionSkinAddNextDrawing() * @property {QAction} onActionOnionSkinReducePrevDrawing() * @property {QAction} onActionOnionSkinAddPrevDrawing() * @property {QAction} onActionToggleOnionSkinForCustomMarkedType(QString) * @property {QAction} onActionOnionSkinSelectedLayerOnly() * @property {QAction} onActionOnionSkinRenderStyle(int) * @property {QAction} onActionOnionSkinDrawingMode(int) * @property {QAction} onActionOnionSkinToggleBaseMode() * @property {QAction} onActionOnionSkinToggleAdvancedMode() * @property {QAction} onActionOnionSkinToggleColorWash() * @property {QAction} onActionOnionSkinLinkSliders() * @property {QAction} onActionOnionSkinToggleAdvancedNext(int) * @property {QAction} onActionOnionSkinToggleAdvancedPrev(int) * @property {QAction} onActionOnionSkinAdvancedNextSliderChanged(int,int) * @property {QAction} onActionOnionSkinAdvancedPrevSliderChanged(int,int) * @property {QAction} onActionMaxOpacitySliderChanged(int) */ /** * Actions available in the onionSkinView responder. * @name actions#onionSkinView */ /** * Actions available in the opacityPanel responder. * @name actions#opacityPanel * @property {QAction} onActionNewOpacityTexture() * @property {QAction} onActionDeleteOpacityTexture() * @property {QAction} onActionRenameOpacityTexture() * @property {QAction} onActionCurToPrefPalette() * @property {QAction} onActionPrefToCurPalette() */ /** * Actions available in the paletteView responder. * @name actions#paletteView */ /** * Actions available in the pencilPanel responder. * @name actions#pencilPanel * @property {QAction} onActionNewPencilTemplate() * @property {QAction} onActionDeletePencilTemplate() * @property {QAction} onActionRenamePencilTemplate() * @property {QAction} onActionShowSmallThumbnail() * @property {QAction} onActionShowLargeThumbnail() * @property {QAction} onActionShowStroke() */ /** * Actions available in the scene responder. * @name actions#scene * @property {QAction} onActionHideSelection() * @property {QAction} onActionShowHidden() * @property {QAction} onActionRehideSelection() * @property {QAction} onActionInsertPositionKeyframe() * @property {QAction} onActionInsertKeyframe() * @property {QAction} onActionSetKeyFrames() * @property {QAction} onActionInsertControlPointAtFrame() * @property {QAction} onActionSetConstant() * @property {QAction} onActionSetNonConstant() * @property {QAction} onActionToggleContinuity() * @property {QAction} onActionToggleLockInTime() * @property {QAction} onActionResetTransformation() * @property {QAction} onActionResetAll() * @property {QAction} onActionResetAllExceptZ() * @property {QAction} onActionSelectPrevObject() * @property {QAction} onActionSelectNextObject() * @property {QAction} onActionToggleNoFBDragging() * @property {QAction} onActionToggleAutoApply() * @property {QAction} onActionToggleAutoLock() * @property {QAction} onActionToggleAutoLockPalettes() * @property {QAction} onActionToggleAutoLockPaletteLists() * @property {QAction} onActionToggleEnableWrite() * @property {QAction} onActionShowHideManager() * @property {QAction} onActionToggleControl() * @property {QAction} onActionHideAllControls() * @property {QAction} onActionPreviousDrawing() * @property {QAction} onActionNextDrawing() * @property {QAction} onActionPreviousColumn() * @property {QAction} onActionNextColumn() * @property {QAction} onActionShowSubNode(bool) * @property {QAction} onActionGotoDrawing1() * @property {QAction} onActionGotoDrawing2() * @property {QAction} onActionGotoDrawing3() * @property {QAction} onActionGotoDrawing4() * @property {QAction} onActionGotoDrawing5() * @property {QAction} onActionGotoDrawing6() * @property {QAction} onActionGotoDrawing7() * @property {QAction} onActionGotoDrawing8() * @property {QAction} onActionGotoDrawing9() * @property {QAction} onActionGotoDrawing10() * @property {QAction} onActionToggleVelocityEditor() * @property {QAction} onActionCreateScene() * @property {QAction} onActionChooseSelectToolInNormalMode() * @property {QAction} onActionChooseSelectToolInColorMode() * @property {QAction} onActionChoosePaintToolInPaintMode() * @property {QAction} onActionChooseInkTool() * @property {QAction} onActionChoosePaintToolInRepaintMode() * @property {QAction} onActionChoosePaintToolInUnpaintMode() * @property {QAction} onActionChoosePaintToolInPaintUnpaintedMode() * @property {QAction} onActionChooseBrushToolInBrushMode() * @property {QAction} onActionChooseBrushToolInRepaintBrushMode() * @property {QAction} onActionToggleDrawBehindMode() * @property {QAction} onActionWhatsThis() * @property {QAction} onActionEditProperties() */ /** * Actions available in the sceneUI responder. * @name actions#sceneUI * @property {QAction} onActionSaveLayouts() * @property {QAction} onActionSaveWorkspaceAs() * @property {QAction} onActionShowLayoutManager() * @property {QAction} onActionFullscreen() * @property {QAction} onActionRaiseArea(QString,bool) * @property {QAction} onActionRaiseArea(QString) * @property {QAction} onActionSetLayout(QString,int) * @property {QAction} onActionLockScene() * @property {QAction} onActionLockSceneVersion() * @property {QAction} onActionPaintModePaletteManager() * @property {QAction} onActionPaintModeLogView() * @property {QAction} onActionPaintModeModelView() * @property {QAction} onActionPaintModeToolPropertiesView() * @property {QAction} onActionUndo() * @property {QAction} onActionUndo(int) * @property {QAction} onActionRedo() * @property {QAction} onActionRedo(int) * @property {QAction} onActionShowCurrentDrawingOnTop() * @property {QAction} onActionShowWelcomeScreen() * @property {QAction} onActionShowWelcomeScreenQuit() * @property {QAction} onActionSaveLayoutInScene() * @property {QAction} onActionNewView(int) * @property {QAction} onActionNewView(QString) * @property {QAction} onActionNewViewChecked(QString) * @property {QAction} onActionToggleRenderer() * @property {QAction} onActionToggleBBoxHighlighting() * @property {QAction} onActionToggleShowLockedDrawingsInOutline() * @property {QAction} onActionCancelSoftRender() * @property {QAction} onActionCheckFiles() * @property {QAction} onActionCleanPaletteLists() * @property {QAction} onActionDeleteVersions() * @property {QAction} onActionMacroManager() * @property {QAction} onActionRestoreDefaultLayout() * @property {QAction} onActionExit() * @property {QAction} onActionExitDelayed() * @property {QAction} onActionAddVectorDrawing() * @property {QAction} onActionAddSound() * @property {QAction} onActionAddPeg() * @property {QAction} onActionToggleShowMergeSelectionDialog() * @property {QAction} onActionToggleShowScanDialog() * @property {QAction} onActionSetPreviewResolution(int) * @property {QAction} onActionSetTempoMarker() * @property {QAction} onActionSingleFlip() * @property {QAction} onActionNewScene() * @property {QAction} onActionNewSceneDelayed() * @property {QAction} onActionOpen() * @property {QAction} onActionOpenDelayed() * @property {QAction} onActionOpenScene() * @property {QAction} onActionOpenSceneDelayed() * @property {QAction} onActionOpenScene(QString) * @property {QAction} onActionSaveEverything() * @property {QAction} onActionSaveEverythingIncludingSceneMachineFrames() * @property {QAction} onActionSaveAsScene() * @property {QAction} onActionSaveVersion() * @property {QAction} onActionSaveDialog() * @property {QAction} onActionImportDrawings() * @property {QAction} onActionImport3dmodels() * @property {QAction} onActionImportTimings() * @property {QAction} onActionScanDrawings() * @property {QAction} onActionImportLocalLibrary() * @property {QAction} onActionImportSound() * @property {QAction} onActionMmxImport() * @property {QAction} onActionFlashExport() * @property {QAction} onActionFLVExport() * @property {QAction} onActionMmxExport() * @property {QAction} onActionSoundtrackExport() * @property {QAction} onActionComposite() * @property {QAction} onActionCompositeBatchOnly() * @property {QAction} onActionSaveOpenGLFrames() * @property {QAction} onActionToggleFlipForCustomMarkedType(QString) * @property {QAction} onActionCreateFullImport() * @property {QAction} onActionPerformFullImport() * @property {QAction} onActionPerformPartialImport() * @property {QAction} onActionPerformPartialUpdate() * @property {QAction} onActionToggleToolBar(QString) * @property {QAction} onActionSetDefaultDisplay(QString) * @property {QAction} onActionCloseScene() * @property {QAction} onActionCloseSceneDelayed() * @property {QAction} onActionCloseThenReopen() * @property {QAction} onActionOpenDrawings() * @property {QAction} onActionOpenDrawingsDelayed() * @property {QAction} onActionOpenDrawingsModify() * @property {QAction} onActionOpenElements() * @property {QAction} onActionOpenElementsDelayed() * @property {QAction} onActionClearRecentSceneList() * @property {QAction} onActionOpenBackgroundFile() * @property {QAction} onActionUnloadBackground() * @property {QAction} onActionScaleBackgroundUp() * @property {QAction} onActionScaleBackgroundDown() * @property {QAction} onActionResetBackgroundPosition() * @property {QAction} onActionSetDrawingMarksFlipKey() * @property {QAction} onActionSetDrawingMarksFlipBreakdown() * @property {QAction} onActionSetDrawingMarksFlipInBetween() * @property {QAction} onActionChooseSelectTool() * @property {QAction} onActionChooseContourEditorTool() * @property {QAction} onActionChooseCenterlineEditorTool() * @property {QAction} onActionChooseDeformTool() * @property {QAction} onActionChoosePerspectiveTool() * @property {QAction} onActionChooseEnvelopeTool() * @property {QAction} onActionChooseCutterTool() * @property {QAction} onActionChooseMorphTool() * @property {QAction} onActionChoosePivotTool() * @property {QAction} onActionChooseBrushTool() * @property {QAction} onActionChooseRepositionAllDrawingsTool() * @property {QAction} onActionChooseEraserTool() * @property {QAction} onActionChooseRepaintBrushTool() * @property {QAction} onActionChoosePencilTool() * @property {QAction} onActionChoosePencilEditorTool() * @property {QAction} onActionChooseLineTool() * @property {QAction} onActionChoosePolylineTool() * @property {QAction} onActionChooseRectangleTool() * @property {QAction} onActionChooseEllipseTool() * @property {QAction} onActionChoosePaintTool() * @property {QAction} onActionChooseInkTool() * @property {QAction} onActionChoosePaintUnpaintedTool() * @property {QAction} onActionChooseRepaintTool() * @property {QAction} onActionChooseStampTool() * @property {QAction} onActionChooseStrokeTool() * @property {QAction} onActionChooseCloseGapTool() * @property {QAction} onActionChooseUnpaintTool() * @property {QAction} onActionChooseDropperTool() * @property {QAction} onActionChooseEditTransformTool() * @property {QAction} onActionChooseGrabberTool() * @property {QAction} onActionChooseZoomTool() * @property {QAction} onActionChooseRotateTool() * @property {QAction} onActionChooseThirdPersonNavigation3dTool() * @property {QAction} onActionChooseFirstPersonNavigation3dTool() * @property {QAction} onActionChooseShiftAndTraceTool() * @property {QAction} onActionChooseNoTool() * @property {QAction} onActionChooseResizePenStyleTool() * @property {QAction} onActionChooseSpSelectTool() * @property {QAction} onActionChooseSpTranslateTool() * @property {QAction} onActionChooseSpRotateTool() * @property {QAction} onActionChooseSpScaleTool() * @property {QAction} onActionChooseSpSkewTool() * @property {QAction} onActionChooseSpMaintainSizeTool() * @property {QAction} onActionChooseSpTransformTool() * @property {QAction} onActionChooseSpInverseKinematicsTool() * @property {QAction} onActionChooseSpOffsetZTool() * @property {QAction} onActionChooseSpSplineOffsetTool() * @property {QAction} onActionChooseSpSmoothEditingTool() * @property {QAction} onActionChooseMoveBackgroundTool() * @property {QAction} onActionChooseTextTool() * @property {QAction} onActionActivatePreset(int) * @property {QAction} onActionToggleKeyframeMode() * @property {QAction} onActionAnimatedKeyframeMode() * @property {QAction} onActionAnimatedOnRangeKeyframeMode() * @property {QAction} onActionStaticKeyframeMode() * @property {QAction} onActionSetKeyframeMode() * @property {QAction} onActionSetAllKeyframesMode() * @property {QAction} onActionSetConstantSegMode() * @property {QAction} onActionMainPlay() * @property {QAction} onActionMainPlayFw() * @property {QAction} onActionMainPlayBw() * @property {QAction} onActionMainPlayPreviewFw() * @property {QAction} onActionMainPlayPreviewSwf() * @property {QAction} onActionMainStopPlaying() * @property {QAction} onActionMainToggleLoopPlay() * @property {QAction} onActionMainToggleEnableCacheForPlay() * @property {QAction} onActionMainToggleEnableSoundForPlay() * @property {QAction} onActionMainToggleEnableSoundScrubbing() * @property {QAction} onActionChooseSpRepositionTool() * @property {QAction} onActionMainSetPlaybackStartFrame() * @property {QAction} onActionMainSetPlaybackStopFrame() * @property {QAction} onActionMainGotoFrame() * @property {QAction} onActionMainSetPlaybackSpeed() * @property {QAction} onActionMainGotoFirstFrame() * @property {QAction} onActionMainGotoPreviousFrame() * @property {QAction} onActionMainGotoLastFrame() * @property {QAction} onActionMainGotoNextFrame() * @property {QAction} onActionToggleSideViewPlayback() * @property {QAction} onActionToggleTopViewPlayback() * @property {QAction} onActionTogglePersViewPlayback() * @property {QAction} onActionReshapeMultipleKeyframes() * @property {QAction} onActionJogForward() * @property {QAction} onActionJogBackward() * @property {QAction} onActionShuttleForward() * @property {QAction} onActionShuttleBackward() * @property {QAction} onActionShuttleReset() * @property {QAction} onActionConformationImport() * @property {QAction} onActionShowPreferenceDialog() * @property {QAction} onActionShowShortcutsDialog() * @property {QAction} onActionManageLocalCaches() * @property {QAction} onActionReadChangedDrawings() * @property {QAction} onActionReadChangedDrawingsNoWarning() * @property {QAction} onActionPaletteOperations() * @property {QAction} onActionToggleDebugMode() * @property {QAction} onActionHelp() * @property {QAction} onActionHtmlHelp(QString) * @property {QAction} onActionOpenBook(QString) * @property {QAction} onActionAbout() * @property {QAction} onActionCEIP() * @property {QAction} onActionShowLicense() * @property {QAction} onActionShowReadme() * @property {QAction} onActionOpenURL(QString,int) * @property {QAction} onActionOpenURL(QString) * @property {QAction} onActionTerminate() * @property {QAction} onActionToggleGoogleAnalytics() */ /** * Actions available in the scriptResponder responder. * @name actions#scriptResponder * @property {QAction} onActionExecuteScript(QString) * @property {QAction} onActionExecuteScriptWithValidator(QString,AC_ActionInfo*) * @property {QAction} onActionActivateTool(int) * @property {QAction} onActionActivateToolByName(QString) */ /** * Actions available in the selectionResponder responder. * @name actions#selectionResponder */ /** * Actions available in the sessionResponder responder. * @name actions#sessionResponder * @property {QAction} onActionFixSymbolCompositeAndDisplay() */ /** * Actions available in the timelineView responder. * @name actions#timelineView * @property {QAction} onActionPropagateLayerSelection() */ /** * Actions available in the toolProperties responder. * @name actions#toolProperties * @property {QAction} onActionNewBrush() * @property {QAction} onActionDeleteBrush() * @property {QAction} onActionRenameBrush() * @property {QAction} onActionEditBrush() * @property {QAction} onActionImportBrushes() * @property {QAction} onActionExportBrushes() * @property {QAction} onActionShowSmallThumbnail() * @property {QAction} onActionShowLargeThumbnail() * @property {QAction} onActionShowStroke() */ /** * Actions available in the toolPropertiesView responder. * @name actions#toolPropertiesView */ /** * Actions available in the xsheetView responder. * @name actions#xsheetView * @property {QAction} onActionPrintXsheet() * @property {QAction} onActionResetCellsSize() * @property {QAction} onActionZoomIn() * @property {QAction} onActionZoomOut() * @property {QAction} onActionZoomExtents() * @property {QAction} onActionResetZoom() * @property {QAction} onActionResetPan() * @property {QAction} onActionResetView() * @property {QAction} onActionShowUnhideObjectsEditor() * @property {QAction} onActionUnhideAllColumns() * @property {QAction} onActionXsheetHoldValueMenu(int) * @property {QAction} onActionSendToFunctionView() * @property {QAction} onActionToggleShowGrouping() * @property {QAction} onActionToggleMinimalHeaders() * @property {QAction} onActionDisplayShowDlg() * @property {QAction} onActionToggleInsertMode() * @property {QAction} onActionToggleGesturalDrag() * @property {QAction} onActionToggleThumbnails() * @property {QAction} onActionToggleIsShowDrawingCols() * @property {QAction} onActionToggleIsShowFunctionCols() * @property {QAction} onActionToggleIsShowPath3dCols() * @property {QAction} onActionToggleIsShow3dRotationCols() * @property {QAction} onActionToggleIsShowSoundCols() * @property {QAction} onActionToggleSidePanel() * @property {QAction} onActionXsheetHeldFramesLine(int) * @property {QAction} onActionXsheetEmptyCellsX(int) * @property {QAction} onActionXsheetLabelsFrames(int) * @property {QAction} onActionSelectedElementSwapToNextDrawing() * @property {QAction} onActionSelectedElementSwapToPrevDrawing() * @property {QAction} onActionToggleSelection() * @property {QAction} onActionTag() * @property {QAction} onActionUntag() * @property {QAction} onActionUntagAllOthers() * @property {QAction} onActionTagPublic() * @property {QAction} onActionUntagPublic() * @property {QAction} onActionUntagPublicAllOthers() */ ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_application.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library // // // Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the Mozilla Public license 2.0. // https://www.mozilla.org/en-US/MPL/2.0/ // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oApp class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oApp class * @classdesc * The $.oApp class provides access to the Harmony application and its widgets. * @constructor */ $.oApp = function(){ } /** * The Harmony version number * @name $.oApp#version * @type {int} * @readonly */ Object.defineProperty($.oApp.prototype, 'version', { get : function(){ return parseInt(about.getVersionInfoStr().split("version").pop().split(".")[0], 10); } }); /** * The software flavour: Premium, Advanced, Essential * @name $.oApp#flavour * @type {string} * @readonly */ Object.defineProperty($.oApp.prototype, 'flavour', { get : function(){ return about.getFlavorString(); } }); /** * The Harmony Main Window. * @name $.oApp#mainWindow * @type {QWidget} * @readonly */ Object.defineProperty($.oApp.prototype, 'mainWindow', { get : function(){ var windows = QApplication.topLevelWidgets(); for ( var i in windows) { if (windows[i] instanceof QMainWindow && !windows[i].parentWidget()) return windows[i]; } return false } }); /** * The Harmony UI Toolbars. * @name $.oApp#toolbars * @type {QToolbar} * @readonly */ Object.defineProperty($.oApp.prototype, 'toolbars', { get : function(){ var widgets = QApplication.allWidgets(); var _toolbars = widgets.filter(function(x){return x instanceof QToolBar}) return _toolbars } }); /** * The Position of the mouse cursor in the toonboom window coordinates. * @name $.oApp#mousePosition * @type {$.oPoint} * @readonly */ Object.defineProperty($.oApp.prototype, 'mousePosition', { get : function(){ var _position = this.$.app.mainWindow.mapFromGlobal(QCursor.pos()); return new this.$.oPoint(_position.x(), _position.y(), 0); } }); /** * The Position of the mouse cursor in the screen coordinates. * @name $.oApp#globalMousePosition * @type {$.oPoint} * @readonly */ Object.defineProperty($.oApp.prototype, 'globalMousePosition', { get : function(){ var _position = QCursor.pos(); return new this.$.oPoint(_position.x(), _position.y(), 0); } }); /** * Access the tools available in the application * @name $.oApp#tools * @type {$.oTool[]} * @readonly * @example * // Access the list of currently existing tools by using the $.app object * var tools = $.app.tools; * * // output the list of tools names and ids * for (var i in tools){ * log(i+" "+tools[i].name) * } * * // To get a tool by name, use the $.app.getToolByName() function * var brushTool = $.app.getToolByName("Brush"); * log (brushTool.name+" "+brushTool.id) // Output: Brush 9 * * // it's also possible to activate a tool in several ways: * $.app.currentTool = 9; // using the tool "id" * $.app.currentTool = brushTool // by passing a oTool object * $.app.currentTool = "Brush" // using the tool name * * brushTool.activate() // by using the activate function of the oTool class */ Object.defineProperty($.oApp.prototype, 'tools', { get: function(){ if (typeof this._toolsObject === 'undefined'){ this._toolsObject = []; var _currentTool = this.currentTool; var i = 0; Tools.setToolSettings({currentTool:{id:i}}) while(Tools.getToolSettings().currentTool.name){ var tool = Tools.getToolSettings().currentTool; this._toolsObject.push(new this.$.oTool(tool.id,tool.name)); i++; Tools.setToolSettings({currentTool:{id:i}}); } this.currentTool = _currentTool; } return this._toolsObject; } }) /** * The Position of the mouse cursor in the screen coordinates. * @name $.oApp#currentTool * @type {$.oTool} */ Object.defineProperty($.oApp.prototype, 'currentTool', { get : function(){ var _tool = Tools.getToolSettings().currentTool.id; return _tool; }, set : function(tool){ if (tool instanceof this.$.oTool) { tool.activate(); return } if (typeof tool == "string"){ try{ this.getToolByName(tool).activate(); return }catch(err){ this.$.debug("'"+ tool + "' is not a valid tool name. Valid: "+this.tools.map(function(x){return x.name}).join(", ")) } } if (typeof tool == "number"){ this.tools[tool].activate(); return } } }); /** * Gets access to a widget from the Harmony Interface. * @param {string} name The name of the widget to look for. * @param {string} [parentName] The name of the parent widget to look into, in case of duplicates. * @return {QWidget} The widget if found, or null if it doesn't exist. */ $.oApp.prototype.getWidgetByName = function(name, parentName){ var widgets = QApplication.allWidgets(); for( var i in widgets){ if (widgets[i].objectName == name){ if (typeof parentName !== 'undefined' && (widgets[i].parentWidget().objectName != parentName)) continue; return widgets[i]; } } return null; } /** * Access the Harmony Preferences * @name $.oApp#preferences * @example * // To access the preferences of Harmony, grab the preference object in the $.oApp class: * var prefs = $.app.preferences; * * // It's then possible to access all available preferences of the software: * for (var i in prefs){ * log (i+" "+prefs[i]); * } * * // accessing the preference value can be done directly by using the dot notation: * prefs.USE_OVERLAY_UNDERLAY_ART = true; * log (prefs.USE_OVERLAY_UNDERLAY_ART); * * //the details objects of the preferences object allows access to more information about each preference * var details = prefs.details * log(details.USE_OVERLAY_UNDERLAY_ART.category+" "+details.USE_OVERLAY_UNDERLAY_ART.id+" "+details.USE_OVERLAY_UNDERLAY_ART.type); * * for (var i in details){ * log(i+" "+JSON.stringify(details[i])) // each object inside detail is a complete oPreference instance * } * * // the preference object also holds a categories array with the list of all categories * log (prefs.categories) */ Object.defineProperty($.oApp.prototype, 'preferences', { get: function(){ if (typeof this._prefsObject === 'undefined'){ var _prefsObject = {}; _categories = []; _details = {}; Object.defineProperty(_prefsObject, "categories", { enumerable:false, value:_categories }) Object.defineProperty(_prefsObject, "details", { enumerable:false, value:_details }) var prefFile = (new oFile(specialFolders.resource+"/prefs.xml")).parseAsXml().children[0].children; var userPrefFile = new oFile(specialFolders.userConfig + "/Harmony Premium-pref.xml") // Harmony Pref file is called differently on the database userConfig if (!userPrefFile.exists) userPrefFile = new oFile(specialFolders.userConfig + "/Harmony-pref.xml") if (userPrefFile.exists){ var userPref = {objectName: "category", id: "user", children:userPrefFile.parseAsXml().children[0].children}; prefFile.push(userPref); } for (var i in prefFile){ if (prefFile[i].objectName != "category" || prefFile[i].id == "Storyboard") continue; var category = prefFile[i].id; if (_categories.indexOf(category) == -1) _categories.push(category); var preferences = prefFile[i].children; // create a oPreference instance for each found preference and add a getter setter to the $.oApp._prefsObject for (var j in preferences){ // evaluate condition for conditional preferences. For now only support Harmony Premium prefs if (preferences[j].objectName == "if"){ var condition = preferences[j].condition; var regex = /(not essentials|not sboard|not paint)/ if (regex.exec(condition)) preferences = preferences.concat(preferences[j].children) continue; } var type = preferences[j].objectName; var keyword = preferences[j].id; var description = preferences[j].shortDesc; var descriptionText = preferences[j].longDesc; if (type == "color"){ if (typeof preferences[j].alpha === 'undefined') preferences[j].alpha = 255; var value = new ColorRGBA(preferences[j].red, preferences[j].green, preferences[j].blue, preferences[j].alpha) }else{ var value = preferences[j].value; } // var docString = (category+" "+keyword+" "+type+" "+description) // var pref = this.$.oPreference.createPreference(category, keyword, type, value, description, descriptionText, _prefsObject); _details[pref.keyword] = pref; } } this._prefsObject = _prefsObject; } return this._prefsObject; } }) /** * The list of stencils available in the Harmony UI. * @name $.oApp#stencils * @type {$.oStencil[]} * @example * // Access the stencils list through the $.app object. * var stencils = $.app.stencils * * // list all the properties of stencils * for (var i in stencils){ * log(" ---- "+stencils[i].type+" "+stencils[i].name+" ---- ") * for(var j in stencils[i]){ * log (j); * } * } */ Object.defineProperty($.oApp.prototype, 'stencils', { get: function(){ if (typeof this._stencilsObject === 'undefined'){ // parse stencil xml file penstyles.xml to get stencils info var stencilsFile = (new oFile(specialFolders.userConfig+"/penstyles.xml")).read(); var penRegex = /([\S\s]*?)<\/pen>/igm var stencils = []; var stencilXml; while(stencilXml = penRegex.exec(stencilsFile)){ var stencilObject = this.$.oStencil.getFromXml(stencilXml[1]); stencils.push(stencilObject); } this._stencilsObject = stencils; } return this._stencilsObject; } }) /** * The currently selected stencil. Always returns the pencil tool current stencil. * @name $.oApp#currentStencil * @type {$.oStencil} */ Object.defineProperty($.oApp.prototype, 'currentStencil', { get: function(){ return this.stencils[PaletteManager.getCurrentPenstyleIndex()]; }, set: function(stencil){ if (stencil instanceof this.$.oStencil) var stencil = stencil.name this.$.debug("Setting current pen: "+ stencil) PenstyleManager.setCurrentPenstyleByName(stencil); } }) // $.oApp Class Methods /** * get a tool by its name * @return {$.oTool} a oTool object representing the tool, or null if not found. */ $.oApp.prototype.getToolByName = function(toolName){ var _tools = this.tools; for (var i in _tools){ if (_tools[i].name.toLowerCase() == toolName.toLowerCase()) return _tools[i]; } return null; } /** * returns the list of stencils usable by the specified tool * @param {$.oTool} tool the tool object we want valid stencils for * @return {$.oStencil[]} the list of stencils compatible with the specified tool */ $.oApp.prototype.getValidStencils = function (tool){ if (typeof tool === 'undefined') var tool = this.currentTool; return tool.stencils; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oToolbar class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The $.oToolbar constructor. * @name $.oToolbar * @constructor * @classdesc A toolbar that can contain any type of widgets. * @param {string} name The name of the toolbar to create. * @param {QWidget[]} [widgets] The list of widgets to add to the toolbar. * @param {QWidget} [parent] The parent widget to add the toolbar to. * @param {bool} [show] Whether to show the toolbar instantly after creation. */ $.oToolbar = function( name, widgets, parent, show ){ if (typeof parent === 'undefined') var parent = $.app.mainWindow; if (typeof widgets === 'undefined') var widgets = []; if (typeof show === 'undefined') var show = true; this.name = name; this._widgets = widgets; this._parent = parent; if (show) this.show(); } /** * Shows the oToolbar. * @name $.oToolbar#show */ $.oToolbar.prototype.show = function(){ if (this.$.batchMode) { this.$.debug("$.oToolbar not supported in batch mode", this.$.DEBUG_LEVEL.ERROR) return; } var _parent = this._parent; var _toolbar = new QToolbar(); _toolbar.objectName = this.name; for (var i in this.widgets){ _toolbar.addWidget(this.widgets[i]); } _parent.addToolbar(_toolbar); this.toolbar = _toolbar; return this.toolbar; } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_attribute.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library v0.01 // // // Developed by Mathieu Chaptel, Chris Fourney... // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the MIT license. // https://opensource.org/licenses/mit // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oAttribute class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oAttribute class. * @classdesc * The $.oAttribute class holds the smart version of the parameter you can find in layer property.
* It is used internally to get and set values and link a oColumn to a parameter in order to animate it. (Users should never have to instantiate this class)
* For a list of attributes existing in each node type and their type, as well as examples of the values they can hold, refer to :
* {@link NodeType}. * @constructor * @param {$.oNode} oNodeObject The oNodeObject that the attribute is associated to. * @param {attr} attributeObject The internal harmony Attribute Object. * @param {$.oAttribute} parentAttribute The parent attribute of the subattribute. * * @property {$.oNode} node The oNode this attribute belongs to. * @property {attr} attributeObject The internal harmony Attribute Object. * @property {string} keyword The keyword describing this attribute. (always in lower case) * @property {string} shortKeyword The full keyword describing this attribute, including parent attributes separated with a "." (always in lower case) * @property {$.oAttribute} parentAttribute The parent oAttribute object * @property {$.oAttribute[]} subAttributes The subattributes of this attribute. * @example * // oAttribute objects can be grabbed from the node .attributes object with dot notation, by calling the attribute keyword in lowercase. * * var myNode = $.scn.getSelectedNodes()[0]; // grab the first selected node * var Xattribute = myNode.attributes.position.x; // gets the position.x attribute of the node if it has it (for example, PEG nodes have it) * * var Xcolumn = Xattribute.column; // retrieve the linked column to the element (The object that holds the animation) * * Xattribute.setValue(5, 5); // sets the value to 5 at frame 5 * * // attribute values can also be set directly on the node when not animated: * myNode.position.x = 5; * */ $.oAttribute = function( oNodeObject, attributeObject, parentAttribute ){ this._type = "attribute"; this.node = oNodeObject; this.attributeObject = attributeObject; this._shortKeyword = attributeObject.keyword(); if( attributeObject.fullKeyword ){ this._keyword = attributeObject.fullKeyword(); }else{ this._keyword = (parentAttribute?(parentAttribute._keyword+"."):"") + this._shortKeyword; } this.parentAttribute = parentAttribute; // only for subAttributes // recursively add all subattributes as properties on the object this.createSubAttributes(attributeObject); } /** * Private function to create subAttributes in an oAttribute object at initialisation. * @private * @return {void} Nothing returned. */ $.oAttribute.prototype.createSubAttributes = function (attributeObject){ var _subAttributes = []; // if harmony version supports getSubAttributes var _subAttributesList = []; if (attributeObject.getSubAttributes){ _subAttributesList = attributeObject.getSubAttributes(); }else{ var sub_attrs = node.getAttrList( this.node.path, 1, this._keyword ); if( sub_attrs && sub_attrs.length>0 ){ _subAttributesList = sub_attrs; } } for (var i in _subAttributesList){ var _subAttribute = new this.$.oAttribute( this.node, _subAttributesList[i], this ); var _keyword = _subAttribute.shortKeyword; // creating a property on the attribute object with the subattribute name to access it this[_keyword] = _subAttribute; _subAttributes.push(_subAttribute) } // subAttributes is made available as an array for more formal access this.subAttributes = _subAttributes; } /** * Private function to add utility to subattributes on older versions of Harmony. * @private * @deprecated * @return {void} Nothing returned. */ $.oAttribute.prototype.getSubAttributes_oldVersion = function (){ var sub_attrs = []; switch( this.type ){ case "POSITION_3D" : //hard coded subAttr handler for POSITION_3D in older versions of Harmony. sub_attrs = [ 'SEPARATE', 'X', 'Y', 'Z']; break case "ROTATION_3D" : sub_attrs = [ 'SEPARATE', 'ANGLEX', 'ANGLEY', 'ANGLEZ', "QUATERNIONPATH" ]; break case "SCALE_3D" : sub_attrs = [ 'SEPARATE', 'IN_FIELDS', 'XY', 'X', 'Y', 'Z' ]; break case "DRAWING" : sub_attrs = [ 'ELEMENT', 'ELEMENT_MODE', 'CUSTOM_NAME']; break case "ELEMENT" : sub_attrs = [ 'LAYER' ] break case "CUSTOM_NAME" : sub_attrs = [ 'NAME', 'TIMING', 'EXTENSION', 'FIELD_CHART' ] default: break } var _node = this.node.path; var _keyword = this._keyword; sub_attrs = sub_attrs.map(function(x){return node.getAttr( _node, 1, _keyword+"."+x )}) return sub_attrs; } /** * The display name of the attribute * @name $.oAttribute#name * @type {string} */ Object.defineProperty($.oAttribute.prototype, 'name', { get: function(){ return this.attributeObject.name(); } }) /** * The full keyword of the attribute. * @name $.oAttribute#keyword * @type {string} */ Object.defineProperty($.oAttribute.prototype, 'keyword', { get : function(){ // formatting the keyword for our purposes // hard coding a fix for 3DPath attribute name which starts with a number var _keyword = this._keyword.toLowerCase(); if (_keyword == "3dpath") _keyword = "path3d"; return _keyword; } }); /** * The part of the attribute's keyword that is after the "." for subAttributes. * @name $.oAttribute#shortKeyword * @type {string} */ Object.defineProperty($.oAttribute.prototype, 'shortKeyword', { get : function(){ // formatting the keyword for our purposes // hard coding a fix for 3DPath attribute name which starts with a number var _keyword = this._shortKeyword.toLowerCase(); if (_keyword == "3dpath") _keyword = "path3d"; return _keyword; } }); /** * The type of the attribute. * @name $.oAttribute#type * @type {string} */ Object.defineProperty($.oAttribute.prototype, 'type', { get : function(){ return this.attributeObject.typeName(); } }); /** * The column attached to the attribute. * @name $.oAttribute#column * @type {$.oColumn} * @example // link a new column to an attribute by setting this value: var myColumn = $.scn.addColumn("BEZIER"); myNode.attributes.position.x.column = myColumn; // values contained in "myColumn" now define the animation of our peg's x position // to automatically create a column and link it to the attribute, use: myNode.attributes.position.x.addColumn(); // if the column exist already, it will just be returned. // to unlink a column, just set it to null/undefined: myNode.attributes.position.x.column = null; // values are no longer animated. */ Object.defineProperty($.oAttribute.prototype, 'column', { get : function(){ var _column = node.linkedColumn ( this.node.path, this._keyword ); if( _column && _column.length ){ return this.node.scene.$column( _column, this ); }else{ return null; } }, set : function(columnObject){ // unlink if provided with null value or empty string if (!columnObject){ node.unlinkAttr(this.node.path, this._keyword); }else{ node.linkAttr(this.node.path, this._keyword, columnObject.uniqueName); columnObject.attributeObject = this; // TODO: transfer current value of attribute to a first key on the column if column is empty } } }); /** * The frames array holding the values of the animation. Starts at 1, as array indexes correspond to frame numbers. * @name $.oAttribute#frames * @type {$.oFrame[]} */ Object.defineProperty($.oAttribute.prototype, 'frames', { get : function(){ var _column = this.column if (_column != null){ return _column.frames; }else{ //Need a method to get frames of non-column values. Local Values. return [ new this.$.oFrame( 1, this, false ) ]; } }, set : function(){ throw "Not implemented." } }); /** * An array of only the keyframes (frames with a set value) of the animation. * @name $.oAttribute#keyframes * @type {$.oFrame[]} */ // MCNote: I would prefer if this could remain getKeyFrames() Object.defineProperty($.oAttribute.prototype, 'keyframes', { get : function(){ var col = this.column; var frames = this.frames; if( !col ){ return frames[1]; } return this.column.keyframes; }, set : function(){ throw "Not implemented." } }); /** * WIP. * @name $.oAttribute#useSeparate * @type {bool} * @private */ //CF Note: Not sure if this should be a general attribute, or a subattribute. Object.defineProperty($.oAttribute.prototype, "useSeparate", { get : function(){ // TODO throw new Error("not yet implemented"); }, set : function( _value ){ // TODO: when swapping from one to the other, copy key values and link new columns if missing throw new Error("not yet implemented"); } }); /** * Returns the default value of the attribute for most keywords * @name $.oAttribute#defaultValue * @type {bool} * @todo switch the implementation to types? * @example * // to reset an attribute to its default value: * // (mostly used for position/angle/skew parameters of pegs and drawing nodes) * var myAttribute = $.scn.nodes[0].attributes.position.x; * * myAttribute.setValue(myAttribute.defaultValue); */ Object.defineProperty($.oAttribute.prototype, "defaultValue", { get : function(){ // TODO: we could use this to reset bones/deformers to their rest states var _keyword = this._keyword; switch (_keyword){ case "OFFSET.X" : case "OFFSET.Y" : case "OFFSET.Z" : case "POSITION.X" : case "POSITION.Y" : case "POSITION.Z" : case "PIVOT.X": case "PIVOT.Y": case "PIVOT.Z": case "ROTATION.ANGLEX": case "ROTATION.ANGLEY": case "ROTATION.ANGLEZ": case "ANGLE": case "SKEW": case "SPLINE_OFFSET.X": case "SPLINE_OFFSET.Y": case "SPLINE_OFFSET.Z": return 0; case "SCALE.X" : case "SCALE.Y" : case "SCALE.Z" : return 1; case "OPACITY" : return 100; case "COLOR" : return new this.$.oColorValue(); case "OFFSET.3DPATH": // pseudo oPathPoint // CFNote: is this supposed to be an object? // this is a fake object value that can be easily checked with a "==" operator. // oPathPoint will be converted to string for checking, and have the same format. // I made this to check if the value is default but I guess it's not ideal for assigning a default value, so maybe we should change it. return "{x:0, y:0, z:0}"; default: return null; // for attributes that don't have a default value, we return null } } }); // $.oAttribute Class methods /** * Provides the keyframes of the attribute. * @return {$.oFrame[]} The filtered keyframes. */ $.oAttribute.prototype.getKeyframes = function(){ var _frames = this.frames; _frames = _frames.filter(function(x){return x.isKeyframe}); return _frames; } /** * Provides the keyframes of the attribute. * @return {$.oFrame[]} The filtered keyframes. * @deprecated For case consistency, keyframe will never have a capital F */ $.oAttribute.prototype.getKeyFrames = function(){ this.$.debug("oAttribute.getKeyFrames is deprecated. Use oAttribute.getKeyframes instead.", this.$.DEBUG_LEVEL.ERROR); var _frames = this.frames; _frames = _frames.filter(function(x){return x.isKeyframe}); return _frames; } /** * Recursively get all the columns linked to the attribute and its subattributes * @return {$.oColumn[]} the list of columns linked to the subattributes */ $.oAttribute.prototype.getLinkedColumns = function(){ var _columns = []; var _subAttributes = this.subAttributes; var _ownColumn = this.column; if (_ownColumn != null) _columns.push(_ownColumn); for (var i=0; i<_subAttributes.length; i++) { _columns = _columns.concat(_subAttributes[i].getLinkedColumns()); } return _columns; } /** * Recursively sets an attribute to the same value as another. Both must have the same keyword. * @param {bool} [duplicateColumns=false] In the case that the attribute has a column, whether to duplicate the column before linking * @private */ $.oAttribute.prototype.setToAttributeValue = function(attributeToCopy, duplicateColumns){ if (typeof duplicateColumns === 'undefined') var duplicateColumns = false; if (this.keyword !== attributeToCopy.keyword) return; var _subAttributes = this.subAttributes; var _column = attributeToCopy.column; if (_column == null) { var value = attributeToCopy.getValue(); this.setValue(value); }else{ if (duplicateColumns) var _column = _column.duplicate(this); this.column = _column; } var _subAttributesToCopy = attributeToCopy.subAttributes; for (var i=0; i<_subAttributes.length; i++){ _subAttributes[i].setToAttributeValue(_subAttributesToCopy[i], duplicateColumns); } } //CFNote: Is it worth having a getValueType? /** * Gets the value of the attribute at the given frame. * @param {int} frame The frame at which to set the value, if not set, assumes 1 * * @return {object} The value of the attribute in the native format of that attribute (contextual to the attribute). */ $.oAttribute.prototype.getValue = function (frame) { if (typeof frame === 'undefined') var frame = 1; this.$.debug('getting value of frame :'+frame+' of attribute: '+this._keyword+' of node '+this.node+' - type '+this.type, this.$.DEBUG_LEVEL.LOG) var _attr = this.attributeObject; var _type = this.type; var _value; var _column = this.column; // handling conversion of all return types into our own types switch (_type){ case 'BOOL': _value = _attr.boolValueAt(frame) break; case 'INT': _value = _attr.intValueAt(frame) break; case 'DOUBLE': case 'DOUBLEVB': _value = _attr.doubleValueAt(frame) break; case 'STRING': _value = _attr.textValueAt(frame) break; case 'COLOR': _value = new this.$.oColorValue(_attr.colorValueAt(frame)) break; case 'POSITION_2D': _value = _attr.pos2dValueAt(frame) _value = new this.$.oPoint(_value.x, _value.y) break; case 'POSITION_3D': _value = _attr.pos3dValueAt(frame) _value = new this.$.oPoint(_value.x, _value.y, _value.z) break; case 'SCALE_3D': _value = _attr.pos3dValueAt(frame) _value = new this.$.oPoint(_value.x, _value.y, _value.z) break; case 'PATH_3D': _attr = this.parentAttribute.attributeObject; var _frame = _column?(new this.$.oFrame(frame, _column)):(new this.$.oFrame(frame, _attr)); if(_column && _frame.isKeyframe){ _value = new this.$.oPathPoint(_column, _frame); }else{ _value = _attr.pos3dValueAt(frame); } break; /*case 'DRAWING': // override with returning an oElement object this.$.debug( "DRAWING: " + this.keyword , this.$.DEBUG_LEVEL.LOG); value = _column.element; break;*/ case 'ELEMENT': // an element always has a column, so we'll fetch it from there _value = column.getEntry(_column.uniqueName, 1, frame); // Convert to an instance of oDrawing, with a safety in case of psd import _drawing = _column.element.getDrawingByName(_value); if (_drawing) _value = _drawing; break; // TODO: How does QUATERNION_PATH work? subcolumns I imagine // TODO: How to get types SCALE_3D, ROTATION_3D, DRAWING, GENERIC_ENUM? -> maybe we don't need to, they don't have intrinsic values default: // enums, etc _value = _attr.textValueAt(frame); // in case of subattributes, create a fake string that can have properties so we can create getter setters on it for its subattrs if ( _attr.hasSubAttributes && _attr.hasSubAttributes() ){ _value = { value:_value }; _value.toString = function(){ return this.value }; }else{ var sub_attrs = node.getAttrList( this.node.path, 1, this._keyword ); if( sub_attrs && sub_attrs.length>0 ){ _value = { value:_value }; _value.toString = function(){ return this.value }; } } } return _value; } /** * Sets the value of the attribute at the given frame. * @param {string} value The value to set on the attribute. * @param {int} [frame=1] The frame at which to set the value, if not set, assumes 1 */ $.oAttribute.prototype.setValue = function (value, frame) { var _attr = this.attributeObject; var _column = this.column; var _type = this.type; var _animate = false; if (!frame){ // we don't animate var frame = 1; }else if (!_column){ // generate a new column to be able to animate _column = this.addColumn(); } if( _column ){ _animate = true; } try{ this.$.debug("setting attr "+this._keyword+" (type : "+this.type+") on node "+this.node+" to value "+JSON.stringify(value)+" at frame "+frame, this.$.DEBUG_LEVEL.LOG) }catch(err){ this.$.debug("setting attr "+this._keyword+" at frame "+frame, this.$.DEBUG_LEVEL.LOG) }; switch(_type){ // TODO: sanitize input case "COLOR" : // doesn't work for burnin because it has color.Red, color.green etc and not .r .g ... value = (value instanceof this.$.oColorValue)?value: new this.$.oColorValue(value); value = ColorRGBA(value.r, value.g, value.b, value.a); _animate ? _attr.setValueAt(value, frame) : _attr.setValue(value); break; case "GENERIC_ENUM" : node.setTextAttr(this.node.path, this._keyword, frame, value); break; case "PATH_3D" : // check if frame is tied to a column or an attribute var _frame = _column?(new this.$.oFrame(frame, this.column)):(new this.$.oFrame(frame, _attr)); if (_column){ if (!_frame.isKeyframe) _frame.isKeyframe = true; var _point = new this.$.oPathPoint (this.column, _frame); _point.set(value); }else{ // TODO: create keyframe? this.parentAttribute.setValue(value); } break; case "POSITION_2D": value = Point2d(value.x, value.y); _animate ? _attr.setValueAt(value, frame) : _attr.setValue(value); break; case "POSITION_3D": value = Point3d(value.x, value.y, value.z); _animate ? _attr.setValueAt(value, frame) : _attr.setValue(value); break; case "ELEMENT" : _column = this.column; value = (value instanceof this.$.oDrawing) ? value.name : value; column.setEntry(_column.uniqueName, 1, frame, value+""); break; case "QUATERNIONPATH" : // set quaternion paths as textattr until a better way is found default : try{ _animate ? _attr.setValueAt( value, frame ) : _attr.setValue( value ); }catch(err){ this.$.debug("error setting attr "+this._keyword+" value "+value+": "+err, this.$.DEBUG_LEVEL.DEBUG); this.$.debug("setting text attr "+this._keyword+" value "+value+" as textAttr ", this.$.DEBUG_LEVEL.ERROR); node.setTextAttr( this.node.path, this._keyword, frame, value ); } } } /** * Adds a column with a default name, based on the attribute type. * If a column already exists, it returns it. * @returns {$.oColumn} the created column */ $.oAttribute.prototype.addColumn = function(){ var _column = this.column; if (_column) return _column; if (this.hasSubAttributes){ throw new Error("Can't create columns for attribute "+this.keyword+", column must be created for its subattributes."); } var _type = this.type; var _columnType = ""; var _columnName = this.node.name+": "+this.name.replace(/\s/g, "_"); switch(_type){ case 'INT': case 'DOUBLE': case 'DOUBLEVB': _columnType = "BEZIER"; break; case "QUATERNIONPATH" : _columnName = "QUARTERNION"; break; case "PATH_3D" : _columnName = "3DPATH"; break; case "ELEMENT" : _columnType = "DRAWING"; _columnName = this.node.name; break; default : throw new Error("Can't create columns for attribute "+this.keyword+", not supported by attribute type '"+_type+"'"); } var _column = this.$.scn.addColumn(_columnType, _columnName); this.column = _column; if (!this.column) { _column.remove(); throw new Error("Can't create columns for attribute "+this.keyword+", animation not supported."); } return this.column; } /** * Gets the value of the attribute at the given frame. * @param {int} frame The frame at which to set the value, if not set, assumes 1 * @deprecated use oAttribute.getValue(frame) instead (see: function names as verbs) * @return {object} The value of the attribute in the native format of that attribute (contextual to the attribute). */ $.oAttribute.prototype.value = function(frame){ return this.getValue( frame ); } /** * Represents an oAttribute object in string form * @private * @returns {string} */ $.oAttribute.prototype.toString = function(){ return "[object $.oAttribute '"+this.keyword+(this.subAttributes.length?"' subAttributes: "+this.subAttributes.map(function(x){return x.shortKeyword}):"")+"]"; } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_backdrop.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library // // // Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the Mozilla Public license 2.0. // https://www.mozilla.org/en-US/MPL/2.0/ // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oBackdrop class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oBackdrop class. * @constructor * @classdesc The $.oBackdrop Class represents a backdrop in the node view, and allows users to add, remove and modify existing Backdrops. Accessing these functions is done through the oGroupNode class. * @param {string} groupPath The path to the object in which this backdrop is placed. * @param {backdropObject} backdropObject The harmony-internal backdrop object associated with this oBackdrop. * @example * function createColoredBackdrop(){ * // This script will prompt for a color and create a backdrop around the selection * $.beginUndo() * * var doc = $.scn; // grab the scene * var nodes = doc.selectedNodes; // grab the selection * * if(!nodes) return // exit the function if no nodes are selected * * var color = pickColor(); // prompt for color * * var group = doc.root // get the group to add the backdrop to * var backdrop = group.addBackdropToNodes(nodes, "BackDrop", "", color) * * $.endUndo(); * * // function to get the color chosen by the user * function pickColor(){ * var d = new QColorDialog; * d.exec(); * var color = d.selectedColor(); * return new $.oColorValue({r:color.red(), g:color.green(), b:color.blue(), a:color.alpha()}) * } * } */ $.oBackdrop = function( groupPath, backdropObject ){ this.group = ( groupPath instanceof this.$.oGroupNode )? groupPath.path: groupPath; this.backdropObject = backdropObject; } /** * The index of this backdrop in the current group. * @name $.oBackdrop#index * @type {int} */ Object.defineProperty($.oBackdrop.prototype, 'index', { get : function(){ var _groupBackdrops = Backdrop.backdrops(this.group).map(function(x){return x.title.text}) return _groupBackdrops.indexOf(this.title) } }) /** * The title of the backdrop. * @name $.oBackdrop#title * @type {string} */ Object.defineProperty($.oBackdrop.prototype, 'title', { get : function(){ var _title = this.backdropObject.title.text; return _title; }, set : function(newTitle){ var _backdrops = Backdrop.backdrops(this.group); // incrementing to prevent two backdrops to have the same title var names = _backdrops.map(function(x){return x.title.text}) var count = 0; var title = newTitle while (names.indexOf(title) != -1){ count++; title = newTitle+"_"+count; } newTitle = title; var _index = this.index; _backdrops[_index].title.text = newTitle; this.backdropObject = _backdrops[_index]; Backdrop.setBackdrops(this.group, _backdrops); } }) /** * The body text of the backdrop. * @name $.oBackdrop#body * @type {string} */ Object.defineProperty($.oBackdrop.prototype, 'body', { get : function(){ var _title = this.backdropObject.description.text; return _title; }, set : function(newBody){ var _backdrops = Backdrop.backdrops(this.group); var _index = this.index; _backdrops[_index].description.text = newBody; this.backdropObject = _backdrops[_index]; Backdrop.setBackdrops(this.group, _backdrops); } }) /** * The title font of the backdrop in form { family:"familyName", "size":int, "color": oColorValue } * @name $.oBackdrop#titleFont * @type {object} */ Object.defineProperty($.oBackdrop.prototype, 'titleFont', { get : function(){ var _font = {family : this.backdropObject.title.font, size : this.backdropObject.title.size, color : ( new oColorValue() ).parseColorFromInt(this.backdropObject.title.color)} return _font; }, set : function(newFont){ var _backdrops = Backdrop.backdrops(this.group); var _index = this.index; _backdrops[_index].title.font = newFont.family; _backdrops[_index].title.size = newFont.size; _backdrops[_index].title.color = newFont.color.toInt(); this.backdropObject = _backdrops[_index]; Backdrop.setBackdrops(this.group, _backdrops); } }) /** * The body font of the backdrop in form { family:"familyName", "size":int, "color": oColorValue } * @name $.oBackdrop#bodyFont * @type {object} */ Object.defineProperty($.oBackdrop.prototype, 'bodyFont', { get : function(){ var _font = {family : this.backdropObject.description.font, size : this.backdropObject.description.size, color : ( new oColorValue() ).parseColorFromInt(this.backdropObject.description.color)} return _font; }, set : function(newFont){ var _backdrops = Backdrop.backdrops(this.group); var _index = this.index; _backdrops[_index].title.font = newFont.family; _backdrops[_index].title.size = newFont.size; _backdrops[_index].title.color = newFont.color.toInt(); this.backdropObject = _backdrops[_index]; Backdrop.setBackdrops(this.group, _backdrops); } }) /** * The nodes contained within this backdrop * @name $.oBackdrop#parent * @type {$.oNode[]} * @readonly */ Object.defineProperty($.oBackdrop.prototype, 'parent', { get : function(){ if (!this.hasOwnProperty("_parent")){ this._parent = this.$.scn.getNodeByPath(this.group); } return this._parent } }) /** * The nodes contained within this backdrop * @name $.oBackdrop#nodes * @type {$.oNode[]} * @readonly */ Object.defineProperty($.oBackdrop.prototype, 'nodes', { get : function(){ var _nodes = this.parent.nodes; var _bounds = this.bounds; _nodes = _nodes.filter(function(x){ return _bounds.contains(x.bounds); }) return _nodes; } }) /** * The position of the backdrop on the horizontal axis. * @name $.oBackdrop#x * @type {float} */ Object.defineProperty($.oBackdrop.prototype, 'x', { get : function(){ var _x = this.backdropObject.position.x; return _x; }, set : function(newX){ var _backdrops = Backdrop.backdrops(this.group); var _index = this.index; _backdrops[_index].position.x = newX; this.backdropObject = _backdrops[_index]; Backdrop.setBackdrops(this.group, _backdrops); } }) /** * The position of the backdrop on the vertical axis. * @name $.oBackdrop#y * @type {float} */ Object.defineProperty($.oBackdrop.prototype, 'y', { get : function(){ var _y = this.backdropObject.position.y; return _y; }, set : function(newY){ var _backdrops = Backdrop.backdrops(this.group); var _index = this.index; _backdrops[_index].position.y = newY; this.backdropObject = _backdrops[_index]; Backdrop.setBackdrops(this.group, _backdrops); } }) /** * The width of the backdrop. * @name $.oBackdrop#width * @type {float} */ Object.defineProperty($.oBackdrop.prototype, 'width', { get : function(){ var _width = this.backdropObject.position.w; return _width; }, set : function(newWidth){ var _backdrops = Backdrop.backdrops(this.group); var _index = this.index; _backdrops[_index].position.w = newWidth; this.backdropObject = _backdrops[_index]; Backdrop.setBackdrops(this.group, _backdrops); } }) /** * The height of the backdrop. * @name $.oBackdrop#height * @memberof $.oBackdrop# * @type {float} */ Object.defineProperty($.oBackdrop.prototype, 'height', { get : function(){ var _height = this.backdropObject.position.h; return _height; }, set : function(newHeight){ var _backdrops = Backdrop.backdrops(this.group); var _index = this.index; _backdrops[_index].position.h = newHeight; this.backdropObject = _backdrops[_index]; Backdrop.setBackdrops(this.group, _backdrops); } }) /** * The position of the backdrop. * @name $.oBackdrop#position * @type {oPoint} */ Object.defineProperty($.oBackdrop.prototype, 'position', { get : function(){ var _position = new oPoint(this.x, this.y, this.index) return _position; }, set : function(newPos){ var _backdrops = Backdrop.backdrops(this.group); var _index = this.index; _backdrops[_index].position.x = newPos.x; _backdrops[_index].position.y = newPos.y; this.backdropObject = _backdrops[_index]; Backdrop.setBackdrops(this.group, _backdrops); } }) /** * The bounds of the backdrop. * @name $.oBackdrop#bounds * @type {oBox} */ Object.defineProperty($.oBackdrop.prototype, 'bounds', { get : function(){ var _box = new oBox(this.x, this.y, this.width+this.x, this.height+this.y) return _box; }, set : function(newBounds){ var _backdrops = Backdrop.backdrops(this.group); var _index = this.index; _backdrops[_index].position.x = newBounds.top; _backdrops[_index].position.y = newBounds.left; _backdrops[_index].position.w = newBounds.width; _backdrops[_index].position.h = newBounds.height; this.backdropObject = _backdrops[_index]; Backdrop.setBackdrops(this.group, _backdrops); } }) /** * The color of the backdrop. * @name $.oBackdrop#color * @type {oColorValue} */ Object.defineProperty($.oBackdrop.prototype, 'color', { get : function(){ var _color = this.backdropObject.color; // TODO: get the rgba values from the int return _color; }, set : function(newOColorValue){ var _color = new oColorValue(newOColorValue); var _index = this.index; var _backdrops = Backdrop.backdrops(this.group); _backdrops[_index].color = _color.toInt(); this.backdropObject = _backdrops[_index]; Backdrop.setBackdrops(this.group, _backdrops); } }) ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_color.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library // // // Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the Mozilla Public license 2.0. // https://www.mozilla.org/en-US/MPL/2.0/ // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oColorValue class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * This class holds a color value. It can be used to set color attributes to a specific value and to convert colors between different formats such as hex strings, RGBA decompositions, as well as HSL values. * @constructor * @classdesc Constructor for the $.oColorValue Class. * @param {string/object} colorValue Hex string value, or object in form {rgba} * * @property {int} r The int value of the red component. * @property {int} g The int value of the green component. * @property {int} b The int value of the blue component. * @property {int} a The int value of the alpha component. * @example * // initialise the class to start setting up attributes and making conversions by creating a new instance * * var myColor = new $.oColorValue("#336600ff"); * $.log(myColor.r+" "+mycolor.g+" "+myColor.b+" "+myColor+a) // you can then access each component of the color * * var myBackdrop = $.scn.root.addBackdrop("Backdrop") * var myBackdrop.color = myColor // can be used to set the color of a backdrop * */ $.oColorValue = function( colorValue ){ if (typeof colorValue === 'undefined') var colorValue = "#000000ff"; this.r = 0; this.g = 0; this.b = 0; this.a = 255; //Special case in which RGBA values are defined directly. switch( arguments.length ){ case 4: this.a = ( (typeof arguments[3]) == "number" ) ? arguments[3] : 0; case 3: this.r = ( (typeof arguments[0]) == "number" ) ? arguments[0] : 0; this.g = ( (typeof arguments[1]) == "number" ) ? arguments[1] : 0; this.b = ( (typeof arguments[2]) == "number" ) ? arguments[2] : 0; return; default: } if (typeof colorValue === 'string'){ this.fromColorString(colorValue); }else{ if (typeof colorValue.r === 'undefined') colorValue.r = 0; if (typeof colorValue.g === 'undefined') colorValue.g = 0; if (typeof colorValue.b === 'undefined') colorValue.b = 0; if (typeof colorValue.a === 'undefined') colorValue.a = 255; this.r = colorValue.r; this.g = colorValue.g; this.b = colorValue.b; this.a = colorValue.a; } } /** * Creates an int from the color value, as used for backdrop colors. * @return: {string} ALPHA<<24 RED<<16 GREEN<<8 BLUE */ $.oColorValue.prototype.toInt = function (){ return ((this.a & 0xff) << 24) | ((this.r & 0xff) << 16) | ((this.g & 0xff) << 8) | (this.b & 0xff); } /** * The colour value represented as a string. * @return: {string} RGBA components in a string in format #RRGGBBAA */ $.oColorValue.prototype.toString = function (){ var _hex = "#"; var r = ("00"+this.r.toString(16)).slice(-2); var g = ("00"+this.g.toString(16)).slice(-2); var b = ("00"+this.b.toString(16)).slice(-2); var a = ("00"+this.a.toString(16)).slice(-2); _hex += r + g + b + a; return _hex; } /** * The colour value represented as a string. * @return: {string} RGBA components in a string in format #RRGGBBAA */ $.oColorValue.prototype.toHex = function (){ return this.toString(); } /** * Ingest a hex string in form #RRGGBBAA to define the colour. * @param {string} hexString The colour in form #RRGGBBAA */ $.oColorValue.prototype.fromColorString = function (hexString){ hexString = hexString.replace("#",""); if (hexString.length == 6) hexString += "ff"; if (hexString.length != 8) throw new Error("incorrect color string format"); this.$.debug( "HEX : " + hexString, this.$.DEBUG_LEVEL.LOG); this.r = parseInt(hexString.slice(0,2), 16); this.g = parseInt(hexString.slice(2,4), 16); this.b = parseInt(hexString.slice(4,6), 16); this.a = parseInt(hexString.slice(6,8), 16); } /** * Uses a color integer (used in backdrops) and parses the INT; applies the RGBA components of the INT to the oColorValue * @param { int } colorInt 24 bit-shifted integer containing RGBA values */ $.oColorValue.prototype.parseColorFromInt = function(colorInt){ this.r = colorInt >> 16 & 0xFF; this.g = colorInt >> 8 & 0xFF; this.b = colorInt & 0xFF; this.a = colorInt >> 24 & 0xFF; } /** * Gets the color's HUE value. * @name $.oColorValue#h * @type {float} */ Object.defineProperty($.oColorValue.prototype, 'h', { get : function(){ var r = this.r; var g = this.g; var b = this.b; var cmin = Math.min(r,g,b); var cmax = Math.max(r,g,b); var delta = cmax - cmin; var h = 0; var s = 0; var l = 0; if (delta == 0){ h = 0.0; // Red is max }else if (cmax == r){ h = ((g - b) / delta) % 6.0; // Green is max }else if (cmax == g){ h = (b - r) / delta + 2.0; // Blue is max }else{ h = (r - g) / delta + 4.0; } h = Math.round(h * 60.0); //WRAP IN 360. if (h < 0){ h += 360.0; } // // Calculate lightness // l = (cmax + cmin) / 2.0; // // Calculate saturation // s = delta == 0 ? 0 : delta / (1.0 - Math.abs(2.0 * l - 1.0)); // s = Math.min( Math.abs(s)*100.0, 100.0 ); // l = (Math.abs(l)/255.0)*100.0; return h; }, set : function( new_h ){ var h = Math.min( new_h, 360.0 ); var s = Math.min( this.s, 100.0 )/100.0; var l = Math.min( this.l, 100.0 )/100.0; var c = (1.0 - Math.abs(2.0 * l - 1.0)) * s; var x = c * (1 - Math.abs((h / 60.0) % 2.0 - 1.0)); var m = l - c/2.0; var r = 0.0; var g = 0.0; var b = 0.0; if (0.0 <= h && h < 60.0) { r = c; g = x; b = 0; } else if (60.0 <= h && h < 120.0) { r = x; g = c; b = 0; } else if (120.0 <= h && h < 180.0) { r = 0; g = c; b = x; } else if (180.0 <= h && h < 240.0) { r = 0; g = x; b = c; } else if (240.0 <= h && h < 300.0) { r = x; g = 0; b = c; } else if (300.0 <= h && h < 360.0) { r = c; g = 0; b = x; } this.r = (r + m) * 255.0; this.g = (g + m) * 255.0; this.b = (b + m) * 255.0; } }); /** * Gets the color's SATURATION value. * @name $.oColorValue#s * @type {float} */ Object.defineProperty($.oColorValue.prototype, 's', { get : function(){ var r = this.r; var g = this.g; var b = this.b; var cmin = Math.min(r,g,b); var cmax = Math.max(r,g,b); var delta = cmax - cmin; var s = 0; var l = 0; // Calculate lightness l = (cmax + cmin) / 2.0; s = delta == 0 ? 0 : delta / (1.0 - Math.abs(2.0 * l - 1.0)); // Calculate saturation s = Math.min( Math.abs(s)*100.0, 100.0 ); return s; }, set : function( new_s ){ var h = Math.min( this.h, 360.0 ); var s = Math.min( new_s, 100.0 )/100.0; var l = Math.min( this.l, 100.0 )/100.0; var c = (1.0 - Math.abs(2.0 * l - 1.0)) * s; var x = c * (1 - Math.abs((h / 60.0) % 2.0 - 1.0)); var m = l - c/2.0; var r = 0.0; var g = 0.0; var b = 0.0; if (0.0 <= h && h < 60.0) { r = c; g = x; b = 0; } else if (60.0 <= h && h < 120.0) { r = x; g = c; b = 0; } else if (120.0 <= h && h < 180.0) { r = 0; g = c; b = x; } else if (180.0 <= h && h < 240.0) { r = 0; g = x; b = c; } else if (240.0 <= h && h < 300.0) { r = x; g = 0; b = c; } else if (300.0 <= h && h < 360.0) { r = c; g = 0; b = x; } this.r = (r + m) * 255.0; this.g = (g + m) * 255.0; this.b = (b + m) * 255.0; } }); /** * Gets the color's LIGHTNESS value. * @name $.oColorValue#l * @type {float} */ Object.defineProperty($.oColorValue.prototype, 'l', { get : function(){ var r = this.r; var g = this.g; var b = this.b; var cmin = Math.min(r,g,b); var cmax = Math.max(r,g,b); var delta = cmax - cmin; var s = 0; var l = 0; // Calculate lightness l = (cmax + cmin) / 2.0; l = (Math.abs(l)/255.0)*100.0; return l; }, set : function( new_l ){ var h = Math.min( this.h, 360.0 ); var s = Math.min( this.s, 100.0 )/100.0; var l = Math.min( new_l, 100.0 )/100.0; var c = (1.0 - Math.abs(2.0 * l - 1.0)) * s; var x = c * (1 - Math.abs((h / 60.0) % 2.0 - 1.0)); var m = l - c/2.0; var r = 0.0; var g = 0.0; var b = 0.0; if (0.0 <= h && h < 60.0) { r = c; g = x; b = 0; } else if (60.0 <= h && h < 120.0) { r = x; g = c; b = 0; } else if (120.0 <= h && h < 180.0) { r = 0; g = c; b = x; } else if (180.0 <= h && h < 240.0) { r = 0; g = x; b = c; } else if (240.0 <= h && h < 300.0) { r = x; g = 0; b = c; } else if (300.0 <= h && h < 360.0) { r = c; g = 0; b = x; } this.r = (r + m) * 255.0; this.g = (g + m) * 255.0; this.b = (b + m) * 255.0; } }); ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oColor class // // // // // ////////////////////////////////////// ////////////////////////////////////// // oPalette constructor /** * The base class for the $.oColor. * @constructor * @classdesc $.oColor Base Class * @param {$.oPalette} oPaletteObject The palette to which the color belongs. * @param {int} attributeObject The index of the color in the palette. * * @property {$.oPalette} palette The palette to which the color belongs. */ $.oColor = function( oPaletteObject, index ){ // We don't use id in the constructor as multiple colors with the same id can exist in the same palette. this._type = "color"; this.palette = oPaletteObject; this._index = index; } // $.oColor Object Properties /** * The Harmony color object. * @name $.oColor#colorObject * @type {BaseColor} */ Object.defineProperty($.oColor.prototype, 'colorObject', { get : function(){ return this.palette.paletteObject.getColorByIndex(this._index); } }); /** * The name of the color. * @name $.oColor#name * @type {string} */ Object.defineProperty($.oColor.prototype, 'name', { get : function(){ var _color = this.colorObject; return _color.name; }, set : function(newName){ var _color = this.colorObject; _color.setName(newName); } }); /** * The id of the color. * @name $.oColor#id * @type {string} */ Object.defineProperty($.oColor.prototype, 'id', { get : function(){ var _color = this.colorObject; return _color.id }, set : function(newId){ // TODO: figure out a way to change id? Create a new color with specific id in the palette? throw new Error("setting oColor.id Not yet implemented"); } }); /** * The index of the color. * @name $.oColor#index * @type {int} */ Object.defineProperty($.oColor.prototype, 'index', { get : function(){ return this._index; }, set : function(newIndex){ var _color = this.palette.paletteObject.moveColor(this._index, newIndex); this._index = newIndex; } }); /** * The type of the color. * @name $.oColor#type * @type {int} */ Object.defineProperty($.oColor.prototype, 'type', { set : function(){ throw new Error("setting oColor.type Not yet implemented."); }, get : function(){ var _color = this.colorObject; if (_color.isTexture) return "texture"; switch (_color.colorType) { case PaletteObjectManager.Constants.ColorType.SOLID_COLOR: return "solid"; case PaletteObjectManager.Constants.ColorType.LINEAR_GRADIENT : return "gradient"; case PaletteObjectManager.Constants.ColorType.RADIAL_GRADIENT: return "radial gradient"; default: } } }); /** * Whether the color is selected. * @name $.oColor#selected * @type {bool} */ Object.defineProperty($.oColor.prototype, 'selected', { get : function(){ var _currentId = PaletteManager.getCurrentColorId() var _colors = this.palette.colors; var _ids = _colors.map(function(x){return x.id}) return this._index == _ids.indexOf(_currentId); }, set : function(isSelected){ // TODO: find a way to work with index as more than one color can have the same id, also, can there be no selected color when removing selection? if (isSelected){ var _id = this.id; PaletteManager.setCurrentColorById(_id); } } }); /** * Takes a string or array of strings for gradients and filename for textures. Instead of passing rgba objects, it accepts "#rrggbbaa" hex strings for convenience.
set gradients, provide an object with keys from 0 to 1 for the position of each color.
(ex: {0: new $.oColorValue("000000ff"), 1:new $.oColorValue("ffffffff")}). * @name $.oColor#value * @type {$.oColorValue} */ Object.defineProperty($.oColor.prototype, 'value', { get : function(){ var _color = this.colorObject; switch(this.type){ case "solid": return new this.$.oColorValue(_color.colorData); case "texture": return this.palette.path.parent.path + this.palette.name+"_textures/" + this.id + ".tga"; case "gradient": case "radial gradient": var _gradientArray = _color.colorData; var _value = {}; for (var i in _gradientArray){ var _data = _gradientArray[i]; _value[_gradientArray[i].t] = new this.$.oColorValue(_data.r, _data.g, _data.b, _data.a); } return _value; default: } }, set : function(newValue){ var _color = this.colorObject; switch(this.type){ case "solid": _value = new $.oColorValue(newValue); _color.setColorData(_value); break; case "texture": // TODO: need to copy the file into the folder first? _color.setTextureFile(newValue); break; case "gradient": case "radial gradient": var _value = []; var _gradient = newValue; for (var i in _gradient){ var _color = _gradient[i]; var _tack = {r:_color.r, g:_color.g, b:_color.b, a:_color.a, t:parseFloat(i, 10)} _value.push(_tack); } _color.setColorData(_value); break; default: }; } }); // Methods /** * Moves the palette to another Palette Object (CFNote: perhaps have it push to paletteObject, instead of being done at the color level) * @param {$.oPalette} oPaletteObject The paletteObject to move this color into. * @param {int} index Need clarification from mchap * * @return: {$.oColor} The new resulting $.oColor object. */ $.oColor.prototype.moveToPalette = function (oPaletteObject, index){ if (typeof index === 'undefined') var index = oPaletteObject.paletteObject.nColors; var _duplicate = this.copyToPalette(oPaletteObject, index) this.remove() return _duplicate; } /** * Copies the palette to another Palette Object (CFNote: perhaps have it push to paletteObject, instead of being done at the color level) * @param {$.oPalette} oPaletteObject The paletteObject to move this color into. * @param {int} index Need clarification from mchap * * @return: {$.oColor} The new resulting $.oColor object. */ $.oColor.prototype.copyToPalette = function (oPaletteObject, index){ var _color = this.colorObject; oPaletteObject.paletteObject.cloneColor(_color); var _colors = oPaletteObject.colors; var _duplicate = _colors.pop(); if (typeof index !== 'undefined') _duplicate.index = index; return _duplicate; } /** * Removes the color from the palette it belongs to. */ $.oColor.prototype.remove = function (){ // TODO: find a way to work with index as more than one color can have the same id this.palette.paletteObject.removeColor(this.id); } /** * Static helper function to convert from {r:int, g:int, b:int, a:int} to a hex string in format #FFFFFFFF
* Consider moving this to a helper function. * @param { obj } rgbaObject RGB object * @static * @return: { string } Hex color string in format #FFFFFFFF. */ $.oColor.prototype.rgbaToHex = function (rgbaObject){ var _hex = "#"; _hex += rvbObject.r.toString(16) _hex += rvbObject.g.toString(16) _hex += rvbObject.b.toString(16) _hex += rvbObject.a.toString(16) return _hex; } /** * Static helper function to convert from hex string in format #FFFFFFFF to {r:int, g:int, b:int, a:int}
* Consider moving this to a helper function. * @param { string } hexString RGB object * @static * @return: { obj } The hex object returned { r:int, g:int, b:int, a:int } */ $.oColor.prototype.hexToRgba = function (hexString){ var _rgba = {}; //Needs a better fail state. _rgba.r = parseInt(hexString.slice(1,3), 16) _rgba.g = parseInt(hexString.slice(3,5), 16) _rgba.b = parseInt(hexString.slice(5,7), 16) _rgba.a = parseInt(hexString.slice(7,9), 16) return _rgba; } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_column.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library // // // Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the Mozilla Public license 2.0. // https://www.mozilla.org/en-US/MPL/2.0/ // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oColumn class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oColumn class. * @classdesc Columns are the objects that hold all the animation information of an attribute. Any animated value in Harmony is so thanks to a column linked to the attribute representing the node parameter. Columns can be added from the scene class, or are directly created when giving a non 1 value when setting an attribute. * @constructor * @param {string} uniqueName The unique name of the column. * @param {$.oAttribute} oAttributeObject The oAttribute thats connected to the column. * * @property {string} uniqueName The unique name of the column. * @property {$.oAttribute} attributeObject The attribute object that the column is attached to. * @example * // You can get the entirety of the columns in the scene by calling: * var doc = $.scn; * var allColumns = doc.columns; * * // However, to get a specific column, you can retrieve it from its linked attribute: * * var myAttribute = doc.nodes[0].attributes.position.x * var mycolumn = myAttribute.column; * * // once you have the column, you can do things like remove duplicates keys to simplify an animation; * myColumn.removeDuplicateKeys(); * * // you can extract all the keys to be able to iterate over it: * var keyFrames = myColumn.getKeyFrames(); * * for (var i in keyFrames){ * $.log (keyFrames[i].frameNumber); * } * * // you can also link a given column to more than one attribute so they share the same animated values: * * doc.nodes[0].attributes.position.y.column = myColumn; // now position.x and position.y will share the same animation on the node. */ $.oColumn = function( uniqueName, oAttributeObject ){ var instance = this.$.getInstanceFromCache.call(this, uniqueName); if (instance) return instance; this._type = "column"; this.uniqueName = uniqueName; this.attributeObject = oAttributeObject; this._cacheFrames = []; //Helper cache for subsequent actions. try{ // fails when the column has no attribute if( !this.$.cache_columnToNodeAttribute ){ this.$.cache_columnToNodeAttribute = {}; } this.$.cache_columnToNodeAttribute[this.uniqueName] = { "node":oAttributeObject.node, "attribute": this.attributeObject, "date": (new Date()).getTime() }; }catch(err){} } // $.oColumn Object Properties /** * The name of the column. * @name $.oColumn#name * @type {string} */ Object.defineProperty( $.oColumn.prototype, 'name', { get : function(){ return column.getDisplayName(this.uniqueName); }, set : function(newName){ var _success = column.rename(this.uniqueName, newName) if (_success){ this.uniqueName = newName; }else{ throw new Error("Failed to rename column "+this.uniqueName+" to "+newName+".") } } }); /** * The type of the column. There are nine column types: drawing (DRAWING), sound (SOUND), 3D Path (3DPATH), Bezier Curve (BEZIER), Ease Curve (EASE), Expression (EXPR), Timing (TIMING) for timing columns, Quaternion path (QUATERNIONPATH) for 3D rotation and Annotation (ANNOTATION) for annotation columns. * @name $.oColumn#type * @readonly * @type {string} */ Object.defineProperty( $.oColumn.prototype, 'type', { get : function(){ return column.type(this.uniqueName) } }); /** * Whether the column is selected. * @name $.oColumn#selected * @type {bool} */ Object.defineProperty($.oColumn.prototype, 'selected', { get : function(){ var sel_num = selection.numberOfColumnsSelected(); for( var n=0;n allows to loop through subcolumns if they exist if (this.type == "3DPATH"){ return { x : 1, y : 2, z : 3, velocity : 4} } return { a : 1 }; } }); /** * The type of easing used by the column * @name $.oColumn#subColumns * @readonly * @type {object} */ Object.defineProperty($.oColumn.prototype, 'easeType', { get : function(){ switch(this.type){ case "BEZIER": return "BEZIER"; case "3DPATH": return column.velocityType( this.uniqueName ); default: return null; } } }) /** * An object with three int values : start, end and step, representing the value of the stepped section parameter (interpolation with non linear "step" parameter). * @name $.oColumn#stepSection * @type {object} */ Object.defineProperty($.oColumn.prototype, 'stepSection', { get : function(){ var _columnName = this.uniqueName; var _section = { start: func.holdStartFrame (_columnName), end : func.holdStopFrame (_columnName), step : func.holdStep (_columnName) } return _section; }, set : function(newSection){ var _columnName = this.uniqueName; func.setHoldStartFrame (_columnName, newSection.start) func.setHoldStopFrame (_columnName, newSection.end) func.setHoldStep (_columnName, newSection.step) } }); // $.oColumn Class methods /** * Deletes the column from the scene. The column must be unlinked from any attribute first. */ $.oColumn.prototype.remove = function(){ column.removeUnlinkedFunctionColumn(this.name); if (this.type) throw new Error("Couldn't remove column "+this.name+", unlink it from any attribute first.") } /** * Extends the exposure of the drawing's keyframes given the provided arguments. * @deprecated Use oDrawingColumn.extendExposures instead. * @param {$.oFrame[]} exposures The exposures to extend. If UNDEFINED, extends all keyframes. * @param {int} amount The amount to extend. * @param {bool} replace Setting this to false will insert frames as opposed to overwrite existing ones. */ $.oColumn.prototype.extendExposures = function( exposures, amount, replace){ if (this.type != "DRAWING") return false; // if amount is undefined, extend function below will automatically fill empty frames if (typeof exposures === 'undefined') var exposures = this.attributeObject.getKeyframes(); for (var i in exposures) { if (!exposures[i].isBlank) exposures[i].extend(amount, replace); } } /** * Removes concurrent/duplicate keys from drawing layers. */ $.oColumn.prototype.removeDuplicateKeys = function(){ var _keys = this.getKeyframes(); var _pointsToRemove = []; var _pointC; // check the extremities var _pointA = _keys[0].value; var _pointB = _keys[1].value; if (JSON.stringify(_pointA) == JSON.stringify(_pointB)) _pointsToRemove.push(_keys[0].frameNumber); for (var k=1; k<_keys.length-2; k++){ _pointA = _keys[k-1].value; _pointB = _keys[k].value; _pointC = _keys[k+1].value; MessageLog.trace(this.attributeObject.keyword+" pointA: "+JSON.stringify(_pointA)+" pointB: "+JSON.stringify(_pointB)+" pointC: "+JSON.stringify(_pointC)); if (JSON.stringify(_pointA) == JSON.stringify(_pointB) && JSON.stringify(_pointB) == JSON.stringify(_pointC)){ _pointsToRemove.push(_keys[k].frameNumber); } } _pointA = _keys[_keys.length-2].value; _pointB = _keys[_keys.length-1].value; if (JSON.stringify(_pointC) == JSON.stringify(_pointB)) _pointsToRemove.push(_keys[_keys.length-1].frameNumber); var _frames = this.frames; for (var i in _pointsToRemove){ _frames[_pointsToRemove[i]].isKeyframe = false; } } /** * Duplicates a column. Because of the way Harmony works, specifying an attribute the column will be connected to ensures higher value fidelity between the original and the copy. * @param {$.oAttribute} [newAttribute] An attribute to link the column to upon duplication. * * @return {$.oColumn} The column generated. */ $.oColumn.prototype.duplicate = function(newAttribute) { var _duplicateColumn = this.$.scene.addColumn(this.type, this.name); // linking to an attribute if one is provided if (typeof newAttribute !== 'undefined'){ newAttribute.column = _duplicateColumn; _duplicateColumn.attributeObject = newAttribute; } var _duplicatedFrames = _duplicateColumn.frames; var _keyframes = this.keyframes; // we set the ease twice to avoid incompatibilities between ease parameters and yet unchanged points for (var i in _keyframes){ var _duplicateFrame = _duplicatedFrames[_keyframes[i].frameNumber]; _duplicateFrame.value = _keyframes[i].value; } for (var i in _keyframes){ var _duplicateFrame = _duplicatedFrames[_keyframes[i].frameNumber]; _duplicateFrame.ease = _keyframes[i].ease; } for (var i in _keyframes){ var _duplicateFrame = _duplicatedFrames[_keyframes[i].frameNumber]; _duplicateFrame.ease = _keyframes[i].ease; } _duplicateColumn.stepSection = this.stepSection; return _duplicateColumn; } /** * Filters out only the keyframes from the frames array. * * @return {$.oFrame[]} Provides the array of frames from the column. */ $.oColumn.prototype.getKeyframes = function(){ var _frames = this.frames; var _ease = this.easeType; if( _ease == "BEZIER" || _ease == "EASE" ){ var _keyFrames = []; var _columnName = this.uniqueName; var _points = func.numberOfPoints(_columnName); for (var i = 0; i<_points; i++) { var _frameNumber = func.pointX( _columnName, i ) _keyFrames.push( _frames[_frameNumber] ); } return _keyFrames; } _frames = _frames.filter(function(x){return x.isKeyframe}); return _frames; } /** * Filters out only the keyframes from the frames array. * @deprecated For case consistency, keyframe will never have a capital F * @return {$.oFrame[]} Provides the array of frames from the column. */ $.oColumn.prototype.getKeyFrames = function(){ this.$.debug("oColumn.getKeyFrames is deprecated. Use oColumn.getKeyframes instead.", this.$.DEBUG_LEVEL.ERROR); return this.keyframes; } /** * Gets the value of the column at the given frame. * @param {int} [frame=1] The frame at which to get the value * @return {various} The value of the column, can be different types depending on column type. */ $.oColumn.prototype.getValue = function(frame){ if (typeof frame === 'undefined') var frame = 1; // this.$.log("Getting value of frame "+this.frameNumber+" of column "+this.column.name) if (this.attributeObject){ return this.attributeObject.getValue(frame); }else{ this.$.debug("getting unlinked column "+this.name+" value at frame "+frame, this.$.DEBUG_LEVEL.ERROR); this.$.debug("warning : getting a value from a column without attribute destroys value fidelity", this.$.DEBUG_LEVEL.ERROR); if (this.type == "3DPATH") { var _frame = new this.$.oFrame(frame, this, this.subColumns); return new this.$.oPathPoint(this, _frame); } return column.getEntry (this.uniqueName, 1, frame); } } /** * Sets the value of the column at the given frame. * @param {various} newValue The new value to set the column to * @param {int} [frame=1] The frame at which to get the value */ $.oColumn.prototype.setValue = function(newValue, frame){ if (typeof frame === 'undefined') var frame = 1; if (this.attributeObject){ this.attributeObject.setValue( newValue, frame); }else{ this.$.debug("setting unlinked column "+this.name+" value to "+newValue+" at frame "+frame, this.$.DEBUG_LEVEL.ERROR); this.$.debug("warning : setting a value on a column without attribute destroys value fidelity", this.$.DEBUG_LEVEL.ERROR); if (this.type == "3DPATH") { column.setEntry (this.uniqueName, 1, frame, newValue.x); column.setEntry (this.uniqueName, 2, frame, newValue.y); column.setEntry (this.uniqueName, 3, frame, newValue.z); column.setEntry (this.uniqueName, 4, frame, newValue.velocity); }else{ column.setEntry (this.uniqueName, 1, frame, newValue.toString()); } } } /** * Retrieves the nodes index in the timeline provided. * @param {oTimeline} [timeline] Optional: the timeline object to search the column Layer. (by default, grabs the current timeline) * * @return {int} The index within that timeline. */ $.oColumn.prototype.getTimelineLayer = function(timeline){ if (typeof timeline === 'undefined') var timeline = this.$.scene.getTimeline(); var _columnNames = timeline.allLayers.map(function(x){return x.column?x.column.uniqueName:null}); return timeline.allLayers[_columnNames.indexOf(this.uniqueName)]; } /** * @private */ $.oColumn.prototype.toString = function(){ return "[object $.oColumn '"+this.name+"']" } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oDrawingColumn class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * the $.oDrawingColumn constructor. Only called internally by the factory function [scene.getColumnByName()]{@link $.oScene#getColumnByName}; * @constructor * @classdesc oDrawingColumn is a special case of column which can be linked to an [oElement]{@link $.oElement}. This type of column is used to display drawings and always is visible in the Xsheet window. * @augments $.oColumn * @param {string} uniqueName The unique name of the column. * @param {$.oAttribute} oAttributeObject The oAttribute thats connected to the column. * * @property {string} uniqueName The unique name of the column. * @property {$.oAttribute} attributeObject The attribute object that the column is attached to. */ $.oDrawingColumn = function( uniqueName, oAttributeObject ) { // $.oDrawingColumn can only represent a column of type 'DRAWING' if (column.type(uniqueName) != 'DRAWING') throw new Error("'uniqueName' parameter must point to a 'DRAWING' type node"); //MessageBox.information("getting an instance of $.oDrawingColumn for column : "+uniqueName) var instance = $.oColumn.call(this, uniqueName, oAttributeObject); if (instance) return instance; } // extends $.oColumn and can use its methods $.oDrawingColumn.prototype = Object.create($.oColumn.prototype); $.oDrawingColumn.prototype.constructor = $.oColumn; /** * Retrieve and set the drawing element attached to the column. * @name $.oDrawingColumn#element * @type {$.oElement} */ Object.defineProperty($.oDrawingColumn.prototype, 'element', { get : function(){ return new this.$.oElement(column.getElementIdOfDrawing( this.uniqueName), this); }, set : function(oElementObject){ column.setElementIdOfDrawing( this.uniqueName, oElementObject.id ); oElementObject.column = this; } }) /** * Extends the exposure of the drawing's keyframes by the specified amount. * @param {$.oFrame[]} [exposures] The exposures to extend. If not specified, extends all keyframes. * @param {int} [amount] The number of frames to add to each exposure. If not specified, will extend frame up to the next one. * @param {bool} [replace=false] Setting this to false will insert frames as opposed to overwrite existing ones.(currently unsupported)) */ $.oDrawingColumn.prototype.extendExposures = function( exposures, amount, replace){ // if amount is undefined, extend function below will automatically fill empty frames if (typeof exposures === 'undefined') var exposures = this.getKeyframes(); this.$.debug("extendingExposures "+exposures.map(function(x){return x.frameNumber})+" by "+amount, this.$.DEBUG_LEVEL.DEBUG) // can't extend blank exposures, so we remove them from the list to extend exposures = exposures.filter(function(x){return !x.isBlank}) for (var i in exposures) { exposures[i].extend(amount, replace); } } /** * Duplicates a Drawing column. * @param {bool} [duplicateElement=true] Whether to also duplicate the element. Default is true. * @param {$.oAttribute} [newAttribute] Whether to link the new column to an attribute at this point. * * @return {$.oColumn} The created column. */ $.oDrawingColumn.prototype.duplicate = function(newAttribute, duplicateElement) { // duplicate element? if (typeof duplicateElement === 'undefined') var duplicateElement = true; var _duplicateElement = duplicateElement?this.element.duplicate():this.element; var _duplicateColumn = this.$.scene.addColumn(this.type, this.name, _duplicateElement); // linking to an attribute if one is provided if (typeof newAttribute !== 'undefined'){ newAttribute.column = _duplicateColumn; _duplicateColumn.attributeObject = newAttribute; } var _frames = this.frames; for (var i in _frames){ var _duplicateFrame = _duplicateColumn.frames[i]; _duplicateFrame.value = _frames[i].value; if (_frames[i].isKeyframe) _duplicateFrame.isKeyframe = true; } return _duplicateColumn; } /** * Renames the column's exposed drawings according to the frame they are first displayed at. * @param {string} [prefix] a prefix to add to all names. * @param {string} [suffix] a suffix to add to all names. */ $.oDrawingColumn.prototype.renameAllByFrame = function(prefix, suffix){ if (typeof prefix === 'undefined') var prefix = ""; if (typeof suffix === 'undefined') var suffix = ""; // get exposed drawings var _displayedDrawings = this.getExposedDrawings(); this.$.debug("Column "+this.name+" has drawings : "+_displayedDrawings.map(function(x){return x.value}), this.$.DEBUG_LEVEL.LOG); // remove duplicates var _seen = []; for (var i=0; i<_displayedDrawings.length; i++){ var _drawing = _displayedDrawings[i].value; if (_seen.indexOf(_drawing.name) == -1){ _seen.push(_drawing.name); }else{ _displayedDrawings.splice(i,1); i--; } } // rename for (var i in _displayedDrawings){ var _frameNum = _displayedDrawings[i].frameNumber; var _drawing = _displayedDrawings[i].value; this.$.debug("renaming drawing "+_drawing+" of column "+this.name+" to "+prefix+_frameNum+suffix, this.$.DEBUG_LEVEL.LOG); _drawing.name = prefix+_frameNum+suffix; } } /** * Removes unused drawings from the column. * @param {$.oFrame[]} exposures The exposures to extend. If UNDEFINED, extends all keyframes. */ $.oDrawingColumn.prototype.removeUnexposedDrawings = function(){ var _element = this.element; var _displayedDrawings = this.getExposedDrawings().map(function(x){return x.value.name;}); var _element = this.element; var _drawings = _element.drawings; for (var i=_drawings.length-1; i>=0; i--){ this.$.debug("removing drawing "+_drawings[i].name+" of column "+this.name+"? "+(_displayedDrawings.indexOf(_drawings[i].name) == -1), this.$.DEBUG_LEVEL.LOG); if (_displayedDrawings.indexOf(_drawings[i].name) == -1) _drawings[i].remove(); } } $.oDrawingColumn.prototype.getExposedDrawings = function (){ return this.keyframes.filter(function(x){return x.value != null}); } /** * @private */ $.oDrawingColumn.prototype.toString = function(){ return "<$.oDrawingColumn '"+this.name+"'>"; } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_database.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library // // // Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the Mozilla Public license 2.0. // https://www.mozilla.org/en-US/MPL/2.0/ // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oDataBase class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oDataBase. * @classdesc * A class to access the contents of the Harmony database from a scene. * @constructor */ $.oDatabase = function(){ } /** * Function to query the database using the dbu utility. * @private */ $.oDatabase.prototype.query = function(args){ var dbbin = specialFolders.bin+"/dbu"; var p = new $.oProcess(dbbin, args); var result = p.execute(); result = result.split("Name:").join("").split("\r\n"); return result; } /** * Lists the environments existing on the local database * @return {string[]} The list of names of environments */ $.oDatabase.prototype.getEnvironments = function(){ var dbFile = new this.$.oFile("/USA_DB/envs/env.db"); if (!dbFile.exists){ this.$.debug("Can't access Harmony Database at address : /USA_DB/envs/env.db", this.$.DEBUG_LEVEL.ERROR); return null; } var dbqueryArgs = ["-l", "-sh", "Name", dbFile.path]; var envs = this.query(dbqueryArgs); return envs; } /** * Lists the jobs in the given environment in the local database * @param {string} [environment] The name of the environment to return the jobs from. Returns the jobs from the current environment by default. * @return {string[]} The list of job names in the environment. */ $.oDatabase.prototype.getJobs = function(environment){ if (typeof environment === 'undefined' && this.$.scene.online) { var environment = this.$.scene.environment; }else{ return null; } var dbFile = new this.$.oFile("/USA_DB/online_jobs/jobs.db"); if (!dbFile.exists){ this.$.debug("Can't access Harmony Database at address : /USA_DB/online_jobs/jobs.db", this.$.DEBUG_LEVEL.ERROR); return null; } var dbqueryArgs = ["-l", "-sh", "Name", "-search", "Env == "+environment, dbFile.path]; var jobs = this.query(dbqueryArgs); return jobs; } /** * Lists the scenes in the given environment in the local database * @param {string} [job] The name of the jobs to return the scenes from. Returns the scenes from the current job by default. * @return {string[]} The list of scene names in the job. */ $.oDatabase.prototype.getScenes = function(job){ if (typeof job === 'undefined' && this.$.scene.online){ var job = this.$.scene.job; }else{ return null; } var dbFile = new this.$.oFile("/USA_DB/db_jobs/"+job+"/scene.db"); if (!dbFile.exists){ this.$.debug("Can't access Harmony Database at address : /USA_DB/db_jobs/"+job+"/scene.db", this.$.DEBUG_LEVEL.ERROR); return null; } var dbqueryArgs = ["-l", "-sh", "Name", dbFile.path]; var scenes = this.query(dbqueryArgs); return scenes; } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_dialog.js ================================================ "use strict" ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library // // // Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the Mozilla Public license 2.0. // https://www.mozilla.org/en-US/MPL/2.0/ // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oDialog class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The base class for the $.oDialog. * @classdesc * $.oDialog Base Class -- helper class for showing GUI content. * @constructor */ $.oDialog = function( ){ } /** * Prompts with a confirmation dialog (yes/no choice). * @name $.oDialog#confirm * @function * @param {string} [labelText] The label/internal text of the dialog. * @param {string} [title] The title of the confirmation dialog. * @param {string} [okButtonText] The text on the OK button of the dialog. * @param {string} [cancelButtonText] The text on the CANCEL button of the dialog. * * @return {bool} Result of the confirmation dialog. */ $.oDialog.prototype.confirm = function( labelText, title, okButtonText, cancelButtonText ){ if (this.$.batchMode) { this.$.debug("$.oDialog.confirm not supported in batch mode", this.$.DEBUG_LEVEL.WARNING) return; } if (typeof labelText === 'undefined') var labelText = false; if (typeof title === 'undefined') var title = "Confirmation"; if (typeof okButtonText === 'undefined') var okButtonText = "OK"; if (typeof cancelButtonText === 'undefined') var cancelButtonText = "Cancel"; var d = new Dialog(); d.title = title; d.okButtonText = okButtonText; d.cancelButtonText = cancelButtonText; if( labelText ){ var label = new Label; label.text = labelText; } d.add( label ); if ( !d.exec() ){ return false; } return true; } /** * Prompts with an alert dialog (informational). * @param {string} [labelText] The label/internal text of the dialog. * @param {string} [title] The title of the confirmation dialog. * @param {string} [okButtonText] The text on the OK button of the dialog. * */ $.oDialog.prototype.alert = function( labelText, title, okButtonText ){ if (this.$.batchMode) { this.$.debug("$.oDialog.alert not supported in batch mode", this.$.DEBUG_LEVEL.WARNING) return; } if (typeof labelText === 'undefined') var labelText = "Alert!"; if (typeof title === 'undefined') var title = "Alert"; if (typeof okButtonText === 'undefined') var okButtonText = "OK"; this.$.debug(labelText, this.$.DEBUG_LEVEL.LOG) var d = new QMessageBox( false, title, labelText, QMessageBox.Ok ); d.setWindowTitle( title ); d.buttons()[0].text = okButtonText; if( labelText ){ d.text = labelText; } if ( !d.exec() ){ return; } } /** * Prompts with an alert dialog with a text box which can be selected (informational). * @param {string} [labelText] The label/internal text of the dialog. * @param {string} [title] The title of the confirmation dialog. * @param {string} [okButtonText="OK"] The text on the OK button of the dialog. * @param {bool} [htmlSupport=false] */ $.oDialog.prototype.alertBox = function( labelText, title, okButtonText, htmlSupport){ if (this.$.batchMode) { this.$.debug("$.oDialog.alert not supported in batch mode", this.$.DEBUG_LEVEL.WARNING) return; } if (typeof labelText === 'undefined') var labelText = ""; if (typeof title === 'undefined') var title = ""; if (typeof okButtonText === 'undefined') var okButtonText = "OK"; if (typeof htmlSupport === 'undefined') var htmlSupport = false; this.$.debug(labelText, this.$.DEBUG_LEVEL.LOG) var d = new QDialog(); if (htmlSupport){ var label = new QTextEdit(labelText + ""); }else{ var label = new QPlainTextEdit(labelText + ""); } label.readOnly = true; var button = new QPushButton(okButtonText); var layout = new QVBoxLayout(d); layout.addWidget(label, 1, Qt.Justify); layout.addWidget(button, 0, Qt.AlignHCenter); d.setWindowTitle( title ); button.clicked.connect(d.accept); d.exec(); } /** * Prompts with an toast alert. This is a small message that can't be clicked and only stays on the screen for the duration specified. * @param {string} labelText The label/internal text of the dialog. * @param {$.oPoint} [position] The position on the screen where the toast will appear (by default, slightly under the middle of the screen). * @param {float} [duration=2000] The duration of the display (in milliseconds). * @param {$.oColorValue} [color="#000000"] The color of the background (a 50% alpha value will be applied). */ $.oDialog.prototype.toast = function(labelText, position, duration, color){ if (this.$.batchMode) { this.$.debug("$.oDialog.alert not supported in batch mode", this.$.DEBUG_LEVEL.WARNING); return; } if (typeof duration === 'undefined') var duration = 2000; if (typeof color === 'undefined') var color = new $.oColorValue(0,0,0); if (typeof position === 'undefined'){ var center = QApplication.desktop().screen().rect.center(); var position = new $.oPoint(center.x(), center.y()+UiLoader.dpiScale(150)) } var toast = new QWidget() var flags = new Qt.WindowFlags(Qt.Popup|Qt.FramelessWindowHint|Qt.WA_TransparentForMouseEvents); toast.setWindowFlags(flags); toast.setAttribute(Qt.WA_TranslucentBackground); toast.setAttribute(Qt.WA_DeleteOnClose); var styleSheet = "QWidget {" + "background-color: rgba("+color.r+", "+color.g+", "+color.b+", 50%); " + "color: white; " + "border-radius: "+UiLoader.dpiScale(10)+"px; " + "padding: "+UiLoader.dpiScale(10)+"px; " + "font-family: Arial; " + "font-size: "+UiLoader.dpiScale(12)+"pt;}" toast.setStyleSheet(styleSheet); var layout = new QHBoxLayout(toast); layout.addWidget(new QLabel(labelText), 0, Qt.AlignHCenter); var timer = new QTimer() timer.singleShot = true; timer.timeout.connect(this, function(){ toast.close(); }) toast.show(); toast.move(position.x-toast.width/2, position.y); timer.start(duration); } /** * Prompts for a user input. * @param {string} [labelText] The label/internal text of the dialog. * @param {string} [title] The title of the confirmation dialog. * @param {string} [prefilledText] The text to display in the input area. * */ $.oDialog.prototype.prompt = function( labelText, title, prefilledText){ if (typeof labelText === 'undefined') var labelText = "enter value :"; if (typeof title === 'undefined') var title = "Prompt"; if (typeof prefilledText === 'undefined') var prefilledText = ""; return Input.getText(labelText, prefilledText, title); } /** * Prompts with a file selector window * @param {string} [text="Select a file:"] The title of the confirmation dialog. * @param {string} [filter="*"] The filter for the file type and/or file name that can be selected. Accepts wildcard character "*". * @param {string} [getExisting=true] Whether to select an existing file or a save location * @param {string} [acceptMultiple=false] Whether or not selecting more than one file is ok. Is ignored if getExisting is falses. * @param {string} [startDirectory] The directory showed at the opening of the dialog. * * @return {string[]} The list of selected Files, 'undefined' if the dialog is cancelled */ $.oDialog.prototype.browseForFile = function( text, filter, getExisting, acceptMultiple, startDirectory){ if (this.$.batchMode) { this.$.debug("$.oDialog.browseForFile not supported in batch mode", this.$.DEBUG_LEVEL.WARNING) return; } if (typeof title === 'undefined') var title = "Select a file:"; if (typeof filter === 'undefined') var filter = "*" if (typeof getExisting === 'undefined') var getExisting = true; if (typeof acceptMultiple === 'undefined') var acceptMultiple = false; if (getExisting){ if (acceptMultiple){ var _files = QFileDialog.getOpenFileNames(0, text, startDirectory, filter); }else{ var _files = QFileDialog.getOpenFileName(0, text, startDirectory, filter); } }else{ var _files = QFileDialog.getSaveFileName(0, text, startDirectory, filter); } for (var i in _files){ _files[i] = _files[i].replace(/\\/g, "/"); } this.$.debug(_files); return _files; } /** * Prompts with a browse for folder dialog (informational). * @param {string} [text] The title of the confirmation dialog. * @param {string} [startDirectory] The directory showed at the opening of the dialog. * * @return {string} The path of the selected folder, 'undefined' if the dialog is cancelled */ $.oDialog.prototype.browseForFolder = function(text, startDirectory){ if (this.$.batchMode) { this.$.debug("$.oDialog.browseForFolder not supported in batch mode", this.$.DEBUG_LEVEL.WARNING) return; } if (typeof title === 'undefined') var title = "Select a folder:"; var _folder = QFileDialog.getExistingDirectory(0, text, startDirectory); _folder = _folder.split("\\").join("/"); // this.$.alert(_folder) return _folder; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oProgressDialog class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The $.oProgressDialog constructor. * @name $.oProgressDialog * @constructor * @classdesc An simple progress dialog to display the progress of a task. * To react to the user clicking the cancel button, connect a function to $.oProgressDialog.canceled() signal. * When $.batchmode is true, the progress will be outputted as a "Progress : value/range" string to the Harmony stdout. * @param {string} [labelText] The text displayed above the progress bar. * @param {string} [range=100] The maximum value that represents a full progress bar. * @param {string} [title] The title of the dialog * @param {bool} [show=false] Whether to immediately show the dialog. * * @property {bool} wasCanceled Whether the progress bar was cancelled. * @property {$.oSignal} canceled A Signal emitted when the dialog is canceled. Can be connected to a callback. */ $.oProgressDialog = function( labelText, range, title, show ){ if (typeof title === 'undefined') var title = "Progress"; if (typeof range === 'undefined') var range = 100; if (typeof labelText === 'undefined') var labelText = ""; this._value = 0; this._range = range; this._title = title; this._labelText = labelText; this.canceled = new this.$.oSignal(); this.wasCanceled = false; if (!this.$.batchMode) { this.progress = new QProgressDialog(); this.progress.title = this._title; this.progress.setLabelText( this._labelText ); this.progress.setRange( 0, this._range ); this.progress.setWindowFlags(Qt.Popup|Qt.WindowStaysOnTopHint) this.progress["canceled()"].connect( this, function(){this.wasCanceled = true; this.canceled.emit()} ); if (show) this.show(); } } // legacy compatibility $.oDialog.Progress = $.oProgressDialog; /** * The text displayed by the window. * @name $.oProgressDialog#label * @type {string} */ Object.defineProperty( $.oProgressDialog.prototype, 'label', { get: function(){ return this._labelText; }, set: function( val ){ this._labelText = val; if (!this.$.batchMode) this.progress.setLabelText( val ); } }); /** * The maximum value that can be displayed by the progress dialog (equivalent to "finished") * @name $.oProgressDialog#range * @type {int} */ Object.defineProperty( $.oProgressDialog.prototype, 'range', { get: function(){ return this._range; }, set: function( val ){ this._range = val; if (!this.$.batchMode) this.progress.setRange( 0, val ); } }); /** * The current value of the progress bar. Setting this to the value of 'range' will close the dialog. * @name $.oProgressDialog#value * @type {int} */ Object.defineProperty( $.oProgressDialog.prototype, 'value', { get: function(){ return this._value; }, set: function( val ){ if (val > this.range) val = this.range; this._value = val; if (this.$.batchMode) { this.$.log("Progress : "+val+"/"+this._range) }else { this.progress.value = val; } // update the widget appearance QCoreApplication.processEvents(); } }); /** * Whether the Progress Dialog was cancelled by the user. * @name $.oProgressDialog#cancelled * @deprecated use $.oProgressDialog.wasCanceled to get the cancel status, or connect a function to the "canceled" signal. */ Object.defineProperty( $.oProgressDialog.prototype, 'cancelled', { get: function(){ return this.wasCanceled; } }); // oProgressDialog Class Methods /** * Shows the dialog. */ $.oProgressDialog.prototype.show = function(){ if (this.$.batchMode) { this.$.debug("$.oProgressDialog not supported in batch mode", this.$.DEBUG_LEVEL.ERROR) return; } this.progress.show(); } /** * Closes the dialog. */ $.oProgressDialog.prototype.close = function(){ this.value = this.range; this.$.log("Progress : "+this.value+"/"+this._range) if (this.$.batchMode) { this.$.debug("$.oProgressDialog not supported in batch mode", this.$.DEBUG_LEVEL.ERROR) return; } this.canceled.blocked = true; this.progress.close(); this.canceled.blocked = false; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oPieMenu class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The $.oPieMenu constructor. * @name $.oPieMenu * @constructor * @classdesc A type of menu with nested levels that appear around the mouse * @param {string} name The name for this pie Menu. * @param {QWidget[]} [widgets] The widgets to display in the menu. * @param {bool} [show=false] Whether to immediately show the dialog. * @param {float} [minAngle] The low limit of the range of angles used by the menu, in multiples of PI (0 : left, 0.5 : top, 1 : right, -0.5 : bottom) * @param {float} [maxAngle] The high limit of the range of angles used by the menu, in multiples of PI (0 : left, 0.5 : top, 1 : right, -0.5 : bottom) * @param {float} [radius] The radius of the menu. * @param {$.oPoint} [position] The central position of the menu. * * @property {string} name The name for this pie Menu. * @property {QWidget[]} widgets The widgets to display in the menu. * @property {float} minAngle The low limit of the range of angles used by the menu, in multiples of PI (0 : left, 0.5 : top, 1 : right, -0.5 : bottom) * @property {float} maxAngle The high limit of the range of angles used by the menu, in multiples of PI (0 : left, 0.5 : top, 1 : right, -0.5 : bottom) * @property {float} radius The radius of the menu. * @property {$.oPoint} position The central position of the menu or button position for imbricated menus. * @property {QWidget} menuWidget The central position of the menu or button position for imbricated menus. * @property {QColor} sliceColor The color of the slices. Can set to any fill type accepted by QBrush * @property {QColor} backgroundColor The background of the menu. Can set to any fill type accepted by QBrush * @property {QColor} linesColor The color of the lines. * @example // This example function creates a menu full of generated push buttons with callbacks, but any type of widget can be added. // Normally it doesn't make sense to create buttons this way, and they will be created one by one to cater to specific needs, // such as launching Harmony actions, or scripts, etc. Assign this function to a shortcut by creating a Harmony Package for it. function openMenu(){ MessageLog.clearLog() // we create a list of tool widgets for our submenu var toolSubMenuWidgets = [ new $.oToolButton("select"), new $.oToolButton("brush"), new $.oToolButton("pencil"), new $.oToolButton("eraser"), ]; // we initialise our submenu var toolSubMenu = new $.oPieSubMenu("tools", toolSubMenuWidgets); // we create a list of tool widgets for our submenu // (check out the scripts from http://raindropmoment.com and http://www.cartoonflow.com, they are great!) var ScriptSubMenuWidgets = [ new $.oScriptButton(specialFolders.userScripts + "/CF_CopyPastePivots_1.0.1.js", "CF_CopyPastePivots" ), new $.oScriptButton(specialFolders.userScripts + "/ANM_Paste_In_Place.js", "ANM_Paste_In_Place"), new $.oScriptButton(specialFolders.userScripts + "/ANM_Set_Layer_Pivots_At_Center_Of_Drawings.js", "ANM_Set_Layer_Pivots_At_Center_Of_Drawings"), new $.oScriptButton(specialFolders.userScripts + "/DEF_Copy_Deformation_Values_To_Resting.js", "DEF_Copy_Deformation_Values_To_Resting"), ]; var scriptsSubMenu = new $.oPieSubMenu("scripts", ScriptSubMenuWidgets); // we create a list of color widgets for our submenu var colorSubMenuWidgets = [] var currentPalette = $.scn.selectedPalette var colors = currentPalette.colors for (var i in colors){ colorSubMenuWidgets.push(new $.oColorButton(currentPalette.name, colors[i].name)); } var colorSubMenu = new $.oPieSubMenu("colors", colorSubMenuWidgets); onionSkinSlider = new QSlider(Qt.Horizontal) onionSkinSlider.minimum = 0; onionSkinSlider.maximum = 256; onionSkinSlider.valueChanged.connect(function(value){ preferences.setDouble("DRAWING_ONIONSKIN_MAX_OPACITY", value/256.0); view.refreshViews(); }) // widgets that will appear in the main menu var mainWidgets = [ onionSkinSlider, toolSubMenu, colorSubMenu, scriptsSubMenu ] // we initialise our main menu. The numerical values are for the minimum and maximum angle of the // circle in multiples of Pi. Going clockwise, 0 is right, 1 is left, -0.5 is the bottom from the right, // and 1.5 is the bottom from the left side. 0.5 is the top of the circle. var menu = new $.oPieMenu("menu", mainWidgets, false, -0.2, 1.2); // configurating the look of it // var backgroundGradient = new QRadialGradient (menu.center, menu.maxRadius); // var menuBg = menu.backgroundColor // backgroundGradient.setColorAt(1, new QColor(menuBg.red(), menuBg.green(), menuBg.blue(), 255)); // backgroundGradient.setColorAt(0, menuBg); // var sliceGradient = new QRadialGradient (menu.center, menu.maxRadius); // var menuColor = menu.sliceColor // sliceGradient.setColorAt(1, new QColor(menuColor.red(), menuColor.green(), menuColor.blue(), 20)); // sliceGradient.setColorAt(0, menuColor); // menu.backgroundColor = backgroundGradient // menu.sliceColor = sliceGradient // we show it! menu.show(); }*/ $.oPieMenu = function( name, widgets, show, minAngle, maxAngle, radius, position, parent){ this.name = name; this.widgets = widgets; if (typeof minAngle === 'undefined') var minAngle = 0; if (typeof maxAngle === 'undefined') var maxAngle = 1; if (typeof radius === 'undefined') var radius = this.getMenuRadius(); if (typeof position === 'undefined') var position = this.$.app.globalMousePosition; if (typeof show === 'undefined') var show = false; if (typeof parent === 'undefined') var parent = this.$.app.mainWindow; this._parent = parent; // close all previously opened piemenu widgets if (!$._piemenu) $._piemenu = [] while ($._piemenu.length){ var pie = $._piemenu.pop(); if (pie){ // a menu was already open, we close it pie.closeMenu() } } QWidget.call(this, parent) this.objectName = "pieMenu_" + name; $._piemenu.push(this) this.radius = radius; this.minAngle = minAngle; this.maxAngle = maxAngle; this.globalCenter = position; // how wide outside the icons is the slice drawn this._circleMargin = 30; // set these values before calling show() to customize the menu appearance this.sliceColor = new QColor(0, 200, 255, 200); this.backgroundColor = new QColor(40, 40, 40, 180); this.linesColor = new QColor(0,0,0,0); // create main button this.button = this.buildButton() // add buildWidget call before show(), // for some reason show() is not in QWidget.prototype ? this.qWidgetShow = this.show this.show = function(){ this.buildWidget() } this.focusPolicy = Qt.StrongFocus; this.focusOutEvent = function(){ this.deactivate() } var menu = this; this.button.clicked.connect(function(){return menu.deactivate()}) if (show) this.show(); } $.oPieMenu.prototype = Object.create(QWidget.prototype); /** * function run when the menu button is clicked */ $.oPieMenu.prototype.deactivate = function(){ this.closeMenu() } /** * Closes the menu and all its subWidgets * @private */ $.oPieMenu.prototype.closeMenu = function(){ for (var i in this.widgets){ this.widgets[i].close() } this.close(); } /** * The top left point of the entire widget * @name $.oPieMenu#anchor * @type {$.oPoint} */ Object.defineProperty($.oPieMenu.prototype, "anchor", { get: function(){ var point = this.globalCenter.add(-this.center.x, -this.center.y); return point; } }) /** * The center of the entire widget * @name $.oPieMenu#center * @type {$.oPoint} */ Object.defineProperty($.oPieMenu.prototype, "center", { get: function(){ return new this.$.oPoint(this.widgetSize/2, this.widgetSize/2) } }) /** * The min radius of the pie background * @name $.oPieMenu#minRadius * @type {int} */ Object.defineProperty($.oPieMenu.prototype, "minRadius", { get: function(){ return this._circleMargin; } }) /** * The max radius of the pie background * @name $.oPieMenu#maxRadius * @type {int} */ Object.defineProperty($.oPieMenu.prototype, "maxRadius", { get: function(){ return this.radius + this._circleMargin; } }) /** * The widget size of the pie background (it's a square so it's both the width and the height.) * @name $.oPieMenu#widgetSize * @type {int} */ Object.defineProperty($.oPieMenu.prototype, "widgetSize", { get: function(){ return this.maxRadius*4; } }) /** * Builds the menu's main button. * @returns {$.oPieButton} */ $.oPieMenu.prototype.buildButton = function(){ // add main button in constructor because it needs to exist before show() var icon = specialFolders.resource + "/icons/brushpreset/defaultpresetellipse/ellipse03.svg" button = new this.$.oPieButton(icon, "", this); button.objectName = this.name+"_button"; button.parentMenu = this; return button; } /** * Build and show the pie menu and its widgets. * @private */ $.oPieMenu.prototype.buildWidget = function(){ // match the widget geometry with the main window/parent var anchor = this.anchor this.move(anchor.x, anchor.y); this.minimumHeight = this.maximumHeight = this.widgetSize; this.minimumWidth = this.maximumWidth = this.widgetSize; var flags = new Qt.WindowFlags(Qt.Popup|Qt.FramelessWindowHint|Qt.WA_TransparentForMouseEvents); this.setWindowFlags(flags); this.setAttribute(Qt.WA_TranslucentBackground); this.setAttribute(Qt.WA_DeleteOnClose); // draw background pie slice this.slice = this.drawSlice(); this.qWidgetShow() // arrange widgets into half a circle around the center var center = this.center; for (var i=0; i < this.widgets.length; i++){ var widget = this.widgets[i]; widget.pieIndex = i; widget.setParent(this); var itemPosition = this.getItemPosition(i); var widgetPosition = new this.$.oPoint(center.x + itemPosition.x, center.y + itemPosition.y); widget.show(); widget.move(widgetPosition.x - widget.width/2, widgetPosition.y - widget.height/2); } this.button.show(); this.button.move(center.x - (this.button.width/2), center.y - (this.button.height/2)); } /** * draws a background transparent slice and set up the mouse tracking. * @param {int} [minRadius] specify a minimum radius for the slice * @private */ $.oPieMenu.prototype.drawSlice = function(){ var index = 0; // get the slice and background geometry var center = this.center; var angleSlice = this.getItemAngleRange(index); var slicePath = this.getSlicePath(center, angleSlice[0], angleSlice[1], this.minRadius, this.maxRadius); var contactPath = this.getSlicePath(center, this.minAngle, this.maxAngle, this.minRadius, this.maxRadius); // create a widget to paint into var sliceWidget = new QWidget(this); sliceWidget.objectName = "slice"; // make widget background invisible sliceWidget.setStyleSheet("background-color: rgba(0, 0, 0, 0.5%);"); var flags = new Qt.WindowFlags(Qt.FramelessWindowHint); sliceWidget.setWindowFlags(flags) sliceWidget.minimumHeight = this.height; sliceWidget.minimumWidth = this.width; sliceWidget.lower(); var sliceWidth = angleSlice[1]-angleSlice[0]; // painting the slice on sliceWidget.update() var sliceColor = this.sliceColor; var backgroundColor = this.backgroundColor; var linesColor = this.linesColor; sliceWidget.paintEvent = function(){ var painter = new QPainter(); painter.save(); painter.begin(sliceWidget); // draw background painter.setRenderHint(QPainter.Antialiasing); painter.setPen(new QPen(linesColor)); painter.setBrush(new QBrush(backgroundColor)); painter.drawPath(contactPath); // draw slice and rotate around widget center painter.translate(center.x, center.y); painter.rotate(sliceWidth*index*(-180)); painter.translate(-center.x, -center.y); painter.setPen(new QPen(linesColor)); painter.setBrush(new QBrush(sliceColor)); painter.drawPath(slicePath); painter.end(); painter.restore(); } //set up automatic following of the mouse sliceWidget.mouseTracking = true; var pieMenu = this; var currentDistance = false; sliceWidget.mouseMoveEvent = function(mousePos){ // work out the index based on relative position to the center var position = new pieMenu.$.oPoint(mousePos.x(), mousePos.y()); var angle = -position.add(-center.x, -center.y).polarCoordinates.angle/Math.PI; if (angle < (-0.5)) angle += 2; // our coordinates system uses continuous angle values with cutoff at the bottom (1.5/-0.5) var currentIndex = pieMenu.getIndexAtAngle(angle); var distance = position.distance(center); // on distance value change, if the distance is greater than the maxRadius, activate the widget var indexChanged = (index != currentIndex) var indexWithinRange = (currentIndex >= 0 && currentIndex < pieMenu.widgets.length) var distanceWithinRange = (distance > pieMenu.minRadius && distance < pieMenu.maxRadius) var distanceChanged = (distanceWithinRange != currentDistance) // react to distance/angle change when the mouse moves on the pieMenu if (indexWithinRange){ var indexWidget = pieMenu.widgets[currentIndex]; if (indexChanged && distance < pieMenu.maxRadius){ index = currentIndex; sliceWidget.update(); indexWidget.setFocus(true); } if (distanceChanged){ currentDistance = distanceWithinRange; if (distance > pieMenu.maxRadius){ // activate the button if (indexWidget.activate) indexWidget.activate(); }else if (distance < pieMenu.minRadius){ // cursor reentered the widget: close the subMenu if (indexWidget.deactivate) indexWidget.deactivate(); } if (distance < pieMenu.minRadius){ if (pieMenu.deactivate) pieMenu.deactivate(); } } } } return sliceWidget; } /** * Generate a pie slice path to draw based on parameters * @param {$.oPoint} center the center of the slice * @param {float} minAngle a value between -0.5 and 1.5 for the lowest angle value for the pie slice * @param {float} maxAngle a value between -0.5 and 1.5 for the highest angle value for the pie slice * @param {float} minRadius the smallest circle radius * @param {float} maxRadius the largest circle radius * @private */ $.oPieMenu.prototype.getSlicePath = function(center, minAngle, maxAngle, minRadius, maxRadius){ // work out the geometry var smallArcBoundingBox = new QRectF(center.x-minRadius, center.y-minRadius, minRadius*2, minRadius*2); var smallArcStart = new this.$.oPoint(); smallArcStart.polarCoordinates = {radius: minRadius, angle:minAngle*(-Math.PI)} smallArcStart.translate(center.x, center.y); var smallArcAngleStart = minAngle*180; var smallArcSweep = (maxAngle-minAngle)*180; // convert values from 0-2 (radiant angles in multiples of pi) to degrees var bigArcBoundingBox = new QRectF(center.x-maxRadius, center.y-maxRadius, maxRadius*2, maxRadius*2); var bigArcAngleStart = maxAngle*180; var bigArcSweep = -smallArcSweep; // we draw the slice path var slicePath = new QPainterPath; slicePath.moveTo(new QPointF(smallArcStart.x, smallArcStart.y)); slicePath.arcTo(smallArcBoundingBox, smallArcAngleStart, smallArcSweep); slicePath.arcTo(bigArcBoundingBox, bigArcAngleStart, bigArcSweep); return slicePath; } /** * Get the angle range for the item pie slice based on index. * @private * @param {int} index the index of the widget * @return {float[]} */ $.oPieMenu.prototype.getItemAngleRange = function(index){ var length = this.widgets.length; var angleStart = this.minAngle+(index/length)*(this.maxAngle-this.minAngle); var angleEnd = this.minAngle+((index+1)/length)*(this.maxAngle-this.minAngle); return [angleStart, angleEnd]; } /** * Get the angle for the item widget based on index. * @private * @param {int} index the index of the widget * @return {float} */ $.oPieMenu.prototype.getItemAngle = function(index){ var angleRange = this.getItemAngleRange(index, this.minAngle, this.maxAngle); var angle = (angleRange[1] - angleRange[0])/2+angleRange[0] return angle; } /** * Get the widget index for the angle value. * @private * @param {float} angle the index of the widget * @return {float} */ $.oPieMenu.prototype.getIndexAtAngle = function(angle){ var angleRange = (this.maxAngle-this.minAngle)/this.widgets.length return Math.floor((angle-this.minAngle)/angleRange); } /** * Get the position from the center for the item based on index. * @private * @param {int} index the index of the widget * @return {$.oPoint} */ $.oPieMenu.prototype.getItemPosition = function(index){ // we add pi to the angle because of the inverted Y axis of widgets coordinates var pi = Math.PI; var angle = this.getItemAngle(index, this.minAngle, this.maxAngle)*(-pi); var _point = new this.$.oPoint(); _point.polarCoordinates = {radius:this.radius, angle:angle} return _point; } /** * Get a pie menu radius setting for a given amount of items. * @private * @return {float} */ $.oPieMenu.prototype.getMenuRadius = function(){ var itemsNumber = this.widgets.length var _maxRadius = UiLoader.dpiScale(200); var _minRadius = UiLoader.dpiScale(30); var _speed = 10; // the higher the value, the slower the progression // hyperbolic tangent function to determine the radius var exp = Math.exp(2*itemsNumber/_speed); var _radius = ((exp-1)/(exp+1))*_maxRadius+_minRadius; return _radius; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oPieSubMenu class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The $.oPieSubMenu constructor. * @name $.oPieSubMenu * @constructor * @classdesc A menu with more options that opens/closes when the user clicks on the button. * @param {string} name The name for this pie Menu. * @param {QWidget[]} [widgets] The widgets to display in the menu. * * @property {string} name The name for this pie Menu. * @property {string} widgets The widgets to display in the menu. * @property {string} menu The oPieMenu Object containing the widgets for the submenu * @property {string} itemAngle a set angle for each items instead of spreading them across the entire circle * @property {string} extraRadius using a set radius between each submenu levels * @property {$.oPieMenu} parentMenu the parent menu for this subMenu. Set during initialisation of the menu. */ $.oPieSubMenu = function(name, widgets) { this.menuIcon = specialFolders.resource + "/icons/toolbar/menu.svg"; this.closeIcon = specialFolders.resource + "/icons/toolbar/collapseopen.png"; // min/max angle and radius will be set from parent during buildWidget() this.$.oPieMenu.call(this, name, widgets, false); // change these settings before calling show() to modify the look of the pieSubMenu this.itemAngle = 0.06; this.extraRadius = UiLoader.dpiScale(80); this.parentMenu = undefined; this.focusOutEvent = function(){} // delete focusOutEvent response from submenu } $.oPieSubMenu.prototype = Object.create($.oPieMenu.prototype) /** * function called when main button is clicked */ $.oPieSubMenu.prototype.deactivate = function(){ this.toggleMenu() } /** * The top left point of the entire widget * @name $.oPieSubMenu#anchor * @type {$.oPoint} */ Object.defineProperty($.oPieSubMenu.prototype, "anchor", { get: function(){ var center = this.parentMenu.globalCenter; return center.add(-this.widgetSize/2, -this.widgetSize/2); } }) /** * The min radius of the pie background * @name $.oPieSubMenu#minRadius * @type {int} */ Object.defineProperty($.oPieSubMenu.prototype, "minRadius", { get: function(){ return this.parentMenu.maxRadius; } }) /** * The max radius of the pie background * @name $.oPieSubMenu#maxRadius * @type {int} */ Object.defineProperty($.oPieSubMenu.prototype, "maxRadius", { get: function(){ return this.minRadius + this.extraRadius; } }) /** * activate the menu button when activate() is called on the menu * @private */ $.oPieSubMenu.prototype.activate = function(){ this.showMenu(true); this.setFocus(true) } /** * In order for pieSubMenus to behave like other pie widgets, we reimplement * move() so that it only moves the button, and the slice will remain aligned with * the parent. * @param {int} x The x coordinate for the button relative to the piewidget * @param {int} y The x coordinate for the button relative to the piewidget * @private */ $.oPieSubMenu.prototype.move = function(x, y){ // move the actual widget to its anchor, but move the button instead QWidget.prototype.move.call(this, this.anchor.x, this.anchor.y); // calculate the actual position for the button as if it was a child of the pieMenu // whereas it uses global coordinates var buttonPos = new this.$.oPoint(x, y) var parentAnchor = this.parentMenu.anchor; var anchorDiff = parentAnchor.add(-this.anchor.x, -this.anchor.y) var localPos = buttonPos.add(anchorDiff.x, anchorDiff.y) // move() is used by the pieMenu with half the widget size to center the button, so we have to cancel it out this.button.move(localPos.x+this.widgetSize/2-this.button.width/2, localPos.y+this.widgetSize/2-this.button.height/2 ); } /** * sets a parent and assigns it to this.parentMenu. * using the normal setParent from QPushButton creates a weird bug * where calling parent() returns a QWidget and not a $.oPieButton * @private */ $.oPieSubMenu.prototype.setParent = function(parent){ $.oPieMenu.prototype.setParent.call(this, parent); this.parentMenu = parent; } /** * build the main button for the menu * @private * @returns {$.oPieButton} */ $.oPieSubMenu.prototype.buildButton = function(){ // add main button in constructor because it needs to exist before show() var button = new this.$.oPieButton(this.menuIcon, this.name, this); button.objectName = this.name+"_button"; return button; } /** * Shows or hides the menu itself (not the button) * @param {*} visibility */ $.oPieSubMenu.prototype.showMenu = function(visibility){ this.slice.visible = visibility; for (var i in this.widgets){ this.widgets[i].visible = visibility; } var icon = visibility?this.closeIcon:this.menuIcon; UiLoader.setSvgIcon(this.button, icon); } /** * toggles the display of the menu */ $.oPieSubMenu.prototype.toggleMenu = function(){ this.showMenu(!this.slice.visible); } /** * Function to initialise the widgets for the submenu * @private */ $.oPieSubMenu.prototype.buildWidget = function(){ if (!this.parentMenu){ throw new Error("must set parent first before calling $.oPieMenu.buildWidget()") } parentWidget = this.parentMenu; // submenu widgets calculate their range from to go on both sides of the button, at a fixed angle // (in order to keep the span of submenu options centered around the menu button) var widgetNum = this.widgets.length/2; var angle = parentWidget.getItemAngle(this.pieIndex); // create the submenu on top of the main menu this.radius = parentWidget.radius+this.extraRadius; this.minAngle = angle-widgetNum*this.itemAngle; this.maxAngle = angle+widgetNum*this.itemAngle; $.oPieMenu.prototype.buildWidget.call(this); this.showMenu(false) } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oPieButton class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for $.oPieButton * @constructor * @classdesc This subclass of QPushButton provides an easy way to create a button for a PieMenu.
* * This class is a subclass of QPushButton and all the methods from that class are available to modify this button. * @param {string} iconFile The icon file for the button * @param {string} text A text to display next to the icon * @param {QWidget} parent The parent QWidget for the button. Automatically set during initialisation of the menu. * */ $.oPieButton = function(iconFile, text, parent) { // if icon isnt provided if (typeof parent === 'undefined') var parent = $.app.mainWindow if (typeof text === 'undefined') var text = "" if (typeof iconFile === 'undefined') var iconFile = specialFolders.resource+"/icons/script/qtgeneric.svg" QPushButton.call(this, text, parent); this.minimumHeight = 24; this.minimumWidth = 24; // set during addition to the pie Menu this.pieIndex = undefined; UiLoader.setSvgIcon(this, iconFile) this.setIconSize(new QSize(this.minimumWidth, this.minimumHeight)); this.cursor = new QCursor(Qt.PointingHandCursor); var styleSheet = "QPushButton{ background-color: rgba(0, 0, 0, 1%); }" + "QPushButton:hover{ background-color: rgba(0, 200, 255, 80%); }"+ "QToolTip{ background-color: rgba(0, 255, 255, 100%); }" this.setStyleSheet(styleSheet); var button = this; this.clicked.connect(function(){button.activate()}) } $.oPieButton.prototype = Object.create(QPushButton.prototype); /** * Closes the parent menu of the button and all its subWidgets. */ $.oPieButton.prototype.closeMenu = function(){ var menu = this.parentMenu; while (menu && menu.parentMenu){ menu = menu.parentMenu; } menu.closeMenu() } /** * Reimplement this function in order to activate the button and also close the menu. */ $.oPieButton.prototype.activate = function(){ // reimplement to change the behavior when the button is activated. // by default, will just close the menu. this.closeMenu(); } /** * sets a parent and assigns it to this.parentMenu. * using the normal setParent from QPushButton creates a weird bug * where calling parent() returns a QWidget and not a $.oPieButton * @private */ $.oPieButton.prototype.setParent = function(parent){ QPushButton.prototype.setParent.call(this, parent); this.parentMenu = parent; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oToolButton class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for $.oToolButton * @name $.oToolButton * @constructor * @classdescription This subclass of QPushButton provides an easy way to create a button for a tool. * This class is a subclass of QPushButton and all the methods from that class are available to modify this button. * @param {string} toolName The path to the script file that will be launched * @param {string} scriptFunction The function name to launch from the script * @param {QWidget} parent The parent QWidget for the button. Automatically set during initialisation of the menu. * */ $.oToolButton = function(toolName, iconFile, parent) { this.toolName = toolName; if (typeof iconFile === "undefined"){ // find an icon for the function in the script-icons folder var scriptIconsFolder = new this.$.oFolder(specialFolders.resource+"/icons/drawingtool"); var iconFiles = scriptIconsFolder.getFiles(toolName.replace(" ", "").toLowerCase() + ".*"); if (iconFiles.length > 0){ var iconFile = iconFiles[0].path; }else{ // choose default toonboom "missing icon" script icon // currently svg icons seem unsupported? var iconFile = specialFolders.resource+"/icons/script/qtgeneric.svg"; } } this.$.oPieButton.call(this, iconFile, parent); this.toolTip = this.toolName; } $.oToolButton.prototype = Object.create($.oPieButton.prototype); $.oToolButton.prototype.activate = function(){ this.$.app.currentTool = this.toolName; this.closeMenu() } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oActionButton class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for $.oActionButton * @name $.oActionButton * @constructor * @classdescription This subclass of QPushButton provides an easy way to create a button for a tool. * This class is a subclass of QPushButton and all the methods from that class are available to modify this button. * @param {string} actionName The action string that will be executed with Action.perform * @param {string} responder The responder for the action * @param {string} text A text for the button display. * @param {string} iconFile An icon path for the button. * @param {QWidget} parent The parent QWidget for the button. Automatically set during initialisation of the menu. */ $.oActionButton = function(actionName, responder, text, iconFile, parent) { this.action = actionName; this.responder = responder; if (typeof text === 'undefined') var text = "action"; if (typeof iconFile === 'undefined') var iconFile = specialFolders.resource+"/icons/old/exec.png"; this.$.oPieButton.call(this, iconFile, text, parent); this.toolTip = this.toolName; } $.oActionButton.prototype = Object.create($.oPieButton.prototype); $.oActionButton.prototype.activate = function(){ if (this.responder){ // log("Validating : "+ this.actionName + " ? "+ Action.validate(this.actionName, this.responder).enabled) if (Action.validate(this.action, this.responder).enabled){ Action.perform(this.action, this.responder); } }else{ if (Action.Validate(this.action).enabled){ Action.perform(this.action); } } view.refreshViews(); this.closeMenu() } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oColorButton class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for $.oColorButton * @name $.oColorButton * @constructor * @classdescription This subclass of QPushButton provides an easy way to create a button to choose a color from a palette. * This class is a subclass of QPushButton and all the methods from that class are available to modify this button. * @param {string} paletteName The name of the palette that contains the color * @param {string} colorName The name of the color (if more than one is present, will pick the first match) * @param {bool} showName Whether to display the name of the color on the button * @param {QWidget} parent The parent QWidget for the button. Automatically set during initialisation of the menu. * */ $.oColorButton = function(paletteName, colorName, showName, parent) { this.paletteName = paletteName; this.colorName = colorName; if (typeof showName === "undefined") var showName = false; this.$.oPieButton.call(this, "", showName?colorName:"", parent); var palette = this.$.scn.getPaletteByName(paletteName); var color = palette.getColorByName(colorName); var colorValue = color.value var iconMap = new QPixmap(this.minimumHeight,this.minimumHeight) iconMap.fill(new QColor(colorValue.r, colorValue.g, colorValue.b, colorValue.a)) var icon = new QIcon(iconMap); this.icon = icon; this.toolTip = this.paletteName + ": " + this.colorName; } $.oColorButton.prototype = Object.create($.oPieButton.prototype); $.oColorButton.prototype.activate = function(){ var palette = this.$.scn.getPaletteByName(this.paletteName); var color = palette.getColorByName(this.colorName); this.$.scn.currentPalette = palette; palette.currentColor = color; this.closeMenu() } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oScriptButton class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for $.oScriptButton * @name $.oScriptButton * @constructor * @classdescription This subclass of QPushButton provides an easy way to create a button for a widget that will launch a function from another script file.
* The buttons created this way automatically load the icon named after the script if it finds one named like the function in a script-icons folder next to the script file.
* It will also automatically set the callback to lanch the function from the script.
* This class is a subclass of QPushButton and all the methods from that class are available to modify this button. * @param {string} scriptFile The path to the script file that will be launched * @param {string} scriptFunction The function name to launch from the script * @param {QWidget} parent The parent QWidget for the button. Automatically set during initialisation of the menu. */ $.oScriptButton = function(scriptFile, scriptFunction, parent) { this.scriptFile = scriptFile; this.scriptFunction = scriptFunction; // find an icon for the function in the script-icons folder var scriptFile = new this.$.oFile(scriptFile) var scriptIconsFolder = new this.$.oFolder(scriptFile.folder.path+"/script-icons"); var iconFiles = scriptIconsFolder.getFiles(scriptFunction+".*"); if (iconFiles.length > 0){ var iconFile = iconFiles[0].path; }else{ // choose default toonboom "missing icon" script icon // currently svg icons seem unsupported? var iconFile = specialFolders.resource+"/icons/script/qtgeneric.svg"; } this.$.oPieButton.call(this, iconFile, "", parent); this.toolTip = this.scriptFunction; } $.oScriptButton.prototype = Object.create($.oPieButton.prototype); $.oScriptButton.prototype.activate = function(){ include(this.scriptFile); eval(this.scriptFunction)(); this.closeMenu() } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oPrefButton class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for $.oPrefButton * @name $.oPrefButton * @constructor * @classdescription This subclass of QPushButton provides an easy way to create a button to change a boolean preference. * This class is a subclass of QPushButton and all the methods from that class are available to modify this button. * @param {string} preferenceString The name of the preference to show/change. * @param {string} text A text for the button display. * @param {string} iconFile An icon path for the button. * @param {QWidget} parent The parent QWidget for the button. Automatically set during initialisation of the menu. */ $.oPrefButton = function(preferenceString, text, iconFile, parent) { this.preferenceString = preferenceString; if (typeof iconFile === 'undefined') var iconFile = specialFolders.resource+"/icons/toolproperties/settings.svg"; this.checkable = true; this.checked = preferences.getBool(preferenceString, true); $.oPieButton.call(this, iconFile, text, parent); this.toolTip = this.preferenceString; } $.oPrefButton.prototype = Object.create($.oPieButton.prototype); $.oPrefButton.prototype.activate = function(){ var value = preferences.getBool(this.preferenceString, true); this.checked != value; preferences.setBool(this.preferenceString, value); this.closeMenu() } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oStencilButton class // // // // // ////////////////////////////////////// ////////////////////////////////////// // not currently working $.oStencilButton = function(stencilName, parent) { this.stencilName = stencilName; var iconFile = specialFolders.resource+"/icons/brushpreset/default.svg"; $.oPieButton.call(this, iconFile, stencilName, parent); this.toolTip = stencilName; } $.oStencilButton.prototype = Object.create($.oPieButton.prototype); $.oStencilButton.prototype.activate = function(){ this.$.app.currentStencil = this.stencilName; this.closeMenu() } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_drawing.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library // // // Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the Mozilla Public license 2.0. // https://www.mozilla.org/en-US/MPL/2.0/ // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oDrawing class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The $.oDrawing constructor. * @constructor * @classdesc The $.oDrawing Class represents a single drawing from an element. * @param {int} name The name of the drawing. * @param {$.oElement} oElementObject The element object associated to the element. * * @property {int} name The name of the drawing. * @property {$.oElement} element The element object associated to the element. */ $.oDrawing = function (name, oElementObject) { this._type = "drawing"; this._name = name; this.element = oElementObject; this._key = Drawing.Key({ elementId: oElementObject.id, exposure: name }); //log(JSON.stringify(this._key)) this._overlay = new this.$.oArtLayer(3, this); this._lineArt = new this.$.oArtLayer(2, this); this._colorArt = new this.$.oArtLayer(1, this); this._underlay = new this.$.oArtLayer(0, this); this._artLayers = [this._underlay, this._colorArt, this._lineArt, this._overlay]; } /** * The different types of lines ends. * @name $.oDrawing#LINE_END_TYPE * @enum */ $.oDrawing.LINE_END_TYPE = { ROUND: 1, FLAT: 2, BEVEL: 3 }; /** * The reference to the art layers to use with oDrawing.setAsActiveDrawing() * @name $.oDrawing#ART_LAYER * @enum */ $.oDrawing.ART_LAYER = { OVERLAY: 8, LINEART: 4, COLORART: 2, UNDERLAY: 1 }; /** * The name of the drawing. * @name $.oDrawing#name * @type {string} */ Object.defineProperty($.oDrawing.prototype, 'name', { get: function () { return this._name; }, set: function (newName) { if (this._name == newName) return; var _column = this.element.column.uniqueName; // this ripples recursively if (Drawing.isExists(this.element.id, newName)) this.element.getDrawingByName(newName).name = newName + "_1"; column.renameDrawing(_column, this._name, newName); this._name = newName; } }) /** * The internal Id used to identify drawings. * @name $.oDrawing#id * @readonly * @type {int} */ Object.defineProperty($.oDrawing.prototype, 'id', { get: function () { return this._key.drawingId; } }) /** * The folder path of the drawing on the filesystem. * @name $.oDrawing#path * @readonly * @type {string} */ Object.defineProperty($.oDrawing.prototype, 'path', { get: function () { return fileMapper.toNativePath(Drawing.filename(this.element.id, this.name)) } }) /** * The drawing pivot of the drawing. * @name $.oDrawing#pivot * @type {$.oPoint} */ Object.defineProperty($.oDrawing.prototype, 'pivot', { get: function () { if (this.$.batchMode){ throw new Error("oDrawing.pivot is not available in batch mode.") } var _pivot = Drawing.getPivot({ "drawing": this._key }); return new this.$.oPoint(_pivot.x, _pivot.y, 0); }, set: function (newPivot) { var _pivot = { x: newPivot.x, y: newPivot.y }; Drawing.setPivot({ drawing: this._key, pivot: _pivot }); } }) /** * The color Ids present on the drawing. * @name $.oDrawing#usedColorIds * @type {string[]} */ Object.defineProperty($.oDrawing.prototype, 'usedColorIds', { get: function () { var _colorIds = DrawingTools.getDrawingUsedColors(this._key); return _colorIds; } }) /** * The bounding box of the drawing, in drawing space coordinates. (null if the drawing is empty.) * @name $.oDrawing#boundingBox * @readonly * @type {$.oBox} */ Object.defineProperty($.oDrawing.prototype, 'boundingBox', { get: function () { if (this.$.batchMode){ throw new Error("oDrawing.boudingBox is not available in batch mode.") } var _box = new this.$.oBox() for (var i in this.artLayers) { var _layerBox = this.artLayers[i].boundingBox if (_layerBox) _box.include(_layerBox) } return _box } }) /** * Access the underlay art layer's content through this object. * @name $.oDrawing#underlay * @readonly * @type {$.oArtLayer} */ Object.defineProperty($.oDrawing.prototype, 'underlay', { get: function () { return this._underlay; } }) /** * Access the color art layer's content through this object. * @name $.oDrawing#colorArt * @readonly * @type {$.oArtLayer} */ Object.defineProperty($.oDrawing.prototype, 'colorArt', { get: function () { return this._colorArt; } }) /** * Access the line art layer's content through this object. * @name $.oDrawing#lineArt * @readonly * @type {$.oArtLayer} */ Object.defineProperty($.oDrawing.prototype, 'lineArt', { get: function () { return this._lineArt; } }) /** * Access the overlay art layer's content through this object. * @name $.oDrawing#overlay * @readonly * @type {$.oArtLayer} */ Object.defineProperty($.oDrawing.prototype, 'overlay', { get: function () { return this._overlay; } }) /** * The list of artLayers of this drawing. * @name $.oDrawing#artLayers * @readonly * @type {$.oArtLayer[]} */ Object.defineProperty($.oDrawing.prototype, 'artLayers', { get: function () { return this._artLayers; } }) /** * the shapes contained amongst all artLayers of this drawing. * @name $.oDrawing#shapes * @readonly * @type {$.oShape[]} */ Object.defineProperty($.oDrawing.prototype, 'shapes', { get: function () { var _shapes = []; for (var i in this.artLayers) { _shapes = _shapes.concat(this.artLayers[i].shapes); } return _shapes; } }) /** * the strokes contained amongst all artLayers of this drawing. * @name $.oDrawing#strokes * @readonly * @type {$.oStroke[]} */ Object.defineProperty($.oDrawing.prototype, 'strokes', { get: function () { var _strokes = []; for (var i in this.artLayers) { _strokes = _strokes.concat(this.artLayers[i].strokes); } return _strokes; } }) /** * The contours contained amongst all the shapes of the artLayer. * @name $.oDrawing#contours * @type {$.oContour[]} */ Object.defineProperty($.oDrawing.prototype, 'contours', { get: function () { var _contours = [] for (var i in this.artLayers) { _contours = _contours.concat(this.artLayers[i].contours) } return _contours } }) /** * the currently active art layer of this drawing. * @name $.oDrawing#activeArtLayer * @type {$.oArtLayer} */ Object.defineProperty($.oDrawing.prototype, 'activeArtLayer', { get: function () { var settings = Tools.getToolSettings(); if (!settings.currentDrawing) return null; return this.artLayers[settings.activeArt] }, set: function (newArtLayer) { var layers = this.$.oDrawing.ART_LAYER var index = layers[newArtLayer.name.toUpperCase()] this.setAsActiveDrawing(index); } }) /** * the selected shapes on this drawing * @name $.oDrawing#selectedShapes * @type {$.oShape} */ Object.defineProperty($.oDrawing.prototype, 'selectedShapes', { get: function () { var _selectedShapes = []; for (var i in this.artLayers) { _selectedShapes = _selectedShapes.concat(this.artLayers[i].selectedShapes); } return _selectedShapes; } }) /** * the selected shapes on this drawing * @name $.oDrawing#selectedStrokes * @type {$.oShape} */ Object.defineProperty($.oDrawing.prototype, 'selectedStrokes', { get: function () { var _selectedStrokes = []; for (var i in this.artLayers) { _selectedStrokes = _selectedStrokes.concat(this.artLayers[i].selectedStrokes); } return _selectedStrokes; } }) /** * the selected shapes on this drawing * @name $.oDrawing#selectedContours * @type {$.oShape} */ Object.defineProperty($.oDrawing.prototype, 'selectedContours', { get: function () { var _selectedContours = []; for (var i in this.artLayers) { _selectedContours = _selectedContours.concat(this.artLayers[i].selectedContours); } return _selectedContours; } }) /** * all the data from this drawing. For internal use. * @name $.oDrawing#drawingData * @type {Object} * @readonly * @private */ Object.defineProperty($.oDrawing.prototype, 'drawingData', { get: function () { var _data = Drawing.query.getData({drawing: this._key}); if (!_data) throw new Error("Data unavailable for drawing "+this.name) return _data; } }) // $.oDrawing Class methods /** * Import a given file into an existing drawing. * @param {$.oFile} file The path to the file * @param {bool} [convertToTvg=false] Whether to convert the bitmap to the tvg format (this doesn't vectorise the drawing) * * @return { $.oFile } the oFile object pointing to the drawing file after being it has been imported into the element folder. */ $.oDrawing.prototype.importBitmap = function (file, convertToTvg) { var _path = new this.$.oFile(this.path); if (!(file instanceof this.$.oFile)) file = new this.$.oFile(file); if (!file.exists) throw new Error ("Can't import bitmap "+file.path+", file doesn't exist"); if (convertToTvg && file.extension.toLowerCase() != "tvg"){ // use utransform binary to perform conversion var _bin = specialFolders.bin + "/utransform"; var tempFolder = this.$.scn.tempFolder; var _convertedFilePath = tempFolder.path + "/" + file.name + ".tvg"; var _convertProcess = new this.$.oProcess(_bin, ["-outformat", "TVG", "-debug", "-scale", "1", "-bboxtvgincrease","0" , "-outfile", _convertedFilePath, file.path]); log(_convertProcess.execute()) var convertedFile = new this.$.oFile(_convertedFilePath); if (!convertedFile.exists) throw new Error ("Converting " + file.path + " to TVG has failed."); file = convertedFile; } return file.copy(_path.folder, _path.name, true); } /** * @returns {int[]} The frame numbers at which this drawing appears. */ $.oDrawing.prototype.getVisibleFrames = function () { var _element = this.element; var _column = _element.column; if (!_column) { this.$.debug("Column missing: can't get visible frames for drawing " + this.name + " of element " + _element.name, this.$.DEBUG_LEVEL.ERROR); return null; } var _frames = []; var _keys = _column.keyframes; for (var i in _keys) { if (_keys[i].value == this.name) _frames.push(_keys[i].frameNumber); } return _frames; } /** * Remove the drawing from the element. */ $.oDrawing.prototype.remove = function () { var _element = this.element; var _column = _element.column; if (!_column) { throw new Error ("Column missing: impossible to delete drawing " + this.name + " of element " + _element.name); } var _frames = _column.frames; var _lastFrame = _frames.pop(); var _thisDrawing = this; // we have to expose the drawing on the column to delete it. Exposing at the last frame... this.$.debug("deleting drawing " + _thisDrawing + " from element " + _element.name, this.$.DEBUG_LEVEL.LOG); var _lastDrawing = _lastFrame.value; var _keyFrame = _lastFrame.isKeyFrame; _lastFrame.value = _thisDrawing; column.deleteDrawingAt(_column.uniqueName, _lastFrame.frameNumber); // resetting the last frame _lastFrame.value = _lastDrawing; _lastFrame.isKeyFrame = _keyFrame; } /** * refresh the preview of the drawing. */ $.oDrawing.prototype.refreshPreview = function () { if (this.element.format == "TVG") return; var _path = new this.$.oFile(this.path); var _elementFolder = _path.folder; var _previewFiles = _elementFolder.getFiles(_path.name + "-*.tga"); for (var i in _previewFiles) { _previewFiles[i].remove(); } } /** * Change the currently active drawing. Can specify an art Layer * Doesn't work in batch mode. * @param {oDrawing.ART_LAYER} [artLayer] activate the given art layer * @return {bool} success of setting the drawing as current */ $.oDrawing.prototype.setAsActiveDrawing = function (artLayer) { if (this.$.batchMode) { this.$.debug("Setting as active drawing not available in batch mode", this.$.DEBUG_LEVEL.ERROR); return false; } var _column = this.element.column; if (!_column) { this.$.debug("Column missing: impossible to set as active drawing " + this.name + " of element " + _element.name, this.$.DEBUG_LEVEL.ERROR); return false; } var _frame = this.getVisibleFrames(); if (_frame.length == 0) { this.$.debug("Drawing not exposed: impossible to set as active drawing " + this.name + " of element " + _element.name, this.$.DEBUG_LEVEL.ERROR); return false; } DrawingTools.setCurrentDrawingFromColumnName(_column.uniqueName, _frame[0]); if (artLayer) DrawingTools.setCurrentArt(artLayer); return true; } /** * Duplicates the drawing to the given frame, and renames the drawing with the given name. * @param {int} [frame] the frame at which to create the drawing. By default, the current frame. * @param {string} [newName] A new name for the drawing. By default, the name will be the number of the frame. * @returns {$.oDrawing} the newly created drawing */ $.oDrawing.prototype.duplicate = function(frame, newName){ var _element = this.element if (typeof frame ==='undefined') var frame = this.$.scn.currentFrame; if (typeof newName === 'undefined') var newName = frame; var newDrawing = _element.addDrawing(frame, newName, this.path) return newDrawing; } /** * Replaces a color Id present on the drawing by another. * @param {string} currentId * @param {string} newId */ $.oDrawing.prototype.replaceColorId = function (currentId, newId){ DrawingTools.recolorDrawing( this._key, [{from:currentId, to:newId}]); } /** * Copies the contents of the Drawing into the clipboard * @param {oDrawing.ART_LAYER} [artLayer] Specify to only copy the contents of the specified artLayer */ $.oDrawing.prototype.copyContents = function (artLayer) { var _current = this.setAsActiveDrawing(artLayer); if (!_current) { this.$.debug("Impossible to copy contents of drawing " + this.name + " of element " + _element.name + ", the drawing cannot be set as active.", this.DEBUG_LEVEL.ERROR); return; } ToolProperties.setApplyAllArts(!artLayer); Action.perform("deselect()", "cameraView"); Action.perform("onActionChooseSelectTool()"); Action.perform("selectAll()", "cameraView"); if (Action.validate("copy()", "cameraView").enabled) Action.perform("copy()", "cameraView"); } /** * Pastes the contents of the clipboard into the Drawing * @param {oDrawing.ART_LAYER} [artLayer] Specify to only paste the contents onto the specified artLayer */ $.oDrawing.prototype.pasteContents = function (artLayer) { var _current = this.setAsActiveDrawing(artLayer); if (!_current) { this.$.debug("Impossible to copy contents of drawing " + this.name + " of element " + _element.name + ", the drawing cannot be set as active.", this.DEBUG_LEVEL.ERROR); return; } ToolProperties.setApplyAllArts(!artLayer); Action.perform("deselect()", "cameraView"); Action.perform("onActionChooseSelectTool()"); if (Action.validate("paste()", "cameraView").enabled) Action.perform("paste()", "cameraView"); } /** * Converts the line ends of the Drawing object to the defined type. * Doesn't work in batch mode. This function modifies the selection. * * @param {oDrawing.LINE_END_TYPE} endType the type of line ends to set. * @param {oDrawing.ART_LAYER} [artLayer] only apply to provided art Layer. */ $.oDrawing.prototype.setLineEnds = function (endType, artLayer) { if (this.$.batchMode) { this.$.debug("setting line ends not available in batch mode", this.DEBUG_LEVEL.ERROR); return; } var _current = this.setAsActiveDrawing(artLayer); if (!_current) { this.$.debug("Impossible to change line ends on drawing " + this.name + " of element " + _element.name + ", the drawing cannot be set as active.", this.DEBUG_LEVEL.ERROR); return; } // apply to all arts only if art layer not specified ToolProperties.setApplyAllArts(!artLayer); Action.perform("deselect()", "cameraView"); Action.perform("onActionChooseSelectTool()"); Action.perform("selectAll()", "cameraView"); var widget = $.getHarmonyUIWidget("pencilShape", "frameBrushParameters"); if (widget) { widget.onChangeTipStart(endType); widget.onChangeTipEnd(endType); widget.onChangeJoin(endType); } Action.perform("deselect()", "cameraView"); } /** * Converts the Drawing object to a string of the drawing name. * @return: { string } The name of the drawing. */ $.oDrawing.prototype.toString = function () { return this.name; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oArtLayer class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oArtLayer class. * @constructor * @classdesc $.oArtLayer represents art layers, as described by the artlayer toolbar. Access the drawing contents of the layers through this class. * @param {int} index The artLayerIndex (0: underlay, 1: line art, 2: color art, 3:overlay). * @param {$.oDrawing} oDrawingObject The oDrawing this layer belongs to. */ $.oArtLayer = function (index, oDrawingObject) { this._layerIndex = index; this._drawing = oDrawingObject; //log(this._drawing._key) this._key = { "drawing": this._drawing._key, "art": index } } /** * The name of the artLayer (lineArt, colorArt, etc) * @name $.oArtLayer#name * @type {string} */ Object.defineProperty($.oArtLayer.prototype, 'name', { get: function(){ var names = ["underlay", "colorArt", "lineArt", "overlay"]; return names[this._layerIndex]; } }) /** * The shapes contained on the artLayer. * @name $.oArtLayer#shapes * @type {$.oShape[]} */ Object.defineProperty($.oArtLayer.prototype, 'shapes', { get: function () { if (!this.hasOwnProperty("_shapes")){ var _shapesNum = Drawing.query.getNumberOfLayers(this._key); var _shapes = []; for (var i = 0; i < _shapesNum; i++) { _shapes.push(this.getShapeByIndex(i)); } this._shapes = _shapes; } return this._shapes; } }) /** * The strokes contained amongst all the shapes of the artLayer. * @name $.oArtLayer#strokes * @type {$.oStroke[]} */ Object.defineProperty($.oArtLayer.prototype, 'strokes', { get: function () { var _strokes = []; var _shapes = this.shapes; for (var i in _shapes) { _strokes = _strokes.concat(_shapes[i].strokes); } return _strokes; } }) /** * The contours contained amongst all the shapes of the artLayer. * @name $.oArtLayer#contours * @type {$.oContour[]} */ Object.defineProperty($.oArtLayer.prototype, 'contours', { get: function () { var _contours = []; var _shapes = this.shapes; for (var i in _shapes) { _contours = _contours.concat(_shapes[i].contours); } return _contours; } }) /** * The bounds of the layer, in drawing space coordinates. (null if the drawing is empty.) * @name $.oArtLayer#boundingBox * @type {$.oBox} */ Object.defineProperty($.oArtLayer.prototype, 'boundingBox', { get: function () { var _box = Drawing.query.getBox(this._key); if (_box.empty) return null; var _boundingBox = new $.oBox(_box.x0, _box.y0, _box.x1, _box.y1); return _boundingBox; } }) /** * the currently selected shapes on the ArtLayer. * @name $.oArtLayer#selectedShapes * @type {$.oShape[]} */ Object.defineProperty($.oArtLayer.prototype, 'selectedShapes', { get: function () { var _shapes = Drawing.selection.get(this._key).selectedLayers; var _artLayer = this; return _shapes.map(function (x) { return _artLayer.getShapeByIndex(x) }); } }) /** * the currently selected strokes on the ArtLayer. * @name $.oArtLayer#selectedStrokes * @type {$.oStroke[]} */ Object.defineProperty($.oArtLayer.prototype, 'selectedStrokes', { get: function () { var _shapes = this.selectedShapes; var _strokes = []; for (var i in _shapes) { _strokes = _strokes.concat(_shapes[i].strokes); } return _strokes; } }) /** * the currently selected contours on the ArtLayer. * @name $.oArtLayer#selectedContours * @type {$.oContour[]} */ Object.defineProperty($.oArtLayer.prototype, 'selectedContours', { get: function () { var _shapes = this.selectedShapes; var _contours = []; for (var i in _shapes) { _contours = _contours.concat(_shapes[i].contours); } return _contours; } }) /** * all the data from this artLayer. For internal use. * @name $.oArtLayer#drawingData * @type {$.oStroke[]} * @readonly * @private */ Object.defineProperty($.oArtLayer.prototype, 'drawingData', { get: function () { var _data = this._drawing.drawingData for (var i in _data.arts){ if (_data.arts[i].art == this._layerIndex) { return _data.arts[i]; } } // in case of empty layerArt, return a default object return {art:this._layerIndex, artName:this.name, layers:[]}; } }) /** * Draws a circle on the artLayer. * @param {$.oPoint} center The center of the circle * @param {float} radius The radius of the circle * @param {$.oLineStyle} [lineStyle] Provide a $.oLineStyle object to specify how the line will look * @param {object} [fillStyle=null] The fill information to fill the circle with. * @returns {$.oShape} the created shape containing the circle. */ $.oArtLayer.prototype.drawCircle = function(center, radius, lineStyle, fillStyle){ if (typeof fillStyle === 'undefined') var fillStyle = null; var arg = { x: center.x, y: center.y, radius: radius }; var _path = Drawing.geometry.createCircle(arg); return this.drawShape(_path, lineStyle, fillStyle); } /** * Draws the given path on the artLayer. * @param {$.oVertex[]} path an array of $.oVertex objects that describe a path. * @param {$.oLineStyle} [lineStyle] the line style to draw with. (By default, will use the current stencil selection) * @param {$.oFillStyle} [fillStyle] the fill information for the path. (By default, will use the current palette selection) * @param {bool} [polygon] Whether bezier handles should be created for the points in the path (ignores "onCurve" properties of oVertex from path) * @param {bool} [createUnderneath] Whether the new shape will appear on top or underneath the contents of the layer. (not working yet) */ $.oArtLayer.prototype.drawShape = function(path, lineStyle, fillStyle, polygon, createUnderneath){ if (typeof fillStyle === 'undefined') var fillStyle = new this.$.oFillStyle(); if (typeof lineStyle === 'undefined') var lineStyle = new this.$.oLineStyle(); if (typeof polygon === 'undefined') var polygon = false; if (typeof createUnderneath === 'undefined') var createUnderneath = false; var index = this.shapes.length; var _lineStyle = {}; if (lineStyle){ _lineStyle.pencilColorId = lineStyle.colorId; _lineStyle.thickness = { "minThickness": lineStyle.minThickness, "maxThickness": lineStyle.maxThickness, "thicknessPath": 0 }; } if (fillStyle) _lineStyle.shaderLeft = 0; if (polygon) _lineStyle.polygon = true; _lineStyle.under = createUnderneath; _lineStyle.stroke = !!lineStyle; var strokeDesciption = _lineStyle; strokeDesciption.path = path; strokeDesciption.closed = !!fillStyle; var shapeDescription = {} if (fillStyle) shapeDescription.shaders = [{ colorId : fillStyle.colorId }] shapeDescription.strokes = [strokeDesciption] if (lineStyle) shapeDescription.thicknessPaths = [lineStyle.stencil.thicknessPath] var config = { label: "draw shape", drawing: this._key.drawing, art: this._key.art, layers: [shapeDescription] }; var layers = DrawingTools.createLayers(config); var newShape = this.getShapeByIndex(index); this._shapes.push(newShape); return newShape; }; /** * Draws the given path on the artLayer. * @param {$.oVertex[]} path an array of $.oVertex objects that describe a path. * @param {$.oLineStyle} lineStyle the line style to draw with. * @returns {$.oShape} the shape containing the added stroke. */ $.oArtLayer.prototype.drawStroke = function(path, lineStyle){ return this.drawShape(path, lineStyle, null); }; /** * Draws the given path on the artLayer as a contour. * @param {$.oVertex[]} path an array of $.oVertex objects that describe a path. * @param {$.oFillStyle} fillStyle the fill style to draw with. * @returns {$.oShape} the shape newly created from the path. */ $.oArtLayer.prototype.drawContour = function(path, fillStyle){ return this.drawShape(path, null, fillStyle); }; /** * Draws a rectangle on the artLayer. * @param {float} x the x coordinate of the top left corner. * @param {float} y the y coordinate of the top left corner. * @param {float} width the width of the rectangle. * @param {float} height the height of the rectangle. * @param {$.oLineStyle} lineStyle a line style to use for the rectangle stroke. * @param {$.oFillStyle} fillStyle a fill style to use for the rectangle fill. * @returns {$.oShape} the shape containing the added stroke. */ $.oArtLayer.prototype.drawRectangle = function(x, y, width, height, lineStyle, fillStyle){ if (typeof fillStyle === 'undefined') var fillStyle = null; var path = [ {x:x,y:y,onCurve:true}, {x:x+width,y:y,onCurve:true}, {x:x+width,y:y-height,onCurve:true}, {x:x,y:y-height,onCurve:true}, {x:x,y:y,onCurve:true} ]; return this.drawShape(path, lineStyle, fillStyle); } /** * Draws a line on the artLayer * @param {$.oPoint} startPoint * @param {$.oPoint} endPoint * @param {$.oLineStyle} lineStyle * @returns {$.oShape} the shape containing the added line. */ $.oArtLayer.prototype.drawLine = function(startPoint, endPoint, lineStyle){ var path = [{x:startPoint.x,y:startPoint.y,onCurve:true},{x:endPoint.x,y:endPoint.y,onCurve:true}]; return this.drawShape(path, lineStyle, null); } /** * Removes the contents of the art layer. */ $.oArtLayer.prototype.clear = function(){ var _shapes = this.shapes; this.$.debug(_shapes, this.$.DEBUG_LEVEL.DEBUG); for (var i=_shapes.length - 1; i>=0; i--){ _shapes[i].remove(); } } /** * get a shape from the artLayer by its index * @param {int} index * * @return {$.oShape} */ $.oArtLayer.prototype.getShapeByIndex = function (index) { return new this.$.oShape(index, this); } /** * @private */ $.oArtLayer.prototype.toString = function(){ return "Object $.oArtLayer ["+this.name+"]"; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oLineStyle class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oLineStyle class. * @constructor * @classdesc * The $.oLineStyle class describes a lineStyle used to describe the appearance of strokes and perform drawing operations.
* Initializing a $.oLineStyle without any parameters attempts to get the current pencil thickness settings and color. * @param {string} colorId the color Id to paint the line with. * @param {$.oStencil} stencil the stencil object representing the thickness keys */ $.oLineStyle = function (colorId, stencil) { if (typeof minThickness === 'undefined') var minThickness = PenstyleManager.getCurrentPenstyleMinimumSize(); if (typeof maxThickness === 'undefined') { var maxThickness = PenstyleManager.getCurrentPenstyleMaximumSize(); if (!maxThickness && !minThickness) maxThickness = 1; } if (typeof stencil === 'undefined') { var stencil = new $.oStencil("", "pencil", {maxThickness:maxThickness, minThickness:minThickness, keys:[]}); } if (typeof colorId === 'undefined'){ var _palette = this.$.scn.selectedPalette; if (_palette) { var _color = this.$.scn.selectedPalette.currentColor; if (_color) { var colorId = _color.id; } else{ var colorId = "0000000000000003"; } } } this.colorId = colorId; this.stencil = stencil; // this.$.debug(colorId+" "+minThickness+" "+maxThickness+" "+stencil, this.$.DEBUG_LEVEL.DEBUG) } /** * The minimum thickness of the line using this lineStyle * @name $.oLineStyle#minThickness * @type {float} */ Object.defineProperty($.oLineStyle.prototype, "minThickness", { get: function(){ return this.stencil.minThickness; }, set: function(newMinThickness){ this.stencil.minThickness = newMinThickness; } }) /** * The minimum thickness of the line using this lineStyle * @name $.oLineStyle#maxThickness * @type {float} */ Object.defineProperty($.oLineStyle.prototype, "maxThickness", { get: function(){ return this.stencil.maxThickness; }, set: function(newMaxThickness){ this.stencil.maxThickness = newMaxThickness; } }) ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oShape class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oShape class. These types of objects are not supported for harmony versions < 16 * @constructor * @classdesc $.oShape represents shapes drawn on the art layer. Strokes, colors, line styles, can be accessed through this class.
Warning, Toonboom stores strokes by index, so stroke objects may become obsolete when modifying the contents of the drawing. * @param {int} index The index of the shape on the artLayer * @param {$.oArtLayer} oArtLayerObject The oArtLayer this layer belongs to. * * @property {int} index the index of the shape in the parent artLayer * @property {$.oArtLayer} artLayer the art layer that contains this shape */ $.oShape = function (index, oArtLayerObject) { this.index = index; this.artLayer = oArtLayerObject; } /** * the toonboom key object identifying this shape. * @name $.oShape#_key * @type {object} * @private * @readonly */ Object.defineProperty($.oShape.prototype, '_key', { get: function () { var _key = this.artLayer._key; return { drawing: _key.drawing, art: _key.art, layers: [this.index] }; } }) /** * The underlying data describing the shape. * @name $.oShape#_data * @type {$.oShape[]} * @readonly * @private */ Object.defineProperty($.oShape.prototype, '_data', { get: function () { return this.artLayer.drawingData.layers[this.index]; } }) /** * The strokes making up the shape. * @name $.oShape#strokes * @type {$.oShape[]} * @readonly */ Object.defineProperty($.oShape.prototype, 'strokes', { get: function () { if (!this.hasOwnProperty("_strokes")) { var _data = this._data; if (!_data.hasOwnProperty("strokes")) return []; var _shape = this; var _strokes = _data.strokes.map(function (x, idx) { return new _shape.$.oStroke(idx, x, _shape) }) this._strokes = _strokes; } return this._strokes; } }) /** * The contours (invisible strokes that can delimit colored areas) making up the shape. * @name $.oShape#contours * @type {$.oContour[]} * @readonly */ Object.defineProperty($.oShape.prototype, 'contours', { get: function () { if (!this.hasOwnProperty("_contours")) { var _data = this._data if (!_data.hasOwnProperty("contours")) return []; var _shape = this; var _contours = _data.contours.map(function (x, idx) { return new this.$.oContour(idx, x, _shape) }) this._contours = _contours; } return this._contours; } }) /** * The fills styles contained in the shape * @name $.oShape#fills * @type {$.oFillStyle[]} * @readonly */ Object.defineProperty($.oShape.prototype, 'fills', { get: function () { if (!this.hasOwnProperty("_fills")) { var _data = this._data if (!_data.hasOwnProperty("contours")) return []; var _fills = _data.contours.map(function (x) { return new this.$.oFillStyle(x.colorId, x.matrix) }) this._fills = _fills; } return this._fills; } }) /** * The stencils used by the shape. * @name $.oShape#stencils * @type {$.oStencil[]} * @readonly */ Object.defineProperty($.oShape.prototype, 'stencils', { get: function () { if (!this.hasOwnProperty("_stencils")) { var _data = this._data; var _shape = this; var _stencils = _data.thicknessPaths.map(function (x) { return new _shape.$.oStencil("", "pencil", x) }) this._stencils = _stencils; } return this._stencils; } }) /** * The bounding box of the shape. * @name $.oShape#bounds * @type {$.oBox} * @readonly */ Object.defineProperty($.oShape.prototype, 'bounds', { get: function () { var _bounds = new this.$.oBox(); var _contours = this.contours; var _strokes = this.strokes; for (var i in _contours){ _bounds.include(_contours[i].bounds); } for (var i in _strokes){ _bounds.include(_strokes[i].bounds); } return _bounds; } }) /** * The x coordinate of the shape. * @name $.oShape#x * @type {float} * @readonly */ Object.defineProperty($.oShape.prototype, 'x', { get: function () { return this.bounds.left; } }) /** * The x coordinate of the shape. * @name $.oShape#x * @type {float} * @readonly */ Object.defineProperty($.oShape.prototype, 'y', { get: function () { return this.bounds.top; } }) /** * The width of the shape. * @name $.oShape#width * @type {float} * @readonly */ Object.defineProperty($.oShape.prototype, 'width', { get: function () { return this.bounds.width; } }) /** * The height coordinate of the shape. * @name $.oShape#height * @type {float} * @readonly */ Object.defineProperty($.oShape.prototype, 'height', { get: function () { return this.bounds.height; } }) /** * Retrieve and set the selected status of each shape. * @name $.oShape#selected * @type {bool} */ Object.defineProperty($.oShape.prototype, 'selected', { get: function () { var _selection = this.artLayer._selectedShapes; var _indices = _selection.map(function (x) { return x.index }); return (_indices.indexOf(this.index) != -1) }, set: function (newSelectedState) { var _key = this.artLayer._key; var currentSelection = Drawing.selection.get(_key); var config = {drawing:_key.drawing, art:_key.art}; if (newSelectedState){ // adding elements to selection config.selectedLayers = currentSelection.selectedLayers.concat([this.index]); config.selectedStrokes = currentSelection.selectedStrokes; }else{ config.selectedLayers = currentSelection.selectedLayers; config.selectedStrokes = currentSelection.selectedStrokes; // remove current element from selection before setting again for (var i=config.selectedLayers.length-1; i>=0; i--){ if (config.selectedLayers[i] == this.index) config.selectedLayers.splice(i, 1); } for (var i=config.selectedStrokes.length-1; i>=0; i--){ if (config.selectedStrokes[i].layer == this.index) config.selectedStrokes.splice(i, 1); } } Drawing.selection.set(config); } }) /** * Deletes the shape from its artlayer. * Updates the index of all other oShapes on the artLayer in order to * keep tracking all of them without having to query the drawing again. */ $.oShape.prototype.remove = function(){ DrawingTools.deleteLayers(this._key); // update shapes list for this artLayer var shapes = this.artLayer.shapes for (var i in shapes){ if (i > this.index){ shapes[i].index--; } } shapes.splice(this.index, 1); } /** * Deletes the shape from its artlayer. * Warning : Because shapes are referenced by index, deleting a shape * that isn't at the end of the list of shapes from this layer * might render other shape objects from this layer obsolete. * Get them again with artlayer.shapes. * @deprecated use oShape.remove instead */ $.oShape.prototype.deleteShape = function(){ this.remove(); } /** * Gets a stroke from this shape by its index * @param {int} index * * @returns {$.oStroke} */ $.oShape.prototype.getStrokeByIndex = function (index) { return this.strokes[index]; } $.oShape.prototype.toString = function (){ return "" } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oFillStyle class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oFillStyle class. * @constructor * @classdesc * The $.oFillStyle class describes a fillStyle used to describe the appearance of filled in color areas and perform drawing operations.
* Initializing a $.oFillStyle without any parameters attempts to get the current color id. * @param {string} colorId the color Id to paint the line with. * @param {object} fillMatrix */ $.oFillStyle = function (colorId, fillMatrix) { if (typeof fillMatrix === 'undefined') var fillMatrix = { "ox": 1, "oy": 1, "xx": 1, "xy": 0, "yx": 0, "yy": 1 } if (typeof colorId === 'undefined'){ var _palette = this.$.scn.selectedPalette; if (_palette) { var _color = this.$.scn.selectedPalette.currentColor; if (_color) { var colorId = _color.id; } else{ var colorId = "0000000000000003"; } } } this.colorId = colorId; this.fillMatrix = fillMatrix; this.$.log("new fill created: " + colorId + " " + JSON.stringify(this.fillMatrix)) } $.oFillStyle.prototype.toString = function(){ return ""; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oStroke class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oStroke class. These types of objects are not supported for harmony versions < 16 * @constructor * @classdesc The $.oStroke class models the strokes that make up the shapes visible on the Drawings. * @param {int} index The index of the stroke in the shape. * @param {object} strokeObject The stroke object descriptor that contains the info for the stroke * @param {oShape} oShapeObject The parent oShape * * @property {int} index the index of the stroke in the parent shape * @property {$.oShape} shape the shape that contains this stroke * @property {$.oArtLayer} artLayer the art layer that contains this stroke */ $.oStroke = function (index, strokeObject, oShapeObject) { this.index = index; this.shape = oShapeObject; this.artLayer = oShapeObject.artLayer; this._data = strokeObject; } /** * The $.oVertex (including bezier handles) making up the complete path of the stroke. * @name $.oStroke#path * @type {$.oVertex[]} * @readonly */ Object.defineProperty($.oStroke.prototype, "path", { get: function () { // path vertices get cached if (!this.hasOwnProperty("_path")){ var _stroke = this; var _path = this._data.path.map(function(point, index){ return new _stroke.$.oVertex(_stroke, point.x, point.y, point.onCurve, index); }) this._path = _path; } return this._path; } }) /** * The oVertex that are on the stroke (Bezier handles excluded.) * The first is repeated at the last position when the stroke is closed. * @name $.oStroke#points * @type {$.oVertex[]} * @readonly */ Object.defineProperty($.oStroke.prototype, "points", { get: function () { return this.path.filter(function(x){return x.onCurve}); } }) /** * The segments making up the stroke. Each segment is a slice of the path, starting and stopping with oVertex present on the curve, and includes the bezier handles oVertex. * @name $.oStroke#segments * @type {$.oVertex[][]} * @readonly */ Object.defineProperty($.oStroke.prototype, "segments", { get: function () { var _points = this.points; var _path = this.path; var _segments = []; for (var i=0; i<_points.length-1; i++){ var _indexStart = _points[i].index; var _indexStop = _points[i+1].index; var _segment = _path.slice(_indexStart, _indexStop+1); _segments.push(_segment); } return _segments; } }) /** * The index of the stroke in the shape * @name $.oStroke#index * @type {int} */ Object.defineProperty($.oStroke.prototype, "index", { get: function () { this.$.debug("stroke object : "+JSON.stringify(this._stroke, null, " "), this.$.DEBUG_LEVEL.DEBUG); return this._data.strokeIndex; } }) /** * The style of the stroke. null if the stroke is invisible * @name $.oStroke#style * @type {$.oLineStyle} */ Object.defineProperty($.oStroke.prototype, "style", { get: function () { if (this._data.invisible){ return null; } var _colorId = this._data.pencilColorId; var _stencil = this.shape.stencils[this._data.thickness]; return new this.$.oLineStyle(_colorId, _stencil); } }) /** * whether the stroke is a closed shape. * @name $.oStroke#closed * @type {bool} */ Object.defineProperty($.oStroke.prototype, "closed", { get: function () { var _path = this.path; $.log(_path) $.log(_path[_path.length-1].strokePosition) return _path[_path.length-1].strokePosition == 0; } }) /** * The bounding box of the stroke. * @name $.oStroke#bounds * @type {$.oBox} * @readonly */ Object.defineProperty($.oStroke.prototype, 'bounds', { get: function () { var _bounds = new this.$.oBox(); // since Harmony doesn't allow natively to calculate the bounding box of a string, // we convert the bezier into a series of points and calculate the box from it var points = Drawing.geometry.discretize({precision: 1, path : this.path}); for (var j in points){ var point = points [j] var pointBox = new this.$.oBox(point.x, point.y, point.x, point.y); _bounds.include(pointBox); } return _bounds; } }) /** * The intersections on this stroke. Each intersection is an object with stroke ($.oStroke), point($.oPoint), strokePoint(float) and ownPoint(float) * One of these objects describes an intersection by giving the stroke it intersects with, the coordinates of the intersection and two values which represent the place on the stroke at which the point is placed, with a value between 0 (start) and 1(end) * @param {$.oStroke} [stroke] Specify a stroke to find intersections specific to it. If no stroke is specified, * @return {Object[]} * @example // get the selected strokes on the active drawing var sel = $.scn.activeDrawing.selectedStrokes; for (var i in sel){ // get intersections with all other elements of the drawing var intersections = sel[i].getIntersections(); for (var j in intersections){ log("intersection : " + j); log("point : " + intersections[j].point); // the point coordinates log("strokes index : " + intersections[j].stroke.index); // the index of the intersecting strokes in their own shape log("own point : " + intersections[j].ownPoint); // how far the intersection is on the stroke itself log("stroke point : " + intersections[j].strokePoint); // how far the intersection is on the intersecting stroke } } */ $.oStroke.prototype.getIntersections = function (stroke){ if (typeof stroke !== 'undefined'){ // get intersection with provided stroke only var _key = { "path0": [{ path: this.path }], "path0": [{ path: stroke.path }] }; var intersections = Drawing.query.getIntersections(_key)[0]; }else{ // get all intersections on the stroke var _drawingKey = this.artLayer._key; var _key = { "drawing": _drawingKey.drawing, "art": _drawingKey.art, "paths": [{ path: this.path }] }; var intersections = Drawing.query.getIntersections(_key)[0]; } var result = []; for (var i in intersections) { var _shape = this.artLayer.getShapeByIndex(intersections[i].layer); var _stroke = _shape.getStrokeByIndex(intersections[i].strokeIndex); for (var j in intersections[i].intersections){ var points = intersections[i].intersections[j]; var point = new this.$.oVertex(this, points.x0, points.y0, true); var intersection = { stroke: _stroke, point: point, ownPoint: points.t0, strokePoint: points.t1 }; result.push(intersection); } } return result; } /** * Adds points on the stroke without moving them, at the distance specified (0=start vertice, 1=end vertice) * @param {float[]} pointsToAdd an array of float value between 0 and the number of current points on the curve * @returns {$.oVertex[]} the points that were created (if points already existed, they will be returned) * @example // get the selected stroke and create points where it intersects with the other two strokes var sel = $.scn.activeDrawing.selectedStrokes[0]; var intersections = sel.getIntersections(); // get the two intersections var intersection1 = intersections[0]; var intersection2 = intersections[1]; // add the points at the intersections on the intersecting strokes intersection1.stroke.addPoints([intersection1.strokePoint]); intersection2.stroke.addPoints([intersection2.strokePoint]); // add the points on the stroke sel.addPoints([intersection1.ownPoint, intersection2.ownPoint]); */ $.oStroke.prototype.addPoints = function (pointsToAdd) { // calculate the points that will be created var points = Drawing.geometry.insertPoints({path:this._data.path, params : pointsToAdd}); // find the newly added points amongst the returned values for (var i in this.path){ var pathPoint = this.path[i]; // if point is found in path, it's not newly created for (var j = points.length-1; j >=0; j--){ var point = points[j]; if (point.x == pathPoint.x && point.y == pathPoint.y) { points.splice(j, 1); break } } } // actually add the points var config = this.artLayer._key; config.label = "addPoint"; config.strokes = [{layer:this.shape.index, strokeIndex:this.index, insertPoints: pointsToAdd }]; DrawingTools.modifyStrokes(config); this.updateDefinition(); var newPoints = []; // find the points for the coordinates from the new path for (var i in points){ var point = points[i]; for (var j in this.path){ var pathPoint = this.path[j]; if (point.x == pathPoint.x && point.y == pathPoint.y) newPoints.push(pathPoint); } } if (newPoints.length != pointsToAdd.length) throw new Error ("some points in " + pointsToAdd + " were not created."); return newPoints; } /** * fetch the stroke information again to update it after modifications. * @returns {object} the data definition of the stroke, for internal use. */ $.oStroke.prototype.updateDefinition = function(){ var _key = this.artLayer._key; var strokes = Drawing.query.getStrokes(_key); this._data = strokes.layers[this.shape.index].strokes[this.index]; // remove cache for path delete this._path; return this._data; } /** * Gets the closest position of the point on the stroke (float value) from a point with x and y coordinates. * @param {oPoint} point * @return {float} the strokePosition of the point on the stroke (@see $.oVertex#strokePosition) */ $.oStroke.prototype.getPointPosition = function(point){ var arg = { path : this.path, points: [{x:point.x, y:point.y}] } var strokePoint = Drawing.geometry.getClosestPoint(arg)[0].closestPoint; if (!strokePoint) return 0; // the only time this fails is when the point is the origin of the stroke return strokePoint.t; } /** * Get the coordinates of the point on the stroke from its strokePosition (@see $.oVertex#strokePosition). * Only works until a distance of 600 drawing vector units. * @param {float} position * @return {$.oPoint} an oPoint object containing the coordinates. */ $.oStroke.prototype.getPointCoordinates = function(position){ var arg = { path : this.path, params : [ position ] }; var point = Drawing.geometry.evaluate(arg)[0]; return new $.oPoint(point.x, point.y); } /** * projects a point onto a stroke and returns the closest point belonging to the stroke. * Only works until a distance of 600 drawing vector units. * @param {$.oPoint} point * @returns {$.oPoint} */ $.oStroke.prototype.getClosestPoint = function (point){ var arg = { path : this.path, points: [{x:point.x, y:point.y}] }; // returns an array of length 1 with an object containing // the original query and a "closestPoint" key that contains the information. var _result = Drawing.geometry.getClosestPoint(arg)[0]; return new $.oPoint(_result.closestPoint.x, _result.closestPoint.y); } /** * projects a point onto a stroke and returns the distance between the point and the stroke. * Only works until a distance of 600 drawing vector units. * @param {$.oPoint} point * @returns {float} */ $.oStroke.prototype.getPointDistance = function (point){ var arg = { path : this.path, points: [{x:point.x, y:point.y}] }; // returns an array of length 1 with an object containing // the original query and a "closestPoint" key that contains the information. var _result = Drawing.geometry.getClosestPoint(arg)[0].closestPoint; return _result.distance; } /** * @private */ $.oStroke.prototype.toString = function(){ return "" } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oContour class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oContour class. These types of objects are not supported for harmony versions < 16 * @constructor * @classdesc The $.oContour class models the strokes that make up the shapes visible on the Drawings.
* $.oContour is a subclass of $.oSroke and shares its properties, but represents a stroke with a fill. * @extends $.oStroke * @param {int} index The index of the contour in the shape. * @param {object} contourObject The stroke object descriptor that contains the info for the stroke * @param {oShape} oShapeObject The parent oShape * * @property {int} index the index of the stroke in the parent shape * @property {$.oShape} shape the shape that contains this stroke * @property {$.oArtLayer} artLayer the art layer that contains this stroke */ $.oContour = function (index, contourObject, oShapeObject) { this.$.oStroke.call(this, index, contourObject, oShapeObject) } $.oContour.prototype = Object.create($.oStroke.prototype) /** * The information about the fill of this contour * @name $.oContour#fill * @type {$.oFillStyle} */ Object.defineProperty($.oContour.prototype, "fill", { get: function () { var _data = this._data; return new this.$.oFillStyle(_data.colorId, _data.matrix); } }) /** * The bounding box of the contour. * @name $.oContour#bounds * @type {$.oBox} * @readonly */ Object.defineProperty($.oContour.prototype, 'bounds', { get: function () { var _data = this._data; var _box = _data.box; var _bounds = new this.$.oBox(_box.x0,_box.y0, _box.x1, _box.y1); return _bounds; } }) /** * @private */ $.oContour.prototype.toString = function(){ return "" } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oVertex class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oVertex class * @constructor * @classdesc * The $.oVertex class represents a single control point on a stroke. This class is used to get the index of the point in the stroke path sequence, as well as its position as a float along the stroke's length. * The onCurve property describes whether this control point is a bezier handle or a point on the curve. * * @param {$.oStroke} stroke the stroke that this vertex belongs to * @param {float} x the x coordinate of the vertex, in drawing space * @param {float} y the y coordinate of the vertex, in drawing space * @param {bool} onCurve whether the point is a bezier handle or situated on the curve * @param {int} index the index of the point on the stroke * * @property {$.oStroke} stroke the stroke that this vertex belongs to * @property {float} x the x coordinate of the vertex, in drawing space * @property {float} y the y coordinate of the vertex, in drawing space * @property {bool} onCurve whether the point is a bezier handle or situated on the curve * @property {int} index the index of the point on the stroke */ $.oVertex = function(stroke, x, y, onCurve, index){ if (typeof onCurve === 'undefined') var onCurve = false; if (typeof index === 'undefined') var index = stroke.getPointPosition({x:x, y:y}); this.x = x; this.y = y; this.onCurve = onCurve; this.stroke = stroke; this.index = index } /** * The position of the point on the curve, from 0 to the maximum number of points * @name $.oVertex#strokePosition * @type {float} * @readonly */ Object.defineProperty($.oVertex.prototype, 'strokePosition', { get: function(){ var _position = this.stroke.getPointPosition(this); return _position; } }) /** * The position of the point on the drawing, as an oPoint * @name $.oVertex#position * @type {oPoint} * @readonly */ Object.defineProperty($.oVertex.prototype, 'position', { get: function(){ var _position = new this.$.oPoint(this.x, this.y, 0); return _position; } }) /** * The angle of the curve going through this vertex, compared to the x axis, counterclockwise. * (In degrees, or null if the stroke is open ended on the right.) * @name $.oVertex#angleRight * @type {float} * @readonly */ Object.defineProperty($.oVertex.prototype, 'angleRight', { get: function(){ var _index = this.index+1; var _path = this.stroke.path; // get the next point by looping around if the stroke is closed if (_index >= _path.length){ if (this.stroke.closed){ var _nextPoint = _path[1]; }else{ return null; } }else{ var _nextPoint = _path[_index]; } var vector = this.$.oVector.fromPoints(this, _nextPoint); var angle = vector.degreesAngle; // if (angle < 0) angle += 360 //ensuring only positive values return angle } }) /** * The angle of the line or bezier handle on the left of this vertex, compared to the x axis, counterclockwise. * (In degrees, or null if the stroke is open ended on the left.) * @name $.oVertex#angleLeft * @type {float} * @readonly */ Object.defineProperty($.oVertex.prototype, 'angleLeft', { get: function(){ var _index = this.index-1; var _path = this.stroke.path; // get the next point by looping around if the stroke is closed if (_index < 0){ if (this.stroke.closed){ var _nextPoint = _path[_path.length-2]; //first and last points are the same when the stroke is closed }else{ return null; } }else{ var _nextPoint = _path[_index]; } var vector = this.$.oVector.fromPoints(_nextPoint, this); var angle = vector.degreesAngle; // if (angle < 0) angle += 360 //ensuring only positive values return angle } }) /** * @private */ $.oVertex.prototype.toString = function(){ return "oVertex : { index:"+this.index+", x: "+this.x+", y: "+this.y+", onCurve: "+this.onCurve+", strokePosition: "+this.strokePosition+" }" } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oStencil class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oStencil class. * @constructor * @classdesc The $.oStencil class allows access to some of the settings, name and type of the stencils available in the Harmony UI.
* Harmony stencils can have the following types: "pencil", "penciltemplate", "brush", "texture", "bitmapbrush" and "bitmaperaser". Each type is only available to specific tools.
* Access the main size information of the brush with the mainBrushShape property. * @param {string} xmlDescription the part of the penstyles.xml file between tags that describe a stencils. * @property {string} name the display name of the stencil * @property {string} type the type of stencil * @property {Object} thicknessPathObject the description of the shape of the stencil */ $.oStencil = function (name, type, thicknessPathObject) { this.name = name; this.type = type; this.thicknessPathObject = thicknessPathObject; // log("thicknessPath: " + JSON.stringify(this.thicknessPathObject)) } /** * The minimum thickness of the line using this stencil * @name $.oStencil#minThickness * @type {float} */ Object.defineProperty($.oStencil.prototype, "minThickness", { get: function(){ return this.thicknessPathObject.minThickness; }, set: function(newMinThickness){ this.thicknessPathObject.minThickness = newMinThickness; // TODO: also change in thicknessPath.keys } }) /** * The maximum thickness of the line using this stencil * @name $.oStencil#maxThickness * @type {float} */ Object.defineProperty($.oStencil.prototype, "maxThickness", { get: function(){ return this.thicknessPathObject.maxThickness; }, set: function(newMaxThickness){ this.thicknessPathObject.maxThickness = newMaxThickness; // TODO: also change in thicknessPath.keys } }) /** * Parses the xml string of the stencil xml description to create an object with all the information from it. * @private */ $.oStencil.getFromXml = function (xmlString) { var object = this.prototype.$.oStencil.getSettingsFromXml(xmlString) var maxThickness = object.mainBrushShape.sizeRange.maxValue var minThickness = object.mainBrushShape.sizeRange.minPercentage * maxThickness var thicknessPathObject = { maxThickness:maxThickness, minThickness:minThickness, keys: [ {t:0}, {t:1} ] } var _stencil = new this.$.oStencil(object.name, object.style, thicknessPathObject) for (var i in object) { try{ // attempt to set values from the object _stencil[i] = _settings[i]; }catch(err){ this.$.log(err) } } return _stencil; } /** * Parses the xml string of the stencil xml description to create an object with all the information from it. * @private */ $.oStencil.getSettingsFromXml = function (xmlString) { var object = {}; var objectRE = /<(\w+)>([\S\s]*?)<\/\1>/igm var match; var string = xmlString + ""; while (match = objectRE.exec(xmlString)) { object[match[1]] = this.prototype.$.oStencil.getSettingsFromXml(match[2]); // remove the match from the string to parse the rest as properties string = string.replace(match[0], ""); } var propsRE = /<(\w+) value="([\S\s]*?)"\/>/igm var match; while (match = propsRE.exec(string)) { // try to convert the value to int, float or bool var value = match[2]; var intValue = parseInt(value, 10); var floatValue = parseFloat(value); if (value == "true" || value == "false") { value = !!value; } else if (!isNaN(floatValue)) { if (intValue == floatValue) { value = intValue; } else { value = floatValue; } } object[match[1]] = match[2]; } return object; } $.oStencil.prototype.toString = function (){ return "$.oStencil: '" + this.name + "'" } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_element.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library v0.01 // // // Developed by Mathieu Chaptel, Chris Fourney... // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the MIT license. // https://opensource.org/licenses/mit // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oElement class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The base class for the $.oElement.
Elements hold the drawings displayed by a "READ" Node or Drawing Node. They can be used to create new drawings, rename them, etc. * @constructor * @classdesc $.oElement Class * @param {int} id The element ID. * @param {$.oColumn} oColumnObject The column object associated to the element. * * @property {int} id The element ID. * @property {$.oColumn} oColumnObject The column object associated to the element. */ $.oElement = function( id, oColumnObject){ this._type = "element"; this.id = id; this.column = oColumnObject; } // $.oElement Object Properties /** * The name of the element. * @name $.oElement#name * @type {string} */ Object.defineProperty($.oElement.prototype, 'name', { get : function(){ return element.getNameById(this.id) }, set : function(newName){ element.renameById(this.id, newName); } }) /** * The folder path of the element on the filesystem. * @name $.oElement#path * @type {string} */ Object.defineProperty($.oElement.prototype, 'path', { get : function(){ return fileMapper.toNativePath(element.completeFolder(this.id)) } }) /** * The drawings available in the element. * @name $.oElement#drawings * @type {$.oDrawing[]} */ Object.defineProperty($.oElement.prototype, 'drawings', { get : function(){ var _drawingsNumber = Drawing.numberOf(this.id); var _drawings = []; for (var i=0; i<_drawingsNumber; i++){ _drawings.push( new this.$.oDrawing(Drawing.name(this.id, i), this) ); } return _drawings; } }) /** * The file format of the element. * @name $.oElement#format * @type {string} */ Object.defineProperty($.oElement.prototype, 'format', { get : function(){ var _type = element.pixmapFormat(this.id); if (element.vectorType(this.id)) _type = "TVG"; return _type; } }) /** * The palettes linked to this element. * @name $.oElement#palettes * @type {$.oPalette[]} */ Object.defineProperty($.oElement.prototype, 'palettes', { get: function(){ var _paletteList = PaletteObjectManager.getPaletteListByElementId(this.id); var _palettes = []; for (var i=0; i<_paletteList.numPalettes; i++){ _palettes.push( new this.$.oPalette( _paletteList.getPaletteByIndex(i), _paletteList ) ); } return _palettes; } }) // $.oElement Class methods /** * Adds a drawing to the element. Provide a filename to import an external file as a drawing. * @param {int} [atFrame=1] The frame at which to add the drawing on the $.oDrawingColumn. Values < 1 create no exposure. * @param {name} [name] The name of the drawing to add. * @param {string} [filename] Optionally, a path for a drawing file to use for this drawing. Can pass an oFile object as well. * @param {bool} [convertToTvg=false] If the filename isn't a tvg file, specify if you want it converted (this doesn't vectorize the drawing). * * @return {$.oDrawing} The added drawing */ $.oElement.prototype.addDrawing = function( atFrame, name, filename, convertToTvg ){ if (typeof atFrame === 'undefined') var atFrame = 1; if (typeof filename === 'undefined') var filename = null; var nameByFrame = this.$.app.preferences.XSHEET_NAME_BY_FRAME; if (typeof name === 'undefined') var name = nameByFrame?atFrame:1; var name = name +""; // convert name to string // ensure a new drawing is always created by incrementing depending on preference var _drawingNames = this.drawings.map(function(x){return x.name}); // index of existing names var _nameFormat = /(.*?)_(\d+)$/ while (_drawingNames.indexOf(name) != -1){ if (nameByFrame || isNaN(name)){ var nameGroups = name.match(_nameFormat); if (nameGroups){ // increment the part after the underscore name = nameGroups[1] + "_" + (parseInt(nameGroups[2])+1); }else{ name += "_1"; } }else{ name = parseInt(name, 10); if (isNaN(name)) name = 0; name = name + 1 + ""; // increment and convert back to string } } if (!(filename instanceof this.$.oFile)) filename = new this.$.oFile(filename); var _fileExists = filename.exists; Drawing.create (this.id, name, _fileExists, true); var _drawing = new this.$.oDrawing( name, this ); if (_fileExists) _drawing.importBitmap(filename, convertToTvg); // place drawing on the column at the provided frame if (this.column != null || this.column != undefined && atFrame >= 1){ column.setEntry(this.column.uniqueName, 1, atFrame, name); } return _drawing; } /** * Gets a drawing object by the name. * @param {string} name The name of the drawing to get. * * @return {$.oDrawing} The drawing found by the search */ $.oElement.prototype.getDrawingByName = function ( name ){ var _drawings = this.drawings; for (var i in _drawings){ if (_drawings[i].name == name) return _drawings[i]; } return null; } /** * Link a provided palette to an element as an Element palette. * @param {$.oPalette} oPaletteObject The oPalette object to link * @param {int} [listIndex] The index in the element palette list at which to add the newly linked palette * @return {$.oPalette} The linked element palette. */ $.oElement.prototype.linkPalette = function ( oPaletteObject , listIndex){ var _paletteList = PaletteObjectManager.getPaletteListByElementId(this.id); if (typeof listIndex === 'undefined') var listIndex = _paletteList.numPalettes; var _palettePath = oPaletteObject.path.path.replace(".plt", ""); var _palette = new this.$.oPalette(_paletteList.insertPalette (_palettePath, listIndex), _paletteList); return _palette; } /** * If the palette passed as a parameter is linked to this element, it will be unlinked, and moved to the scene palette list. * @param {$.oPalette} oPaletteObject * @return {bool} the success of the unlinking process. */ $.oElement.prototype.unlinkPalette = function (oPaletteObject) { var _palettes = this.palettes; var _ids = _palettes.map(function(x){return x.id}); var _paletteId = oPaletteObject.id; var _paletteIndex = _ids.indexOf(_paletteId); if (_paletteIndex == -1) return; // palette already isn't linked var _palette = _palettes[_paletteIndex]; try{ _palette.remove(false); return true; }catch(err){ this.$.debug("Failed to unlink palette "+_palette.name+" from element "+this.name); return false; } } /** * Duplicate an element. * @param {string} [name] The new name for the duplicated element. * @return {$.oElement} The duplicate element */ $.oElement.prototype.duplicate = function(name){ if (typeof name === 'undefined') var name = this.name; var _fieldGuide = element.fieldChart(this.id); var _scanType = element.scanType(this.id); var _duplicateElement = this.$.scene.addElement(name, this.format, _fieldGuide, _scanType); var _drawings = this.drawings; var _elementFolder = new this.$.oFolder(_duplicateElement.path); for (var i in _drawings){ var _drawingFile = new this.$.oFile(_drawings[i].path); try{ var duplicateDrawing = _duplicateElement.addDrawing(0, _drawings[i].name, _drawingFile); _drawingFile.copy(_elementFolder, duplicateDrawing.name, true); }catch(err){ this.debug("could not copy drawing file "+drawingFile.name+" into element "+_duplicateElement.name, this.DEBUG_LEVEL.ERROR); } } return _duplicateElement; } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_file.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library // // // Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the Mozilla Public license 2.0. // https://www.mozilla.org/en-US/MPL/2.0/ // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oFolder class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The $.oFolder helper class -- providing utilities for folder manipulation and access. * @constructor * @classdesc $.oFolder Base Class * @param {string} path The path to the folder. * * @property {string} path The path to the folder. */ $.oFolder = function(path){ this._type = "folder"; this._path = fileMapper.toNativePath(path).split("\\").join("/"); // fix lowercase drive letter var path_components = this._path.split("/"); if (path_components[0] && about.isWindowsArch()){ // local path that starts with a drive letter path_components[0] = path_components[0].toUpperCase() this._path = path_components.join("/"); } } /** * The path of the folder. Setting a path doesn't move the file, only changes where the file object is pointing. * @name $.oFolder#path * @type {string} */ Object.defineProperty($.oFolder.prototype, 'path', { get: function(){ return this._path; }, set: function( newPath ){ this._path = fileMapper.toNativePath( newPath ).split("\\").join("/"); } }); /** * The path of the file encoded as a toonboom relative path. * @name $.oFile#toonboomPath * @readonly * @type {string} */ Object.defineProperty( $.oFolder.prototype, 'toonboomPath', { get: function(){ var _path = this._path; if (!this.$.scene.online) return _path; if (_path.slice(0,2) != ("//")) return _path; var _pathComponents = _path.replace("//", "").split("/"); var _drive = (_pathComponents[1]=="usadata000")?_pathComponents[1]:_pathComponents[1].toUpperCase(); var _path = _pathComponents.slice(2); return ["",_drive].concat(_path).join("/"); } }); /** * The name of the folder. * @name $.oFolder#name * @type {string} */ Object.defineProperty($.oFolder.prototype, 'name', { get: function(){ var _name = this.path.split("/"); _name = _name.pop(); return _name; }, set: function(newName){ this.rename(newName) } }); /** * The parent folder. * @name $.oFolder#folder * @type {$.oFolder} */ Object.defineProperty($.oFolder.prototype, 'folder', { get: function(){ var _folder = this.path.slice(0,this.path.lastIndexOf("/", this.path.length-2)); return new this.$.oFolder(_folder); } }); /** * The parent folder. * @name $.oFolder#exists * @type {string} */ Object.defineProperty($.oFolder.prototype, 'exists', { get: function(){ var dir = new QDir; dir.setPath(this.path) return dir.exists(); } }); /** * The files in the folder. * @name $.oFolder#files * @type {$.oFile[]} * @deprecated use oFolder.getFiles() instead to specify filter */ Object.defineProperty($.oFolder.prototype, 'files', { get: function(){ var dir = new QDir; dir.setPath(this.path); dir.setFilter( QDir.Files ); if (!dir.exists) throw new Error("can't get files from folder "+this.path+" because it doesn't exist"); return dir.entryList().map(function(x){return new this.$.oFile(dir.path()+"/"+x)}); } }); /** * The folders within this folder. * @name $.oFolder#folders * @type {$.oFile[]} * @deprecated oFolder.folder is the containing parent folder, it can't also mean the children folders */ Object.defineProperty($.oFolder.prototype, 'folders', { get: function(){ var _dir = new QDir; _dir.setPath(this.path); if (!_dir.exists) throw new Error("can't get files from folder "+this.path+" because it doesn't exist"); _dir.setFilter(QDir.Dirs); var _folders = _dir.entryList(); for (var i = _folders.length-1; i>=0; i--){ if (_folders[i] == "." || _folders[i] == "..") _folders.splice(i,1); } return _folders.map(function(x){return new this.$.oFolder( _dir.path() + "/" + x )}); } }); /** * The content within the folder -- both folders and files. * @name $.oFolder#content * @type {$.oFile/$.oFolder[] } */ Object.defineProperty($.oFolder.prototype, 'content', { get: function(){ var content = this.files; content = content.concat( this.folders ); return content; } }); /** * Lists the file names contained inside the folder. * @param {string} [filter] Filter wildcards for the content of the folder. * * @returns {string[]} The names of the files contained in the folder that match the filter. */ $.oFolder.prototype.listFiles = function(filter){ if (typeof filter === 'undefined') var filter = "*"; var _dir = new QDir; _dir.setPath(this.path); if (!_dir.exists) throw new Error("can't get files from folder "+this.path+" because it doesn't exist"); _dir.setNameFilters([filter]); _dir.setFilter( QDir.Files); var _files = _dir.entryList(); return _files; } /** * get the files from the folder * @param {string} [filter] Filter wildcards for the content of the folder. * * @returns {$.oFile[]} A list of files contained in the folder as oFile objects. */ $.oFolder.prototype.getFiles = function( filter ){ if (typeof filter === 'undefined') var filter = "*"; // returns the list of $.oFile in a directory that match a filter var _path = this.path; var _files = []; var _file_list = this.listFiles(filter); for( var i in _file_list){ _files.push( new this.$.oFile( _path+'/'+_file_list[i] ) ); } return _files; } /** * lists the folder names contained inside the folder. * @param {string} [filter="*.*"] Filter wildcards for the content of the folder. * * @returns {string[]} The names of the files contained in the folder that match the filter. */ $.oFolder.prototype.listFolders = function(filter){ if (typeof filter === 'undefined') var filter = "*"; var _dir = new QDir; _dir.setPath(this.path); if (!_dir.exists){ this.$.debug("can't get files from folder "+this.path+" because it doesn't exist", this.$.DEBUG_LEVEL.ERROR); return []; } _dir.setNameFilters([filter]); _dir.setFilter(QDir.Dirs); //QDir.NoDotAndDotDot not supported? var _folders = _dir.entryList(); _folders = _folders.filter(function(x){return x!= "." && x!= ".."}) return _folders; } /** * gets the folders inside the oFolder * @param {string} [filter] Filter wildcards for the content of the folder. * * @returns {$.oFolder[]} A list of folders contained in the folder, as oFolder objects. */ $.oFolder.prototype.getFolders = function( filter ){ if (typeof filter === 'undefined') var filter = "*"; // returns the list of $.oFile in a directory that match a filter var _path = this.path; var _folders = []; var _folders_list = this.listFolders(filter); for( var i in _folders_list){ _folders.push( new this.$.oFolder(_path+'/'+_folders_list[i])); } return _folders; } /** * Creates the folder, if it doesn't already exist. * @returns { bool } The existence of the newly created folder. */ $.oFolder.prototype.create = function(){ if( this.exists ){ this.$.debug("folder "+this.path+" already exists and will not be created", this.$.DEBUG_LEVEL.WARNING) return true; } var dir = new QDir(this.path); dir.mkpath(this.path); if (!this.exists) throw new Error ("folder " + this.path + " could not be created.") } /** * Copy the folder and its contents to another path. * @param {string} folderPath The path to an existing folder in which to copy this folder. (Can provide an oFolder) * @param {string} [copyName] Optionally, a name for the folder copy, if different from the original * @param {bool} [overwrite=false] Whether to overwrite the files that are already present at the copy location. * @returns {$.oFolder} the oFolder describing the newly created copy. */ $.oFolder.prototype.copy = function( folderPath, copyName, overwrite ){ // TODO: it should propagate errors from the recursive copy and throw them before ending? if (typeof overwrite === 'undefined') var overwrite = false; if (typeof copyName === 'undefined' || !copyName) var copyName = this.name; if (!(folderPath instanceof this.$.oFolder)) folderPath = new $.oFolder(folderPath); if (this.name == copyName && folderPath == this.folder.path) copyName += "_copy"; if (!folderPath.exists) throw new Error("Target folder " + folderPath +" doesn't exist. Can't copy folder "+this.path) var nextFolder = new $.oFolder(folderPath.path + "/" + copyName); nextFolder.create(); var files = this.getFiles(); for (var i in files){ var _file = files[i]; var targetFile = new $.oFile(nextFolder.path + "/" + _file.fullName); // deal with overwriting if (targetFile.exists && !overwrite){ this.$.debug("File " + targetFile + " already exists, skipping copy of "+ _file, this.$.DEBUG_LEVEL.ERROR); continue; } _file.copy(nextFolder, undefined, overwrite); } var folders = this.getFolders(); for (var i in folders){ folders[i].copy(nextFolder, undefined, overwrite); } return nextFolder; } /** * Move this folder to the specified path. * @param {string} destFolderPath The new complete path of the folder after the move * @param {bool} [overwrite=false] Whether to overwrite the target. * * @return { bool } The result of the move. * @todo implement with Robocopy */ $.oFolder.prototype.move = function( destFolderPath, overwrite ){ if (typeof overwrite === 'undefined') var overwrite = false; if (destFolderPath instanceof this.$.oFolder) destFolderPath = destFolderPath.path; var dir = new Dir; dir.path = destFolderPath; if (dir.exists && !overwrite) throw new Error("destination file "+dir.path+" exists and will not be overwritten. Can't move folder."); var path = fileMapper.toNativePath(this.path); var destPath = fileMapper.toNativePath(dir.path+"/"); var destDir = new Dir; try { destDir.rename( path, destPath ); this._path = destPath; return true; }catch (err){ throw new Error ("Couldn't move folder "+this.path+" to new address "+destPath + ": " + err); } } /** * Move this folder to a different parent folder, while retaining its content and base name. * @param {string} destFolderPath The path of the destination to copy the folder into. * @param {bool} [overwrite=false] Whether to overwrite the target. Default is false. * * @return: { bool } The result of the move. */ $.oFolder.prototype.moveToFolder = function( destFolderPath, overwrite ){ destFolderPath = (destFolderPath instanceof this.$.oFolder)?destFolderPath:new this.$.oFolder(destFolderPath) var folder = destFolderPath.path; var name = this.name; this.move(folder+"/"+name, overwrite); } /** * Renames the folder * @param {string} newName */ $.oFolder.prototype.rename = function(newName){ var destFolderPath = this.folder.path+"/"+newName if ((new this.$.oFolder(destFolderPath)).exists) throw new Error("Can't rename folder "+this.path + " to "+newName+", a folder already exists at this location") this.move(destFolderPath) } /** * Deletes the folder. * @param {bool} removeContents Whether to check if the folder contains files before deleting. */ $.oFolder.prototype.remove = function (removeContents){ if (typeof removeContents === 'undefined') var removeContents = false; if (this.listFiles.length > 0 && this.listFolders.length > 0 && !removeContents) throw new Error("Can't remove folder "+this.path+", it is not empty.") var _folder = new Dir(this.path); _folder.rmdirs(); } /** * Get the sub folder or file by name. * @param {string} name The sub name of a folder or file within a directory. * @return: {$.oFolder/$.oFile} The resulting oFile or oFolder. */ $.oFolder.prototype.get = function( destName ){ var new_path = this.path + "/" + destName; var new_folder = new $.oFolder( new_path ); if( new_folder.exists ){ return new_folder; } var new_file = new $.oFile( new_path ); if( new_file.exists ){ return new_file; } return false; } /** * Used in converting the folder to a string value, provides the string-path. * @return {string} The folder path's as a string. */ $.oFolder.prototype.toString = function(){ return this.path; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oFile class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The $.oFile helper class -- providing utilities for file manipulation and access. * @constructor * @classdesc $.oFile Base Class * @param {string} path The path to the file. * * @property {string} path The path to the file. */ $.oFile = function(path){ this._type = "file"; this._path = fileMapper.toNativePath(path).split('\\').join('/'); // fix lowercase drive letter var path_components = this._path.split("/"); if (path_components[0] && about.isWindowsArch()){ // local path that starts with a drive letter path_components[0] = path_components[0].toUpperCase() this._path = path_components.join("/"); } } /** * The name of the file with extension. * @name $.oFile#fullName * @type {string} */ Object.defineProperty($.oFile.prototype, 'fullName', { get: function(){ var _name = this.path.slice( this.path.lastIndexOf("/")+1 ); return _name; } }); /** * The name of the file without extension. * @name $.oFile#name * @type {string} */ Object.defineProperty($.oFile.prototype, 'name', { get: function(){ var _fullName = this.fullName; if (_fullName.indexOf(".") == -1) return _fullName; var _name = _fullName.slice(0, _fullName.lastIndexOf(".")); return _name; }, set: function(newName){ this.rename(newName) } }); /** * The extension of the file. * @name $.oFile#extension * @type {string} */ Object.defineProperty($.oFile.prototype, 'extension', { get: function(){ var _fullName = this.fullName; if (_fullName.indexOf(".") == -1) return ""; var _extension = _fullName.slice(_fullName.lastIndexOf(".")+1); return _extension; } }); /** * The folder containing the file. * @name $.oFile#folder * @type {$.oFolder} */ Object.defineProperty($.oFile.prototype, 'folder', { get: function(){ var _folder = this.path.slice(0,this.path.lastIndexOf("/")); return new this.$.oFolder(_folder); } }); /** * Whether the file exists already. * @name $.oFile#exists * @type {bool} */ Object.defineProperty($.oFile.prototype, 'exists', { get: function(){ var _file = new File( this.path ); return _file.exists; } }) /** * The path of the file. Setting a path doesn't move the file, only changes where the file object is pointing. * @name $.oFile#path * @type {string} */ Object.defineProperty( $.oFile.prototype, 'path', { get: function(){ return this._path; }, set: function( newPath ){ this._path = fileMapper.toNativePath( newPath ).split("\\").join("/"); } }); /** * The path of the file encoded as a toonboom relative path. * @name $.oFile#toonboomPath * @readonly * @type {string} */ Object.defineProperty( $.oFile.prototype, 'toonboomPath', { get: function(){ var _path = this._path; if (!this.$.scene.online) return _path; if (_path.slice(0,2) != ("//")) return _path; var _pathComponents = _path.replace("//", "").split("/"); var _drive = (_pathComponents[1]=="usadata000")?_pathComponents[1]:_pathComponents[1].toUpperCase(); var _path = _pathComponents.slice(2); return ["",_drive].concat(_path).join("/"); } }); //Todo, Size, Date Created, Date Modified /** * Reads the content of the file. * * @return: { string } The contents of the file. */ $.oFile.prototype.read = function() { var file = new File(this.path); try { if (file.exists) { file.open(FileAccess.ReadOnly); var string = file.read(); file.close(); return string; } } catch (err) { this.$.debug(err, this.DEBUG_LEVEL.ERROR) return null } } /** * Writes to the file. * @param {string} content Content to write to the file. * @param {bool} [append=false] Whether to append to the file. */ $.oFile.prototype.write = function(content, append){ if (typeof append === 'undefined') var append = false var file = new File(this.path); try { if (append){ file.open(FileAccess.Append); }else{ file.open(FileAccess.WriteOnly); } file.write(content); file.close(); return true } catch (err) {return false;} } /** * Moves the file to the specified path. * @param {string} folder destination folder for the file. * @param {bool} [overwrite=false] Whether to overwrite the file. * * @return: { bool } The result of the move. */ $.oFile.prototype.move = function( newPath, overwrite ){ if (typeof overwrite === 'undefined') var overwrite = false; if(newPath instanceof this.$.oFile) newPath = newPath.path; var _file = new PermanentFile(this.path); var _dest = new PermanentFile(newPath); // this.$.alert("moving "+_file.path()+" to "+_dest.path()+" exists?"+_dest.exists()) if (_dest.exists()){ if (!overwrite){ this.$.debug("destination file "+newPath+" exists and will not be overwritten. Can't move file.", this.$.DEBUG_LEVEL.ERROR); return false; }else{ _dest.remove() } } var success = _file.move(_dest); // this.$.alert(success) if (success) { this.path = _dest.path() return this; } return false; } /** * Moves the file to the folder. * @param {string} folder destination folder for the file. * @param {bool} [overwrite=false] Whether to overwrite the file. * * @return: { bool } The result of the move. */ $.oFile.prototype.moveToFolder = function( folder, overwrite ){ if (folder instanceof this.$.oFolder) folder = folder.path; var _fileName = this.fullName; return this.move(folder+"/"+_fileName, overwrite) } /** * Renames the file. * @param {string} newName the new name for the file, without the extension. * @param {bool} [overwrite=false] Whether to replace a file of the same name if it exists in the folder. * * @return: { bool } The result of the renaming. */ $.oFile.prototype.rename = function( newName, overwrite){ if (newName == this.name) return true; if (this.extension != "") newName += "."+this.extension; return this.move(this.folder.path+"/"+newName, overwrite); } /** * Copies the file to the folder. * @param {string} [folder] Content to write to the file. * @param {string} [copyName] Name of the copied file without the extension. If not specified, the copy will keep its name unless another file is present in which case it will be called "_copy" * @param {bool} [overwrite=false] Whether to overwrite the file. * * @return: { bool } The result of the copy. */ $.oFile.prototype.copy = function( destfolder, copyName, overwrite){ if (typeof overwrite === 'undefined') var overwrite = false; if (typeof copyName === 'undefined') var copyName = this.name; if (typeof destfolder === 'undefined') var destfolder = this.folder.path; var _fileName = this.fullName; if(destfolder instanceof this.$.oFolder) destfolder = destfolder.path; // remove extension from name in case user added it to the param copyName.replace ("."+this.extension, ""); if (this.name == copyName && destfolder == this.folder.path) copyName += "_copy"; var _fileName = copyName+((this.extension.length>0)?"."+this.extension:""); var _file = new PermanentFile(this.path); var _dest = new PermanentFile(destfolder+"/"+_fileName); if (_dest.exists() && !overwrite){ throw new Error("Destination file "+destfolder+"/"+_fileName+" exists and will not be overwritten. Can't copy file.", this.DEBUG_LEVEL.ERROR); } this.$.debug("copying "+_file.path()+" to "+_dest.path(), this.$.DEBUG_LEVEL.LOG) var success = _file.copy(_dest); if (!success) throw new Error ("Copy of file "+_file.path()+" to location "+_dest.path()+" has failed.", this.$.DEBUG_LEVEL.ERROR) return new this.$.oFile(_dest.path()); } /** * Removes the file. * @return: { bool } The result of the removal. */ $.oFile.prototype.remove = function(){ var _file = new PermanentFile(this.path) if (_file.exists()) return _file.remove() } /** * Parses the file as a XML and returns an object containing the values. * @example * // parses the xml file as an object with imbricated hierarchy. * // each xml node is represented by a simple object with a "children" property containing the children nodes, * // and a objectName property representing the name of the node. * // If the node has attributes, those are set as properties on the object. All values are set as strings. * * // example: parsing the shortcuts file * * var shortcutsFile = (new $.oFile(specialFolders.userConfig+"/shortcuts.xml")).parseAsXml(); * * // The returned object will always be a simple document object with a single "children" property containing the document nodes. * * var shortcuts = shortcuts.children[0].children // children[0] is the "shortcuts" parent node, we want the nodes contained within * * for (var i in shortcuts){ * log (shortcuts[i].id) * } */ $.oFile.prototype.parseAsXml = function(){ if (this.extension.toLowerCase() != "xml") return // build an object model representation of the contents of the XML by parsing it character by character var xml = this.read(); var xmlDocument = new this.$.oXml(xml); return xmlDocument; } /** * Used in converting the file to a string value, provides the string-path. * @return {string} The file path's as a string. */ $.oFile.prototype.toString = function(){ return this.path; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oXml class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oXml class. * @classdesc * The $.oXml class can be used to create an object from a xml string. It will contain a "children" property which is an array that holds all the children node from the main document. * @constructor * @param {string} xmlString the string to parse for xml content * @param {string} objectName "xmlDocument" for the top node, otherwise, the string description of the xml node (ex: ) * @property {string} objectName * @property {$.oXml[]} children */ $.oXml = function (xmlString, objectName){ if (typeof objectName === 'undefined') var objectName = "xmlDocument"; this.objectName = objectName; this.children = []; var string = xmlString+""; // matches children xml nodes, multiline or single line, and provides one group for the objectName and one for the insides to parse again. var objectRE = /<(\w+)[ >?]([\S\s]+?\/\1|[^<]+?\/)>/igm var match; while (match = objectRE.exec(xmlString)){ this.children.push(new this.$.oXml(match[2], match[1])); // remove the match from the string to parse the rest as properties string = string.replace(match[0], ""); } // matches a line with name="property" var propertyRE = /(\w+)="([^\=\<\>]+?)"/igm var match; while (match = propertyRE.exec(string)){ // set the property on the object this[match[1]] = match[2]; } } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_frame.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library // // // Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the Mozilla Public license 2.0. // https://www.mozilla.org/en-US/MPL/2.0/ // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oFrame class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oFrame. * @constructor * @classdesc Frames describe the frames of a oColumn, and allow to access the value, ease settings, as well as frameNumber. * @param {int} frameNumber The frame to which this references. * @param {oColumn} oColumnObject The column to which this frame references. * @param {int} subColumns The subcolumn index. * * @property {int} frameNumber The frame to which this references. * @property {oColumn} column The oColumnObject to which this frame references. * @property {oAttribute} attributeObject The oAttributeObject to which this frame references. * @property {int} subColumns The subcolumn index. * @example * // to access the frames of a column, simply call oColumn.frames: * var myColumn = $.scn.columns[O] // access the first column of the list of columns present in the scene * * var frames = myColumn.frames; * * // then you can iterate over them to check their properties: * * for (var i in frames){ * $.log(frames[i].isKeyframe); * $.log(frames[i].continuity); * } * * // you can get and set the value of the frame * * frames[1].value = 5; // frame array values and frameNumbers are matched, so this sets the value of frame 1 */ $.oFrame = function( frameNumber, oColumnObject, subColumns ){ this._type = "frame"; this.frameNumber = frameNumber; if( oColumnObject instanceof $.oAttribute ){ //Direct access to an attribute, when not keyable. We still provide a frame access for consistency. > MCNote ????? this.column = false; this.attributeObject = oColumnObject; }else if( oColumnObject instanceof $.oColumn ){ this.column = oColumnObject; if (this.column && typeof subColumns === 'undefined'){ var subColumns = this.column.subColumns; }else{ var subColumns = { a : 1 }; } this.attributeObject = this.column.attributeObject; } this.subColumns = subColumns; } // $.oFrame Object Properties /** * The value of the frame. Contextual to the attribute type. * @name $.oFrame#value * @type {object} * @todo Include setting values on column that don't have attributes linked? */ Object.defineProperty($.oFrame.prototype, 'value', { get : function(){ if (this.attributeObject){ this.$.debug("getting value of frame "+this.frameNumber+" through attribute object : "+this.attributeObject.keyword, this.$.DEBUG_LEVEL.LOG); return this.attributeObject.getValue(this.frameNumber); }else{ this.$.debug("getting value of frame "+this.frameNumber+" through column object : "+this.column.name, this.$.DEBUG_LEVEL.LOG); return this.column.getValue(this.frameNumber); } /* // this.$.log("Getting value of frame "+this.frameNumber+" of column "+this.column.name) if (this.attributeObject){ return this.attributeObject.getValue(this.frameNumber); }else{ this.$.debug("getting unlinked column "+this.name+" value at frame "+this.frameNumber, this.$.DEBUG_LEVEL.ERROR); this.$.debug("warning : getting a value from a column without attribute destroys value fidelity", this.$.DEBUG_LEVEL.ERROR); if (this. return column.getEntry (this.name, 1, this.frameNumber); }*/ }, set : function(newValue){ if (this.attributeObject){ return this.attributeObject.setValue(newValue, this.frameNumber); }else{ return this.column.setValue(newValue, this.frameNumber); } /*// this.$.log("Setting frame "+this.frameNumber+" of column "+this.column.name+" to value: "+newValue) if (this.attributeObject){ this.attributeObject.setValue( newValue, this.frameNumber ); }else{ this.$.debug("setting unlinked column "+this.name+" value to "+newValue+" at frame "+this.frameNumber, this.$.DEBUG_LEVEL.ERROR); this.$.debug("warning : setting a value on a column without attribute destroys value fidelity", this.$.DEBUG_LEVEL.ERROR); var _subColumns = this.subColumns; for (var i in _subColumns){ column.setEntry (this.name, _subColumns[i], this.frameNumber, newValue); } }*/ } }); /** * Whether the frame is a keyframe. * @name $.oFrame#isKeyframe * @type {bool} */ Object.defineProperty($.oFrame.prototype, 'isKeyframe', { get : function(){ if( !this.column ) return true; if( this.frameNumber == 0 ) return false; // frames array start at 0 but first index is not a real frame var _column = this.column.uniqueName; if (this.column.type == 'DRAWING' || this.column.type == 'TIMING'){ if( column.getTimesheetEntry){ return !column.getTimesheetEntry(_column, 1, this.frameNumber).heldFrame; }else{ return false; //No valid way to check for keys on a drawing without getTimesheetEntry } }else if (['BEZIER', '3DPATH', 'EASE', 'QUATERNION'].indexOf(this.column.type) != -1){ return column.isKeyFrame(_column, 1, this.frameNumber); } return false; }, set : function(keyframe){ this.$.log("setting keyframe for frame "+this.frameNumber); var col = this.column; if( !col ) return; var _column = col.uniqueName; if( col.type == "DRAWING" ){ if (keyframe){ column.addKeyDrawingExposureAt( _column, this.frameNumber ); }else{ column.removeKeyDrawingExposureAt( _column, this.frameNumber ); } }else{ if (keyframe){ //Sanity check, in certain situations, the setKeyframe resets to 0 (specifically if there is no pre-existing key elsewhere.) //This will check the value prior to the key, set the key, and enforce the value after. //var val = 0.0; // try{ var val = this.value; // }catch(err){} //this.$.log("setting keyframe for frame "+this.frameNumber); column.setKeyFrame( _column, this.frameNumber ); // try{ //var post_val = this.value; //if (val != post_val) { this.value = val; //} // }catch(err){} }else{ column.clearKeyFrame( _column, this.frameNumber ); } } } }); /** * Whether the frame is a keyframe. * @name $.oFrame#isKeyFrame * @deprecated For case consistency, keyframe will never have a capital F * @type {bool} */ Object.defineProperty($.oFrame.prototype, 'isKeyFrame', { get : function(){ return this.isKeyframe; }, set : function(keyframe){ this.$.debug("oFrame.isKeyFrame is deprecated. Use oFrame.isKeyframe instead.", this.$.DEBUG_LEVEL.ERROR); this.isKeyframe = keyframe; } }); /** * Whether the frame is a keyframe. * @name $.oFrame#isKey * @type {bool} */ Object.defineProperty($.oFrame.prototype, 'isKey', { get : function(){ return this.isKeyframe; }, set : function(keyFrame){ this.isKeyframe = keyFrame; } }); /** * The duration of the keyframe exposure of the frame. * @name $.oFrame#duration * @type {int} */ Object.defineProperty($.oFrame.prototype, 'duration', { get : function(){ var _startFrame = this.startFrame; var _sceneLength = frame.numberOf() if( !this.column ){ return _sceneLength; } // walk up the frames of the scene to the next keyFrame to determine duration var _frames = this.column.frames for (var i=this.frameNumber+1; i<_sceneLength; i++){ if (_frames[i].isKeyframe) return _frames[i].frameNumber - _startFrame; } return _sceneLength - _startFrame; }, set : function( val ){ throw "Not implemented."; } }); /** * Identifies if the frame is blank/empty. * @name $.oFrame#isBlank * @type {int} */ Object.defineProperty($.oFrame.prototype, 'isBlank', { get : function(){ var col = this.column; if( !col ){ return false; } if ( col.type != "DRAWING") return false; if( !column.getTimesheetEntry ){ return (this.value == ""); } return column.getTimesheetEntry( col.uniqueName, 1, this.frameNumber ).emptyCell; }, set : function( val ){ throw "Not implemented."; } }); /** * Identifies the starting frame of the exposed drawing. * @name $.oFrame#startFrame * @type {int} * @readonly */ Object.defineProperty($.oFrame.prototype, 'startFrame', { get : function(){ if( !this.column ){ return 1; } if (this.isKeyframe) return this.frameNumber; if (this.isBlank) return -1; var _frames = this.column.frames; for (var i=this.frameNumber-1; i>=1; i--){ if (_frames[i].isKeyframe) return _frames[i].frameNumber; } return -1; } }); /** * Returns the drawing types used in the drawing column. K = key drawings, I = inbetween, B = breakdown * @name $.oFrame#marker * @type {string} */ Object.defineProperty($.oFrame.prototype, 'marker', { get : function(){ if( !this.column ){ return ""; } var _column = this.column; if (_column.type != "DRAWING") return ""; return column.getDrawingType(_column.uniqueName, this.frameNumber); }, set: function( marker ){ if( !this.column ){ return; } var _column = this.column; if (_column.type != "DRAWING") throw "can't set 'marker' property on columns that are not 'DRAWING' type" column.setDrawingType( _column.uniqueName, this.frameNumber, marker ); } }); /** * Find the index of this frame in the corresponding columns keyframes. -1 if unavailable. * @name $.oFrame#keyframeIndex * @type {int} */ Object.defineProperty($.oFrame.prototype, 'keyframeIndex', { get : function(){ var _kf = this.column.getKeyframes().map(function(x){return x.frameNumber}); var _kfIndex = _kf.indexOf(this.frameNumber); return _kfIndex; } }); /** * Find the the nearest keyframe to this, on the left. Returns itself if it is a key. * @name $.oFrame#keyframeLeft * @type {oFrame} */ Object.defineProperty($.oFrame.prototype, 'keyframeLeft', { get : function(){ return (new this.$.oFrame(this.startFrame, this.column)); } }); /** * Find the the nearest keyframe to this, on the right. * @name $.oFrame#keyframeRight * @type {oFrame} */ Object.defineProperty($.oFrame.prototype, 'keyframeRight', { get : function(){ return (new this.$.oFrame(this.startFrame+this.duration, this.column)); } }); /** * Access the velocity value of a keyframe from a 3DPATH column. * @name $.oFrame#velocity * @type {oFrame} */ Object.defineProperty($.oFrame.prototype, 'velocity', { get : function(){ if (!this.column) return null; if (this.column.type != "3DPATH") return null; var _columnName = this.column.uniqueName; if (!this.isKeyframe) return column.getEntry(_columnName, 4, this.frameNumber); var index = this.keyframeIndex; var _y = func.pointY(_columnName, index); return _y; }, set : function(newVelocity){ var _curVelocity = this.velocity; throw new Error("setting oFrame.velocity is not yet implemented") } }); /** * Gets a general ease object for the frame, which can be used to set frames to the same ease values. ease Objects contain the following properties: * x : frame number * y : position of the value of the column or velocity for 3dpath * easeIn : a $.oPoint object representing the left handle for bezier columns, or a {point, ease} object for ease columns. * easeOut : a $.oPoint object representing the left handle for bezier columns, or a {point, ease} object for ease columns. * continuity : the type of bezier used by the point. * constant : whether the frame is interpolated or a held value. * @name $.oFrame#ease * @type {oPoint/object} */ Object.defineProperty($.oFrame.prototype, 'ease', { get : function(){ var _column = this.column; if (!_column) return null; if ( !this.isKeyFrame ) return null; if ( this.isBlank ) return null; var _columnName = _column.uniqueName; var _index = this.keyframeIndex; var ease = { x : func.pointX(_columnName, _index), y : func.pointY(_columnName, _index), constant : func.pointConstSeg(_columnName, _index), continuity : func.pointContinuity(_columnName, _index) } if( _column.easeType == "BEZIER" ){ ease.easeIn = new this.$.oPoint(func.pointHandleLeftX(_columnName, _index), func.pointHandleLeftY(_columnName, _index),0); ease.easeOut = new this.$.oPoint(func.pointHandleRightX(_columnName, _index), func.pointHandleRightY(_columnName, _index),0); } if( _column.easeType == "EASE" ){ ease.easeIn = {point:func.pointEaseIn(_columnName, _index), angle:func.angleEaseIn(_columnName, _index)}; ease.easeOut = {point:func.pointEaseOut(_columnName, _index), angle:func.angleEaseOut(_columnName, _index)}; } return ease; }, set : function(newEase){ if ( !this.isKeyFrame ) throw new Error("can't set ease on a non keyframe");; if (this.isBlank) throw new Error("can't set ease on an empty frame"); var _column = this.column; if (!_column) throw new Error ("Can't set ease on a frame without a column"); var _columnName = _column.uniqueName; if( _column.easeType == "BEZIER" ){ if (!newEase.hasOwnProperty("x")) throw new Error("Incorrect ease type for a BEZIER column"); func.setBezierPoint (_columnName, newEase.x, newEase.y, newEase.easeIn.x, newEase.easeIn.y, newEase.easeOut.x, newEase.easeOut.y, newEase.constant, newEase.continuity) } if (_column.easeType == "EASE" ){ if (!newEase.hasOwnProperty("point")) throw new Error("Incorrect ease type for a EASE column"); func.setEasePoint(_columnName, newEase.x, newEase.y, newEase.easeIn.point, newEase.easeIn.angle, newEase.easeOut.point, newEase.easeOut.angle, newEase.constant, newEase.continuity) } } }); /** * Gets the ease parameter of the segment, easing into this frame. * @name $.oFrame#easeIn * @type {oPoint/object} */ Object.defineProperty($.oFrame.prototype, 'easeIn', { get : function(){ return this.ease.easeIn; }, set : function(newEaseIn){ var _ease = this.ease; _ease.easeIn = newEaseIn; this.ease = ease; } }); /** * Gets the ease parameter of the segment, easing out of this frame. * @name $.oFrame#easeOut * @type {oPoint/object} */ Object.defineProperty($.oFrame.prototype, 'easeOut', { get : function(){ return this.ease.easeOut; }, set : function(newEaseOut){ var _ease = this.ease; _ease.easeOut = newEaseOut; this.ease = _ease; } }); /** * Determines the frame's continuity setting. Can take the values "CORNER", (two independent bezier handles on each side), "SMOOTH"(handles are aligned) or "STRAIGHT" (no handles and in straight lines). * @name $.oFrame#continuity * @type {string} */ Object.defineProperty($.oFrame.prototype, 'continuity', { get : function(){ var _frame = this.keyframeLeft; //Works on the left keyframe, in the event that this is not a keyframe itself. return _frame.ease.continuity; }, set : function( newContinuity ){ var _frame = this.keyframeLeft; //Works on the left keyframe, in the event that this is not a keyframe itself. var _ease = _frame.ease; _ease.continuity = newContinuity; _frame.ease = ease; } }); /** * Whether the frame is tweened or constant. Uses nearest keyframe if this frame isnt. * @name $.oFrame#constant * @type {string} */ Object.defineProperty($.oFrame.prototype, 'constant', { get : function(){ var _frame = this.keyframeLeft; //Works on the left keyframe, in the event that this is not a keyframe itself. return _frame.ease.constant; }, set : function( newConstant ){ if( this.column ){ var _frame = this.keyframeLeft; //Works on the left keyframe, in the event that this is not a keyframe itself. var _ease = _frame.ease; _ease.constant = newConstant; _frame.ease = _ease; } } }); /** * Identifies or sets whether there is a tween. Inverse of constant. * @name $.oFrame#tween * @type {string} */ Object.defineProperty($.oFrame.prototype, 'tween', { get : function(){ return !this.constant; }, set : function( new_tween ){ this.constant = !new_tween; } }); // oFrame Class Methods /** * Extends the frames value to the specified duration, replaces in the event that replace is specified. * @param {int} duration The duration to extend it to; if no duration specified, extends to the next available keyframe. * @param {bool} replace Setting this to false will insert frames as opposed to overwrite existing ones. */ $.oFrame.prototype.extend = function( duration, replace ){ if (typeof replace === 'undefined') var replace = true; // setting this to false will insert frames as opposed to overwrite existing ones if( !this.column ){ return; } var _frames = this.column.frames; if (typeof duration === 'undefined'){ // extend to next non blank keyframe if not set var duration = 0; var curFrameEnd = this.startFrame + this.duration; var sceneLength = this.$.scene.length; // find next non blank keyframe while ((curFrameEnd + duration) <= sceneLength && _frames[curFrameEnd + duration].isBlank){ duration ++; } } var _value = this.value; var startExtending = this.startFrame+this.duration; for (var i = 0; i * Provides a list of values similar to an array, but with simpler filtering and sorting functions provided.
* It can have any starting index and so can implement lists with a first index of 1 like the $.oColumn.frames returned value. * @param {object[]} initArray An array to initialize the list. * @param {int} [startIndex=0] The first index exposed in the list. * @param {int} [length=0] The length of the list -- the max between this value and the initial array's length is used. * @param {function} [getFunction=null] The function used to initialize the list when accessing an uninitiated element in the list.
In form function( listItem, index ){ return value; } * @param {function} [setFunction=null] The function run when setting an entry in the list.
In form function( listItem, index, value ){ return resolvedValue; } -- must return a resolved value. * @param {function} [sizeFunction=null] The function run when resizing the list.
In form function( listItem, length ){ } */ $.oList = function( initArray, startIndex, length, getFunction, setFunction, sizeFunction ){ if(typeof initArray == 'undefined') var initArray = []; if(typeof startIndex == 'undefined') var startIndex = 0; if(typeof getFunction == 'undefined') var getFunction = false; if(typeof setFunction == 'undefined') var setFunction = false; if(typeof sizeFunction == 'undefined') var sizeFunction = false; if(typeof length == 'undefined') var length = 0; //Extend the cache if the content has been provided initially. //Must be not enumerable. . . // this._initArray = initArray; // this._cache = []; // this._getFunction = getFunction; // this._setFunction = setFunction; // this._sizeFunction = sizeFunction; // this.startIndex = startIndex; // this._length = Math.max( initArray.length, startIndex+length ); // this.currentIndex = startIndex; Object.defineProperty( this, '_initArray', { enumerable : false, writable : true, value: initArray }); Object.defineProperty( this, '_cache', { enumerable : false, writable : true, value: [] }); Object.defineProperty( this, '_getFunction', { enumerable : false, writable : true, configurable: false, value: getFunction }); Object.defineProperty( this, '_setFunction', { enumerable : false, writable : true, configurable: false, value: setFunction }); Object.defineProperty( this, '_sizeFunction', { enumerable : false, writable : true, configurable: false, value: sizeFunction }); Object.defineProperty( this, 'currentIndex', { enumerable : false, writable : true, configurable: false, value: startIndex }); Object.defineProperty( this, '_startIndex', { enumerable : false, writable : true, configurable: false, value: startIndex }); Object.defineProperty( this, '_length', { enumerable : false, writable : true, configurable: false, value: Math.max( initArray.length, startIndex+length ) }); this.createGettersSetters(); } Object.defineProperty( $.oList.prototype, '_type', { enumerable : false, writable : false, configurable: false, value: 'dynList' }); /** * The next item in the list, undefined if reaching the end of the list. * @name $.oList#createGettersSetters * @private */ Object.defineProperty($.oList.prototype, 'createGettersSetters', { enumerable : false, value: function(){ { //Dynamic getter/setters. var func_get = function( listItem, index ){ if( index >= listItem._cache._length ) return null; if( listItem._cache[index].cacheAvailable ){ return listItem._cache[index].value; } if( listItem._getFunction ){ listItem._cache[index].cacheAvailable = true; listItem._cache[index].value = listItem._getFunction( listItem, index ); return listItem._cache[ index ].value; } return null; }; //Either set the cache function directly, or run the setFunction to get a value and set it. var func_set = function( listItem, index, value ){ if( index >= listItem._cache._length ){ if( listItem._sizeFunction ){ listItem.length = index+1; }else{ throw new ReferenceError( 'Index of out of range: '+index+ ' out of ' + listItem._cache._length ) } } if( listItem._setFunction ){ listItem._cache[index].cacheAvailable = true; try{ listItem._cache[index].value = listItem._setFunction( listItem, index, value ); }catch(err){} }else{ listItem._cache[index].cacheAvailable = true; listItem._cache[index].value = value; } }; var setup_length = Math.max( this._length, this._cache.length ); if( this._cache.length < setup_length ){ this._cache = this._cache.concat( new Array( setup_length-this._cache.length ) ); } for( var n=0;n=this.startIndex && n< this.length; if( currentEnumerable && !this._cache[n].enumerable ){ Object.defineProperty( this, n, { enumerable : true, configurable: true, set : eval( 'val = function(val){ return func_set( this, '+n+', val ); }' ), get : eval( 'val = function(){ return func_get( this, '+n+'); }' ) }); this._cache[n].enumerable = true; }else if( !currentEnumerable && this._cache[n].enumerable ){ Object.defineProperty( this, n, { enumerable : false, configurable: true, value : null }); this._cache[n].enumerable = false; } } } } }); /** * The startIndex of the list. * @name $.oList#startIndex * @type {int} */ Object.defineProperty( $.oList.prototype, 'startIndex', { enumerable : false, get: function(){ return this._startIndex; }, set: function( val ){ this._startIndex = val; this.currentIndex = Math.max( this.currentIndex, val ); this.createGettersSetters(); } }); /** * The length of the list. * @name $.oList#length * @function * @return {int} The length of the list, considering the startIndex. */ Object.defineProperty($.oList.prototype, 'length', { enumerable : false, get: function(){ return this._length; }, set: function( val ){ //Reset the size as needed. var new_val = val+this.startIndex; if( new_val != this._length ){ this._length = new_val; this.createGettersSetters(); } this._sizeFunction( this, this._length ); } }); /** * The first item in the list, resets the iterator to the first entry. * @name $.oList#first * @function * @return {object} The first item in the list. */ Object.defineProperty($.oList.prototype, 'first', { enumerable : false, value: function(){ this.currentIndex = this.startIndex; return this[ this.startIndex ]; } }); /** * The next item in the list, undefined if reaching the end of the list. * @name $.oList#next * @function * @return {object} Grabs the next item using the property $.oList.currentIndex, and increase the iterator * @example * var myList = new $.oList([1,2,3], 1) * * var item = myList.first(); // 1 * * while( item != undefined ){ * $.log(item) // traces the whole array one item at a time : 1,2,3 * item = myList.next(); * } */ Object.defineProperty($.oList.prototype, 'next', { enumerable : false, value: function(){ this.currentIndex++; if( this.currentIndex >= this.length ){ return; } if( !this.hasOwnProperty( this.currentIndex) ) return; // we return undefined so we can check correctly in the case of list of boolean values return this[ this.currentIndex ]; } }); /** * The index of the last valid element of the list * @name $.oList#lastIndex * @type {int} */ Object.defineProperty($.oList.prototype, 'lastIndex', { enumerable : false, get: function(){ return this.length - 1; } }); /** * Similar to Array.push. Adds the value given as parameter to the end of the oList * @name $.oList#push * @function * @param {various} newElement The value to add at the end of the oList * * @return {int} Returns the new length of the oList. */ Object.defineProperty($.oList.prototype, 'push', { enumerable : false, value : function( newElement ){ var origLength = this.length; this.length = origLength+1; this[ origLength ] = newElement; return origLength+1; } }); /** * Similar to Array.pop. Removes the last value from the array and returns it. It is then removed from the array. * @name $.oList#pop * @function * @return {int} The item popped from the back of the array. */ Object.defineProperty($.oList.prototype, 'pop', { enumerable : false, value : function( ){ var origLength = this.length; if( !this.hasOwnProperty( origLength-1 ) ){ return; } var cache = this._cache.pop(); this.length = origLength-1; return cache.value; } }); /** * Returns an oList object containing only the elements that passed the provided filter function. * @name $.oList#filterByFunction * @function * @param {function} func A function that is used to filter, returns true if it is to be kept in the list. * * @return {$.oList} The list represented as an array, filtered given the function. */ Object.defineProperty($.oList.prototype, 'filterByFunction', { enumerable : false, value : function( func ){ var _results = []; for (var i in this){ if ( func(this[i]) ){ _results.push( this[i] ); } } return new this.$.oList( _results ); } }); /** * Returns an oList object containing only the elements that have the same property value as provided. * @name $.oList#filterByProperty * @function * @param {string} property The property to find. * @param {string} search The value to search for in the property. * * @return {$.oList} The list represented as an array, filtered given its properties. * @example * var doc = $.s // grab the scene object * var nodeList = new $.oList(doc.nodes, 1) // get a list of all the nodes, with a first index of 1 * * $.log(nodeList) // outputs the list of all the node paths * * var readNodes = nodeList.filterByProperty("type", "READ") // get a new list of only the nodes of type 'READ' * * $.log(readNodes.extractProperty("name")) // prints the names of the result * */ Object.defineProperty($.oList.prototype, 'filterByProperty', { enumerable : false, value : function(property, search){ var _results = [] var _lastIndex = this.lastIndex; for (var i=this.startIndex; i < _lastIndex; i++){ // this.$.log(i+" "+(property in this[i])+" "+(this[i][property] == search)+_lastIndex) if ((property in this[i]) && (this[i][property] == search)) _results.push(this[i]) } // this.$.log(_results) return new this.$.oList(_results) } }); /** * Returns an oList object containing only the values of the specified property. * @name $.oList#extractProperty * @function * @param {string} property The property to find. * * @return {$.oList} The newly created oList object containing the property values. */ Object.defineProperty($.oList.prototype, 'extractProperty', { enumerable : false, value : function(property){ var _results = [] var _lastIndex = this.lastIndex; for (var i=this.startIndex; i < _lastIndex; i++){ _results.push(this[i][property]) } return new this.$.oList(_results) } }); /** * Returns an oList object sorted according to the values of the specified property. * @name $.oList#sortByProperty * @function * @param {string} property The property to find. * @param {bool} [ascending=true] Whether the sort is ascending/descending. * * @return {$.oList} The sorted $oList. */ Object.defineProperty($.oList.prototype, 'sortByProperty', { enumerable : false, value : function( property, ascending ){ if (typeof ascending === 'undefined') var ascending = true; var _array = this.toArray(); if (ascending){ var results = _array.sort(function (a,b){return a[property] - b[property]}); }else{ var results = _array.sort(function (a,b){return b[property] - a[property]}); } // Sort in place or return a copy? return new this.$.oList( results, this.startIndex ); } }); /** * Returns an oList object sorted according to the sorting function provided. * @name $.oList#sortByFunction * @function * @param {function} func A function that is used to sort, in form function (a,b){return a - b}. (A positive a-b value will put the element b before a) * * @return {$.oList} The sorted $oList. */ Object.defineProperty($.oList.prototype, 'sortByFunction', { enumerable : false, value : function( func ){ var _array = this.toArray(); var results = _array.sort( func ); // Sort in place or return a copy? return new this.$.oList( results, this.startIndex ); } }); // Methods must be declared as unenumerable properties this way /** * Converts the oList to an array * @name $.oList#toArray * @function * @return {object[]} The list represented as an array. */ Object.defineProperty($.oList.prototype, 'toArray', { enumerable : false, value : function(){ var _array = []; for (var i=0; i this.right) this.right = box.right; if (box.bottom > this.bottom) this.bottom = box.bottom; } /** * Checks whether the box contains another $.oBox. * @param {$.oBox} box The $.oBox to check for. * @param {bool} [partial=false] whether to accept partially contained boxes. */ $.oBox.prototype.contains = function(box, partial){ if (typeof partial === 'undefined') var partial = false; var fitLeft = (box.left >= this.left); var fitTop = (box.top >= this.top); var fitRight =(box.right <= this.right); var fitBottom = (box.bottom <= this.bottom); if (partial){ return (fitLeft || fitRight) && (fitTop || fitBottom); }else{ return fitLeft && fitRight && fitTop && fitBottom; } } /** * Adds the bounds of the nodes to the current $.oBox. * @param {oNode[]} oNodeArray An array of nodes to include in the box. */ $.oBox.prototype.includeNodes = function(oNodeArray){ // convert to array if only one node is passed if (!Array.isArray(oNodeArray)) oNodeArray = [oNodeArray]; for (var i in oNodeArray){ var _node = oNodeArray[i]; var _nodeBox = _node.bounds; this.include(_nodeBox); } } /** * @private */ $.oBox.prototype.toString = function(){ return "{top:"+this.top+", right:"+this.right+", bottom:"+this.bottom+", left:"+this.left+"}" } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oMatrix class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The $.oMatrix constructor. * @constructor * @classdesc The $.oMatrix is a subclass of the native Matrix4x4 object from Harmony. It has the same methods and properties plus the ones listed here. * @param {Matrix4x4} matrixObject a matrix object to initialize the instance from */ $.oMatrix = function(matrixObject){ Matrix4x4.constructor.call(this); if (matrixObject){ log(matrixObject) this.m00 = matrixObject.m00; this.m01 = matrixObject.m01; this.m02 = matrixObject.m02; this.m03 = matrixObject.m03; this.m10 = matrixObject.m10; this.m11 = matrixObject.m11; this.m12 = matrixObject.m12; this.m13 = matrixObject.m13; this.m20 = matrixObject.m20; this.m21 = matrixObject.m21; this.m22 = matrixObject.m22; this.m23 = matrixObject.m23; this.m30 = matrixObject.m30; this.m31 = matrixObject.m31; this.m32 = matrixObject.m32; this.m33 = matrixObject.m33; } } $.oMatrix.prototype = Object.create(Matrix4x4.prototype) /** * A 2D array that contains the values from the matrix, rows by rows. * @name $.oMatrix#values * @type {Array} */ Object.defineProperty($.oMatrix.prototype, "values", { get:function(){ return [ [this.m00, this.m01, this.m02, this.m03], [this.m10, this.m11, this.m12, this.m13], [this.m20, this.m21, this.m22, this.m23], [this.m30, this.m31, this.m32, this.m33], ] } }) /** * @private */ $.oMatrix.prototype.toString = function(){ return "< $.oMatrix object : \n"+this.values.join("\n")+">"; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oVector class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The $.oVector constructor. * @constructor * @classdesc The $.oVector is a replacement for the Vector3d objects of Harmony. * @param {float} x a x coordinate for this vector. * @param {float} y a y coordinate for this vector. * @param {float} [z=0] a z coordinate for this vector. If omitted, will be set to 0 and vector will be 2D. */ $.oVector = function(x, y, z){ if (typeof z === "undefined" || isNaN(z)) var z = 0; // since Vector3d doesn't have a prototype, we need to cheat to subclass it. this._vector = new Vector3d(x, y, z); } /** * The X Coordinate of the vector. * @name $.oVector#x * @type {float} */ Object.defineProperty($.oVector.prototype, "x", { get: function(){ return this._vector.x; }, set: function(newX){ this._vector.x = newX; } }) /** * The Y Coordinate of the vector. * @name $.oVector#y * @type {float} */ Object.defineProperty($.oVector.prototype, "y", { get: function(){ return this._vector.y; }, set: function(newY){ this._vector.y = newY; } }) /** * The Z Coordinate of the vector. * @name $.oVector#z * @type {float} */ Object.defineProperty($.oVector.prototype, "z", { get: function(){ return this._vector.z; }, set: function(newX){ this._vector.z = newX; } }) /** * The length of the vector. * @name $.oVector#length * @type {float} * @readonly */ Object.defineProperty($.oVector.prototype, "length", { get: function(){ return this._vector.length(); } }) /** * @static * A function of the oVector class (not oVector objects) that gives a vector from two points. */ $.oVector.fromPoints = function(pointA, pointB){ return new $.oVector(pointB.x-pointA.x, pointB.y-pointA.y, pointB.z-pointA.z); } /** * Adds another vector to this one. * @param {$.oVector} vector2 * @returns {$.oVector} returns itself. */ $.oVector.prototype.add = function (vector2){ this.x += vector2.x; this.y += vector2.y; this.z += vector2.z; return this; } /** * Multiply this vector coordinates by a number (scalar multiplication) * @param {float} num * @returns {$.oVector} returns itself */ $.oVector.prototype.multiply = function(num){ this.x = num*this.x; this.y = num*this.y; this.z = num*this.z; return this; } /** * The dot product of the two vectors * @param {$.oVector} vector2 a vector object. * @returns {float} the resultant vector from the dot product of the two vectors. */ $.oVector.prototype.dot = function(vector2){ var _dot = this._vector.dot(new Vector3d(vector2.x, vector2.y, vector2.z)); return _dot; } /** * The cross product of the two vectors * @param {$.oVector} vector2 a vector object. * @returns {$.oVector} the resultant vector from the dot product of the two vectors. */ $.oVector.prototype.cross = function(vector2){ var _cross = this._vector.cross(new Vector3d(vector2.x, vector2.y, vector2.z)); return new this.$.oVector(_cross.x, _cross.y, _cross.z); } /** * The projected vectors resulting from the operation * @param {$.oVector} vector2 a vector object. * @returns {$.oVector} the resultant vector from the projection of the current vector. */ $.oVector.prototype.project = function(vector2){ var _projection = this._vector.project(new Vector3d(vector2.x, vector2.y, vector2.z)); return new this.$.oVector(_projection.x, _projection.y, _projection.z); } /** * Normalize the vector. * @returns {$.oVector} returns itself after normalization. */ $.oVector.prototype.normalize = function(){ this._vector.normalize(); return this; } /** * The angle of this vector in radians. * @name $.oVector#angle * @type {float} * @readonly */ Object.defineProperty($.oVector.prototype, "angle", { get: function(){ return Math.atan2(this.y, this.x); } }) /** * The angle of this vector in degrees. * @name $.oVector#degreesAngle * @type {float} * @readonly */ Object.defineProperty($.oVector.prototype, "degreesAngle", { get: function(){ return this.angle * (180 / Math.PI); } }) /** * @private */ $.oVector.prototype.toString = function(){ return "<$.oVector ["+this.x+", "+this.y+", "+this.z+"]>"; } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_metadata.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library v0.01 // // // Developed by Mathieu Chaptel, Chris Fourney... // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the MIT license. // https://opensource.org/licenses/mit // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oMetadata class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the $.oMetadata class. * @classdesc Provides access to getting/setting metadata as an object interface.
Given a node as a source, will use provide the metadata associated to that node, * otherwise provides metadata for the scene. * @param {$.oNode} source A node as the source of the metadata-- otherwise provides the scene metadata. * @todo Need to extend this to allow node metadata. * @constructor * @example * var metadata = $.scene.getMetadata(); * metadata.create( "mySceneMetadataName", {"ref":"thisReferenceValue"} ); * metadata["mySceneMetadataName"]; //Provides: {"ref":"thisReferenceValue"} */ $.oMetadata = function( source ){ this._type = "metadata"; if( !source ){ source = 'scene'; } this.source = source; this._metadatas = {}; this.refresh(); } /** * Refreshes the preferences by re-reading the preference file and ingesting their values appropriately. They are then available as properties of this class.
* Note, any new preferences will not be available as properties until Harmony saves the preference file at exit. In order to reference new preferences, use the get function. * @name $.oMetadata#refresh * @function */ $.oMetadata.prototype.refresh = function(){ //---------------------------- //GETTER/SETTERS var set_val = function( meta, name, val ){ var metadata = meta._metadatas[ name ]; var valtype = false; var jsonify = false; switch( typeof val ){ case 'string': valtype = 'string'; break; case 'number': if( val%1.0==0.0 ){ valtype = 'int'; }else{ valtype = 'double'; } break case 'boolean': case 'undefined': case 'null': valtype = 'bool'; break case 'object': default: valtype = 'string'; jsonify = true; break } if(jsonify){ val = 'json('+JSON.stringify( val )+')'; } if( meta.source == "scene" ){ var type = false; scene.setMetadata( { "name" : name, "type" : valtype, "creator" : "OpenHarmony", "version" : "1.0", "value" : val } ); }else{ var metaAttr = this.source.attributes["meta"]; if( metaAttr ){ metaAttr[ name ] = val; } } meta.refresh(); } var get_val = function( meta, name ){ return meta._metadatas[name].value; } //Definition of properties. var getterSetter_create = function( targ, id, type, value ){ if( type == "string" ){ if( value.slice( 0, 5 ) == "json(" ){ var obj = value.slice( 5, value.length-1 ); value = JSON.parse( obj ); } } targ._metadatas[ id ] = { "value": value, "type":type }; //Create a getter/setter for it! Object.defineProperty( targ, id, { enumerable : true, configurable: true, set : eval( 'val = function(val){ set_val( targ, "'+id+'", val ); }' ), get : eval( 'val = function(){ return get_val( targ, "'+id+'"); }' ) }); } //Clear this objects previous getter/setters to make room for new ones. if( this._metadatas ){ for( n in this._metadatas ){ //Remove them if they've disappeared. Object.defineProperty( this, n, { enumerable : false, configurable: true, set : function(){}, get : function(){} }); } } this._metadatas = {}; if( this.source == "scene" ){ var metadatas = scene.metadatas(); for( var n=0;n0 ){ for( var i=0;iThe metadata is created on the source to which this metadata object references. * @name $.oMetadata#create * @param {string} name The name of the new metadata to create. * @param {object} val The value of the new metadata created. */ $.oMetadata.prototype.create = function( name, val ){ var name = name.toLowerCase(); if( this[ name ] ){ throw ReferenceError( "Metadata already exists by name: " + name ); } var valtype = false; var jsonify = false; switch( typeof val ){ case 'string': valtype = 'string'; break; case 'number': if( val%1.0==0.0 ){ valtype = 'int'; }else{ valtype = 'double'; } break case 'boolean': case 'undefined': case 'null': valtype = 'bool'; break case 'object': default: valtype = 'string'; jsonify = true; break } if(jsonify){ val = 'json('+JSON.stringify( val )+')'; } if( this.source == "scene" ){ scene.setMetadata( { "name" : name, "type" : valtype, "creator" : "OpenHarmony", "version" : "1.0", "value" : val } ); }else{ var attr = this.source.createAttribute( "meta."+name, valtype, valtype, false ); if( attr ){ attr.setValue( val, 1 ); } } this.refresh(); } /** * Removes a new metadata based on name and value.
The metadata is removed from the source to which this metadata object references. * @name $.oMetadata#remove * @param {string} name The name of the metadata to remove. */ $.oMetadata.prototype.remove = function( name ){ var name = name.toLowerCase(); if( !this.hasOwnProperty( name ) ){ return true; } var res = false; if( this.source == "scene" ){ if( !scene.removeMetadata ){ res = scene.removeMetadata( scene.metadata(name), this._metadatas[ name ].type ); }else{ throw ReferenceError( "This is supposed to exist, but doesn't seem to be available." ); } }else{ res = this.source.removeAttribute( "meta."+name ); } this.refresh(); return res; } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_misc.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library v0.01 // // // Developed by Mathieu Chaptel, Chris Fourney... // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the MIT license. // https://opensource.org/licenses/mit // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// //TODO : view.currentToolManager integration. ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oUtils class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The $.oUtils helper class -- providing generic utilities. Doesn't need instantiation. * @classdesc $.oUtils utility Class */ $.oUtils = function(){ this._type = "utils"; } /** * Finds the longest common substring between two strings. * @param {string} str1 * @param {string} str2 * @returns {string} the found string */ $.oUtils.longestCommonSubstring = function( str1, str2 ){ if (!str1 || !str2) return { length: 0, sequence: "", offset: 0 }; var sequence = "", str1Length = str1.length, str2Length = str2.length, num = new Array(str1Length), maxlen = 0, lastSubsBegin = 0; for (var i = 0; i < str1Length; i++) { var subArray = new Array(str2Length); for (var j = 0; j < str2Length; j++) subArray[j] = 0; num[i] = subArray; } var subsBegin = null; for (var i = 0; i < str1Length; i++){ for (var j = 0; j < str2Length; j++){ if (str1[i] !== str2[j]){ num[i][j] = 0; }else{ if ((i === 0) || (j === 0)){ num[i][j] = 1; }else{ num[i][j] = 1 + num[i - 1][j - 1]; } if (num[i][j] > maxlen){ maxlen = num[i][j]; subsBegin = i - num[i][j] + 1; if (lastSubsBegin === subsBegin){//if the current LCS is the same as the last time this block ran sequence += str1[i]; }else{ //this block resets the string builder if a different LCS is found lastSubsBegin = subsBegin; sequence= ""; //clear it sequence += str1.substr(lastSubsBegin, (i + 1) - lastSubsBegin); } } } } } return { length: maxlen, sequence: sequence, offset: subsBegin }; } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_network.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library v0.01 // // // Developed by Mathieu Chaptel, Chris Fourney... // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the MIT license. // https://opensource.org/licenses/mit // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oNetwork methods // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * Network helper for HTTP methods.
Available under $.network * @constructor * @classdesc Network Helper Class * @param {dom} $ The connection back to the DOM. * */ $.oNetwork = function( ){ //Expect a path for CURL. var avail_paths = [ "c:\\Windows\\System32\\curl.exe" ]; if( !about.isWindowsArch() ){ avail_paths = [ "/usr/bin/curl", "/usr/local/bin/curl" ]; } var curl_path = false; for( var n=0;nNote, Harmony has issues with HTTPS, useCurl=true prevents this * @param {string} address The address for the web query. * @param {function} callback_func Providing a callback function prevents blocking, and will respond on this function. The callback function is in form func( results ){} * @param {bool} use_json In the event of a JSON api, this will return an object converted from the returned JSON. * * @return: {string/object} The resulting object/string from the query -- otherwise a bool as false when an error occurred.. */ $.oNetwork.prototype.webQuery = function ( address, callback_func, use_json ){ if (typeof callback_func === 'undefined') var callback_func = false; if (typeof use_json === 'undefined') var use_json = false; if( this.useCurl && this.curlPath ){ try{ var cmdline = [ "-L", address ]; var p = new QProcess(); if( !callback_func ){ p.start( this.curlPath, cmdline ); p.waitForFinished( 10000 ); try{ var readOut = ( new QTextStream( p.readAllStandardOutput() ) ).readAll(); if( use_json ){ readOut = JSON.parse( readOut ); } return readOut; }catch(err){ this.$.debug( err + " ("+err.lineNumber+")", this.$.DEBUG_LEVEL["ERROR"] ); return false; } }else{ p.start( this.curlPath, cmdline ); var callback = function( status ){ var readOut = ( new QTextStream( p.readAllStandardOutput() ) ).readAll(); if( use_json ){ readOut = JSON.parse( readOut ); } callback_func( readOut ); } p["finished(int)"].connect( this, callback ); return true; } }catch( err ){ this.$.debug( err + " ("+err.lineNumber+")", this.$.DEBUG_LEVEL["ERROR"] ); return false; } }else{ System.println( callback ); var data = new QByteArray( "" ); var qurl = new QUrl( address ); var request = new QNetworkRequest( qurl ); var header = new QByteArray("text/xml;charset=ISO-8859-1"); var accessManager = new QNetworkAccessManager(); request.setHeader( QNetworkRequest.ContentTypeHeader, header ); request.setHeader( QNetworkRequest.ServerHeader, "application/json" ); request.setHeader( QNetworkRequest.ContentLengthHeader, data.size() ); request.setAttribute( QNetworkRequest.CacheLoadControlAttribute, QNetworkRequest.AlwaysNetwork ); request.setAttribute( QNetworkRequest.FollowRedirectsAttribute, true ); if( callback_func ){ replyRecd = function( reply ){ try{ var statusCode = reply.attribute( QNetworkRequest.HttpStatusCodeAttribute ); var reasonCode = reply.attribute( QNetworkRequest.HttpReasonPhraseAttribute ); if( !statusCode ){ callback_func( false ); return; } if( statusCode == 301 ){ callback_func( false ); return; } var stream = new QTextStream( reply ); var result = stream.readAll(); if( use_json ){ try{ result = JSON.parse( result ); }catch(err){ this.$.debug( err + " ("+err.lineNumber+")", this.$.DEBUG_LEVEL["ERROR"] ); callback_func( false ); } } callback_func( result ); }catch(err){ this.$.debug( err + " ("+err.lineNumber+")", this.$.DEBUG_LEVEL["ERROR"] ); callback_func( false ); } } error = function( error ){ switch( ""+error ){ case "1": // MessageBox.information( "Connection Refused" ); break; case "2": // MessageBox.information( "Remote Host Closed" ); break; case "3": // MessageBox.information( "Host Not Found" ); break; case "4": // MessageBox.information( "Timeout Error" ); break; case "5": // MessageBox.information( "Operation Cancelled" ); break; case "6": // MessageBox.information( "SSL Handshake Failed" ); break; } callback( false ); } accessManager["finished(QNetworkReply*)"].connect( this, replyRecd ); var send_reply = accessManager.get( request ); send_reply["error(QNetworkReply::NetworkError)"].connect( this, error ); return true; }else{ System.println( "STARTING" ); var wait = new QEventLoop(); var timeout = new QTimer(); timeout["timeout"].connect( this, wait["quit"] ); accessManager["finished(QNetworkReply*)"].connect( this, wait["quit"] ); var send_reply = accessManager.get( request ); timeout.start( 10000 ); wait.exec(); timeout.stop(); try{ var statusCode = send_reply.attribute( QNetworkRequest.HttpStatusCodeAttribute ); var reasonCode = send_reply.attribute( QNetworkRequest.HttpReasonPhraseAttribute ); if( !statusCode ){ return false; } if( statusCode == 301 ){ return false; } var stream = new QTextStream( send_reply ); var result = stream.readAll(); if( use_json ){ try{ result = JSON.parse( result ); }catch(err){ this.$.debug( err + " ("+err.lineNumber+")", this.$.DEBUG_LEVEL["ERROR"] ); } } return( result ); }catch(err){ System.println( err ); this.$.debug( err + " ("+err.lineNumber+")", this.$.DEBUG_LEVEL["ERROR"] ); } return( false ); } } } /** * Downloads a file from the internet at the given address
Note, only implemented with useCurl=true. * @param {string} address The address for the file to be downloaded. * @param {function} path The local file path to save the download. * @param {bool} replace Replace the file if it exists. * * @return: {string/object} The resulting object/string from the query -- otherwise a bool as false when an error occurred.. */ $.oNetwork.prototype.downloadSingle = function ( address, path, replace ){ if (typeof replace === 'undefined') var replace = false; try{ if( this.useCurl && this.curlPath ){ var file = new this.$.oFile( path ); if( file.exists ){ if( replace ){ file.remove(); }else{ this.$.debug( "File already exists- unable to replace: " + path, this.$.DEBUG_LEVEL["ERROR"] ); return false; } } var cmdline = [ "-L", "-o", path, address ]; var p = new QProcess(); p.start( this.curlPath, cmdline ); p.waitForFinished( 10000 ); var file = new this.$.oFile( path ); return file.exists; }else{ this.$.debug( "Downloads without curl are not implemented.", this.$.DEBUG_LEVEL["ERROR"] ); return false; } }catch( err ){ this.$.debug( err + " ("+err.lineNumber+")", this.$.DEBUG_LEVEL["ERROR"] ); return false; } } /** * Threads multiple downloads at a time [10 concurrent]. Downloads a from the internet at the given addresses
Note, only implemented with useCurl=true. * @param {object[]} instructions The instructions for download, in format [ { "path": localPathOnDisk, "url":"DownloadPath" } ] * @param {bool} replace Replace the file if it exists. * * @return: {bool[]} The results of the download, for each file in the instruction bool[] */ $.oNetwork.prototype.downloadMulti = function ( address_path, replace ){ if (typeof replace === 'undefined') var replace = false; var progress = new QProgressDialog(); progress.setLabelText( "Downloading files..." ); progress.show(); progress.setRange( 0, address_path.length ); var complete_process = function( val ){ } var dload_cnt = 0; try{ if( this.useCurl && this.curlPath ){ var in_proc = []; var skipped = []; for( var x=0;x= 10 ){ //Allow 10 concurrent processes. var procs = []; for( var n=0;n 0 ){ procs.push( in_proc[n] ); }else{ dload_cnt++; progress.setValue( dload_cnt ); } } in_proc = procs; } var file = new this.$.oFile( path ); if( file.exists ){ if( replace ){ file.remove(); }else{ this.$.debug( "File already exists- unable to replace: " + path, this.$.DEBUG_LEVEL["ERROR"] ); skipped[x] == true; continue; } } var cmdline = [ "-L", "-o", path, url ]; var p = new QProcess(); p["finished(int)"].connect( this, complete_process ); p.start( this.curlPath, cmdline ); in_proc.push( p ); progress.setLabelText( "Downloading file: "+path ); QCoreApplication.processEvents(); }catch(err){ this.$.debug( err + " : " + err.lineNumber + " : " + err.fileName, this.$.DEBUG_LEVEL["ERROR"] ); } } while( in_proc.length > 0 ){ //Allow 5 concurrent processes. var procs = []; for( var n=0;n 0 ){ procs.push( in_proc[n] ); }else{ dload_cnt++; progress.setValue( dload_cnt ); } progress.setLabelText( "Downloading "+in_proc.length+" File(s)" ); } in_proc = procs; } progress.accept(); var file_results = []; for( var x=0;x * It holds the value of its position in the node view, and functions to link to other nodes, as well as set the attributes of the node.

* It uses a cache system, so a node for a given path will only be created once.
* If the nodes change path through other means than the openHarmony functions during the execution of the script, use oNode.invalidateCache() to create new nodes again.

* This constructor should not be invoqued by users, who should use $.scene.getNodeByPath() or $.scene.root.getNodeByName() instead. * @constructor * @param {string} path Path to the node in the network. * @param {$.oScene} [oSceneObject] Access to the oScene object of the DOM. * @see NodeType * @example * // To grab a node object from the scene, it's possible to create a new node object by calling the constructor: * var myNode = new $.oNode("Top/Drawing", $.scn) * * // However, most nodes will be grabbed directly from the scene object. * var doc = $.scn * var nodes = doc.nodes; // grabs the list of all the nodes in the scene * * // It's possible to grab a single node from the path in the scene * var myNode = doc.getNodeByPath("Top/Drawing") * var myNode = doc.$node("Top/Drawing") // short synthax but same function * * // depending on the type of node, oNode objects returned by these functions can actually be an instance the subclasses * // oDrawingNode, oGroupNode, oPegNode... * * $.log(myNode instanceof $.oNode) // true * $.log(myNode instanceof $.oDrawingNode) // true * * // These other subclasses of nodes have other methods that are only shared by nodes of a certain type. * * // Not documented in this class, oNode objects have attributes which correspond to the values visible in the Layer Properties window. * // The attributes values can be accessed and set by using the dot notation on the oNode object: * * myNode.can_animate = false; * myNode.position.separate = true; * myNode.position.x = 10; * * // To access the oAttribute objects in the node, call the oNode.attributes object that contains them * * var attributes = myNode.attributes; */ $.oNode = function( path, oSceneObject ){ var instance = this.$.getInstanceFromCache.call(this, path); if (instance) return instance; this._path = path; this.type = node.type(this.path); this.scene = (typeof oSceneObject === 'undefined')?this.$.scene:oSceneObject; this._type = 'node'; this.refreshAttributes(); } /** * Initialize the attribute cache. * @private */ $.oNode.prototype.attributesBuildCache = function (){ //Cache time can be used at later times, to check for auto-rebuild of caches. Not yet implemented. this._cacheTime = (new Date()).getTime(); var _attributesList = node.getAttrList( this.path, 1 ); var _attributes = {}; for (var i in _attributesList){ var _attribute = new this.$.oAttribute(this, _attributesList[i]); var _keyword = _attribute.keyword; _attributes[_keyword] = _attribute; } this._attributes_cached = _attributes; } /** * Private function to create attributes setters and getters as properties of the node * @private */ $.oNode.prototype.setAttrGetterSetter = function (attr, context){ if (typeof context === 'undefined') context = this; // this.$.debug("Setting getter setters for attribute: "+attr.keyword+" of node: "+this.name, this.$.DEBUG_LEVEL.DEBUG) var _keyword = attr.shortKeyword; Object.defineProperty( context, _keyword, { enumerable : true, configurable : true, get : function(){ // MessageLog.trace("getting attribute "+attr.keyword+". animated: "+(attr.column != null)) var _subAttrs = attr.subAttributes; if (_subAttrs.length == 0){ // if attribute has animation, return the frames if (attr.column != null) return attr.frames; // otherwise return the value var _value = attr.getValue(); }else{ // if there are subattributes, create getter setters for each on the returned object // this means every result of attr.getValue must be an object. // For attributes that have a string return value, attr.getValue() actually returns a fake string object // which is an object with a value property and a toString() method returning the value. var _value = (attr.column != null)?new this.$.oList(attr.frames, 1):attr.getValue(); for (var i in _subAttrs){ this.setAttrGetterSetter( _subAttrs[i], _value ); } } return _value; }, set : function(newValue){ // this.$.debug("setting attribute through getter setter "+attr.keyword+" to value: "+newValue, this.$.DEBUG_LEVEL.DEBUG) // if attribute has animation, passed value must be a frame object var _subAttrs = attr.subAttributes; // setting the attribute directly if no subattributes are present, or if value is a color (exception) if (_subAttrs.length == 0 || attr.type == "COLOR"){ if (attr.column != null) { if (!newValue.hasOwnProperty("frameNumber")) { // fallback to set frame 1 newValue = {value:newValue, frameNumber:1}; } attr.setValue(newValue.value, newValue.frameNumber) }else{ return attr.setValue(newValue) } }else{ var _frame = undefined; var _value = newValue; // dealing with value being an object with frameNumber for animated values if (attr.column != null) { if (!(newValue instanceof oFrame)) { // fallback to set frame 1 newValue = {value:newValue, frameNumber:1}; } _frame = newValue.frameNumber; _value = newValue.value; } // setting non animated attribute value for (var i in _subAttrs){ // set each subAttr individually based on corresponding values in the provided object var _keyword = _subAttrs[i].shortKeyword; if (_value.hasOwnProperty(_keyword)) _subAttrs[i].setValue(_value[_keyword], _frame); } } } }); }; /** * The derived path to the node. * @deprecated use oNode.path instead * @name $.oNode#fullPath * @readonly * @type {string} */ Object.defineProperty($.oNode.prototype, 'fullPath', { get : function( ){ return this._path; } }); /** * The path of the node (includes all groups from 'Top' separated by forward slashes). * To change the path of a node, use oNode.moveToGroup() * @name $.oNode#path * @type {string} * @readonly */ Object.defineProperty($.oNode.prototype, 'path', { get : function( ){ return this._path; } }); /** * The type of the node. * @name $.oNode#type * @readonly * @type {string} */ Object.defineProperty( $.oNode.prototype, 'type', { get : function( ){ return node.type( this.path ); } }); /** * Is the node a group? * @name $.oNode#isGroup * @readonly * @deprecated check if the node is an instance of oGroupNode instead * @type {bool} */ Object.defineProperty($.oNode.prototype, 'isGroup', { get : function( ){ if( this.root ){ //in a sense, its a group. return true; } return node.isGroup( this.path ); } }); /** * The $.oNode objects contained in this group. This is deprecated and was moved to oGroupNode * @DEPRECATED Use oGroupNode.children instead. * @name $.oNode#children * @readonly * @type {$.oNode[]} */ Object.defineProperty($.oNode.prototype, 'children', { get : function( ){ if( !this.isGroup ){ return []; } var _children = []; var _subnodes = node.subNodes( this.path ); for( var n=0; n<_subnodes.length; n++ ){ _children.push( this.scene.getNodeByPath( _subnodes[n] ) ); } return _children; }, set : function( arr_children ){ //Consider a way to have this group adopt the children, move content here? //this may be a bit tough to extend. } }); /** * Does the node exist? * @name $.oNode#exists * @type {bool} * @readonly */ Object.defineProperty($.oNode.prototype, 'exists', { get : function(){ if( this.type ){ return true; }else{ return false; } } }); /** * Is the node selected? * @name $.oNode#selected * @type {bool} */ Object.defineProperty($.oNode.prototype, 'selected', { get : function(){ for( var n=0;n 0 ){ for (var j = 0; j < node.numberOfOutputLinks(this.path, i); j++){ lookup_list.push( [i,j] ); } }else{ lookup_list.push( [i,0] ); } } var newList = new this.$.oList( [], 0, lookup_list.length, function( listItem, index ){ return new this.$.oNodeLink( nodeRef, lookup_list[index][0], false, false, lookup_list[index][1] ); }, function(){ throw new ReferenceError("Unable to set inLinks"); }, false ); return newList; } }); /** * The list of nodes connected to the inport of this node, as a flat list, in order of inport. * @name $.oNode#linkedOutNodes * @readonly * @type {$.oNode[]} */ Object.defineProperty($.oNode.prototype, 'linkedOutNodes', { get: function(){ var _outNodes = this.getOutLinks().map(function(x){return x.inNode}); return _outNodes; } }) /** * The list of nodes connected to the inport of this node, as a flat list, in order of inport. * @name $.oNode#linkedInNodes * @readonly * @type {$.oNode[]} */ Object.defineProperty($.oNode.prototype, 'linkedInNodes', { get: function(){ var _inNodes = this.getInLinks().map(function(x){return x.outNode}); return _inNodes } }) /** * The list of nodes connected to the inport of this node, in order of inport. Similar to oNode.inNodes * @name $.oNode#ins * @readonly * @type {$.oNode[]} * @deprecated alias for deprecated oNode.inNodes property */ Object.defineProperty($.oNode.prototype, 'ins', { get : function(){ return this.inNodes; } }); /** * The list of nodes connected to the outport of this node, in order of outport and links. Similar to oNode.outNodes * @name $.oNode#outs * @readonly * @type {$.oNode[][]} * @deprecated alias for deprecated oNode.outNodes property */ Object.defineProperty($.oNode.prototype, 'outs', { get : function(){ return this.outNodes; } }); /** * An object containing all attributes of this node. * @name $.oNode#attributes * @readonly * @type {oAttribute} * @example * // You can get access to the actual oAttribute object for a node parameter by using the dot notation: * * var myNode = $.scn.$node("Top/Drawing") * var drawingAttribute = myNode.attributes.drawing.element * * // from there, it's possible to set/get the value of the attribute, get the column, the attribute keyword etc. * * drawingAttribute.setValue ("1", 5); // creating an exposure of drawing 1 at frame 5 * var drawingColumn = drawingAttribute.column; // grabbing the column linked to the attribute that holds all the animation * $.log(drawingAttribute.keyword); // "DRAWING.ELEMENT" * * // for a more direct way to access an attribute, it's possible to also call: * * var drawingAttribute = myNode.getAttributeByName("DRAWING.ELEMENT"); */ Object.defineProperty($.oNode.prototype, 'attributes', { get : function(){ return this._attributes_cached; } }); /** * The bounds of the node rectangle in the node view. * @name $.oNode#bounds * @readonly * @type {oBox} */ Object.defineProperty( $.oNode.prototype, 'bounds', { get : function(){ return new this.$.oBox(this.x, this.y, this.x+this.width, this.y+this.height); } }); /** * The transformation matrix of the node at the currentFrame. * @name $.oNode#matrix * @readonly * @type {oMatrix} */ Object.defineProperty( $.oNode.prototype, 'matrix', { get : function(){ return this.getMatrixAtFrame(this.scene.currentFrame); } }); /** * The list of all columns linked across all the attributes of this node. * @name $.oNode#linkedColumns * @readonly * @type {oColumn[]} */ Object.defineProperty($.oNode.prototype, 'linkedColumns', { get : function(){ var _attributes = this.attributes; var _columns = []; for (var i in _attributes){ _columns = _columns.concat(_attributes[i].getLinkedColumns()); } return _columns; } }) /** * Whether the node can create new in-ports. * @name $.oNode#canCreateInPorts * @readonly * @type {bool} */ Object.defineProperty($.oNode.prototype, 'canCreateInPorts', { get : function(){ return ["COMPOSITE", "GROUP", "MultiLayerWrite", "TransformGate", "TransformationSwitch", "DeformationCompositeModule", "MATTE_COMPOSITE", "COMPOSITE_GENERIC", "ParticleBkerComposite", "ParticleSystemComposite", "ParticleRegionComposite", "PointConstraintMulti", "MULTIPORT_OUT"] .indexOf(this.type) != -1; } }) /** * Whether the node can create new out-ports. * @name $.oNode#canCreateOutPorts * @readonly * @type {bool} */ Object.defineProperty($.oNode.prototype, 'canCreateOutPorts', { get : function(){ return ["GROUP", "MULTIPORT_IN"] .indexOf(this.type) != -1; } }) /** * Returns the number of links connected to an in-port * @param {int} inPort the number of the port to get links from. */ $.oNode.prototype.getInLinksNumber = function(inPort){ if (this.inPorts < inPort) return null; return node.isLinked(this.path, inPort)?1:0; } /** * Returns the oLink object representing the connection of a specific inPort * @param {int} inPort the number of the port to get links from. * @return {$.oLink} the oLink Object representing the link connected to the inport */ $.oNode.prototype.getInLink = function(inPort){ if (this.inPorts < inPort) return null; var _info = node.srcNodeInfo(this.path, inPort); // this.$.log(this.path+" "+inPort+" "+JSON.stringify(_info)) if (!_info) return null; var _inNode = this.scene.getNodeByPath(_info.node); var _inLink = new this.$.oLink(_inNode, this, _info.port, inPort, _info.link, true); // this.$.log("inLink: "+_inLink) return _inLink; } /** * Returns all the valid oLink objects describing the links that are connected into this node. * @return {$.oLink[]} An array of $.oLink objects. */ $.oNode.prototype.getInLinks = function(){ var _inPorts = this.inPorts; var _inLinks = []; for (var i = 0; i<_inPorts; i++){ var _link = this.getInLink(i); if (_link != null) _inLinks.push(_link); } return _inLinks; } /** * Returns a free unconnected in-port * @param {bool} [createNew=true] Whether to allow creation of new ports * @return {int} the port number that isn't connected */ $.oNode.prototype.getFreeInPort = function(createNew){ if (typeof createNew === 'undefined') var createNew = true; var _inPorts = this.inPorts; for (var i=0; i<_inPorts; i++){ if (this.getInLinksNumber(i) == 0) return i; } if (_inPorts == 0 && this.canCreateInPorts) return 0; if (createNew && this.canCreateInPorts) return _inPorts; this.$.debug("can't get free inPort for node "+this.path, this.$.DEBUG_LEVEL.ERROR); return null } /** * Links this node's inport to the given module, at the inport and outport indices. * @param {$.oNode} nodeToLink The node to link this one's inport to. * @param {int} [ownPort] This node's inport to connect. * @param {int} [destPort] The target node's outport to connect. * @param {bool} [createPorts] Whether to create new ports on the nodes. * * @return {bool} The result of the link, if successful. */ $.oNode.prototype.linkInNode = function( nodeToLink, ownPort, destPort, createPorts){ if (!(nodeToLink instanceof this.$.oNode)) throw new Error("Incorrect type for argument 'nodeToLink'. Must provide an $.oNode.") var _link = (new this.$.oLink(nodeToLink, this, destPort, ownPort)).getValidLink(createPorts, createPorts); if (_link == null) return; this.$.debug("linking "+_link, this.$.DEBUG_LEVEL.LOG); return _link.connect(); }; /** * Searches for and unlinks the $.oNode object from this node's inNodes. * @param {$.oNode} oNodeObject The node to link this one's inport to. * @return {bool} The result of the unlink. */ $.oNode.prototype.unlinkInNode = function( oNodeObject ){ var _node = oNodeObject.path; var _links = this.getInLinks(); for (var i in _links){ if (_links[i].outNode.path == _node) return _links[i].disconnect(); } throw new Error (oNodeObject.name + " is not linked to node " + this.name + ", can't unlink."); }; /** * Unlinks a specific port from this node's inport. * @param {int} inPort The inport to disconnect. * * @return {bool} The result of the unlink, if successful. */ $.oNode.prototype.unlinkInPort = function( inPort ){ // Default values for optional parameters if (typeof inPort === 'undefined') inPort = 0; return node.unlink( this.path, inPort ); }; /** * Returns the node connected to a specific in-port * @param {int} inPort the number of the port to get the linked Node from. * @return {$.oNode} The node connected to this in-port */ $.oNode.prototype.getLinkedInNode = function(inPort){ if (this.inPorts < inPort) return null; return this.scene.getNodeByPath(node.srcNode(this.path, inPort)); } /** * Returns the number of links connected to an outPort * @param {int} outPort the number of the port to get links from. * @return {int} the number of links */ $.oNode.prototype.getOutLinksNumber = function(outPort){ if (this.outPorts < outPort) return null; return node.numberOfOutputLinks(this.path, outPort); } /** * Returns the $.oLink object representing the connection of a specific outPort / link * @param {int} outPort the number of the port to get the link from. * @param {int} [outLink] the index of the link. * @return {$.oLink} The link object describing the connection */ $.oNode.prototype.getOutLink = function(outPort, outLink){ if (typeof outLink === 'undefined') var outLink = 0; if (this.outPorts < outPort) return null; if (this.getOutLinksNumber(outPort) < outLink) return null; var _info = node.dstNodeInfo(this.path, outPort, outLink); if (!_info) return null; var _outNode = this.scene.getNodeByPath(_info.node); var _outLink = new this.$.oLink(this, _outNode, outPort, _info.port, outLink, true); return _outLink; } /** * Returns all the valid oLink objects describing the links that are coming out of this node. * @return {$.oLink[]} An array of $.oLink objects. */ $.oNode.prototype.getOutLinks = function(){ var _outPorts = this.outPorts; var _links = []; for (var i = 0; i<_outPorts; i++){ var _outLinks = this.getOutLinksNumber(i); for (var j = 0; j<_outLinks; j++){ var _link = this.getOutLink(i, j); if (_link != null) _links.push(_link); } } return _links; } /** * Returns a free unconnected out-port * @param {bool} [createNew=true] Whether to allow creation of new ports * @return {int} the port number that isn't connected */ $.oNode.prototype.getFreeOutPort = function(createNew){ if (typeof createNew === 'undefined') var createNew = false; var _outPorts = this.outPorts; for (var i=0; i<_outPorts; i++){ if (this.getOutLinksNumber(i) == 0) return i; } if (_outPorts == 0 && this.canCreateOutPorts) return 0; if (createNew && this.canCreateOutPorts) return _outPorts; return _outPorts-1; // if no empty outPort can be found, return the last one } /** * Links this node's out-port to the given module, at the inport and outport indices. * @param {$.oNode} nodeToLink The node to link this one's outport to. * @param {int} [ownPort] This node's outport to connect. * @param {int} [destPort] The target node's inport to connect. * @param {bool} [createPorts] Whether to create new ports on the nodes. * * @return {bool} The result of the link, if successful. */ $.oNode.prototype.linkOutNode = function(nodeToLink, ownPort, destPort, createPorts){ if (!(nodeToLink instanceof this.$.oNode)) throw new Error("Incorrect type for argument 'nodeToLink'. Must provide an $.oNode.") var _link = (new this.$.oLink(this, nodeToLink, ownPort, destPort)).getValidLink(createPorts, createPorts) if (_link == null) return; this.$.debug("linking "+_link, this.$.DEBUG_LEVEL.LOG); return _link.connect(); } /** * Links this node's out-port to the given module, at the inport and outport indices. * @param {$.oNode} oNodeObject The node to unlink from this node's outports. * * @return {bool} The result of the link, if successful. */ $.oNode.prototype.unlinkOutNode = function( oNodeObject ){ var _node = oNodeObject.path; var _links = this.getOutLinks(); for (var i in _links){ if (_links[i].inNode.path == _node) return _links[i].disconnect(); } throw new Error (oNodeObject.name + " is not linked to node " + this.name + ", can't unlink."); }; /** * Returns the node connected to a specific outPort * @param {int} outPort the number of the port to get the node from. * @param {int} [outLink=0] the index of the link. * @return {$.oNode} The node connected to this outPort and outLink */ $.oNode.prototype.getLinkedOutNode = function(outPort, outLink){ if (typeof outLink == 'undefined') var outLink = 0; if (this.outPorts < outPort || this.getOutLinksNumber(outPort) < outLink) return null; return this.scene.getNodeByPath(node.dstNode(this.path, outPort, outLink)); } /** * Unlinks a specific port/link from this node's output. * @param {int} outPort The outPort to disconnect. * @param {int} outLink The outLink to disconnect. * * @return {bool} The result of the unlink, if successful. */ $.oNode.prototype.unlinkOutPort = function( outPort, outLink ){ // Default values for optional parameters if (typeof outLink === 'undefined') outLink = 0; try{ var dstNodeInfo = node.dstNodeInfo(this.path, outPort, outLink); if (dstNodeInfo) node.unlink(dstNodeInfo.node, dstNodeInfo.port); return true; }catch(err){ this.$.debug("couldn't unlink port "+outPort+" of node "+this.path, this.$.DEBUG_LEVEL.ERROR) return false; } }; /** * Inserts the $.oNodeObject provided as an innode to this node, placing it between any existing nodes if the link already exists. * @param {int} inPort This node's inport to connect. * @param {$.oNode} oNodeObject The node to link this one's outport to. * @param {int} inPortTarget The target node's inPort to connect. * @param {int} outPortTarget The target node's outPort to connect. * * @return {bool} The result of the link, if successful. */ $.oNode.prototype.insertInNode = function( inPort, oNodeObject, inPortTarget, outPortTarget ){ var _node = oNodeObject.path; //QScriptValue if( this.ins[inPort] ){ //INSERT BETWEEN. var node_linkinfo = node.srcNodeInfo( this.path, inPort ); node.link( node_linkinfo.node, node_linkinfo.port, _node, inPortTarget, true, true ); node.unlink( this.path, inPort ); return node.link( oNodeObject.path, outPortTarget, this.path, inPort, true, true ); } return this.linkInNode( oNodeObject, inPort, outPortTarget ); }; /** * Moves the node into the specified group. This doesn't create any composite or links to the multiport nodes. The node will be unlinked. * @param {oGroupNode} group the group node to move the node into. */ $.oNode.prototype.moveToGroup = function(group){ var _name = this.name; if (group instanceof oGroupNode) group = group.path; if (this.group != group){ this.$.beginUndo("oH_moveNodeToGroup_"+_name) var _groupNodes = node.subNodes(group); node.moveToGroup(this.path, group); this._path = group+"/"+_name; // detect creation of a composite and remove it var _newNodes = node.subNodes(group) if (_newNodes.length > _groupNodes.length+1){ for (var i in _newNodes){ if (_groupNodes.indexOf(_newNodes[i]) == -1 && _newNodes[i] != this.path) { var _comp = this.scene.getNodeByPath(_newNodes[i]); if (_comp && _comp.type == "COMPOSITE") _comp.remove(); break; } } } // remove automated links var _inPorts = this.inPorts; for (var i=0; i<_inPorts; i++){ this.unlinkInPort(i); } var _outPorts = this.outPorts; for (var i=0; i<_outPorts; i++){ var _outLinks = this.getOutLinksNumber(i); for (var j=_outLinks-1; j>=0; j--){ this.unlinkOutPort(i, j); } } this.refreshAttributes(); this.$.endUndo(); } } /** * Get the transformation matrix for the node at the given frame * @param {int} frameNumber * @returns {oMatrix} the matrix object */ $.oNode.prototype.getMatrixAtFrame = function (frameNumber){ return new this.$.oMatrix(node.getMatrix(this.path, frameNumber)); } /** * Retrieves the node layer in the timeline provided. * @param {oTimeline} [timeline] Optional: the timeline object to search the column Layer. (by default, grabs the current timeline) * * @return {int} The index within that timeline. */ $.oNode.prototype.getTimelineLayer = function(timeline){ if (typeof timeline === 'undefined') var timeline = this.$.scene.currentTimeline; var _nodeLayers = timeline.layers.map(function(x){return x.node.path}); if (_nodeLayers.indexOf(this.path)0){ return timeline.layers[_nodeLayers.indexOf(this.path)]; } return null } /** * Retrieves the node index in the timeline provided. * @param {oTimeline} [timeline] Optional: the timeline object to search the column Layer. (by default, grabs the current timeline) * * @return {int} The index within that timeline. */ $.oNode.prototype.timelineIndex = function(timeline){ if (typeof timeline === 'undefined') var timeline = this.$.scene.currentTimeline; var _nodes = timeline.compositionLayersList; return _nodes.indexOf(this.path); } /** * obtains the nodes contained in the group, allows recursive search. This method is deprecated and was moved to oGroupNode * @DEPRECATED * @param {bool} recurse Whether to recurse internally for nodes within children groups. * * @return {$.oNode[]} The subbnodes contained in the group. */ $.oNode.prototype.subNodes = function(recurse){ if (typeof recurse === 'undefined') recurse = false; var _nodes = node.subNodes(this.path); var _subNodes = []; for (var _node in _nodes){ var _oNodeObject = new this.$.oNode( _nodes[_node] ); _subNodes.push(_oNodeObject); if (recurse && node.isGroup(_nodes[_node])) _subNodes = _subNodes.concat(_$.oNodeObject.subNodes(recurse)); } return _subNodes; }; /** * Place a node above one or more nodes with an offset. * @param {$.oNode[]} oNodeArray The array of nodes to center this above. * @param {float} xOffset The horizontal offset to apply after centering. * @param {float} yOffset The vertical offset to apply after centering. * * @return {oPoint} The resulting position of the node. */ $.oNode.prototype.centerAbove = function( oNodeArray, xOffset, yOffset ){ if (!oNodeArray) throw new Error ("An array of nodes to center node '"+this.name+"' above must be provided.") // Defaults for optional parameters if (typeof xOffset === 'undefined') var xOffset = 0; if (typeof yOffset === 'undefined') var yOffset = -30; // Works with nodes and nodes array if (oNodeArray instanceof this.$.oNode) oNodeArray = [oNodeArray]; if (oNodeArray.filter(function(x){return !x}).length) throw new Error ("Can't center node '"+ this.name+ "' above nodes "+ oNodeArray + ", invalid nodes found.") var _box = new this.$.oBox(); _box.includeNodes( oNodeArray ); this.x = _box.center.x - this.width/2 + xOffset; this.y = _box.top - this.height + yOffset; return new this.$.oPoint(this.x, this.y, this.z); }; /** * Place a node below one or more nodes with an offset. * @param {$.oNode[]} oNodeArray The array of nodes to center this below. * @param {float} xOffset The horizontal offset to apply after centering. * @param {float} yOffset The vertical offset to apply after centering. * * @return {oPoint} The resulting position of the node. */ $.oNode.prototype.centerBelow = function( oNodeArray, xOffset, yOffset){ if (!oNodeArray) throw new Error ("An array of nodes to center node '"+this.name+"' below must be provided.") // Defaults for optional parameters if (typeof xOffset === 'undefined') var xOffset = 0; if (typeof yOffset === 'undefined') var yOffset = 30; // Works with nodes and nodes array if (oNodeArray instanceof this.$.oNode) oNodeArray = [oNodeArray]; if (oNodeArray.filter(function(x){return !x}).length) throw new Error ("Can't center node '"+ this.name+ "' below nodes "+ oNodeArray + ", invalid nodes found.") var _box = new this.$.oBox(); _box.includeNodes(oNodeArray); this.x = _box.center.x - this.width/2 + xOffset; this.y = _box.bottom + yOffset; return new this.$.oPoint(this.x, this.y, this.z); } /** * Place at center of one or more nodes with an offset. * @param {$.oNode[]} oNodeArray The array of nodes to center this below. * @param {float} xOffset The horizontal offset to apply after centering. * @param {float} yOffset The vertical offset to apply after centering. * * @return {oPoint} The resulting position of the node. */ $.oNode.prototype.placeAtCenter = function( oNodeArray, xOffset, yOffset ){ // Defaults for optional parameters if (typeof xOffset === 'undefined') var xOffset = 0; if (typeof yOffset === 'undefined') var yOffset = 0; // Works with nodes and nodes array if (typeof oNodeArray === 'oNode') oNodeArray = [oNodeArray]; var _box = new this.$.oBox(); _box.includeNodes(oNodeArray); this.x = _box.center.x - this.width/2 + xOffset; this.y = _box.center.y - this.height/2 + yOffset; return new this.$.oPoint(this.x, this.y, this.z); } /** * Create a clone of the node. * @param {string} newName The new name for the cloned module. * @param {oPoint} newPosition The new position for the cloned module. */ $.oNode.prototype.clone = function( newName, newPosition ){ // Defaults for optional parameters if (typeof newPosition === 'undefined') var newPosition = this.nodePosition; if (typeof newName === 'undefined') var newName = this.name+"_clone"; this.$.beginUndo("oH_cloneNode_"+this.name); var _clonedNode = this.group.addNode(this.type, newName, newPosition); var _attributes = this.attributes; for (var i in _attributes){ var _clonedAttribute = _clonedNode.getAttributeByName(_attributes[i].keyword); _clonedAttribute.setToAttributeValue(_attributes[i]); } var palettes = this.palettes for (var i in palettes){ _clonedNode.linkPalette(palettes[i]) } this.$.endUndo(); return _clonedNode; }; /** * Duplicates a node by creating an independent copy. * @param {string} [newName] The new name for the duplicated node. * @param {oPoint} [newPosition] The new position for the duplicated node. */ $.oNode.prototype.duplicate = function(newName, newPosition){ if (typeof newPosition === 'undefined') var newPosition = this.nodePosition; if (typeof newName === 'undefined') var newName = this.name+"_duplicate"; this.$.beginUndo("oH_cloneNode_"+this.name); var _duplicateNode = this.group.addNode(this.type, newName, newPosition); var _attributes = this.attributes; for (var i in _attributes){ var _duplicateAttribute = _duplicateNode.getAttributeByName(_attributes[i].keyword); _duplicateAttribute.setToAttributeValue(_attributes[i], true); } var palettes = this.palettes for (var i in palettes){ _duplicateNode.linkPalette(palettes[i]) } this.$.endUndo(); return _duplicateNode; }; /** * Removes the node from the scene. * @param {bool} deleteColumns Should the columns of drawings be deleted as well? * @param {bool} deleteElements Should the elements of drawings be deleted as well? * * @return {void} */ $.oNode.prototype.remove = function( deleteColumns, deleteElements ){ if (typeof deleteFrames === 'undefined') var deleteColumns = true; if (typeof deleteElements === 'undefined') var deleteElements = true; this.$.beginUndo("oH_deleteNode_"+this.name) // restore links for special types; if (this.type == "PEG"){ var inNodes = this.inNodes; //Pegs can only have one inNode but we'll implement the general case for other types var outNodes = this.outNodes; for (var i in inNodes){ for (var j in outNodes){ for( var k in outNodes[j] ){ inNodes[i].linkOutNode(outNodes[j][k]); } } } } node.deleteNode(this.path, deleteColumns, deleteElements); this.$.endUndo(); } /** * Provides a matching attribute based on provided keyword name. Keyword can include "." to get subattributes. * @param {string} keyword The attribute keyword to search. * @return {oAttribute} The matched attribute object, given the keyword. */ $.oNode.prototype.getAttributeByName = function( keyword ){ keyword = keyword.toLowerCase(); keyword = keyword.split("."); // we go through the keywords, trying to access an attribute corresponding to the name var _attribute = this.attributes; for (var i in keyword){ var _keyword = keyword[i]; // applying conversion to the name 3dpath if (_keyword == "3dpath") _keyword = "path3d"; if (!(_keyword in _attribute)) return null; _attribute = _attribute[_keyword]; } if (_attribute instanceof this.$.oAttribute) return _attribute; return null; } /** * Used in converting the node to a string value, provides the string-path. * @return {string} The node path's as a string. */ $.oNode.prototype.toString = function(){ return this.path; } /** * Provides a matching attribute based on the column name provided. Assumes only one match at the moment. * @param {string} columnName The column name to search. * @return {oAttribute} The matched attribute object, given the column name. */ $.oNode.prototype.getAttributeByColumnName = function( columnName ){ // var attribs = []; //Initially check for cache. var cdate = (new Date()).getTime(); if( this.$.cache_columnToNodeAttribute[columnName] ){ if( ( cdate - this.$.cache_columnToNodeAttribute[columnName].date ) < 5000 ){ //Cache is in form : { "node":oAttributeObject.node, "attribute": this, "date": (new Date()).getTime() } // attribs.push( this.$.cache_columnToNodeAttribute[columnName].attribute ); return this.$.cache_columnToNodeAttribute[columnName].attribute; } } var _attributes = this.attributes; for( var n in _attributes){ var t_attrib = _attributes[n]; if( t_attrib.subAttributes.length>0 ){ //Also check subattributes. for( var t=0; tattribute lookup table for timeline building. * @private * @return {object} The column_name->attribute object LUT. {colName: { "node":oNode, "column":oColumn } } */ $.oNode.prototype.getAttributesColumnCache = function( obj_lut ){ if (typeof obj_lut === 'undefined') obj_lut = {}; for( var n in this.attributes ){ var t_attrib = this.attributes[n]; if( t_attrib.subAttributes.length>0 ){ //Also check subattributes. for( var t=0;t0 ){ //Its a sub attribute created. try{ var sub_attr = this.attributes[ res_split[0] ]; for( x = 1; x * It represents peg nodes in the scene. * @constructor * @augments $.oNode * @classdesc Peg Module Class * @param {string} path Path to the node in the network. * @param {oScene} oSceneObject Access to the oScene object of the DOM. */ $.oPegNode = function( path, oSceneObject ) { if (node.type(path) != 'PEG') throw "'path' parameter must point to a 'PEG' type node"; var instance = this.$.oNode.call( this, path, oSceneObject ); if (instance) return instance; this._type = 'pegNode'; } $.oPegNode.prototype = Object.create( $.oNode.prototype ); $.oPegNode.prototype.constructor = $.oPegNode; ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oDrawingNode class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * Constructor for the $.oDrawingNode class * @classdesc * $.oDrawingNode is a subclass of $.oNode and implements the same methods and properties as $.oNode.
* It represents 'read' nodes or Drawing nodes in the scene. * @constructor * @augments $.oNode * @param {string} path Path to the node in the network. * @param {$.oScene} oSceneObject Access to the oScene object of the DOM. * @example * // Drawing Nodes are more than a node, as they do not work without an associated Drawing column and element. * // adding a drawing node will automatically create a column and an element, unless they are provided as arguments. * // Creating an element makes importing a drawing file possible. * * var doc = $.scn; * * var drawingName = "myDrawing"; * var myElement = doc.addElement(drawingName, "TVG"); // add an element that holds TVG(Toonboom Vector Drawing) files * var myDrawingColumn = doc.addColumn("DRAWING", drawingName, myElement); // create a column and link the element created to it * * var sceneRoot = doc.root; // grab the scene root group * * // Creating the Drawing node and linking the previously created element and column * var myDrawingNode = sceneRoot.addDrawingNode(drawingName, new $.oPoint(), myDrawingColumn, myElement); * * // This also works: * * var myOtherNode = sceneRoot.addDrawingNode("Drawing2"); */ $.oDrawingNode = function(path, oSceneObject) { // $.oDrawingNode can only represent a node of type 'READ' if (node.type(path) != 'READ') throw "'path' parameter must point to a 'READ' type node"; var instance = this.$.oNode.call(this, path, oSceneObject); if (instance) return instance; this._type = 'drawingNode'; } $.oDrawingNode.prototype = Object.create($.oNode.prototype); $.oDrawingNode.prototype.constructor = $.oDrawingNode; /** * The element that holds the drawings displayed by the node. * @name $.oDrawingNode#element * @type {$.oElement} */ Object.defineProperty($.oDrawingNode.prototype, "element", { get : function(){ var _column = this.attributes.drawing.element.column; return ( new this.$.oElement( node.getElementId(this.path), _column ) ); }, set : function( oElementObject ){ var _column = this.attributes.drawing.element.column; column.setElementIdOfDrawing( _column.uniqueName, oElementObject.id ); } }); /** * The column that holds the drawings displayed by the node. * @name $.oDrawingNode.timingColumn * @type {$.oDrawingColumn} */ Object.defineProperty($.oDrawingNode.prototype, "timingColumn", { get : function(){ var _column = this.attributes.drawing.element.column; return _column; }, set : function (oColumnObject){ var _attribute = this.attributes.drawing.element; _attribute.column = oColumnObject; } }); /** * An array of the colorIds contained within the drawings displayed by the node. * @name $.oDrawingNode#usedColorIds * @type {int[]} */ Object.defineProperty($.oDrawingNode.prototype, "usedColorIds", { get : function(){ // this.$.log("used colors in node : "+this.name) var _drawings = this.element.drawings; var _colors = []; for (var i in _drawings){ var _drawingColors = _drawings[i].usedColorIds; for (var c in _drawingColors){ if (_colors.indexOf(_drawingColors[c]) == -1) _colors.push(_drawingColors[c]); } } return _colors; } }); /** * An array of the colors contained within the drawings displayed by the node, found in the palettes. * @name $.oDrawingNode#usedColors * @type {$.oColor[]} */ Object.defineProperty($.oDrawingNode.prototype, "usedColors", { get : function(){ // get unique Color Ids var _ids = this.usedColorIds; // look in both element and scene palettes var _palettes = this.palettes.concat(this.$.scn.palettes); // build a palette/id list to speedup massive palettes/palette lists var _colorIds = {} for (var i in _palettes){ var _palette = _palettes[i]; var _colors = _palette.colors; _colorIds[_palette.name] = {}; for (var j in _colors){ _colorIds[_palette.name][_colors[j].id] = _colors[j]; } } // for each id on the drawing, identify the corresponding color var _usedColors = _ids.map(function(id){ for (var paletteName in _colorIds){ if (_colorIds[paletteName][id]) return _colorIds[paletteName][id]; } throw new Error("Missing color found for id: "+id+". Color doesn't belong to any palette in the scene or element."); }) return _usedColors; } }) /** * The drawing.element keyframes. * @name $.oDrawingNode#timings * @type {$.oFrames[]} * @example * // The timings hold the keyframes that display the drawings across time. * * var timings = $.scn.$node("Top/Drawing").timings; * for (var i in timings){ * $.log( timings.frameNumber+" : "+timings.value); // outputs the frame and the value of each keyframe * } * * // timings are keyframe objects, so they are dynamic. * timings[2].value = "5"; // sets the displayed image of the second key to the drawing named "5" * * // to set a new value to a frame that wasn't a keyframe before, it's possible to use the attribute keyword like so: * * var myNode = $.scn.$node("Top/Drawing"); * myNode.drawing.element = {frameNumber: 5, value: "10"} // setting the value of the frame 5 * myNode.drawing.element = {frameNumber: 6, value: timings[1].value} // setting the value to the same as one of the timings */ Object.defineProperty($.oDrawingNode.prototype, "timings", { get : function(){ return this.attributes.drawing.element.getKeyframes(); } }) /** * The element palettes linked to the node. * @name $.oDrawingNode#palettes * @type {$.oPalette[]} */ Object.defineProperty($.oDrawingNode.prototype, "palettes", { get : function(){ var _element = this.element; return _element.palettes; } }) // Class Methods /** * Gets the drawing name at the given frame. * @param {int} frameNumber * @return {$.oDrawing} */ $.oDrawingNode.prototype.getDrawingAtFrame = function(frameNumber){ if (typeof frame === "undefined") var frame = this.$.scene.currentFrame; var _attribute = this.attributes.drawing.element return _attribute.getValue(frameNumber); } /** * Gets the list of palettes containing colors used by a drawing node. This only gets palettes with the first occurrence of the colors. * @return {$.oPalette[]} The palettes that contain the color IDs used by the drawings of the node. */ $.oDrawingNode.prototype.getUsedPalettes = function(){ var _palettes = {}; var _usedPalettes = []; var _usedColors = this.usedColors; // build an object of palettes under ids as keys to remove duplicates for (var i in _usedColors){ var _palette = _usedColors[i].palette; _palettes[_palette.id] = _palette; } for (var i in _palettes){ _usedPalettes.push(_palettes[i]); } return _usedPalettes; } /** * Displays all the drawings from the node's element onto the timeline * @param {int} [framesPerDrawing=1] The number of frames each drawing will be shown for */ $.oDrawingNode.prototype.exposeAllDrawings = function(framesPerDrawing){ if (typeof framesPerDrawing === 'undefined') var framesPerDrawing = 1; var _drawings = this.element.drawings; var frameNumber = 1; for (var i=0; i < _drawings.length; i++){ //log("showing drawing "+_drawings[i].name+" at frame "+i) this.showDrawingAtFrame(_drawings[i], frameNumber); frameNumber+=framesPerDrawing; } var _column = this.attributes.drawing.element.column; var _exposures = _column.getKeyframes(); _column.extendExposures(_exposures, framesPerDrawing-1); } /** * Displays the given drawing at the given frame * @param {$.oDrawing} drawing * @param {int} frameNum */ $.oDrawingNode.prototype.showDrawingAtFrame = function(drawing, frameNum){ var _column = this.attributes.drawing.element.column; _column.setValue(drawing.name, frameNum); } /** * Links a palette to a drawing node as Element Palette. * @param {$.oPalette} oPaletteObject the palette to link to the node * @param {int} [index] The index of the list at which the palette should appear once linked * * @return {$.oPalette} The linked element Palette. */ $.oDrawingNode.prototype.linkPalette = function(oPaletteObject, index){ return this.element.linkPalette(oPaletteObject, index); } /** * Unlinks an Element Palette from a drawing node. * @param {$.oPalette} oPaletteObject the palette to unlink from the node * * @return {bool} The success of the unlink operation. */ $.oDrawingNode.prototype.unlinkPalette = function(oPaletteObject){ return this.element.unlinkPalette(oPaletteObject); } /** * Duplicates a node by creating an independent copy. * @param {string} [newName] The new name for the duplicated node. * @param {oPoint} [newPosition] The new position for the duplicated node. * @param {bool} [duplicateElement] Whether to also duplicate the element. */ $.oDrawingNode.prototype.duplicate = function(newName, newPosition, duplicateElement){ if (typeof newPosition === 'undefined') var newPosition = this.nodePosition; if (typeof newName === 'undefined') var newName = this.name+"_1"; if (typeof duplicateElement === 'undefined') var duplicateElement = true; var _duplicateElement = duplicateElement?this.element.duplicate(this.name):this.element; var _duplicateNode = this.group.addDrawingNode(newName, newPosition, _duplicateElement); var _attributes = this.attributes; for (var i in _attributes){ var _duplicateAttribute = _duplicateNode.getAttributeByName(_attributes[i].keyword); _duplicateAttribute.setToAttributeValue(_attributes[i], true); } var _duplicateAttribute = _duplicateNode.getAttributeByName(_attributes[i].keyword); _duplicateAttribute.setToAttributeValue(_attributes[i], true); return _duplicateNode; }; /** * Updates the imported drawings in the node. * @param {$.oFile} sourcePath the oFile object pointing to the source to update from * @param {string} [drawingName] the drawing to import the updated bitmap into * @todo implement a memory of the source through metadata */ $.oDrawingNode.prototype.update = function(sourcePath, drawingName){ if (!this.element) return; // no element means nothing to update, import instead. if (typeof drawingName === 'undefined') var drawingName = this.element.drawings[0].name; var _drawing = this.element.getDrawingByName(drawingName); _drawing.importBitmap(sourcePath); _drawing.refreshPreview(); } /** * Extracts the position information on a drawing node, and applies it to a new peg instead. * @return {$.oPegNode} The created peg. */ $.oDrawingNode.prototype.extractPeg = function(){ var _drawingNode = this; var _peg = this.group.addNode("PEG", this.name+"-P"); var _columns = _drawingNode.linkedColumns; _peg.position.separate = _drawingNode.offset.separate; _peg.scale.separate = _drawingNode.scale.separate; // link each column that can be to the peg instead and reset the drawing node for (var i in _columns){ var _attribute = _columns[i].attributeObject; var _keyword = _attribute._keyword; var _nodeAttribute = _drawingNode.getAttributeByName(_keyword); if (_keyword.indexOf("OFFSET") != -1) _keyword = _keyword.replace("OFFSET", "POSITION"); var _pegAttribute = _peg.getAttributeByName(_keyword); if (_pegAttribute !== null){ _pegAttribute.column = _columns[i]; _nodeAttribute.column = null; _drawingNode[_keyword] = _attribute.defaultValue; } } _drawingNode.offset.separate = false; // doesn't work? _drawingNode.can_animate = false; _peg.centerAbove(_drawingNode, -1, -30) _drawingNode.linkInNode(_peg) return _peg; } /** * Gets the contour curves of the drawing, as a concave hull. * @param {int} [count] The number of points on the contour curve to derive. * @param {int} [frame] The frame to derive the contours. * * @return {oPoint[][]} The contour curves. */ $.oDrawingNode.prototype.getContourCurves = function( count, frame ){ if (typeof frame === 'undefined') var frame = this.scene.currentFrame; if (typeof count === 'undefined') var count = 3; var res = EnvelopeCreator().getDrawingBezierPath( this.path, frame, //FRAME 2.5, //DISCRETIZER 0, //K count, //DESIRED POINT COUNT 0, //BLUR 0, //EXPAND false, //SINGLELINE true, //USE MIN POINTS, 0, //ADDITIONAL BISSECTING false ); if( res.success ){ var _curves = res.results.map(function(x){return [ new this.$.oPoint( x[0][0], x[0][1], 0.0 ), new this.$.oPoint( x[1][0], x[1][1], 0.0 ), new this.$.oPoint( x[2][0], x[2][1], 0.0 ), new this.$.oPoint( x[3][0], x[3][1], 0.0 ) ]; } ); return _curves; } return []; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oTransformSwitchNode class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * Constructor for the $.oTransformSwitchNode class * @classdesc * $.oTransformSwitchNode is a subclass of $.oNode and implements the same methods and properties as $.oNode.
* It represents transform switches in the scene. * @constructor * @augments $.oNode * @param {string} path Path to the node in the network. * @param {oScene} oSceneObject Access to the oScene object of the DOM. * @property {$.oTransformNamesObject} names An array-like object with static indices (starting at 0) for each transformation name, which can be retrieved/set directly. * @example * // Assuming the existence of a Deformation group applied to a 'Drawing' node at the root of the scene * var myNode = $.scn.getNodeByPath("Top/Deformation-Drawing/Transformation-Switch"); * * myNode.names[0] = "B"; // setting the value for the first transform drawing name to "B" * * var drawingNames = ["A", "B", "C"] // example of iterating over the existing names to set/retrieve them * for (var i in myNode.names){ * $.log(i+": "+myNode.names[i]); * $.log(myNode.names[i] = drawingNames[i]); * } * * $.log("length: " + myNode.names.length) // the number of names * $.log("names: " + myNode.names) // prints the list of names * $.log("indexOf 'B': " + myNode.names.indexOf("B")) // can use methods from Array */ $.oTransformSwitchNode = function( path, oSceneObject ) { if (node.type(path) != 'TransformationSwitch') throw "'path' parameter ("+path+") must point to a 'TransformationSwitch' type node. Got: "+node.type(path); var instance = this.$.oNode.call( this, path, oSceneObject ); if (instance) return instance; this._type = 'transformSwitchNode'; this.names = new this.$.oTransformNamesObject(this); } $.oTransformSwitchNode.prototype = Object.create( $.oNode.prototype ); $.oTransformSwitchNode.prototype.constructor = $.oTransformSwitchNode; /** * Constructor for the $.oTransformNamesObject class * @classdesc * $.oTransformNamesObject is an array like object with static length that exposes getter setters for * each transformation name used by the oTransformSwitchNode. It can use the same methods as any array. * @constructor * @param {$.oTransformSwitchNode} instance the transform Node instance using this object * @property {int} length the number of valid elements in the object. */ $.oTransformNamesObject = function(transformSwitchNode){ Object.defineProperty(this, "transformSwitchNode", { enumerable:false, get: function(){ return transformSwitchNode; }, }) this.refresh(); } $.oTransformNamesObject.prototype = Object.create(Array.prototype); /** * creates a $.oTransformSwitch.names property with an index for each name to get/set the name value * @private */ Object.defineProperty($.oTransformNamesObject.prototype, "createGetterSetter", { enumerable:false, value: function(index){ var attrName = "transformation_" + (index+1); var transformNode = this.transformSwitchNode; Object.defineProperty(this, index, { enumerable:true, configurable:true, get: function(){ return transformNode.transformationnames[attrName]; }, set: function(newName){ newName = newName+""; // convert to string this.$.debug("setting "+attrName+" to drawing "+newName+" on "+transformNode.path, this.$.DEBUG_LEVEL.DEBUG) if (newName instanceof this.$.oDrawing) newName = newName.name; transformNode.transformationnames[attrName] = newName; } }) } }) /** * The length of the array of names on the oTransformSwitchNode node. Corresponds to the transformationnames.size subAttribute. * @name $.oTransformNamesObject#length * @type {int} */ Object.defineProperty($.oTransformNamesObject.prototype, "length", { enumerable:false, get: function(){ return this.transformSwitchNode.transformationnames.size; }, }) /** * A string representation of the names list * @private */ Object.defineProperty($.oTransformNamesObject.prototype, "toString", { enumerable:false, value: function(){ return this.join(","); } }) /** * @private */ Object.defineProperty($.oTransformNamesObject.prototype, "refresh", { enumerable:false, value:function(){ for (var i in this){ delete this[i]; } for (var i=0; i * It represents color overrides in the scene. * @constructor * @augments $.oNode * @param {string} path Path to the node in the network. * @param {oScene} oSceneObject Access to the oScene object of the DOM. */ $.oColorOverrideNode = function(path, oSceneObject) { // $.oDrawingNode can only represent a node of type 'READ' if (node.type(path) != 'COLOR_OVERRIDE_TVG') throw "'path' parameter must point to a 'COLOR_OVERRIDE_TVG' type node"; var instance = this.$.oNode.call(this, path, oSceneObject); if (instance) return instance; this._type = 'colorOverrideNode'; this._coObject = node.getColorOverride(path) } $.oColorOverrideNode.prototype = Object.create($.oNode.prototype); $.oColorOverrideNode.prototype.constructor = $.oColorOverrideNode; /** * The list of palette overrides in this color override node * @name $.oColorOverrideNode#palettes * @type {$.oPalette[]} * @readonly */ Object.defineProperty($.oColorOverrideNode.prototype, "palettes", { get: function(){ this.$.debug("getting palettes", this.$.DEBUG_LEVEL.LOG) if (!this._palettes){ this._palettes = []; var _numPalettes = this._coObject.getNumPalettes(); for (var i=0; i<_numPalettes; i++){ var _palettePath = this._coObject.palettePath(i) + ".plt"; var _palette = this.$.scn.getPaletteByPath(_palettePath); if (_palette) this._palettes.push(_palette); } } return this._palettes; } }) /** * Add a new palette to the palette list (for now, only supports scene palettes) * @param {$.oPalette} palette */ $.oColorOverrideNode.prototype.addPalette = function(palette){ var _palettes = this.palettes // init palettes cache to add to it this._coObject.addPalette(palette.path.path); this._palettes.push(palette); } /** * Removes a palette to the palette list (for now, only supports scene palettes) * @param {$.oPalette} palette */ $.oColorOverrideNode.prototype.removePalette = function(palette){ this._coObject.removePalette(palette.path.path); } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oGroupNode class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * Constructor for the $.oGroupNode class * @classdesc * $.oGroupNode is a subclass of $.oNode and implements the same methods and properties as $.oNode.
* It represents groups in the scene. From this class, it's possible to add nodes, and backdrops, import files and templates into the group. * @constructor * @augments $.oNode * @param {string} path Path to the node in the network. * @param {oScene} oSceneObject Access to the oScene object of the DOM. * @example * // to add a new node, grab the group it'll be created in first * var doc = $.scn * var sceneRoot = doc.root; // grab the scene root group * * var myGroup = sceneRoot.addGrop("myGroup", false, false); // create a group in the scene root, with a peg and composite but no nodes * var MPO = myGroup.multiportOut; // grab the multiport in of the group * * var myNode = myGroup.addDrawingNode("myDrawingNode"); // add a drawing node inside the group * myNode.linkOutNode(MPO); // link the newly created node to the multiport * myNode.centerAbove(MPO); * * var sceneComposite = doc.$node("Top/Composite"); // grab the scene composite node * myGroup.linkOutNode(sceneComposite); // link the group to it * * myGroup.centerAbove(sceneComposite); */ $.oGroupNode = function(path, oSceneObject) { // $.oDrawingNode can only represent a node of type 'READ' if (node.type(path) != 'GROUP') throw "'path' parameter must point to a 'GROUP' type node"; var instance = this.$.oNode.call(this, path, oSceneObject); if (instance) return instance; this._type = 'groupNode'; } $.oGroupNode.prototype = Object.create($.oNode.prototype); $.oGroupNode.prototype.constructor = $.oGroupNode; /** * The multiport in node of the group. If one doesn't exist, it will be created. * @name $.oGroupNode#multiportIn * @readonly * @type {$.oNode} */ Object.defineProperty($.oGroupNode.prototype, "multiportIn", { get : function(){ if (this.isRoot) return null var _MPI = this.scene.getNodeByPath(node.getGroupInputModule(this.path, "Multiport-In", 0,-100,0),this.scene) return (_MPI) } }) /** * The multiport out node of the group. If one doesn't exist, it will be created. * @name $.oGroupNode#multiportOut * @readonly * @type {$.oNode} */ Object.defineProperty($.oGroupNode.prototype, "multiportOut", { get : function(){ if (this.isRoot) return null var _MPO = this.scene.getNodeByPath(node.getGroupOutputModule(this.path, "Multiport-Out", 0, 100,0),this.scene) return (_MPO) } }); /** * All the nodes contained within the group, one level deep. * @name $.oGroupNode#nodes * @readonly * @type {$.oNode[]} */ Object.defineProperty($.oGroupNode.prototype, "nodes", { get : function() { var _path = this.path; var _nodes = node.subNodes(_path); var self = this; return _nodes.map(function(x){return self.scene.getNodeByPath(x)}); } }); /** * All the backdrops contained within the group. * @name $.oGroupNode#backdrops * @readonly * @type {$.oBackdrop[]} */ Object.defineProperty($.oGroupNode.prototype, "backdrops", { get : function() { var _path = this.path; var _backdropObjects = Backdrop.backdrops(this.path); var _backdrops = _backdropObjects.map(function(x){return new this.$.oBackdrop(_path, x)}); return _backdrops; } }); /** * Returns a node from within a group based on its name. * @param {string} name The name of the node. * * @return {$.oNode} The node, or null if can't be found. */ $.oGroupNode.prototype.getNodeByName = function(name){ var _path = this.path+"/"+name; return this.scene.getNodeByPath(_path); } /** * Returns all the nodes of a certain type in the group. * Pass a value to recurse to look into the groups as well. * @param {string} typeName The type of the nodes. * @param {bool} recurse Whether to look inside the groups. * * @return {$.oNode[]} The nodes found. */ $.oGroupNode.prototype.getNodesByType = function(typeName, recurse){ if (typeof recurse === 'undefined') var recurse = false; return this.subNodes(recurse).filter(function(x){return x.type == typeName}); } /** * Returns a child node in a group based on a search. * @param {string} name The name of the node. * * @return {$.oNode} The node, or null if can't be found. */ $.oGroupNode.prototype.$node = function(name){ return this.getNodeByName(name); } /** * Gets all the nodes contained within the group. * @param {bool} [recurse=false] Whether to recurse the groups within the groups. * * @return {$.oNode[]} The nodes in the group */ $.oGroupNode.prototype.subNodes = function(recurse){ if (typeof recurse === 'undefined') recurse = false; var _nodes = node.subNodes(this.path); var _subNodes = []; for (var i in _nodes){ var _oNodeObject = this.scene.getNodeByPath(_nodes[i]); _subNodes.push(_oNodeObject); if (recurse && node.isGroup(_nodes[i])) _subNodes = _subNodes.concat(_oNodeObject.subNodes(recurse)); } return _subNodes; } /** * Gets all children of the group. * @param {bool} [recurse=false] Whether to recurse the groups within the groups. * * @return {$.oNode[]} The nodes in the group */ $.oGroupNode.prototype.children = function(recurse){ return this.subNodes(recurse); } /** * Creates an in-port on top of a group * @param {int} portNum The port number where a port will be added * @type {string} * * @return {int} The number of the created port in case the port specified was not correct (for example larger than the current number of ports + 1) */ $.oGroupNode.prototype.addInPort = function(portNum, type){ var _inPorts = this.inPorts; if (typeof portNum === 'undefined') var portNum = _inPorts; if (portNum > _inPorts) portNum = _inPorts; var _type = (type=="transform")?"READ":"none" var _dummyNode = this.addNode(_type, "dummy_add_port_node"); var _MPI = this.multiportIn; _dummyNode.linkInNode(_MPI, 0, portNum, true); _dummyNode.unlinkInNode(_MPI); _dummyNode.remove(); return portNum; } /** * Creates an out-port at the bottom of a group. For some reason groups can have many unconnected in-ports but only one unconnected out-port. * @param {int} [portNum] The port number where a port will be added * @type {string} * * @return {int} The number of the created port in case the port specified was not correct (for example larger than the current number of ports + 1) */ $.oGroupNode.prototype.addOutPort = function(portNum, type){ var _outPorts = this.outPorts; if (typeof portNum === 'undefined') var portNum = _outPorts; if (portNum > _outPorts) portNum = _outPorts; var _type = (type=="transform")?"PEG":"none" var _dummyNode = this.addNode(_type, "dummy_add_port_node"); var _MPO = this.multiportOut; _dummyNode.linkOutNode(_MPO, 0, portNum, true); _dummyNode.unlinkOutNode(_MPO); _dummyNode.remove(); return portNum; } /** * Gets all children of the group. * @param {bool} [recurse=false] Whether to recurse the groups within the groups. * * @return {$.oNode[]} The nodes in the group */ $.oGroupNode.prototype.children = function(recurse){ return this.subNodes(recurse); } /** * Sorts out the node view inside the group * @param {bool} [recurse=false] Whether to recurse the groups within the groups. */ $.oGroupNode.prototype.orderNodeView = function(recurse){ if (typeof recurse === 'undefined') var recurse = false; TB_orderNetworkUpBatchFromList( node.subNodes(this.path) ); if (!this.isRoot){ var _MPO = this.multiportOut; var _MPI = this.multiportIn; _MPI.x = _MPO.x } if (recurse){ var _subNodes = this.subNodes().filter(function(x){return x.type == "GROUP"}); for (var i in _subNodes){ _subNodes[i].orderNodeView(recurse); } } } /** * Adds a node to the group. * @param {string} type The type-name of the node to add. * @param {string} [name=type] The name of the newly created node. * @param {$.oPoint} [nodePosition={0,0,0}] The position for the node to be placed in the network. * * @return {$.oNode} The created node, or bool as false. * @example * // to add a node, simply call addNode on the group you want the node to be added to. * var sceneRoot = $.scn.root; // grab the scene root group ("Top") * * var peg = sceneRoot.addNode("PEG", "MyNewlyCreatedPeg"); // adding a peg * * // Now we'll also create a drawing node to connect under the peg * var sceneComposite = $.scn.getNodeByPath("Top/Composite"); // can also use $.scn.$node("Top/Composite") for shorter synthax * * var drawingNode = sceneRoot.addDrawingNode("myNewDrawingNode"); * drawingNode.linkOutNode(sceneComposite); * drawingNode.can_animate = false // setting some attributes on the newly created Node * * peg.linkOutNode(drawingNode); * * //through all this we didn't specify nodePosition parameters so we'll sort everything at once * * sceneRoot.orderNodeView(); * * // we can also do: * * peg.centerAbove(drawingNode); * */ $.oGroupNode.prototype.addNode = function( type, name, nodePosition ){ // Defaults for optional parameters if (typeof nodePosition === 'undefined') var nodePosition = new this.$.oPoint(0,0,0); if (typeof name === 'undefined') var name = type[0]+type.slice(1).toLowerCase(); if (typeof name !== 'string') name = name+""; var _group = this.path; // create node and return result (this sanitizes/increments the name, so we only create the oNode with the returned value) var _path = node.add(_group, name, type, nodePosition.x, nodePosition.y, nodePosition.z); _node = this.scene.getNodeByPath(_path); return _node; } /** * Adds a drawing layer to the group, with a drawing column and element linked. Possible to specify the column and element to use. * @param {string} name The name of the newly created node. * @param {$.oPoint} [nodePosition={0,0,0}] The position for the node to be placed in the network. * @param {$.object} [element] The element to attach to the column. * @param {object} [drawingColumn] The column to attach to the drawing module. * @return {$.oNode} The created node, or bool as false. */ $.oGroupNode.prototype.addDrawingNode = function( name, nodePosition, oElementObject, drawingColumn){ // add drawing column and element if not passed as parameters this.$.beginUndo("oH_addDrawingNode_"+name); // Defaults for optional parameters if (typeof nodePosition === 'undefined') var nodePosition = new this.$.oPoint(0,0,0); if (typeof name === 'undefined') var name = type[0]+type.slice(1).toLowerCase(); // creating the node first to get the "safe name" returned by harmony var _node = this.addNode("READ", name, nodePosition); if (typeof oElementObject === 'undefined') var oElementObject = this.scene.addElement(_node.name); if (typeof drawingColumn === 'undefined'){ // first look for a column in the element if (!oElementObject.column) { var drawingColumn = this.scene.addColumn("DRAWING", _node.name, oElementObject); }else{ var drawingColumn = oElementObject.column; } } // setup the node // setup animate mode/separate based on preferences? _node.attributes.drawing.element.column = drawingColumn; this.$.endUndo(); return _node; } /** * Adds a new group to the group, and optionally move the specified nodes into it. * @param {string} name The name of the newly created group. * @param {$.oPoint} [addComposite=false] Whether to add a composite. * @param {bool} [addPeg=false] Whether to add a peg. * @param {$.oNode[]} [includeNodes] The nodes to add to the group. * @param {$.oPoint} [nodePosition={0,0,0}] The position for the node to be placed in the network. * @return {$.oGroupNode} The created node, or bool as false. */ $.oGroupNode.prototype.addGroup = function( name, addComposite, addPeg, includeNodes, nodePosition ){ // Defaults for optional parameters if (typeof addPeg === 'undefined') var addPeg = false; if (typeof addComposite === 'undefined') var addComposite = false; if (typeof includeNodes === 'undefined') var includeNodes = []; this.$.beginUndo("oH_addGroup_"+name); var nodeBox = new this.$.oBox(); includeNodes = includeNodes.filter(function(x){return !!x}) // filter out all invalid types if (includeNodes.length > 0) nodeBox.includeNodes(includeNodes); if (typeof nodePosition === 'undefined') var nodePosition = includeNodes.length?nodeBox.center:new this.$.oPoint(0,0,0); var _group = this.addNode( "GROUP", name, nodePosition ); var _MPI = _group.multiportIn; var _MPO = _group.multiportOut; if (addComposite){ var _composite = _group.addNode("COMPOSITE", name+"_Composite"); _composite.composite_mode = "Pass Through"; // get preference? _composite.linkOutNode(_MPO); _composite.centerAbove(_MPO); } if (addPeg){ var _peg = _group.addNode("PEG", name+"-P"); _peg.linkInNode(_MPI); _peg.centerBelow(_MPI); } // moves nodes into the created group and recreates their hierarchy and links if (includeNodes.length > 0){ includeNodes = includeNodes.sort(function(a, b){return a.timelineIndex()>=b.timelineIndex()?1:-1}) var _links = this.scene.getNodesLinks(includeNodes); for (var i in includeNodes){ includeNodes[i].moveToGroup(_group); } for (var i in _links){ _links[i].connect(); } // link all unconnected nodes to the peg/MPI and comp/MPO var _topNode = _peg?_peg:_MPI; var _bottomNode = _composite?_composite:_MPO; for (var i in includeNodes){ for (var j=0; j < includeNodes[i].inPorts; j++){ if (includeNodes[i].getInLinksNumber(j) == 0) includeNodes[i].linkInNode(_topNode); } for (var j=0; j < includeNodes[i].outPorts; j++){ if (includeNodes[i].getOutLinksNumber(j) == 0) includeNodes[i].linkOutNode(_bottomNode,0,0); } } //shifting MPI/MPO/peg/comp out of the way of included nodes if (_peg){ _peg.centerAbove(includeNodes); includeNodes.push(_peg); } if (_composite){ _composite.centerBelow(includeNodes); includeNodes.push(_composite); } _MPI.centerAbove(includeNodes); _MPO.centerBelow(includeNodes); } this.$.endUndo(); return _group; } /** * Imports the specified template into the scene. * @param {string} tplPath The path of the TPL file to import. * @param {$.oNode[]} [destinationNodes=false] The nodes affected by the template. * @param {bool} [extendScene=true] Whether to extend the exposures of the content imported. * @param {$.oPoint} [nodePosition={0,0,0}] The position to offset imported new nodes. * @param {object} [pasteOptions] An object containing paste options as per Harmony's standard paste options. * * @return {$.oNode[]} The resulting pasted nodes. */ $.oGroupNode.prototype.importTemplate = function( tplPath, destinationNodes, extendScene, nodePosition, pasteOptions ){ if (typeof nodePosition === 'undefined') var nodePosition = new oPoint(0,0,0); if (typeof destinationNodes === 'undefined' || destinationNodes.length == 0) var destinationNodes = false; if (typeof extendScene === 'undefined') var extendScene = true; if (typeof pasteOptions === 'undefined') var pasteOptions = copyPaste.getCurrentPasteOptions(); pasteOptions.extendScene = extendScene; this.$.beginUndo("oH_importTemplate"); var _group = this.path; if(tplPath instanceof this.$.oFolder) tplPath = tplPath.path; this.$.debug("importing template : "+tplPath, this.$.DEBUG_LEVEL.LOG); var _copyOptions = copyPaste.getCurrentCreateOptions(); var _tpl = copyPaste.copyFromTemplate(tplPath, 0, 999, _copyOptions); // any way to get the length of a template before importing it? if (destinationNodes){ // TODO: deal with import options to specify frames copyPaste.paste(_tpl, destinationNodes.map(function(x){return x.path}), 0, 999, pasteOptions); var _nodes = destinationNodes; }else{ var oldBackdrops = this.backdrops; copyPaste.pasteNewNodes(_tpl, _group, pasteOptions); var _scene = this.scene; var _nodes = selection.selectedNodes().map(function(x){return _scene.$node(x)}); for (var i in _nodes){ // only move the root nodes if (_nodes[i].parent.path != this.path) continue _nodes[i].x += nodePosition.x; _nodes[i].y += nodePosition.y; } // move backdrops present in the template var backdrops = this.backdrops.slice(oldBackdrops.length); for (var i in backdrops){ backdrops[i].x += nodePosition.x; backdrops[i].y += nodePosition.y; } // move waypoints in the top level of the template for (var i in _nodes) { var nodePorts = _nodes[i].outPorts; for (var p = 0; p < nodePorts; p++) { var theseWP = waypoint.childWaypoints(_nodes[i], p); if (theseWP.length > 0) { for (var w in theseWP) { var x = waypoint.coordX(theseWP[w]); var y = waypoint.coordY(theseWP[w]); x += nodePosition.x; y += nodePosition.y; waypoint.setCoord(theseWP[w],x,y); } } } } } this.$.endUndo(); return _nodes; } /** * Adds a backdrop to a group in a specific position. * @param {string} [title="Backdrop"] The title of the backdrop. * @param {string} [body=""] The body text of the backdrop. * @param {$.oColorValue} [color="#323232ff"] The oColorValue of the node. * @param {float} [x=0] The X position of the backdrop, an offset value if nodes are specified. * @param {float} [y=0] The Y position of the backdrop, an offset value if nodes are specified. * @param {float} [width=30] The Width of the backdrop, a padding value if nodes are specified. * @param {float} [height=30] The Height of the backdrop, a padding value if nodes are specified. * * @return {$.oBackdrop} The created backdrop. */ $.oGroupNode.prototype.addBackdrop = function(title, body, color, x, y, width, height ){ if (typeof color === 'undefined') var color = new this.$.oColorValue("#323232ff"); if (typeof body === 'undefined') var body = ""; if (typeof x === 'undefined') var x = 0; if (typeof y === 'undefined') var y = 0; if (typeof width === 'undefined') var width = 30; if (typeof height === 'undefined') var height = 30; var position = {"x":x, "y":y, "w":width, "h":height}; var groupPath = this.path; if(!(color instanceof this.$.oColorValue)) color = new this.$.oColorValue(color); // incrementing title so that two backdrops can't have the same title if (typeof title === 'undefined') var title = "Backdrop"; var _groupBackdrops = Backdrop.backdrops(groupPath); var names = _groupBackdrops.map(function(x){return x.title.text}) var count = 0; var newTitle = title; while (names.indexOf(newTitle) != -1){ count++; newTitle = title+"_"+count; } title = newTitle; var _backdrop = { "position" : position, "title" : {"text":title, "color":4278190080, "size":12, "font":"Arial"}, "description" : {"text":body, "color":4278190080, "size":12, "font":"Arial"}, "color" : color.toInt() } Backdrop.addBackdrop(groupPath, _backdrop) return new this.$.oBackdrop(groupPath, _backdrop) }; /** * Adds a backdrop to a group around specified nodes * @param {$.oNode[]} nodes The nodes that the backdrop encompasses. * @param {string} [title="Backdrop"] The title of the backdrop. * @param {string} [body=""] The body text of the backdrop. * @param {$.oColorValue} [color=#323232ff] The oColorValue of the node. * @param {float} [x=0] The X position of the backdrop, an offset value if nodes are specified. * @param {float} [y=0] The Y position of the backdrop, an offset value if nodes are specified. * @param {float} [width=20] The Width of the backdrop, a padding value if nodes are specified. * @param {float} [height=20] The Height of the backdrop, a padding value if nodes are specified. * * @return {$.oBackdrop} The created backdrop. * @example * function createColoredBackdrop(){ * // This script will prompt for a color and create a backdrop around the selection * $.beginUndo() * * var doc = $.scn; // grab the scene * var nodes = doc.getSelectedNodes(); // grab the selection * * if(!nodes) return // exit the function if no nodes are selected * * var color = pickColor(); // prompt for color * * var group = nodes[0].group // get the group to add the backdrop to * var backdrop = group.addBackdropToNodes(nodes, "BackDrop", "", color) * * $.endUndo(); * * // function to get the color chosen by the user * function pickColor(){ * var d = new QColorDialog; * d.exec(); * var color = d.selectedColor(); * return new $.oColorValue({r:color.red(), g:color.green(), b:color.blue(), a:color.alpha()}) * } * } */ $.oGroupNode.prototype.addBackdropToNodes = function( nodes, title, body, color, x, y, width, height ){ if (typeof color === 'undefined') var color = new this.$.oColorValue("#323232ff"); if (typeof body === 'undefined') var body = ""; if (typeof x === 'undefined') var x = 0; if (typeof y === 'undefined') var y = 0; if (typeof width === 'undefined') var width = 20; if (typeof height === 'undefined') var height = 20; // get default size from node bounds if (typeof nodes === 'undefined') var nodes = []; if (nodes.length > 0) { var _nodeBox = new this.$.oBox(); _nodeBox.includeNodes(nodes); x = _nodeBox.left - x - width; y = _nodeBox.top - y - height; width = _nodeBox.width + width*2; height = _nodeBox.height + height*2; } var _backdrop = this.addBackdrop(title, body, color, x, y, width, height) return _backdrop; }; /** * Imports a PSD into the group. * This function is not available when running as harmony in batch mode. * @param {string} path The PSD file to import. * @param {bool} [separateLayers=true] Separate the layers of the PSD. * @param {bool} [addPeg=true] Whether to add a peg. * @param {bool} [addComposite=true] Whether to add a composite. * @param {string} [alignment="ASIS"] Alignment type. * @param {$.oPoint} [nodePosition={0,0,0}] The position for the node to be placed in the node view. * * @return {$.oNode[]} The nodes being created as part of the PSD import. * @example * // This example browses for a PSD file then import it in the root of the scene, then connects it to the main composite. * * function importCustomPSD(){ * $.beginUndo("importCustomPSD"); * var psd = $.dialog.browseForFile("get PSD", "*.psd"); // prompt for a PSD file * * if (!psd) return; // dialog was cancelled, exit the function * * var doc = $.scn; // get the scene object * var sceneRoot = doc.root // grab the scene root group * var psdNodes = sceneRoot.importPSD(psd); // import the psd with default settings * var psdComp = psdNodes.pop() // get the composite node at the end of the psdNodes array * var sceneComp = doc.$node("Top/Composite") // get the scene main composite * psdComp.linkOutNode(sceneComp); // ... and link the two. * sceneRoot.orderNodeView(); // orders the node view inside the group * $.endUndo(); * } */ $.oGroupNode.prototype.importPSD = function( path, separateLayers, addPeg, addComposite, alignment, nodePosition){ if (typeof alignment === 'undefined') var alignment = "ASIS" // create an enum for alignments? if (typeof addComposite === 'undefined') var addComposite = true; if (typeof addPeg === 'undefined') var addPeg = true; if (typeof separateLayers === 'undefined') var separateLayers = true; if (typeof nodePosition === 'undefined') var nodePosition = new this.$.oPoint(0,0,0); if (this.$.batchMode){ this.$.debug("Error: can't import PSD file "+_psdFile.path+" in batch mode.", this.$.DEBUG_LEVEL.ERROR); return null } var _psdFile = (path instanceof this.$.oFile)?path:new this.$.oFile( path ); if (!_psdFile.exists){ this.$.debug("Error: can't import PSD file "+_psdFile.path+" because it doesn't exist", this.$.DEBUG_LEVEL.ERROR); return null; } this.$.beginUndo("oH_importPSD_"+_psdFile.name); var _elementName = _psdFile.name; var _xSpacing = 45; var _ySpacing = 30; var _element = this.scene.addElement(_elementName, "PSD"); // save scene otherwise PSD is copied correctly into the element // but the TGA for each layer are not generated // TODO: how to go around this to avoid saving? scene.saveAll(); var _drawing = _element.addDrawing(1); if (addPeg) var _peg = this.addNode("PEG", _elementName+"-P", nodePosition); if (addComposite) var _comp = this.addNode("COMPOSITE", _elementName+"-Composite", nodePosition); // Import the PSD in the element CELIO.pasteImageFile({ src : _psdFile.path, dst : { elementId : _element.id, exposure : _drawing.name}}); var _layers = CELIO.getLayerInformation(_psdFile.path); var _info = CELIO.getInformation(_psdFile.path); // create the nodes for each layer var _nodes = []; if (separateLayers){ var _scale = _info.height/scene.defaultResolutionY(); var _x = nodePosition.x - _layers.length/2*_xSpacing; var _y = nodePosition.y - _layers.length/2*_ySpacing; for (var i in _layers){ // generate nodes and set them to show the element for each layer var _layer = _layers[i]; var _layerName = _layer.layerName.split(" ").join("_"); var _nodePosition = new this.$.oPoint(_x+=_xSpacing, _y +=_ySpacing, 0); // get/build the group var _group = this; var _groupPathComponents = _layer.layerPathComponents; var _destinationPath = this.path; var _groupPeg = _peg; var _groupComp = _comp; // recursively creating groups if they are missing for (var i in _groupPathComponents){ var _destinationPath = _destinationPath + "/" + _groupPathComponents[i]; var _nextGroup = this.$.scene.getNodeByPath(_destinationPath); if (!_nextGroup){ _nextGroup = _group.addGroup(_groupPathComponents[i], true, true, [], _nodePosition); if (_groupPeg) _nextGroup.linkInNode(_groupPeg); if (_groupComp) _nextGroup.linkOutNode(_groupComp, 0, 0); } // store the peg/comp for next iteration or layer node _group = _nextGroup; _groupPeg = _group.multiportIn.linkedOutNodes[0]; _groupComp = _group.multiportOut.linkedInNodes[0]; } var _column = this.scene.addColumn("DRAWING", _layerName, _element); var _node = _group.addDrawingNode(_layerName, _nodePosition, _element, _column); _node.enabled = _layers[i].visible; _node.can_animate = false; // use general pref? _node.apply_matte_to_color = "Straight"; _node.alignment_rule = alignment; _node.scale.x = _scale; _node.scale.y = _scale; _column.setValue(_layer.layer != ""?"1:"+_layer.layer:1, 1); _column.extendExposures(); if (_groupPeg) _node.linkInNode(_groupPeg); if (_groupComp) _node.linkOutNode(_groupComp, 0, 0); _nodes.push(_node); } }else{ this.$.endUndo(); throw new Error("importing PSD as a flattened layer not yet implemented"); } if (addPeg){ _peg.centerAbove(_nodes, 0, -_ySpacing ) _nodes.unshift(_peg) } if (addComposite){ _comp.centerBelow(_nodes, 0, _ySpacing ) _nodes.push(_comp) } // TODO how to display only one node with the whole file this.$.endUndo() return _nodes } /** * Updates a PSD previously imported into the group * @param {string} path The updated psd file to import. * @param {bool} [separateLayers=true] Separate the layers of the PSD. * * @return {$.oNode[]} The nodes that have been updated/created */ $.oGroupNode.prototype.updatePSD = function( path, separateLayers ){ if (typeof separateLayers === 'undefined') var separateLayers = true; var _psdFile = (path instanceof this.$.oFile)?path:new this.$.oFile(path); if (!_psdFile.exists){ this.$.debug("Error: can't import PSD file "+_psdFile.path+" for update because it doesn't exist", this.$.DEBUG_LEVEL.ERROR); return null; } this.$.beginUndo("oH_updatePSD_"+_psdFile.name) // get info from the PSD var _info = CELIO.getInformation(_psdFile.path); var _layers = CELIO.getLayerInformation(_psdFile.path); var _scale = _info.height/scene.defaultResolutionY(); // use layer information to find nodes from precedent export if (separateLayers){ var _nodes = this.subNodes(true).filter(function(x){return x.type == "READ"}); var _nodeNames = _nodes.map(function(x){return x.name}); var _psdNodes = []; var _missingLayers = []; var _PSDelement = ""; var _positions = new Array(_layers.length); var _scale = _info.height/scene.defaultResolutionY(); // for each layer find the node by looking at the column name for (var i in _layers){ var _layer = _layers[i]; var _layerName = _layers[i].layerName.split(" ").join("_"); var _found = false; // find the node for (var j in _nodes){ if (_nodes[j].element.format != "PSD") continue; var _drawingColumn = _nodes[j].attributes.drawing.element.column; // update the node if found if (_drawingColumn.name == _layer.layerName){ _psdNodes.push(_nodes[j]); _found = true; // update scale in case PSDfile size changed _nodes[j].scale.x = _scale; _nodes[j].scale.y = _scale; _positions[_layer.position] = _nodes[j]; // store the element _PSDelement = _nodes[j].element break; } // if not found, add to the list of layers to import _found = false; } if (!_found) _missingLayers.push(_layer); } if (_psdNodes.length == 0){ // PSD was never imported, use import instead? this.$.debug("can't find a PSD element to update", this.$.DEBUG_LEVEL.ERROR); this.$.endUndo(); return null; } // pasting updated PSD into element CELIO.pasteImageFile({ src : _psdFile.path, dst : { elementId : _PSDelement.id, exposure : "1"}}) for (var i in _missingLayers){ // find previous import Settings re: group/alignment etc var _layer = _missingLayers[i]; var _layerName = _layer.layerName.split(" ").join("_"); var _layerIndex = _layer.position; var _nodePosition = new this.$.oPoint(0,0,0); var _group = _psdNodes[0].group; var _alignment = _psdNodes[0].alignment_rule; var _scale = _psdNodes[0].scale.x; var _peg = _psdNodes[0].inNodes[0]; var _comp = _psdNodes[0].outNodes[0]; var _scale = _info.height/scene.defaultResolutionY() var _port; //TODO: set into right group according to PSD organisation // looking for the existing node below and get the comp port from it for (var j = _layerIndex-1; j>=0; j--){ if (_positions[j] != undefined) break; } var _nodeBelow = _positions[j]; var _compNodes = _comp.inNodes; for (var j=0; j<_compNodes.length; j++){ if (_nodeBelow.path == _compNodes[j].path){ _port = j+1; _nodePosition = _compNodes[j].nodePosition; _nodePosition.x -= 35; _nodePosition.y -= 25; } } // generate nodes and set them to show the element for each layer var _node = this.addDrawingNode(_layerName, _nodePosition, _PSDelement); _node.enabled = _layer.visible; _node.can_animate = false; // use general pref? _node.apply_matte_to_color = "Straight"; _node.alignment_rule = _alignment; _node.scale.x = _scale; _node.scale.y = _scale; _node.attributes.drawing.element.setValue(_layer.layer != ""?"1:"+_layer.layer:1, 1); _node.attributes.drawing.element.column.extendExposures(); // find composite/peg to connect to based on other layers //if (addPeg) _node.linkInNode(_peg) if (_port) _node.linkOutNode(_comp, 0, _port) _nodes.push(_node); } this.$.endUndo(); return nodes; } else{ this.$.endUndo(); throw new Error("updating a PSD imported as a flattened layer not yet implemented"); } } /** * Import a generic image format (PNG, JPG, TGA etc) as a read node. * @param {string} path The image file to import. * @param {string} [alignment="ASIS"] Alignment type. * @param {$.oPoint} [nodePosition={0,0,0}] The position for the node to be placed in the node view. * * @return {$.oNode} The node for the imported image */ $.oGroupNode.prototype.importImage = function( path, alignment, nodePosition, convertToTvg){ if (typeof alignment === 'undefined') var alignment = "ASIS"; // create an enum for alignments? if (typeof nodePosition === 'undefined') var nodePosition = new this.$.oPoint(0,0,0); var _imageFile = (path instanceof this.$.oFile)?path:new this.$.oFile( path ); var _elementName = _imageFile.name; var _elementType = convertToTvg?"TVG":_imageFile.extension.toUpperCase(); var _element = this.scene.addElement(_elementName, _elementType); var _column = this.scene.addColumn("DRAWING", _elementName, _element); _element.column = _column; if (_imageFile.exists) { var _drawing = _element.addDrawing(1, 1, _imageFile.path, convertToTvg); }else{ this.$.debug("Image file to import "+_imageFile.path+" could not be found.", this.$.DEBUG_LEVEL.ERROR); } var _imageNode = this.addDrawingNode(_elementName, nodePosition, _element); _imageNode.can_animate = false; // use general pref? _imageNode.apply_matte_to_color = "Straight"; _imageNode.alignment_rule = alignment; var _scale = CELIO.getInformation(_imageFile.path).height/this.scene.defaultResolutionY; _imageNode.scale.x = _scale; _imageNode.scale.y = _scale; _imageNode.attributes.drawing.element.setValue(_drawing.name, 1); _imageNode.attributes.drawing.element.column.extendExposures(); // TODO how to display only one node with the whole file return _imageNode; } /** * imports an image as a tvg drawing. * @param {$.oFile} path the image file to import * @param {string} [alignment="ASIS"] the alignment mode for the imported image * @param {$.oPoint} [nodePosition={0,0,0}] the position for the created node. */ $.oGroupNode.prototype.importImageAsTVG = function(path, alignment, nodePosition){ if (!(path instanceof this.$.oFile)) path = new this.$.oFile(path); var _imageNode = this.importImage(_convertedFilePath, alignment, nodePosition, true); _imageNode.name = path.name; return _imageNode; } /** * imports an image sequence as a node into the current group. * @param {$.oFile[]} imagePaths a list of paths to the images to import (can pass a list of strings or $.oFile) * @param {number} [exposureLength=1] the number of frames each drawing should be exposed at. If set to 0/false, each drawing will use the numbering suffix of the file to set its frame. * @param {boolean} [convertToTvg=false] whether to convert the files to tvg during import * @param {string} [alignment="ASIS"] the alignment to apply to the node * @param {$.oPoint} [nodePosition] the position of the node in the nodeview * * @returns {$.oDrawingNode} the created node */ $.oGroupNode.prototype.importImageSequence = function(imagePaths, exposureLength, convertToTvg, alignment, nodePosition, extendScene) { if (typeof exposureLength === 'undefined') var exposureLength = 1; if (typeof alignment === 'undefined') var alignment = "ASIS"; // create an enum for alignments? if (typeof nodePosition === 'undefined') var nodePosition = new this.$.oPoint(0,0,0); if (typeof extendScene === 'undefined') var extendScene = false; // match anything but capture trailing numbers and separates punctuation preceding it var numberingRe = /(.*?)([\W_]+)?(\d*)$/i; // sanitize imagePaths imagePaths = imagePaths.map(function(x){ if (x instanceof this.$.oFile){ return x; } else { return new this.$.oFile(x); } }) var images = []; if (!exposureLength) { // figure out scene length based on exposure and extend the scene if needed var sceneLength = 0; var image = {frame:0, path:""}; for (var i in imagePaths){ var imagePath = imagePaths[i]; if (!(imagePath instanceof this.$.oFile)) imagePath = new this.$.oFile(imagePath); var nameGroups = imagePath.name.match(numberingRe); if (nameGroups[3]){ // use trailing number as frame number var frameNumber = parseInt(nameGroups[3], 10); if (frameNumber > sceneLength) sceneLength = frameNumber; images.push({frame: frameNumber, path:imagePath}); } } } else { // simply create a list of numbers based on exposure images = imagePaths.map(function(x, index){ var frameNumber = index * exposureLength + 1; return ({frame:frameNumber, path:x}); }) var sceneLength = images[images.length-1].frame + exposureLength - 1; } if (extendScene){ if (this.scene.length < sceneLength) this.scene.length = sceneLength; } // create a node to hold the image sequence var firstImage = imagePaths[0]; var name = firstImage.name.match(numberingRe)[1]; // match anything before trailing digits var drawingNode = this.importImage(firstImage, alignment, nodePosition, convertToTvg); drawingNode.name = name; for (var i in images){ var image = images[i]; drawingNode.element.addDrawing(image.frame, image.frame, image.path, convertToTvg); } drawingNode.timingColumn.extendExposures(); return drawingNode; } /** * Imports a QT into the group * @param {string} path The palette file to import. * @param {bool} [importSound=true] Whether to import the sound * @param {bool} [extendScene=true] Whether to extend the scene to the duration of the QT. * @param {string} [alignment="ASIS"] Alignment type. * @param {$.oPoint} [nodePosition] The position for the node to be placed in the network. * * @return {$.oNode} The imported Quicktime Node. */ $.oGroupNode.prototype.importQT = function( path, importSound, extendScene, alignment, nodePosition){ if (typeof alignment === 'undefined') var alignment = "ASIS"; if (typeof extendScene === 'undefined') var extendScene = true; if (typeof importSound === 'undefined') var importSound = true; if (typeof nodePosition === 'undefined') var nodePosition = new this.$.oPoint(0,0,0); var _QTFile = (path instanceof this.$.oFile)?path:new this.$.oFile(path); if (!_QTFile.exists){ throw new Error ("Import Quicktime failed: file "+_QTFile.path+" doesn't exist"); } var _movieName = _QTFile.name; this.$.beginUndo("oH_importQT_"+_movieName); var _element = this.scene.addElement(_movieName, "PNG"); var _elementName = _element.name; var _movieNode = this.addDrawingNode(_movieName, nodePosition, _element); var _column = _movieNode.attributes.drawing.element.column; _element.column = _column; // setup the node _movieNode.can_animate = false; _movieNode.alignment_rule = alignment; // create the temp folder var _tempFolder = new this.$.oFolder(this.$.scn.tempFolder.path + "/movImport/" + _element.id); _tempFolder.create(); var _tempFolderPath = _tempFolder.path; var _audioPath = _tempFolder.path + "/" + _movieName + ".wav"; // progressDialog will display an infinite loading bar as we don't have precise feedback var progressDialog = new this.$.oProgressDialog("Importing video...", 0, "Import Movie", true); // setup import MovieImport.setMovieFilename(_QTFile.path); MovieImport.setImageFolder(_tempFolder); MovieImport.setImagePrefix(_movieName); if (importSound) MovieImport.setAudioFile(_audioPath); this.$.log("converting movie file to pngs..."); MovieImport.doImport(); this.$.log("conversion finished"); progressDialog.range = 100; progressDialog.value = 80; var _movielength = MovieImport.numberOfImages(); if (extendScene && this.scene.length < _movielength) this.scene.length = _movielength; // create a drawing for each frame for (var i=1; i<=_movielength; i++) { _drawingPath = _tempFolder + "/" + _movieName + "-" + i + ".png"; _element.addDrawing(i, i, _drawingPath); } progressDialog.value = 95; // creating an audio column for the sound if (importSound && MovieImport.isAudioFileCreated() ){ var _soundName = _elementName + "_sound"; var _soundColumn = this.scene.addColumn("SOUND", _soundName); column.importSound( _soundColumn.name, 1, _audioPath); } progressDialog.value = 100; this.$.endUndo(); return _movieNode; } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_nodeAttributes.js ================================================ var docstringStart = "/**" var docstringEnd = "*/" /* // values outputted by following script: function traceAll(){ var globalMessage = [docstringStart+"\n * Attributes associated to Node types\n * @class NodeTypes \n "+docstringEnd]; var nodes = selection.selectedNodes() for (var i in nodes){ var message = [ "Attributes present in the node : " + node.getName(nodes[i]), "@name NodeTypes#"+ node.type(nodes[i]) ] message = message.concat(node.getAttrList( nodes[i], 1).map(function(x){return traceAttributes(x, nodes[i])); message = ("\n "+docstringStart+"\n * ")+message.join("\n * ")+("\n "+docstringEnd+"\n"); globalMessage.push(message); } MessageLog.trace(globalMessage.join("\n")); } function traceAttributes(attribute, theNode){ var message = [formatAttribute(attribute, theNode)]; if (attribute.hasSubAttributes()){ var subattributes = attribute.getSubAttributes(); for (var i in subattributes){ message = message.concat(traceAttributes(subattributes[i], theNode)); } } return message } function formatAttribute(attr, theNode){ var keyword = attr.fullKeyword(); var type = attr.typeName().toLowerCase(); var name = attr.name(); var defaultValue = node.getTextAttr(theNode, 1, keyword).split(" ").join("_").replace(".0000", ""); if (defaultValue == "N") defaultValue = "false"; if (defaultValue == "Y") defaultValue = "true"; var message = "@property {"+type+"} "+keyword.toLowerCase()+((defaultValue)?"="+defaultValue:"")+" - "+name+"." return message; } */ /** * Attributes associated to Node types.
These are the types to specify when creating a node, and the corresponding usual node name when creating directly through Harmony's interface. The attributes displayed here can be set and manipulated by calling the displayed names. * @class NodeTypes * @hideconstructor * @namespace * @example * // This is how to use this page: * * var myNode = $.scn.root.addNode("READ"); // This is the node type as specified for each node under the default display name. * $.log(myNode.type) // This is how to find out the type * * myNode.drawing.element = "1" // Sets the drawing.element attribute to display drawing "1" * * myNode.drawing.element = {frameNumber: 5, "2"} // If the attribute can be animated, pass a {frameNumber, value} object to set a specific frame; * * myNode.attributes.drawing.element.setValue ("2", 5) // also possible to set the attribute directly. * * // refer to the node type on this page to find out what properties can be set with what synthax for each Node Type. */ /** * Attributes present in the node of type: 'MasterController' * @name NodeTypes#MasterController * @property {string} [specs_editor=" "] - Specifications. * @property {file_editor} script_editor - . * @property {file_editor} init_script - . * @property {file_editor} cleanup_script - . * @property {file_editor} ui_script - . * @property {string} ui_data - . * @property {file_library} files - . * @property {generic_enum} [show_controls_mode="Normal"] - Show Controls Mode. */ /** * Attributes present in the node of type: 'ShapeCurve' * @name NodeTypes#Shape-Curve * @property {bool} flattenz=true - Flatten Z. * @property {position_3d} position1 - Position 1. * @property {bool} position1.separate=On - Separate. * @property {double} position1.x=0 - Pos x. * @property {double} position1.y=0 - Pos y. * @property {double} position1.z=0 - Pos z. * @property {path_3d} position1.3dpath - Path. * @property {position_3d} position2 - Left Handle Offset. * @property {bool} position2.separate=On - Separate. * @property {double} position2.x=0 - Pos x. * @property {double} position2.y=-1 - Pos y. * @property {double} position2.z=0 - Pos z. * @property {path_3d} position2.3dpath - Path. * @property {position_3d} position3 - Right Handle Offset. * @property {bool} position3.separate=On - Separate. * @property {double} position3.x=0 - Pos x. * @property {double} position3.y=1 - Pos y. * @property {double} position3.z=0 - Pos z. * @property {path_3d} position3.3dpath - Path. * @property {bool} closeshape=false - Close Shape Contour. */ /** * Attributes present in the node of type: 'SubNodeAnimation' * @name NodeTypes#Subnode-Animation */ /** * Attributes present in the node of type: 'BoneModule' * @name NodeTypes#Stick * @property {generic_enum} [influencetype="Infinite"] - Influence Type. * @property {double} influencefade=0.5000 - Influence Fade Radius. * @property {bool} symmetric=true - Symmetric Ellipse of Influence. * @property {double} transversalradius=1 - Transversal Influence Radius Left. * @property {double} transversalradiusright=1 - Transversal Influence Radius Right. * @property {double} longitudinalradiusbegin=1 - Longitudinal Influence Radius Begin. * @property {double} longitudinalradius=1 - Longitudinal Influence Radius End. * @property {double} restlength=3 - Rest Length. * @property {double} length=3 - Length. */ /** * Attributes present in the node of type: 'CurveModule' * @name NodeTypes#Curve * @property {bool} localreferential=true - Apply Parent Transformation. * @property {generic_enum} [influencetype="Infinite"] - Influence Type. * @property {double} influencefade=0.5000 - Influence Fade Radius. * @property {bool} symmetric=true - Symmetric Ellipse of Influence. * @property {double} transversalradius=1 - Transversal Influence Radius Left. * @property {double} transversalradiusright=1 - Transversal Influence Radius Right. * @property {double} longitudinalradiusbegin=1 - Longitudinal Influence Radius Begin. * @property {double} longitudinalradius=1 - Longitudinal Influence Radius End. * @property {bool} closepath=false - Close Contour. * @property {double} restlength0=2 - Rest Length 0. * @property {double} restingorientation0=0 - Resting Orientation 0. * @property {position_2d} restingoffset - Resting Offset. * @property {bool} restingoffset.separate=On - Separate. * @property {double} restingoffset.x=6 - Pos x. * @property {double} restingoffset.y=0 - Pos y. * @property {double} restlength1=2 - Rest Length 1. * @property {double} restingorientation1=0 - Resting Orientation 1. * @property {double} length0=2 - Length 0. * @property {double} orientation0=0 - Orientation 0. * @property {position_2d} offset - Offset. * @property {bool} offset.separate=On - Separate. * @property {double} offset.x=6 - Pos x. * @property {double} offset.y=0 - Pos y. * @property {point_2d} offset.2dpoint - Point. * @property {double} length1=2 - Length 1. * @property {double} orientation1=0 - Orientation 1. */ /** * Attributes present in the node of type: 'BendyBoneModule' * @name NodeTypes#Bone * @property {generic_enum} [influencetype="Infinite"] - Influence Type. * @property {double} influencefade=0.5000 - Influence Fade Radius. * @property {bool} symmetric=true - Symmetric Ellipse of Influence. * @property {double} transversalradius=1 - Transversal Influence Radius Left. * @property {double} transversalradiusright=1 - Transversal Influence Radius Right. * @property {double} longitudinalradiusbegin=1 - Longitudinal Influence Radius Begin. * @property {double} longitudinalradius=1 - Longitudinal Influence Radius End. * @property {position_2d} restoffset - Rest Offset. * @property {bool} restoffset.separate=On - Separate. * @property {double} restoffset.x=0 - Pos x. * @property {double} restoffset.y=0 - Pos y. * @property {double} restorientation=0 - Rest Orientation. * @property {double} restradius=0.5000 - Rest Radius. * @property {double} restbias=0.4500 - Rest Bias. * @property {double} restlength=3 - Rest Length. * @property {position_2d} offset - Offset. * @property {bool} offset.separate=On - Separate. * @property {double} offset.x=0 - Pos x. * @property {double} offset.y=0 - Pos y. * @property {point_2d} offset.2dpoint - Point. * @property {double} orientation=0 - Orientation. * @property {double} radius=0.5000 - Radius. * @property {double} bias=0.4500 - Bias. * @property {double} length=3 - Length. */ /** * Attributes present in the node of type: 'ShapeRender' * @name NodeTypes#Shape-Render * @property {generic_enum} [edgetype="Hard Edge"] - Edge Type. * @property {double} scale=25 - Scale. * @property {double} discretizationscale=5 - Discretization Scale. * @property {double} preblur=0 - Pre-Blur. * @property {double} postblur=0 - Post-Blur. */ /** * Attributes present in the node of type: 'NormalFloat' * @name NodeTypes#Normal-Map-Converter * @property {generic_enum} [conversiontype="Genarts"] - Conversion Type. * @property {double} offset=0 - Offset. * @property {double} length=1 - Length. * @property {bool} invertred=false - Invert Red. * @property {bool} invertgreen=false - Invert Green. * @property {bool} invertblue=false - Invert Blue. */ /** * Attributes present in the node of type: 'GameBoneModule' * @name NodeTypes#Game-Bone * @property {position_2d} restoffset - Rest Offset. * @property {bool} restoffset.separate=On - Separate. * @property {double} restoffset.x=0 - Pos x. * @property {double} restoffset.y=0 - Pos y. * @property {double} restorientation=0 - Rest Orientation. * @property {double} restradius=0.5000 - Rest Radius. * @property {double} restbias=0.4500 - Rest Bias. * @property {double} restlength=3 - Rest Length. * @property {position_2d} offset - Offset. * @property {bool} offset.separate=On - Separate. * @property {double} offset.x=0 - Pos x. * @property {double} offset.y=0 - Pos y. * @property {point_2d} offset.2dpoint - Point. * @property {double} orientation=0 - Orientation. * @property {double} radius=0.5000 - Radius. * @property {double} bias=0.4500 - Bias. * @property {double} length=3 - Length. */ /** * Attributes present in the node of type: 'OffsetModule' * @name NodeTypes#Offset * @property {bool} localreferential=true - Apply Parent Transformation. * @property {position_2d} restingoffset - Resting Offset. * @property {bool} restingoffset.separate=On - Separate. * @property {double} restingoffset.x=1 - Pos x. * @property {double} restingoffset.y=0 - Pos y. * @property {double} restingorientation=0 - Resting Orientation. * @property {position_2d} offset - Offset. * @property {bool} offset.separate=On - Separate. * @property {double} offset.x=1 - Pos x. * @property {double} offset.y=0 - Pos y. * @property {point_2d} offset.2dpoint - Point. * @property {double} orientation=0 - Orientation. */ /** * Attributes present in the node of type: 'ArticulationModule' * @name NodeTypes#Articulation * @property {generic_enum} [influencetype="Infinite"] - Influence Type. * @property {double} influencefade=0.5000 - Influence Fade Radius. * @property {bool} symmetric=true - Symmetric Ellipse of Influence. * @property {double} transversalradius=1 - Transversal Influence Radius Left. * @property {double} transversalradiusright=1 - Transversal Influence Radius Right. * @property {double} longitudinalradiusbegin=1 - Longitudinal Influence Radius Begin. * @property {double} longitudinalradius=1 - Longitudinal Influence Radius End. * @property {double} restradius=0.5000 - Rest Radius. * @property {double} restingorientation=0 - Resting Orientation. * @property {double} restbias=0.4500 - Rest Bias. * @property {double} radius=0.5000 - Radius. * @property {double} orientation=0 - Orientation. * @property {double} bias=0.4500 - Bias. */ /** * Attributes present in the node of type: 'TransformGate' * @name NodeTypes#Transformation-Gate * @property {double} active=100 - ACTIVE. * @property {int} target_gate=1 - LOCAL TARGET GATE. * @property {int} default_gate=0 - DEFAULT GATE. */ /** * Attributes present in the node of type: 'ParticleBaker' * @name NodeTypes#Particle-Baker * @property {int} maxnumparticles=10000 - Maximum Number of Particles. * @property {generic_enum} [simulationquality="Normal"] - Simulation Quality. * @property {int} seed=0 - Seed. * @property {int} transientframes=0 - Number of Pre-roll Frames. * @property {bool} moveage=false - Age Particles. * @property {bool} moveposition=true - Move Position. * @property {bool} moveangle=false - Move Angle. * @property {bool} roundage=false - Round Particle Age. */ /** * Attributes present in the node of type: 'KinematicOutputModule' * @name NodeTypes#Kinematic-Output */ /** * Attributes present in the node of type: 'ParticleVisualizer' * @name NodeTypes#Particle-Visualizer * @property {bool} forcedots=false - Force to Render as Dots. * @property {generic_enum} [sortingstrategy="Back to Front"] - Rendering Order. * @property {bool} fixalpha=true - Fix Output Alpha. * @property {bool} useviewscaling=true - Scale Particle System Using Parent Peg. * @property {double} globalsize=1 - Global Scaling Factor. */ /** * Attributes present in the node of type: 'FreeFormDeformation' * @name NodeTypes#Free-Form-Deformer * @property {generic_enum} [deformationquality="Very High"] - Deformation Quality. */ /** * Attributes present in the node of type: 'ComputeNormals' * @name NodeTypes#Normal-Map * @property {string} objectlist - Volume Creation. * @property {bool} depthinblue=false - Output Elevation in Blue Channel. * @property {double} blurscale=1 - Bevel Multiplier. * @property {bool} clipblurredwithgeometry=true - Clip Blurred Image with Geometry. * @property {double} elevationscale=1 - Elevation Multiplier. * @property {double} elevationsmoothness=1 - Elevation Smoothness Multiplier. * @property {bool} generatenormals=true - Generate Normals. * @property {generic_enum} [normalquality="Low"] - Normal Map Quality. * @property {bool} usetruckfactor=false - Consider Truck Factor. * @property {string} colorinformation - Colour Information. */ /** * Attributes present in the node of type: 'PointConstraint3' * @name NodeTypes#Three-Points-Constraints * @property {double} active=100 - Active. * @property {generic_enum} [flattentype="Allow 3D Transform"] - Flatten Type. * @property {generic_enum} [transformtype="Translate"] - Transform Type. * @property {generic_enum} [primaryport="Right"] - Primary Port. */ /** * Attributes present in the node of type: 'GROUP' * @name NodeTypes#Group * @property {string} [editor=" "] - . * @property {string} target_composite - Target Composite. * @property {string} timeline_module - Substitute Node in Timeline. * @property {bool} mask=false - Mask Flag. * @property {bool} publish_to_parents=true - Publish to Parent Groups. */ /** * Attributes present in the node of type: 'ComputeWorld' * @name NodeTypes#Surface-Map * @property {string} objectlist - Volume Creation. * @property {double} blurscale=1 - Bevel Multiplier. * @property {bool} clipblurredwithgeometry=true - Clip Blurred Image with Geometry. * @property {double} elevationscale=1 - Elevation Multiplier. * @property {double} elevationsmoothness=1 - Elevation Smoothness Multiplier. * @property {bool} usetruckfactor=false - Consider Truck Factor. * @property {string} colorinformation - Colour Information. */ /** * Attributes present in the node of type: 'FOCUS_SET' * @name NodeTypes#Focus * @property {bool} mirror=true - Mirror. * @property {double} ratio=2 - Mirror Front/Back Ratio. * @property {simple_bezier} radius=(Curve) - Radius. * @property {generic_enum} [quality="High"] - Quality. */ /** * Attributes present in the node of type: 'SCRIPT_MODULE' * @name NodeTypes#ScriptModule * @property {string} [specs_editor=" "] - Specifications. * @property {file_editor} script_editor - . * @property {file_editor} init_script - . * @property {file_editor} cleanup_script - . * @property {file_editor} ui_script - . * @property {string} ui_data - . * @property {file_library} files - . */ /** * Attributes present in the node of type: 'StaticConstraint' * @name NodeTypes#Static-Transformation * @property {push_button} bakeattr - Bake Immediate Parent's Transformation. * @property {push_button} bakeattr_all - Bake All Incoming Transformations. * @property {bool} active=false - Active. * @property {position_3d} translate - Translate. * @property {bool} translate.separate=On - Separate. * @property {double} translate.x=0 - Pos x. * @property {double} translate.y=0 - Pos y. * @property {double} translate.z=0 - Pos z. * @property {scale_3d} scale - Skew. * @property {bool} scale.separate=On - Separate. * @property {bool} scale.in_fields=Off - In fields. * @property {doublevb} scale.xy=1 - Scale. * @property {doublevb} scale.x=1 - Scale x. * @property {doublevb} scale.y=1 - Scale y. * @property {doublevb} scale.z=1 - Scale z. * @property {rotation_3d} rotate - Rotate. * @property {bool} rotate.separate=On - Separate. * @property {doublevb} rotate.anglex=0 - Angle_x. * @property {doublevb} rotate.angley=0 - Angle_y. * @property {doublevb} rotate.anglez=0 - Angle_z. * @property {double} skewx=0 - Skew X. * @property {double} skewy=0 - Skew Y. * @property {double} skewz=0 - Skew Z. * @property {bool} inverted=false - Invert Transformation. */ /** * Attributes present in the node of type: 'GLCacheLock' * @name NodeTypes#OpenGL-Cache-Lock * @property {bool} composite_3d=false - 3D. */ /** * Attributes present in the node of type: 'PLUGIN' * @name NodeTypes#Brightness-Contrast * @property {double} b=0 - Brightness. * @property {double} c=0 - Contrast. */ /** * Attributes present in the node of type: 'PLUGIN' * @name NodeTypes#Sparkle * @property {double} angle=0 - Start angle. * @property {double} scale=1 - Scale. * @property {double} factor=0.7500 - Factor. * @property {double} density=50 - Density. * @property {int} n_points=8 - Number of Points. * @property {double} prob_app=100 - Probability of Appearing. * @property {double} point_noise=0 - Point Noise. * @property {double} center_noise=0 - Center Noise. * @property {double} angle_noise=0 - Angle Noise. * @property {int} seed=0 - Random Seed. * @property {bool} use_drawing_color=true - Use Drawing Colours. * @property {bool} flatten_sparkles_of_same_color=true - Flatten Sparkles of Same Colour. * @property {color} sparkle_color=80ffffff - Sparkles' Colour. * @property {int} sparkle_color.red=255 - Red. * @property {int} sparkle_color.green=255 - Green. * @property {int} sparkle_color.blue=255 - Blue. * @property {int} sparkle_color.alpha=128 - Alpha. * @property {generic_enum} [sparkle_color.preferred_ui="Separate"] - Preferred Editor. */ /** * Attributes present in the node of type: 'WeightedDeform' * @name NodeTypes#Weighted-Deform * @property {double} blend=0 - Pre-Blend Amount. * @property {double} postblend=0 - Post-Blend Amount. * @property {generic_enum} [deformationquality="Very High"] - Deformation Quality. */ /** * Attributes present in the node of type: 'BurnIn' * @name NodeTypes#Burn-In * @property {bool} drawbackgroundbox=false - Add background box. * @property {color} textcolor=ffffffff - Text colour. * @property {int} textcolor.red=255 - Red. * @property {int} textcolor.green=255 - Green. * @property {int} textcolor.blue=255 - Blue. * @property {int} textcolor.alpha=255 - Alpha. * @property {generic_enum} [textcolor.preferred_ui="Separate"] - Preferred Editor. * @property {color} backgroundcolor=ff000000 - Background colour. * @property {int} backgroundcolor.red=0 - Red. * @property {int} backgroundcolor.green=0 - Green. * @property {int} backgroundcolor.blue=0 - Blue. * @property {int} backgroundcolor.alpha=255 - Alpha. * @property {generic_enum} [backgroundcolor.preferred_ui="Separate"] - Preferred Editor. * @property {int} size=36 - Size. * @property {int} frameoffset=0 - Frame Offset. * @property {bool} drawframenumber=true - Frame Number. * @property {bool} drawtimecode=true - Time code. * @property {string} [printinfo="Environment %e Job %j Scene %s"] - Scene Name. * @property {generic_enum} [alignment="Left"] - Alignment. * @property {int} font=Arial - Font. */ /** * Attributes present in the node of type: 'TransformLimit' * @name NodeTypes#Transformation-Limit * @property {double} active=100 - Active. * @property {generic_enum} [switchtype="Active Value"] - Switch Effects. * @property {double} tx=100 - Translate X. * @property {double} ty=100 - Translate Y. * @property {double} tz=100 - Translate Z. * @property {double} rot=100 - Rotate. * @property {double} skew=100 - Skew. * @property {double} sx=100 - Scale X. * @property {double} sy=100 - Scale Y. * @property {bool} allowflip=true - Allow Flipping. * @property {bool} uniformscale=false - Uniform Scale. * @property {double} pignore=0 - Ignore Parents. * @property {string} parentname - Parent's Name. * @property {generic_enum} [flattentype="Allow 3D Translate"] - Flatten Type. * @property {generic_enum} [skewtype="Skew Optimized for Rotation"] - Skew Type. * @property {generic_enum} [flipaxis="Allow Flip on X-Axis"] - Flip Axis. * @property {position_2d} pos - Control Position. * @property {bool} pos.separate=On - Separate. * @property {double} pos.x=0 - Pos x. * @property {double} pos.y=0 - Pos y. * @property {point_2d} pos.2dpoint - Point. */ /** * Attributes present in the node of type: 'SCRIPT_MODULE' * @name NodeTypes#Maya-Batch-Render * @property {string} [specs_editor=" "] - Specifications. * @property {file_editor} script_editor - . * @property {file_editor} init_script - . * @property {file_editor} cleanup_script - . * @property {file_editor} ui_script - . * @property {string} ui_data - . * @property {file_library} files - . * @property {string} renderer - renderer. */ /** * Attributes present in the node of type: 'FIELD_CHART' * @name NodeTypes#Field-Chart * @property {bool} enable_3d=false - Enable 3D. * @property {bool} face_camera=false - Face Camera. * @property {generic_enum} [camera_alignment="None"] - Camera Alignment. * @property {position_3d} offset - Position. * @property {bool} offset.separate=On - Separate. * @property {double} offset.x=0 - Pos x. * @property {double} offset.y=0 - Pos y. * @property {double} offset.z=0 - Pos z. * @property {path_3d} offset.3dpath - Path. * @property {scale_3d} scale - Scale. * @property {bool} scale.separate=On - Separate. * @property {bool} scale.in_fields=Off - In fields. * @property {doublevb} scale.xy=1 - Scale. * @property {doublevb} scale.x=1 - Scale x. * @property {doublevb} scale.y=1 - Scale y. * @property {doublevb} scale.z=1 - Scale z. * @property {rotation_3d} rotation - Rotation. * @property {bool} rotation.separate=Off - Separate. * @property {doublevb} rotation.anglex=0 - Angle_x. * @property {doublevb} rotation.angley=0 - Angle_y. * @property {doublevb} rotation.anglez=0 - Angle_z. * @property {quaternion_path} rotation.quaternionpath - Quaternion. * @property {alias} angle=0 - Angle. * @property {double} skew=0 - Skew. * @property {position_3d} pivot - Pivot. * @property {bool} pivot.separate=On - Separate. * @property {double} pivot.x=0 - Pos x. * @property {double} pivot.y=0 - Pos y. * @property {double} pivot.z=0 - Pos z. * @property {position_3d} spline_offset - Spline Offset. * @property {bool} spline_offset.separate=On - Separate. * @property {double} spline_offset.x=0 - Pos x. * @property {double} spline_offset.y=0 - Pos y. * @property {double} spline_offset.z=0 - Pos z. * @property {bool} ignore_parent_peg_scaling=false - Ignore Parent Scaling. * @property {bool} disable_field_rendering=false - Disable Field Rendering. * @property {int} depth=0 - Depth. * @property {bool} enable_min_max_angle=false - Enable Min/Max Angle. * @property {double} min_angle=-360 - Min Angle. * @property {double} max_angle=360 - Max Angle. * @property {bool} nail_for_children=false - Nail for Children. * @property {bool} ik_hold_orientation=false - Hold Orientation in IK. * @property {bool} ik_hold_x=false - Hold X in IK. * @property {bool} ik_hold_y=false - Hold Y in IK. * @property {bool} ik_excluded=false - Is Excluded from IK. * @property {bool} ik_can_rotate=true - Can Rotate during IK. * @property {bool} ik_can_translate_x=false - Can Translate in X during IK. * @property {bool} ik_can_translate_y=false - Can Translate in Y during IK. * @property {double} ik_bone_x=0.2000 - X Direction of Bone. * @property {double} ik_bone_y=0 - Y Direction of Bone. * @property {double} ik_stiffness=1 - Stiffness of Bone. * @property {drawing} drawing - Drawing. * @property {bool} drawing.element_mode=On - Element Mode. * @property {element} drawing.element=unknown - Element. * @property {string} drawing.element.layer - Layer. * @property {custom_name} drawing.custom_name - Custom Name. * @property {string} drawing.custom_name.name - Local Name. * @property {timing} drawing.custom_name.timing - Timing. * @property {string} drawing.custom_name.extension=tga - Extension. * @property {double} drawing.custom_name.field_chart=12 - FieldChart. * @property {bool} read_overlay=true - Overlay Art Enabled. * @property {bool} read_line_art=true - Line Art Enabled. * @property {bool} read_color_art=true - Colour Art Enabled. * @property {bool} read_underlay=true - Underlay Art Enabled. * @property {generic_enum} [overlay_art_drawing_mode="Vector"] - Overlay Art Type. * @property {generic_enum} [line_art_drawing_mode="Vector"] - Line Art Type. * @property {generic_enum} [color_art_drawing_mode="Vector"] - Colour Art Type. * @property {generic_enum} [underlay_art_drawing_mode="Vector"] - Underlay Art Type. * @property {bool} pencil_line_deformation_preserve_thickness=false - Preserve Line Thickness. * @property {generic_enum} [pencil_line_deformation_quality="Low"] - Pencil Lines Quality. * @property {int} pencil_line_deformation_smooth=1 - Pencil Lines Smoothing. * @property {double} pencil_line_deformation_fit_error=3 - Fit Error. * @property {bool} read_color=true - Colour. * @property {bool} read_transparency=true - Transparency. * @property {generic_enum} [color_transformation="Linear"] - Colour Space. * @property {string} color_space - Colour Space. * @property {generic_enum} [apply_matte_to_color="Premultiplied with Black"] - Transparency Type. * @property {bool} enable_line_texture=true - Enable Line Texture. * @property {generic_enum} [antialiasing_quality="Medium"] - Antialiasing Quality. * @property {double} antialiasing_exponent=1 - Antialiasing Exponent. * @property {double} opacity=100 - Opacity. * @property {generic_enum} [texture_filter="Nearest (Filtered)"] - Texture Filter. * @property {bool} adjust_pencil_thickness=false - Adjust Pencil Lines Thickness. * @property {bool} normal_line_art_thickness=true - Normal Thickness. * @property {generic_enum} [zoom_independent_line_art_thickness="Scale Independent"] - Scale Independent. * @property {double} mult_line_art_thickness=1 - Proportional. * @property {double} add_line_art_thickness=0 - Constant. * @property {double} min_line_art_thickness=0 - Minimum. * @property {double} max_line_art_thickness=0 - Maximum. * @property {generic_enum} [use_drawing_pivot="Apply Embedded Pivot on Drawing Layer"] - Use Embedded Pivots. * @property {bool} flip_hor=false - Flip Horizontal. * @property {bool} flip_vert=false - Flip Vertical. * @property {bool} turn_before_alignment=false - Turn Before Alignment. * @property {bool} no_clipping=false - No Clipping. * @property {int} x_clip_factor=0 - Clipping Factor (x). * @property {int} y_clip_factor=0 - Clipping Factor (y). * @property {generic_enum} [alignment_rule="Center First Page"] - Alignment Rule. * @property {double} morphing_velo=0 - Morphing Velocity. * @property {bool} can_animate=true - Animate Using Animation Tools. * @property {bool} tile_horizontal=false - Tile Horizontally. * @property {bool} tile_vertical=false - Tile Vertically. * @property {generic_enum} [size="12"] - Size. * @property {bool} opaque=false - Opaque. */ /** * Attributes present in the node of type: 'READ' * @name NodeTypes#Drawing * @property {bool} enable_3d=false - Enable 3D. * @property {bool} face_camera=false - Face Camera. * @property {generic_enum} [camera_alignment="None"] - Camera Alignment. * @property {position_3d} offset - Position. * @property {bool} offset.separate=On - Separate. * @property {double} offset.x=0 - Pos x. * @property {double} offset.y=0 - Pos y. * @property {double} offset.z=0 - Pos z. * @property {path_3d} offset.3dpath - Path. * @property {scale_3d} scale - Scale. * @property {bool} scale.separate=On - Separate. * @property {bool} scale.in_fields=Off - In fields. * @property {doublevb} scale.xy=1 - Scale. * @property {doublevb} scale.x=1 - Scale x. * @property {doublevb} scale.y=1 - Scale y. * @property {doublevb} scale.z=1 - Scale z. * @property {rotation_3d} rotation - Rotation. * @property {bool} rotation.separate=Off - Separate. * @property {doublevb} rotation.anglex=0 - Angle_x. * @property {doublevb} rotation.angley=0 - Angle_y. * @property {doublevb} rotation.anglez=0 - Angle_z. * @property {quaternion_path} rotation.quaternionpath - Quaternion. * @property {alias} angle=0 - Angle. * @property {double} skew=0 - Skew. * @property {position_3d} pivot - Pivot. * @property {bool} pivot.separate=On - Separate. * @property {double} pivot.x=0 - Pos x. * @property {double} pivot.y=0 - Pos y. * @property {double} pivot.z=0 - Pos z. * @property {position_3d} spline_offset - Spline Offset. * @property {bool} spline_offset.separate=On - Separate. * @property {double} spline_offset.x=0 - Pos x. * @property {double} spline_offset.y=0 - Pos y. * @property {double} spline_offset.z=0 - Pos z. * @property {bool} ignore_parent_peg_scaling=false - Ignore Parent Scaling. * @property {bool} disable_field_rendering=false - Disable Field Rendering. * @property {int} depth=0 - Depth. * @property {bool} enable_min_max_angle=false - Enable Min/Max Angle. * @property {double} min_angle=-360 - Min Angle. * @property {double} max_angle=360 - Max Angle. * @property {bool} nail_for_children=false - Nail for Children. * @property {bool} ik_hold_orientation=false - Hold Orientation in IK. * @property {bool} ik_hold_x=false - Hold X in IK. * @property {bool} ik_hold_y=false - Hold Y in IK. * @property {bool} ik_excluded=false - Is Excluded from IK. * @property {bool} ik_can_rotate=true - Can Rotate during IK. * @property {bool} ik_can_translate_x=false - Can Translate in X during IK. * @property {bool} ik_can_translate_y=false - Can Translate in Y during IK. * @property {double} ik_bone_x=0.2000 - X Direction of Bone. * @property {double} ik_bone_y=0 - Y Direction of Bone. * @property {double} ik_stiffness=1 - Stiffness of Bone. * @property {drawing} drawing=C:/Users/mathieuc/Documents/NewScene/elements/Drawing/Drawing-1.tvg - Drawing. * @property {bool} drawing.element_mode=On - Element Mode. * @property {element} drawing.element=unknown - Element. * @property {string} drawing.element.layer - Layer. * @property {custom_name} drawing.custom_name - Custom Name. * @property {string} drawing.custom_name.name - Local Name. * @property {timing} drawing.custom_name.timing - Timing. * @property {string} drawing.custom_name.extension=tga - Extension. * @property {double} drawing.custom_name.field_chart=12 - FieldChart. * @property {bool} read_overlay=true - Overlay Art Enabled. * @property {bool} read_line_art=true - Line Art Enabled. * @property {bool} read_color_art=true - Colour Art Enabled. * @property {bool} read_underlay=true - Underlay Art Enabled. * @property {generic_enum} [overlay_art_drawing_mode="Vector"] - Overlay Art Type. * @property {generic_enum} [line_art_drawing_mode="Vector"] - Line Art Type. * @property {generic_enum} [color_art_drawing_mode="Vector"] - Colour Art Type. * @property {generic_enum} [underlay_art_drawing_mode="Vector"] - Underlay Art Type. * @property {bool} pencil_line_deformation_preserve_thickness=false - Preserve Line Thickness. * @property {generic_enum} [pencil_line_deformation_quality="Low"] - Pencil Lines Quality. * @property {int} pencil_line_deformation_smooth=1 - Pencil Lines Smoothing. * @property {double} pencil_line_deformation_fit_error=3 - Fit Error. * @property {bool} read_color=true - Colour. * @property {bool} read_transparency=true - Transparency. * @property {generic_enum} [color_transformation="Linear"] - Colour Space. * @property {string} color_space - Colour Space. * @property {generic_enum} [apply_matte_to_color="Premultiplied with Black"] - Transparency Type. * @property {bool} enable_line_texture=true - Enable Line Texture. * @property {generic_enum} [antialiasing_quality="High"] - Antialiasing Quality. * @property {double} antialiasing_exponent=1 - Antialiasing Exponent. * @property {double} opacity=100 - Opacity. * @property {generic_enum} [texture_filter="Nearest (Filtered)"] - Texture Filter. * @property {bool} adjust_pencil_thickness=false - Adjust Pencil Lines Thickness. * @property {bool} normal_line_art_thickness=true - Normal Thickness. * @property {generic_enum} [zoom_independent_line_art_thickness="Scale Independent"] - Scale Independent. * @property {double} mult_line_art_thickness=1 - Proportional. * @property {double} add_line_art_thickness=0 - Constant. * @property {double} min_line_art_thickness=0 - Minimum. * @property {double} max_line_art_thickness=0 - Maximum. * @property {generic_enum} [use_drawing_pivot="Apply Embedded Pivot on Drawing Layer"] - Use Embedded Pivots. * @property {bool} flip_hor=false - Flip Horizontal. * @property {bool} flip_vert=false - Flip Vertical. * @property {bool} turn_before_alignment=false - Turn Before Alignment. * @property {bool} no_clipping=false - No Clipping. * @property {int} x_clip_factor=0 - Clipping Factor (x). * @property {int} y_clip_factor=0 - Clipping Factor (y). * @property {generic_enum} [alignment_rule="Center First Page"] - Alignment Rule. * @property {double} morphing_velo=0 - Morphing Velocity. * @property {bool} can_animate=false - Animate Using Animation Tools. * @property {bool} tile_horizontal=false - Tile Horizontally. * @property {bool} tile_vertical=false - Tile Vertically. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'Shake' * @name NodeTypes#Shake * @property {double} frequency=0.3000 - Frequency. * @property {int} octaves=2 - Octaves. * @property {double} multiplier=0.5000 - Multiplier. * @property {double} positionx=1 - Position X. * @property {double} positiony=1.3300 - Position Y. * @property {double} positionz=0.1000 - Position Z. * @property {double} rotationx=0 - Rotation X. * @property {double} rotationy=0 - Rotation Y. * @property {double} rotationz=1 - Rotation Z. * @property {double} pivotx=0 - Pivot X. * @property {double} pivoty=0 - Pivot Y. * @property {double} pivotz=0 - Pivot Z. * @property {int} steps=1 - Steps. * @property {int} seed=0 - Random Seed. */ /** * Attributes present in the node of type: 'QUADMAP' * @name NodeTypes#Quadmap * @property {position_2d} src_point_1 - Source Point 1. * @property {bool} src_point_1.separate=On - Separate. * @property {double} src_point_1.x=-12 - Pos x. * @property {double} src_point_1.y=12 - Pos y. * @property {point_2d} src_point_1.2dpoint - Point. * @property {position_2d} src_point_2 - Source Point 2. * @property {bool} src_point_2.separate=On - Separate. * @property {double} src_point_2.x=12 - Pos x. * @property {double} src_point_2.y=12 - Pos y. * @property {point_2d} src_point_2.2dpoint - Point. * @property {position_2d} src_point_3 - Source Point 3. * @property {bool} src_point_3.separate=On - Separate. * @property {double} src_point_3.x=-12 - Pos x. * @property {double} src_point_3.y=-12 - Pos y. * @property {point_2d} src_point_3.2dpoint - Point. * @property {position_2d} src_point_4 - Source Point 4. * @property {bool} src_point_4.separate=On - Separate. * @property {double} src_point_4.x=12 - Pos x. * @property {double} src_point_4.y=-12 - Pos y. * @property {point_2d} src_point_4.2dpoint - Point. * @property {position_2d} point_1 - Destination Point 1. * @property {bool} point_1.separate=On - Separate. * @property {double} point_1.x=-12 - Pos x. * @property {double} point_1.y=12 - Pos y. * @property {point_2d} point_1.2dpoint - Point. * @property {position_2d} point_2 - Destination Point 2. * @property {bool} point_2.separate=On - Separate. * @property {double} point_2.x=12 - Pos x. * @property {double} point_2.y=12 - Pos y. * @property {point_2d} point_2.2dpoint - Point. * @property {position_2d} point_3 - Destination Point 3. * @property {bool} point_3.separate=On - Separate. * @property {double} point_3.x=-12 - Pos x. * @property {double} point_3.y=-12 - Pos y. * @property {point_2d} point_3.2dpoint - Point. * @property {position_2d} point_4 - Destination Point 4. * @property {bool} point_4.separate=On - Separate. * @property {double} point_4.x=12 - Pos x. * @property {double} point_4.y=-12 - Pos y. * @property {point_2d} point_4.2dpoint - Point. * @property {position_2d} pivot - Pivot. * @property {bool} pivot.separate=On - Separate. * @property {double} pivot.x=0 - Pos x. * @property {double} pivot.y=0 - Pos y. */ /** * Attributes present in the node of type: 'RADIALBLUR-PLUGIN' * @name NodeTypes#Blur-Radial-Zoom * @property {bool} invert_matte_port=false - Invert Matte. * @property {bool} truck_factor=true - Truck Factor. * @property {bool} bidirectional=true - Bidirectional. * @property {generic_enum} [precision="Medium 8"] - Precision. * @property {double} blurriness=0 - Blurriness. * @property {double} fall_off=0 - Fall Off. * @property {double} spiral=0 - Custom. * @property {position_2d} focus - Focus. * @property {bool} focus.separate=On - Separate. * @property {double} focus.x=0 - Pos x. * @property {double} focus.y=0 - Pos y. * @property {point_2d} focus.2dpoint - Point. * @property {generic_enum} [smoothness="Quadratic"] - Variation. * @property {double} quality=1 - Quality. * @property {bool} legacy=false - Legacy. */ /** * Attributes present in the node of type: 'CAMERA' * @name NodeTypes#Camera * @property {position_3d} offset - Offset. * @property {bool} offset.separate=On - Separate. * @property {double} offset.x=0 - Pos x. * @property {double} offset.y=0 - Pos y. * @property {double} offset.z=12 - Pos z. * @property {position_2d} pivot - Pivot. * @property {bool} pivot.separate=On - Separate. * @property {double} pivot.x=0 - Pos x. * @property {double} pivot.y=0 - Pos y. * @property {doublevb} angle=0 - Angle. * @property {bool} override_scene_fov=false - Override Scene Fov. * @property {doublevb} fov=41.1121 - FOV. * @property {double} near_plane=0.1000 - Near Plane. * @property {double} far_plane=1000 - Far Plane. */ /** * Attributes present in the node of type: 'COMPOSITE' * @name NodeTypes#Composite * @property {generic_enum} [composite_mode="Pass Through"] - Mode. * @property {bool} flatten_output=false - Flatten Output. * @property {bool} flatten_vector=false - Vector Flatten Output. * @property {bool} composite_2d=false - 2D. * @property {bool} composite_3d=false - 3D. * @property {generic_enum} [output_z="Leftmost"] - Output Z. * @property {int} output_z_input_port=1 - Port For Output Z. * @property {bool} apply_focus=true - Apply Focus. * @property {double} multiplier=1 - Focus Multiplier. * @property {string} tvg_palette=compositedPalette - Palette Name. * @property {bool} merge_vector=false - Flatten. */ /** * Attributes present in the node of type: 'CastShadow' * @name NodeTypes#Cast-Shadow * @property {generic_enum} [lighttype="Point"] - Light Type. * @property {generic_enum} [shadetype="Smooth"] - Shading Type. * @property {double} anglebias=0 - Angle Bias. * @property {double} shadowdarkness=100 - Shadow Darkness. * @property {double} shadowlength=0 - Shadow Length. * @property {double} shadowbias=0 - Shadow Bias. * @property {int} samplecount=1 - Sample Count. * @property {double} samplesize=0.5000 - Sample Size. * @property {int} softcount=1 - Soft Shadow Sample Count. * @property {double} softsize=1 - Soft Shadow Sample Size. * @property {double} softweight=1 - Soft Shadow Weight. * @property {double} postblur=0 - Final Blur. * @property {double} postblurthreshold=0 - Final Blur Threshold. * @property {double} shadownoise=0 - Shadow Noise. * @property {int} expandsource=0 - Expand Render Source. * @property {double} shadowgamma=1 - Shadow Gamma. * @property {int} antialiasbias=1 - Anti-Alias Bias. * @property {int} antialiasblend=1 - Anti-Alias Blend. * @property {int} antialiasblur=1 - Anti-Alias Blur. * @property {double} exponent=2 - Abruptness. * @property {color} color=ff000000 - Light Colour. * @property {int} color.red=0 - Red. * @property {int} color.green=0 - Green. * @property {int} color.blue=0 - Blue. * @property {int} color.alpha=255 - Alpha. * @property {generic_enum} [color.preferred_ui="Separate"] - Preferred Editor. * @property {bool} useimagecolor=false - Use Image Colour. * @property {double} imagecolorweight=50 - Image Colour Intensity. * @property {bool} usetruckfactor=true - Consider Truck Factor. * @property {bool} polarmethod=false - Use Polar Method. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'PRECOMP' * @name NodeTypes#Pre-render-Cache * @property {generic_enum} [composite_mode="As Bitmap"] - Mode. * @property {bool} flatten_output=true - Flatten Output. * @property {bool} flatten_vector=false - Vector Flatten Output. * @property {bool} composite_2d=false - 2D. * @property {bool} composite_3d=false - 3D. * @property {generic_enum} [output_z="Leftmost"] - Output Z. * @property {int} output_z_input_port=1 - Port For Output Z. * @property {bool} apply_focus=true - Apply Focus. * @property {double} multiplier=1 - Focus Multiplier. * @property {string} tvg_palette=compositedPalette - Palette Name. * @property {bool} merge_vector=false - Flatten. * @property {bool} prerender_current_frame=false - Current Frame. * @property {bool} prerender_selected_frames=false - Selected Frames. * @property {int} prerender_frames_from=1 - From. * @property {int} prerender_frames_to=1 - To. * @property {push_button} render_prerender_cache - Render. * @property {push_button} clear_prerender_cache - Clear. */ /** * Attributes present in the node of type: 'MATTE_COMPOSITE' * @name NodeTypes#Matte-Composite */ /** * Attributes present in the node of type: 'TransformationSwitch' * @name NodeTypes#Transformation-Switch * @property {drawing} drawing - Drawing. * @property {bool} drawing.element_mode=On - Element Mode. * @property {element} drawing.element=unknown - Element. * @property {string} drawing.element.layer - Layer. * @property {custom_name} drawing.custom_name - Custom Name. * @property {string} drawing.custom_name.name - Local Name. * @property {timing} drawing.custom_name.timing - Timing. * @property {string} drawing.custom_name.extension=tga - Extension. * @property {double} drawing.custom_name.field_chart=12 - FieldChart. * @property {array_string} transformationnames=2;; - Transformation Names. * @property {int} transformationnames.size=2 - Size. * @property {string} transformationnames.transformation_1 - Transformation 1. * @property {string} transformationnames.transformation_2 - Transformation 2. */ /** * Attributes present in the node of type: 'MOTION_BLUR' * @name NodeTypes#Motion-Blur-Legacy * @property {double} nb_frames_trail=10 - Number of Frames in the Trail. * @property {double} samples=200 - Number of Samples. * @property {double} falloff=2 - Fall-off Rate. * @property {double} intensity=1 - Intensity. * @property {bool} mirror=false - Use Mirror on Edges. */ /** * Attributes present in the node of type: 'BLUR_VARIABLE' * @name NodeTypes#Blur-Variable * @property {bool} invert_matte_port=false - Invert Matte. * @property {double} black_radius=0 - Black radius. * @property {double} white_radius=0 - White radius. * @property {generic_enum} [quality="High"] - Quality. * @property {bool} keep_inside_source_image=false - Keep inside source image. */ /** * Attributes present in the node of type: 'LAYER_SELECTOR' * @name NodeTypes#Layer-Selector * @property {bool} flatten=false - Flatten. * @property {bool} apply_to_matte_ports=false - Apply to Matte Ports on Input Effects. * @property {generic_enum} [antialiasing_quality="Ignore"] - Antialiasing Quality. * @property {double} antialiasing_exponent=1 - Antialiasing Exponent. * @property {bool} read_overlay=false - Read Overlay. * @property {bool} read_lineart=true - Read LineArt. * @property {bool} read_colourart=true - Read ColourArt. * @property {bool} read_underlay=false - Read Underlay. */ /** * Attributes present in the node of type: 'MOTIONBLUR-PLUGIN' * @name NodeTypes#Motion-Blur * @property {double} nb_frames_trail=1 - Duration (Number of Frames). * @property {int} samples=30 - Samples per Frame. * @property {double} falloff=0 - Fall-off Rate. * @property {bool} use_camera_transformation=false - Use Camera Motion. * @property {bool} preroll_motion=true - Preroll Motion. */ /** * Attributes present in the node of type: 'COLOR_CARD' * @name NodeTypes#Colour-Card * @property {int} depth=0 - Depth. * @property {double} offset_z=-12 - Offset Z. * @property {color} color=ffffffff - Color. * @property {int} color.red=255 - Red. * @property {int} color.green=255 - Green. * @property {int} color.blue=255 - Blue. * @property {int} color.alpha=255 - Alpha. * @property {generic_enum} [color.preferred_ui="Separate"] - Preferred Editor. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'TransformLoop' * @name NodeTypes#Transform-Loop * @property {bool} autorange=true - Automatic Range Detection. * @property {int} rangestart=1 - Start. * @property {int} rangeend=1 - End. * @property {generic_enum} [looptype="Repeat"] - Loop Type. */ /** * Attributes present in the node of type: 'COLOR_MASK' * @name NodeTypes#Channel-Selector * @property {bool} red=true - Red. * @property {bool} green=true - Green. * @property {bool} blue=true - Blue. * @property {bool} alpha=true - Alpha. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'SCALE' * @name NodeTypes#Scale-Output * @property {bool} by_value=true - Custom Resolution. * @property {string} resolution_name - Resolution Name. * @property {int} res_x=720 - Width. * @property {int} res_y=540 - Height. */ /** * Attributes present in the node of type: 'COLOR_LEVELS' * @name NodeTypes#Colour-Levels * @property {levels-channel-params-attribute} rgb - RGB. * @property {double} rgb.input_black=0 - Input Black. * @property {double} rgb.input_white=1 - Input White. * @property {double} rgb.gamma=1 - Gamma. * @property {double} rgb.output_black=0 - Output Black. * @property {double} rgb.output_white=1 - Output White. * @property {levels-channel-params-attribute} red - Red. * @property {double} red.input_black=0 - Input Black. * @property {double} red.input_white=1 - Input White. * @property {double} red.gamma=1 - Gamma. * @property {double} red.output_black=0 - Output Black. * @property {double} red.output_white=1 - Output White. * @property {levels-channel-params-attribute} green - Green. * @property {double} green.input_black=0 - Input Black. * @property {double} green.input_white=1 - Input White. * @property {double} green.gamma=1 - Gamma. * @property {double} green.output_black=0 - Output Black. * @property {double} green.output_white=1 - Output White. * @property {levels-channel-params-attribute} blue - Blue. * @property {double} blue.input_black=0 - Input Black. * @property {double} blue.input_white=1 - Input White. * @property {double} blue.gamma=1 - Gamma. * @property {double} blue.output_black=0 - Output Black. * @property {double} blue.output_white=1 - Output White. * @property {levels-channel-params-attribute} alpha - Alpha. * @property {double} alpha.input_black=0 - Input Black. * @property {double} alpha.input_white=1 - Input White. * @property {double} alpha.gamma=1 - Gamma. * @property {double} alpha.output_black=0 - Output Black. * @property {double} alpha.output_white=1 - Output White. */ /** * Attributes present in the node of type: 'COLOR_FADE' * @name NodeTypes#Colour-Fade * @property {double} fadefactor=100 - Fade. * @property {generic_enum} [colorspace="RGB"] - Colour Interpolation. * @property {generic_enum} [hueinterpolation="Linear"] - Hue Interpolation. */ /** * Attributes present in the node of type: 'Gamma' * @name NodeTypes#Gamma * @property {double} rgb_gamma=1 - RGB Gamma. * @property {double} red_gamma=1 - Red Gamma. * @property {double} green_gamma=1 - Green Gamma. * @property {double} blue_gamma=1 - Blue Gamma. * @property {double} alpha_gamma=1 - Alpha Gamma. */ /** * Attributes present in the node of type: 'DITHER' * @name NodeTypes#Dither * @property {double} magnitude=1 - Magnitude. * @property {bool} correlate=false - Correlate. * @property {bool} random=true - Random. */ /** * Attributes present in the node of type: 'GRAIN' * @name NodeTypes#Grain * @property {bool} invert_matte_port=false - Invert Matte. * @property {double} noise=0.3000 - Noise. * @property {double} smooth=0 - Smooth. * @property {bool} random=true - Random At Each Frame. * @property {int} seed=0 - Seed Value. */ /** * Attributes present in the node of type: 'ShapeLine' * @name NodeTypes#Shape-Line * @property {bool} flattenz=true - Flatten Z. * @property {position_3d} position1 - Position 1. * @property {bool} position1.separate=On - Separate. * @property {double} position1.x=0 - Pos x. * @property {double} position1.y=0 - Pos y. * @property {double} position1.z=0 - Pos z. * @property {path_3d} position1.3dpath - Path. * @property {bool} closeshape=false - Close Shape Contour. */ /** * Attributes present in the node of type: 'COMPOSITE_GENERIC' * @name NodeTypes#Composite-Generic * @property {generic_enum} [color_operation="Apply With Alpha"] - Colour Operation. * @property {double} intensity_color_red=1 - Intensity Red. * @property {double} intensity_color_blue=1 - Intensity Blue. * @property {double} intensity_color_green=1 - Intensity Green. * @property {double} opacity=100 - Opacity. * @property {generic_enum} [alpha_operation="Apply"] - Alpha Operation. * @property {generic_enum} [output_z="Leftmost"] - Output Z. * @property {int} output_z_input_port=1 - Port For Output Z. */ /** * Attributes present in the node of type: 'COLOR_ART' * @name NodeTypes#Colour-Art * @property {bool} flatten=false - Flatten. * @property {bool} apply_to_matte_ports=false - Apply to Matte Ports on Input Effects. * @property {generic_enum} [antialiasing_quality="Ignore"] - Antialiasing Quality. * @property {double} antialiasing_exponent=1 - Antialiasing Exponent. */ /** * Attributes present in the node of type: 'UNDERLAY' * @name NodeTypes#Underlay-Layer * @property {bool} flatten=false - Flatten. * @property {bool} apply_to_matte_ports=false - Apply to Matte Ports on Input Effects. * @property {generic_enum} [antialiasing_quality="Ignore"] - Antialiasing Quality. * @property {double} antialiasing_exponent=1 - Antialiasing Exponent. */ /** * Attributes present in the node of type: 'CUTTER' * @name NodeTypes#Cutter * @property {bool} inverted=false - Inverted. */ /** * Attributes present in the node of type: 'FOCUS_APPLY' * @name NodeTypes#Focus-Multiplier * @property {double} multiplier=1 - Multiplier. */ /** * Attributes present in the node of type: 'PEG_APPLY3' * @name NodeTypes#Apply-Image-Transformation */ /** * Attributes present in the node of type: 'PEG_APPLY3_V2' * @name NodeTypes#Apply-Peg-Transformation */ /** * Attributes present in the node of type: 'LINE_ART' * @name NodeTypes#Line-Art * @property {bool} flatten=false - Flatten. * @property {bool} apply_to_matte_ports=false - Apply to Matte Ports on Input Effects. * @property {generic_enum} [antialiasing_quality="Ignore"] - Antialiasing Quality. * @property {double} antialiasing_exponent=1 - Antialiasing Exponent. */ /** * Attributes present in the node of type: 'OVERLAY' * @name NodeTypes#Overlay-Layer * @property {bool} flatten=false - Flatten. * @property {bool} apply_to_matte_ports=false - Apply to Matte Ports on Input Effects. * @property {generic_enum} [antialiasing_quality="Ignore"] - Antialiasing Quality. * @property {double} antialiasing_exponent=1 - Antialiasing Exponent. */ /** * Attributes present in the node of type: 'AutoPatchModule' * @name NodeTypes#Auto-Patch */ /** * Attributes present in the node of type: 'SubNodeAnimationFilter' * @name NodeTypes#3D-Kinematic-Output * @property {string} sub_node_name - Subnode Name. */ /** * Attributes present in the node of type: 'BOXBLUR-PLUGIN' * @name NodeTypes#Blur-Box * @property {bool} invert_matte_port=false - Invert Matte. * @property {bool} truck_factor=true - Truck Factor. * @property {bool} bidirectional=true - Bidirectional. * @property {generic_enum} [precision="Medium 8"] - Precision. * @property {bool} repeat_edge_pixels=false - Repeat Edge Pixels. * @property {bool} directional=false - Directional. * @property {double} angle=0 - Angle. * @property {int} iterations=1 - Number of Iterations. * @property {double} radius=0 - Radius. * @property {double} width=0 - Width. * @property {double} length=0 - Length. * @property {double} fall_off=0 - Fall Off. */ /** * Attributes present in the node of type: 'RGB_DIFFERENCE_KEYER' * @name NodeTypes#RGB-Difference-Keyer * @property {color} color=ff000000 - Colour. * @property {int} color.red=0 - Red. * @property {int} color.green=0 - Green. * @property {int} color.blue=0 - Blue. * @property {int} color.alpha=255 - Alpha. * @property {generic_enum} [color.preferred_ui="Separate"] - Preferred Editor. * @property {bool} pre_multiplied_alpha=true - Pre-multiplied Alpha. * @property {double} key_difference_weighting=50 - Key Weight. * @property {double} edge_key_threshold=0 - Background Bias. * @property {double} key_primary_spill_reduction=0 - Spill Reduction. * @property {double} key_low_intensity_scaled_thresh=100 - Low Intensity Threshold. * @property {bool} blend_input_alpha=true - Blend Input Alpha. * @property {bool} despill_matte=true - Despill Matte. * @property {color} spill_color=ff000000 - Spill Replace Colour. * @property {int} spill_color.red=0 - Red. * @property {int} spill_color.green=0 - Green. * @property {int} spill_color.blue=0 - Blue. * @property {int} spill_color.alpha=255 - Alpha. * @property {generic_enum} [spill_color.preferred_ui="Separate"] - Preferred Editor. * @property {double} matte_clip_black=0 - Clip Black. * @property {double} matte_clip_white=100 - Clip White. * @property {bool} invert_matte=false - Invert Output Matte. */ /** * Attributes present in the node of type: 'CHANNEL_SWAP' * @name NodeTypes#Channel-Swap * @property {generic_enum} [redchannelselection="Red"] - Red Channel From. * @property {generic_enum} [greenchannelselection="Green"] - Green Channel From. * @property {generic_enum} [bluechannelselection="Blue"] - Blue Channel From. * @property {generic_enum} [alphachannelselection="Alpha"] - Alpha Channel From. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'FADE' * @name NodeTypes#Transparency * @property {double} transparency=50 - Transparency. */ /** * Attributes present in the node of type: 'OGLBYPASS' * @name NodeTypes#OpenGL-Bypass */ /** * Attributes present in the node of type: 'NOTE' * @name NodeTypes#Note * @property {string} text - Text. */ /** * Attributes present in the node of type: 'OpenGLPreview' * @name NodeTypes#RenderPreview * @property {generic_enum} [refreshstrategy="Current Frame Only"] - Render. * @property {generic_enum} [scaling="Use Render Preview Setting"] - Preview Resolution. * @property {generic_enum} [renderstrategy="Use Previously Rendered"] - Outdated Images Mode. * @property {push_button} computeallimages - Render Frames. */ /** * Attributes present in the node of type: 'TbdColorSelector' * @name NodeTypes#Colour-Selector * @property {string} selectedcolors - Selected Colours. * @property {bool} applytomatte=false - Apply to Matte Ports on Input Effects. */ /** * Attributes present in the node of type: 'COLOR_OVERRIDE_TVG' * @name NodeTypes#Colour-Override */ /** * Attributes present in the node of type: 'FLATTEN' * @name NodeTypes#Flatten */ /** * Attributes present in the node of type: 'DISPLAY' * @name NodeTypes#Display */ /** * Attributes present in the node of type: 'DynamicSpring' * @name NodeTypes#Dynamic-Spring * @property {double} active=100 - Active. * @property {bool} matchexposures=false - Match Animation on Active Attribute. * @property {double} tensionx=7 - Tension X. * @property {double} inertiax=80 - Inertia X. * @property {double} tensiony=7 - Tension Y. * @property {double} inertiay=80 - Inertia Y. * @property {double} tensionz=7 - Tension Z. * @property {double} inertiaz=80 - Inertia Z. * @property {double} tensionscale=7 - Tension Scale. * @property {double} inertiascale=80 - Inertia Scale. * @property {double} tensionskew=7 - Tension Skew. * @property {double} inertiaskew=80 - Inertia Skew. * @property {double} tensionrotate=7 - Tension Rotate. * @property {double} inertiarotate=80 - Inertia Rotate. * @property {double} pignore=0 - Ignore Parents. * @property {string} parentname - Parent's Name. */ /** * Attributes present in the node of type: 'WRITE' * @name NodeTypes#Write * @property {generic_enum} [export_to_movie="Output Drawings"] - Export to movie. * @property {string} drawing_name=frames/final- - Drawing name. * @property {string} movie_path=frames/output - Movie path. * @property {string} movie_format=com.toonboom.quicktime.legacy - Movie format setting. * @property {string} movie_audio - Movie audio settings. * @property {string} movie_video - Movie video settings. * @property {string} movie_videoaudio - Movie video and audio settings. * @property {int} leading_zeros=3 - Leading zeros. * @property {int} start=1 - Start. * @property {string} drawing_type=TGA - Drawing type. * @property {enable} [enabling="Always Enabled"] - Enabling. * @property {generic_enum} [enabling.filter="Always Enabled"] - Filter. * @property {string} enabling.filter_name - Filter name. * @property {int} enabling.filter_res_x=720 - X resolution. * @property {int} enabling.filter_res_y=540 - Y resolution. * @property {bool} script_movie=false - Script Movie. * @property {string} [script_editor="// Following code will be called at the end of Write Node rendering // operations to create a movie file from rendered images. // ..."] - Movie Generation Script. * @property {string} color_space - Colour Space. * @property {generic_enum} [composite_partitioning="Off"] - Composite Partitioning. * @property {double} z_partition_range=1 - Z Partition Range. * @property {bool} clean_up_partition_folders=true - Clean up partition folders. */ /** * Attributes present in the node of type: 'MultiLayerWrite' * @name NodeTypes#Multi-Layer-Write * @property {generic_enum} [export_to_movie="Output Drawings"] - Export to movie. * @property {string} drawing_name=frames/final- - Drawing name. * @property {string} movie_path=frames/output - Movie path. * @property {string} movie_format=com.toonboom.quicktime.legacy - Movie format setting. * @property {string} movie_audio - Movie audio settings. * @property {string} movie_video - Movie video settings. * @property {string} movie_videoaudio - Movie video and audio settings. * @property {int} leading_zeros=3 - Leading zeros. * @property {int} start=1 - Start. * @property {string} drawing_type=PSD - Drawing type. * @property {enable} [enabling="Always Enabled"] - Enabling. * @property {generic_enum} [enabling.filter="Always Enabled"] - Filter. * @property {string} enabling.filter_name - Filter name. * @property {int} enabling.filter_res_x=720 - X resolution. * @property {int} enabling.filter_res_y=540 - Y resolution. * @property {bool} script_movie=false - Script Movie. * @property {string} [script_editor="// Following code will be called at the end of Write Node rendering // operations to create a movie file from rendered images. // ..."] - Movie Generation Script. * @property {string} color_space - Colour Space. * @property {generic_enum} [composite_partitioning="Off"] - Composite Partitioning. * @property {double} z_partition_range=1 - Z Partition Range. * @property {bool} clean_up_partition_folders=true - Clean up partition folders. * @property {string} input_names - Input Names. */ /** * Attributes present in the node of type: 'BLUR_DIRECTIONAL' * @name NodeTypes#Blur-Directional * @property {bool} invert_matte_port=false - Invert Matte. * @property {bool} truck_factor=true - Truck Factor. * @property {double} fallof_rate=0 - Falloff Rate. * @property {double} angle=0 - Angle. * @property {double} radius=0 - Radius. * @property {generic_enum} [direction_of_trail="Angle"] - Direction of trail. * @property {bool} ignore_alpha=false - Ignore Alpha. * @property {bool} extra_final_blur=true - Extra Final Blur. */ /** * Attributes present in the node of type: 'TONE' * @name NodeTypes#Tone * @property {bool} truck_factor=true - Truck Factor. * @property {generic_enum} [blur_type="Radial"] - Blur Type. * @property {double} radius=2 - Radius. * @property {double} directional_angle=0 - Directional Angle. * @property {double} directional_falloff_rate=1 - Directional Falloff Rate. * @property {bool} use_matte_color=false - Use Matte Colour. * @property {bool} invert_matte=false - Invert Matte. * @property {color} color=649c9c9c - Color. * @property {int} color.red=-100 - Red. * @property {int} color.green=-100 - Green. * @property {int} color.blue=-100 - Blue. * @property {int} color.alpha=100 - Alpha. * @property {generic_enum} [color.preferred_ui="Separate"] - Preferred Editor. * @property {bool} multiplicative=false - Multiplicative. * @property {double} colour_gain=1 - Intensity. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'GAUSSIANBLUR-PLUGIN' * @name NodeTypes#Blur-Gaussian * @property {bool} invert_matte_port=false - Invert Matte. * @property {bool} truck_factor=true - Truck Factor. * @property {bool} bidirectional=true - Bidirectional. * @property {generic_enum} [precision="Medium 8"] - Precision. * @property {bool} repeat_edge_pixels=false - Repeat Edge Pixels. * @property {bool} directional=false - Directional. * @property {double} angle=0 - Angle. * @property {int} iterations=1 - Number of Iterations. * @property {double} blurriness=0 - Blurriness. * @property {double} vertical=0 - Vertical Blurriness. * @property {double} horizontal=0 - Horizontal Blurriness. */ /** * Attributes present in the node of type: 'HIGHLIGHT' * @name NodeTypes#Highlight * @property {bool} truck_factor=true - Truck Factor. * @property {generic_enum} [blur_type="Radial"] - Blur Type. * @property {double} radius=2 - Radius. * @property {double} directional_angle=0 - Directional Angle. * @property {double} directional_falloff_rate=1 - Directional Falloff Rate. * @property {bool} use_matte_color=false - Use Matte Colour. * @property {bool} invert_matte=false - Invert Matte. * @property {color} color=64646464 - Color. * @property {int} color.red=100 - Red. * @property {int} color.green=100 - Green. * @property {int} color.blue=100 - Blue. * @property {int} color.alpha=100 - Alpha. * @property {generic_enum} [color.preferred_ui="Separate"] - Preferred Editor. * @property {bool} multiplicative=false - Multiplicative. * @property {double} colour_gain=1 - Intensity. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'MATTE_BLUR' * @name NodeTypes#Matte-Blur * @property {bool} truck_factor=true - Truck Factor. * @property {generic_enum} [blur_type="Radial"] - Blur Type. * @property {double} radius=0 - Radius. * @property {double} directional_angle=0 - Directional Angle. * @property {double} directional_falloff_rate=1 - Directional Falloff Rate. * @property {bool} use_matte_color=false - Use Matte Colour. * @property {bool} invert_matte=false - Invert Matte. * @property {color} color=ffffffff - Color. * @property {int} color.red=255 - Red. * @property {int} color.green=255 - Green. * @property {int} color.blue=255 - Blue. * @property {int} color.alpha=255 - Alpha. * @property {generic_enum} [color.preferred_ui="Separate"] - Preferred Editor. * @property {bool} multiplicative=false - Multiplicative. * @property {double} colour_gain=1 - Intensity. */ /** * Attributes present in the node of type: 'PEG' * @name NodeTypes#Peg * @property {bool} enable_3d=false - Enable 3D. * @property {bool} face_camera=false - Face Camera. * @property {generic_enum} [camera_alignment="None"] - Camera Alignment. * @property {position_3d} position - Position. * @property {bool} position.separate=On - Separate. * @property {double} position.x=0 - Pos x. * @property {double} position.y=0 - Pos y. * @property {double} position.z=0 - Pos z. * @property {path_3d} position.3dpath - Path. * @property {scale_3d} scale - Scale. * @property {bool} scale.separate=On - Separate. * @property {bool} scale.in_fields=Off - In fields. * @property {doublevb} scale.xy=1 - Scale. * @property {doublevb} scale.x=1 - Scale x. * @property {doublevb} scale.y=1 - Scale y. * @property {doublevb} scale.z=1 - Scale z. * @property {rotation_3d} rotation - Rotation. * @property {bool} rotation.separate=Off - Separate. * @property {doublevb} rotation.anglex=0 - Angle_x. * @property {doublevb} rotation.angley=0 - Angle_y. * @property {doublevb} rotation.anglez=0 - Angle_z. * @property {quaternion_path} rotation.quaternionpath - Quaternion. * @property {alias} angle=0 - Angle. * @property {double} skew=0 - Skew. * @property {position_3d} pivot - Pivot. * @property {bool} pivot.separate=On - Separate. * @property {double} pivot.x=0 - Pos x. * @property {double} pivot.y=0 - Pos y. * @property {double} pivot.z=0 - Pos z. * @property {position_3d} spline_offset - Spline Offset. * @property {bool} spline_offset.separate=On - Separate. * @property {double} spline_offset.x=0 - Pos x. * @property {double} spline_offset.y=0 - Pos y. * @property {double} spline_offset.z=0 - Pos z. * @property {bool} ignore_parent_peg_scaling=false - Ignore Parent Scaling. * @property {bool} disable_field_rendering=false - Disable Field Rendering. * @property {int} depth=0 - Depth. * @property {bool} enable_min_max_angle=false - Enable Min/Max Angle. * @property {double} min_angle=-360 - Min Angle. * @property {double} max_angle=360 - Max Angle. * @property {bool} nail_for_children=false - Nail for Children. * @property {bool} ik_hold_orientation=false - Hold Orientation in IK. * @property {bool} ik_hold_x=false - Hold X in IK. * @property {bool} ik_hold_y=false - Hold Y in IK. * @property {bool} ik_excluded=false - Is Excluded from IK. * @property {bool} ik_can_rotate=true - Can Rotate during IK. * @property {bool} ik_can_translate_x=false - Can Translate in X during IK. * @property {bool} ik_can_translate_y=false - Can Translate in Y during IK. * @property {double} ik_bone_x=0.2000 - X Direction of Bone. * @property {double} ik_bone_y=0 - Y Direction of Bone. * @property {double} ik_stiffness=1 - Stiffness of Bone. * @property {bool} group_at_network_building=false - Group at Network Building. * @property {bool} add_composite_to_group=true - Add Composite to Group. */ /** * Attributes present in the node of type: 'GLOW' * @name NodeTypes#Glow * @property {bool} truck_factor=true - Truck Factor. * @property {generic_enum} [blur_type="Radial"] - Blur Type. * @property {double} radius=0 - Radius. * @property {double} directional_angle=0 - Directional Angle. * @property {double} directional_falloff_rate=1 - Directional Falloff Rate. * @property {bool} use_matte_color=false - Use Source Colour. * @property {bool} invert_matte=false - Invert Matte. * @property {color} color=ff646464 - Color. * @property {int} color.red=100 - Red. * @property {int} color.green=100 - Green. * @property {int} color.blue=100 - Blue. * @property {int} color.alpha=255 - Alpha. * @property {generic_enum} [color.preferred_ui="Separate"] - Preferred Editor. * @property {bool} multiplicative=false - Multiplicative. * @property {double} colour_gain=1 - Intensity. */ /** * Attributes present in the node of type: 'BLEND_MODE_MODULE' * @name NodeTypes#Blending * @property {generic_enum} [blend_mode="Normal"] - Blend Mode. * @property {generic_enum} [flash_blend_mode="Normal"] - SWF Blend Mode. */ /** * Attributes present in the node of type: 'BLUR_RADIAL' * @name NodeTypes#Blur * @property {bool} invert_matte_port=false - Invert Matte. * @property {bool} truck_factor=true - Truck Factor. * @property {double} radius=0 - Radius. * @property {generic_enum} [quality="High"] - Quality. */ /** * Attributes present in the node of type: 'ImageSwitch' * @name NodeTypes#Image-Switch * @property {int} port_index=0 - Port Index. */ /** * Attributes present in the node of type: 'ORTHOLOCK' * @name NodeTypes#OrthoLock * @property {generic_enum} [rotation_axis="X and Y Axes"] - Rotation Axis. * @property {double} max_angle=0 - Max Angle. */ /** * Attributes present in the node of type: 'CHROMA_KEYING' * @name NodeTypes#Chroma-Keying * @property {bool} invert_matte_port=false - Invert Matte. * @property {color} color=ffffffff - Color. * @property {int} color.red=255 - Red. * @property {int} color.green=255 - Green. * @property {int} color.blue=255 - Blue. * @property {int} color.alpha=255 - Alpha. * @property {generic_enum} [color.preferred_ui="Separate"] - Preferred Editor. * @property {double} chroma_key_minimum=0 - Black Point. * @property {double} chroma_key_maximum=255 - White Point. * @property {double} chroma_key_filter_intensity=1 - Blur Passes. * @property {double} chroma_key_sampling=2 - Adjacent Pixels per Pass. * @property {bool} applyfinalthreshold=true - Threshold Matte. * @property {double} finalthreshold=10 - Threshold. * @property {bool} cutimage=true - Cut Colour. */ /** * Attributes present in the node of type: 'Quake' * @name NodeTypes#Quake * @property {int} hold=1 - Hold Time. * @property {bool} interpolate=false - Interpolate. * @property {double} moveamplitude=1 - Move Amplitude. * @property {bool} applyx=true - Apply on X. * @property {bool} applyy=true - Apply on Y. * @property {bool} applyz=true - Apply on Z. * @property {double} rotationamplitude=0 - Rotation Amplitude. * @property {int} seed=0 - Random Seed. */ /** * Attributes present in the node of type: 'GRADIENT-PLUGIN' * @name NodeTypes#Gradient * @property {int} depth=0 - Depth. * @property {bool} invert_matte_port=false - Invert Matte. * @property {generic_enum} [type="Linear"] - Gradient Type. * @property {position_2d} 0 - Position 0. * @property {bool} 0.separate=On - Separate. * @property {double} 0.x=0 - Pos x. * @property {double} 0.y=12 - Pos y. * @property {point_2d} 0.2dpoint - Point. * @property {position_2d} 1 - Position 1. * @property {bool} 1.separate=On - Separate. * @property {double} 1.x=0 - Pos x. * @property {double} 1.y=-12 - Pos y. * @property {point_2d} 1.2dpoint - Point. * @property {color} color0=ff000000 - Colour 0. * @property {int} color0.red=0 - Red. * @property {int} color0.green=0 - Green. * @property {int} color0.blue=0 - Blue. * @property {int} color0.alpha=255 - Alpha. * @property {generic_enum} [color0.preferred_ui="Separate"] - Preferred Editor. * @property {color} color1=ffffffff - Colour 1. * @property {int} color1.red=255 - Red. * @property {int} color1.green=255 - Green. * @property {int} color1.blue=255 - Blue. * @property {int} color1.alpha=255 - Alpha. * @property {generic_enum} [color1.preferred_ui="Separate"] - Preferred Editor. * @property {double} offset_z=0 - Offset Z. */ /** * Attributes present in the node of type: 'BRIGHTNESS_CONTRAST' * @name NodeTypes#BrightnessContrast * @property {bool} invert_matte_port=false - Invert Matte. * @property {bool} brightcontrast_legacy_brightness=false - Legacy Brightness. * @property {double} brightcontrast_brightness_adjustment=0 - Brightness. * @property {double} brightcontrast_contrast_adjustment=0 - Contrast. * @property {bool} brightcontrast_legacy_contrast=false - Legacy Contrast. */ /** * Attributes present in the node of type: 'Switch' * @name NodeTypes#Constraint-Switch * @property {double} active=100 - ACTIVE. * @property {int} gatenum=0 - TARGET GATE. * @property {position_2d} uioffsetpos - GUI OFFSET. * @property {bool} uioffsetpos.separate=On - Separate. * @property {double} uioffsetpos.x=0 - Pos x. * @property {double} uioffsetpos.y=0 - Pos y. * @property {double} uiscale=1 - GUI SCALE. */ /** * Attributes present in the node of type: 'Grid' * @name NodeTypes#Grid * @property {int} size=12 - Size. * @property {double} aspect=1.3333 - Aspect. * @property {bool} showtext=true - Display Text. * @property {color} gridcolor=ff000000 - Color. * @property {int} gridcolor.red=0 - Red. * @property {int} gridcolor.green=0 - Green. * @property {int} gridcolor.blue=0 - Blue. * @property {int} gridcolor.alpha=255 - Alpha. * @property {generic_enum} [gridcolor.preferred_ui="Separate"] - Preferred Editor. * @property {color} bgcolor=ffffffff - Color. * @property {int} bgcolor.red=255 - Red. * @property {int} bgcolor.green=255 - Green. * @property {int} bgcolor.blue=255 - Blue. * @property {int} bgcolor.alpha=255 - Alpha. * @property {generic_enum} [bgcolor.preferred_ui="Separate"] - Preferred Editor. * @property {bool} opaque=false - Fill. * @property {bool} fitvertical=true - Fit Vertical. */ /** * Attributes present in the node of type: 'Turbulence' * @name NodeTypes#Turbulence * @property {generic_enum} [fractal_type="Fractional Brownian"] - Fractal Type. * @property {generic_enum} [noise_type="Perlin"] - Noise Type. * @property {locked} frequency - Frequency. * @property {bool} frequency.separate=Off - Separate. * @property {doublevb} frequency.xyfrequency=1 - Frequency xy. * @property {doublevb} frequency.xfrequency=0 - Frequency x. * @property {doublevb} frequency.yfrequency=0 - Frequency y. * @property {locked} amount - Amount. * @property {bool} amount.separate=Off - Separate. * @property {doublevb} amount.xyamount=0 - Amount xy. * @property {doublevb} amount.xamount=0 - Amount x. * @property {doublevb} amount.yamount=0 - Amount y. * @property {locked} offset - Offset. * @property {bool} offset.separate=Off - Separate. * @property {doublevb} offset.xyoffset=0 - Offset xy. * @property {doublevb} offset.xoffset=0 - Offset x. * @property {doublevb} offset.yoffset=0 - Offset y. * @property {locked} seed - Seed. * @property {bool} seed.separate=On - Separate. * @property {doublevb} seed.xyseed=0 - Seed xy. * @property {doublevb} seed.xseed=10 - Seed x. * @property {doublevb} seed.yseed=0 - Seed y. * @property {double} evolution=0 - Evolution. * @property {double} evolution_frequency=0 - Evolution Frequency. * @property {double} gain=0.6500 - Gain. * @property {double} lacunarity=2 - Sub Scaling. * @property {double} octaves=1 - Complexity. * @property {bool} pinning=false - Pinning. * @property {generic_enum} [deformationquality="Medium"] - Deformation Quality. */ /** * Attributes present in the node of type: 'PointConstraintMulti' * @name NodeTypes#Multi-Points-Constraint * @property {double} active=100 - Active. * @property {generic_enum} [flattentype="Allow 3D Transform"] - Flatten Type. * @property {bool} convexhull=false - Ignore Internal Points. * @property {bool} allowpersp=false - Allow Perspective Transform. */ /** * Attributes present in the node of type: 'VISIBILITY' * @name NodeTypes#Visibility * @property {bool} oglrender=true - Display in OpenGL View. * @property {bool} softrender=true - Soft Render. */ /** * Attributes present in the node of type: 'REFRACT' * @name NodeTypes#Refract * @property {bool} invert_matte_port=false - Invert Matte. * @property {double} intensity=10 - Intensity. * @property {double} height=0 - Height. */ /** * Attributes present in the node of type: 'MULTIPORT_OUT' * @name NodeTypes#Multi-Port-Out */ /** * Attributes present in the node of type: 'REMOVE_TRANSPARENCY' * @name NodeTypes#Remove-Transparency * @property {double} threshold=50 - Threshold. * @property {bool} remove_color_transparency=true - Remove Colour Transparency. * @property {bool} remove_alpha_transparency=true - Remove Alpha Transparency. */ /** * Attributes present in the node of type: 'Bloom' * @name NodeTypes#Bloom * @property {double} luminancethresholdthresh=75 - Threshold. * @property {bool} luminancethresholdsoften=true - Soften Colours. * @property {double} luminancethresholdgamma=1.5000 - Gamma Correction. * @property {bool} luminancethresholdgrey=false - Output Greyscale Matte. * @property {bool} invert_matte_port=false - Invert Matte. * @property {bool} truck_factor=true - Truck Factor. * @property {double} radius=4 - Radius. * @property {generic_enum} [quality="High"] - Quality. * @property {bool} composite_src_image=true - Composite with Source Image. * @property {generic_enum} [blend_mode="Screen"] - Blend Mode. */ /** * Attributes present in the node of type: 'MULTIPORT_IN' * @name NodeTypes#Multi-Port-In */ /** * Attributes present in the node of type: 'MedianFilter' * @name NodeTypes#Median * @property {bool} invert_matte_port=false - Invert Matte. * @property {double} radius=0 - Radius. * @property {int} bitdepth=256 - Colour Depth. */ /** * Attributes present in the node of type: 'ToneShader' * @name NodeTypes#Tone-Shader * @property {generic_enum} [lighttype="Directional"] - Light Type. * @property {double} floodangle=90 - Cone Angle. * @property {double} floodsharpness=0 - Diffusion. * @property {double} floodradius=2000 - Falloff. * @property {double} pointelevation=200 - Light Source Elevation. * @property {double} anglethreshold=90 - Surface Reflectivity. * @property {generic_enum} [shadetype="Smooth"] - Shading Type. * @property {double} bias=0.1000 - Bias. * @property {double} exponent=2 - Abruptness. * @property {color} lightcolor=ff646464 - Light Colour. * @property {int} lightcolor.red=100 - Red. * @property {int} lightcolor.green=100 - Green. * @property {int} lightcolor.blue=100 - Blue. * @property {int} lightcolor.alpha=255 - Alpha. * @property {generic_enum} [lightcolor.preferred_ui="Separate"] - Preferred Editor. * @property {bool} flatten=true - Flatten Fx. * @property {bool} useimagecolor=false - Use image Colour. * @property {double} imagecolorweight=50 - Image Colour Intensity. * @property {bool} adjustlevel=false - Adjust Light Intensity. * @property {double} adjustedlevel=75 - Intensity. * @property {double} scale=1 - Multiplier. * @property {bool} usesurface=false - Use Surface Lighting. * @property {double} speculartransparency=0 - Specular Transparency. * @property {double} specularity=100 - Specular Strength. * @property {double} distancefalloff=0 - Distance Falloff. */ /** * Attributes present in the node of type: 'DeformationCompositeModule' * @name NodeTypes#Deformation-Composite * @property {bool} outputmatrixonly=false - Output Kinematic Only. * @property {bool} outputselectedonly=false - Output Selected Port Only. * @property {generic_enum} [outputkinematicchainselector="Rightmost"] - Output Kinematic Chain. * @property {int} outputkinematicchain=1 - Output Kinematic Chain Selection. */ /** * Attributes present in the node of type: 'AnimatedMatteGenerator' * @name NodeTypes#Animated-Matte-Generator * @property {double} snapradius=15 - Drag-to-Snap Distance. * @property {bool} snapoutlinesonly=false - Snap to Outlines Only. * @property {generic_enum} [outputtype="Feathered"] - Type. * @property {double} outputinterpolation=0 - Interpolation Factor. * @property {color} insidecolor=ffffffff - Inside Colour. * @property {int} insidecolor.red=255 - Red. * @property {int} insidecolor.green=255 - Green. * @property {int} insidecolor.blue=255 - Blue. * @property {int} insidecolor.alpha=255 - Alpha. * @property {generic_enum} [insidecolor.preferred_ui="Separate"] - Preferred Editor. * @property {color} outsidecolor=ffffffff - Outside Colour. * @property {int} outsidecolor.red=255 - Red. * @property {int} outsidecolor.green=255 - Green. * @property {int} outsidecolor.blue=255 - Blue. * @property {int} outsidecolor.alpha=255 - Alpha. * @property {generic_enum} [outsidecolor.preferred_ui="Separate"] - Preferred Editor. * @property {generic_enum} [interpolationmode="Distance"] - Interpolation Mode. * @property {generic_enum} [colorinterpolation="Constant"] - Colour Interpolation. * @property {generic_enum} [alphamapping="Linear"] - Alpha Mapping. * @property {int} colorlutdomain=100 - Colour LUT Domain. * @property {int} alphalutdomain=100 - Alpha LUT Domain. * @property {double} colorgamma=1 - Colour Gamma. * @property {double} alphagamma=1 - Alpha Gamma. * @property {double} colorlut=0 - Colour LUT. * @property {double} alphalut=0 - Alpha LUT. * @property {bool} snapunderlay=false - Underlay Art. * @property {bool} snapcolor=true - Colour Art. * @property {bool} snapline=true - Line Art. * @property {bool} snapoverlay=false - Overlay Art. * @property {bool} usehints=true - Enable Hints. * @property {double} snapradiusdraghint=20 - Drag-to-Snap Distance. * @property {double} snapradiusgeneratehint=95 - Generated Matte Snap Distance. * @property {double} mindistancepregeneratehints=75 - Minimum Distance Between Generated Hints. * @property {bool} snaphintunderlay=false - Underlay Art. * @property {bool} snaphintcolor=true - Colour Art. * @property {bool} snaphintline=false - Line Art. * @property {bool} snaphintoverlay=false - Overlay Art. * @property {bool} overlayisnote=true - Use Overlay Layer as Note. * @property {string} contourcentres - Contour Centres. */ /** * Attributes present in the node of type: 'COLOR2BW' * @name NodeTypes#Greyscale * @property {double} percent=100 - Percent. * @property {bool} matte_output=false - Matte Output. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'CONTRAST' * @name NodeTypes#Contrast * @property {double} mid_point=0.5000 - Mid Point. * @property {double} dark_pixel_adjustement=1 - Dark Pixel Adjustment. * @property {double} bright_pixel_adjustement=1 - Bright Pixel Adjustment. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'COLOR_CURVES' * @name NodeTypes#Colour-Curves * @property {color-correction-control-point-attribute} control_point_red_0 - Control point. * @property {position_2d} control_point_red_0.control - Control Point. * @property {bool} control_point_red_0.control.separate=Off - Separate. * @property {double} control_point_red_0.control.x=0 - Pos x. * @property {double} control_point_red_0.control.y=0 - Pos y. * @property {point_2d} control_point_red_0.control.2dpoint - Point. * @property {position_2d} control_point_red_0.left_handle - Left Handle. * @property {bool} control_point_red_0.left_handle.separate=Off - Separate. * @property {double} control_point_red_0.left_handle.x=-0.1000 - Pos x. * @property {double} control_point_red_0.left_handle.y=-0.1000 - Pos y. * @property {point_2d} control_point_red_0.left_handle.2dpoint - Point. * @property {position_2d} control_point_red_0.right_handle - Right Handle. * @property {bool} control_point_red_0.right_handle.separate=Off - Separate. * @property {double} control_point_red_0.right_handle.x=0.1000 - Pos x. * @property {double} control_point_red_0.right_handle.y=0.1000 - Pos y. * @property {point_2d} control_point_red_0.right_handle.2dpoint - Point. * @property {color-correction-control-point-attribute} control_point_red_1 - Control point. * @property {position_2d} control_point_red_1.control - Control Point. * @property {bool} control_point_red_1.control.separate=Off - Separate. * @property {double} control_point_red_1.control.x=1 - Pos x. * @property {double} control_point_red_1.control.y=1 - Pos y. * @property {point_2d} control_point_red_1.control.2dpoint - Point. * @property {position_2d} control_point_red_1.left_handle - Left Handle. * @property {bool} control_point_red_1.left_handle.separate=Off - Separate. * @property {double} control_point_red_1.left_handle.x=-0.1000 - Pos x. * @property {double} control_point_red_1.left_handle.y=-0.1000 - Pos y. * @property {point_2d} control_point_red_1.left_handle.2dpoint - Point. * @property {position_2d} control_point_red_1.right_handle - Right Handle. * @property {bool} control_point_red_1.right_handle.separate=Off - Separate. * @property {double} control_point_red_1.right_handle.x=0.1000 - Pos x. * @property {double} control_point_red_1.right_handle.y=0.1000 - Pos y. * @property {point_2d} control_point_red_1.right_handle.2dpoint - Point. * @property {color-correction-control-point-attribute} control_point_green_0 - Control point. * @property {position_2d} control_point_green_0.control - Control Point. * @property {bool} control_point_green_0.control.separate=Off - Separate. * @property {double} control_point_green_0.control.x=0 - Pos x. * @property {double} control_point_green_0.control.y=0 - Pos y. * @property {point_2d} control_point_green_0.control.2dpoint - Point. * @property {position_2d} control_point_green_0.left_handle - Left Handle. * @property {bool} control_point_green_0.left_handle.separate=Off - Separate. * @property {double} control_point_green_0.left_handle.x=-0.1000 - Pos x. * @property {double} control_point_green_0.left_handle.y=-0.1000 - Pos y. * @property {point_2d} control_point_green_0.left_handle.2dpoint - Point. * @property {position_2d} control_point_green_0.right_handle - Right Handle. * @property {bool} control_point_green_0.right_handle.separate=Off - Separate. * @property {double} control_point_green_0.right_handle.x=0.1000 - Pos x. * @property {double} control_point_green_0.right_handle.y=0.1000 - Pos y. * @property {point_2d} control_point_green_0.right_handle.2dpoint - Point. * @property {color-correction-control-point-attribute} control_point_green_1 - Control point. * @property {position_2d} control_point_green_1.control - Control Point. * @property {bool} control_point_green_1.control.separate=Off - Separate. * @property {double} control_point_green_1.control.x=1 - Pos x. * @property {double} control_point_green_1.control.y=1 - Pos y. * @property {point_2d} control_point_green_1.control.2dpoint - Point. * @property {position_2d} control_point_green_1.left_handle - Left Handle. * @property {bool} control_point_green_1.left_handle.separate=Off - Separate. * @property {double} control_point_green_1.left_handle.x=-0.1000 - Pos x. * @property {double} control_point_green_1.left_handle.y=-0.1000 - Pos y. * @property {point_2d} control_point_green_1.left_handle.2dpoint - Point. * @property {position_2d} control_point_green_1.right_handle - Right Handle. * @property {bool} control_point_green_1.right_handle.separate=Off - Separate. * @property {double} control_point_green_1.right_handle.x=0.1000 - Pos x. * @property {double} control_point_green_1.right_handle.y=0.1000 - Pos y. * @property {point_2d} control_point_green_1.right_handle.2dpoint - Point. * @property {color-correction-control-point-attribute} control_point_blue_0 - Control point. * @property {position_2d} control_point_blue_0.control - Control Point. * @property {bool} control_point_blue_0.control.separate=Off - Separate. * @property {double} control_point_blue_0.control.x=0 - Pos x. * @property {double} control_point_blue_0.control.y=0 - Pos y. * @property {point_2d} control_point_blue_0.control.2dpoint - Point. * @property {position_2d} control_point_blue_0.left_handle - Left Handle. * @property {bool} control_point_blue_0.left_handle.separate=Off - Separate. * @property {double} control_point_blue_0.left_handle.x=-0.1000 - Pos x. * @property {double} control_point_blue_0.left_handle.y=-0.1000 - Pos y. * @property {point_2d} control_point_blue_0.left_handle.2dpoint - Point. * @property {position_2d} control_point_blue_0.right_handle - Right Handle. * @property {bool} control_point_blue_0.right_handle.separate=Off - Separate. * @property {double} control_point_blue_0.right_handle.x=0.1000 - Pos x. * @property {double} control_point_blue_0.right_handle.y=0.1000 - Pos y. * @property {point_2d} control_point_blue_0.right_handle.2dpoint - Point. * @property {color-correction-control-point-attribute} control_point_blue_1 - Control point. * @property {position_2d} control_point_blue_1.control - Control Point. * @property {bool} control_point_blue_1.control.separate=Off - Separate. * @property {double} control_point_blue_1.control.x=1 - Pos x. * @property {double} control_point_blue_1.control.y=1 - Pos y. * @property {point_2d} control_point_blue_1.control.2dpoint - Point. * @property {position_2d} control_point_blue_1.left_handle - Left Handle. * @property {bool} control_point_blue_1.left_handle.separate=Off - Separate. * @property {double} control_point_blue_1.left_handle.x=-0.1000 - Pos x. * @property {double} control_point_blue_1.left_handle.y=-0.1000 - Pos y. * @property {point_2d} control_point_blue_1.left_handle.2dpoint - Point. * @property {position_2d} control_point_blue_1.right_handle - Right Handle. * @property {bool} control_point_blue_1.right_handle.separate=Off - Separate. * @property {double} control_point_blue_1.right_handle.x=0.1000 - Pos x. * @property {double} control_point_blue_1.right_handle.y=0.1000 - Pos y. * @property {point_2d} control_point_blue_1.right_handle.2dpoint - Point. * @property {color-correction-control-point-attribute} control_point_rgb_0 - Control point. * @property {position_2d} control_point_rgb_0.control - Control Point. * @property {bool} control_point_rgb_0.control.separate=Off - Separate. * @property {double} control_point_rgb_0.control.x=0 - Pos x. * @property {double} control_point_rgb_0.control.y=0 - Pos y. * @property {point_2d} control_point_rgb_0.control.2dpoint - Point. * @property {position_2d} control_point_rgb_0.left_handle - Left Handle. * @property {bool} control_point_rgb_0.left_handle.separate=Off - Separate. * @property {double} control_point_rgb_0.left_handle.x=-0.1000 - Pos x. * @property {double} control_point_rgb_0.left_handle.y=-0.1000 - Pos y. * @property {point_2d} control_point_rgb_0.left_handle.2dpoint - Point. * @property {position_2d} control_point_rgb_0.right_handle - Right Handle. * @property {bool} control_point_rgb_0.right_handle.separate=Off - Separate. * @property {double} control_point_rgb_0.right_handle.x=0.1000 - Pos x. * @property {double} control_point_rgb_0.right_handle.y=0.1000 - Pos y. * @property {point_2d} control_point_rgb_0.right_handle.2dpoint - Point. * @property {color-correction-control-point-attribute} control_point_rgb_1 - Control point. * @property {position_2d} control_point_rgb_1.control - Control Point. * @property {bool} control_point_rgb_1.control.separate=Off - Separate. * @property {double} control_point_rgb_1.control.x=1 - Pos x. * @property {double} control_point_rgb_1.control.y=1 - Pos y. * @property {point_2d} control_point_rgb_1.control.2dpoint - Point. * @property {position_2d} control_point_rgb_1.left_handle - Left Handle. * @property {bool} control_point_rgb_1.left_handle.separate=Off - Separate. * @property {double} control_point_rgb_1.left_handle.x=-0.1000 - Pos x. * @property {double} control_point_rgb_1.left_handle.y=-0.1000 - Pos y. * @property {point_2d} control_point_rgb_1.left_handle.2dpoint - Point. * @property {position_2d} control_point_rgb_1.right_handle - Right Handle. * @property {bool} control_point_rgb_1.right_handle.separate=Off - Separate. * @property {double} control_point_rgb_1.right_handle.x=0.1000 - Pos x. * @property {double} control_point_rgb_1.right_handle.y=0.1000 - Pos y. * @property {point_2d} control_point_rgb_1.right_handle.2dpoint - Point. * @property {color-correction-control-point-attribute} control_point_alpha_0 - Control point. * @property {position_2d} control_point_alpha_0.control - Control Point. * @property {bool} control_point_alpha_0.control.separate=Off - Separate. * @property {double} control_point_alpha_0.control.x=0 - Pos x. * @property {double} control_point_alpha_0.control.y=0 - Pos y. * @property {point_2d} control_point_alpha_0.control.2dpoint - Point. * @property {position_2d} control_point_alpha_0.left_handle - Left Handle. * @property {bool} control_point_alpha_0.left_handle.separate=Off - Separate. * @property {double} control_point_alpha_0.left_handle.x=-0.1000 - Pos x. * @property {double} control_point_alpha_0.left_handle.y=-0.1000 - Pos y. * @property {point_2d} control_point_alpha_0.left_handle.2dpoint - Point. * @property {position_2d} control_point_alpha_0.right_handle - Right Handle. * @property {bool} control_point_alpha_0.right_handle.separate=Off - Separate. * @property {double} control_point_alpha_0.right_handle.x=0.1000 - Pos x. * @property {double} control_point_alpha_0.right_handle.y=0.1000 - Pos y. * @property {point_2d} control_point_alpha_0.right_handle.2dpoint - Point. * @property {color-correction-control-point-attribute} control_point_alpha_1 - Control point. * @property {position_2d} control_point_alpha_1.control - Control Point. * @property {bool} control_point_alpha_1.control.separate=Off - Separate. * @property {double} control_point_alpha_1.control.x=1 - Pos x. * @property {double} control_point_alpha_1.control.y=1 - Pos y. * @property {point_2d} control_point_alpha_1.control.2dpoint - Point. * @property {position_2d} control_point_alpha_1.left_handle - Left Handle. * @property {bool} control_point_alpha_1.left_handle.separate=Off - Separate. * @property {double} control_point_alpha_1.left_handle.x=-0.1000 - Pos x. * @property {double} control_point_alpha_1.left_handle.y=-0.1000 - Pos y. * @property {point_2d} control_point_alpha_1.left_handle.2dpoint - Point. * @property {position_2d} control_point_alpha_1.right_handle - Right Handle. * @property {bool} control_point_alpha_1.right_handle.separate=Off - Separate. * @property {double} control_point_alpha_1.right_handle.x=0.1000 - Pos x. * @property {double} control_point_alpha_1.right_handle.y=0.1000 - Pos y. * @property {point_2d} control_point_alpha_1.right_handle.2dpoint - Point. */ /** * Attributes present in the node of type: 'HUE_SATURATION' * @name NodeTypes#Hue-Saturation * @property {hue_range} masterrangecolor - Master. * @property {double} masterrangecolor.hue_shift=0 - Hue. * @property {double} masterrangecolor.saturation=0 - Saturation. * @property {double} masterrangecolor.lightness=0 - Lightness. * @property {hue_range} redrangecolor - Reds. * @property {double} redrangecolor.hue_shift=0 - Hue. * @property {double} redrangecolor.saturation=0 - Saturation. * @property {double} redrangecolor.lightness=0 - Lightness. * @property {double} redrangecolor.low_range=345 - LowRange. * @property {double} redrangecolor.high_range=15 - HighRange. * @property {double} redrangecolor.low_falloff=30 - LowFalloff. * @property {double} redrangecolor.high_falloff=30 - HighFalloff. * @property {hue_range} greenrangecolor - Greens. * @property {double} greenrangecolor.hue_shift=0 - Hue. * @property {double} greenrangecolor.saturation=0 - Saturation. * @property {double} greenrangecolor.lightness=0 - Lightness. * @property {double} greenrangecolor.low_range=105 - LowRange. * @property {double} greenrangecolor.high_range=135 - HighRange. * @property {double} greenrangecolor.low_falloff=30 - LowFalloff. * @property {double} greenrangecolor.high_falloff=30 - HighFalloff. * @property {hue_range} bluerangecolor - Blues. * @property {double} bluerangecolor.hue_shift=0 - Hue. * @property {double} bluerangecolor.saturation=0 - Saturation. * @property {double} bluerangecolor.lightness=0 - Lightness. * @property {double} bluerangecolor.low_range=225 - LowRange. * @property {double} bluerangecolor.high_range=255 - HighRange. * @property {double} bluerangecolor.low_falloff=30 - LowFalloff. * @property {double} bluerangecolor.high_falloff=30 - HighFalloff. * @property {hue_range} cyanrangecolor - Cyans. * @property {double} cyanrangecolor.hue_shift=0 - Hue. * @property {double} cyanrangecolor.saturation=0 - Saturation. * @property {double} cyanrangecolor.lightness=0 - Lightness. * @property {double} cyanrangecolor.low_range=165 - LowRange. * @property {double} cyanrangecolor.high_range=195 - HighRange. * @property {double} cyanrangecolor.low_falloff=30 - LowFalloff. * @property {double} cyanrangecolor.high_falloff=30 - HighFalloff. * @property {hue_range} magentarangecolor - Magentas. * @property {double} magentarangecolor.hue_shift=0 - Hue. * @property {double} magentarangecolor.saturation=0 - Saturation. * @property {double} magentarangecolor.lightness=0 - Lightness. * @property {double} magentarangecolor.low_range=285 - LowRange. * @property {double} magentarangecolor.high_range=315 - HighRange. * @property {double} magentarangecolor.low_falloff=30 - LowFalloff. * @property {double} magentarangecolor.high_falloff=30 - HighFalloff. * @property {hue_range} yellowrangecolor - Yellows. * @property {double} yellowrangecolor.hue_shift=0 - Hue. * @property {double} yellowrangecolor.saturation=0 - Saturation. * @property {double} yellowrangecolor.lightness=0 - Lightness. * @property {double} yellowrangecolor.low_range=45 - LowRange. * @property {double} yellowrangecolor.high_range=75 - HighRange. * @property {double} yellowrangecolor.low_falloff=30 - LowFalloff. * @property {double} yellowrangecolor.high_falloff=30 - HighFalloff. * @property {bool} colorizeenable=false - Colorize. * @property {hsl} colorizehsl - Colorize HSL. * @property {double} colorizehsl.hue=0 - Hue. * @property {double} colorizehsl.saturation=25 - Saturation. * @property {double} colorizehsl.lightness=0 - Lightness. * @property {push_button} resetred - Reset Range. * @property {push_button} resetgreen - Reset Range. * @property {push_button} resetblue - Reset Range. * @property {push_button} resetcyan - Reset Range. * @property {push_button} resetmagenta - Reset Range. * @property {push_button} resetyellow - Reset Range. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'NEGATE' * @name NodeTypes#Negate * @property {bool} color=true - Negate Colour. * @property {bool} color_alpha=false - Negate Alpha. * @property {bool} color_clamp_to_alpha=true - Negate Colour Clamp to Alpha. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'TurbulentNoise' * @name NodeTypes#TurbulentNoise * @property {int} depth=0 - Depth. * @property {bool} invert_matte_port=false - Invert Matte. * @property {generic_enum} [fractal_type="Fractional Brownian"] - Fractal Type. * @property {generic_enum} [noise_type="Perlin"] - Noise Type. * @property {locked} frequency - Frequency. * @property {bool} frequency.separate=Off - Separate. * @property {doublevb} frequency.xyfrequency=0 - Frequency xy. * @property {doublevb} frequency.xfrequency=0 - Frequency x. * @property {doublevb} frequency.yfrequency=0 - Frequency y. * @property {locked} offset - Offset. * @property {bool} offset.separate=Off - Separate. * @property {doublevb} offset.xyoffset=0 - Offset xy. * @property {doublevb} offset.xoffset=0 - Offset x. * @property {doublevb} offset.yoffset=0 - Offset y. * @property {double} evolution=0 - Evolution. * @property {double} evolution_frequency=0 - Evolution Frequency. * @property {double} gain=0.6500 - Gain. * @property {double} lacunarity=2 - Sub Scaling. * @property {double} octaves=1 - Complexity. */ /** * Attributes present in the node of type: 'BezierMesh' * @name NodeTypes#Mesh-Warp * @property {array_position_2d} mesh - Mesh. * @property {int} mesh.size=105 - Size. * @property {position_2d} mesh.meshpoint0x0 - MeshPoint0x0. * @property {bool} mesh.meshpoint0x0.separate=On - Separate. * @property {double} mesh.meshpoint0x0.x=-12 - Pos x. * @property {double} mesh.meshpoint0x0.y=-12 - Pos y. * @property {point_2d} mesh.meshpoint0x0.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x1 - MeshPoint0x1. * @property {bool} mesh.meshpoint0x1.separate=On - Separate. * @property {double} mesh.meshpoint0x1.x=-10 - Pos x. * @property {double} mesh.meshpoint0x1.y=-12 - Pos y. * @property {point_2d} mesh.meshpoint0x1.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x2 - MeshPoint0x2. * @property {bool} mesh.meshpoint0x2.separate=On - Separate. * @property {double} mesh.meshpoint0x2.x=-8 - Pos x. * @property {double} mesh.meshpoint0x2.y=-12 - Pos y. * @property {point_2d} mesh.meshpoint0x2.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x3 - MeshPoint0x3. * @property {bool} mesh.meshpoint0x3.separate=On - Separate. * @property {double} mesh.meshpoint0x3.x=-6 - Pos x. * @property {double} mesh.meshpoint0x3.y=-12 - Pos y. * @property {point_2d} mesh.meshpoint0x3.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x4 - MeshPoint0x4. * @property {bool} mesh.meshpoint0x4.separate=On - Separate. * @property {double} mesh.meshpoint0x4.x=-4 - Pos x. * @property {double} mesh.meshpoint0x4.y=-12 - Pos y. * @property {point_2d} mesh.meshpoint0x4.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x5 - MeshPoint0x5. * @property {bool} mesh.meshpoint0x5.separate=On - Separate. * @property {double} mesh.meshpoint0x5.x=-2 - Pos x. * @property {double} mesh.meshpoint0x5.y=-12 - Pos y. * @property {point_2d} mesh.meshpoint0x5.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x6 - MeshPoint0x6. * @property {bool} mesh.meshpoint0x6.separate=On - Separate. * @property {double} mesh.meshpoint0x6.x=0 - Pos x. * @property {double} mesh.meshpoint0x6.y=-12 - Pos y. * @property {point_2d} mesh.meshpoint0x6.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x7 - MeshPoint0x7. * @property {bool} mesh.meshpoint0x7.separate=On - Separate. * @property {double} mesh.meshpoint0x7.x=2 - Pos x. * @property {double} mesh.meshpoint0x7.y=-12 - Pos y. * @property {point_2d} mesh.meshpoint0x7.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x8 - MeshPoint0x8. * @property {bool} mesh.meshpoint0x8.separate=On - Separate. * @property {double} mesh.meshpoint0x8.x=4 - Pos x. * @property {double} mesh.meshpoint0x8.y=-12 - Pos y. * @property {point_2d} mesh.meshpoint0x8.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x9 - MeshPoint0x9. * @property {bool} mesh.meshpoint0x9.separate=On - Separate. * @property {double} mesh.meshpoint0x9.x=6 - Pos x. * @property {double} mesh.meshpoint0x9.y=-12 - Pos y. * @property {point_2d} mesh.meshpoint0x9.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x10 - MeshPoint0x10. * @property {bool} mesh.meshpoint0x10.separate=On - Separate. * @property {double} mesh.meshpoint0x10.x=8 - Pos x. * @property {double} mesh.meshpoint0x10.y=-12 - Pos y. * @property {point_2d} mesh.meshpoint0x10.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x11 - MeshPoint0x11. * @property {bool} mesh.meshpoint0x11.separate=On - Separate. * @property {double} mesh.meshpoint0x11.x=10 - Pos x. * @property {double} mesh.meshpoint0x11.y=-12 - Pos y. * @property {point_2d} mesh.meshpoint0x11.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x12 - MeshPoint0x12. * @property {bool} mesh.meshpoint0x12.separate=On - Separate. * @property {double} mesh.meshpoint0x12.x=12 - Pos x. * @property {double} mesh.meshpoint0x12.y=-12 - Pos y. * @property {point_2d} mesh.meshpoint0x12.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x13 - MeshPoint0x13. * @property {bool} mesh.meshpoint0x13.separate=On - Separate. * @property {double} mesh.meshpoint0x13.x=-12 - Pos x. * @property {double} mesh.meshpoint0x13.y=-10 - Pos y. * @property {point_2d} mesh.meshpoint0x13.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x14 - MeshPoint0x14. * @property {bool} mesh.meshpoint0x14.separate=On - Separate. * @property {double} mesh.meshpoint0x14.x=-6 - Pos x. * @property {double} mesh.meshpoint0x14.y=-10 - Pos y. * @property {point_2d} mesh.meshpoint0x14.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x15 - MeshPoint0x15. * @property {bool} mesh.meshpoint0x15.separate=On - Separate. * @property {double} mesh.meshpoint0x15.x=0 - Pos x. * @property {double} mesh.meshpoint0x15.y=-10 - Pos y. * @property {point_2d} mesh.meshpoint0x15.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x16 - MeshPoint0x16. * @property {bool} mesh.meshpoint0x16.separate=On - Separate. * @property {double} mesh.meshpoint0x16.x=6 - Pos x. * @property {double} mesh.meshpoint0x16.y=-10 - Pos y. * @property {point_2d} mesh.meshpoint0x16.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x17 - MeshPoint0x17. * @property {bool} mesh.meshpoint0x17.separate=On - Separate. * @property {double} mesh.meshpoint0x17.x=12 - Pos x. * @property {double} mesh.meshpoint0x17.y=-10 - Pos y. * @property {point_2d} mesh.meshpoint0x17.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x18 - MeshPoint0x18. * @property {bool} mesh.meshpoint0x18.separate=On - Separate. * @property {double} mesh.meshpoint0x18.x=-12 - Pos x. * @property {double} mesh.meshpoint0x18.y=-8 - Pos y. * @property {point_2d} mesh.meshpoint0x18.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x19 - MeshPoint0x19. * @property {bool} mesh.meshpoint0x19.separate=On - Separate. * @property {double} mesh.meshpoint0x19.x=-6 - Pos x. * @property {double} mesh.meshpoint0x19.y=-8 - Pos y. * @property {point_2d} mesh.meshpoint0x19.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x20 - MeshPoint0x20. * @property {bool} mesh.meshpoint0x20.separate=On - Separate. * @property {double} mesh.meshpoint0x20.x=0 - Pos x. * @property {double} mesh.meshpoint0x20.y=-8 - Pos y. * @property {point_2d} mesh.meshpoint0x20.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x21 - MeshPoint0x21. * @property {bool} mesh.meshpoint0x21.separate=On - Separate. * @property {double} mesh.meshpoint0x21.x=6 - Pos x. * @property {double} mesh.meshpoint0x21.y=-8 - Pos y. * @property {point_2d} mesh.meshpoint0x21.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x22 - MeshPoint0x22. * @property {bool} mesh.meshpoint0x22.separate=On - Separate. * @property {double} mesh.meshpoint0x22.x=12 - Pos x. * @property {double} mesh.meshpoint0x22.y=-8 - Pos y. * @property {point_2d} mesh.meshpoint0x22.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x23 - MeshPoint0x23. * @property {bool} mesh.meshpoint0x23.separate=On - Separate. * @property {double} mesh.meshpoint0x23.x=-12 - Pos x. * @property {double} mesh.meshpoint0x23.y=-6 - Pos y. * @property {point_2d} mesh.meshpoint0x23.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x24 - MeshPoint0x24. * @property {bool} mesh.meshpoint0x24.separate=On - Separate. * @property {double} mesh.meshpoint0x24.x=-10 - Pos x. * @property {double} mesh.meshpoint0x24.y=-6 - Pos y. * @property {point_2d} mesh.meshpoint0x24.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x25 - MeshPoint0x25. * @property {bool} mesh.meshpoint0x25.separate=On - Separate. * @property {double} mesh.meshpoint0x25.x=-8 - Pos x. * @property {double} mesh.meshpoint0x25.y=-6 - Pos y. * @property {point_2d} mesh.meshpoint0x25.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x26 - MeshPoint0x26. * @property {bool} mesh.meshpoint0x26.separate=On - Separate. * @property {double} mesh.meshpoint0x26.x=-6 - Pos x. * @property {double} mesh.meshpoint0x26.y=-6 - Pos y. * @property {point_2d} mesh.meshpoint0x26.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x27 - MeshPoint0x27. * @property {bool} mesh.meshpoint0x27.separate=On - Separate. * @property {double} mesh.meshpoint0x27.x=-4 - Pos x. * @property {double} mesh.meshpoint0x27.y=-6 - Pos y. * @property {point_2d} mesh.meshpoint0x27.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x28 - MeshPoint0x28. * @property {bool} mesh.meshpoint0x28.separate=On - Separate. * @property {double} mesh.meshpoint0x28.x=-2 - Pos x. * @property {double} mesh.meshpoint0x28.y=-6 - Pos y. * @property {point_2d} mesh.meshpoint0x28.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x29 - MeshPoint0x29. * @property {bool} mesh.meshpoint0x29.separate=On - Separate. * @property {double} mesh.meshpoint0x29.x=0 - Pos x. * @property {double} mesh.meshpoint0x29.y=-6 - Pos y. * @property {point_2d} mesh.meshpoint0x29.2dpoint - Point. * @property {position_2d} mesh.meshpoint0x30 - MeshPoint0x30. * @property {bool} mesh.meshpoint0x30.separate=On - Separate. * @property {double} mesh.meshpoint0x30.x=2 - Pos x. * @property {double} mesh.meshpoint0x30.y=-6 - Pos y. * @property {point_2d} mesh.meshpoint0x30.2dpoint - Point. * @property {position_2d} mesh.meshpoint1x0 - MeshPoint1x0. * @property {bool} mesh.meshpoint1x0.separate=On - Separate. * @property {double} mesh.meshpoint1x0.x=4 - Pos x. * @property {double} mesh.meshpoint1x0.y=-6 - Pos y. * @property {point_2d} mesh.meshpoint1x0.2dpoint - Point. * @property {position_2d} mesh.meshpoint1x1 - MeshPoint1x1. * @property {bool} mesh.meshpoint1x1.separate=On - Separate. * @property {double} mesh.meshpoint1x1.x=6 - Pos x. * @property {double} mesh.meshpoint1x1.y=-6 - Pos y. * @property {point_2d} mesh.meshpoint1x1.2dpoint - Point. * @property {position_2d} mesh.meshpoint1x2 - MeshPoint1x2. * @property {bool} mesh.meshpoint1x2.separate=On - Separate. * @property {double} mesh.meshpoint1x2.x=8 - Pos x. * @property {double} mesh.meshpoint1x2.y=-6 - Pos y. * @property {point_2d} mesh.meshpoint1x2.2dpoint - Point. * @property {position_2d} mesh.meshpoint1x3 - MeshPoint1x3. * @property {bool} mesh.meshpoint1x3.separate=On - Separate. * @property {double} mesh.meshpoint1x3.x=10 - Pos x. * @property {double} mesh.meshpoint1x3.y=-6 - Pos y. * @property {point_2d} mesh.meshpoint1x3.2dpoint - Point. * @property {position_2d} mesh.meshpoint1x4 - MeshPoint1x4. * @property {bool} mesh.meshpoint1x4.separate=On - Separate. * @property {double} mesh.meshpoint1x4.x=12 - Pos x. * @property {double} mesh.meshpoint1x4.y=-6 - Pos y. * @property {point_2d} mesh.meshpoint1x4.2dpoint - Point. * @property {position_2d} mesh.meshpoint1x5 - MeshPoint1x5. * @property {bool} mesh.meshpoint1x5.separate=On - Separate. * @property {double} mesh.meshpoint1x5.x=-12 - Pos x. * @property {double} mesh.meshpoint1x5.y=-4 - Pos y. * @property {point_2d} mesh.meshpoint1x5.2dpoint - Point. * @property {position_2d} mesh.meshpoint1x6 - MeshPoint1x6. * @property {bool} mesh.meshpoint1x6.separate=On - Separate. * @property {double} mesh.meshpoint1x6.x=-6 - Pos x. * @property {double} mesh.meshpoint1x6.y=-4 - Pos y. * @property {point_2d} mesh.meshpoint1x6.2dpoint - Point. * @property {position_2d} mesh.meshpoint1x7 - MeshPoint1x7. * @property {bool} mesh.meshpoint1x7.separate=On - Separate. * @property {double} mesh.meshpoint1x7.x=0 - Pos x. * @property {double} mesh.meshpoint1x7.y=-4 - Pos y. * @property {point_2d} mesh.meshpoint1x7.2dpoint - Point. * @property {position_2d} mesh.meshpoint1x8 - MeshPoint1x8. * @property {bool} mesh.meshpoint1x8.separate=On - Separate. * @property {double} mesh.meshpoint1x8.x=6 - Pos x. * @property {double} mesh.meshpoint1x8.y=-4 - Pos y. * @property {point_2d} mesh.meshpoint1x8.2dpoint - Point. * @property {position_2d} mesh.meshpoint1x9 - MeshPoint1x9. * @property {bool} mesh.meshpoint1x9.separate=On - Separate. * @property {double} mesh.meshpoint1x9.x=12 - Pos x. * @property {double} mesh.meshpoint1x9.y=-4 - Pos y. * @property {point_2d} mesh.meshpoint1x9.2dpoint - Point. * @property {position_2d} mesh.meshpoint1x10 - MeshPoint1x10. * @property {bool} mesh.meshpoint1x10.separate=On - Separate. * @property {double} mesh.meshpoint1x10.x=-12 - Pos x. * @property {double} mesh.meshpoint1x10.y=-2 - Pos y. * @property {point_2d} mesh.meshpoint1x10.2dpoint - Point. * @property {position_2d} mesh.meshpoint2x0 - MeshPoint2x0. * @property {bool} mesh.meshpoint2x0.separate=On - Separate. * @property {double} mesh.meshpoint2x0.x=-6 - Pos x. * @property {double} mesh.meshpoint2x0.y=-2 - Pos y. * @property {point_2d} mesh.meshpoint2x0.2dpoint - Point. * @property {position_2d} mesh.meshpoint2x1 - MeshPoint2x1. * @property {bool} mesh.meshpoint2x1.separate=On - Separate. * @property {double} mesh.meshpoint2x1.x=0 - Pos x. * @property {double} mesh.meshpoint2x1.y=-2 - Pos y. * @property {point_2d} mesh.meshpoint2x1.2dpoint - Point. * @property {position_2d} mesh.meshpoint2x2 - MeshPoint2x2. * @property {bool} mesh.meshpoint2x2.separate=On - Separate. * @property {double} mesh.meshpoint2x2.x=6 - Pos x. * @property {double} mesh.meshpoint2x2.y=-2 - Pos y. * @property {point_2d} mesh.meshpoint2x2.2dpoint - Point. * @property {position_2d} mesh.meshpoint2x3 - MeshPoint2x3. * @property {bool} mesh.meshpoint2x3.separate=On - Separate. * @property {double} mesh.meshpoint2x3.x=12 - Pos x. * @property {double} mesh.meshpoint2x3.y=-2 - Pos y. * @property {point_2d} mesh.meshpoint2x3.2dpoint - Point. * @property {position_2d} mesh.meshpoint2x4 - MeshPoint2x4. * @property {bool} mesh.meshpoint2x4.separate=On - Separate. * @property {double} mesh.meshpoint2x4.x=-12 - Pos x. * @property {double} mesh.meshpoint2x4.y=0 - Pos y. * @property {point_2d} mesh.meshpoint2x4.2dpoint - Point. * @property {position_2d} mesh.meshpoint2x5 - MeshPoint2x5. * @property {bool} mesh.meshpoint2x5.separate=On - Separate. * @property {double} mesh.meshpoint2x5.x=-10 - Pos x. * @property {double} mesh.meshpoint2x5.y=0 - Pos y. * @property {point_2d} mesh.meshpoint2x5.2dpoint - Point. * @property {position_2d} mesh.meshpoint2x6 - MeshPoint2x6. * @property {bool} mesh.meshpoint2x6.separate=On - Separate. * @property {double} mesh.meshpoint2x6.x=-8 - Pos x. * @property {double} mesh.meshpoint2x6.y=0 - Pos y. * @property {point_2d} mesh.meshpoint2x6.2dpoint - Point. * @property {position_2d} mesh.meshpoint2x7 - MeshPoint2x7. * @property {bool} mesh.meshpoint2x7.separate=On - Separate. * @property {double} mesh.meshpoint2x7.x=-6 - Pos x. * @property {double} mesh.meshpoint2x7.y=0 - Pos y. * @property {point_2d} mesh.meshpoint2x7.2dpoint - Point. * @property {position_2d} mesh.meshpoint2x8 - MeshPoint2x8. * @property {bool} mesh.meshpoint2x8.separate=On - Separate. * @property {double} mesh.meshpoint2x8.x=-4 - Pos x. * @property {double} mesh.meshpoint2x8.y=0 - Pos y. * @property {point_2d} mesh.meshpoint2x8.2dpoint - Point. * @property {position_2d} mesh.meshpoint2x9 - MeshPoint2x9. * @property {bool} mesh.meshpoint2x9.separate=On - Separate. * @property {double} mesh.meshpoint2x9.x=-2 - Pos x. * @property {double} mesh.meshpoint2x9.y=0 - Pos y. * @property {point_2d} mesh.meshpoint2x9.2dpoint - Point. * @property {position_2d} mesh.meshpoint2x10 - MeshPoint2x10. * @property {bool} mesh.meshpoint2x10.separate=On - Separate. * @property {double} mesh.meshpoint2x10.x=0 - Pos x. * @property {double} mesh.meshpoint2x10.y=0 - Pos y. * @property {point_2d} mesh.meshpoint2x10.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x0 - MeshPoint3x0. * @property {bool} mesh.meshpoint3x0.separate=On - Separate. * @property {double} mesh.meshpoint3x0.x=2 - Pos x. * @property {double} mesh.meshpoint3x0.y=0 - Pos y. * @property {point_2d} mesh.meshpoint3x0.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x1 - MeshPoint3x1. * @property {bool} mesh.meshpoint3x1.separate=On - Separate. * @property {double} mesh.meshpoint3x1.x=4 - Pos x. * @property {double} mesh.meshpoint3x1.y=0 - Pos y. * @property {point_2d} mesh.meshpoint3x1.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x2 - MeshPoint3x2. * @property {bool} mesh.meshpoint3x2.separate=On - Separate. * @property {double} mesh.meshpoint3x2.x=6 - Pos x. * @property {double} mesh.meshpoint3x2.y=0 - Pos y. * @property {point_2d} mesh.meshpoint3x2.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x3 - MeshPoint3x3. * @property {bool} mesh.meshpoint3x3.separate=On - Separate. * @property {double} mesh.meshpoint3x3.x=8 - Pos x. * @property {double} mesh.meshpoint3x3.y=0 - Pos y. * @property {point_2d} mesh.meshpoint3x3.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x4 - MeshPoint3x4. * @property {bool} mesh.meshpoint3x4.separate=On - Separate. * @property {double} mesh.meshpoint3x4.x=10 - Pos x. * @property {double} mesh.meshpoint3x4.y=0 - Pos y. * @property {point_2d} mesh.meshpoint3x4.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x5 - MeshPoint3x5. * @property {bool} mesh.meshpoint3x5.separate=On - Separate. * @property {double} mesh.meshpoint3x5.x=12 - Pos x. * @property {double} mesh.meshpoint3x5.y=0 - Pos y. * @property {point_2d} mesh.meshpoint3x5.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x6 - MeshPoint3x6. * @property {bool} mesh.meshpoint3x6.separate=On - Separate. * @property {double} mesh.meshpoint3x6.x=-12 - Pos x. * @property {double} mesh.meshpoint3x6.y=2 - Pos y. * @property {point_2d} mesh.meshpoint3x6.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x7 - MeshPoint3x7. * @property {bool} mesh.meshpoint3x7.separate=On - Separate. * @property {double} mesh.meshpoint3x7.x=-6 - Pos x. * @property {double} mesh.meshpoint3x7.y=2 - Pos y. * @property {point_2d} mesh.meshpoint3x7.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x8 - MeshPoint3x8. * @property {bool} mesh.meshpoint3x8.separate=On - Separate. * @property {double} mesh.meshpoint3x8.x=0 - Pos x. * @property {double} mesh.meshpoint3x8.y=2 - Pos y. * @property {point_2d} mesh.meshpoint3x8.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x9 - MeshPoint3x9. * @property {bool} mesh.meshpoint3x9.separate=On - Separate. * @property {double} mesh.meshpoint3x9.x=6 - Pos x. * @property {double} mesh.meshpoint3x9.y=2 - Pos y. * @property {point_2d} mesh.meshpoint3x9.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x10 - MeshPoint3x10. * @property {bool} mesh.meshpoint3x10.separate=On - Separate. * @property {double} mesh.meshpoint3x10.x=12 - Pos x. * @property {double} mesh.meshpoint3x10.y=2 - Pos y. * @property {point_2d} mesh.meshpoint3x10.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x11 - MeshPoint3x11. * @property {bool} mesh.meshpoint3x11.separate=On - Separate. * @property {double} mesh.meshpoint3x11.x=-12 - Pos x. * @property {double} mesh.meshpoint3x11.y=4 - Pos y. * @property {point_2d} mesh.meshpoint3x11.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x12 - MeshPoint3x12. * @property {bool} mesh.meshpoint3x12.separate=On - Separate. * @property {double} mesh.meshpoint3x12.x=-6 - Pos x. * @property {double} mesh.meshpoint3x12.y=4 - Pos y. * @property {point_2d} mesh.meshpoint3x12.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x13 - MeshPoint3x13. * @property {bool} mesh.meshpoint3x13.separate=On - Separate. * @property {double} mesh.meshpoint3x13.x=0 - Pos x. * @property {double} mesh.meshpoint3x13.y=4 - Pos y. * @property {point_2d} mesh.meshpoint3x13.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x14 - MeshPoint3x14. * @property {bool} mesh.meshpoint3x14.separate=On - Separate. * @property {double} mesh.meshpoint3x14.x=6 - Pos x. * @property {double} mesh.meshpoint3x14.y=4 - Pos y. * @property {point_2d} mesh.meshpoint3x14.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x15 - MeshPoint3x15. * @property {bool} mesh.meshpoint3x15.separate=On - Separate. * @property {double} mesh.meshpoint3x15.x=12 - Pos x. * @property {double} mesh.meshpoint3x15.y=4 - Pos y. * @property {point_2d} mesh.meshpoint3x15.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x16 - MeshPoint3x16. * @property {bool} mesh.meshpoint3x16.separate=On - Separate. * @property {double} mesh.meshpoint3x16.x=-12 - Pos x. * @property {double} mesh.meshpoint3x16.y=6 - Pos y. * @property {point_2d} mesh.meshpoint3x16.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x17 - MeshPoint3x17. * @property {bool} mesh.meshpoint3x17.separate=On - Separate. * @property {double} mesh.meshpoint3x17.x=-10 - Pos x. * @property {double} mesh.meshpoint3x17.y=6 - Pos y. * @property {point_2d} mesh.meshpoint3x17.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x18 - MeshPoint3x18. * @property {bool} mesh.meshpoint3x18.separate=On - Separate. * @property {double} mesh.meshpoint3x18.x=-8 - Pos x. * @property {double} mesh.meshpoint3x18.y=6 - Pos y. * @property {point_2d} mesh.meshpoint3x18.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x19 - MeshPoint3x19. * @property {bool} mesh.meshpoint3x19.separate=On - Separate. * @property {double} mesh.meshpoint3x19.x=-6 - Pos x. * @property {double} mesh.meshpoint3x19.y=6 - Pos y. * @property {point_2d} mesh.meshpoint3x19.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x20 - MeshPoint3x20. * @property {bool} mesh.meshpoint3x20.separate=On - Separate. * @property {double} mesh.meshpoint3x20.x=-4 - Pos x. * @property {double} mesh.meshpoint3x20.y=6 - Pos y. * @property {point_2d} mesh.meshpoint3x20.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x21 - MeshPoint3x21. * @property {bool} mesh.meshpoint3x21.separate=On - Separate. * @property {double} mesh.meshpoint3x21.x=-2 - Pos x. * @property {double} mesh.meshpoint3x21.y=6 - Pos y. * @property {point_2d} mesh.meshpoint3x21.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x22 - MeshPoint3x22. * @property {bool} mesh.meshpoint3x22.separate=On - Separate. * @property {double} mesh.meshpoint3x22.x=0 - Pos x. * @property {double} mesh.meshpoint3x22.y=6 - Pos y. * @property {point_2d} mesh.meshpoint3x22.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x23 - MeshPoint3x23. * @property {bool} mesh.meshpoint3x23.separate=On - Separate. * @property {double} mesh.meshpoint3x23.x=2 - Pos x. * @property {double} mesh.meshpoint3x23.y=6 - Pos y. * @property {point_2d} mesh.meshpoint3x23.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x24 - MeshPoint3x24. * @property {bool} mesh.meshpoint3x24.separate=On - Separate. * @property {double} mesh.meshpoint3x24.x=4 - Pos x. * @property {double} mesh.meshpoint3x24.y=6 - Pos y. * @property {point_2d} mesh.meshpoint3x24.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x25 - MeshPoint3x25. * @property {bool} mesh.meshpoint3x25.separate=On - Separate. * @property {double} mesh.meshpoint3x25.x=6 - Pos x. * @property {double} mesh.meshpoint3x25.y=6 - Pos y. * @property {point_2d} mesh.meshpoint3x25.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x26 - MeshPoint3x26. * @property {bool} mesh.meshpoint3x26.separate=On - Separate. * @property {double} mesh.meshpoint3x26.x=8 - Pos x. * @property {double} mesh.meshpoint3x26.y=6 - Pos y. * @property {point_2d} mesh.meshpoint3x26.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x27 - MeshPoint3x27. * @property {bool} mesh.meshpoint3x27.separate=On - Separate. * @property {double} mesh.meshpoint3x27.x=10 - Pos x. * @property {double} mesh.meshpoint3x27.y=6 - Pos y. * @property {point_2d} mesh.meshpoint3x27.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x28 - MeshPoint3x28. * @property {bool} mesh.meshpoint3x28.separate=On - Separate. * @property {double} mesh.meshpoint3x28.x=12 - Pos x. * @property {double} mesh.meshpoint3x28.y=6 - Pos y. * @property {point_2d} mesh.meshpoint3x28.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x29 - MeshPoint3x29. * @property {bool} mesh.meshpoint3x29.separate=On - Separate. * @property {double} mesh.meshpoint3x29.x=-12 - Pos x. * @property {double} mesh.meshpoint3x29.y=8 - Pos y. * @property {point_2d} mesh.meshpoint3x29.2dpoint - Point. * @property {position_2d} mesh.meshpoint3x30 - MeshPoint3x30. * @property {bool} mesh.meshpoint3x30.separate=On - Separate. * @property {double} mesh.meshpoint3x30.x=-6 - Pos x. * @property {double} mesh.meshpoint3x30.y=8 - Pos y. * @property {point_2d} mesh.meshpoint3x30.2dpoint - Point. * @property {position_2d} mesh.meshpoint4x0 - MeshPoint4x0. * @property {bool} mesh.meshpoint4x0.separate=On - Separate. * @property {double} mesh.meshpoint4x0.x=0 - Pos x. * @property {double} mesh.meshpoint4x0.y=8 - Pos y. * @property {point_2d} mesh.meshpoint4x0.2dpoint - Point. * @property {position_2d} mesh.meshpoint4x1 - MeshPoint4x1. * @property {bool} mesh.meshpoint4x1.separate=On - Separate. * @property {double} mesh.meshpoint4x1.x=6 - Pos x. * @property {double} mesh.meshpoint4x1.y=8 - Pos y. * @property {point_2d} mesh.meshpoint4x1.2dpoint - Point. * @property {position_2d} mesh.meshpoint4x2 - MeshPoint4x2. * @property {bool} mesh.meshpoint4x2.separate=On - Separate. * @property {double} mesh.meshpoint4x2.x=12 - Pos x. * @property {double} mesh.meshpoint4x2.y=8 - Pos y. * @property {point_2d} mesh.meshpoint4x2.2dpoint - Point. * @property {position_2d} mesh.meshpoint4x3 - MeshPoint4x3. * @property {bool} mesh.meshpoint4x3.separate=On - Separate. * @property {double} mesh.meshpoint4x3.x=-12 - Pos x. * @property {double} mesh.meshpoint4x3.y=10 - Pos y. * @property {point_2d} mesh.meshpoint4x3.2dpoint - Point. * @property {position_2d} mesh.meshpoint4x4 - MeshPoint4x4. * @property {bool} mesh.meshpoint4x4.separate=On - Separate. * @property {double} mesh.meshpoint4x4.x=-6 - Pos x. * @property {double} mesh.meshpoint4x4.y=10 - Pos y. * @property {point_2d} mesh.meshpoint4x4.2dpoint - Point. * @property {position_2d} mesh.meshpoint4x5 - MeshPoint4x5. * @property {bool} mesh.meshpoint4x5.separate=On - Separate. * @property {double} mesh.meshpoint4x5.x=0 - Pos x. * @property {double} mesh.meshpoint4x5.y=10 - Pos y. * @property {point_2d} mesh.meshpoint4x5.2dpoint - Point. * @property {position_2d} mesh.meshpoint4x6 - MeshPoint4x6. * @property {bool} mesh.meshpoint4x6.separate=On - Separate. * @property {double} mesh.meshpoint4x6.x=6 - Pos x. * @property {double} mesh.meshpoint4x6.y=10 - Pos y. * @property {point_2d} mesh.meshpoint4x6.2dpoint - Point. * @property {position_2d} mesh.meshpoint4x7 - MeshPoint4x7. * @property {bool} mesh.meshpoint4x7.separate=On - Separate. * @property {double} mesh.meshpoint4x7.x=12 - Pos x. * @property {double} mesh.meshpoint4x7.y=10 - Pos y. * @property {point_2d} mesh.meshpoint4x7.2dpoint - Point. * @property {position_2d} mesh.meshpoint4x8 - MeshPoint4x8. * @property {bool} mesh.meshpoint4x8.separate=On - Separate. * @property {double} mesh.meshpoint4x8.x=-12 - Pos x. * @property {double} mesh.meshpoint4x8.y=12 - Pos y. * @property {point_2d} mesh.meshpoint4x8.2dpoint - Point. * @property {position_2d} mesh.meshpoint4x9 - MeshPoint4x9. * @property {bool} mesh.meshpoint4x9.separate=On - Separate. * @property {double} mesh.meshpoint4x9.x=-10 - Pos x. * @property {double} mesh.meshpoint4x9.y=12 - Pos y. * @property {point_2d} mesh.meshpoint4x9.2dpoint - Point. * @property {position_2d} mesh.meshpoint4x10 - MeshPoint4x10. * @property {bool} mesh.meshpoint4x10.separate=On - Separate. * @property {double} mesh.meshpoint4x10.x=-8 - Pos x. * @property {double} mesh.meshpoint4x10.y=12 - Pos y. * @property {point_2d} mesh.meshpoint4x10.2dpoint - Point. * @property {position_2d} mesh.meshpoint5x0 - MeshPoint5x0. * @property {bool} mesh.meshpoint5x0.separate=On - Separate. * @property {double} mesh.meshpoint5x0.x=-6 - Pos x. * @property {double} mesh.meshpoint5x0.y=12 - Pos y. * @property {point_2d} mesh.meshpoint5x0.2dpoint - Point. * @property {position_2d} mesh.meshpoint5x1 - MeshPoint5x1. * @property {bool} mesh.meshpoint5x1.separate=On - Separate. * @property {double} mesh.meshpoint5x1.x=-4 - Pos x. * @property {double} mesh.meshpoint5x1.y=12 - Pos y. * @property {point_2d} mesh.meshpoint5x1.2dpoint - Point. * @property {position_2d} mesh.meshpoint5x2 - MeshPoint5x2. * @property {bool} mesh.meshpoint5x2.separate=On - Separate. * @property {double} mesh.meshpoint5x2.x=-2 - Pos x. * @property {double} mesh.meshpoint5x2.y=12 - Pos y. * @property {point_2d} mesh.meshpoint5x2.2dpoint - Point. * @property {position_2d} mesh.meshpoint5x3 - MeshPoint5x3. * @property {bool} mesh.meshpoint5x3.separate=On - Separate. * @property {double} mesh.meshpoint5x3.x=0 - Pos x. * @property {double} mesh.meshpoint5x3.y=12 - Pos y. * @property {point_2d} mesh.meshpoint5x3.2dpoint - Point. * @property {position_2d} mesh.meshpoint5x4 - MeshPoint5x4. * @property {bool} mesh.meshpoint5x4.separate=On - Separate. * @property {double} mesh.meshpoint5x4.x=2 - Pos x. * @property {double} mesh.meshpoint5x4.y=12 - Pos y. * @property {point_2d} mesh.meshpoint5x4.2dpoint - Point. * @property {position_2d} mesh.meshpoint5x5 - MeshPoint5x5. * @property {bool} mesh.meshpoint5x5.separate=On - Separate. * @property {double} mesh.meshpoint5x5.x=4 - Pos x. * @property {double} mesh.meshpoint5x5.y=12 - Pos y. * @property {point_2d} mesh.meshpoint5x5.2dpoint - Point. * @property {position_2d} mesh.meshpoint5x6 - MeshPoint5x6. * @property {bool} mesh.meshpoint5x6.separate=On - Separate. * @property {double} mesh.meshpoint5x6.x=6 - Pos x. * @property {double} mesh.meshpoint5x6.y=12 - Pos y. * @property {point_2d} mesh.meshpoint5x6.2dpoint - Point. * @property {position_2d} mesh.meshpoint5x7 - MeshPoint5x7. * @property {bool} mesh.meshpoint5x7.separate=On - Separate. * @property {double} mesh.meshpoint5x7.x=8 - Pos x. * @property {double} mesh.meshpoint5x7.y=12 - Pos y. * @property {point_2d} mesh.meshpoint5x7.2dpoint - Point. * @property {position_2d} mesh.meshpoint5x8 - MeshPoint5x8. * @property {bool} mesh.meshpoint5x8.separate=On - Separate. * @property {double} mesh.meshpoint5x8.x=10 - Pos x. * @property {double} mesh.meshpoint5x8.y=12 - Pos y. * @property {point_2d} mesh.meshpoint5x8.2dpoint - Point. * @property {position_2d} mesh.meshpoint5x9 - MeshPoint5x9. * @property {bool} mesh.meshpoint5x9.separate=On - Separate. * @property {double} mesh.meshpoint5x9.x=12 - Pos x. * @property {double} mesh.meshpoint5x9.y=12 - Pos y. * @property {point_2d} mesh.meshpoint5x9.2dpoint - Point. * @property {int} mesh.rows=4 - Rows. * @property {int} mesh.columns=4 - Columns. * @property {position_2d} origin - Origin. * @property {bool} origin.separate=On - Separate. * @property {double} origin.x=0 - Pos x. * @property {double} origin.y=0 - Pos y. * @property {point_2d} origin.2dpoint - Point. * @property {double} width=12 - Width. * @property {double} height=12 - Height. * @property {generic_enum} [deformationquality="Very High"] - Deformation Quality. */ /** * Attributes present in the node of type: 'EXTERNAL' * @name NodeTypes#External * @property {string} program_name - External Program. * @property {string} program_input - Program First Input File ($IN1). * @property {string} program_input2 - Program Second Input File ($IN2). * @property {string} program_output - Program Output File ($OUT). * @property {string} extension=TGA - Extension ($EXT). * @property {double} program_num_param=0 - Numerical Parameter 1 ($NUM). * @property {bool} program_uniqueid=true - Generate Unique FileNames. * @property {double} program_num_param_2=0 - Numerical Parameter 2 ($NUM2). * @property {double} program_num_param_3=0 - Numerical Parameter 3 ($NUM3). * @property {double} program_num_param_4=0 - Numerical Parameter 4 ($NUM4). * @property {double} program_num_param_5=0 - Numerical Parameter 5 ($NUM5). */ /** * Attributes present in the node of type: 'PointConstraint2' * @name NodeTypes#Two-Points-Constraint * @property {double} active=100 - Active. * @property {double} volumemod=75 - Volume Modifier. * @property {double} volumemax=200 - Volume Max. * @property {double} volumemin=25 - Volume Min. * @property {double} stretchmax=0 - Distance Max. * @property {double} stretchmin=0 - Distance Min. * @property {double} skewcontrol=0 - Skew Modifier. * @property {double} smooth=0 - Smoothing. * @property {double} balance=0 - Point Balance. * @property {generic_enum} [flattentype="Allow 3D Transform"] - Flatten Type. * @property {generic_enum} [transformtype="Translate"] - Transform Type. * @property {generic_enum} [primaryport="Right"] - Primary Port. */ /** * Attributes present in the node of type: 'COLOR_SCALE' * @name NodeTypes#Colour-Scale * @property {double} red=1 - Red. * @property {double} green=1 - Green. * @property {double} blue=1 - Blue. * @property {double} alpha=1 - Alpha. * @property {double} hue=1 - Hue. * @property {double} hue_offset=0 - Hue Offset. * @property {double} saturation=1 - Saturation. * @property {double} value=1 - Value. * @property {bool} clampneg=false - Clamp Negative. * @property {bool} pre_multiplied_alpha=false - Pre-multiplied Alpha. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'LightPosition' * @name NodeTypes#Light-Position * @property {position_3d} position0 - Position. * @property {bool} position0.separate=On - Separate. * @property {double} position0.x=0 - Pos x. * @property {double} position0.y=0 - Pos y. * @property {double} position0.z=0 - Pos z. * @property {path_3d} position0.3dpath - Path. * @property {position_3d} position1 - Look at. * @property {bool} position1.separate=On - Separate. * @property {double} position1.x=1 - Pos x. * @property {double} position1.y=0 - Pos y. * @property {double} position1.z=0 - Pos z. * @property {path_3d} position1.3dpath - Path. */ /** * Attributes present in the node of type: 'SurfaceNormal' * @name NodeTypes#Surface-Normal * @property {generic_enum} [normalquality="Low"] - Surface Normal Quality. */ /** * Attributes present in the node of type: 'FilterBanding' * @name NodeTypes#Colour-Banding * @property {double} threshold1=20 - Threshold 1. * @property {color} colour1=ffffffff - Colour 1. * @property {int} colour1.red=255 - Red. * @property {int} colour1.green=255 - Green. * @property {int} colour1.blue=255 - Blue. * @property {int} colour1.alpha=255 - Alpha. * @property {generic_enum} [colour1.preferred_ui="Separate"] - Preferred Editor. * @property {double} blur1=0 - Blur 1. * @property {double} threshold2=40 - Threshold 2. * @property {color} colour2=ffffffff - Colour 2. * @property {int} colour2.red=255 - Red. * @property {int} colour2.green=255 - Green. * @property {int} colour2.blue=255 - Blue. * @property {int} colour2.alpha=255 - Alpha. * @property {generic_enum} [colour2.preferred_ui="Separate"] - Preferred Editor. * @property {double} blur2=0 - Blur 2. * @property {double} threshold3=20 - Threshold 3. * @property {color} colour3=ffffffff - Colour 3. * @property {int} colour3.red=255 - Red. * @property {int} colour3.green=255 - Green. * @property {int} colour3.blue=255 - Blue. * @property {int} colour3.alpha=255 - Alpha. * @property {generic_enum} [colour3.preferred_ui="Separate"] - Preferred Editor. * @property {double} blur3=0 - Blur 3. * @property {double} threshold4=20 - Threshold 4. * @property {color} colour4=ffffffff - Colour 4. * @property {int} colour4.red=255 - Red. * @property {int} colour4.green=255 - Green. * @property {int} colour4.blue=255 - Blue. * @property {int} colour4.alpha=255 - Alpha. * @property {generic_enum} [colour4.preferred_ui="Separate"] - Preferred Editor. * @property {double} blur4=0 - Blur 4. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'MATTE_RESIZE' * @name NodeTypes#Matte-Resize * @property {double} radius=0 - Radius. */ /** * Attributes present in the node of type: 'IncreaseOpacity' * @name NodeTypes#Increase-Opacity * @property {double} factor=1.8000 - Factor. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'FLICKER_BLUR' * @name NodeTypes#Anti-Flicker * @property {double} radius=0 - Radius. */ /** * Attributes present in the node of type: 'ObjectDefinition' * @name NodeTypes#Volume-Object * @property {int} objectid=1 - ID. * @property {bool} cutvolumecues=false - Cut Volume Cues with Geometry. * @property {bool} usegeometry=true - Use Drawing to Create Volume. * @property {double} geometryintensity=50 - Elevation Baseline. * @property {double} elevationmin=0 - Elevation Min. * @property {double} elevationmax=1 - Elevation Max. * @property {double} smoothness=1 - Elevation Smoothness. * @property {bool} invertmask=false - Invert Height Matte. */ /** * Attributes present in the node of type: 'LuminanceThreshold' * @name NodeTypes#Luminance-Threshold * @property {double} luminancethresholdthresh=75 - Threshold. * @property {bool} luminancethresholdsoften=true - Soften Colours. * @property {double} luminancethresholdgamma=1.5000 - Gamma Correction. * @property {bool} luminancethresholdgrey=false - Output Greyscale Matte. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'PIXELATE' * @name NodeTypes#Pixelate * @property {bool} invert_matte_port=false - Invert Matte. * @property {double} factor=0.0125 - Factor. */ /** * Attributes present in the node of type: 'LightShader' * @name NodeTypes#Light-Shader * @property {generic_enum} [lighttype="Directional"] - Light Type. * @property {double} floodangle=90 - Cone Angle. * @property {double} floodsharpness=0 - Diffusion. * @property {double} floodradius=2000 - Falloff. * @property {double} pointelevation=200 - Light Source Elevation. * @property {double} anglethreshold=90 - Surface Reflectivity. * @property {generic_enum} [shadetype="Smooth"] - Shading Type. * @property {double} bias=0.1000 - Bias. * @property {double} exponent=2 - Abruptness. * @property {color} lightcolor=ffc8c8c8 - Light Colour. * @property {int} lightcolor.red=200 - Red. * @property {int} lightcolor.green=200 - Green. * @property {int} lightcolor.blue=200 - Blue. * @property {int} lightcolor.alpha=255 - Alpha. * @property {generic_enum} [lightcolor.preferred_ui="Separate"] - Preferred Editor. * @property {bool} flatten=true - Flatten Fx. * @property {bool} useimagecolor=false - Use image Colour. * @property {double} imagecolorweight=50 - Image Colour Intensity. * @property {bool} adjustlevel=false - Adjust Light Intensity. * @property {double} adjustedlevel=75 - Intensity. * @property {double} scale=1 - Multiplier. * @property {bool} usesurface=false - Use Surface Lighting. * @property {double} speculartransparency=100 - Specular Transparency. * @property {double} specularity=100 - Specular Strength. * @property {double} distancefalloff=0 - Distance Falloff. */ /** * Attributes present in the node of type: 'DeformationRootModule' * @name NodeTypes#Deformation-Root * @property {generic_enum} [deformationquality="Very High"] - Quality. */ /** * Attributes present in the node of type: 'DeformationSwitchModule' * @name NodeTypes#Deformation-Switch * @property {generic_enum} [vectorquality="Very High"] - Vector Quality. * @property {double} fadeexponent=3 - Influence Fade Exponent. * @property {bool} fadeinside=false - Fade Inside Zones. * @property {int} enabledeformation=1 - Enable Deformation. * @property {int} chainselectionreference=1 - Kinematic Chain Selection Reference. */ /** * Attributes present in the node of type: 'AutoMuscleModule' * @name NodeTypes#Auto-Muscle * @property {bool} enableleft=true - Muscle Left. * @property {double} leftstart=-3 - Left Start. * @property {double} leftspan=1 - Left Span. * @property {double} leftamplitude=0.2500 - Left Amplitude. * @property {bool} enableright=true - Muscle Right. * @property {bool} symmetric=false - Same as Left. * @property {double} rightstart=-3 - Right Start. * @property {double} rightspan=1 - Right Span. * @property {double} rightamplitude=0.2500 - Right Amplitude. */ /** * Attributes present in the node of type: 'AutoFoldModule' * @name NodeTypes#Deformation-AutoFold * @property {int} enable=1 - Enable AutoFold. * @property {double} length=12 - Length. */ /** * Attributes present in the node of type: 'DeformationScaleModule' * @name NodeTypes#Deformation-Scale * @property {bool} enableleft=true - Scale Left. * @property {double} leftfadein=0 - Left Fade In. * @property {double} leftfadeout=0 - Left Fade Out. * @property {double} leftstart=0 - Left Start. * @property {double} leftspan=1 - Left Span. * @property {double} leftscale0=1 - Left Start Scale. * @property {double} leftscale1=1 - Left End Scale. * @property {double} lefthandleposition0=25 - Left Start Handle Position. * @property {double} lefthandleposition1=25 - Left End Handle Position. * @property {double} lefthandlescale0=1 - Left Start Handle Scale. * @property {double} lefthandlescale1=1 - Left End Handle Scale. * @property {bool} enableright=true - Scale Right. * @property {bool} symmetric=false - Same as Left. * @property {double} rightfadein=0 - Right Fade In. * @property {double} rightfadeout=0 - Right Fade Out. * @property {double} rightstart=0 - Right Start. * @property {double} rightspan=1 - Right Span. * @property {double} rightscale0=1 - Right Start Scale. * @property {double} rightscale1=1 - Right End Scale. * @property {double} righthandleposition0=25 - Right Start Handle Position. * @property {double} righthandleposition1=25 - Right End Handle Position. * @property {double} righthandlescale0=1 - Right Start Handle Scale. * @property {double} righthandlescale1=1 - Right End Handle Scale. */ /** * Attributes present in the node of type: 'DeformationWaveModule' * @name NodeTypes#Deformation-Wave * @property {bool} enableleft=true - Wave Left. * @property {double} leftstart=0 - Left Start. * @property {double} leftspan=10 - Left Span. * @property {double} leftoffsett=0 - Left Offset Deformer. * @property {double} leftamplitude=1 - Left Amplitude. * @property {double} leftoffset=1 - Left Offset Scaling. * @property {double} leftperiod=1 - Left Period. * @property {bool} enableright=true - Wave Right. * @property {bool} symmetric=false - Same as Left. * @property {double} rightstart=0 - Right Start. * @property {double} rightspan=10 - Right Span. * @property {double} rightoffsett=0 - Right Offset Deformer. * @property {double} rightamplitude=1 - Right Amplitude. * @property {double} rightoffset=1 - Right Offset Scaling. * @property {double} rightperiod=1 - Right Period. */ /** * Attributes present in the node of type: 'FoldModule' * @name NodeTypes#Deformation-Fold * @property {int} enable=1 - Enable Fold. * @property {double} t=1 - Where. * @property {double} tbefore=1 - Span Before. * @property {double} tafter=1 - Span After. * @property {double} angle=0 - Orientation. * @property {double} length=12 - Length. */ /** * Attributes present in the node of type: 'DeformationUniformScaleModule' * @name NodeTypes#Deformation-Uniform-Scale * @property {double} scale=1 - Scale. */ /** * Attributes present in the node of type: 'DEPTHBLUR' * @name NodeTypes#Z-Buffer-Smoothing * @property {double} histogram_range=80 - Histogram Range. * @property {int} kernel_size=5 - Kernel Size. */ /** * Attributes present in the node of type: 'ParticleExplosion' * @name NodeTypes#Explosion * @property {int} trigger=1 - Trigger. * @property {double} explosionx=0 - X. * @property {double} explosiony=0 - Y. * @property {double} explosionz=0 - Z. * @property {double} explosionradius=3 - Radius. * @property {double} explosionsigma=1 - Sigma. * @property {double} explosionmagnitude=5 - Magnitude. * @property {double} explosionepsilon=0.0010 - Epsilon. */ /** * Attributes present in the node of type: 'ParticleImageEmitter' * @name NodeTypes#Image-Fracture * @property {int} trigger=0 - Trigger. * @property {double} ageatbirth=0 - Age at Birth. * @property {double} ageatbirthstd=0 - Age at Birth Standard Deviation. * @property {double} mass=1 - Particles Mass. * @property {generic_enum} [typechoosingstrategy="Sequentially Assign Type Number"] - Type Generation Strategy. * @property {int} particletype0=1 - Particle Type 0. * @property {int} particletype1=1 - Particle Type 1. * @property {double} particlesize=1 - Size over Age. * @property {bool} overridevelocity=false - Align Initial Velocity. * @property {generic_enum} [blend_mode="Normal"] - Blend Mode. * @property {double} blendintensity=100 - Blend Intensity. * @property {generic_enum} [colouringstrategy="Use Drawing Colour"] - Colouring Strategy. * @property {color} particlecolour=ffffffff - Colour. * @property {int} particlecolour.red=255 - Red. * @property {int} particlecolour.green=255 - Green. * @property {int} particlecolour.blue=255 - Blue. * @property {int} particlecolour.alpha=255 - Alpha. * @property {generic_enum} [particlecolour.preferred_ui="Separate"] - Preferred Editor. * @property {bool} alignwithdirection=true - Align with Direction. * @property {bool} userotation=false - Use Rotation of Particle. * @property {bool} directionalscale=false - Directional Scale. * @property {double} directionalscalefactor=1 - Directional Scale Exponent Factor. * @property {bool} keepvolume=true - Keep Volume. * @property {generic_enum} [blur="No Blur"] - Blur. * @property {double} blurintensity=1 - Blur Intensity. * @property {double} blurfallof=0.5000 - Falloff Rate. * @property {bool} flipwithdirectionx=false - Flip X Axis to Match Direction. * @property {bool} flipwithdirectiony=false - Flip Y Axis to Match Direction. * @property {generic_enum} [alignwithdirectionaxis="Positive X"] - Axis to Align. */ /** * Attributes present in the node of type: 'ParticleBounce' * @name NodeTypes#Bounce * @property {int} trigger=1 - Trigger. * @property {double} friction=0.5000 - Friction. * @property {double} resilience=0.5000 - Resilience. * @property {double} cutoff=0.5000 - Cutoff Speed. */ /** * Attributes present in the node of type: 'ParticleMove' * @name NodeTypes#Move-Particles * @property {int} trigger=1 - Trigger. * @property {bool} moveage=false - Age Particles. * @property {bool} moveposition=true - Move Position. * @property {bool} moveangle=true - Move Angle. * @property {bool} followeachother=false - Make Particles Follow each Other. * @property {double} followintensity=1 - Follow Intensity. */ /** * Attributes present in the node of type: 'ParticleKill' * @name NodeTypes#Kill * @property {int} trigger=1 - Trigger. * @property {bool} handlenaturaldeth=true - Use Maximum Lifespan. * @property {bool} killyounger=false - Kill Younger. * @property {int} killyoungerthan=-1 - Kill Younger than. * @property {bool} killolder=true - Kill Older. * @property {int} killolderthan=100 - Kill Older than. */ /** * Attributes present in the node of type: 'ParticleOrbit' * @name NodeTypes#Orbit * @property {int} trigger=1 - Trigger. * @property {generic_enum} [strategy="Around Point"] - Orbit Type. * @property {double} magnitude=1 - Magnitude. * @property {double} v0x=0 - Point X. * @property {double} v0y=0 - Point Y. * @property {double} v0z=0 - Point Z. * @property {double} v1x=0 - Direction X. * @property {double} v1y=0 - Direction Y. * @property {double} v1z=1 - Direction Z. */ /** * Attributes present in the node of type: 'ParticleGravity' * @name NodeTypes#Gravity * @property {int} trigger=1 - Trigger. * @property {bool} applygravity=true - Apply Gravity. * @property {double} directionx=0 - X Direction. * @property {double} directiony=-1 - Y Direction. * @property {double} directionz=0 - Z Direction. * @property {bool} relativegravity=false - Apply Gravity between Particles (Relative Gravity). * @property {double} relativemagnitude=1 - Relative Gravity Magnitude. * @property {double} relativegravityepsilon=0.0010 - Relative Gravity Epsilon. * @property {double} relativegravitymaxradius=2 - Relative Gravity Maximum Distance. */ /** * Attributes present in the node of type: 'Particle3dRegion' * @name NodeTypes#3D-Region * @property {generic_enum} [shapetype="Sphere"] - Type. * @property {double} sizex=6 - Width. * @property {double} sizey=6 - Height. * @property {double} sizez=6 - Depth. * @property {double} outerradius=6 - Max. * @property {double} innerradius=0 - Min. */ /** * Attributes present in the node of type: 'ParticleSink' * @name NodeTypes#Sink * @property {int} trigger=1 - Trigger. * @property {bool} ifinside=false - Invert. */ /** * Attributes present in the node of type: 'ParticleRandom' * @name NodeTypes#Random-Parameter * @property {int} trigger=1 - Trigger. * @property {generic_enum} [parametertorandomize="Speed"] - Parameter. */ /** * Attributes present in the node of type: 'ParticleVortex' * @name NodeTypes#Vortex * @property {int} trigger=1 - Trigger. * @property {double} vortexx=0 - X Direction. * @property {double} vortexy=12 - Y Direction. * @property {double} vortexz=0 - Z Direction. * @property {double} vortexradius=4 - Radius. * @property {double} vortexexponent=1 - Exponent (1=cone). * @property {double} vortexupspeed=0.0050 - Up Acceleration. * @property {double} vortexinspeed=0.0050 - In Acceleration. * @property {double} vortexaroundspeed=0.0050 - Around Acceleration. */ /** * Attributes present in the node of type: 'ParticleWindFriction' * @name NodeTypes#Wind-Friction * @property {int} trigger=1 - Trigger. * @property {double} windfrictionx=0 - Friction/Wind X. * @property {double} windfrictiony=0 - Friction/Wind Y. * @property {double} windfrictionz=0 - Friction/Wind Z. * @property {double} windfrictionminspeed=0 - Min Speed. * @property {double} windfrictionmaxspeed=10 - Max Speed. */ /** * Attributes present in the node of type: 'ParticleRotationVelocity' * @name NodeTypes#Rotation-Velocity * @property {int} trigger=1 - Trigger. * @property {double} w0=0 - Minimum. * @property {double} w1=5 - Maximum. * @property {generic_enum} [axisstrategy="Constant Axis"] - Axis Type. * @property {double} v0x=0 - Axis0 X. * @property {double} v0y=0 - Axis0 Y. * @property {double} v0z=1 - Axis0 Z. * @property {double} v1x=0 - Axis1 X. * @property {double} v1y=1 - Axis1 Y. * @property {double} v1z=0 - Axis1 Z. */ /** * Attributes present in the node of type: 'ParticleRepulse' * @name NodeTypes#Repulse * @property {int} trigger=1 - Trigger. * @property {double} magnitude=1 - Magnitude. * @property {double} lookahead=1 - Look Ahead. * @property {double} epsilon=0.0010 - Epsilon. */ /** * Attributes present in the node of type: 'ParticleJavascript' * @name NodeTypes#Scripted-Action[Beta] * @property {int} trigger=1 - Trigger. * @property {file_editor} particle_action_script - . * @property {file_library} files - . */ /** * Attributes present in the node of type: 'ParticleVelocity' * @name NodeTypes#Velocity * @property {int} trigger=1 - Trigger. * @property {generic_enum} [velocitytype="Constant Speed"] - Velocity Type. * @property {double} v0x=1 - X. * @property {double} v0y=0 - Y. * @property {double} v0z=0 - Z. * @property {double} minspeed=0.5000 - Minimum. * @property {double} maxspeed=0.5000 - Maximum. * @property {double} theta0=0 - Minimum Angle (degrees). * @property {double} theta1=30 - Maximum Angle (degrees). * @property {bool} bilateral=false - Bilateral. */ /** * Attributes present in the node of type: 'ParticleSize' * @name NodeTypes#Size * @property {int} trigger=1 - Trigger. * @property {generic_enum} [sizestrategy="Constant Size"] - Size Type. * @property {double} particlesize=1 - Size. */ /** * Attributes present in the node of type: 'ParticlePlanarRegion' * @name NodeTypes#Planar-Region * @property {generic_enum} [shapetype="Rectangle"] - Shape Type. * @property {double} sizex=12 - Width. * @property {double} sizey=12 - Height. * @property {double} x1=0 - X. * @property {double} y1=0 - Y. * @property {double} x2=0 - X. * @property {double} y2=0 - Y. * @property {double} x3=1 - X. * @property {double} y3=1 - Y. * @property {double} minradius=0 - Minimum. * @property {double} maxradius=6 - Maximum. * @property {bool} mirrornegativeframes=false - Mirror Negative Frames. */ /** * Attributes present in the node of type: 'ParticleBkerComposite' * @name NodeTypes#Baker-Composite */ /** * Attributes present in the node of type: 'ParticleSprite' * @name NodeTypes#Sprite-Emitter * @property {int} trigger=1 - Trigger. * @property {double} ageatbirth=0 - Age at Birth. * @property {double} ageatbirthstd=0 - Age at Birth Standard Deviation. * @property {double} mass=1 - Particles Mass. * @property {generic_enum} [typechoosingstrategy="Sequentially Assign Type Number"] - Type Generation Strategy. * @property {int} particletype0=1 - Particle Type 0. * @property {int} particletype1=1 - Particle Type 1. * @property {double} particlesize=1 - Size over Age. * @property {bool} overridevelocity=false - Align Initial Velocity. * @property {generic_enum} [blend_mode="Normal"] - Blend Mode. * @property {double} blendintensity=100 - Blend Intensity. * @property {generic_enum} [colouringstrategy="Use Drawing Colour"] - Colouring Strategy. * @property {color} particlecolour=ffffffff - Colour. * @property {int} particlecolour.red=255 - Red. * @property {int} particlecolour.green=255 - Green. * @property {int} particlecolour.blue=255 - Blue. * @property {int} particlecolour.alpha=255 - Alpha. * @property {generic_enum} [particlecolour.preferred_ui="Separate"] - Preferred Editor. * @property {bool} alignwithdirection=true - Align with Direction. * @property {bool} userotation=false - Use Rotation of Particle. * @property {bool} directionalscale=false - Directional Scale. * @property {double} directionalscalefactor=1 - Directional Scale Exponent Factor. * @property {bool} keepvolume=true - Keep Volume. * @property {generic_enum} [blur="No Blur"] - Blur. * @property {double} blurintensity=1 - Blur Intensity. * @property {double} blurfallof=0.5000 - Falloff Rate. * @property {bool} flipwithdirectionx=false - Flip X Axis to Match Direction. * @property {bool} flipwithdirectiony=false - Flip Y Axis to Match Direction. * @property {generic_enum} [alignwithdirectionaxis="Positive X"] - Axis to Align. * @property {generic_enum} [renderingstrategy="Use Particle Type"] - Rendering Strategy. * @property {generic_enum} [cycletype="No Cycle"] - Cycling. * @property {int} cyclesize=5 - Number of Drawings in Cycle. * @property {int} numberofparticles=100 - Number of Particles. * @property {double} probabilityofgeneratingparticles=100 - Probability of Generating Any Particles. * @property {int} indexselector=0 - Selector. * @property {double} multisize=1 - Region Size for Baked Particle Input. * @property {bool} copyvelocity=false - Copy Particle Velocity for Baked Particle Input. * @property {double} mininitialangle=0 - Minimum Initial Rotation. * @property {double} maxinitialangle=0 - Maximum Initial Rotation. * @property {bool} copyage=false - Add Particle Age for Baked Particle Input. * @property {bool} applyprobabilityforeachparticle=true - Apply Probability for Each Particle. * @property {double} sourcetimespan=0 - Source Sampling Duration. * @property {double} sourcesamplesperframe=16 - Source Samples per Frame. * @property {int} seed=0 - Streak Seed. * @property {double} streaksize=0 - Streak Size. * @property {double} sourcetimeoffset=0 - Source Sampling Time Offset. * @property {bool} setmaxlifespan=false - Set Maximum Lifespan. * @property {double} maxlifespan=30 - Maximum Lifespan. * @property {double} maxlifespansigma=0 - Maximum Lifespan Sigma. */ /** * Attributes present in the node of type: 'ParticleRegionComposite' * @name NodeTypes#Particle-Region-Composite */ /** * Attributes present in the node of type: 'ParticleSystemComposite' * @name NodeTypes#Particle-System-Composite */ /** * Attributes present in the node of type: 'LensFlare' * @name NodeTypes#LensFlare * @property {generic_enum} [blend_mode="Normal"] - Blend Mode. * @property {generic_enum} [flash_blend_mode="Normal"] - SWF Blend Mode. * @property {bool} usergba=false - Blend Mode: Normal/Screen. * @property {bool} brightenable=true - On/Off. * @property {double} brightness=100 - Intensity. * @property {color} brightcolor=ffffffff - Color. * @property {int} brightcolor.red=255 - Red. * @property {int} brightcolor.green=255 - Green. * @property {int} brightcolor.blue=255 - Blue. * @property {int} brightcolor.alpha=255 - Alpha. * @property {generic_enum} [brightcolor.preferred_ui="Separate"] - Preferred Editor. * @property {double} positionx=6 - PositionX. * @property {double} positiony=6 - PositionY. * @property {double} positionz=0 - PositionZ. * @property {generic_enum} [flareconfig="Type 1"] - Flare Type. * @property {bool} enable1=true - Enable/Disable. * @property {double} size1=0.7500 - Size. * @property {double} position1=0 - Position. * @property {int} drawing1=1 - Drawing. * @property {double} blur1=0 - Blur Intensity. * @property {bool} enable2=true - Enable/Disable. * @property {double} size2=0.8000 - Size. * @property {double} position2=2 - Position. * @property {int} drawing2=2 - Drawing. * @property {double} blur2=0 - Blur Intensity. * @property {bool} enable3=true - Enable/Disable. * @property {double} size3=1.2000 - Size. * @property {double} position3=-0.2000 - Position. * @property {int} drawing3=3 - Drawing. * @property {double} blur3=0 - Blur Intensity. * @property {bool} enable4=true - Enable/Disable. * @property {double} size4=0.6500 - Size. * @property {double} position4=0.7500 - Position. * @property {int} drawing4=4 - Drawing. * @property {double} blur4=0 - Blur Intensity. * @property {bool} enable5=true - Enable/Disable. * @property {double} size5=1 - Size. * @property {double} position5=2 - Position. * @property {int} drawing5=5 - Drawing. * @property {double} blur5=0 - Blur Intensity. * @property {bool} enable6=true - Enable/Disable. * @property {double} size6=1 - Size. * @property {double} position6=0 - Position. * @property {int} drawing6=6 - Drawing. * @property {double} blur6=0 - Blur Intensity. * @property {bool} enable7=true - Enable/Disable. * @property {double} size7=0.8000 - Size. * @property {double} position7=2 - Position. * @property {int} drawing7=7 - Drawing. * @property {double} blur7=0 - Blur Intensity. * @property {bool} enable8=true - Enable/Disable. * @property {double} size8=0.5500 - Size. * @property {double} position8=-0.3000 - Position. * @property {int} drawing8=8 - Drawing. * @property {double} blur8=0 - Blur Intensity. * @property {bool} enable9=true - Enable/Disable. * @property {double} size9=0.6500 - Size. * @property {double} position9=1.2500 - Position. * @property {int} drawing9=9 - Drawing. * @property {double} blur9=0 - Blur Intensity. * @property {bool} enable10=true - Enable/Disable. * @property {double} size10=0.5500 - Size. * @property {double} position10=2.3000 - Position. * @property {int} drawing10=10 - Drawing. * @property {double} blur10=0 - Blur Intensity. */ /** * Attributes present in the node of type: 'CROP' * @name NodeTypes#Crop * @property {int} res_x=1920 - X Resolution. * @property {int} res_y=1080 - Y Resolution. * @property {double} offset_x=0 - X Offset. * @property {double} offset_y=0 - Y Offset. * @property {bool} draw_frame=false - Draw Frame. * @property {color} frame_color=ffffffff - Frame Color. * @property {int} frame_color.red=255 - Red. * @property {int} frame_color.green=255 - Green. * @property {int} frame_color.blue=255 - Blue. * @property {int} frame_color.alpha=255 - Alpha. * @property {generic_enum} [frame_color.preferred_ui="Separate"] - Preferred Editor. * @property {enable} [enabling="Always Enabled"] - Enabling. * @property {generic_enum} [enabling.filter="Always Enabled"] - Filter. * @property {string} enabling.filter_name - Filter name. * @property {int} enabling.filter_res_x=720 - X resolution. * @property {int} enabling.filter_res_y=540 - Y resolution. */ /** * Attributes present in the node of type: 'GLUE' * @name NodeTypes#Glue * @property {bool} invert_matte_port=true - Invert Matte. * @property {double} bias=0.5000 - Bias. * @property {double} tension=1 - Tension. * @property {generic_enum} [type="Curve"] - Type. * @property {bool} use_z=true - Use Z for Composition Order. * @property {bool} a_over_b=true - A Over B. * @property {bool} spread_a=false - Spread A. */ /** * Attributes present in the node of type: 'DeformTransformOut' * @name NodeTypes#Point-Kinematic-Output * @property {generic_enum} [sample="One-Point Sampling"] - Sampling Type. * @property {position_2d} pivot1 - Main Position Tracker. * @property {bool} pivot1.separate=Off - Separate. * @property {double} pivot1.x=0 - Pos x. * @property {double} pivot1.y=0 - Pos y. * @property {point_2d} pivot1.2dpoint - Point. * @property {position_2d} pivot2 - Tracker 2. * @property {bool} pivot2.separate=Off - Separate. * @property {double} pivot2.x=1 - Pos x. * @property {double} pivot2.y=0 - Pos y. * @property {point_2d} pivot2.2dpoint - Point. * @property {position_2d} pivot3 - Tracker 3. * @property {bool} pivot3.separate=Off - Separate. * @property {double} pivot3.x=0 - Pos x. * @property {double} pivot3.y=1 - Pos y. * @property {point_2d} pivot3.2dpoint - Point. * @property {double} volume=1 - Volume Modifier. */ /** * Attributes present in the node of type: 'AmbientOcclusion' * @name NodeTypes#Ambient-Occlusion * @property {double} darkness=200 - Darkness. * @property {double} zrange=1 - Shadow Max Depth Range. * @property {double} bias=0 - Shadow Bias. * @property {double} exponent=1 - Abruptness. * @property {double} gamma=2 - Shadow Gamma. * @property {double} blur=10 - Affected Range. * @property {double} postblur=0 - Post-Blur. * @property {double} postblurthreshold=0.1000 - Post-Blur Depth Threshold. * @property {bool} useimagecolor=false - Use Image Colour. * @property {color} color=ff000000 - Light Colour. * @property {int} color.red=0 - Red. * @property {int} color.green=0 - Green. * @property {int} color.blue=0 - Blue. * @property {int} color.alpha=255 - Alpha. * @property {generic_enum} [color.preferred_ui="Separate"] - Preferred Editor. * @property {double} imagecolorweight=50 - Image Colour Intensity. * @property {bool} invert_matte_port=false - Invert Matte. */ /** * Attributes present in the node of type: 'SHADOW' * @name NodeTypes#Shadow * @property {bool} truck_factor=true - Truck Factor. * @property {generic_enum} [blur_type="Radial"] - Blur Type. * @property {double} radius=2 - Radius. * @property {double} directional_angle=0 - Directional Angle. * @property {double} directional_falloff_rate=1 - Directional Falloff Rate. * @property {bool} use_matte_color=false - Use Source Colour. * @property {bool} invert_matte=false - Invert Matte. * @property {color} color=649c9c9c - Color. * @property {int} color.red=-100 - Red. * @property {int} color.green=-100 - Green. * @property {int} color.blue=-100 - Blue. * @property {int} color.alpha=100 - Alpha. * @property {generic_enum} [color.preferred_ui="Separate"] - Preferred Editor. * @property {bool} multiplicative=false - Multiplicative. * @property {double} colour_gain=1 - Intensity. */ ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_nodeLink.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library v0.01 // // // Developed by Mathieu Chaptel, Chris Fourney... // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the MIT license. // https://opensource.org/licenses/mit // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oNodeLink class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The base class for the oTimeline. * @constructor * @classdesc oTimeline Base Class * @param {oNode} outNode The source oNode of the link. * @param {int} outPort The outport of the outNode that is connecting the link. * @param {oNode} inNode The destination oNode of the link. * @param {int} inPort The inport of the inNode that is connecting the link. * * @property {bool} autoDisconnect Whether to auto-disconnect links if they already exist. Defaults to true. * @example * //Connect two pegs together. * var peg1 = $.scene.getNodeByPath( "Top/Peg1" ); * var peg2 = $.scene.getNodeByPath( "Top/Peg2" ); * * //Create a new $.oNodeLink -- We'll connect two pegs with this new nodeLink. * var link = new $.oNodeLink( peg1, //Out Node * 0, //Out Port * peg2, //In Node * 0 ); //In Port * * //The node link doesn't exist yet, but lets apply it. * link.apply(); * //This connection between peg1 and peg2 now exists. * * //We can also get the outlinks for the entire node and all of its outputs. * var outLinks = peg2.outLinks; * * //Lets connect peg3 to the chain with this existing outLink. This will use an existing link if its already there, or create a new one if none exists. * var peg3 = $.scene.getNodeByPath( "Top/Peg3" ); * outLinks[0].linkIn( peg3, 0 ); * * //Uh oh! We need to connect a peg between 1 and 2. * var peg4 = $.scene.getNodeByPath( "Top/Peg4" ); * * //The link we already created above can have a node inserted between it easily. * link.insertNode( peg4, 0, 0 ); //Peg to insert, in port, out port. * * //Oh no! Peg 5 is in a group. Well, it still works! * var peg5 = $.scene.getNodeByPath( "Top/Group/Peg5" ); * var newLink = peg1.addOutLink( peg5 ); */ $.oNodeLink = function( outNode, outPort, inNode, inPort, outlink ){ //Public properties. this.autoDisconnect = true; this.path = false; //Private properties. this._outNode = outNode; this._outPort = outPort; this._outLink = outlink; this._realOutNode = outNode; this._realOutPort = 0; this._inNode = inNode; this._inPort = inPort; this._stopUpdates = false; this._newInNode = null; this._newInPort = null; this._newOutNode = null; this._newOutPort = null; this._exists = false; this._validated = false; //Assume validation when providing all details. This is done to speed up subsequent lookups. if( outNode && inNode && typeof outPort === 'number' && typeof inPort === 'number' && typeof outlink === 'number' ){ //Skip validation in the event that we've beengiven all details -- this is to just speed up list generation. return; } this.validate(); } /** * Whether the nodeLink exists in the provided state. * @name $.oNodeLink#exists * @type {bool} * @example * //Connect two pegs together. * var peg1 = $.scene.getNodeByPath( "Top/Peg1" ); * var peg2 = $.scene.getNodeByPath( "Top/NodeDoesntExist" ); * var link = new $.oNodeLink( peg1, //Out Node * 0, //Out Port * peg2, //In Node * 0 ); //In Port * link.exists == false; //FALSE, This link doesnt exist in this context, because the node doesnt exist. */ Object.defineProperty($.oNodeLink.prototype, 'exists', { get : function(){ if( !this._validated ){ this.validate(); } return this._exists; } }); /** * The outnode of this $.oNodeLink. The outNode that is outputting the connection for this link on its outPort and outLink. * @name $.oNodeLink#outNode * @type {$.oNode} */ Object.defineProperty($.oNodeLink.prototype, 'outNode', { get : function(){ return this._outNode; }, set : function( val ){ this._validated = false; this._newOutNode = val; if( this.stopUpdates ){ return; } this.apply(); // do we really want to apply every time we set? } }); /** * The inNode of this $.oNodeLink. The inNode that is accepting this link on its inport. * @name $.oNodeLink#inNode * @type {$.oNode} */ Object.defineProperty($.oNodeLink.prototype, 'inNode', { get : function(){ return this._inNode; }, set : function( val ){ //PATH FIND UP TO THE INNODE. this._validated = false; this._newInNode = val; if( this.stopUpdates ){ return; } this.apply(); // do we really want to apply every time we set? } }); /** * The outport of this $.oNodeLink. The port that the outNode connected to for this link. * @name $.oNodeLink#outPort * @type {int} */ Object.defineProperty($.oNodeLink.prototype, 'outPort', { get : function(){ return this._outPort; }, set : function( val ){ this._validated = false; this._newOutPort = val; if( this.stopUpdates ){ return; } this.apply(); // do we really want to apply every time we set? } }); /** * The outLink of this $.oNodeLink. The link index that the outNode connected to for this link. * @name $.oNodeLink#outLink * @type {int} */ Object.defineProperty($.oNodeLink.prototype, 'outLink', { get : function(){ return this._outLink; } }); /** * The inPort of this $.oNodeLink. * @name $.oNodeLink#inPort * @type {oNode[]} */ Object.defineProperty($.oNodeLink.prototype, 'inPort', { get : function(){ return this._inPort; }, set : function( val ){ this._validated = false; this._newInPort = val; if( this.stopUpdates ){ return; } this.apply(); // do we really want to apply every time we set? } }); /** * When enabled, changes to the link will no longer update -- the changes will then apply when no longer stopped. * @name $.oNodeLink#stopUpdates * @private * @type {bool} */ Object.defineProperty($.oNodeLink.prototype, 'stopUpdates', { get : function(){ return this._stopUpdates; }, set : function( val ){ this._stopUpdates = val; if( !val ){ this.apply(); } } }); /** * Dereferences up a node's chain, in order to find the exact node its actually attached to. * @private * @param {oNode} onode The node to dereference the groups for. * @param {int} port The port to dereference. * @param {object[]} path The array path to pass along recursively.
[ { "node": src_node, "port":srcNodeInfo.port, "link":srcNodeInfo.link } ] * @private * @return {object} Object in form { "node":oNode, "port":int, "link": int } */ $.oNodeLink.prototype.findInputPath = function( onode, port, path ) { var srcNodeInfo = node.srcNodeInfo( onode.path, port ); if( !srcNodeInfo ){ return path; } var src_node = this.$.scene.getNodeByPath( srcNodeInfo.node ); if( !src_node ){ return path; } if( src_node.type == "MULTIPORT_IN" ){ //Continue to dereference until we find something other than a group/multiport in. var ret = { "node": src_node, "port":srcNodeInfo.port, "link":srcNodeInfo.link }; path.push( ret ); var src_node = src_node.group; var ret = { "node": src_node, "port":srcNodeInfo.port, "link":srcNodeInfo.link }; path.push( ret ); }else if( src_node.type == "GROUP" ){ //Continue to dereference until we find something other than a group/multiport out. var ret = { "node": src_node, "port":srcNodeInfo.port, "link":srcNodeInfo.link }; path.push( ret ); var src_node = src_node.multiportOut; var ret = { "node": src_node, "port":srcNodeInfo.port, "link":srcNodeInfo.link }; path.push( ret ); }else{ var ret = { "node": src_node, "port":srcNodeInfo.port, "link":srcNodeInfo.link }; path.push( ret ); return path; } return this.findInputPath( src_node, srcNodeInfo.port, path ); } /** * Changes both the in-node and in-port at once. * @param {oNode} onode The node to link on the input. * @param {int} port The port to link on the input. * @example * //Connect two pegs together. * var peg1 = $.scene.getNodeByPath( "Top/Peg1" ); * var peg2 = $.scene.getNodeByPath( "Top/Peg2" ); * * var outLinks = peg1.outLinks; * outLinks[0].linkIn( peg2, 0 ); //Links the input of peg2, port 0 -- to this link, connecting its outNode [peg1] and outPort [0] and outLink [arbitrary]. */ $.oNodeLink.prototype.linkIn = function( onode, port ) { this._validated = false; var stopUpdates_val = this.stopUpdates; this.stopUpdates = true; this.inNode = onode; this.inPort = port; this.stopUpdates = stopUpdates_val; } /** * Changes both the out-node and out-port at once. * @param {oNode} onode The node to link on the output. * @param {int} port The port to link on the output. * @example * //Connect two pegs together. * var peg1 = $.scene.getNodeByPath( "Top/Peg1" ); * var peg2 = $.scene.getNodeByPath( "Top/Peg2" ); * * var inLinks = peg1.inLinks; * inLinks[0].linkOut( peg2, 0 ); //Links the output of peg2, port 0 -- to this link, connecting its inNode [peg1] and inPort [0]. */ $.oNodeLink.prototype.linkOut = function( onode, port ) { this._validated = false; var stopUpdates_val = this.stopUpdates; this.stopUpdates = true; this.outNode = onode; this.outPort = port; this.stopUpdates = stopUpdates_val; } /** * Insert a node in the middle of the link chain. * @param {oNode} nodeToInsert The node to link on the output. * @param {int} inPort The port to link on the output. * @param {int} outPort The port to link on the output. * @example * //Connect two pegs together. * var peg1 = $.scene.getNodeByPath( "Top/Peg1" ); * var peg2 = $.scene.getNodeByPath( "Top/Peg2" ); * * //Create a new $.oNodeLink -- We'll connect two pegs with this new nodeLink. * var link = new $.oNodeLink( peg1, //Out Node * 0, //Out Port * peg2, //In Node * 0 ); //In Port * * //The link we already created above can have a node inserted between it easily. * var peg4 = $.scene.getNodeByPath( "Top/Peg4" ); * link.insertNode( peg4, 0, 0 ); //Peg to insert, in port, out port. */ $.oNodeLink.prototype.insertNode = function( nodeToInsert, inPort, outPort ) { this.stopUpdates = true; var inNode = this.inNode; var inPort = this.inPort; this.inNode = nodeToInsert; this.inport = inPort; this.stopUpdates = false; var new_link = new this.$.oNodeLink( nodeToInsert, outPort, inNode, inPort, 0 ); new_link.apply( true ); } /** * Apply the links as needed after unfreezing the oNodeLink * @param {bool} force Forcefully reconnect/disconnect the note given the current settings of this nodelink. * @example * //Connect two pegs together. * var peg1 = $.scene.getNodeByPath( "Top/Peg1" ); * var peg2 = $.scene.getNodeByPath( "Top/Peg2" ); * * //Create a new $.oNodeLink -- We'll connect two pegs with this new nodeLink. * var link = new $.oNodeLink( peg1, //Out Node * 0, //Out Port * peg2, //In Node * 0 ); //In Port * * //The node link doesn't exist yet, but lets apply it. * link.apply(); */ $.oNodeLink.prototype.apply = function( force ) { this._stopUpdates = false; this._validated = false; // ? Shouldn't we use this to bypass application if it's already been validated? var disconnect_in = false; var disconnect_out = false; var inports_removed = {}; var outports_removed = {}; if( force || !this._exists ){ //Apply this. this._newInNode = this._newInNode ? this._newInNode : this._inNode; this._newOutNode = this._newOutNode ? this._newOutNode : this._outNode; this._newOutPort = ( this._newOutPort === null ) ? this._outPort : this._newOutPort; this._newInPort = ( this._newInPort === null ) ? this._inPort : this._newInPort; var force = true; disconnect_in = true; disconnect_out = true; }else{ //Force a reconnect -- track content as needed. //Check and validate in ports. var target_port = this._inPort; if( this._newInPort !== null ){ if( this._newInPort != this._inPort ){ target_port = this._newInPort; disconnect_in = true; } } var old_inPortCount = false; //Used to track if the inport count has changed upon its removal. if( this._newInNode !== null ){ if( this._newInNode ){ if( !this._inNode || ( this._inNode.path != this._newInNode.path ) ){ disconnect_in = true; } }else if( this._inNode ){ disconnect_in = true; } } //Check and validate out ports. if( this._newOutPort !== null ){ if( ( this._newOutPort !== this._outPort ) ){ disconnect_out = true; } } if( this._newOutNode !== null ){ if( this._newOutNode ){ if( !this._outNode || ( this._outNode.path != this._newOutNode.path ) ){ disconnect_out = true; } }else if( this._outNode ){ disconnect_out = true; } } } if( !disconnect_in && !disconnect_out ){ //Nothing happened. // System.println( "NOTHING TO DO" ); return; } if( this._newInNode ){ // if( this._newInNode.inNodes.length > target_port ){ if( this._newInNode.inPorts > target_port ){ // if( this._newInNode.inNodes[ target_port ] ){ if( node.isLinked(this._newInNode.path, target_port) ){ //-- Theres already a connection here-- lets remove it. if( this.autoDisconnect ){ node.unlink (this._newInNode.path, target_port) // this._newInNode.unlinkInPort( target_port ); inports_removed[ this._newInNode.path ] = target_port; }else{ throw "Unable to link "+this._outNode+" to "+this._newInNode+", port "+target_port+" is already occupied."; } } } } //We'll work with the new values -- pretend any new connection is a new one. this._newInNode = this._newInNode ? this._newInNode : this._inNode; this._newOutNode = this._newOutNode ? this._newOutNode : this._outNode; this._newOutPort = ( this._newOutPort === null ) ? this._outPort : this._newOutPort; this._newInPort = ( this._newInPort === null ) ? this._inPort : this._newInPort; if( !this._newInNode || !this._newOutNode ){ //Nothing to attach. this._inNode = this._newInNode; this._inPort = this._newInPort; this._outNode = this._newOutNode; this._outPort = this._newOutPort; return; } if( !this._newInNode.exists || !this._newOutNode.exists ){ this._inNode = this._newInNode; this._inPort = this._newInPort; this._outNode = this._newOutNode; this._outPort = this._newOutPort; return; } //Kill and rebuild the current connection - but first, calculate existing port indices so they can be reconnected contextually. // var newInPortCount = this._newInNode ? this._newInNode.inNodes.length : 0; var newInPortCount = this._newInNode ? this._newInNode.inPorts : 0; // var newOutPortCount = this._newOutNode ? this._newOutNode.outNodes.length : 0; var newOutPortCount = this._newOutNode ? this._newOutNode.outPorts : 0; //Unlink it anyway! Dont worry, we'll reattach that after. if( this._inNode ){ // this._inNode.unlinkInPort( this._inPort ); node.unlink (this._inNode.path, this._inPort) if( this._outNode ) outports_removed[ this._outNode.path ] = this._outPort; inports_removed[ this._inNode.path ] = this._inPort; } //Cant connect without a valid port. if( ( this._newOutPort === null ) || ( this._newOutPort === false ) ){ this._inNode = this._newInNode; this._inPort = this._newInPort; this._outNode = this._newOutNode; this._outPort = this._newOutPort; return; } if( ( this._newInPort === null ) || ( this._newInPort === false ) ){ this._inNode = this._newInNode; this._inPort = this._newInPort; this._outNode = this._newOutNode; this._outPort = this._newOutPort; return; } //Check to see if any of the port values have changed. var newInPortCount_result = this._newInNode ? this._newInNode.inNodes.length : 0; var newOutPortCount_result = this._newOutNode ? this._newOutNode.outNodes.length : 0; if( newOutPortCount_result != newOutPortCount ){ //Outport might have changed. React appropriately. if( this._newOutNode.path in outports_removed ){ if( this._newOutPort > outports_removed[ this._newOutNode.path ] ){ this._newOutPort-=1; } } } if( newInPortCount_result != newInPortCount ){ //Outport might have changed. React appropriately. if( this._newInNode.path in inports_removed ){ if( this._newInPort > inports_removed[ this._newInNode.path ] ){ this._newInPort-=1; } } } var new_inGroup = this._newInNode.group; var new_outGroup = this._newOutNode.group; if( new_inGroup.path == new_outGroup.path ){ //Simple direct connection within the same group. node.link( _newOutNode.path, this._newInPort, this._newInNode.path, this._newOutPort); //this._newOutNode.linkOutNode( this._newInNode, this._newInPort, this._newOutPort ); MCNote: use the API so we can replace stuff into it later }else{ //Look for an access route. var common_path = []; var split_in = new_inGroup.path.split( "/" ); var split_out = new_outGroup.path.split( "/" ); //Find the common top path. for( var n=0;n= 0; n-- ){ var t_path = inward_path[n]; if( !t_path.exists ){ // t_path.from.linkOutNode( t_path.to, t_path.fromport, t_path.toport, t_path.createPort ); node.link(t_path.from.path, t_path.fromport, t_path.to.path, t_path.toport, t_path.createPort, t_path.createPort); // System.println( "RESULT IN: " + t_path.from + " : " + t_path.fromport + " -- " + t_path.to + " : " + t_path.toport + " " + t_path.createPort ); } } } this._inNode = this._newInNode; this._inPort = this._newInPort; this._outNode = this._newOutNode; this._outPort = this._newOutPort; this._newInNode = null; this._newInPort = null; this._newOutNode = null; this._newOutPort = null; if( !this.validate() ){ throw ReferenceError( "Failed to connect the targets appropriately." ); } } /** * findInwardPath. Used internally when applying link. * finds the sequence of groups to go into to find the node to connect from a higher level * @private * @return {path} an array of path node objects : { "end" : bool, * "exists" : bool, * "from" : oNode, * "fromport" : int, * "to" : oNode, * "toport" : int, * "createPort" : bool * } */ $.oNodeLink.prototype.findInwardPath = function( createPort ){ var from_node = this._outNode; var from_port = this._outPort; var targ_node = this._inNode; var targ_port = this._inPort; var path = []; var length_parent = from_node.group.path.split("/").length; var targ_grp = targ_path_split.slice( 0, length_parent+1 ).join("/"); if( targ_grp == targ_path ){ //Should it create the port? path.push( { "end": true, "exists":false, "from":from_node, "fromport":from_port, "to":targ_node, "toport":targ_port, "createPort":createPort } ); return path; } //Find a common link from this target to the next. var grp = this.$.scene.getNodeByPath( targ_grp ); var mport = grp.multiportIn; // var followPort = mport.outNodes.length; var followPort = mport.outPorts; //Find if the outnodes of from, at the given outNode, connects to the multiportOut already. try{ var found_existing = false; var createPortForward = true; // if( from_node.outNodes.length>from_port ){ if( from_node.outPorts > from_port){ // var ops = from_node.outNodes[from_port]; var ops = from_node.getOutLinksNumber(from_port); // for( var n=0; nport ){ if( from_node.outPorts > port ){ for( var n = 0; n < from_node.outPorts; n++ ){ // if( from_node.outNodes[port][n].path == mport.path ){ if( node.dstNode(from_node.path, port, n) == mport.path ){ //Dont add it as a new connection, add it as an existing one. var info = node.dstNodeInfo( from_node.path, port, n ); if( info ){ found_existing = true; followPort = info.port; break; } } } } path.push( { "end" : false, "exists":found_existing, "from":from_node, "fromport":port, "to":mport, "toport":followPort } ); // path = find_outward( grp, followPort, targ, path ); var checkLink = new this.$.oNodeLink(grp, followPort, this._inNode) path = path.concat( checkLink.findOutwardPath()); }catch(err){ this.$.debug( "ERR: " + err.message + " " + err.lineNumber + " : " + err.fileName , this.$.DEBUG_LEVEL.ERROR); } }else{ path.push( { "end": true, "exists":true, "from":from_node, "fromport":port, "to":false, "toport":false } ); } return path; } /** * Validates the details of a given connection. Used internally when details change. * @private * @return {bool} Whether the connection is a valid connection that exists currently in the node system. */ $.oNodeLink.prototype.validate = function ( ) { //Initialize the connection and get the information. //First check to see if the path is valid. this._exists = false; this._validated = true; var inportProvided = !(!this._inPort && this._inPort!== 0); var outportProvided = !(!this._outPort && this._outPort!== 0); if( !inportProvided && !outportProvided ){ //inport is the safest to determine contextually. //If either has 1 input. if( this._inNode && this._inNode.inNodes.length == 1 ){ this._inPort = 0; inportProvided = true; } } if( !this._outNode && !this._inNode ){ //Unable to comply, need at least the nodes. this._exists = false; return false; }else if( !this._outNode ){ //No outnode. Just look for one above it given the inport. //Lets derive up the chain. if( inportProvided ){ this.validateUpwards( this._inPort ); if( !this.path || this.path.length==0 ){ return false; } this._outNode = this.path[ this.path.length-1 ].node; this._outPort = this.path[ this.path.length-1 ].port; this._outLink = this.path[ this.path.length-1 ].link; this._realOutNode = this.path[ 0 ].node; this._realOutPort = this.path[ 0 ].port; this._realOutLink = this.path[ 0 ].link; this._exists = true; return true; } }else if( !this._inNode ){ //There can be multiple links. This is very difficult and only possible if theres only a singular path, we'll have to derive them all downwards. //This is just hopeful thinking that there is only one valid path. this._outLink = this._outLink ? this._outLink : 0; var huntInNode = function( currentNode, port, link ){ try{ // var on = currentNode.outNodes[port]; var numOutLinks = currentNode.getOutLinksNumber(port); // if( on.length != 1 ){ if( numOutLinks != 1 ){ return false; } var dstNodeInfo = node.dstNodeInfo( currentNode.path, port, link ); if( !dstNodeInfo ){ return false; } var outNode = this.$.scene.getNodeByPath(node.dstNode( currentNode.path, port, 0 )) // if( on[0].type == "MULTIPORT_OUT" ){ if( outNode.type == "MULTIPORT_OUT" ){ return huntInNode( currentNode.grp, dstNodeInfo.port ); // }else if( on[0].type == "GROUP" ){ }else if( outNode.type == "GROUP" ){ return huntInNode( outNode.multiportIn, dstNodeInfo.port, dstNodeInfo.link ); }else{ // var ret = { "node": on[0], "port":dstNodeInfo.port }; var ret = { "node": outNode, "port":dstNodeInfo.port }; return ret; } }catch(err){ this.$.debug( err , this.$.DEBUG_LEVEL.ERROR); return false; } } //Find the in node recursively. var res = huntInNode( this._outNode, this._outPort, this._outLink ); if( !res ){ this._exists = false; return false; } if( inportProvided ){ if( res.port != this._inPort ){ this._exists = false; return false; } } this._inNode = res.node; this._inPort = res.port; inportProvided = true; } if( !this._outNode || !this._inNode ){ this._exists = false; return false; } if( !inportProvided && !outportProvided ){ //Still no ports provided. //Just simply assume the 0 port on the input. this._inPort = 0; inportProvided = true; } if( !inportProvided ){ //Derive upwards for each input, if its valid, keep it. var inNodes = this._inNode.inNodes; for( var n=0;n * A $.oLink object is always describing just one connection between two nodes in the same group. For distant nodes in separate groups, use $.oLinkPath. * @constructor * @param {$.oNode} outNode The node from which the link is coming out. * @param {$.oNode} inNode The node into which the link is connected. * @param {oScene} [outPortNum] The out-port of the outNode used by this link. * @param {oScene} [inPortNum] The in-port of the inNode used by this link. * @param {oScene} [outLinkNum] The link index coming out of the out-port. * @param {bool} [isValid=false] Bypass checks and assume this link is connected. * @example * // find out if two nodes are linked, and through which ports * var doc = $.scn; * var myNode = doc.root.$node("Drawing"); * var sceneComp = doc.root.$node("Composite"); * * var myLink = new $.oLink(myNode, sceneComp); * * log(myLink.linked+" "+myLink.inPort+" "+myLink.outPort+" "+myLink.outLink); // trace the details of the connection. * * // activate/deactivate connections simply: * myLink.connect(); * log (myLink.linked) // true * * myLink.disconnect(); * log (myLink.linked) // false * * // it is also possible to set the linked status directly on the linked property: * myLink.linked = true; * * // however, changing the ports of the link object don't physically change the connection * * myLink.inPort = 2 // the connection didn't change, the link object simply represents now a different connection possible. * log (myLink.linked) // false * * myLink.connect() // this will connect the nodes once more, with different ports. A new connection is created. */ $.oLink = function(outNode, inNode, outPortNum, inPortNum, outLinkNum, isValid){ this._outNode = outNode; this._inNode = inNode; this._outPort = (typeof outPortNum !== 'undefined')? outPortNum:undefined; this._outLink = (typeof outLinkNum !== 'undefined')? outLinkNum:undefined; this._inPort = (typeof inPortNum !== 'undefined')? inPortNum:undefined; this._linked = (typeof isValid !== 'undefined')? isValid:false; } /** * The node that the link is coming out of. Changing this value doesn't reconnect the link, just changes the connection described by the link object. * @name $.oLink#outNode * @type {$.oNode} */Object.defineProperty($.oLink.prototype, 'outNode', { get : function(){ return this._outNode; }, set : function(newOutNode){ this._outNode = newOutNode; this._linked = false; } }); /** * The node that the link is connected into. Changing this value doesn't reconnect the link, just changes the connection described by the link object. * @name $.oLink#inNode * @type {$.oNode} */ Object.defineProperty($.oLink.prototype, 'inNode', { get : function(){ return this._inNode; }, set: function(newInNode){ this._inNode = newInNode; this._linked = false; } }); /** * The in-port used by the link. Changing this value doesn't reconnect the link, just changes the connection described by the link object. *
In the event this value wasn't known by the link object but the link is actually connected, the correct value will be found. * @name $.oLink#inPort * @type {int} */ Object.defineProperty($.oLink.prototype, 'inPort', { get : function(){ if (this.linked) return this._inPort; // cached value was correct var _found = this.findPorts(); if (_found) return this._inPort; // nodes are not connected return null; }, set : function(newInPort){ this._inPort = newInPort; this._linked = false; } }); /** * The out-port used by the link. Changing this value doesn't reconnect the link, just changes the connection described by the link object. *
In the event this value wasn't known by the link object but the link is actually connected, the correct value will be found. * @name $.oLink#outPort * @type {int} */ Object.defineProperty($.oLink.prototype, 'outPort', { get : function(){ if (this.linked) return this._outPort; // cached value was correct var _found = this.findPorts(); if (_found) return this._outPort; // nodes are not connected return null; }, set : function(newOutPort){ this._outPort = newOutPort; this._linked = false; } }); /** * The index of the link coming out of the out-port. *
In the event this value wasn't known by the link object but the link is actually connected, the correct value will be found. * @name $.oLink#outLink * @readonly * @type {int} */ Object.defineProperty($.oLink.prototype, 'outLink', { get : function(){ if (this.linked) return this._outLink; var _found = this.findPorts(); if (_found) return this._outLink; // nodes are not connected return null; } }); /** * Get and set the linked status of a link * @name $.oLink#linked * @type {bool} */ Object.defineProperty($.oLink.prototype, 'linked', { get : function(){ if (this._linked) return this._linked; // first check if node object refers to two valid nodes if (this.outNode === undefined || this.inNode === undefined){ this.$.debug("checking 'linked' for invalid link: "+this.outNode+">"+this.inNode, this.$.DEBUG_LEVEL.ERROR) return false; } // if ports/links unknown, get a valid link we can check if (this._outPort === undefined || this._inPort === undefined || this._outLink === undefined){ if (!this.findPorts()){ return false; } } // if ports/links are specified, we check the if the nodes connected to each port correspond with the link values var _linkedOutNode = this.outNode.getLinkedOutNode(this._outPort, this._outLink); var _linkedInNode = this.inNode.getLinkedInNode(this._inPort); if (_linkedOutNode == null || _linkedInNode == null) return false; var validOutLink = (_linkedOutNode.path == this.inNode.path); var validInLink = (_linkedInNode.path == this.outNode.path); if (validOutLink && validInLink){ this._linked = true; return true; } return false; }, set : function(newLinkedStatus){ if (newLinkedStatus){ this.connect(); }else{ this.disconnect(); } } }); /** * Compares the start and end nodes groups to see if the path traverses several groups or not. * @name $.oLink#isMultiLevel * @readonly * @type {bool} */ Object.defineProperty($.oLink.prototype, 'isMultiLevel', { get : function(){ //this.$.debug("isMultiLevel? "+this.outNode +" "+this.inNode, this.$.DEBUG_LEVEL.LOG); if (!this.outNode || !this.outNode.group || !this.inNode || !this.inNode.group) return false; return this.outNode.group.path != this.inNode.group.path; } }); /** * Compares the start and end nodes groups to see if the path traverses several groups or not. * @name $.oLink#isMultiLevel * @readonly * @type {bool} */ Object.defineProperty($.oLink.prototype, 'waypoints', { get : function(){ if (!this.linked) return [] var _waypoints = waypoint.getAllWaypointsAbove (this.inNode, this.inPort) return _waypoints; } }); /** * Get a link that can be connected by working out ports that can be used. If a link already exists, it will be returned. * @return {$.oLink} A separate $.oLink object that can be connected. Null if none could be constructed. */ $.oLink.prototype.getValidLink = function(createOutPorts, createInPorts){ if (typeof createOutPorts === 'undefined') var createOutPorts = false; if (typeof createInPorts === 'undefined') var createInPorts = true; var start = this.outNode; var end = this.inNode; var outPort = this._outPort; var inPort = this._inPort; if (!start || !end) { $.debug("A valid link can't be found: node missing in link "+this.toString(), this.$.DEBUG_LEVEL.ERROR) return null; } if (this.isMultiLevel) return null; var _link = new this.$.oLink(start, end, outPort, inPort); _link.findPorts(); // if can't be found, choose a new non existent link if (!_link.linked){ if (typeof outPort === 'undefined' || outPort === undefined){ _link._outPort = start.getFreeOutPort(createOutPorts); // if (_link._outPort == null) _link._outPort = 0; // just use a current port and add a link } _link._outLink = start.getOutLinksNumber(_link._outPort); if (typeof inPort === 'undefined' || inPort === undefined){ _link._inPort = end.getFreeInPort(createInPorts); if (_link._inPort == null){ this.$.debug("can't create link because the node "+end+" can't create a free inPort", this.$.DEBUG_LEVEL.ERROR); return null; // can't create a valid link. } }else{ _link._inPort = inPort; if (end.getInLinksNumber(inPort)!= 0 && !end.canCreateInPorts){ this.$.debug("can't create link because the requested port "+_link._inPort+" of node "+end+" isn't free", this.$.DEBUG_LEVEL.ERROR); return null; } } } return _link; } /** * Attempts to connect a link. Will guess the ports if not provided. * @return {bool} */ $.oLink.prototype.connect = function(){ if (this._linked){ return true; } // do we want to just always get a valid link here or do we want it to fail if not set properly? if (!this.findPorts()){ var _validLink = this.getValidLink(this.outNode.canCreateInPorts, this.inNode.canCreateInPorts); if (!_validLink) return false; this.inPort = _validLink.inPort; this.outPort = _validLink.outPort; this.outLink = _validLink.outLink; }; if (this.inNode.getInLinksNumber(this._inPort) > 0 && !this.inNode.canCreateInPorts) return false; // can't connect if the in-port is already connected var createOutPorts = (this.outNode.outPorts <= this._outPort && this.outNode.canCreateOutPorts); var createInPorts = ((this.inNode.inPorts <= this._inPort || this.inNode.getInLinksNumber(this._inPort)>0) && this.inNode.canCreateInPorts); if (this._outNode.type == "GROUP" && createOutPorts) this._outNode.addOutPort(this._outPort); if (this._inNode.type == "GROUP" && createInPorts) this._inNode.addInPort(this._inPort); try{ this.$.debug("linking nodes "+this._outNode+" to "+this._inNode+" through outPort: "+this._outPort+", inPort: "+this._inPort+" and create ports: "+createOutPorts+" "+createInPorts, this.$.DEBUG_LEVEL.LOG); var success = node.link(this._outNode, this._outPort, this._inNode, this._inPort, createOutPorts, createInPorts); this._linked = success; if (!success) throw new Error(); return success; }catch(err){ this.$.debug("linking nodes "+this._outNode+" to "+this._inNode+" through outPort: "+this._outPort+", inPort: "+this._inPort+", create outports: "+createOutPorts+", create inports:"+createInPorts, this.$.DEBUG_LEVEL.ERROR); this.$.debug("Error linking nodes: " +err, this.$.DEBUG_LEVEL.ERROR); return false; } } /** * Disconnects a link. * @return {bool} Whether disconnecting was successful; */ $.oLink.prototype.disconnect = function(){ if (!this._linked) return true; if (!this.findPorts()) return false; node.unlink(this._inNode, this._inPort); this._linked = false; return true; } /** * Finds ports missing or undefined ports in the link object if it is linked, and update the object accordingly.
* This will not update ports if the link isn't connected. Use getValidLink to get a connectable unconnected link. * @private * @return {bool} Whether finding ports was successful. */ $.oLink.prototype.findPorts = function(){ // Unless some ports are specified, this will always find the first link and stop there. Provide more info in case of multiple links if (!this.outNode|| !this.inNode) { this.$.debug("calling 'findPorts' for invalid link: "+this.outNode+" > "+this.inNode, this.$.DEBUG_LEVEL.ERROR); return false; } if (this._inPort !== undefined && this._outPort!== undefined && this._outLink!== undefined) return true; // ports already are valid, even if link might not be linked var _inNodePath = this.inNode.path; var _outNodePath = this.outNode.path; // Try to find outPort based on inPort // most likely to be missing is outLink, and this is the quickest way to find it. if (this._inPort != undefined){ var _nodeInfo = node.srcNodeInfo(_inNodePath, this._inPort); if (_nodeInfo && _nodeInfo.node == _outNodePath && (this._outPort == undefined || this._outPort == _nodeInfo.port)){ this._outPort = _nodeInfo.port; this._outLink = _nodeInfo.link; this._linked = true; // this.$.log("found ports through provided inPort: "+ this._inPort) return true; } } // Try to find ports based on outLink/outPort if (this._outPort !== undefined && this._outLink !== undefined){ var _nodeInfo = node.dstNodeInfo(_outNodePath, this._outPort, this._outLink); if (_nodeInfo && _nodeInfo.node == _inNodePath){ this._inPort = _nodeInfo.port; this._linked = true; // this.$.log("found ports through provided outPort/outLink: "+this._outPort+" "+this._outLink) return true; } } // Find the ports if we are missing all of them, looking at in-ports to avoid messing with outLinks var _inPorts = this.inNode.inPorts; for (var i = 0; i<_inPorts; i++){ var _nodeInfo = node.srcNodeInfo(_inNodePath, i); if (_nodeInfo && _nodeInfo.node == _outNodePath){ if (this._outPort !== undefined && this._outPort !== _nodeInfo.port) continue; this._inPort = i; this._outPort = _nodeInfo.port; this._outLink = _nodeInfo.link; // this.$.log("found ports through iterations") this._linked = true; return true; } } // The nodes are not linked this._linked = false; return false; } /** * Connects the given node in the middle of the link. The link must be connected. * @param {$.oNode} oNode The node to insert in the link * @param {int} [nodeInPort = 0] The inPort to use on the inserted node * @param {int} [nodeOutPort = 0] The outPort to use on the inserted node * @param {int} [nodeOutLink = 0] The outLink to use on the inserted node * @return {$.oLink[]} an Array of two oLink objects that describe the new connections. * @example * include("openHarmony.js") * doc = $.scn * var node1 = doc.$node("Top/Drawing") * var node2 = doc.$node("Top/Composite") * var node3 = doc.$node("Top/Transparency") * * var link = new $.oLink(node1, node2) * link.insertNode(node3) // insert the Transparency node between the Drawing and Composite */ $.oLink.prototype.insertNode = function(oNode, nodeInPort, nodeOutPort, nodeOutLink){ if (!this.linked) return // can't insert a node if the link isn't connected this.$.beginUndo("oh_insertNode") var _inNode = this.inNode var _outNode = this.outNode var _inPort = this.inPort var _outPort = this.outPort var _outLink = this.outLink var _topLink = new this.$.oLink(_outNode, oNode, _outPort, nodeInPort, _outLink) var _lowerLink = new this.$.oLink(oNode, _inNode, nodeOutPort, _inPort, nodeOutLink) this.linked = false; var success = (_topLink.connect() && _lowerLink.connect()); this.$.endUndo() if (success) { return [_topLink, _lowerLink] } else{ // we restore the links to default state and return false this.$.debug("failed to insert node "+oNode+" into link "+this) this.$.undo() return false } } /** * Converts the node link to a string. * @private */ $.oLink.prototype.toString = function( ) { return ('link: {"'+this._outNode+'" ['+this._outPort+', '+this._outLink+'] -> "'+this._inNode+'" ['+this._inPort+']} linked:'+this._linked); // return '{outNode:'+this.outNode+' inNode:'+this.inNode+' }'; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oLinkPath class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * Constructor for $.oLinkPath class * @classdesc * The $.oLinkPath class allows to figure out paths as a series of links between distant nodes.
* It can either look for existing paths and check that two distant nodes are connected or create new ones that can then be connected. * @constructor * @param {$.oNode} startNode The first node from which the link is coming out. * @param {$.oNode} endNode The last node into which the link is connected. * @param {oScene} [outPortNum] The out-port of the startNode. * @param {oScene} [inPortNum] The in-port of the endNode. * @param {oScene} [outLinkNum] The link index coming out of the out-port of the startNode. * @see NodeType */ $.oLinkPath = function( startNode, endNode, outPort, inPort, outLink){ this.startNode = startNode; this.endNode = endNode; this.outPort = (typeof outPort !== 'undefined')? outPort:undefined; this.inPort = (typeof inPort !== 'undefined')? inPort:undefined; this.outLink = (typeof outLink !== 'undefined')? outLink:undefined; } /** * Compares the start and end nodes groups to see if the path traverses several groups or not. * @name $.oLinkPath#isMultiLevel * @readonly * @type {bool} */ Object.defineProperty($.oLinkPath.prototype, 'isMultiLevel', { get : function(){ //this.$.log(this.startNode+" "+this.endNode) return this.startNode.group.path != this.endNode.group.path; } }); /** * Identifies the group in which the two nodes will connect if they are at different levels of depth. * @name $.oLinkPath#lowestCommonGroup * @readonly * @type {$.oGroupNode} */ Object.defineProperty($.oLinkPath.prototype, 'lowestCommonGroup', { get : function(){ var startPath = this.startNode.group.path.split("/"); var endPath = this.endNode.group.path.split("/"); var commonPath = []; for (var i=0; iAll preferences that have been written to the file are accessible as properties of this class.
* Alternatively, new preferences can be retrieved with the .get function. * @constructor * @example * var pref = $.getPreferences(); * pref.create( "MyNewPreferenceName", "MyPreferenceValue" ); * pref["MyNewPreferenceName"]; // Provides: MyPreferenceValue * pref.get("MyNewPreferenceName"); // Provides: MyPreferenceValue */ $.oPreferences = function( ){ this._type = "preferences"; this._addedPreferences = [] this.refresh(); } /** * Refreshes the preferences by re-reading the preference file and ingesting their values appropriately. They are then available as properties of this class.
* Note, any new preferences will not be available as properties until Harmony saves the preference file at exit. In order to reference new preferences, use the get function. * @name $.oPreferences#refresh * @function */ $.oPreferences.prototype.refresh = function(){ var fl = specialFolders.userConfig + "/Harmony Premium-pref.xml"; var nfl = new this.$.oFile( fl ); if( !nfl.exists ){ System.println( "Unable to find preference file: " + fl ); this.$.debug( "Unable to find preference file: " + fl, this.$.DEBUG_LEVEL.ERROR ); return; } var xmlDom = new QDomDocument(); xmlDom.setContent( nfl.read() ); if( !xmlDom ){ return; } var prefXML = xmlDom.elementsByTagName( "preferences" ); if( prefXML.length() == 0 ){ this.$.debug( "Unable to find preferences in file: " + fl, this.$.DEBUG_LEVEL.ERROR ); return; } var XMLpreferences = prefXML.at(0); //Clear this objects previous getter/setters to make room for new ones. if( this._preferences ){ for( n in this._preferences ){ //Remove them if they've disappeared. Object.defineProperty( this, n, { enumerable : false, configurable: true, set : function(){}, get : function(){} }); } } this._preferences = {}; if( !XMLpreferences.hasChildNodes() ){ this.$.debug( "Unable to find preferences in file: " + fl, this.$.DEBUG_LEVEL.ERROR ); return; } //THE DEFAULT SETTER var set_val = function( pref, name, val ){ var prefObj = pref._preferences[name]; //Check against types, unable to set types differently. switch( typeof val ){ case 'string': if( prefObj["type"] != "string" ){ throw ReferenceError( "Harmony does not support preference type-changes. Preference must remain " + prefObj["type"] ); } preferences.setString( name, val ); break; case 'number': if( prefObj["type"] == "int" ){ val = Math.floor( val ); preferences.setInt( name, val ); }else if( prefObj["type"] == "double" ){ //This is fine. preferences.setDouble( name, val ); }else{ throw ReferenceError( "Harmony does not support preference type-changes. Preference must remain " + prefObj["type"] ); } break case 'boolean': case 'undefined': case 'null': if( prefObj["type"] != "bool" ){ throw ReferenceError( "Harmony does not support preference type-changes. Preference must remain " + prefObj["type"] ); } preferences.setBool( name, val ? true:false ); break case 'object': default: var set = false; try{ if( val.r && val.g && val.b && val.a ){ if( prefObj["type"] != "color" ){ throw ReferenceError( "Harmony does not support preference type-changes. Preference must remain " + prefObj["type"] ); } value = preferences.setColor( name, new ColorRGBA( val.r, val.g, val.b, val.a ) ); set = true; } }catch(err){ } if(!set){ if( prefObj["type"] != "string" ){ throw ReferenceError( "Harmony does not support preference type-changes. Preference must remain " + prefObj["type"] ); } var json_val = 'json('+JSON.stringify( val )+')'; preferences.setString( name, json_val ); } break } { pref._preferences[name].value = val; } } //THE DEFAULT GETTER var get_val = function( pref, name ){ return pref._preferences[name].value; } var getterSetter_create = function( targ, id, type ){ switch( type ){ case 'color': var tempVal = preferences.getColor( id, new ColorRGBA () ); value = new $.oColorValue( tempVal.r, tempVal.g, tempVal.b, tempVal.a ); break; case 'int': value = preferences.getInt( id, 0 ); break case 'double': value = preferences.getDouble( id, 0.0 ); break case 'bool': value = preferences.getBool( id, false ); break case 'string': value = preferences.getString( id, "unknown" ); if( value.slice( 0, 5 ) == "json(" ){ var obj = value.slice( 5, value.length-1 ); value = JSON.parse( obj ); } break default: break; } if( value === null ) return; targ._preferences[ id ] = { "value": value, "type":type }; //Create a getter/setter for it! Object.defineProperty( targ, id, { enumerable : true, configurable: true, set : eval( 'val = function(val){ set_val( targ, "'+id+'", val ); }' ), get : eval( 'val = function(){ return get_val( targ, "'+id+'"); }' ) }); } //Get all the children preferences. var childNodes = XMLpreferences.childNodes(); for( var cn=0;cnNote- A new preference isn't actively written into the Harmony's preference file until created and the application closed. Use preference.get for newly created preferences. * @name $.oPreferences#create * @param {string} name The name of the new preference to create. * @param {object} val The value of the new preference created. */ $.oPreferences.prototype.create = function( name, val ){ if( this[ name ] ){ throw ReferenceError( "Preference already exists by name: " + name ); } var type = ''; //Check against types, unable to set types differently. switch( typeof val ){ case 'string': type = 'string'; preferences.setString( name, val ); break; case 'number': type = 'double'; preferences.setDouble( name, val ); break case 'boolean': case 'undefined': case 'null': type = 'bool'; preferences.setBool( name, val ? true:false ); break case 'object': default: var set = false; try{ if( val.r && val.g && val.b && val.a ){ type = 'color'; value = preferences.setColor( name, new ColorRGBA( val.r, val.g, val.b, val.a ) ); set = true; } }catch(err){ } if(!set){ type = 'string'; var json_val = 'json('+JSON.stringify( val )+')'; preferences.setString( name, json_val ); } break } this._addedPreferences.push( {"type":type, "name":name } ); this.refresh(); } /** * Retrieves a preference and attempts to identify its type automatically.
This is generally useful for accessing newly created preferences that have not been written to disk. * @name $.oPreferences#get * @param {string} name The name of the preference to retrieve. * @example * var pref = $.getPreferences(); * pref.create( "MyNewPreferenceName", "MyPreferenceValue" ); * //This new preference won't be available in the file until Harmony closes. * //So if preferences are reinstantiated, it won't be readily available -- but it can still be retrieved with get. * * var pref2 = $.getPreferences(); * pref["MyNewPreferenceName"]; // Provides: undefined -- its not in the Harmony preference file. * pref.get("MyNewPreferenceName"); // Provides: MyPreferenceValue, its still available */ $.oPreferences.prototype.get = function( name ){ if( this[name] ){ return this[name]; } var testTime = (new Date()).getTime(); var doubleExist = preferences.getDouble( name, testTime ); if( doubleExist!= testTime ){ this._addedPreferences.push( {"type":'double', "name":name } ); this.refresh(); return doubleExist; } var intExist = preferences.getInt( name, testTime ); if( intExist!= testTime ){ this._addedPreferences.push( {"type":'int', "name":name } ); this.refresh(); return intExist; } var colorExist = preferences.getColor( name, new ColorRGBA(1,2,3,4) ); if( !( (colorExist.r==1) && (colorExist.g==2) && (colorExist.b==3) && (colorExist.a==4) ) ){ this._addedPreferences.push( {"type":'color', "name":name } ); this.refresh(); return colorExist; } var stringExist = preferences.getString( name, "doesntExist" ); if( stringExist != "doesntExist" ){ this._addedPreferences.push( {"type":'color', "name":name } ); this.refresh(); return this[name]; } return preferences.getBool( name, false ); } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oPreference class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The constructor for the oPreference Class. * @classdesc * The oPreference class wraps a single preference item. * @constructor * @param {string} category The category of the preference * @param {string} keyword The keyword used by the preference * @param {string} type The type of value held by the preference * @param {string} description A short string of description * @param {string} descriptionText The complete tooltip text for the preference * @example * // To access the preferences of Harmony, grab the preference object in the $.oApp class: * var prefs = $.app.preferences; * * // It's then possible to access all available preferences of the software: * for (var i in prefs){ * log (i+" "+prefs[i]); * } * * // accessing the preference value can be done directly by using the dot notation: * prefs.USE_OVERLAY_UNDERLAY_ART = true; * log (prefs.USE_OVERLAY_UNDERLAY_ART); * * //the details objects of the preferences object allows access to more information about each preference * var details = prefs.details * log(details.USE_OVERLAY_UNDERLAY_ART.category+" "+details.USE_OVERLAY_UNDERLAY_ART.id+" "+details.USE_OVERLAY_UNDERLAY_ART.type); * * for (var i in details){ * log(i+" "+JSON.stringify(details[i])) // each object inside detail is a complete oPreference instance * } * * // the preference object also holds a categories array with the list of all categories * log (prefs.categories) */ $.oPreference = function(category, keyword, type, value, description, descriptionText){ this.category = category; this.keyword = keyword; this.type = type; this.description = description; this.descriptionText = descriptionText; this.defaultValue = value; } /** * get and set a preference value * @name $.oPreference#value */ Object.defineProperty ($.oPreference.prototype, 'value', { get: function(){ try{ switch(this.type){ case "bool": var _value = preferences.getBool(this.keyword, this.defaultValue); break case "int": var _value = preferences.getInt(this.keyword, this.defaultValue); break; case "double": var _value = preferences.getDouble(this.keyword, this.defaultValue); break; case "color": var _value = preferences.getColor(this.keyword, this.defaultValue); _value = new this.$.oColorValue(_value.r, _value.g, _value.b, _value.a) break; default: var _value = preferences.getString(this.keyword, this.defaultValue); } }catch(err){ this.$.debug(err, this.$.DEBUG_LEVEL.ERROR) } this.$.debug("Getting value of Preference "+this.keyword+" : "+_value, this.$.DEBUG_LEVEL.LOG) return _value; }, set : function(newValue){ switch(this.type){ case "bool": preferences.setBool(this.keyword, newValue); break case "int": preferences.setInt(this.keyword, newValue); break; case "double": preferences.setDouble(this.keyword, newValue); break; case "color": if (typeof newValue == String) newValue = (new oColorValue()).fromColorString(newValue); preferences.setColor(this.keyword, new ColorRGBA(newValue.r, newValue.g, newValue.b, newValue.a)); break; default: preferences.setString(this.keyword, newValue); } this.$.debug("Preference "+this.keyword+" was set to : "+newValue, this.$.DEBUG_LEVEL.LOG) } }) /** * Creates getter setters on a simple object for the preference described by the params * @private * @param {string} category The category of the preference * @param {string} keyword The keyword used by the preference * @param {string} type The type of value held by the preference * @param {string} description A short string of description * @param {string} descriptionText The complete tooltip text for the preference * @param {Object} prefObject The preference object that will receive the getter setter property (usually $.oApp._prefObject) */ $.oPreference.createPreference = function(category, keyword, type, value, description, descriptionText, prefObject){ if (!prefObject.details.hasOwnProperty(keyword)){ var pref = new $.oPreference(category, keyword, type, value, description, descriptionText); Object.defineProperty(prefObject, keyword,{ enumerable: true, get : function(){ return pref.value; }, set : function(newValue){ pref.value = newValue; } }) }else{ var pref = prefObject.details[keyword] } return pref; } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_scene.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library // // // Developped by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is garanteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the Mozilla Public license 2.0. // https://www.mozilla.org/en-US/MPL/2.0/ // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oScene class // // // // // ////////////////////////////////////// ////////////////////////////////////// //TODO: Metadata, settings, aspect, camera peg, view. /** * The constructor for $.oScene. * @classdesc * The base Class to access all the contents of the scene, and add elements.
This is the main class to do exporting operations as well as column/element/palette creation. * @constructor * @example * // Access to the direct dom object. Available and automatically instantiated as $.getScene, $.scene, $.scn, $.s * var doc = $.getScene ; * var doc = $.scn ; * ver doc = $.s ; // all these are equivalents * * // To grab the scene from a QWidget Dialog callback, store the $ object in a local variable to access all the fonctions from the library. * function myCallBackFunction(){ * var this.$ = $; * * var doc = this.$.scn; * } * * */ $.oScene = function( ){ // $.oScene.nodes property is a class property shared by all instances, so it can be passed by reference and always contain all nodes in the scene //var _topNode = new this.$.oNode("Top"); //this.__proto__.nodes = _topNode.subNodes(true); this._type = "scene"; } //------------------------------------------------------------------------------------- //--- $.oScene Objects Properties //------------------------------------------------------------------------------------- //------------------------------------------------------------------------------------- /** * The folder that contains this scene. * @name $.oScene#path * @type {$.oFolder} * @readonly */ Object.defineProperty($.oScene.prototype, 'path', { get : function(){ return new this.$.oFolder( scene.currentProjectPathRemapped() ); } }); /** * The stage file of the scene. * @name $.oScene#stage * @type {$.oFile} * @readonly */ Object.defineProperty($.oScene.prototype, 'stage', { get : function(){ if (this.online) return this.path + "/stage/" + this.name + ".stage"; return this.path + "/" + this.version + ".xstage"; } }); /** * The folder that contains this scene. * @name $.oScene#paletteFolder * @type {$.oFolder} * @readonly */ Object.defineProperty($.oScene.prototype, 'paletteFolder', { get : function(){ return new this.$.oFolder( this.path+"/palette-library" ); } }); /** * The temporary folder where files are created before being saved. * If the folder doesn't exist yet, it will be created. * @name $.oScene#tempFolder * @type {$.oFolder} * @readonly */ Object.defineProperty($.oScene.prototype, 'tempFolder', { get : function(){ if (!this.hasOwnProperty("_tempFolder")){ this._tempFolder = new this.$.oFolder(scene.tempProjectPathRemapped()); if (!this._tempFolder.exists) this._tempFolder.create() } return this._tempFolder; } }); /** * The name of the scene. * @name $.oScene#name * @readonly * @type {string} */ Object.defineProperty($.oScene.prototype, 'name', { get : function(){ return scene.currentScene(); } }); /** * Wether the scene is hosted on a Toonboom database. * @name $.oScene#online * @readonly * @type {bool} */ Object.defineProperty($.oScene.prototype, 'online', { get : function(){ return about.isDatabaseMode() } }); /** * The name of the scene. * @name $.oScene#environnement * @readonly * @type {string} */ Object.defineProperty($.oScene.prototype, 'environnement', { get : function(){ if (!this.online) return null; return scene.currentEnvironment(); } }); /** * The name of the scene. * @name $.oScene#job * @readonly * @type {string} */ Object.defineProperty($.oScene.prototype, 'job', { get : function(){ if (!this.online) return null; return scene.currentJob(); } }); /** * The name of the scene. * @name $.oScene#version * @readonly * @type {string} */ Object.defineProperty($.oScene.prototype, 'version', { get : function(){ return scene.currentVersionName(); } }); /** * The sceneName file of the scene. * @Deprecated * @readonly * @name $.oScene#sceneName * @type {string} */ Object.defineProperty($.oScene.prototype, 'sceneName', { get : function(){ return this.name; } }); /** * The startframe to the playback of the scene. * @name $.oScene#startPreview * @type {int} */ Object.defineProperty($.oScene.prototype, 'startPreview', { get : function(){ return scene.getStartFrame(); }, set : function(val){ scene.setStartFrame( val ); } }); /** * The stopFrame to the playback of the scene. * @name $.oScene#stopPreview * @type {int} */ Object.defineProperty($.oScene.prototype, 'stopPreview', { get : function(){ return scene.getStopFrame()+1; }, set : function(val){ scene.setStopFrame( val-1 ); } }); /** * The frame rate of the scene. * @name $.oScene#framerate * @type {float} */ Object.defineProperty($.oScene.prototype, 'framerate', { get : function(){ return scene.getFrameRate(); }, set : function(val){ return scene.setFrameRate( val ); } }); /** * The Field unit aspect ratio as a coefficient (width/height). * @name $.oScene#unitsAspectRatio * @type {double} */ Object.defineProperty($.oScene.prototype, 'unitsAspectRatio', { get : function(){ return this.aspectRatioX/this.aspectRatioY; } }); /** * The horizontal aspect ratio of Field units. * @name $.oScene#aspectRatioX * @type {double} */ Object.defineProperty($.oScene.prototype, 'aspectRatioX', { get : function(){ return scene.unitsAspectRatioX(); }, set : function(val){ scene.setUnitsAspectRatio( val, this.aspectRatioY ); } }); /** * The vertical aspect ratio of Field units. * @name $.oScene#aspectRatioY * @type {double} */ Object.defineProperty($.oScene.prototype, 'aspectRatioY', { get : function(){ return scene.unitsAspectRatioY(); }, set : function(val){ scene.setUnitsAspectRatio( this.aspectRatioY, val ); } }); /** * The horizontal Field unit count. * @name $.oScene#unitsX * @type {double} */ Object.defineProperty($.oScene.prototype, 'unitsX', { get : function(){ return scene.numberOfUnitsX(); }, set : function(val){ scene.setNumberOfUnits( val, this.unitsY, this.unitsZ ); } }); /** * The vertical Field unit count. * @name $.oScene#unitsY * @type {double} */ Object.defineProperty($.oScene.prototype, 'unitsY', { get : function(){ return scene.numberOfUnitsY(); }, set : function(val){ scene.setNumberOfUnits( this.unitsX, val, this.unitsZ ); } }); /** * The depth Field unit count. * @name $.oScene#unitsZ * @type {double} */ Object.defineProperty($.oScene.prototype, 'unitsZ', { get : function(){ return scene.numberOfUnitsZ(); }, set : function(val){ scene.setNumberOfUnits( this.unitsX, this.unitsY, val ); } }); /** * The center coordinates of the scene. * @name $.oScene#center * @type {$.oPoint} */ Object.defineProperty($.oScene.prototype, 'center', { get : function(){ return new this.$.oPoint( scene.coordAtCenterX(), scene.coordAtCenterY(), 0.0 ); }, set : function( val ){ scene.setCoordAtCenter( val.x, val.y ); } }); /** * The amount of drawing units represented by 1 field on the horizontal axis. * @name $.oScene#fieldVectorResolutionX * @type {double} * @readonly */ Object.defineProperty($.oScene.prototype, 'fieldVectorResolutionX', { get : function(){ var yUnit = this.fieldVectorResolutionY; var unit = yUnit * this.unitsAspectRatio; return unit } }); /** * The amount of drawing units represented by 1 field on the vertical axis. * @name $.oScene#fieldVectorResolutionY * @type {double} * @readonly */ Object.defineProperty($.oScene.prototype, 'fieldVectorResolutionY', { get : function(){ var verticalResolution = 1875 // the amount of drawing units for the max vertical field value var unit = verticalResolution/12; // the vertical number of units on drawings is always 12 regardless of $.scn.unitsY return unit } }); /** * The horizontal resolution in pixels (for rendering). * @name $.oScene#resolutionX * @readonly * @type {int} */ Object.defineProperty($.oScene.prototype, 'resolutionX', { get : function(){ return scene.currentResolutionX(); } }); /** * The vertical resolution in pixels (for rendering). * @name $.oScene#resolutionY * @type {int} */ Object.defineProperty($.oScene.prototype, 'resolutionY', { get : function(){ return scene.currentResolutionY(); } }); /** * The default horizontal resolution in pixels. * @name $.oScene#defaultResolutionX * @type {int} */ Object.defineProperty($.oScene.prototype, 'defaultResolutionX', { get : function(){ return scene.defaultResolutionX(); }, set : function(val){ scene.setDefaultResolution( val, this.defaultResolutionY, this.fov ); } }); /** * The default vertical resolution in pixels. * @name $.oScene#defaultResolutionY * @type {int} */ Object.defineProperty($.oScene.prototype, 'defaultResolutionY', { get : function(){ return scene.defaultResolutionY(); }, set : function(val){ scene.setDefaultResolution( this.defaultResolutionX, val, this.fov ); } }); /** * The field of view of the scene. * @name $.oScene#fov * @type {double} */ Object.defineProperty($.oScene.prototype, 'fov', { get : function(){ return scene.defaultResolutionFOV(); }, set : function(val){ scene.setDefaultResolution( this.defaultResolutionX, this.defaultResolutionY, val ); } }); /** * The default Display of the scene. * @name $.oScene#defaultDisplay * @type {oNode} */ Object.defineProperty($.oScene.prototype, 'defaultDisplay', { get : function(){ return this.getNodeByPath(scene.getDefaultDisplay()); }, set : function(newDisplay){ node.setAsGlobalDisplay(newDisplay.path); } }); /** * Whether the scene contains unsaved changes. * @name $.oScene#unsaved * @readonly * @type {bool} */ Object.defineProperty($.oScene.prototype, 'unsaved', { get : function(){ return scene.isDirty(); } }); /** * The root group of the scene. * @name $.oScene#root * @type {$.oGroupNode} * @readonly */ Object.defineProperty($.oScene.prototype, 'root', { get : function(){ var _topNode = this.getNodeByPath( "Top" ); return _topNode } }); /** * Contains the list of all the nodes present in the scene. * @name $.oScene#nodes * @readonly * @type {$.oNode[]} */ Object.defineProperty($.oScene.prototype, 'nodes', { get : function(){ var _topNode = this.root; return _topNode.subNodes( true ); } }); /** * Contains the list of columns present in the scene. * @name $.oScene#columns * @readonly * @type {$.oColumn[]} * @todo add attribute finding to get complete column objects */ Object.defineProperty($.oScene.prototype, 'columns', { get : function(){ var _columns = []; for (var i=0; i0){ frame.insert(_length-1, _toAdd) }else{ frame.remove(_length-1, _toAdd) } } }); /** * The current frame of the scene. * @name $.oScene#currentFrame * @type {int} */ Object.defineProperty($.oScene.prototype, 'currentFrame', { get : function(){ return frame.current(); }, set : function( frm ){ return frame.setCurrent( frm ); } }); /** * Retrieve and change the selection of nodes. * @name $.oScene#selectedNodes * @type {$.oNode[]} */ Object.defineProperty($.oScene.prototype, 'selectedNodes', { get : function(){ return this.getSelectedNodes(); }, set : function(nodesToSelect){ selection.clearSelection (); for (var i in nodesToSelect){ selection.addNodeToSelection(nodesToSelect[i].path); }; } }); /** * Retrieve and change the selected frames. This is an array with two values, one for the start and one for the end of the selection (not included). * @name $.oScene#selectedFrames * @type {int[]} */ Object.defineProperty($.oScene.prototype, 'selectedFrames', { get : function(){ if (selection.isSelectionRange()){ var _selectedFrames = [selection.startFrame(), selection.startFrame()+selection.numberOfFrames()]; }else{ var _selectedFrames = [this.currentFrame, this.currentFrame+1]; } return _selectedFrames; }, set : function(frameRange){ selection.setSelectionFrameRange(frameRange[0], frameRange[1]-frameRange[0]); } }); /** * Retrieve and set the selected palette from the scene palette list. * @name $.oScene#selectedPalette * @type {$.oPalette} */ Object.defineProperty($.oScene.prototype, "selectedPalette", { get: function(){ var _paletteList = PaletteObjectManager.getScenePaletteList() var _id = PaletteManager.getCurrentPaletteId() if (_id == "") return null; var _palette = new this.$.oPalette(_paletteList.getPaletteById(_id), _paletteList); return _palette; }, set: function(newSelection){ var _id = newSelection.id; PaletteManager.setCurrentPaletteById(_id); } }) /** * The selected strokes on the currently active Drawing * @name $.oScene#selectedShapes * @type {$.oStroke[]} */ Object.defineProperty($.oScene.prototype, "selectedShapes", { get : function(){ var _currentDrawing = this.activeDrawing; var _shapes = _currentDrawing.selectedShapes; return _shapes; } }) /** * The selected strokes on the currently active Drawing * @name $.oScene#selectedStrokes * @type {$.oStroke[]} */ Object.defineProperty($.oScene.prototype, "selectedStrokes", { get : function(){ var _currentDrawing = this.activeDrawing; var _strokes = _currentDrawing.selectedStrokes; return _strokes; } }) /** * The selected strokes on the currently active Drawing * @name $.oScene#selectedContours * @type {$.oContour[]} */ Object.defineProperty($.oScene.prototype, "selectedContours", { get : function(){ var _currentDrawing = this.activeDrawing; var _strokes = _currentDrawing.selectedContours; return _strokes; } }) /** * The currently active drawing in the harmony UI. * @name $.oScene#activeDrawing * @type {$.oDrawing} */ Object.defineProperty($.oScene.prototype, 'activeDrawing', { get : function(){ var _curDrawing = Tools.getToolSettings().currentDrawing; if (!_curDrawing) return null; var _element = this.selectedNodes[0].element; var _drawings = _element.drawings; for (var i in _drawings){ if (_drawings[i].id == _curDrawing.drawingId) return _drawings[i]; } return null }, set : function( newCurrentDrawing ){ newCurrentDrawing.setAsActiveDrawing(); } }); /** * The current timeline using the default Display. * @name $.oScene#currentTimeline * @type {$.oTimeline} * @readonly */ Object.defineProperty($.oScene.prototype, 'currentTimeline', { get : function(){ if (!this.hasOwnProperty("_timeline")){ this._timeline = this.getTimeline(); } return this._timeline; } }); //------------------------------------------------------------------------------------- //--- $.oScene Objects Methods //------------------------------------------------------------------------------------- //------------------------------------------------------------------------------------- /** * Gets a node by the path. * @param {string} fullPath The path of the node in question. * * @return {$.oNode} The node found given the query. */ $.oScene.prototype.getNodeByPath = function(fullPath){ var _type = node.type(fullPath); if (_type == "") return null; var _node; switch(_type){ case "READ" : _node = new this.$.oDrawingNode( fullPath, this ); break; case "PEG" : _node = new this.$.oPegNode( fullPath, this ); break; case "COLOR_OVERRIDE_TVG" : _node = new this.$.oColorOverrideNode( fullPath, this ); break; case "TransformationSwitch" : _node = new this.$.oTransformSwitchNode( fullPath, this ); break; case "GROUP" : _node = new this.$.oGroupNode( fullPath, this ); break; default: _node = new this.$.oNode( fullPath, this ); } return _node; } /** * Returns the nodes of a certain type in the entire scene. * @param {string} typeName The name of the node. * * @return {$.oNode[]} The nodes found. */ $.oScene.prototype.getNodesByType = function(typeName){ return this.root.getNodesByType(typeName, true); } /** * Gets a column by the name. * @param {string} uniqueName The unique name of the column as a string. * @param {$.oAttribute} oAttributeObject The oAttributeObject owning the column. * @todo cache and find attribute if it is missing * * @return {$.oColumn} The column found given the query. */ $.oScene.prototype.getColumnByName = function( uniqueName, oAttributeObject ){ var _type = column.type(uniqueName); switch (_type) { case "" : return null; case "DRAWING" : return new this.$.oDrawingColumn(uniqueName, oAttributeObject); default : return new this.$.oColumn(uniqueName, oAttributeObject); } } /** * Gets an element by Id. * @param {string} id The unique name of the column as a string. * @param {$.oColumn} [oColumnObject] The oColumn object linked to the element in case of duplicate. * * @return {$.oElement} The element found given the query. In case of an element linked to several column, only the first one will be returned, unless the column is specified */ $.oScene.prototype.getElementById = function( id, oColumnObject ){ if (element.getNameById(id) == "") return null; var _sceneElements = this.elements.filter(function(x){return x.id == id}); if (typeof oColumnObject !== 'undefined') _sceneElements = _sceneElements.filter(function(x){return x.column.uniqueName == oColumnObject.uniqueName}); if (_sceneElements.length > 0) return _sceneElements[0]; return null; } /** * Gets the selected Nodes. * @param {bool} recurse Whether to recurse into groups. * * @return {$.oNode[]} The selected nodes. */ $.oScene.prototype.getSelectedNodes = function( recurse, sortResult ){ if (typeof recurse === 'undefined') var recurse = false; if (typeof sort_result === 'undefined') var sortResult = false; //Avoid sorting, save time, if unnecessary and used internally. var _selection = selection.selectedNodes(); var _selectedNodes = []; for (var i = 0; i<_selection.length; i++){ var _oNodeObject = this.$node(_selection[i]) _selectedNodes.push(_oNodeObject) if (recurse && node.isGroup(_selection[i])){ _selectedNodes = _selectedNodes.concat(_oNodeObject.subNodes(recurse)) } } // sorting by timeline index if( sortResult ){ var _timeline = this.getTimeline(); _selectedNodes = _selectedNodes.sort(function(a, b){return a.timelineIndex(_timeline)-b.timelineIndex(_timeline)}) } return _selectedNodes; } /** * Searches for a node based on the query. * @param {string} query The query for finding the node[s]. * * @return {$.oNode[]} The node[s] found given the query. */ $.oScene.prototype.nodeSearch = function( query, sort_result ){ if (typeof sort_result === 'undefined') var sort_result = true; //Avoid sorting, save time, if unnecessary and used internally. //----------------------------------- //Breakdown with regexp as needed, find the query details. //----------------------------------- // NAME, NODE, WILDCARDS, ATTRIBUTE VALUE MATCHING, SELECTION/OPTIONS, COLOURS //---------------------------------------------- // -- PATH/WILDCARD#TYPE[ATTRIBUTE:VALUE,ATTRIBUTE:VALUE][OPTION:VALUE,OPTION:VALUE] // ^(.*?)(\#.*?)?(\[.*\])?(\(.*\))?$ //ALLOW USAGE OF AN INPUT LIST, LIST OF NAMES, OR OBJECTS, //-------------------------------------------------- //-- EASY RETURNS FOR FAST OVERLOADS. //* -- OVERRIDE FOR ALL NODES if( query == "*" ){ return this.nodes; //(SELECTED) SELECTED -- OVERRIDE FOR ALL SELECTED NODES }else if( query == "(SELECTED)" || query == "SELECTED" ){ return this.getSelectedNodes( true, sort_result ); //(NOT SELECTED) !SELECTED NOT SELECTED -- OVERRIDE FOR ALL SELECTED NODES }else if( query == "(NOT SELECTED)" || query == "NOT SELECTED" || query == "(! SELECTED)" || query == "! SELECTED" || query == "(UNSELECTED)" || query == "UNSELECTED" ){ var nodes_returned = []; var sel_list = {}; for( var p=0;p 1 && query_match[1] && query_match[1].length > 0 ){ //CONSIDER A LIST, COMMA SEPARATION, AND ESCAPED COMMAS. var query_list = []; var last_str = ''; var split_list = query_match[1].split( "," ); for( var n=0; n0 ){ query_list.push( last_str ); } this.$.debug( "GETTING NODE LIST FROM QUERY", this.$.DEBUG_LEVEL.LOG ); //NOW DEAL WITH WILDCARDS var added_nodes = {}; //Add the full path to a list when adding/querying existing. Prevent duplicate attempts. var all_nodes = false; for( var x=0; x=0) || (query_list[x].indexOf("?")>=0) ){ //THERE ARE WILDCARDS. this.$.debug( "WILDCARD NODE QUERY: "+query_list[x], this.$.DEBUG_LEVEL.LOG ); //Make a wildcard search for the nodes. if( all_nodes === false ){ all_nodes = this.nodes; } //Run the Wildcard regexp against the available nodes. var regexp = query_list[x]; regexp = regexp.split( "?" ).join( "." ); regexp = regexp.split( "*" ).join( ".*?" ); regexp = '^'+regexp+'$'; this.$.debug( "WILDCARD QUERY REGEXP: "+regexp, this.$.DEBUG_LEVEL.LOG ); var regexp_filter = RegExp( regexp, 'gi' ); for( var n=0;n=3 && query_list[x]=="re:" ){ //THERE ARE WILDCARDS. this.$.debug( "REGEXP NODE QUERY: "+query_list[x], this.$.DEBUG_LEVEL.LOG ); //Make a wildcard search for the nodes. if( all_nodes === false ){ all_nodes = this.nodes; } //Run the Wildcard regexp against the available nodes. var regexp = query_list[x]; this.$.debug( "REGEXP QUERY REGEXP: "+regexp, this.$.DEBUG_LEVEL.LOG ); var regexp_filter = RegExp( regexp, 'gi' ); for( var n=0;n 2 ){ var filtered_nodes = nodes_returned; for( var n=2;n=0; j--){ //if (nodes[j].attributes.drawing.element.frames[_frame].isBlank) continue; DrawingTools.setCurrentDrawingFromNodeName( nodes[j].path, _frame ); Action.perform("selectAll()", "cameraView"); // select all and check. If empty, operation ends for the current frame if (Action.validate("copy()", "cameraView").enabled){ Action.perform("copy()", "cameraView"); DrawingTools.setCurrentDrawingFromNodeName( _mergedNode.path, _frame ); Action.perform("paste()", "cameraView"); } } } _mergedNode.attributes.drawing.element.column.extendExposures(); _mergedNode.placeAtCenter(nodes) // connect to the same composite as the first node, at the same place // delete nodes that were merged if parameter is specified if (deleteMerged){ for (var i in nodes){ nodes[i].remove(); } } return _mergedNode; } /** * export a template from the specified nodes. * @param {$.oNodes[]} nodes The list of nodes included in the template. * @param {bool} [exportPath] The path of the TPL file to export. * @param {string} [exportPalettesMode='usedOnly'] can have the values : "usedOnly", "all", "createPalette" * @param {string} [renameUsedColors=] if creating a palette, optionally set here the name for the colors (they will have a number added to each) * @param {copyOptions} [copyOptions] An object containing paste options as per Harmony's standard paste options. * * @return {bool} The success of the export. * @todo turn exportPalettesMode into an enum? * @example * // how to export a clean palette with no extra drawings and everything renamed by frame, and only the necessary colors gathered in one palette: * * $.beginUndo(); * * var doc = $.scn; * var nodes = doc.getSelectedNodes(); * * for (var i in nodes){ * if (nodes[i].type != "READ") continue; * * var myColumn = nodes[i].element.column; // we grab the column directly from the element of the node * myColumn.removeUnexposedDrawings(); // remove extra unused drawings * myColumn.renameAllByFrame(); // rename all drawings by frame * } * * doc.exportTemplate(nodes, "C:/templateExample.tpl", "createPalette"); // "createPalette" value will create one palette for all colors * * $.endUndo(); */ $.oScene.prototype.exportTemplate = function(nodes, exportPath, exportPalettesMode, renameUsedColors, copyOptions){ if (typeof exportPalettesMode === 'undefined') var exportPalettesMode = "usedOnly"; if (typeof copyOptions === 'undefined') var copyOptions = copyPaste.getCurrentCreateOptions(); if (typeof renameUsedColors === 'undefined') var renameUsedColors = false; if (!Array.isArray(nodes)) nodes = [nodes]; // add nodes included in groups as they'll get automatically exported var _allNodes = nodes; for (var i in nodes){ if (nodes[i].type == "GROUP") _allNodes = _allNodes.concat(nodes[i].subNodes(true)); } var _readNodes = _allNodes.filter(function (x){return x.type == "READ";}); var _templateFolder = new this.$.oFolder(exportPath); while (_templateFolder.exists) _templateFolder = new this.$.oFolder(_templateFolder.path.replace(".tpl", "_1.tpl")); var _name = _templateFolder.name.replace(".tpl", ""); var _folder = _templateFolder.folder.path; // create the palette with only the colors contained in the layers if (_readNodes.length > 0){ if(exportPalettesMode == "usedOnly"){ var _usedPalettes = []; var _usedPalettePaths = []; for (var i in _readNodes){ var _palettes = _readNodes[i].getUsedPalettes(); for (var j in _palettes){ if (_usedPalettePaths.indexOf(_palettes[j].path.path) == -1){ _usedPalettes.push(_palettes[j]); _usedPalettePaths.push(_palettes[j].path.path); } } } this.$.debug("found palettes : "+_usedPalettes.map(function(x){return x.name}), this.$.DEBUG_LEVEL.LOG); } if (exportPalettesMode == "createPalette"){ var templatePalette = this.createPaletteFromNodes(_readNodes, _name, renameUsedColors); var _usedPalettes = [templatePalette]; } } this.selectedNodes = _allNodes; this.selectedFrames = [this.startPreview, this.stopPreview]; this.$.debug("exporting selection :"+this.selectedFrames+"\n\n"+this.selectedNodes.join("\n")+"\n\n to folder : "+_folder+"/"+_name, this.$.DEBUG_LEVEL.LOG) try{ var success = copyPaste.createTemplateFromSelection (_name, _folder); if (success == "") throw new Error("export failed") }catch(error){ this.$.debug("Export of template "+_name+" failed. Error: "+error, this.$.DEBUG_LEVEL.ERROR); return false; } this.$.debug("export of template "+_name+" finished, cleaning palettes", this.$.DEBUG_LEVEL.LOG); if (_readNodes.length > 0 && exportPalettesMode != "all"){ // deleting the extra palettes from the exported template var _paletteFolder = new this.$.oFolder(_templateFolder.path+"/palette-library"); var _paletteFiles = _paletteFolder.getFiles(); var _paletteNames = _usedPalettes.map(function(x){return x.name}); for (var i in _paletteFiles){ var _paletteName = _paletteFiles[i].name; if (_paletteNames.indexOf(_paletteName) == -1) _paletteFiles[i].remove(); } // building the template palette list var _listFile = ["ToonBoomAnimationInc PaletteList 1"]; if (exportPalettesMode == "createPalette"){ _listFile.push("palette-library/"+_name+' LINK "'+_paletteFolder+"/"+_name+'.plt"'); }else if (exportPalettesMode == "usedOnly"){ for (var i in _usedPalettes){ this.$.debug("palette "+_usedPalettes[i].name+" to be included in template", this.$.DEBUG_LEVEL.LOG); _listFile.push("palette-library/"+_usedPalettes[i].name+' LINK "'+_paletteFolder+"/"+_usedPalettes[i].name+'.plt"'); } } var _paletteListFile = new this.$.oFile(_templateFolder.path+"/PALETTE_LIST"); try{ _paletteListFile.write(_listFile.join("\n")); }catch(err){ this.$.debug(err, this.$.DEBUG_LEVEL.ERROR) } // remove the palette created for the template if (exportPalettesMode == "createPalette"){ var _paletteFile = _paletteFolder.getFiles()[0]; if (_paletteFile){ _paletteFile.rename(_name); if (templatePalette) templatePalette.remove(true); } } } selection.clearSelection(); return true; } /** * Imports the specified template into the scene. * @deprecated * @param {string} tplPath The path of the TPL file to import. * @param {string} [group] The path of the existing target group to which the TPL is imported. * @param {$.oNode[]} [destinationNodes] The nodes affected by the template. * @param {bool} [extendScene] Whether to extend the exposures of the content imported. * @param {$.oPoint} [nodePosition] The position to offset imported new nodes. * @param {object} [pasteOptions] An object containing paste options as per Harmony's standard paste options. * * @return {$.oNode[]} The resulting pasted nodes. */ $.oScene.prototype.importTemplate = function( tplPath, group, destinationNodes, extendScene, nodePosition, pasteOptions ){ if (typeof group === 'undefined') var group = this.root; var _group = (group instanceof this.$.oGroupNode)?group:this.$node(group); if (_group != null && _group instanceof this.$.oGroupNode){ this.$.log("oScene.importTemplate is deprecated. Use oGroupNode.importTemplate instead") var _node = _group.addNode(tplPath, destinationNodes, extendScene, nodePosition, pasteOptions ) return _nodes; }else{ throw new Error (group+" is an invalid group to import the template into.") } } /** * Exports a png of the selected node/frame. if no node is given, all layers will be visible. * @param {$.oFile} path The path in which to save the image. Image will be outputted as PNG. * @param {$.oNode} [includedNodes] The nodes to include in the rendering. If no node is specified, all layers will be visible. * @param {int} [exportFrame] The frame at which to create the image. By default, the timeline current Frame. * @param {bool} [exportCameraFrame=false] Whether to export the camera frames * @param {bool} [exportBackground=false] Whether to add a white background. * @param {float} [frameScale=1] A factor by which to scale the frame. ex: 1.05 will add a 10% margin (5% on both sides) */ $.oScene.prototype.exportLayoutImage = function (path, includedNodes, exportFrame, exportCameraFrame, exportBackground, frameScale, format){ if (typeof includedNodes === 'undefined') var includedNodes = []; if (typeof exportCameraFrame === 'undefined') var exportCameraFrame = false; if (typeof exportBackground === 'undefined') var exportBackground = false; if (typeof frameScale === 'undefined') var frameScale = 1; if (typeof frame === 'undefined') var frame = 1; if (typeof format === 'undefined') var format = "PNG4"; if (typeof path != this.$.oFile) path = new $.oFile(path); var exporter = new LayoutExport(); var params = new LayoutExportParams(); params.renderStaticCameraAtSceneRes = true; params.fileFormat = format; params.borderScale = frameScale; params.exportCameraFrame = exportCameraFrame; params.exportAllCameraFrame = false; params.filePattern = path.name; params.fileDirectory = path.folder; params.whiteBackground = exportBackground; includedNodes = includedNodes.filter(function(x){return ["CAMERA", "READ", "COLOR_CARD", "GRADIENT"].indexOf(x.type) != -1 && x.enabled }) var _timeline = new this.$.oTimeline(); includedNodes = includedNodes.sort(function (a, b){return b.timelineIndex(_timeline) - a.timelineIndex(_timeline)}) if (includedNodes.length == 0) { params.node = this.root; params.frame = exportFrame; params.layoutname = this.name; exporter.addRender(params); if (!exporter.save(params)) throw new Error("failed to export layer "+oNode.name+" at location "+path); }else{ for (var i in includedNodes){ var includedNode = includedNodes[i]; params.whiteBackground = (i==0 && exportBackground); params.node = includedNode.path; params.frame = exportFrame; params.layoutname = includedNode.name; params.exportCameraFrame = ((i == includedNodes.length-1) && exportCameraFrame); exporter.addRender(params); if (!exporter.save(params)) throw new Error("failed to export layer "+oNode.name+" at location "+path); } } exporter.flush(); return path; } /** * Export the scene as a single PSD file, with layers described by the layerDescription array. This function is not supported in batch mode. * @param {$.oFile} path * @param {float} margin a factor by which to increase the rendering area. for example, 1.05 creates a 10% margin. (5% on each side) * @param {Object[]} layersDescription must be an array of objects {layer: $.oNode, frame: int} which describe all the images to export. By default, will include all visible layers of the timeline. */ $.oScene.prototype.exportPSD = function (path, margin, layersDescription){ if (typeof margin === 'undefined') var margin = 1; if (typeof layersDescription === 'undefined') { // export the current frame for each drawing layer present in the default timeline. var _allNodes = this.nodes.filter(function(x){return ["READ", "COLOR_CARD", "GRADIENT"].indexOf(x.type) != -1 && x.enabled }) var _timeline = new this.$.oTimeline(); _allNodes = _allNodes.sort(function (a, b){return b.timelineIndex(_timeline) - a.timelineIndex(_timeline)}) var _scene = this; var layersDescription = _allNodes.map(function(x){return ({layer: x, frame: _scene.currentFrame})}) } if (typeof path != this.$.oFile) path = new $.oFile(path) var tempPath = new $.oFile(path.folder+"/"+path.name+"~") var errors = []; // setting up render var exporter = new LayoutExport(); var params = new LayoutExportParams(); params.renderStaticCameraAtSceneRes = true; params.fileFormat = "PSD4"; params.borderScale = margin; params.exportCameraFrame = false; params.exportAllCameraFrame = false; params.filePattern = tempPath.name; params.fileDirectory = tempPath.folder; params.whiteBackground = false; // export layers for (var i in layersDescription){ var _frame = layersDescription[i].frame; var _layer = layersDescription[i].layer; params.node = _layer.path; params.frame = _frame; params.layoutname = _layer.name; params.exportCameraFrame = (i == layersDescription.length-1); exporter.addRender(params); if (!exporter.save(params)) errors.push(params.layoutname); } if (errors.length > 0) throw new Error("errors during export of file "+path+" with layers "+errors) // write file exporter.flush(); if (path.exists) path.remove(); log(tempPath.exist+" "+tempPath); tempPath.rename(path.name+".psd"); } /** * Imports a PSD to the scene. * @Deprecated use oGroupNode.importPSD instead * @param {string} path The palette file to import. * @param {string} [group] The path of the existing group to import the PSD into. * @param {$.oPoint} [nodePosition] The position for the node to be placed in the network. * @param {bool} [separateLayers] Separate the layers of the PSD. * @param {bool} [addPeg] Whether to add a peg. * @param {bool} [addComposite] Whether to add a composite. * @param {string} [alignment] Alignment type. * * @return {$.oNode[]} The nodes being created as part of the PSD import. */ $.oScene.prototype.importPSD = function( path, group, nodePosition, separateLayers, addPeg, addComposite, alignment ){ if (typeof group === 'undefined') var group = this.root; var _group = (group instanceof this.$.oGroupNode)?group:this.$node(group); if (_group != null && _group instanceof this.$.oGroupNode){ this.$.log("oScene.importPSD is deprecated. Use oGroupNode.importPSD instead") var _node = _group.importPSD(path, separateLayers, addPeg, addComposite, alignment, nodePosition) return _node; }else{ throw new Error (group+" is an invalid group to import a PSD file to.") } } /** * Updates a previously imported PSD by matching layer names. * @deprecated * @param {string} path The PSD file to update. * @param {bool} [separateLayers] Whether the PSD was imported as separate layers. * * @returns {$.oNode[]} The nodes affected by the update */ $.oScene.prototype.updatePSD = function( path, group, separateLayers ){ if (typeof group === 'undefined') var group = this.root; var _group = (group instanceof this.$.oGroupNode)?group:this.$node(group); if (_group != null && _group instanceof this.$.oGroupNode){ this.$.log("oScene.updatePSD is deprecated. Use oGroupNode.updatePSD instead") var _node = _group.updatePSD(path, separateLayers) return _node; }else{ throw new Error (group+" is an invalid group to update a PSD file in.") } } /** * Imports a sound into the scene * @param {string} path The sound file to import. * @param {string} layerName The name to give the layer created. * * @return {$.oNode} The imported sound column. */ $.oScene.prototype.importSound = function(path, layerName){ var _audioFile = new this.$.oFile(path); if (typeof layerName === 'undefined') var layerName = _audioFile.name; // creating an audio column for the sound var _soundColumn = this.addColumn("SOUND", layerName); column.importSound( _soundColumn.name, 1, path); return _soundColumn; } /** * Exports a QT of the scene * @param {string} path The path to export the quicktime file to. * @param {string} display The name of the display to use to export. * @param {double} scale The scale of the export compared to the scene resolution. * @param {bool} exportSound Whether to include the sound in the export. * @param {bool} exportPreviewArea Whether to only export the preview area of the timeline. * * @return {bool} The success of the export */ $.oScene.prototype.exportQT = function( path, display, scale, exportSound, exportPreviewArea){ if (typeof display === 'undefined') var display = node.getName(node.getNodes(["DISPLAY"])[0]); if (typeof exportSound === 'undefined') var exportSound = true; if (typeof exportPreviewArea === 'undefined') var exportPreviewArea = false; if (typeof scale === 'undefined') var scale = 1; if (display instanceof oNode) display = display.name; var _startFrame = exportPreviewArea?scene.getStartFrame():1; var _stopFrame = exportPreviewArea?scene.getStopFrame():this.length-1; var _resX = this.defaultResolutionX*scale var _resY= this.defaultResolutionY*scale return exporter.exportToQuicktime ("", _startFrame, _stopFrame, exportSound, _resX, _resY, path, display, true, 1); } /** * Imports a QT into the scene * @Deprecated * @param {string} path The quicktime file to import. * @param {string} group The group to import the QT into. * @param {$.oPoint} nodePosition The position for the node to be placed in the network. * @param {bool} extendScene Whether to extend the scene to the duration of the QT. * @param {string} alignment Alignment type. * * @return {$.oNode} The imported Quicktime Node. */ $.oScene.prototype.importQT = function( path, group, importSound, nodePosition, extendScene, alignment ){ if (typeof group === 'undefined') var group = this.root; var _group = (group instanceof this.$.oGroupNode)?group:this.$node(group); if (_group != null && _group instanceof this.$.oGroupNode){ this.$.log("oScene.importQT is deprecated. Use oGroupNode.importQTs instead") var _node = _group.importQT(path, importSound, extendScene, alignment, nodePosition) return _node; }else{ throw new Error (group+" is an invalid group to import a QT file to.") } } /** * Adds a backdrop to a group in a specific position. * @Deprecated * @param {string} groupPath The group in which this backdrop is created. * @param {string} title The title of the backdrop. * @param {string} body The body text of the backdrop. * @param {$.oColorValue} color The oColorValue of the node. * @param {float} x The X position of the backdrop, an offset value if nodes are specified. * @param {float} y The Y position of the backdrop, an offset value if nodes are specified. * @param {float} width The Width of the backdrop, a padding value if nodes are specified. * @param {float} height The Height of the backdrop, a padding value if nodes are specified. * * @return {$.oBackdrop} The created backdrop. */ $.oScene.prototype.addBackdrop = function( groupPath, title, body, color, x, y, width, height ){ if (typeof group === 'undefined') var group = this.root; var _group = (group instanceof this.$.oGroupNode)?group:this.$node(group); if (_group != null && _group instanceof this.$.oGroupNode){ this.$.log("oScene.addBackdrop is deprecated. Use oGroupNode.addBackdrop instead") var _backdrop = _group.addBackdrop(title, body, color, x, y, width, height) return _backdrop; }else{ throw new Error (groupPath+" is an invalid group to add the BackDrop to.") } }; /** * Adds a backdrop to a group around specified nodes * @Deprecated * @param {string} groupPath The group in which this backdrop is created. * @param {$.oNode[]} nodes The nodes that the backdrop encompasses. * @param {string} title The title of the backdrop. * @param {string} body The body text of the backdrop. * @param {$.oColorValue} color The oColorValue of the node. * @param {float} x The X position of the backdrop, an offset value if nodes are specified. * @param {float} y The Y position of the backdrop, an offset value if nodes are specified. * @param {float} width The Width of the backdrop, a padding value if nodes are specified. * @param {float} height The Height of the backdrop, a padding value if nodes are specified. * * @return {$.oBackdrop} The created backdrop. */ $.oScene.prototype.addBackdropToNodes = function( groupPath, nodes, title, body, color, x, y, width, height ){ if (typeof group === 'undefined') var group = this.root; var _group = (group instanceof this.$.oGroupNode)?group:this.$node(group); if (_group != null && _group instanceof this.$.oGroupNode) { this.$.log("oScene.addBackdropToNodes is deprecated. Use oGroupNode.addBackdropToNodes instead") var _backdrop = _group.addBackdropToNodes(nodes, title, body, color, x, y, width, height) return _backdrop; }else{ throw new Error (groupPath+" is an invalid group to add the BackDrop to.") } }; /** * Saves the scene. */ $.oScene.prototype.save = function( ){ scene.saveAll(); } /** * Saves the scene in a different location (only available on offline scenes). * @param {string} newPath the new location for the scene (must be a folder path and not a .xstage) */ $.oScene.prototype.saveAs = function(newPath){ if (this.online) { this.$.debug("Can't use saveAs() in database mode.", this.$.DEBUG_LEVEL.ERROR); return; } if (newPath instanceof this.$.oFile) newPath = newPath.path; return scene.saveAs(newPath); } /** * Saves the scene as new version. * @param {string} newVersionName The name for the new version * @param {bool} markAsDefault Wether to make this new version the default version that will be opened from the database. */ $.oScene.prototype.saveNewVersion = function(newVersionName, markAsDefault){ if (typeof markAsDefault === 'undefined') var markAsDefault = true; return scene.saveAsNewVersion (newVersionName, markAsDefault); } /** * Renders the write nodes of the scene. This action saves the scene. * @param {bool} [renderInBackground=true] Whether to do the render on the main thread and block script execution * @param {int} [startFrame=1] The first frame to render * @param {int} [endFrame=oScene.length] The end of the render (non included) * @param {int} [resX] The horizontal resolution of the render. Uses the scene resolution by default. * @param {int} [resY] The vertical resolution of the render. Uses the scene resolution by default. * @param {string} [preRenderScript] The path to the script to execute on the scene before doing the render * @param {string} [postRenderScript] The path to the script to execute on the scene after the render is finished * @return {$.oProcess} In case of using renderInBackground, will return the oProcess object doing the render */ $.oScene.prototype.renderWriteNodes = function(renderInBackground, startFrame, endFrame, resX, resY, preRenderScript, postRenderScript){ if (typeof renderInBackground === 'undefined') var renderInBackground = true; if (typeof startFrame === 'undefined') var startFrame = 1; if (typeof endFrame === 'undefined') var endFrame = this.length+1; if (typeof resX === 'undefined') var resX = this.resolutionX; if (typeof resY === 'undefined') var resY = this.resolutionY; this.save(); var harmonyBin = specialFolders.bin+"/HarmonyPremium.exe"; var args = ["-batch", "-frames", startFrame, endFrame, "-res", resX, resY, this.fov]; if (typeof preRenderScript !== 'undefined'){ args.push("-preRenderScript"); args.push(preRenderScript); } if (typeof postRenderScript !== 'undefined'){ args.push("-postRenderScript"); args.push(postRenderScript); } if (this.online){ args.push("-env"); args.push(this.environnement); args.push("-job"); args.push(this.job); args.push("-scene"); args.push(this.name); }else{ args.push(this.stage); } var p = new this.$.oProcess(harmonyBin, args); p.readChannel = "All"; this.$.log("Starting render of scene "+this.name); if (renderInBackground){ var length = endFrame - startFrame + 1; var progressDialogue = new this.$.oProgressDialog("Rendering : ",length,"Render Write Nodes", true); var cancelRender = function(){ p.kill(); this.$.alert("Render was canceled.") } var renderProgress = function(message){ // reporting progress to log window var progressRegex = /Rendered Frame ([0-9]+)/igm; var matches = []; while (match = progressRegex.exec(message)) { matches.push(match[1]); } if (matches.length!=0){ var progress = parseInt(matches.pop(), 10) - startFrame; progressDialogue.label = "Rendering Frame: " + progress + "/" + length; progressDialogue.value = progress; var percentage = Math.round(progress/length * 100); this.$.log("render : " + percentage + "% complete"); } } var renderFinished = function(exitCode){ if (exitCode == 0){ // render success progressDialogue.label = "Rendering Finished" progressDialogue.value = length; this.$.log(exitCode + " : render finished"); }else{ this.$.log(exitCode + " : render cancelled"); } } progressDialogue.canceled.connect(this, cancelRender); p.readyRead.connect(this, renderProgress); p.finished.connect(this, renderFinished); p.launchAndRead(); return p; }else{ var readout = p.execute(); this.$.log("render finished"); return readout; } } /** * Closes the scene. * @param {bool} [exit] Whether it should exit after closing. */ $.oScene.prototype.close = function( exit ){ if (typeof nodePosition === 'undefined') exit = false; if( exit ){ scene.closeSceneAndExit(); }else{ scene.closeScene(); } } /** * Gets the current camera matrix. * * @return {Matrix4x4} The matrix of the camera. */ $.oScene.prototype.getCameraMatrix = function( ){ return scene.getCameraMatrix(); } /** * Gets the current projection matrix. * * @return {Matrix4x4} The projection matrix of the camera/scene. */ $.oScene.prototype.getProjectionMatrix = function( ){ var fov = this.fov; var f = scene.toOGL( new Point3d( 0.0, 0.0, this.unitsZ ) ).z; var n = 0.00001; //Standard pprojection matrix derivation. var S = 1.0 / Math.tan( ( fov/2.0 ) * ( $.pi/180.0 ) ); var projectionMatrix = [ S, 0.0, 0.0, 0.0, 0.0, S, 0.0, 0.0, 0.0, 0.0, -1.0*(f/(f-n)), -1.0, 0.0, 0.0, -1.0*((f*n)/(f-n)), 0.0 ]; var newMatrix = new Matrix4x4(); for( var r=0;r<4;r++ ){ for( var c=0;c<4;c++ ){ newMatrix["m"+r+""+c] = projectionMatrix[ (c*4.0)+r ]; } } return newMatrix; } /** * Gets the current scene's metadata. * * @see $.oMetadata * @return {$.oMetadata} The metadata of the scene. */ $.oScene.prototype.getMetadata = function( ){ return new this.$.oMetadata( ); } // Short Notations /** * Gets a node by the path. * @param {string} fullPath The path of the node in question. * * @return {$.oNode} The node found given the query. */ $.oScene.prototype.$node = function( fullPath ){ return this.getNodeByPath( fullPath ); } /** * Gets a column by the name. * @param {string} uniqueName The unique name of the column as a string. * @param {$.oAttribute} oAttributeObject The oAttribute object the column is linked to. * * @return {$.oColumn} The node found given the query. */ $.oScene.prototype.$column = function( uniqueName, oAttributeObject ){ return this.getColumnByName( uniqueName, oAttributeObject ); } /** * Gets a palette by its name. * @param {string} name The name of the palette. * * @return {$.oPalette} The node found given the query. */ $.oScene.prototype.$palette = function( name ){ return this.getPaletteByName( name ); } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony/openHarmony_threading.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library // // // Developped by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is garanteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the Mozilla Public license 2.0. // https://www.mozilla.org/en-US/MPL/2.0/ // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oThread class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The base class for the $.oThread -- WIP, NOT TRULY THREADED AS THE EVENT MANAGER DOESNT ALLOW FOR THREADS YET. * @constructor * @classdesc $.oThread Base Class * @param {function} kernel The kernel that is iterating. * @param {object[]} list The list of elements to iterate upon. * @param {int} [threadCount] The amount of threads to initiate. Default: 5 * @param {bool} [start] Whether to start on instantiation, or to wait until prompted. Default: false * @param {int} [timeout] Timeout in MS * @param {bool} [reserveThread] Whether to reserve a thread for this to process while blocking. * * @property {int} threadCount The amount of threads to initiate. * @property {QTimer[]} threads The underlying QTimers that behave as threads. * @property {object[]} results_thread The results from the kernel, should match indices of provided list. * @property {string[]} error_thread The errors from the kernel, in the event there are code errors. * @property {bool[]} complete_thread The completion (note: not success) state of the thread. Success state would be the result. * @property {bool} started The start state of all threads. * @property {int} timeout MS timeout for blocking processes. */ $.oThread = function( kernel, list, threadCount, start, timeout, reserveThread ){ if (typeof threadCount === 'undefined') var threadCount = "2"; if (typeof start === 'undefined') var start = false; if (typeof reserveThread === 'undefined') reserveThread = true; threadCount = Math.min( threadCount, list.length ); this.list = list; this.threadCount = threadCount; this.threads = []; this.started_thread = []; this.results_thread = []; this.error_thread = []; this.complete_thread = []; this.started = false; this.startAtInstantiation = start; this.threads_available = false; this.reserveThread = reserveThread; this.reservedThread = false; this.timeout = 1000.0 * 60.0; if ( timeout ) this.timeout = timeout; //Instantiate the results. for( var n=0;n"; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oNodeLayer class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * Constructor for $.oNodeLayer class * @classdesc * The $.oNodeLayer class represents a timeline layer corresponding to a node from the scene. * @constructor * @extends $.oLayer * @param {oTimeline} oTimelineObject The timeline associated to this layer. * @param {int} layerIndex The index of the layer on the timeline. * * @property {int} index The index of the layer on the timeline. * @property {oTimeline} timeline The timeline associated to this layer. * @property {oNode} node The node associated to the layer. */ $.oNodeLayer = function( oTimelineObject, layerIndex){ this.$.oLayer.apply(this, [oTimelineObject, layerIndex]); } $.oNodeLayer.prototype = Object.create($.oLayer.prototype); /** * The name of this layer/node. * @name $.oNodeLayer#name * @type {string} */ Object.defineProperty($.oNodeLayer.prototype, "name", { get: function(){ return this.node.name; }, set: function(newName){ this.node.name = newName; } }) /** * The layer index when ignoring subLayers. * @name $.oNodeLayer#layerIndex * @type {int} */ Object.defineProperty($.oNodeLayer.prototype, "layerIndex", { get: function(){ var _layers = this.timeline.layers.map(function(x){return x.node.path}); return _layers.indexOf(this.node.path); } }) /** * wether or not the layer is selected. * @name $.oNodeLayer#selected * @type {bool} */ Object.defineProperty($.oNodeLayer.prototype, "selected", { get: function(){ if ($.batchMode) return this.node.selected; var selectionLength = Timeline.numLayerSel for (var i=0; i"; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oDrawingLayer class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * Constructor for $.oDrawingLayer class * @classdesc * The $.oDrawingLayer class represents a timeline layer corresponding to a 'READ' node (or Drawing in Toonboom UI) from the scene. * @constructor * @extends $.oNodeLayer * @param {oTimeline} oTimelineObject The timeline associated to this layer. * @param {int} layerIndex The index of the layer on the timeline. * * @property {int} index The index of the layer on the timeline. * @property {oTimeline} timeline The timeline associated to this layer. * @property {oNode} node The node associated to the layer. */ $.oDrawingLayer = function( oTimelineObject, layerIndex){ this.$.oNodeLayer.apply(this, [oTimelineObject, layerIndex]); } $.oDrawingLayer.prototype = Object.create($.oNodeLayer.prototype); /** * The oFrame objects that hold the drawings for this layer. * @name oDrawingLayer#drawingColumn * @type {oFrame[]} */ Object.defineProperty($.oDrawingLayer.prototype, "drawingColumn", { get: function(){ return this.node.attributes.drawing.elements.column; } }) /** * The oFrame objects that hold the drawings for this layer. * @name oDrawingLayer#exposures * @type {oFrame[]} */ Object.defineProperty($.oDrawingLayer.prototype, "exposures", { get: function(){ return this.drawingColumn.frames; } }) /** * @private */ $.oDrawingLayer.prototype.toString = function(){ return "<$.oDrawingLayer '"+this.name+"'>"; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oColumnLayer class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * Constructor for $.oColumnLayer class * @classdesc * The $.oColumnLayer class represents a timeline layer corresponding to the animated values of a column linked to a node. * @constructor * @extends $.oLayer * @param {oTimeline} oTimelineObject The timeline associated to this layer. * @param {int} layerIndex The index of the layer on the timeline. * * @property {int} index The index of the layer on the timeline. * @property {oTimeline} timeline The timeline associated to this layer. * @property {oNode} node The node associated to the layer. */ $.oColumnLayer = function( oTimelineObject, layerIndex){ this.$.oLayer.apply(this, [oTimelineObject, layerIndex]); } $.oColumnLayer.prototype = Object.create($.oLayer.prototype); /** * The name of this layer. * (corresponding to the display name of the column, not the name displayed in timeline, not exposed by the Toonboom API). * @name $.oColumnLayer#name * @type {string} */ Object.defineProperty($.oColumnLayer.prototype, "name", { get: function(){ return this.column.name; } }) /** * the node attribute associated with this layer. Only available if the attribute has a column. * @name $.oColumnLayer#attribute * @type {$.oColumn} */ Object.defineProperty($.oColumnLayer.prototype, "attribute", { get: function(){ if (!this._attribute){ this._attribute = this.column.attributeObject; } return this._attribute } }) /** * the node associated with this layer * @name $.oColumnLayer#column * @type {$.oColumn} */ Object.defineProperty($.oColumnLayer.prototype, "column", { get: function(){ if (!this._column){ var _name = Timeline.layerToColumn(this.index); var _attribute = this.node.getAttributeByColumnName(_name); this._column = _attribute.column; } return this._column; } }) /** * The layer representing the node to which this column is linked */ Object.defineProperty($.oColumnLayer.prototype, "nodeLayer", { get: function(){ var _node = this.node; var _nodeLayerType = this.$.oNodeLayer; this.timeline.allLayers.filter(function (x){return x.node == _node && x instanceof _nodeLayerType})[0]; } }) /** * @private */ $.oColumnLayer.prototype.toString = function(){ return "<$.oColumnLayer '"+this.name+"'>"; } ////////////////////////////////////// ////////////////////////////////////// // // // // // $.oTimeline class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * The $.oTimeline constructor. * @constructor * @classdesc The $.oTimeline class represents a timeline corresponding to a specific display. * @param {string} [display] The display node's path. By default, the defaultDisplay of the scene. * * @property {string} display The display node's path. */ $.oTimeline = function(display){ if (typeof display === 'undefined') var display = this.$.scn.defaultDisplay; if (display instanceof this.$.oNode) display = display.path; this.display = display; } /** * Gets the list of node layers in timeline. * @name $.oTimeline#layers * @type {$.oLayer[]} */ Object.defineProperty($.oTimeline.prototype, 'layers', { get : function(){ var nodeLayer = this.$.oNodeLayer; return this.allLayers.filter(function (x){return x instanceof nodeLayer}) } }); /** * Gets the list of all layers in timeline, nodes and columns. In batchmode, will only return the nodes, not the sublayers. * @name $.oTimeline#allLayers * @type {$.oLayer[]} */ Object.defineProperty($.oTimeline.prototype, 'allLayers', { get : function(){ if (!this._layers){ var _layers = []; if (!$.batchMode){ for( var i=0; i < Timeline.numLayers; i++ ){ if (Timeline.layerIsNode(i)){ var _layer = new this.$.oNodeLayer(this, i); if (_layer.node.type == "READ") var _layer = new this.$.oDrawingLayer(this, i); }else if (Timeline.layerIsColumn(i)) { var _layer = new this.$.oColumnLayer(this, i); }else{ var _layer = new this.$.oLayer(this, i); } _layers.push(_layer); } } else { var _tl = this; var _layers = this.nodes.map(function(x, index){ if (x.type == "READ") return new _tl.$.oDrawingLayer(_tl, index); return new _tl.$.oNodeLayer(_tl, index) }) } this._layers = _layers; } return this._layers; } }); /** * Gets the list of selected layers as oTimelineLayer objects. * @name $.oTimeline#selectedLayers * @type {oTimelineLayer[]} */ Object.defineProperty($.oTimeline.prototype, 'selectedLayers', { get : function(){ return this.allLayers.filter(function(x){return x.selected}); } }); /** * The node layers in the scene, based on the timeline's order given a specific display. * @name $.oTimeline#compositionLayers * @type {oNode[]} * @deprecated use oTimeline.nodes instead if you want the nodes */ Object.defineProperty($.oTimeline.prototype, 'compositionLayers', { get : function(){ return this.nodes; } }); /** * The nodes present in the timeline. * @name $.oTimeline#nodes * @type {oNode[]} */ Object.defineProperty($.oTimeline.prototype, 'nodes', { get : function(){ var _timeline = this.compositionLayersList; var _scene = this.$.scene; _timeline = _timeline.map( function(x){return _scene.getNodeByPath(x)} ); return _timeline; } }); /** * Gets the paths of the nodes displayed in the timeline. * @name $.oTimeline#nodesList * @type {string[]} * @deprecated only returns node path strings, use oTimeline.layers insteads */ Object.defineProperty($.oTimeline.prototype, 'nodesList', { get : function(){ return this.compositionLayersList; } }); /** * Gets the paths of the layers in order, given the specific display's timeline. * @name $.oTimeline#compositionLayersList * @type {string[]} * @deprecated only returns node path strings */ Object.defineProperty($.oTimeline.prototype, 'compositionLayersList', { get : function(){ var _composition = this.composition; var _timeline = _composition.map(function(x){return x.node}) return _timeline; } }); /** * gets the composition for this timeline (array of native toonboom api 'compositionItems' objects) * @deprecated exposes native harmony api objects */ Object.defineProperty($.oTimeline.prototype, "composition", { get: function(){ return compositionOrder.buildCompositionOrderForDisplay(this.display); } }) /** * Refreshes the oTimeline's cached listing- in the event it changes in the runtime of the script. * @deprecated oTimeline.composition is now always refreshed when accessed. */ $.oTimeline.prototype.refresh = function( ){ if (!node.type(this.display)) { this.composition = compositionOrder.buildDefaultCompositionOrder(); }else{ this.composition = compositionOrder.buildCompositionOrderForDisplay(this.display); } } /** * Build column to oNode/Attribute lookup cache. Makes the layer generation faster if using oTimeline.layers, oTimeline.selectedLayers * @deprecated */ $.oTimeline.prototype.buildLayerCache = function( forced ){ if (typeof forced === 'undefined') forced = false; var cdate = (new Date).getTime(); var rebuild = forced; if( !this.$.cache_columnToNodeAttribute_date ){ rebuild = true; }else if( !rebuild ){ if( ( cdate - this.$.cache_columnToNodeAttribute_date ) > 1000*10 ){ rebuild = true; } } if(rebuild){ var nodeLayers = this.compositionLayers; if( this.$.cache_nodeAttribute ){ this.$.cache_columnToNodeAttribute = {}; } for( var n=0;n OpenHarmonyToolInstaller 0 0 547 642 OpenHarmony Tool Installer 3 0 Available Tools 4 0 Details 0 0 INSTALL/REMOVE ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/openHarmony.js ================================================ ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// // // openHarmony Library // // // Developed by Mathieu Chaptel, Chris Fourney // // // This library is an open source implementation of a Document Object Model // for Toonboom Harmony. It also implements patterns similar to JQuery // for traversing this DOM. // // Its intended purpose is to simplify and streamline toonboom scripting to // empower users and be easy on newcomers, with default parameters values, // and by hiding the heavy lifting required by the official API. // // This library is provided as is and is a work in progress. As such, not every // function has been implemented or is guaranteed to work. Feel free to contribute // improvements to its official github. If you do make sure you follow the provided // template and naming conventions and document your new methods properly. // // This library doesn't overwrite any of the objects and classes of the official // Toonboom API which must remains available. // // This library is made available under the Mozilla Public license 2.0. // https://www.mozilla.org/en-US/MPL/2.0/ // // The repository for this library is available at the address: // https://github.com/cfourney/OpenHarmony/ // // // For any requests feel free to contact m.chaptel@gmail.com // // // // ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////// ////////////////////////////////////// // // // // // $ (DOM) class // // // // // ////////////////////////////////////// ////////////////////////////////////// /** * All the classes can be accessed from it, and it can be passed to a different context. * @namespace * @classdesc The $ global object that holds all the functions of openHarmony. * @property {int} debug_level The debug level of the DOM. * @property {bool} batchMode Deactivate all ui and incompatible functions to ensure scripts run in batch. * @property {string} file The openHarmony base file - THIS! * * @property {$.oScene} getScene The harmony scene. * @property {$.oScene} scene The harmony scene. * @property {$.oScene} scn The harmony scene. * @property {$.oScene} s The harmony scene. * @property {$.oApp} getApplication The Harmony Application Object. * @property {$.oApp} application The Harmony Application Object. * @property {$.oApp} app The Harmony Application Object. * @property {$.oNetwork} network Access point for all the functions of the $.oNetwork class * @property {$.oUtils} utils Access point for all the functions of the $.oUtils class * @property {$.oDialog} dialog Access point for all the functions of the $.oDialog class * @property {Object} global The global scope. * * @example * // To access the functions, first call the $ object. It is made available after loading openHarmony like so: * * include ("openHarmony.js"); * * var doc = $.scn; // grabbing the scene document * $.log("hello"); // prints out a message to the MessageLog. * var myPoint = new $.oPoint(0,0,0); // create a new class instance from an openHarmony class. * * // function members of the $ objects get published to the global scope, which means $ can be omitted * * log("hello"); * var myPoint = new oPoint(0,0,0); // This is all valid * var doc = scn; // "scn" isn't a function so this one isn't * */ $ = { debug_level : 0, /** * Enum to set the debug level of debug statements. * @name $#DEBUG_LEVEL * @enum */ DEBUG_LEVEL : { 'ERROR' : 0, 'WARNING' : 1, 'LOG' : 2 }, file : __file__, directory : false, pi : 3.14159265359 }; /** * The openHarmony main Install directory * @name $#directory * @type {string} */ Object.defineProperty( $, "directory", { get : function(){ var currentFile = __file__ return currentFile.split("\\").join("/").split( "/" ).slice(0, -1).join('/'); } }); /** * Whether Harmony is run with the interface or simply from command line */ Object.defineProperty( $, "batchMode", { get: function(){ // use a cache to avoid pulling the widgets every time if (!this.hasOwnProperty("_batchMode")){ this._batchMode = true; // batchmode is false if there are any widgets visible in the application var _widgets = QApplication.topLevelWidgets(); for (var i in _widgets){ if (_widgets[i].visible) this._batchMode = false; } } return this._batchMode } }) /** * Function to load openHarmony files from the %installdir%/openHarmony/ folder. * @name $#loadOpenHarmonyFiles * @private */ var _ohDirectory = $.directory+"/openHarmony/"; var _dir = new QDir(_ohDirectory); _dir.setNameFilters(["openHarmony*.js"]); _dir.setFilter( QDir.Files); var _files = _dir.entryList(); for (var i in _files){ include( _ohDirectory + "/" + _files[i]); } /** * The standard debug that uses logic and level to write to the messagelog. Everything should just call this to write internally to a log in OpenHarmony. * @function * @name $#debug * @param {obj} obj Description. * @param {int} level The debug level of the incoming message to log. */ $.debug = function( obj, level ){ if( level > this.debug_level ) return; try{ if (typeof obj !== 'object') throw new Error(); this.log(JSON.stringify(obj)); }catch(err){ this.log(obj); } } /** * Log the string to the MessageLog. * @function * @name $#log * @param {string} str Text to log. */ $.log = function( str ){ MessageLog.trace( str ); System.println( str ); } /** * Log the object and its contents. * @function * @name $#logObj * @param {object} object The object to log. * @param {int} debugLevel The debug level. */ $.logObj = function( object ){ for (var i in object){ try { if (typeof object[i] === "function") continue; $.log(i+' : '+object[i]) if (typeof object[i] == "Object"){ $.log(' -> ') $.logObj(object[i]) $.log(' ----- ') } }catch(error){} } } //---- App -------------- $.app = new $.oApp(); $.application = $.app; $.getApplication = $.app; //---- Scene -------------- $.s = new $.oScene(); $.scn = $.s; $.scene = $.s; $.getScene = $.s; /** * Prompts with a confirmation dialog (yes/no choice). * @function * @name $#confirm * @param {string} [labelText] The label/internal text of the dialog. * @param {string} [title] The title of the confirmation dialog. * @param {string} [okButtonText] The text on the OK button of the dialog. * @param {string} [cancelButtonText] The text on the CANCEL button of the dialog. * * @return {bool} Result of the confirmation dialog. */ $.confirm = function(){ return $.dialog.confirm.apply( $.dialog, arguments ) }; /** * Prompts with an alert dialog (informational). * @function * @name $#alert * @param {string} [labelText] The label/internal text of the dialog. * @param {string} [title] The title of the confirmation dialog. * @param {string} [okButtonText] The text on the OK button of the dialog. */ $.alert = function(){ return $.dialog.alert.apply( $.dialog, arguments ) }; /** * Prompts with an alert dialog with a text box which can be selected (informational). * @function * @name $#alertBox * @param {string} [labelText] The label/internal text of the dialog. * @param {string} [title] The title of the confirmation dialog. * @param {string} [okButtonText] The text on the OK button of the dialog. */ $.alertBox = function(){ return $.dialog.alertBox.apply( $.dialog, arguments ) }; /** * Prompts with an toast alert. This is a small message that can't be clicked and only stays on the screen for the duration specified. * @function * @name $#toast * @param {string} labelText The label/internal text of the dialog. * @param {$.oPoint} [position] The position on the screen where the toast will appear (by default, slightly under the middle of the screen). * @param {float} [duration=2000] The duration of the display (in milliseconds). * @param {$.oColorValue} [color="#000000"] The color of the background (a 50% alpha value will be applied). */ $.toast = function(){ return $.dialog.toast.apply( $.dialog, arguments ) }; /** * Prompts for a user input. * @function * @name $#prompt * @param {string} [labelText] The label/internal text of the dialog. * @param {string} [title] The title of the confirmation dialog. * @param {string} [prefilledText] The text to display in the input area. */ $.prompt = function(){ return $.dialog.prompt.apply( $.dialog, arguments ) }; /** * Prompts with a file selector window * @function * @name $#browseForFile * @param {string} [text="Select a file:"] The title of the file select dialog. * @param {string} [filter="*"] The filter for the file type and/or file name that can be selected. Accepts wildcard character "*". * @param {string} [getExisting=true] Whether to select an existing file or a save location * @param {string} [acceptMultiple=false] Whether or not selecting more than one file is ok. Is ignored if getExisting is false. * @param {string} [startDirectory] The directory showed at the opening of the dialog. * * @return {string[]} The list of selected Files, 'undefined' if the dialog is cancelled */ $.browseForFile = function(){ return $.dialog.browseForFile.apply( $.dialog, arguments ) }; /** * Prompts with a folder selector window. * @function * @name $#browseForFolder * @param {string} [text] The title of the confirmation dialog. * @param {string} [startDirectory] The directory showed at the opening of the dialog. * * @return {string} The path of the selected folder, 'undefined' if the dialog is cancelled */ $.browseForFolder = function(){ return $.dialog.browseForFolder.apply( $.dialog, arguments ) }; /** * Gets access to a widget from the Harmony Interface. * @function * @name $#getHarmonyUIWidget * @param {string} name The name of the widget to look for. * @param {string} [parentName] The name of the parent widget to look into, in case of duplicates. */ $.getHarmonyUIWidget = function(){ return $.app.getWidgetByName.apply( $.app, arguments ) } //---- Cache Helpers ------ $.cache_columnToNodeAttribute = {}; $.cache_columnToNodeAttribute_date = (new Date()).getTime(); $.cache_oNode = {}; //------------------------------------------------ //-- Undo operations /** * Starts the tracking of the undo accumulation, all subsequent actions are done in a single undo operation.
Close the undo accum with $.endUndo(). * If this function is called multiple time, only the first time will count. * (this prevents small functions wrapped in their own undo block to interfere with global script undo) * @param {string} undoName The name of the operation that is being done in the undo accum. * @name $#beginUndo * @function * @see $.endUndo */ $.beginUndo = function( undoName ){ if ($.batchMode) return if (typeof undoName === 'undefined') var undoName = ''+((new Date()).getTime()); if (!$.hasOwnProperty("undoStackSize")) $.undoStackSize = 0; if ($.undoStackSize == 0) scene.beginUndoRedoAccum( undoName ); $.undoStackSize++; } /** * Cancels the tracking of the undo accumulation, everything between this and the start of the accumulation is undone. * @name $#cancelUndo * @function */ $.cancelUndo = function( ){ scene.cancelUndoRedoAccum( ); } /** * Stops the tracking of the undo accumulation, everything between this and the start of the accumulation behaves as a single undo operation. * If beginUndo function is called multiple time, each call must be matched with this function. * (this prevents small functions wrapped in their own undo block to interfere with global script undo) * @name $#endUndo * @function * @see $.beginUndo */ $.endUndo = function( ){ if ($.batchMode) return if (!$.hasOwnProperty("undoStackSize")) $.undoStackSize = 1; $.undoStackSize--; if ($.undoStackSize == 0) scene.endUndoRedoAccum(); } /** * Undoes the last n operations. If n is not specified, it will be 1 * @name $#undo * @function * @param {int} dist The amount of operations to undo. */ $.undo = function( dist ){ if (typeof dist === 'undefined'){ var dist = 1; } scene.undo( dist ); } /** * Redoes the last n operations. If n is not specified, it will be 1 * @name $#redo * @function * @param {int} dist The amount of operations to undo. */ $.redo = function( dist ){ if (typeof dist === 'undefined'){ var dist = 1; } scene.redo( dist ); } /** * Gets the preferences from the Harmony stage. * @name $#getPreferences * @function */ $.getPreferences = function( ){ return new $.oPreferences(); } //---- Attach Helpers ------ $.network = new $.oNetwork(); $.utils = $.oUtils; $.dialog = new $.oDialog(); $.global = this; //---- Self caching ----- /** * change this value to allow self caching across openHarmony when initialising objects. * @name $#useCache * @type {bool} */ $.useCache = false; /** * function to call in constructors of classes so that instances of this class * are cached and unique based on constructor arguments. * @returns a cached class instance or null if no cached instance exists. */ $.getInstanceFromCache = function(){ if (!this.__proto__.hasOwnProperty("__cache__")) { this.__proto__.__cache__ = {}; } var _cache = this.__proto__.__cache__; if (!this.$.useCache) return; var key = []; for (var i=0; i= m.availableItems.length ){ return false; } var item = m.availableItems[ indx ]; return item; }catch(err){ $.debug( err + " ("+err.fileName+" "+err.lineNumber+")", $.DEBUG_LEVEL["ERROR"] ); } } } //---------------------------------------------- //-- GET THE FILE CONTENTS IN A DIRECTORY ON GIT this.recurse_files = function( contents, arr_files ){ with( context.$.global ){ try{ var $ = context.$; var m = context; for( var n=0;n 0 && local_path.toUpperCase().indexOf(".JS")>0 ){ script_files.push( local_path ); } var lpth = install_base + "/" + local_path; install_files.push( lpth ); var lfl = new $.oFile( lpth ); if( lfl.exists && !overwrite ){ //Confirm deletion? if( !confirmDialog( "Overwrite File", "Overwrite " + lpth, "Overwrite", "Cancel" ) ){ continue; } } install_instructions.push( { "url": url, "path": lpth } ); }catch(err){ continue; } } var downloaded = $.network.downloadMulti( install_instructions, true ); var all_success = true; for( var x=0;x0 ){ var str_limited = []; for( var t=0;t<( Math.min(script_files.length,4) );t++ ){ str_limited.push( " " + script_files[t] ); } if( script_files.length>4 ){ str_limited.push( " " + "And More!" ); } str = str_limited.join( "\n" ); } m.installButton.text = "INSTALLED!"; m.installButton.setEnabled( false ); MessageBox.information( "Installed " + item["name"] + "!\n\nThe installed scripts include:\n" + str ); //TODO: Create the install script with details. var install_detail_script = oh_install + "/" + item["name"]; var install_details_text = []; var install_fl = new $.oFile( install_detail_script ); install_fl.write( item["sha"] + "\n" + install_files.join( "\n" ) ); m.get_tools(); }catch(err){ $.debug( err + " ("+err.fileName+" "+err.lineNumber+")", $.DEBUG_LEVEL["ERROR"] ); } } } this.basePath = ''; this.removeAction = function( ev ){ with( context.$.global ){ try{ var $ = context.$; var m = context; var item = m.getItem(); if( !item ){ $.debug( "Failed to install - no item seems to be selected.", $.DEBUG_LEVEL["ERROR"] ); return; } var install_detail_script = oh_install + "/" + item["name"]; var install_file = new $.oFile( install_detail_script ); if( install_file.exists ){ //--The file exists. It might be an remove if same version, or an upgrade. var read_file = install_file.read().split("\n"); for( var n=1;nThe openHarmony library enables people to create scripts for Harmony using less code and a simpler synthax.

The complete documentation is available at this address:https://cfourney.github.io/OpenHarmony/

Install this library to be able to use the scripts that require it.

", "repository": "https://github.com/cfourney/OpenHarmony/", "isPackage": false, "files": [ "openHarmony.js", "openHarmony/" ], "keywords": [ "openHarmony", "library" ], "author": "OpenHarmony", "license": "MPL-2.0", "website": "https://github.com/cfourney/OpenHarmony/", "localFiles": "" }, { "name": "oH Anim tools - Smartkey", "version": "1.0.0", "compatibility": "Harmony Premium 15", "description": "This script creates a key frame on the current timeline layer, and set the stop motion or interpolated mode of the key according to the surrounding keyframes. A Keyframe placed on an interpolation will remain interpolated, and a key placed between stop motion keyframes will also be set to stop motion.\n

This script uses the OpenHarmony library. Install it first to be able to use it.

\n\n

Assign this script to a shortcut with the script ScriptShortcuts.

", "repository": "https://github.com/cfourney/OpenHarmony/", "isPackage": false, "files": [ "tools/OpenHarmony_basic/openHarmony_anim_tools.js" ], "keywords": [ "openHarmony", "animation" ], "author": "Chris F", "license": "MPL-v2.0", "website": "https://github.com/cfourney/OpenHarmony/", "localFiles": "" }, { "name": "oH Rigging tools", "version": "1.0.0", "compatibility": "Harmony Premium 15", "description": "OpenHarmony Rigging Tools\n

Those scripts require the openHarmony lib to work. Install it first for the scripts to work.

\n

Add Centered Weighted Peg
Adds a peg with a pivot at the center of the selected drawing.

\n\n

Place Pivot with Click
Place the pivot with a simple click.

\n\n

Clean Unused Palettes
\nFinds and removes all unnecessary palettes files from the filesystem. Doesn't support Element Palettes yet!

\n\n

Create Backdrop on Selection
Set up backdrops easily on the selection with this script.

\n", "repository": "https://github.com/cfourney/OpenHarmony/", "isPackage": false, "files": [ "tools/OpenHarmony_basic/openHarmony_rigging_tools.js", "tools/OpenHarmony_basic/openHarmony_basic_backdropPicker.ui" ], "keywords": [ "openHarmony", "palettes" ], "author": "Chris F", "license": "MPL-v2.0", "website": "https://github.com/cfourney/OpenHarmony/", "localFiles": "" } ] } ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/tools/OpenHarmony_basic/INSTALL ================================================ scripts ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/tools/OpenHarmony_basic/README ================================================ Basic Helper Scripts, used for rigging, animation and compositing. ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/tools/OpenHarmony_basic/openHarmony_anim_tools.js ================================================ /** * Load the Open Harmony Library as needed. */ try{ var oh_incl = preferences.getString( 'openHarmonyIncludeDebug', false ); if( oh_incl=="false" ) oh_incl = preferences.getString( 'openHarmonyInclude', false ); if( oh_incl=="false" ) oh_incl = "openHarmony.js"; include(oh_incl); }catch(err){ MessageBox.information ("OpenHarmony is not installed. Get it here: \nhttps://github.com/cfourney/OpenHarmony") } /** * Smart key button. Only adds a key if a column already exists. Maintains sections that are tweens and other sections that are stop-motion/holds. */ function oh_anim_smartKey(){ System.println(""); scene.beginUndoRedoAccum( "oh_anim_smartKey" ); var timeline = $.scene.getTimeline(); var layers = timeline.selectedLayers; //-------------------------------------------------- //--- The key function, used var smart_key_item = function( attr ){ var cfrm = $.scene.currentFrame; var frame = attr.frames[ cfrm ]; if( attr.node.type == "READ" ){ if( attr.column.type == "DRAWING" ){ frame.isKeyFrame = true; return; } //DONT KEY THE OFFSETS IF ITS NOT ANIMATEABLE. var check_pos = { "offset.x" : true, "offset.y" : true, "offset.z" : true, "skew" : true, "scale.x" : true, "scale.y" : true, "scale.z" : true, "rotation.anglez" : true } if( !attr.node["can_animate"] ){ return; } } if( !frame.isKeyFrame ){ var lk = frame.keyframeLeft; frame.isKeyFrame = true; if(!lk){ //Consider this as static. frame.constant = true; }else{ if( lk.constant ){ frame.constant = true; // lk.constant = true; //UNNECESSARY, DEFAULT APPRAOCH TO isKeyFrame = true; }else{ var rk = frame.keyframeRight; if( rk ){ //Something is to the right, keep the tween. frame.constant = false; // lk.constant = false; //UNNECESSARY, DEFAULT APPRAOCH TO isKeyFrame = true; }else{ frame.constant = true; lk.constant = true; } } } } } if( layers.length == 0 ){ var layers = timeline.compositionLayers; var items = []; for( var n=0;n Dialog 0 0 471 223 Backdrop Details true 75 0 Name 75 0 Text Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop 74 0 Color 1 0 Change Color Qt::Horizontal QDialogButtonBox::Cancel|QDialogButtonBox::Ok buttonBox accepted() Dialog accept() 248 254 157 274 buttonBox rejected() Dialog reject() 316 260 286 274 ================================================ FILE: openpype/hosts/harmony/vendor/OpenHarmony/tools/OpenHarmony_basic/openHarmony_rigging_tools.js ================================================ /** * Load the Open Harmony Library as needed. */ try{ var oh_incl = preferences.getString( 'openHarmonyIncludeDebug', false ); if( oh_incl=="false" ) oh_incl = preferences.getString( 'openHarmonyInclude', false ); if( oh_incl=="false" ) oh_incl = "openHarmony.js"; include(oh_incl); }catch(err){ MessageBox.information ("OpenHarmony is not installed. Get it here: \nhttps://github.com/cfourney/OpenHarmony") } /** * Finds and removes all unnecessary asset files from the filesystem. */ function oh_rigging_removeUnnecesaryPaletteFiles(){ var palette_list = $.scene.palettes; var registered_palette_files = {}; //Find the path of all registered palettes. Add it to a group for easy lookup. for( var n=0;n3 ){ labelText += '\n and '+(unreferenced_palettes.length-3)+' more . . .'; } var confirmation = $.dialog.confirm( "Remove Palettes", labelText ); if( confirmation ){ //Delete all palettes from disk. var prog = new $.dialog.Progress( "Removing Palettes", unreferenced_palettes.length, true ); for( var n=0;n2 ){ var clean_lcs = lcs.sequence; if( clean_lcs.slice( clean_lcs.length-1 ) == ("_") ){ clean_lcs = clean_lcs.slice( 0, clean_lcs.length-1 ); } if( clean_lcs.slice( 0,1 ) == ("_") ){ clean_lcs = clean_lcs.slice( 1 ); } if(!common_substrings[clean_lcs]){ common_substrings[clean_lcs] = 0; } common_substrings[clean_lcs]++; } } laststring = bnm; } var names = []; for( var n in common_substrings ){ names.push( n ); } //Now compare cleaned LCS and accumulate them as votes as well. for( var n=0;n2 ){ var clean_lcs = lcs.sequence; if( clean_lcs.slice( clean_lcs.length-1 ) == ("_") ){ clean_lcs = clean_lcs.slice( 0, clean_lcs.length-1 ); } if( clean_lcs.slice( 0,1 ) == ("_") ){ clean_lcs = clean_lcs.slice( 1 ); } if(!common_substrings[clean_lcs]){ common_substrings[clean_lcs] = 0; } common_substrings[clean_lcs]++; if( equivalent[clean_lcs] ){ var clean_lcs2 = equivalent[clean_lcs]; if(!common_substrings[clean_lcs2]){ common_substrings[clean_lcs2] = 0; } common_substrings[clean_lcs2]++; } } } } //Find the highest voted LCS. var highest = 0; var common_name = 'Backdrop'; for( var n in common_substrings ){ if( common_substrings[n] > highest ){ if( n.toUpperCase()=="DRAWING" ){ continue; } highest = common_substrings[n]; common_name = n; } } var res = color_selector.exec( common_name, "", "" ); if( !res ){ //A cancel option was selected. continue; } //Add that beautiful backdrop. $.scene.addBackdropToNodes( grp, grp_items, res.name, res.text, res.color, 0, 0, 35, 35 ); } scene.endUndoRedoAccum( ); } /** * Sets the peg's pivot based on a clicked position in the interface. */ function oh_rigging_setSelectedPegPivotWithClick(){ var nodes = $.scene.nodeSearch( "#PEG(SELECTED)" ); if( nodes.length == 0 ){ $.dialog.alert( "No peg selected." ); return; } Action.perform( "onActionChoosePencilTool()" ); var context = this; var setPiv = function( res ){ var $ = context.$; var m = context; $.beginUndo(); try{ for( var n=0;n 1: if b in [bin.name() for bin in root_bin.bins()]: bin = [bin for bin in root_bin.bins() if b in bin.name()][0] done_bin_lst.append(bin) else: create_bin = hiero.core.Bin(b) root_bin.addItem(create_bin) done_bin_lst.append(create_bin) elif i >= 1 and i < len(path) - 1: if b in [bin.name() for bin in done_bin_lst[i - 1].bins()]: bin = [ bin for bin in done_bin_lst[i - 1].bins() if b in bin.name() ][0] done_bin_lst.append(bin) else: create_bin = hiero.core.Bin(b) done_bin_lst[i - 1].addItem(create_bin) done_bin_lst.append(create_bin) elif i == len(path) - 1: if b in [bin.name() for bin in done_bin_lst[i - 1].bins()]: bin = [ bin for bin in done_bin_lst[i - 1].bins() if b in bin.name() ][0] done_bin_lst.append(bin) else: create_bin = hiero.core.Bin(b) done_bin_lst[i - 1].addItem(create_bin) done_bin_lst.append(create_bin) return done_bin_lst[-1] def split_by_client_version(string): regex = r"[/_.]v\d+" try: matches = re.findall(regex, string, re.IGNORECASE) return string.split(matches[0]) except Exception as error: log.error(error) return None def get_selected_track_items(sequence=None): _sequence = sequence or get_current_sequence() # Getting selection timeline_editor = hiero.ui.getTimelineEditor(_sequence) return timeline_editor.selection() def set_selected_track_items(track_items_list, sequence=None): _sequence = sequence or get_current_sequence() # make sure only trackItems are in list selection only_track_items = [ i for i in track_items_list if isinstance(i, hiero.core.TrackItem)] # Getting selection timeline_editor = hiero.ui.getTimelineEditor(_sequence) return timeline_editor.setSelection(only_track_items) def _read_doc_from_path(path): # reading QtXml.QDomDocument from HROX path hrox_file = QtCore.QFile(path) if not hrox_file.open(QtCore.QFile.ReadOnly): raise RuntimeError("Failed to open file for reading") doc = QtXml.QDomDocument() doc.setContent(hrox_file) hrox_file.close() return doc def _write_doc_to_path(doc, path): # write QtXml.QDomDocument to path as HROX hrox_file = QtCore.QFile(path) if not hrox_file.open(QtCore.QFile.WriteOnly): raise RuntimeError("Failed to open file for writing") stream = QtCore.QTextStream(hrox_file) doc.save(stream, 1) hrox_file.close() def _set_hrox_project_knobs(doc, **knobs): # set attributes to Project Tag proj_elem = doc.documentElement().firstChildElement("Project") for k, v in knobs.items(): if "ocioconfigpath" in k: paths_to_format = v[platform.system().lower()] for _path in paths_to_format: v = _path.format(**os.environ) if not os.path.exists(v): continue log.debug("Project colorspace knob `{}` was set to `{}`".format(k, v)) if isinstance(v, dict): continue proj_elem.setAttribute(str(k), v) def apply_colorspace_project(): """Apply colorspaces from settings. Due to not being able to set the project settings through the Python API, we need to do use some dubious code to find the widgets and set them. It is possible to set the project settings without traversing through the widgets but it involves reading the hrox files from disk with XML, so no in-memory support. See https://community.foundry.com/discuss/topic/137771/change-a-project-s-default-color-transform-with-python # noqa for more details. """ # get presets for hiero project_name = get_current_project_name() imageio = get_project_settings(project_name)["hiero"]["imageio"] presets = imageio.get("workfile") # Open Project Settings UI. for act in hiero.ui.registeredActions(): if act.objectName() == "foundry.project.settings": act.trigger() # Find widgets from their sibling label. labels = { "Working Space:": "workingSpace", "Viewer:": "viewerLut", "Thumbnails:": "thumbnailLut", "Monitor Out:": "monitorOutLut", "8 Bit Files:": "eightBitLut", "16 Bit Files:": "sixteenBitLut", "Log Files:": "logLut", "Floating Point Files:": "floatLut" } widgets = {x: None for x in labels.values()} def _recursive_children(widget, labels, widgets): children = widget.children() for count, child in enumerate(children): if isinstance(child, QtWidgets.QLabel): if child.text() in labels.keys(): widgets[labels[child.text()]] = children[count + 1] _recursive_children(child, labels, widgets) app = QtWidgets.QApplication.instance() title = "Project Settings" for widget in app.topLevelWidgets(): if isinstance(widget, QtWidgets.QMainWindow): if widget.windowTitle() != title: continue _recursive_children(widget, labels, widgets) widget.close() msg = "Setting value \"{}\" is not a valid option for \"{}\"" for key, widget in widgets.items(): options = [widget.itemText(i) for i in range(widget.count())] setting_value = presets[key] assert setting_value in options, msg.format(setting_value, key) widget.setCurrentText(presets[key]) # This code block is for setting up project colorspaces for files on disk. # Due to not having Python API access to set the project settings, the # Foundry recommended way is to modify the hrox files on disk with XML. See # this forum thread for more details; # https://community.foundry.com/discuss/topic/137771/change-a-project-s-default-color-transform-with-python # noqa ''' # backward compatibility layer # TODO: remove this after some time config_data = get_imageio_config( project_name=get_current_project_name(), host_name="hiero" ) if config_data: presets.update({ "ocioConfigName": "custom" }) # get path the the active projects project = get_current_project() current_file = project.path() msg = "The project needs to be saved to disk to apply colorspace settings." assert current_file, msg # save the workfile as subversion "comment:_colorspaceChange" split_current_file = os.path.splitext(current_file) copy_current_file = current_file if "_colorspaceChange" not in current_file: copy_current_file = ( split_current_file[0] + "_colorspaceChange" + split_current_file[1] ) try: # duplicate the file so the changes are applied only to the copy shutil.copyfile(current_file, copy_current_file) except shutil.Error: # in case the file already exists and it want to copy to the # same filewe need to do this trick # TEMP file name change copy_current_file_tmp = copy_current_file + "_tmp" # create TEMP file shutil.copyfile(current_file, copy_current_file_tmp) # remove original file os.remove(current_file) # copy TEMP back to original name shutil.copyfile(copy_current_file_tmp, copy_current_file) # remove the TEMP file as we dont need it os.remove(copy_current_file_tmp) # use the code from below for changing xml hrox Attributes presets.update({"name": os.path.basename(copy_current_file)}) # read HROX in as QDomSocument doc = _read_doc_from_path(copy_current_file) # apply project colorspace properties _set_hrox_project_knobs(doc, **presets) # write QDomSocument back as HROX _write_doc_to_path(doc, copy_current_file) # open the file as current project hiero.core.openProject(copy_current_file) ''' def apply_colorspace_clips(): project_name = get_current_project_name() project = get_current_project(remove_untitled=True) clips = project.clips() # get presets for hiero imageio = get_project_settings(project_name)["hiero"]["imageio"] presets = imageio.get("regexInputs", {}).get("inputs", {}) for clip in clips: clip_media_source_path = clip.mediaSource().firstpath() clip_name = clip.name() clip_colorspace = clip.sourceMediaColourTransform() if "default" in clip_colorspace: continue # check if any colorspace presets for read is matching preset_clrsp = None for k in presets: if not bool(re.search(k["regex"], clip_media_source_path)): continue preset_clrsp = k["colorspace"] if preset_clrsp: log.debug("Changing clip.path: {}".format(clip_media_source_path)) log.info("Changing clip `{}` colorspace {} to {}".format( clip_name, clip_colorspace, preset_clrsp)) # set the found preset to the clip clip.setSourceMediaColourTransform(preset_clrsp) # save project after all is changed project.save() def is_overlapping(ti_test, ti_original, strict=False): covering_exp = ( (ti_test.timelineIn() <= ti_original.timelineIn()) and (ti_test.timelineOut() >= ti_original.timelineOut()) ) if strict: return covering_exp inside_exp = ( (ti_test.timelineIn() >= ti_original.timelineIn()) and (ti_test.timelineOut() <= ti_original.timelineOut()) ) overlaying_right_exp = ( (ti_test.timelineIn() < ti_original.timelineOut()) and (ti_test.timelineOut() >= ti_original.timelineOut()) ) overlaying_left_exp = ( (ti_test.timelineOut() > ti_original.timelineIn()) and (ti_test.timelineIn() <= ti_original.timelineIn()) ) return any(( covering_exp, inside_exp, overlaying_right_exp, overlaying_left_exp )) def get_sequence_pattern_and_padding(file): """ Return sequence pattern and padding from file Attributes: file (string): basename form path Example: Can find file.0001.ext, file.%02d.ext, file.####.ext Return: string: any matching sequence pattern int: padding of sequnce numbering """ foundall = re.findall( r"(#+)|(%\d+d)|(?<=[^a-zA-Z0-9])(\d+)(?=\.\w+$)", file) if not foundall: return None, None found = sorted(list(set(foundall[0])))[-1] padding = int( re.findall(r"\d+", found)[-1]) if "%" in found else len(found) return found, padding def sync_clip_name_to_data_asset(track_items_list): # loop through all selected clips for track_item in track_items_list: # ignore if parent track is locked or disabled if track_item.parent().isLocked(): continue if not track_item.parent().isEnabled(): continue # ignore if the track item is disabled if not track_item.isEnabled(): continue # get name and data ti_name = track_item.name() data = get_trackitem_openpype_data(track_item) # ignore if no data on the clip or not publish instance if not data: continue if data.get("id") != "pyblish.avalon.instance": continue # fix data if wrong name if data["asset"] != ti_name: data["asset"] = ti_name # remove the original tag tag = get_trackitem_openpype_tag(track_item) track_item.removeTag(tag) # create new tag with updated data set_trackitem_openpype_tag(track_item, data) print("asset was changed in clip: {}".format(ti_name)) def set_track_color(track_item, color): track_item.source().binItem().setColor(color) def check_inventory_versions(track_items=None): """ Actual version color identifier of Loaded containers Check all track items and filter only Loader nodes for its version. It will get all versions from database and check if the node is having actual version. If not then it will color it to red. """ from . import parse_container track_items = track_items or get_track_items() # presets clip_color_last = "green" clip_color = "red" containers = [] # Find all containers and collect it's node and representation ids for track_item in track_items: container = parse_container(track_item) if container: containers.append(container) # Skip if nothing was found if not containers: return project_name = get_current_project_name() filter_result = filter_containers(containers, project_name) for container in filter_result.latest: set_track_color(container["_item"], clip_color_last) for container in filter_result.outdated: set_track_color(container["_item"], clip_color) def selection_changed_timeline(event): """Callback on timeline to check if asset in data is the same as clip name. Args: event (hiero.core.Event): timeline event """ timeline_editor = event.sender selection = timeline_editor.selection() track_items = get_track_items( selection=selection, track_type="video", check_enabled=True, check_locked=True, check_tagged=True ) # run checking function sync_clip_name_to_data_asset(track_items) def before_project_save(event): track_items = get_track_items( track_type="video", check_enabled=True, check_locked=True, check_tagged=True ) # run checking function sync_clip_name_to_data_asset(track_items) # also mark old versions of loaded containers check_inventory_versions(track_items) def get_main_window(): """Acquire Nuke's main window""" if _CTX.parent_gui is None: top_widgets = QtWidgets.QApplication.topLevelWidgets() name = "Foundry::UI::DockMainWindow" main_window = next(widget for widget in top_widgets if widget.inherits("QMainWindow") and widget.metaObject().className() == name) _CTX.parent_gui = main_window return _CTX.parent_gui ================================================ FILE: openpype/hosts/hiero/api/menu.py ================================================ import os import sys import hiero.core from hiero.ui import findMenuAction from qtpy import QtGui from openpype.lib import Logger from openpype.tools.utils import host_tools from openpype.settings import get_project_settings from openpype.pipeline import ( get_current_project_name, get_current_asset_name, get_current_task_name ) from . import tags log = Logger.get_logger(__name__) self = sys.modules[__name__] self._change_context_menu = None def get_context_label(): return "{}, {}".format( get_current_asset_name(), get_current_task_name() ) def update_menu_task_label(): """Update the task label in Avalon menu to current session""" object_name = self._change_context_menu found_menu = findMenuAction(object_name) if not found_menu: log.warning("Can't find menuItem: {}".format(object_name)) return label = get_context_label() menu = found_menu.menu() self._change_context_menu = label menu.setTitle(label) def menu_install(): """ Installing menu into Hiero """ from . import ( publish, launch_workfiles_app, reload_config, apply_colorspace_project, apply_colorspace_clips ) from .lib import get_main_window main_window = get_main_window() # here is the best place to add menu menu_name = os.environ['AVALON_LABEL'] context_label = get_context_label() self._change_context_menu = context_label try: check_made_menu = findMenuAction(menu_name) except Exception: check_made_menu = None if not check_made_menu: # Grab Hiero's MenuBar menu = hiero.ui.menuBar().addMenu(menu_name) else: menu = check_made_menu.menu() context_label_action = menu.addAction(context_label) context_label_action.setEnabled(False) menu.addSeparator() workfiles_action = menu.addAction("Work Files...") workfiles_action.setIcon(QtGui.QIcon("icons:Position.png")) workfiles_action.triggered.connect(launch_workfiles_app) default_tags_action = menu.addAction("Create Default Tags") default_tags_action.setIcon(QtGui.QIcon("icons:Position.png")) default_tags_action.triggered.connect(tags.add_tags_to_workfile) menu.addSeparator() creator_action = menu.addAction("Create...") creator_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png")) creator_action.triggered.connect( lambda: host_tools.show_creator(parent=main_window) ) publish_action = menu.addAction("Publish...") publish_action.setIcon(QtGui.QIcon("icons:Output.png")) publish_action.triggered.connect( lambda *args: publish(hiero.ui.mainWindow()) ) loader_action = menu.addAction("Load...") loader_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png")) loader_action.triggered.connect( lambda: host_tools.show_loader(parent=main_window) ) sceneinventory_action = menu.addAction("Manage...") sceneinventory_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png")) sceneinventory_action.triggered.connect( lambda: host_tools.show_scene_inventory(parent=main_window) ) library_action = menu.addAction("Library...") library_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png")) library_action.triggered.connect( lambda: host_tools.show_library_loader(parent=main_window) ) if os.getenv("OPENPYPE_DEVELOP"): menu.addSeparator() reload_action = menu.addAction("Reload pipeline") reload_action.setIcon(QtGui.QIcon("icons:ColorAdd.png")) reload_action.triggered.connect(reload_config) menu.addSeparator() apply_colorspace_p_action = menu.addAction("Apply Colorspace Project") apply_colorspace_p_action.setIcon(QtGui.QIcon("icons:ColorAdd.png")) apply_colorspace_p_action.triggered.connect(apply_colorspace_project) apply_colorspace_c_action = menu.addAction("Apply Colorspace Clips") apply_colorspace_c_action.setIcon(QtGui.QIcon("icons:ColorAdd.png")) apply_colorspace_c_action.triggered.connect(apply_colorspace_clips) menu.addSeparator() exeprimental_action = menu.addAction("Experimental tools...") exeprimental_action.triggered.connect( lambda: host_tools.show_experimental_tools_dialog(parent=main_window) ) def add_scripts_menu(): try: from . import launchforhiero except ImportError: log.warning( "Skipping studio.menu install, because " "'scriptsmenu' module seems unavailable." ) return # load configuration of custom menu project_settings = get_project_settings(get_current_project_name()) config = project_settings["hiero"]["scriptsmenu"]["definition"] _menu = project_settings["hiero"]["scriptsmenu"]["name"] if not config: log.warning("Skipping studio menu, no definition found.") return # run the launcher for Hiero menu studio_menu = launchforhiero.main(title=_menu.title()) # apply configuration studio_menu.build_from_configuration(studio_menu, config) ================================================ FILE: openpype/hosts/hiero/api/otio/__init__.py ================================================ ================================================ FILE: openpype/hosts/hiero/api/otio/hiero_export.py ================================================ """ compatibility OpenTimelineIO 0.12.0 and newer """ import os import re import ast import opentimelineio as otio from . import utils import hiero.core import hiero.ui TRACK_TYPE_MAP = { hiero.core.VideoTrack: otio.schema.TrackKind.Video, hiero.core.AudioTrack: otio.schema.TrackKind.Audio } MARKER_COLOR_MAP = { "magenta": otio.schema.MarkerColor.MAGENTA, "red": otio.schema.MarkerColor.RED, "yellow": otio.schema.MarkerColor.YELLOW, "green": otio.schema.MarkerColor.GREEN, "cyan": otio.schema.MarkerColor.CYAN, "blue": otio.schema.MarkerColor.BLUE, } class CTX: project_fps = None timeline = None include_tags = True def flatten(list_): for item_ in list_: if isinstance(item_, (list, tuple)): for sub_item in flatten(item_): yield sub_item else: yield item_ def create_otio_rational_time(frame, fps): return otio.opentime.RationalTime( float(frame), float(fps) ) def create_otio_time_range(start_frame, frame_duration, fps): return otio.opentime.TimeRange( start_time=create_otio_rational_time(start_frame, fps), duration=create_otio_rational_time(frame_duration, fps) ) def _get_metadata(item): if hasattr(item, 'metadata'): return {key: value for key, value in dict(item.metadata()).items()} return {} def create_time_effects(otio_clip, track_item): # get all subtrack items subTrackItems = flatten(track_item.parent().subTrackItems()) speed = track_item.playbackSpeed() otio_effect = None # retime on track item if speed != 1.: # make effect otio_effect = otio.schema.LinearTimeWarp() otio_effect.name = "Speed" otio_effect.time_scalar = speed # freeze frame effect if speed == 0.: otio_effect = otio.schema.FreezeFrame() otio_effect.name = "FreezeFrame" if otio_effect: # add otio effect to clip effects otio_clip.effects.append(otio_effect) # loop through and get all Timewarps for effect in subTrackItems: if ((track_item not in effect.linkedItems()) and (len(effect.linkedItems()) > 0)): continue # avoid all effect which are not TimeWarp and disabled if "TimeWarp" not in effect.name(): continue if not effect.isEnabled(): continue node = effect.node() name = node["name"].value() # solve effect class as effect name _name = effect.name() if "_" in _name: effect_name = re.sub(r"(?:_)[_0-9]+", "", _name) # more numbers else: effect_name = re.sub(r"\d+", "", _name) # one number metadata = {} # add knob to metadata for knob in ["lookup", "length"]: value = node[knob].value() animated = node[knob].isAnimated() if animated: value = [ ((node[knob].getValueAt(i)) - i) for i in range( track_item.timelineIn(), track_item.timelineOut() + 1) ] metadata[knob] = value # make effect otio_effect = otio.schema.TimeEffect() otio_effect.name = name otio_effect.effect_name = effect_name otio_effect.metadata.update(metadata) # add otio effect to clip effects otio_clip.effects.append(otio_effect) def create_otio_reference(clip): metadata = _get_metadata(clip) media_source = clip.mediaSource() # get file info for path and start frame file_info = media_source.fileinfos().pop() frame_start = file_info.startFrame() path = file_info.filename() # get padding and other file infos padding = media_source.filenamePadding() file_head = media_source.filenameHead() is_sequence = not media_source.singleFile() frame_duration = media_source.duration() fps = utils.get_rate(clip) or CTX.project_fps extension = os.path.splitext(path)[-1] if is_sequence: metadata.update({ "isSequence": True, "padding": padding }) # add resolution metadata metadata.update({ "openpype.source.colourtransform": clip.sourceMediaColourTransform(), "openpype.source.width": int(media_source.width()), "openpype.source.height": int(media_source.height()), "openpype.source.pixelAspect": float(media_source.pixelAspect()) }) otio_ex_ref_item = None if is_sequence: # if it is file sequence try to create `ImageSequenceReference` # the OTIO might not be compatible so return nothing and do it old way try: dirname = os.path.dirname(path) otio_ex_ref_item = otio.schema.ImageSequenceReference( target_url_base=dirname + os.sep, name_prefix=file_head, name_suffix=extension, start_frame=frame_start, frame_zero_padding=padding, rate=fps, available_range=create_otio_time_range( frame_start, frame_duration, fps ) ) except AttributeError: pass if not otio_ex_ref_item: reformat_path = utils.get_reformated_path(path, padded=False) # in case old OTIO or video file create `ExternalReference` otio_ex_ref_item = otio.schema.ExternalReference( target_url=reformat_path, available_range=create_otio_time_range( frame_start, frame_duration, fps ) ) # add metadata to otio item add_otio_metadata(otio_ex_ref_item, media_source, **metadata) return otio_ex_ref_item def get_marker_color(tag): icon = tag.icon() pat = r'icons:Tag(?P\w+)\.\w+' res = re.search(pat, icon) if res: color = res.groupdict().get('color') if color.lower() in MARKER_COLOR_MAP: return MARKER_COLOR_MAP[color.lower()] return otio.schema.MarkerColor.RED def create_otio_markers(otio_item, item): for tag in item.tags(): if not tag.visible(): continue if tag.name() == 'Copy': # Hiero adds this tag to a lot of clips continue frame_rate = utils.get_rate(item) or CTX.project_fps marked_range = otio.opentime.TimeRange( start_time=otio.opentime.RationalTime( tag.inTime(), frame_rate ), duration=otio.opentime.RationalTime( int(tag.metadata().dict().get('tag.length', '0')), frame_rate ) ) # add tag metadata but remove "tag." string metadata = {} for key, value in tag.metadata().dict().items(): _key = key.replace("tag.", "") try: # capture exceptions which are related to strings only _value = ast.literal_eval(value) except (ValueError, SyntaxError): _value = value metadata.update({_key: _value}) # Store the source item for future import assignment metadata['hiero_source_type'] = item.__class__.__name__ marker = otio.schema.Marker( name=tag.name(), color=get_marker_color(tag), marked_range=marked_range, metadata=metadata ) otio_item.markers.append(marker) def create_otio_clip(track_item): clip = track_item.source() speed = track_item.playbackSpeed() # flip if speed is in minus source_in = track_item.sourceIn() if speed > 0 else track_item.sourceOut() duration = int(track_item.duration()) fps = utils.get_rate(track_item) or CTX.project_fps name = track_item.name() media_reference = create_otio_reference(clip) source_range = create_otio_time_range( int(source_in), int(duration), fps ) otio_clip = otio.schema.Clip( name=name, source_range=source_range, media_reference=media_reference ) # Add tags as markers if CTX.include_tags: create_otio_markers(otio_clip, track_item) create_otio_markers(otio_clip, track_item.source()) # only if video if not clip.mediaSource().hasAudio(): # Add effects to clips create_time_effects(otio_clip, track_item) return otio_clip def create_otio_gap(gap_start, clip_start, tl_start_frame, fps): return otio.schema.Gap( source_range=create_otio_time_range( gap_start, (clip_start - tl_start_frame) - gap_start, fps ) ) def _create_otio_timeline(): project = CTX.timeline.project() metadata = _get_metadata(CTX.timeline) metadata.update({ "openpype.timeline.width": int(CTX.timeline.format().width()), "openpype.timeline.height": int(CTX.timeline.format().height()), "openpype.timeline.pixelAspect": int(CTX.timeline.format().pixelAspect()), # noqa "openpype.project.useOCIOEnvironmentOverride": project.useOCIOEnvironmentOverride(), # noqa "openpype.project.lutSetting16Bit": project.lutSetting16Bit(), "openpype.project.lutSetting8Bit": project.lutSetting8Bit(), "openpype.project.lutSettingFloat": project.lutSettingFloat(), "openpype.project.lutSettingLog": project.lutSettingLog(), "openpype.project.lutSettingViewer": project.lutSettingViewer(), "openpype.project.lutSettingWorkingSpace": project.lutSettingWorkingSpace(), # noqa "openpype.project.lutUseOCIOForExport": project.lutUseOCIOForExport(), "openpype.project.ocioConfigName": project.ocioConfigName(), "openpype.project.ocioConfigPath": project.ocioConfigPath() }) start_time = create_otio_rational_time( CTX.timeline.timecodeStart(), CTX.project_fps) return otio.schema.Timeline( name=CTX.timeline.name(), global_start_time=start_time, metadata=metadata ) def create_otio_track(track_type, track_name): return otio.schema.Track( name=track_name, kind=TRACK_TYPE_MAP[track_type] ) def add_otio_gap(track_item, otio_track, prev_out): gap_length = track_item.timelineIn() - prev_out if prev_out != 0: gap_length -= 1 gap = otio.opentime.TimeRange( duration=otio.opentime.RationalTime( gap_length, CTX.project_fps ) ) otio_gap = otio.schema.Gap(source_range=gap) otio_track.append(otio_gap) def add_otio_metadata(otio_item, media_source, **kwargs): metadata = _get_metadata(media_source) # add additional metadata from kwargs if kwargs: metadata.update(kwargs) # add metadata to otio item metadata for key, value in metadata.items(): otio_item.metadata.update({key: value}) def create_otio_timeline(): def set_prev_item(itemindex, track_item): # Add Gap if needed if itemindex == 0: # if it is first track item at track then add # it to previous item return track_item else: # get previous item return track_item.parent().items()[itemindex - 1] # get current timeline CTX.timeline = hiero.ui.activeSequence() CTX.project_fps = CTX.timeline.framerate().toFloat() # convert timeline to otio otio_timeline = _create_otio_timeline() # loop all defined track types for track in CTX.timeline.items(): # skip if track is disabled if not track.isEnabled(): continue # convert track to otio otio_track = create_otio_track( type(track), track.name()) for itemindex, track_item in enumerate(track): # Add Gap if needed if itemindex == 0: # if it is first track item at track then add # it to previous item prev_item = track_item else: # get previous item prev_item = track_item.parent().items()[itemindex - 1] # calculate clip frame range difference from each other clip_diff = track_item.timelineIn() - prev_item.timelineOut() # add gap if first track item is not starting # at first timeline frame if itemindex == 0 and track_item.timelineIn() > 0: add_otio_gap(track_item, otio_track, 0) # or add gap if following track items are having # frame range differences from each other elif itemindex and clip_diff != 1: add_otio_gap(track_item, otio_track, prev_item.timelineOut()) # create otio clip and add it to track otio_clip = create_otio_clip(track_item) otio_track.append(otio_clip) # Add tags as markers if CTX.include_tags: create_otio_markers(otio_track, track) # add track to otio timeline otio_timeline.tracks.append(otio_track) return otio_timeline def write_to_file(otio_timeline, path): otio.adapters.write_to_file(otio_timeline, path) ================================================ FILE: openpype/hosts/hiero/api/otio/hiero_import.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- __author__ = "Daniel Flehner Heen" __credits__ = ["Jakub Jezek", "Daniel Flehner Heen"] import os import hiero.core import hiero.ui import PySide2.QtWidgets as qw try: from urllib import unquote except ImportError: from urllib.parse import unquote # lint:ok import opentimelineio as otio _otio_old = False def inform(messages): if isinstance(messages, type('')): messages = [messages] qw.QMessageBox.information( hiero.ui.mainWindow(), 'OTIO Import', '\n'.join(messages), qw.QMessageBox.StandardButton.Ok ) def get_transition_type(otio_item, otio_track): _in, _out = otio_track.neighbors_of(otio_item) if isinstance(_in, otio.schema.Gap): _in = None if isinstance(_out, otio.schema.Gap): _out = None if _in and _out: return 'dissolve' elif _in and not _out: return 'fade_out' elif not _in and _out: return 'fade_in' else: return 'unknown' def find_trackitem(otio_clip, hiero_track): for item in hiero_track.items(): if item.timelineIn() == otio_clip.range_in_parent().start_time.value: if item.name() == otio_clip.name: return item return None def get_neighboring_trackitems(otio_item, otio_track, hiero_track): _in, _out = otio_track.neighbors_of(otio_item) trackitem_in = None trackitem_out = None if _in: trackitem_in = find_trackitem(_in, hiero_track) if _out: trackitem_out = find_trackitem(_out, hiero_track) return trackitem_in, trackitem_out def apply_transition(otio_track, otio_item, track): warning = None # Figure out type of transition transition_type = get_transition_type(otio_item, otio_track) # Figure out track kind for getattr below kind = '' if isinstance(track, hiero.core.AudioTrack): kind = 'Audio' # Gather TrackItems involved in trasition item_in, item_out = get_neighboring_trackitems( otio_item, otio_track, track ) # Create transition object if transition_type == 'dissolve': transition_func = getattr( hiero.core.Transition, 'create{kind}DissolveTransition'.format(kind=kind) ) try: transition = transition_func( item_in, item_out, otio_item.in_offset.value, otio_item.out_offset.value ) # Catch error raised if transition is bigger than TrackItem source except RuntimeError as e: transition = None warning = ( "Unable to apply transition \"{t.name}\": {e} " "Ignoring the transition.").format(t=otio_item, e=str(e)) elif transition_type == 'fade_in': transition_func = getattr( hiero.core.Transition, 'create{kind}FadeInTransition'.format(kind=kind) ) # Warn user if part of fade is outside of clip if otio_item.in_offset.value: warning = \ 'Fist half of transition "{t.name}" is outside of clip and ' \ 'not valid in Hiero. Only applied second half.' \ .format(t=otio_item) transition = transition_func( item_out, otio_item.out_offset.value ) elif transition_type == 'fade_out': transition_func = getattr( hiero.core.Transition, 'create{kind}FadeOutTransition'.format(kind=kind) ) transition = transition_func( item_in, otio_item.in_offset.value ) # Warn user if part of fade is outside of clip if otio_item.out_offset.value: warning = \ 'Second half of transition "{t.name}" is outside of clip ' \ 'and not valid in Hiero. Only applied first half.' \ .format(t=otio_item) else: # Unknown transition return # Apply transition to track if transition: track.addTransition(transition) # Inform user about missing or adjusted transitions return warning def prep_url(url_in): url = unquote(url_in) if url.startswith('file://localhost/'): return url url = 'file://localhost{sep}{url}'.format( sep=url.startswith(os.sep) and '' or os.sep, url=url.startswith(os.sep) and url[1:] or url ) return url def create_offline_mediasource(otio_clip, path=None): global _otio_old hiero_rate = hiero.core.TimeBase( otio_clip.source_range.start_time.rate ) try: legal_media_refs = ( otio.schema.ExternalReference, otio.schema.ImageSequenceReference ) except AttributeError: _otio_old = True legal_media_refs = ( otio.schema.ExternalReference ) if isinstance(otio_clip.media_reference, legal_media_refs): source_range = otio_clip.available_range() else: source_range = otio_clip.source_range if path is None: path = otio_clip.name media = hiero.core.MediaSource.createOfflineVideoMediaSource( prep_url(path), source_range.start_time.value, source_range.duration.value, hiero_rate, source_range.start_time.value ) return media def load_otio(otio_file, project=None, sequence=None): otio_timeline = otio.adapters.read_from_file(otio_file) build_sequence(otio_timeline, project=project, sequence=sequence) marker_color_map = { "PINK": "Magenta", "RED": "Red", "ORANGE": "Yellow", "YELLOW": "Yellow", "GREEN": "Green", "CYAN": "Cyan", "BLUE": "Blue", "PURPLE": "Magenta", "MAGENTA": "Magenta", "BLACK": "Blue", "WHITE": "Green" } def get_tag(tagname, tagsbin): for tag in tagsbin.items(): if tag.name() == tagname: return tag if isinstance(tag, hiero.core.Bin): tag = get_tag(tagname, tag) if tag is not None: return tag return None def add_metadata(metadata, hiero_item): for key, value in metadata.get('Hiero', dict()).items(): if key == 'source_type': # Only used internally to reassign tag to correct Hiero item continue if isinstance(value, dict): add_metadata(value, hiero_item) continue if value is not None: if not key.startswith('tag.'): key = 'tag.' + key hiero_item.metadata().setValue(key, str(value)) def add_markers(otio_item, hiero_item, tagsbin): if isinstance(otio_item, (otio.schema.Stack, otio.schema.Clip)): markers = otio_item.markers elif isinstance(otio_item, otio.schema.Timeline): markers = otio_item.tracks.markers else: markers = [] for marker in markers: meta = marker.metadata.get('Hiero', dict()) if 'source_type' in meta: if hiero_item.__class__.__name__ != meta.get('source_type'): continue marker_color = marker.color _tag = get_tag(marker.name, tagsbin) if _tag is None: _tag = get_tag(marker_color_map[marker_color], tagsbin) if _tag is None: _tag = hiero.core.Tag(marker_color_map[marker.color]) start = marker.marked_range.start_time.value end = ( marker.marked_range.start_time.value + marker.marked_range.duration.value ) if hasattr(hiero_item, 'addTagToRange'): tag = hiero_item.addTagToRange(_tag, start, end) else: tag = hiero_item.addTag(_tag) tag.setName(marker.name or marker_color_map[marker_color]) # tag.setNote(meta.get('tag.note', '')) # Add metadata add_metadata(marker.metadata, tag) def create_track(otio_track, tracknum, track_kind): if track_kind is None and hasattr(otio_track, 'kind'): track_kind = otio_track.kind # Create a Track if track_kind == otio.schema.TrackKind.Video: track = hiero.core.VideoTrack( otio_track.name or 'Video{n}'.format(n=tracknum) ) else: track = hiero.core.AudioTrack( otio_track.name or 'Audio{n}'.format(n=tracknum) ) return track def create_clip(otio_clip, tagsbin, sequencebin): # Create MediaSource url = None media = None otio_media = otio_clip.media_reference if isinstance(otio_media, otio.schema.ExternalReference): url = prep_url(otio_media.target_url) media = hiero.core.MediaSource(url) elif not _otio_old: if isinstance(otio_media, otio.schema.ImageSequenceReference): url = prep_url(otio_media.abstract_target_url('#')) media = hiero.core.MediaSource(url) if media is None or media.isOffline(): media = create_offline_mediasource(otio_clip, url) # Reuse previous clip if possible clip = None for item in sequencebin.clips(): if item.activeItem().mediaSource() == media: clip = item.activeItem() break if not clip: # Create new Clip clip = hiero.core.Clip(media) # Add Clip to a Bin sequencebin.addItem(hiero.core.BinItem(clip)) # Add markers add_markers(otio_clip, clip, tagsbin) return clip def create_trackitem(playhead, track, otio_clip, clip): source_range = otio_clip.source_range trackitem = track.createTrackItem(otio_clip.name) trackitem.setPlaybackSpeed(source_range.start_time.rate) trackitem.setSource(clip) time_scalar = 1. # Check for speed effects and adjust playback speed accordingly for effect in otio_clip.effects: if isinstance(effect, otio.schema.LinearTimeWarp): time_scalar = effect.time_scalar # Only reverse effect can be applied here if abs(time_scalar) == 1.: trackitem.setPlaybackSpeed( trackitem.playbackSpeed() * time_scalar) elif isinstance(effect, otio.schema.FreezeFrame): # For freeze frame, playback speed must be set after range time_scalar = 0. # If reverse playback speed swap source in and out if trackitem.playbackSpeed() < 0: source_out = source_range.start_time.value source_in = source_range.end_time_inclusive().value timeline_in = playhead + source_out timeline_out = ( timeline_in + source_range.duration.value ) - 1 else: # Normal playback speed source_in = source_range.start_time.value source_out = source_range.end_time_inclusive().value timeline_in = playhead timeline_out = ( timeline_in + source_range.duration.value ) - 1 # Set source and timeline in/out points trackitem.setTimes( timeline_in, timeline_out, source_in, source_out ) # Apply playback speed for freeze frames if abs(time_scalar) != 1.: trackitem.setPlaybackSpeed(trackitem.playbackSpeed() * time_scalar) # Link audio to video when possible if isinstance(track, hiero.core.AudioTrack): for other in track.parent().trackItemsAt(playhead): if other.source() == clip: trackitem.link(other) return trackitem def build_sequence( otio_timeline, project=None, sequence=None, track_kind=None): if project is None: if sequence: project = sequence.project() else: # Per version 12.1v2 there is no way of getting active project project = hiero.core.projects(hiero.core.Project.kUserProjects)[-1] projectbin = project.clipsBin() if not sequence: # Create a Sequence sequence = hiero.core.Sequence(otio_timeline.name or 'OTIOSequence') # Set sequence settings from otio timeline if available if ( hasattr(otio_timeline, 'global_start_time') and otio_timeline.global_start_time ): start_time = otio_timeline.global_start_time sequence.setFramerate(start_time.rate) sequence.setTimecodeStart(start_time.value) # Create a Bin to hold clips projectbin.addItem(hiero.core.BinItem(sequence)) sequencebin = hiero.core.Bin(sequence.name()) projectbin.addItem(sequencebin) else: sequencebin = projectbin # Get tagsBin tagsbin = hiero.core.project("Tag Presets").tagsBin() # Add timeline markers add_markers(otio_timeline, sequence, tagsbin) if isinstance(otio_timeline, otio.schema.Timeline): tracks = otio_timeline.tracks else: tracks = [otio_timeline] for tracknum, otio_track in enumerate(tracks): playhead = 0 _transitions = [] # Add track to sequence track = create_track(otio_track, tracknum, track_kind) sequence.addTrack(track) # iterate over items in track for _itemnum, otio_clip in enumerate(otio_track): if isinstance(otio_clip, (otio.schema.Track, otio.schema.Stack)): inform('Nested sequences/tracks are created separately.') # Add gap where the nested sequence would have been playhead += otio_clip.source_range.duration.value # Process nested sequence build_sequence( otio_clip, project=project, track_kind=otio_track.kind ) elif isinstance(otio_clip, otio.schema.Clip): # Create a Clip clip = create_clip(otio_clip, tagsbin, sequencebin) # Create TrackItem trackitem = create_trackitem( playhead, track, otio_clip, clip ) # Add markers add_markers(otio_clip, trackitem, tagsbin) # Add trackitem to track track.addTrackItem(trackitem) # Update playhead playhead = trackitem.timelineOut() + 1 elif isinstance(otio_clip, otio.schema.Transition): # Store transitions for when all clips in the track are created _transitions.append((otio_track, otio_clip)) elif isinstance(otio_clip, otio.schema.Gap): # Hiero has no fillers, slugs or blanks at the moment playhead += otio_clip.source_range.duration.value # Apply transitions we stored earlier now that all clips are present warnings = [] for otio_track, otio_item in _transitions: # Catch warnings form transitions in case # of unsupported transitions warning = apply_transition(otio_track, otio_item, track) if warning: warnings.append(warning) if warnings: inform(warnings) ================================================ FILE: openpype/hosts/hiero/api/otio/utils.py ================================================ import re import opentimelineio as otio def timecode_to_frames(timecode, framerate): rt = otio.opentime.from_timecode(timecode, 24) return int(otio.opentime.to_frames(rt)) def frames_to_timecode(frames, framerate): rt = otio.opentime.from_frames(frames, framerate) return otio.opentime.to_timecode(rt) def frames_to_secons(frames, framerate): rt = otio.opentime.from_frames(frames, framerate) return otio.opentime.to_seconds(rt) def get_reformated_path(path, padded=True): """ Return fixed python expression path Args: path (str): path url or simple file name Returns: type: string with reformated path Example: get_reformated_path("plate.[0001-1008].exr") > plate.%04d.exr """ if "%" in path: padding_pattern = r"(\d+)" padding = int(re.findall(padding_pattern, path).pop()) num_pattern = r"(%\d+d)" if padded: path = re.sub(num_pattern, "%0{}d".format(padding), path) else: path = re.sub(num_pattern, "%d", path) return path def get_padding_from_path(path): """ Return padding number from DaVinci Resolve sequence path style Args: path (str): path url or simple file name Returns: int: padding number Example: get_padding_from_path("plate.[0001-1008].exr") > 4 """ padding_pattern = "(\\d+)(?=-)" if "[" in path: return len(re.findall(padding_pattern, path).pop()) return None def get_rate(item): if not hasattr(item, 'framerate'): return None num, den = item.framerate().toRational() try: rate = float(num) / float(den) except ZeroDivisionError: return None if rate.is_integer(): return rate return round(rate, 4) ================================================ FILE: openpype/hosts/hiero/api/pipeline.py ================================================ """ Basic avalon integration """ from copy import deepcopy import os import contextlib from collections import OrderedDict from pyblish import api as pyblish from openpype.lib import Logger from openpype.pipeline import ( schema, register_creator_plugin_path, register_loader_plugin_path, deregister_creator_plugin_path, deregister_loader_plugin_path, AVALON_CONTAINER_ID, ) from openpype.tools.utils import host_tools from . import lib, menu, events import hiero log = Logger.get_logger(__name__) # plugin paths API_DIR = os.path.dirname(os.path.abspath(__file__)) HOST_DIR = os.path.dirname(API_DIR) PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish").replace("\\", "/") LOAD_PATH = os.path.join(PLUGINS_DIR, "load").replace("\\", "/") CREATE_PATH = os.path.join(PLUGINS_DIR, "create").replace("\\", "/") AVALON_CONTAINERS = ":AVALON_CONTAINERS" def install(): """Installing Hiero integration.""" # adding all events events.register_events() log.info("Registering Hiero plug-ins..") pyblish.register_host("hiero") pyblish.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) # register callback for switching publishable pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) # install menu menu.menu_install() menu.add_scripts_menu() # register hiero events events.register_hiero_events() def uninstall(): """ Uninstalling Hiero integration for avalon """ log.info("Deregistering Hiero plug-ins..") pyblish.deregister_host("hiero") pyblish.deregister_plugin_path(PUBLISH_PATH) deregister_loader_plugin_path(LOAD_PATH) deregister_creator_plugin_path(CREATE_PATH) # register callback for switching publishable pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) def containerise(track_item, name, namespace, context, loader=None, data=None): """Bundle Hiero's object into an assembly and imprint it with metadata Containerisation enables a tracking of version, author and origin for loaded assets. Arguments: track_item (hiero.core.TrackItem): object to imprint as container name (str): Name of resulting assembly namespace (str): Namespace under which to host container context (dict): Asset information loader (str, optional): Name of node used to produce this container. Returns: track_item (hiero.core.TrackItem): containerised object """ data_imprint = OrderedDict({ "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": str(name), "namespace": str(namespace), "loader": str(loader), "representation": str(context["representation"]["_id"]), }) if data: for k, v in data.items(): data_imprint.update({k: v}) log.debug("_ data_imprint: {}".format(data_imprint)) lib.set_trackitem_openpype_tag(track_item, data_imprint) return track_item def ls(): """List available containers. This function is used by the Container Manager in Nuke. You'll need to implement a for-loop that then *yields* one Container at a time. See the `container.json` schema for details on how it should look, and the Maya equivalent, which is in `avalon.maya.pipeline` """ # get all track items from current timeline all_items = lib.get_track_items() # append all video tracks for track in lib.get_current_sequence(): if type(track) != hiero.core.VideoTrack: continue all_items.append(track) for item in all_items: container_data = parse_container(item) if isinstance(container_data, list): for _c in container_data: yield _c elif container_data: yield container_data def parse_container(item, validate=True): """Return container data from track_item's pype tag. Args: item (hiero.core.TrackItem or hiero.core.VideoTrack): A containerised track item. validate (bool)[optional]: validating with avalon scheme Returns: dict: The container schema data for input containerized track item. """ def data_to_container(item, data): if ( not data or data.get("id") != "pyblish.avalon.container" ): return if validate and data and data.get("schema"): schema.validate(data) if not isinstance(data, dict): return # If not all required data return the empty container required = ['schema', 'id', 'name', 'namespace', 'loader', 'representation'] if any(key not in data for key in required): return container = {key: data[key] for key in required} container["objectName"] = item.name() # Store reference to the node object container["_item"] = item return container # convert tag metadata to normal keys names if type(item) == hiero.core.VideoTrack: return_list = [] _data = lib.get_track_openpype_data(item) if not _data: return # convert the data to list and validate them for _, obj_data in _data.items(): container = data_to_container(item, obj_data) return_list.append(container) return return_list else: _data = lib.get_trackitem_openpype_data(item) return data_to_container(item, _data) def _update_container_data(container, data): for key in container: try: container[key] = data[key] except KeyError: pass return container def update_container(item, data=None): """Update container data to input track_item or track's openpype tag. Args: item (hiero.core.TrackItem or hiero.core.VideoTrack): A containerised track item. data (dict)[optional]: dictionery with data to be updated Returns: bool: True if container was updated correctly """ data = data or {} data = deepcopy(data) if type(item) == hiero.core.VideoTrack: # form object data for test object_name = data["objectName"] # get all available containers containers = lib.get_track_openpype_data(item) container = lib.get_track_openpype_data(item, object_name) containers = deepcopy(containers) container = deepcopy(container) # update data in container updated_container = _update_container_data(container, data) # merge updated container back to containers containers.update({object_name: updated_container}) return bool(lib.set_track_openpype_tag(item, containers)) else: container = lib.get_trackitem_openpype_data(item) updated_container = _update_container_data(container, data) log.info("Updating container: `{}`".format(item.name())) return bool(lib.set_trackitem_openpype_tag(item, updated_container)) def launch_workfiles_app(*args): ''' Wrapping function for workfiles launcher ''' from .lib import get_main_window main_window = get_main_window() # show workfile gui host_tools.show_workfiles(parent=main_window) def publish(parent): """Shorthand to publish from within host""" return host_tools.show_publish(parent) @contextlib.contextmanager def maintained_selection(): """Maintain selection during context Example: >>> with maintained_selection(): ... for track_item in track_items: ... < do some stuff > """ from .lib import ( set_selected_track_items, get_selected_track_items ) previous_selection = get_selected_track_items() reset_selection() try: # do the operation yield finally: reset_selection() set_selected_track_items(previous_selection) def reset_selection(): """Deselect all selected nodes """ from .lib import set_selected_track_items set_selected_track_items([]) def reload_config(): """Attempt to reload pipeline at run-time. CAUTION: This is primarily for development and debugging purposes. """ import importlib for module in ( "openpype.hosts.hiero.lib", "openpype.hosts.hiero.menu", "openpype.hosts.hiero.tags" ): log.info("Reloading module: {}...".format(module)) try: module = importlib.import_module(module) import imp imp.reload(module) except Exception as e: log.warning("Cannot reload module: {}".format(e)) importlib.reload(module) def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle node passthrough states on instance toggles.""" log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( instance, old_value, new_value)) from openpype.hosts.hiero.api import ( get_trackitem_openpype_tag, set_publish_attribute ) # Whether instances should be passthrough based on new value track_item = instance.data["item"] tag = get_trackitem_openpype_tag(track_item) set_publish_attribute(tag, new_value) ================================================ FILE: openpype/hosts/hiero/api/plugin.py ================================================ import os from pprint import pformat import re from copy import deepcopy import hiero from qtpy import QtWidgets, QtCore import qargparse from openpype.settings import get_current_project_settings from openpype.lib import Logger from openpype.pipeline import LoaderPlugin, LegacyCreator from openpype.pipeline.load import get_representation_path_from_context from . import lib log = Logger.get_logger(__name__) def load_stylesheet(): path = os.path.join(os.path.dirname(__file__), "style.css") if not os.path.exists(path): log.warning("Unable to load stylesheet, file not found in resources") return "" with open(path, "r") as file_stream: stylesheet = file_stream.read() return stylesheet class CreatorWidget(QtWidgets.QDialog): # output items items = {} def __init__(self, name, info, ui_inputs, parent=None): super(CreatorWidget, self).__init__(parent) self.setObjectName(name) self.setWindowFlags( QtCore.Qt.Window | QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint | QtCore.Qt.WindowCloseButtonHint | QtCore.Qt.WindowStaysOnTopHint ) self.setWindowTitle(name or "Pype Creator Input") self.resize(500, 700) # Where inputs and labels are set self.content_widget = [QtWidgets.QWidget(self)] top_layout = QtWidgets.QFormLayout(self.content_widget[0]) top_layout.setObjectName("ContentLayout") top_layout.addWidget(Spacer(5, self)) # first add widget tag line top_layout.addWidget(QtWidgets.QLabel(info)) # main dynamic layout self.scroll_area = QtWidgets.QScrollArea(self, widgetResizable=True) self.scroll_area.setVerticalScrollBarPolicy( QtCore.Qt.ScrollBarAsNeeded) self.scroll_area.setVerticalScrollBarPolicy( QtCore.Qt.ScrollBarAlwaysOn) self.scroll_area.setHorizontalScrollBarPolicy( QtCore.Qt.ScrollBarAlwaysOff) self.scroll_area.setWidgetResizable(True) self.content_widget.append(self.scroll_area) scroll_widget = QtWidgets.QWidget(self) in_scroll_area = QtWidgets.QVBoxLayout(scroll_widget) self.content_layout = [in_scroll_area] # add preset data into input widget layout self.items = self.populate_widgets(ui_inputs) self.scroll_area.setWidget(scroll_widget) # Confirmation buttons btns_widget = QtWidgets.QWidget(self) btns_layout = QtWidgets.QHBoxLayout(btns_widget) cancel_btn = QtWidgets.QPushButton("Cancel") btns_layout.addWidget(cancel_btn) ok_btn = QtWidgets.QPushButton("Ok") btns_layout.addWidget(ok_btn) # Main layout of the dialog main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(10, 10, 10, 10) main_layout.setSpacing(0) # adding content widget for w in self.content_widget: main_layout.addWidget(w) main_layout.addWidget(btns_widget) ok_btn.clicked.connect(self._on_ok_clicked) cancel_btn.clicked.connect(self._on_cancel_clicked) stylesheet = load_stylesheet() self.setStyleSheet(stylesheet) def _on_ok_clicked(self): self.result = self.value(self.items) self.close() def _on_cancel_clicked(self): self.result = None self.close() def value(self, data, new_data=None): new_data = new_data or dict() for k, v in data.items(): new_data[k] = { "target": None, "value": None } if v["type"] == "dict": new_data[k]["target"] = v["target"] new_data[k]["value"] = self.value(v["value"]) if v["type"] == "section": new_data.pop(k) new_data = self.value(v["value"], new_data) elif getattr(v["value"], "currentText", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].currentText() elif getattr(v["value"], "isChecked", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].isChecked() elif getattr(v["value"], "value", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].value() elif getattr(v["value"], "text", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].text() return new_data def camel_case_split(self, text): matches = re.finditer( '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', text) return " ".join([str(m.group(0)).capitalize() for m in matches]) def create_row(self, layout, type, text, **kwargs): value_keys = ["setText", "setCheckState", "setValue", "setChecked"] # get type attribute from qwidgets attr = getattr(QtWidgets, type) # convert label text to normal capitalized text with spaces label_text = self.camel_case_split(text) # assign the new text to label widget label = QtWidgets.QLabel(label_text) label.setObjectName("LineLabel") # create attribute name text strip of spaces attr_name = text.replace(" ", "") # create attribute and assign default values setattr( self, attr_name, attr(parent=self)) # assign the created attribute to variable item = getattr(self, attr_name) # set attributes to item which are not values for func, val in kwargs.items(): if func in value_keys: continue if getattr(item, func): log.debug("Setting {} to {}".format(func, val)) func_attr = getattr(item, func) if isinstance(val, tuple): func_attr(*val) else: func_attr(val) # set values to item for value_item in value_keys: if value_item not in kwargs: continue if getattr(item, value_item): getattr(item, value_item)(kwargs[value_item]) # add to layout layout.addRow(label, item) return item def populate_widgets(self, data, content_layout=None): """ Populate widget from input dict. Each plugin has its own set of widget rows defined in dictionary each row values should have following keys: `type`, `target`, `label`, `order`, `value` and optionally also `toolTip`. Args: data (dict): widget rows or organized groups defined by types `dict` or `section` content_layout (QtWidgets.QFormLayout)[optional]: used when nesting Returns: dict: redefined data dict updated with created widgets """ content_layout = content_layout or self.content_layout[-1] # fix order of process by defined order value ordered_keys = list(data.keys()) for k, v in data.items(): try: # try removing a key from index which should # be filled with new ordered_keys.pop(v["order"]) except IndexError: pass # add key into correct order ordered_keys.insert(v["order"], k) # process ordered for k in ordered_keys: v = data[k] tool_tip = v.get("toolTip", "") if v["type"] == "dict": # adding spacer between sections self.content_layout.append(QtWidgets.QWidget(self)) content_layout.addWidget(self.content_layout[-1]) self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) headline.addWidget(Spacer(20, self)) headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label self.content_layout.append(QtWidgets.QWidget(self)) self.content_layout[-1].setObjectName("sectionContent") nested_content_layout = QtWidgets.QFormLayout( self.content_layout[-1]) nested_content_layout.setObjectName("NestedContentLayout") content_layout.addWidget(self.content_layout[-1]) # add nested key as label data[k]["value"] = self.populate_widgets( v["value"], nested_content_layout) if v["type"] == "section": # adding spacer between sections self.content_layout.append(QtWidgets.QWidget(self)) content_layout.addWidget(self.content_layout[-1]) self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) headline.addWidget(Spacer(20, self)) headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label self.content_layout.append(QtWidgets.QWidget(self)) self.content_layout[-1].setObjectName("sectionContent") nested_content_layout = QtWidgets.QFormLayout( self.content_layout[-1]) nested_content_layout.setObjectName("NestedContentLayout") content_layout.addWidget(self.content_layout[-1]) # add nested key as label data[k]["value"] = self.populate_widgets( v["value"], nested_content_layout) elif v["type"] == "QLineEdit": data[k]["value"] = self.create_row( content_layout, "QLineEdit", v["label"], setText=v["value"], setToolTip=tool_tip) elif v["type"] == "QComboBox": data[k]["value"] = self.create_row( content_layout, "QComboBox", v["label"], addItems=v["value"], setToolTip=tool_tip) elif v["type"] == "QCheckBox": data[k]["value"] = self.create_row( content_layout, "QCheckBox", v["label"], setChecked=v["value"], setToolTip=tool_tip) elif v["type"] == "QSpinBox": data[k]["value"] = self.create_row( content_layout, "QSpinBox", v["label"], setValue=v["value"], setDisplayIntegerBase=10000, setRange=(0, 99999), setMinimum=0, setMaximum=100000, setToolTip=tool_tip) return data class Spacer(QtWidgets.QWidget): def __init__(self, height, *args, **kwargs): super(self.__class__, self).__init__(*args, **kwargs) self.setFixedHeight(height) real_spacer = QtWidgets.QWidget(self) real_spacer.setObjectName("Spacer") real_spacer.setFixedHeight(height) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(real_spacer) self.setLayout(layout) class SequenceLoader(LoaderPlugin): """A basic SequenceLoader for Resolve This will implement the basic behavior for a loader to inherit from that will containerize the reference and will implement the `remove` and `update` logic. """ options = [ qargparse.Boolean( "handles", label="Include handles", default=0, help="Load with handles or without?" ), qargparse.Choice( "load_to", label="Where to load clips", items=[ "Current timeline", "New timeline" ], default="Current timeline", help="Where do you want clips to be loaded?" ), qargparse.Choice( "load_how", label="How to load clips", items=[ "Original timing", "Sequentially in order" ], default="Original timing", help="Would you like to place it at original timing?" ) ] def load( self, context, name=None, namespace=None, options=None ): pass def update(self, container, representation): """Update an existing `container` """ pass def remove(self, container): """Remove an existing `container` """ pass class ClipLoader: active_bin = None data = dict() def __init__(self, cls, context, path, **options): """ Initialize object Arguments: cls (avalon.api.Loader): plugin object context (dict): loader plugin context options (dict)[optional]: possible keys: projectBinPath: "path/to/binItem" """ self.__dict__.update(cls.__dict__) self.context = context self.active_project = lib.get_current_project() self.fname = path # try to get value from options or evaluate key value for `handles` self.with_handles = options.get("handles") or bool( options.get("handles") is True) # try to get value from options or evaluate key value for `load_how` self.sequencial_load = options.get("sequentially") or bool( "Sequentially in order" in options.get("load_how", "")) # try to get value from options or evaluate key value for `load_to` self.new_sequence = options.get("newSequence") or bool( "New timeline" in options.get("load_to", "")) self.clip_name_template = options.get( "clipNameTemplate") or "{asset}_{subset}_{representation}" assert self._populate_data(), str( "Cannot Load selected data, look into database " "or call your supervisor") # inject asset data to representation dict self._get_asset_data() log.info("__init__ self.data: `{}`".format(pformat(self.data))) log.info("__init__ options: `{}`".format(pformat(options))) # add active components to class if self.new_sequence: if options.get("sequence"): # if multiselection is set then use options sequence self.active_sequence = options["sequence"] else: # create new sequence self.active_sequence = lib.get_current_sequence(new=True) self.active_sequence.setFramerate( hiero.core.TimeBase.fromString( str(self.data["assetData"]["fps"]))) else: self.active_sequence = lib.get_current_sequence() if options.get("track"): # if multiselection is set then use options track self.active_track = options["track"] else: self.active_track = lib.get_current_track( self.active_sequence, self.data["track_name"]) def _populate_data(self): """ Gets context and convert it to self.data data structure: { "name": "assetName_subsetName_representationName" "path": "path/to/file/created/by/get_repr..", "binPath": "projectBinPath", } """ # create name repr = self.context["representation"] repr_cntx = repr["context"] asset = str(repr_cntx["asset"]) subset = str(repr_cntx["subset"]) representation = str(repr_cntx["representation"]) self.data["clip_name"] = self.clip_name_template.format(**repr_cntx) self.data["track_name"] = "_".join([subset, representation]) self.data["versionData"] = self.context["version"]["data"] # gets file path file = get_representation_path_from_context(self.context) if not file: repr_id = repr["_id"] log.warning( "Representation id `{}` is failing to load".format(repr_id)) return None self.data["path"] = file.replace("\\", "/") # convert to hashed path if repr_cntx.get("frame"): self._fix_path_hashes() # solve project bin structure path hierarchy = str("/".join(( "Loader", repr_cntx["hierarchy"].replace("\\", "/"), asset ))) self.data["binPath"] = hierarchy return True def _fix_path_hashes(self): """ Convert file path where it is needed padding with hashes """ file = self.data["path"] if "#" not in file: frame = self.context["representation"]["context"].get("frame") padding = len(frame) file = file.replace(frame, "#" * padding) self.data["path"] = file def _get_asset_data(self): """ Get all available asset data joint `data` key with asset.data dict into the representation """ asset_doc = self.context["asset"] self.data["assetData"] = asset_doc["data"] def _make_track_item(self, source_bin_item, audio=False): """ Create track item with """ clip = source_bin_item.activeItem() # add to track as clip item if not audio: track_item = hiero.core.TrackItem( self.data["clip_name"], hiero.core.TrackItem.kVideo) else: track_item = hiero.core.TrackItem( self.data["clip_name"], hiero.core.TrackItem.kAudio) track_item.setSource(clip) track_item.setSourceIn(self.handle_start) track_item.setTimelineIn(self.timeline_in) track_item.setSourceOut((self.media_duration) - self.handle_end) track_item.setTimelineOut(self.timeline_out) track_item.setPlaybackSpeed(1) self.active_track.addTrackItem(track_item) return track_item def load(self): # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) # create mediaItem in active project bin # create clip media self.media = hiero.core.MediaSource(self.data["path"]) self.media_duration = int(self.media.duration()) # get handles self.handle_start = self.data["versionData"].get("handleStart") self.handle_end = self.data["versionData"].get("handleEnd") if self.handle_start is None: self.handle_start = self.data["assetData"]["handleStart"] if self.handle_end is None: self.handle_end = self.data["assetData"]["handleEnd"] self.handle_start = int(self.handle_start) self.handle_end = int(self.handle_end) if self.sequencial_load: last_track_item = lib.get_track_items( sequence_name=self.active_sequence.name(), track_name=self.active_track.name() ) if len(last_track_item) == 0: last_timeline_out = 0 else: last_track_item = last_track_item[-1] last_timeline_out = int(last_track_item.timelineOut()) + 1 self.timeline_in = last_timeline_out self.timeline_out = last_timeline_out + int( self.data["assetData"]["clipOut"] - self.data["assetData"]["clipIn"]) else: self.timeline_in = int(self.data["assetData"]["clipIn"]) self.timeline_out = int(self.data["assetData"]["clipOut"]) log.debug("__ self.timeline_in: {}".format(self.timeline_in)) log.debug("__ self.timeline_out: {}".format(self.timeline_out)) # check if slate is included slate_on = "slate" in self.context["version"]["data"]["families"] log.debug("__ slate_on: {}".format(slate_on)) # if slate is on then remove the slate frame from beginning if slate_on: self.media_duration -= 1 self.handle_start += 1 # create Clip from Media clip = hiero.core.Clip(self.media) clip.setName(self.data["clip_name"]) # add Clip to bin if not there yet if self.data["clip_name"] not in [ b.name() for b in self.active_bin.items()]: bin_item = hiero.core.BinItem(clip) self.active_bin.addItem(bin_item) # just make sure the clip is created # there were some cases were hiero was not creating it source_bin_item = None for item in self.active_bin.items(): if self.data["clip_name"] == item.name(): source_bin_item = item if not source_bin_item: log.warning("Problem with created Source clip: `{}`".format( self.data["clip_name"])) # include handles if self.with_handles: self.timeline_in -= self.handle_start self.timeline_out += self.handle_end self.handle_start = 0 self.handle_end = 0 # make track item from source in bin as item track_item = self._make_track_item(source_bin_item) log.info("Loading clips: `{}`".format(self.data["clip_name"])) return track_item class Creator(LegacyCreator): """Creator class wrapper """ clip_color = "Purple" rename_index = None def __init__(self, *args, **kwargs): super(Creator, self).__init__(*args, **kwargs) import openpype.hosts.hiero.api as phiero self.presets = get_current_project_settings()[ "hiero"]["create"].get(self.__class__.__name__, {}) # adding basic current context resolve objects self.project = phiero.get_current_project() self.sequence = phiero.get_current_sequence() if (self.options or {}).get("useSelection"): timeline_selection = phiero.get_timeline_selection() self.selected = phiero.get_track_items( selection=timeline_selection ) else: self.selected = phiero.get_track_items() self.widget = CreatorWidget class PublishClip: """ Convert a track item to publishable instance Args: track_item (hiero.core.TrackItem): hiero track item object kwargs (optional): additional data needed for rename=True (presets) Returns: hiero.core.TrackItem: hiero track item object with pype tag """ vertical_clip_match = {} tag_data = {} types = { "shot": "shot", "folder": "folder", "episode": "episode", "sequence": "sequence", "track": "sequence", } # parents search pattern parents_search_pattern = r"\{([a-z]*?)\}" # default templates for non-ui use rename_default = False hierarchy_default = "{_folder_}/{_sequence_}/{_track_}" clip_name_default = "shot_{_trackIndex_:0>3}_{_clipIndex_:0>4}" subset_name_default = "" review_track_default = "< none >" subset_family_default = "plate" count_from_default = 10 count_steps_default = 10 vertical_sync_default = False driving_layer_default = "" def __init__(self, cls, track_item, **kwargs): # populate input cls attribute onto self.[attr] self.__dict__.update(cls.__dict__) # get main parent objects self.track_item = track_item sequence_name = lib.get_current_sequence().name() self.sequence_name = str(sequence_name).replace(" ", "_") # track item (clip) main attributes self.ti_name = track_item.name() self.ti_index = int(track_item.eventNumber()) # get track name and index track_name = track_item.parent().name() self.track_name = str(track_name).replace(" ", "_") self.track_index = int(track_item.parent().trackIndex()) # adding tag.family into tag if kwargs.get("avalon"): self.tag_data.update(kwargs["avalon"]) # add publish attribute to tag data self.tag_data.update({"publish": True}) # adding ui inputs if any self.ui_inputs = kwargs.get("ui_inputs", {}) # populate default data before we get other attributes self._populate_track_item_default_data() # use all populated default data to create all important attributes self._populate_attributes() # create parents with correct types self._create_parents() def convert(self): # solve track item data and add them to tag data tag_hierarchy_data = self._convert_to_tag_data() self.tag_data.update(tag_hierarchy_data) # if track name is in review track name and also if driving track name # is not in review track name: skip tag creation if (self.track_name in self.review_layer) and ( self.driving_layer not in self.review_layer): return # deal with clip name new_name = self.tag_data.pop("newClipName") if self.rename: # rename track item self.track_item.setName(new_name) self.tag_data["asset_name"] = new_name else: self.tag_data["asset_name"] = self.ti_name self.tag_data["hierarchyData"]["shot"] = self.ti_name # AYON unique identifier folder_path = "/{}/{}".format( tag_hierarchy_data["hierarchy"], self.tag_data["asset_name"] ) self.tag_data["folderPath"] = folder_path if self.tag_data["heroTrack"] and self.review_layer: self.tag_data.update({"reviewTrack": self.review_layer}) else: self.tag_data.update({"reviewTrack": None}) # TODO: remove debug print log.debug("___ self.tag_data: {}".format( pformat(self.tag_data) )) # create pype tag on track_item and add data lib.imprint(self.track_item, self.tag_data) return self.track_item def _populate_track_item_default_data(self): """ Populate default formatting data from track item. """ self.track_item_default_data = { "_folder_": "shots", "_sequence_": self.sequence_name, "_track_": self.track_name, "_clip_": self.ti_name, "_trackIndex_": self.track_index, "_clipIndex_": self.ti_index } def _populate_attributes(self): """ Populate main object attributes. """ # track item frame range and parent track name for vertical sync check self.clip_in = int(self.track_item.timelineIn()) self.clip_out = int(self.track_item.timelineOut()) # define ui inputs if non gui mode was used self.shot_num = self.ti_index log.debug( "____ self.shot_num: {}".format(self.shot_num)) # ui_inputs data or default values if gui was not used self.rename = self.ui_inputs.get( "clipRename", {}).get("value") or self.rename_default self.clip_name = self.ui_inputs.get( "clipName", {}).get("value") or self.clip_name_default self.hierarchy = self.ui_inputs.get( "hierarchy", {}).get("value") or self.hierarchy_default self.hierarchy_data = self.ui_inputs.get( "hierarchyData", {}).get("value") or \ self.track_item_default_data.copy() self.count_from = self.ui_inputs.get( "countFrom", {}).get("value") or self.count_from_default self.count_steps = self.ui_inputs.get( "countSteps", {}).get("value") or self.count_steps_default self.subset_name = self.ui_inputs.get( "subsetName", {}).get("value") or self.subset_name_default self.subset_family = self.ui_inputs.get( "subsetFamily", {}).get("value") or self.subset_family_default self.vertical_sync = self.ui_inputs.get( "vSyncOn", {}).get("value") or self.vertical_sync_default self.driving_layer = self.ui_inputs.get( "vSyncTrack", {}).get("value") or self.driving_layer_default self.review_track = self.ui_inputs.get( "reviewTrack", {}).get("value") or self.review_track_default self.audio = self.ui_inputs.get( "audio", {}).get("value") or False # build subset name from layer name if self.subset_name == "": self.subset_name = self.track_name # create subset for publishing self.subset = self.subset_family + self.subset_name.capitalize() def _replace_hash_to_expression(self, name, text): """ Replace hash with number in correct padding. """ _spl = text.split("#") _len = (len(_spl) - 1) _repl = "{{{0}:0>{1}}}".format(name, _len) return text.replace(("#" * _len), _repl) def _convert_to_tag_data(self): """ Convert internal data to tag data. Populating the tag data into internal variable self.tag_data """ # define vertical sync attributes hero_track = True self.review_layer = "" if self.vertical_sync: # check if track name is not in driving layer if self.track_name not in self.driving_layer: # if it is not then define vertical sync as None hero_track = False # increasing steps by index of rename iteration self.count_steps *= self.rename_index hierarchy_formatting_data = {} hierarchy_data = deepcopy(self.hierarchy_data) _data = self.track_item_default_data.copy() if self.ui_inputs: # adding tag metadata from ui for _k, _v in self.ui_inputs.items(): if _v["target"] == "tag": self.tag_data[_k] = _v["value"] # driving layer is set as positive match if hero_track or self.vertical_sync: # mark review layer if self.review_track and ( self.review_track not in self.review_track_default): # if review layer is defined and not the same as default self.review_layer = self.review_track # shot num calculate if self.rename_index == 0: self.shot_num = self.count_from else: self.shot_num = self.count_from + self.count_steps # clip name sequence number _data.update({"shot": self.shot_num}) # solve # in test to pythonic expression for _k, _v in hierarchy_data.items(): if "#" not in _v["value"]: continue hierarchy_data[ _k]["value"] = self._replace_hash_to_expression( _k, _v["value"]) # fill up pythonic expresisons in hierarchy data for k, _v in hierarchy_data.items(): hierarchy_formatting_data[k] = _v["value"].format(**_data) else: # if no gui mode then just pass default data hierarchy_formatting_data = hierarchy_data tag_hierarchy_data = self._solve_tag_hierarchy_data( hierarchy_formatting_data ) tag_hierarchy_data.update({"heroTrack": True}) if hero_track and self.vertical_sync: self.vertical_clip_match.update({ (self.clip_in, self.clip_out): tag_hierarchy_data }) if not hero_track and self.vertical_sync: # driving layer is set as negative match for (_in, _out), hero_data in self.vertical_clip_match.items(): hero_data.update({"heroTrack": False}) if _in == self.clip_in and _out == self.clip_out: data_subset = hero_data["subset"] # add track index in case duplicity of names in hero data if self.subset in data_subset: hero_data["subset"] = self.subset + str( self.track_index) # in case track name and subset name is the same then add if self.subset_name == self.track_name: hero_data["subset"] = self.subset # assign data to return hierarchy data to tag tag_hierarchy_data = hero_data # add data to return data dict return tag_hierarchy_data def _solve_tag_hierarchy_data(self, hierarchy_formatting_data): """ Solve tag data from hierarchy data and templates. """ # fill up clip name and hierarchy keys hierarchy_filled = self.hierarchy.format(**hierarchy_formatting_data) clip_name_filled = self.clip_name.format(**hierarchy_formatting_data) # remove shot from hierarchy data: is not needed anymore hierarchy_formatting_data.pop("shot") return { "newClipName": clip_name_filled, "hierarchy": hierarchy_filled, "parents": self.parents, "hierarchyData": hierarchy_formatting_data, "subset": self.subset, "family": self.subset_family, "families": [self.data["family"]] } def _convert_to_entity(self, type, template): """ Converting input key to key with type. """ # convert to entity type entity_type = self.types.get(type, None) assert entity_type, "Missing entity type for `{}`".format( type ) # first collect formatting data to use for formatting template formatting_data = {} for _k, _v in self.hierarchy_data.items(): value = _v["value"].format( **self.track_item_default_data) formatting_data[_k] = value return { "entity_type": entity_type, "entity_name": template.format( **formatting_data ) } def _create_parents(self): """ Create parents and return it in list. """ self.parents = [] pattern = re.compile(self.parents_search_pattern) par_split = [(pattern.findall(t).pop(), t) for t in self.hierarchy.split("/")] for type, template in par_split: parent = self._convert_to_entity(type, template) self.parents.append(parent) ================================================ FILE: openpype/hosts/hiero/api/startup/HieroPlayer/PlayerPresets.hrox ================================================ 2 70 2 70 2 70 2 70 2 70 2 70 2 70 2 70 2 70 2 50 2 70 2 70 2 70 2 70 2 70 2 70 2 70 2 70 17 126935040 70 -1 2 70 2 ================================================ FILE: openpype/hosts/hiero/api/startup/Python/Startup/SpreadsheetExport.py ================================================ # This action adds itself to the Spreadsheet View context menu allowing the contents of the Spreadsheet be exported as a CSV file. # Usage: Right-click in Spreadsheet > "Export as .CSV" # Note: This only prints the text data that is visible in the active Spreadsheet View. # If you've filtered text, only the visible text will be printed to the CSV file # Usage: Copy to ~/.hiero/Python/StartupUI import hiero.core.events import hiero.ui import os, csv try: from PySide.QtGui import * from PySide.QtCore import * except: from PySide2.QtGui import * from PySide2.QtWidgets import * from PySide2.QtCore import * ### Magic Widget Finding Methods - This stuff crawls all the PySide widgets, looking for an answer def findWidget(w): global foundryWidgets if "Foundry" in w.metaObject().className(): foundryWidgets += [w] for c in w.children(): findWidget(c) return foundryWidgets def getFoundryWidgetsWithClassName(filter=None): global foundryWidgets foundryWidgets = [] widgets = [] app = QApplication.instance() for w in app.topLevelWidgets(): findWidget(w) filteredWidgets = foundryWidgets if filter: filteredWidgets = [] for widget in foundryWidgets: if filter in widget.metaObject().className(): filteredWidgets += [widget] return filteredWidgets # When right click, get the Sequence Name def activeSpreadsheetTreeView(): """ Does some PySide widget Magic to detect the Active Spreadsheet TreeView. """ spreadsheetViews = getFoundryWidgetsWithClassName( filter="SpreadsheetTreeView") for spreadSheet in spreadsheetViews: if spreadSheet.hasFocus(): activeSpreadSheet = spreadSheet return activeSpreadSheet return None #### Adds "Export .CSV" action to the Spreadsheet Context menu #### class SpreadsheetExportCSVAction(QAction): def __init__(self): QAction.__init__(self, "Export as .CSV", None) self.triggered.connect(self.exportCSVFromActiveSpreadsheetView) hiero.core.events.registerInterest("kShowContextMenu/kSpreadsheet", self.eventHandler) self.setIcon(QIcon("icons:FBGridView.png")) def eventHandler(self, event): # Insert the action to the Export CSV menu event.menu.addAction(self) #### The guts!.. Writes a CSV file from a Sequence Object #### def exportCSVFromActiveSpreadsheetView(self): # Get the active QTreeView from the active Spreadsheet spreadsheetTreeView = activeSpreadsheetTreeView() if not spreadsheetTreeView: return "Unable to detect the active TreeView." seq = hiero.ui.activeView().sequence() if not seq: print("Unable to detect the active Sequence from the activeView.") return # The data model of the QTreeView model = spreadsheetTreeView.model() csvSavePath = os.path.join(QDir.homePath(), "Desktop", seq.name() + ".csv") savePath, filter = QFileDialog.getSaveFileName( None, caption="Export Spreadsheet to .CSV as...", dir=csvSavePath, filter="*.csv") print("Saving To: {}".format(savePath)) # Saving was cancelled... if len(savePath) == 0: return # Get the Visible Header Columns from the QTreeView #csvHeader = ["Event", "Status", "Shot Name", "Reel", "Track", "Speed", "Src In", "Src Out","Src Duration", "Dst In", "Dst Out", "Dst Duration", "Clip", "Clip Media"] # Get a CSV writer object f = open(savePath, "w") csvWriter = csv.writer( f, delimiter=',', quotechar="|", quoting=csv.QUOTE_MINIMAL) # This is a list of the Column titles csvHeader = [] for col in range(0, model.columnCount()): if not spreadsheetTreeView.isColumnHidden(col): csvHeader += [model.headerData(col, Qt.Horizontal)] # Write the Header row to the CSV file csvWriter.writerow(csvHeader) # Go through each row/column and print for row in range(model.rowCount()): row_data = [] for col in range(model.columnCount()): if not spreadsheetTreeView.isColumnHidden(col): row_data.append( model.index(row, col, QModelIndex()).data( Qt.DisplayRole)) # Write row to CSV file... csvWriter.writerow(row_data) f.close() # Conveniently show the CSV file in the native file browser... QDesktopServices.openUrl( QUrl('file:///%s' % (os.path.dirname(savePath)))) # Add the action... csvActions = SpreadsheetExportCSVAction() ================================================ FILE: openpype/hosts/hiero/api/startup/Python/Startup/Startup.py ================================================ import traceback # activate hiero from pype from openpype.pipeline import install_host import openpype.hosts.hiero.api as phiero install_host(phiero) try: __import__("openpype.hosts.hiero.api") __import__("pyblish") except ImportError as e: print(traceback.format_exc()) print("pyblish: Could not load integration: %s " % e) else: # Setup integration import openpype.hosts.hiero.api as phiero phiero.lib.setup() ================================================ FILE: openpype/hosts/hiero/api/startup/Python/Startup/otioexporter/OTIOExportTask.py ================================================ # -*- coding: utf-8 -*- __author__ = "Daniel Flehner Heen" __credits__ = ["Jakub Jezek", "Daniel Flehner Heen"] import os import hiero.core from hiero.core import util import opentimelineio as otio from openpype.hosts.hiero.api.otio import hiero_export class OTIOExportTask(hiero.core.TaskBase): def __init__(self, initDict): """Initialize""" hiero.core.TaskBase.__init__(self, initDict) self.otio_timeline = None def name(self): return str(type(self)) def startTask(self): self.otio_timeline = hiero_export.create_otio_timeline() def taskStep(self): return False def finishTask(self): try: exportPath = self.resolvedExportPath() # Check file extension if not exportPath.lower().endswith(".otio"): exportPath += ".otio" # check export root exists dirname = os.path.dirname(exportPath) util.filesystem.makeDirs(dirname) # write otio file hiero_export.write_to_file(self.otio_timeline, exportPath) # Catch all exceptions and log error except Exception as e: self.setError("failed to write file {f}\n{e}".format( f=exportPath, e=e) ) hiero.core.TaskBase.finishTask(self) def forcedAbort(self): pass class OTIOExportPreset(hiero.core.TaskPresetBase): def __init__(self, name, properties): """Initialise presets to default values""" hiero.core.TaskPresetBase.__init__(self, OTIOExportTask, name) self.properties()["includeTags"] = hiero_export.include_tags = True self.properties().update(properties) def supportedItems(self): return hiero.core.TaskPresetBase.kSequence def addCustomResolveEntries(self, resolver): resolver.addResolver( "{ext}", "Extension of the file to be output", lambda keyword, task: "otio" ) def supportsAudio(self): return True hiero.core.taskRegistry.registerTask(OTIOExportPreset, OTIOExportTask) ================================================ FILE: openpype/hosts/hiero/api/startup/Python/Startup/otioexporter/OTIOExportUI.py ================================================ # -*- coding: utf-8 -*- __author__ = "Daniel Flehner Heen" __credits__ = ["Jakub Jezek", "Daniel Flehner Heen"] import hiero.ui from .OTIOExportTask import ( OTIOExportTask, OTIOExportPreset ) try: # Hiero >= 11.x from PySide2 import QtCore from PySide2.QtWidgets import QCheckBox from hiero.ui.FnTaskUIFormLayout import TaskUIFormLayout as FormLayout except ImportError: # Hiero <= 10.x from PySide import QtCore # lint:ok from PySide.QtGui import QCheckBox, QFormLayout # lint:ok FormLayout = QFormLayout # lint:ok from openpype.hosts.hiero.api.otio import hiero_export class OTIOExportUI(hiero.ui.TaskUIBase): def __init__(self, preset): """Initialize""" hiero.ui.TaskUIBase.__init__( self, OTIOExportTask, preset, "OTIO Exporter" ) def includeMarkersCheckboxChanged(self, state): # Slot to handle change of checkbox state hiero_export.include_tags = state == QtCore.Qt.Checked def populateUI(self, widget, exportTemplate): layout = widget.layout() formLayout = FormLayout() # Hiero ~= 10.0v4 if layout is None: layout = formLayout widget.setLayout(layout) else: layout.addLayout(formLayout) # Checkboxes for whether the OTIO should contain markers or not self.includeMarkersCheckbox = QCheckBox() self.includeMarkersCheckbox.setToolTip( "Enable to include Tags as markers in the exported OTIO file." ) self.includeMarkersCheckbox.setCheckState(QtCore.Qt.Unchecked) if self._preset.properties()["includeTags"]: self.includeMarkersCheckbox.setCheckState(QtCore.Qt.Checked) self.includeMarkersCheckbox.stateChanged.connect( self.includeMarkersCheckboxChanged ) # Add Checkbox to layout formLayout.addRow("Include Tags:", self.includeMarkersCheckbox) hiero.ui.taskUIRegistry.registerTaskUI( OTIOExportPreset, OTIOExportUI ) ================================================ FILE: openpype/hosts/hiero/api/startup/Python/Startup/otioexporter/__init__.py ================================================ from .OTIOExportTask import OTIOExportTask from .OTIOExportUI import OTIOExportUI __all__ = [ "OTIOExportTask", "OTIOExportUI" ] ================================================ FILE: openpype/hosts/hiero/api/startup/Python/Startup/project_helpers.py ================================================ try: from PySide.QtGui import * from PySide.QtCore import * except: from PySide2.QtGui import * from PySide2.QtWidgets import * from PySide2.QtCore import * from hiero.core.util import uniquify, version_get, version_set import hiero.core import hiero.ui import nuke # A globally variable for storing the current Project gTrackedActiveProject = None # This selection handler will track changes in items selected/deselected in the Bin/Timeline/Spreadsheet Views def __trackActiveProjectHandler(event): global gTrackedActiveProject selection = event.sender.selection() binSelection = selection if len(binSelection) > 0 and hasattr(binSelection[0], "project"): proj = binSelection[0].project() # We only store this if its a valid, active User Project if proj in hiero.core.projects(hiero.core.Project.kUserProjects): gTrackedActiveProject = proj hiero.core.events.registerInterest( "kSelectionChanged/kBin", __trackActiveProjectHandler) hiero.core.events.registerInterest( "kSelectionChanged/kTimeline", __trackActiveProjectHandler) hiero.core.events.registerInterest( "kSelectionChanged/Spreadsheet", __trackActiveProjectHandler) def activeProject(): """hiero.ui.activeProject() -> returns the current Project Note: There is not technically a notion of a "active" Project in Hiero/NukeStudio, as it is a multi-project App. This method determines what is "active" by going down the following rules... # 1 - If the current Viewer (hiero.ui.currentViewer) contains a Clip or Sequence, this item is assumed to give the active Project # 2 - If nothing is currently in the Viewer, look to the active View, determine project from active selection # 3 - If no current selection can be determined, fall back to a globally tracked last selection from trackActiveProjectHandler # 4 - If all those rules fail, fall back to the last project in the list of hiero.core.projects() @return: hiero.core.Project""" global gTrackedActiveProject activeProject = None # Case 1 : Look for what the current Viewr tells us - this might not be what we want, and relies on hiero.ui.currentViewer() being robust. cv = hiero.ui.currentViewer().player().sequence() if hasattr(cv, "project"): activeProject = cv.project() else: # Case 2: We can't determine a project from the current Viewer, so try seeing what's selected in the activeView # Note that currently, if you run activeProject from the Script Editor, the activeView is always None, so this will rarely get used! activeView = hiero.ui.activeView() if activeView: # We can determine an active View.. see what's being worked with selection = activeView.selection() # Handle the case where nothing is selected in the active view if len(selection) == 0: # It's possible that there is no selection in a Timeline/Spreadsheet, but these views have "sequence" method, so try that... if isinstance(hiero.ui.activeView(), (hiero.ui.TimelineEditor, hiero.ui.SpreadsheetView)): activeSequence = activeView.sequence() if hasattr(currentItem, "project"): activeProject = activeSequence.project() # The active view has a selection... assume that the first item in the selection has the active Project else: currentItem = selection[0] if hasattr(currentItem, "project"): activeProject = currentItem.project() # Finally, Cases 3 and 4... if not activeProject: activeProjects = hiero.core.projects(hiero.core.Project.kUserProjects) if gTrackedActiveProject in activeProjects: activeProject = gTrackedActiveProject else: activeProject = activeProjects[-1] return activeProject # Method to get all recent projects def recentProjects(): """hiero.core.recentProjects() -> Returns a list of paths to recently opened projects Hiero stores up to 5 recent projects in uistate.ini with the [recentFile]/# key. @return: list of paths to .hrox Projects""" appSettings = hiero.core.ApplicationSettings() recentProjects = [] for i in range(0, 5): proj = appSettings.value('recentFile/%i' % i) if len(proj) > 0: recentProjects.append(proj) return recentProjects # Method to get recent project by index def recentProject(k=0): """hiero.core.recentProject(k) -> Returns the recent project path, specified by integer k (0-4) @param: k (optional, default = 0) - an integer from 0-4, relating to the index of recent projects. @return: hiero.core.Project""" appSettings = hiero.core.ApplicationSettings() proj = appSettings.value('recentFile/%i' % int(k), None) return proj # Method to get open project by index def openRecentProject(k=0): """hiero.core.openRecentProject(k) -> Opens the most the recent project as listed in the Open Recent list. @param: k (optional, default = 0) - an integer from 0-4, relating to the index of recent projects. @return: hiero.core.Project""" appSettings = hiero.core.ApplicationSettings() proj = appSettings.value('recentFile/%i' % int(k), None) proj = hiero.core.openProject(proj) return proj # Duck punch these methods into the relevant ui/core namespaces hiero.ui.activeProject = activeProject hiero.core.recentProjects = recentProjects hiero.core.recentProject = recentProject hiero.core.openRecentProject = openRecentProject # Method to Save a new Version of the activeHrox Project class SaveAllProjects(QAction): def __init__(self): QAction.__init__(self, "Save All Projects", None) self.triggered.connect(self.projectSaveAll) hiero.core.events.registerInterest( "kShowContextMenu/kBin", self.eventHandler) def projectSaveAll(self): allProjects = hiero.core.projects() for proj in allProjects: try: proj.save() print("Saved Project: {} to: {} ".format( proj.name(), proj.path() )) except: print(( "Unable to save Project: {} to: {}. " "Check file permissions.").format( proj.name(), proj.path())) def eventHandler(self, event): event.menu.addAction(self) # For projects with v# in the path name, saves out a new Project with v#+1 class SaveNewProjectVersion(QAction): def __init__(self): QAction.__init__(self, "Save New Version...", None) self.triggered.connect(self.saveNewVersion) hiero.core.events.registerInterest( "kShowContextMenu/kBin", self.eventHandler) self.selectedProjects = [] def saveNewVersion(self): if len(self.selectedProjects) > 0: projects = self.selectedProjects else: projects = [hiero.ui.activeProject()] if len(projects) < 1: return for proj in projects: oldName = proj.name() path = proj.path() v = None prefix = None try: (prefix, v) = version_get(path, "v") except ValueError as msg: print(msg) if (prefix is not None) and (v is not None): v = int(v) newPath = version_set(path, prefix, v, v + 1) try: proj.saveAs(newPath) print("Saved new project version: {} to: {} ".format( oldName, newPath)) except: print(( "Unable to save Project: {}. Check file permissions." ).format(oldName)) else: newPath = path.replace(".hrox", "_v01.hrox") answer = nuke.ask( "%s does not contain a version number.\nDo you want to save as %s?" % (proj, newPath)) if answer: try: proj.saveAs(newPath) print("Saved new project version: {} to: {} ".format( oldName, newPath)) except: print(( "Unable to save Project: {}. Check file " "permissions.").format(oldName)) def eventHandler(self, event): self.selectedProjects = [] if hasattr(event.sender, "selection") and event.sender.selection() is not None and len(event.sender.selection()) != 0: selection = event.sender.selection() self.selectedProjects = uniquify( [item.project() for item in selection]) event.menu.addAction(self) # Instantiate the actions saveAllAct = SaveAllProjects() saveNewAct = SaveNewProjectVersion() fileMenu = hiero.ui.findMenuAction("foundry.menu.file") importAct = hiero.ui.findMenuAction("foundry.project.importFiles") hiero.ui.insertMenuAction(saveNewAct, fileMenu.menu(), before="Import File(s)...") hiero.ui.insertMenuAction(saveAllAct, fileMenu.menu(), before="Import File(s)...") fileMenu.menu().insertSeparator(importAct) ================================================ FILE: openpype/hosts/hiero/api/startup/Python/Startup/selection_tracker.py ================================================ """Puts the selection project into "hiero.selection""" import hiero def selectionChanged(event): hiero.selection = event.sender.selection() hiero.core.events.registerInterest("kSelectionChanged", selectionChanged) ================================================ FILE: openpype/hosts/hiero/api/startup/Python/Startup/setFrameRate.py ================================================ # setFrameRate - adds a Right-click menu to the Project Bin view, allowing multiple BinItems (Clips/Sequences) to have their frame rates set. # Install in: ~/.hiero/Python/StartupUI # Requires 1.5v1 or later import hiero.core import hiero.ui try: from PySide.QtGui import * from PySide.QtCore import * except: from PySide2.QtGui import * from PySide2.QtCore import * from PySide2.QtWidgets import * # Dialog for setting a Custom frame rate. class SetFrameRateDialog(QDialog): def __init__(self,itemSelection=None,parent=None): super(SetFrameRateDialog, self).__init__(parent) self.setWindowTitle("Set Custom Frame Rate") self.setSizePolicy( QSizePolicy.Expanding, QSizePolicy.Fixed ) layout = QFormLayout() self._itemSelection = itemSelection self._frameRateField = QLineEdit() self._frameRateField.setToolTip("Enter custom frame rate here.") self._frameRateField.setValidator(QDoubleValidator(1, 99, 3, self)) self._frameRateField.textChanged.connect(self._textChanged) layout.addRow("Enter fps: ",self._frameRateField) # Standard buttons for Add/Cancel self._buttonbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) self._buttonbox.accepted.connect(self.accept) self._buttonbox.rejected.connect(self.reject) self._buttonbox.button(QDialogButtonBox.Ok).setEnabled(False) layout.addRow("",self._buttonbox) self.setLayout(layout) def _updateOkButtonState(self): # Cancel is always an option but only enable Ok if there is some text. currentFramerate = float(self.currentFramerateString()) enableOk = False enableOk = ((currentFramerate > 0.0) and (currentFramerate <= 250.0)) print("enabledOk", enableOk) self._buttonbox.button(QDialogButtonBox.Ok).setEnabled(enableOk) def _textChanged(self, newText): self._updateOkButtonState() # Returns the current frame rate as a string def currentFramerateString(self): return str(self._frameRateField.text()) # Presents the Dialog and sets the Frame rate from a selection def showDialogAndSetFrameRateFromSelection(self): if self._itemSelection is not None: if self.exec_(): # For the Undo loop... # Construct an TimeBase object for setting the Frame Rate (fps) fps = hiero.core.TimeBase().fromString(self.currentFramerateString()) # Set the frame rate for the selected BinItmes for item in self._itemSelection: item.setFramerate(fps) return # This is just a convenience method for returning QActions with a title, triggered method and icon. def makeAction(title, method, icon = None): action = QAction(title,None) action.setIcon(QIcon(icon)) # We do this magic, so that the title string from the action is used to set the frame rate! def methodWrapper(): method(title) action.triggered.connect( methodWrapper ) return action # Menu which adds a Set Frame Rate Menu to Project Bin view class SetFrameRateMenu: def __init__(self): self._frameRateMenu = None self._frameRatesDialog = None # ant: Could use hiero.core.defaultFrameRates() here but messes up with string matching because we seem to mix decimal points self.frameRates = ["8","12","12.50","15","23.98","24","25","29.97","30","48","50","59.94","60"] hiero.core.events.registerInterest("kShowContextMenu/kBin", self.binViewEventHandler) self.menuActions = [] def createFrameRateMenus(self,selection): selectedClipFPS = [str(bi.activeItem().framerate()) for bi in selection if (isinstance(bi,hiero.core.BinItem) and hasattr(bi,"activeItem"))] selectedClipFPS = hiero.core.util.uniquify(selectedClipFPS) sameFrameRate = len(selectedClipFPS)==1 self.menuActions = [] for fps in self.frameRates: if fps in selectedClipFPS: if sameFrameRate: self.menuActions+=[makeAction(fps,self.setFrameRateFromMenuSelection, icon="icons:Ticked.png")] else: self.menuActions+=[makeAction(fps,self.setFrameRateFromMenuSelection, icon="icons:remove active.png")] else: self.menuActions+=[makeAction(fps,self.setFrameRateFromMenuSelection, icon=None)] # Now add Custom... menu self.menuActions += [makeAction( "Custom...", self.setFrameRateFromMenuSelection, icon=None) ] frameRateMenu = QMenu("Set Frame Rate") for a in self.menuActions: frameRateMenu.addAction(a) return frameRateMenu def setFrameRateFromMenuSelection(self, menuSelectionFPS): selectedBinItems = [bi.activeItem() for bi in self._selection if (isinstance(bi,hiero.core.BinItem) and hasattr(bi,"activeItem"))] currentProject = selectedBinItems[0].project() with currentProject.beginUndo("Set Frame Rate"): if menuSelectionFPS == "Custom...": self._frameRatesDialog = SetFrameRateDialog(itemSelection = selectedBinItems ) self._frameRatesDialog.showDialogAndSetFrameRateFromSelection() else: for b in selectedBinItems: b.setFramerate(hiero.core.TimeBase().fromString(menuSelectionFPS)) return # This handles events from the Project Bin View def binViewEventHandler(self,event): if not hasattr(event.sender, "selection"): # Something has gone wrong, we should only be here if raised # by the Bin view which gives a selection. return # Reset the selection to None... self._selection = None s = event.sender.selection() # Return if there's no Selection. We won't add the Menu. if s == None: return # Filter the selection to BinItems self._selection = [item for item in s if isinstance(item, hiero.core.BinItem)] if len(self._selection)==0: return # Creating the menu based on items selected, to highlight which frame rates are contained self._frameRateMenu = self.createFrameRateMenus(self._selection) # Insert the Set Frame Rate Button before the Set Media Colour Transform Action for action in event.menu.actions(): if str(action.text()) == "Set Media Colour Transform": event.menu.insertMenu(action, self._frameRateMenu) break # Instantiate the Menu to get it to register itself. SetFrameRateMenu = SetFrameRateMenu() ================================================ FILE: openpype/hosts/hiero/api/startup/Python/StartupUI/PimpMySpreadsheet.py ================================================ # PimpMySpreadsheet 1.0, Antony Nasce, 23/05/13. # Adds custom spreadsheet columns and right-click menu for setting the Shot Status, and Artist Shot Assignment. # gStatusTags is a global dictionary of key(status)-value(icon) pairs, which can be overridden with custom icons if required # Requires Hiero 1.7v2 or later. # Install Instructions: Copy to ~/.hiero/Python/StartupUI import hiero.core import hiero.ui try: from PySide.QtGui import * from PySide.QtCore import * except: from PySide2.QtGui import * from PySide2.QtWidgets import * from PySide2.QtCore import * # Set to True, if you wat "Set Status" right-click menu, False if not kAddStatusMenu = True # Set to True, if you wat "Assign Artist" right-click menu, False if not kAssignArtistMenu = True # Global list of Artist Name Dictionaries # Note: Override this to add different names, icons, department, IDs. gArtistList = [{ "artistName": "John Smith", "artistIcon": "icons:TagActor.png", "artistDepartment": "3D", "artistID": 0 }, { "artistName": "Savlvador Dali", "artistIcon": "icons:TagActor.png", "artistDepartment": "Roto", "artistID": 1 }, { "artistName": "Leonardo Da Vinci", "artistIcon": "icons:TagActor.png", "artistDepartment": "Paint", "artistID": 2 }, { "artistName": "Claude Monet", "artistIcon": "icons:TagActor.png", "artistDepartment": "Comp", "artistID": 3 }, { "artistName": "Pablo Picasso", "artistIcon": "icons:TagActor.png", "artistDepartment": "Animation", "artistID": 4 }] # Global Dictionary of Status Tags. # Note: This can be overwritten if you want to add a new status cellType or custom icon # Override the gStatusTags dictionary by adding your own "Status":"Icon.png" key-value pairs. # Add new custom keys like so: gStatusTags["For Client"] = "forClient.png" gStatusTags = { "Approved": "icons:status/TagApproved.png", "Unapproved": "icons:status/TagUnapproved.png", "Ready To Start": "icons:status/TagReadyToStart.png", "Blocked": "icons:status/TagBlocked.png", "On Hold": "icons:status/TagOnHold.png", "In Progress": "icons:status/TagInProgress.png", "Awaiting Approval": "icons:status/TagAwaitingApproval.png", "Omitted": "icons:status/TagOmitted.png", "Final": "icons:status/TagFinal.png" } # The Custom Spreadsheet Columns class CustomSpreadsheetColumns(QObject): """ A class defining custom columns for Hiero's spreadsheet view. This has a similar, but slightly simplified, interface to the QAbstractItemModel and QItemDelegate classes. """ global gStatusTags global gArtistList # Ideally, we'd set this list on a Per Item basis, but this is expensive for a large mixed selection standardColourSpaces = [ "linear", "sRGB", "rec709", "Cineon", "Gamma1.8", "Gamma2.2", "Panalog", "REDLog", "ViperLog" ] arriColourSpaces = [ "Video - Rec709", "LogC - Camera Native", "Video - P3", "ACES", "LogC - Film", "LogC - Wide Gamut" ] r3dColourSpaces = [ "Linear", "Rec709", "REDspace", "REDlog", "PDlog685", "PDlog985", "CustomPDlog", "REDgamma", "SRGB", "REDlogFilm", "REDgamma2", "REDgamma3" ] gColourSpaces = standardColourSpaces + arriColourSpaces + r3dColourSpaces currentView = hiero.ui.activeView() # This is the list of Columns available gCustomColumnList = [ { "name": "Tags", "cellType": "readonly" }, { "name": "Colourspace", "cellType": "dropdown" }, { "name": "Notes", "cellType": "readonly" }, { "name": "FileType", "cellType": "readonly" }, { "name": "Shot Status", "cellType": "dropdown" }, { "name": "Thumbnail", "cellType": "readonly" }, { "name": "MediaType", "cellType": "readonly" }, { "name": "Width", "cellType": "readonly" }, { "name": "Height", "cellType": "readonly" }, { "name": "Pixel Aspect", "cellType": "readonly" }, { "name": "Artist", "cellType": "dropdown" }, { "name": "Department", "cellType": "readonly" }, ] def numColumns(self): """ Return the number of custom columns in the spreadsheet view """ return len(self.gCustomColumnList) def columnName(self, column): """ Return the name of a custom column """ return self.gCustomColumnList[column]["name"] def getTagsString(self, item): """ Convenience method for returning all the Notes in a Tag as a string """ tagNames = [] tags = item.tags() for tag in tags: tagNames += [tag.name()] tagNameString = ','.join(tagNames) return tagNameString def getNotes(self, item): """ Convenience method for returning all the Notes in a Tag as a string """ notes = "" tags = item.tags() for tag in tags: note = tag.note() if len(note) > 0: notes += tag.note() + ', ' return notes[:-2] def getData(self, row, column, item): """ Return the data in a cell """ currentColumn = self.gCustomColumnList[column] if currentColumn["name"] == "Tags": return self.getTagsString(item) if currentColumn["name"] == "Colourspace": try: colTransform = item.sourceMediaColourTransform() except: colTransform = "--" return colTransform if currentColumn["name"] == "Notes": try: note = self.getNotes(item) except: note = "" return note if currentColumn["name"] == "FileType": fileType = "--" M = item.source().mediaSource().metadata() if M.hasKey("foundry.source.type"): fileType = M.value("foundry.source.type") elif M.hasKey("media.input.filereader"): fileType = M.value("media.input.filereader") return fileType if currentColumn["name"] == "Shot Status": status = item.status() if not status: status = "--" return str(status) if currentColumn["name"] == "MediaType": M = item.mediaType() return str(M).split("MediaType")[-1].replace(".k", "") if currentColumn["name"] == "Thumbnail": return str(item.eventNumber()) if currentColumn["name"] == "Width": return str(item.source().format().width()) if currentColumn["name"] == "Height": return str(item.source().format().height()) if currentColumn["name"] == "Pixel Aspect": return str(item.source().format().pixelAspect()) if currentColumn["name"] == "Artist": if item.artist(): name = item.artist()["artistName"] return name else: return "--" if currentColumn["name"] == "Department": if item.artist(): dep = item.artist()["artistDepartment"] return dep else: return "--" return "" def setData(self, row, column, item, data): """ Set the data in a cell - unused in this example """ return None def getTooltip(self, row, column, item): """ Return the tooltip for a cell """ currentColumn = self.gCustomColumnList[column] if currentColumn["name"] == "Tags": return str([item.name() for item in item.tags()]) if currentColumn["name"] == "Notes": return str(self.getNotes(item)) return "" def getFont(self, row, column, item): """ Return the tooltip for a cell """ return None def getBackground(self, row, column, item): """ Return the background colour for a cell """ if not item.source().mediaSource().isMediaPresent(): return QColor(80, 20, 20) return None def getForeground(self, row, column, item): """ Return the text colour for a cell """ #if column == 1: # return QColor(255, 64, 64) return None def getIcon(self, row, column, item): """ Return the icon for a cell """ currentColumn = self.gCustomColumnList[column] if currentColumn["name"] == "Colourspace": return QIcon("icons:LUT.png") if currentColumn["name"] == "Shot Status": status = item.status() if status: return QIcon(gStatusTags[status]) if currentColumn["name"] == "MediaType": mediaType = item.mediaType() if mediaType == hiero.core.TrackItem.kVideo: return QIcon("icons:VideoOnly.png") elif mediaType == hiero.core.TrackItem.kAudio: return QIcon("icons:AudioOnly.png") if currentColumn["name"] == "Artist": try: return QIcon(item.artist()["artistIcon"]) except: return None return None def getSizeHint(self, row, column, item): """ Return the size hint for a cell """ currentColumnName = self.gCustomColumnList[column]["name"] if currentColumnName == "Thumbnail": return QSize(90, 50) return QSize(50, 50) def paintCell(self, row, column, item, painter, option): """ Paint a custom cell. Return True if the cell was painted, or False to continue with the default cell painting. """ currentColumn = self.gCustomColumnList[column] if currentColumn["name"] == "Tags": if option.state & QStyle.State_Selected: painter.fillRect(option.rect, option.palette.highlight()) iconSize = 20 r = QRect(option.rect.x(), option.rect.y() + (option.rect.height() - iconSize) / 2, iconSize, iconSize) tags = item.tags() if len(tags) > 0: painter.save() painter.setClipRect(option.rect) for tag in item.tags(): M = tag.metadata() if not (M.hasKey("tag.status") or M.hasKey("tag.artistID")): QIcon(tag.icon()).paint(painter, r, Qt.AlignLeft) r.translate(r.width() + 2, 0) painter.restore() return True if currentColumn["name"] == "Thumbnail": imageView = None pen = QPen() r = QRect(option.rect.x() + 2, (option.rect.y() + (option.rect.height() - 46) / 2), 85, 46) if not item.source().mediaSource().isMediaPresent(): imageView = QImage("icons:Offline.png") pen.setColor(QColor(Qt.red)) if item.mediaType() == hiero.core.TrackItem.MediaType.kAudio: imageView = QImage("icons:AudioOnly.png") #pen.setColor(QColor(Qt.green)) painter.fillRect(r, QColor(45, 59, 45)) if option.state & QStyle.State_Selected: painter.fillRect(option.rect, option.palette.highlight()) tags = item.tags() painter.save() painter.setClipRect(option.rect) if not imageView: try: imageView = item.thumbnail(item.sourceIn()) pen.setColor(QColor(20, 20, 20)) # If we're here, we probably have a TC error, no thumbnail, so get it from the source Clip... except: pen.setColor(QColor(Qt.red)) if not imageView: try: imageView = item.source().thumbnail() pen.setColor(QColor(Qt.yellow)) except: imageView = QImage("icons:Offline.png") pen.setColor(QColor(Qt.red)) QIcon(QPixmap.fromImage(imageView)).paint(painter, r, Qt.AlignCenter) painter.setPen(pen) painter.drawRoundedRect(r, 1, 1) painter.restore() return True return False def createEditor(self, row, column, item, view): """ Create an editing widget for a custom cell """ self.currentView = view currentColumn = self.gCustomColumnList[column] if currentColumn["cellType"] == "readonly": cle = QLabel() cle.setEnabled(False) cle.setVisible(False) return cle if currentColumn["name"] == "Colourspace": cb = QComboBox() for colourspace in self.gColourSpaces: cb.addItem(colourspace) cb.currentIndexChanged.connect(self.colourspaceChanged) return cb if currentColumn["name"] == "Shot Status": cb = QComboBox() cb.addItem("") for key in gStatusTags.keys(): cb.addItem(QIcon(gStatusTags[key]), key) cb.addItem("--") cb.currentIndexChanged.connect(self.statusChanged) return cb if currentColumn["name"] == "Artist": cb = QComboBox() cb.addItem("") for artist in gArtistList: cb.addItem(artist["artistName"]) cb.addItem("--") cb.currentIndexChanged.connect(self.artistNameChanged) return cb return None def setModelData(self, row, column, item, editor): return False def dropMimeData(self, row, column, item, data, items): """ Handle a drag and drop operation - adds a Dragged Tag to the shot """ for thing in items: if isinstance(thing, hiero.core.Tag): item.addTag(thing) return None def colourspaceChanged(self, index): """ This method is called when Colourspace widget changes index. """ index = self.sender().currentIndex() colourspace = self.gColourSpaces[index] selection = self.currentView.selection() project = selection[0].project() with project.beginUndo("Set Colourspace"): items = [ item for item in selection if (item.mediaType() == hiero.core.TrackItem.MediaType.kVideo) ] for trackItem in items: trackItem.setSourceMediaColourTransform(colourspace) def statusChanged(self, arg): """ This method is called when Shot Status widget changes index. """ view = hiero.ui.activeView() selection = view.selection() status = self.sender().currentText() project = selection[0].project() with project.beginUndo("Set Status"): # A string of "--" characters denotes clear the status if status != "--": for trackItem in selection: trackItem.setStatus(status) else: for trackItem in selection: tTags = trackItem.tags() for tag in tTags: if tag.metadata().hasKey("tag.status"): trackItem.removeTag(tag) break def artistNameChanged(self, arg): """ This method is called when Artist widget changes index. """ view = hiero.ui.activeView() selection = view.selection() name = self.sender().currentText() project = selection[0].project() with project.beginUndo("Assign Artist"): # A string of "--" denotes clear the assignee... if name != "--": for trackItem in selection: trackItem.setArtistByName(name) else: for trackItem in selection: tTags = trackItem.tags() for tag in tTags: if tag.metadata().hasKey("tag.artistID"): trackItem.removeTag(tag) break def _getArtistFromID(self, artistID): """ getArtistFromID -> returns an artist dictionary, by their given ID""" global gArtistList artist = [ element for element in gArtistList if element["artistID"] == int(artistID) ] if not artist: return None return artist[0] def _getArtistFromName(self, artistName): """ getArtistFromID -> returns an artist dictionary, by their given ID """ global gArtistList artist = [ element for element in gArtistList if element["artistName"] == artistName ] if not artist: return None return artist[0] def _artist(self): """_artist -> Returns the artist dictionary assigned to this shot""" artist = None tags = self.tags() for tag in tags: if tag.metadata().hasKey("tag.artistID"): artistID = tag.metadata().value("tag.artistID") artist = self.getArtistFromID(artistID) return artist def _updateArtistTag(self, artistDict): # A shot will only have one artist assigned. Check if one exists and set accordingly artistTag = None tags = self.tags() for tag in tags: if tag.metadata().hasKey("tag.artistID"): artistTag = tag break if not artistTag: artistTag = hiero.core.Tag("Artist") artistTag.setIcon(artistDict["artistIcon"]) artistTag.metadata().setValue("tag.artistID", str(artistDict["artistID"])) artistTag.metadata().setValue("tag.artistName", str(artistDict["artistName"])) artistTag.metadata().setValue("tag.artistDepartment", str(artistDict["artistDepartment"])) self.sequence().editFinished() self.addTag(artistTag) self.sequence().editFinished() return artistTag.setIcon(artistDict["artistIcon"]) artistTag.metadata().setValue("tag.artistID", str(artistDict["artistID"])) artistTag.metadata().setValue("tag.artistName", str(artistDict["artistName"])) artistTag.metadata().setValue("tag.artistDepartment", str(artistDict["artistDepartment"])) self.sequence().editFinished() return def _setArtistByName(self, artistName): """ setArtistByName(artistName) -> sets the artist tag on a TrackItem by a given artistName string""" global gArtistList artist = self.getArtistFromName(artistName) if not artist: print(( "Artist name: {} was not found in " "the gArtistList.").format(artistName)) return # Do the update. self.updateArtistTag(artist) def _setArtistByID(self, artistID): """ setArtistByID(artistID) -> sets the artist tag on a TrackItem by a given artistID integer""" global gArtistList artist = self.getArtistFromID(artistID) if not artist: print("Artist name: {} was not found in the gArtistList.".format( artistID)) return # Do the update. self.updateArtistTag(artist) # Inject status getter and setter methods into hiero.core.TrackItem hiero.core.TrackItem.artist = _artist hiero.core.TrackItem.setArtistByName = _setArtistByName hiero.core.TrackItem.setArtistByID = _setArtistByID hiero.core.TrackItem.getArtistFromName = _getArtistFromName hiero.core.TrackItem.getArtistFromID = _getArtistFromID hiero.core.TrackItem.updateArtistTag = _updateArtistTag def _status(self): """status -> Returns the Shot status. None if no Status is set.""" status = None tags = self.tags() for tag in tags: if tag.metadata().hasKey("tag.status"): status = tag.metadata().value("tag.status") return status def _setStatus(self, status): """setShotStatus(status) -> Method to set the Status of a Shot. Adds a special kind of status Tag to a TrackItem Example: myTrackItem.setStatus("Final") @param status - a string, corresponding to the Status name """ global gStatusTags # Get a valid Tag object from the Global list of statuses if not status in gStatusTags.keys(): print("Status requested was not a valid Status string.") return # A shot should only have one status. Check if one exists and set accordingly statusTag = None tags = self.tags() for tag in tags: if tag.metadata().hasKey("tag.status"): statusTag = tag break if not statusTag: statusTag = hiero.core.Tag("Status") statusTag.setIcon(gStatusTags[status]) statusTag.metadata().setValue("tag.status", status) self.addTag(statusTag) statusTag.setIcon(gStatusTags[status]) statusTag.metadata().setValue("tag.status", status) self.sequence().editFinished() return # Inject status getter and setter methods into hiero.core.TrackItem hiero.core.TrackItem.setStatus = _setStatus hiero.core.TrackItem.status = _status # This is a convenience method for returning QActions with a triggered method based on the title string def titleStringTriggeredAction(title, method, icon=None): action = QAction(title, None) action.setIcon(QIcon(icon)) # We do this magic, so that the title string from the action is used to set the status def methodWrapper(): method(title) action.triggered.connect(methodWrapper) return action # Menu which adds a Set Status Menu to Timeline and Spreadsheet Views class SetStatusMenu(QMenu): def __init__(self): QMenu.__init__(self, "Set Status", None) global gStatusTags self.statuses = gStatusTags self._statusActions = self.createStatusMenuActions() # Add the Actions to the Menu. for act in self.menuActions: self.addAction(act) hiero.core.events.registerInterest("kShowContextMenu/kTimeline", self.eventHandler) hiero.core.events.registerInterest("kShowContextMenu/kSpreadsheet", self.eventHandler) def createStatusMenuActions(self): self.menuActions = [] for status in self.statuses: self.menuActions += [ titleStringTriggeredAction( status, self.setStatusFromMenuSelection, icon=gStatusTags[status]) ] def setStatusFromMenuSelection(self, menuSelectionStatus): selectedShots = [ item for item in self._selection if (isinstance(item, hiero.core.TrackItem)) ] selectedTracks = [ item for item in self._selection if (isinstance(item, (hiero.core.VideoTrack, hiero.core.AudioTrack))) ] # If we have a Track Header Selection, no shots could be selected, so create shotSelection list if len(selectedTracks) >= 1: for track in selectedTracks: selectedShots += [ item for item in track.items() if (isinstance(item, hiero.core.TrackItem)) ] # It's possible no shots exist on the Track, in which case nothing is required if len(selectedShots) == 0: return currentProject = selectedShots[0].project() with currentProject.beginUndo("Set Status"): # Shots selected for shot in selectedShots: shot.setStatus(menuSelectionStatus) # This handles events from the Project Bin View def eventHandler(self, event): if not hasattr(event.sender, "selection"): # Something has gone wrong, we should only be here if raised # by the Timeline/Spreadsheet view which gives a selection. return # Set the current selection self._selection = event.sender.selection() # Return if there's no Selection. We won't add the Menu. if len(self._selection) == 0: return event.menu.addMenu(self) # Menu which adds a Set Status Menu to Timeline and Spreadsheet Views class AssignArtistMenu(QMenu): def __init__(self): QMenu.__init__(self, "Assign Artist", None) global gArtistList self.artists = gArtistList self._artistsActions = self.createAssignArtistMenuActions() # Add the Actions to the Menu. for act in self.menuActions: self.addAction(act) hiero.core.events.registerInterest("kShowContextMenu/kTimeline", self.eventHandler) hiero.core.events.registerInterest("kShowContextMenu/kSpreadsheet", self.eventHandler) def createAssignArtistMenuActions(self): self.menuActions = [] for artist in self.artists: self.menuActions += [ titleStringTriggeredAction( artist["artistName"], self.setArtistFromMenuSelection, icon=artist["artistIcon"]) ] def setArtistFromMenuSelection(self, menuSelectionArtist): selectedShots = [ item for item in self._selection if (isinstance(item, hiero.core.TrackItem)) ] selectedTracks = [ item for item in self._selection if (isinstance(item, (hiero.core.VideoTrack, hiero.core.AudioTrack))) ] # If we have a Track Header Selection, no shots could be selected, so create shotSelection list if len(selectedTracks) >= 1: for track in selectedTracks: selectedShots += [ item for item in track.items() if (isinstance(item, hiero.core.TrackItem)) ] # It's possible no shots exist on the Track, in which case nothing is required if len(selectedShots) == 0: return currentProject = selectedShots[0].project() with currentProject.beginUndo("Assign Artist"): # Shots selected for shot in selectedShots: shot.setArtistByName(menuSelectionArtist) # This handles events from the Project Bin View def eventHandler(self, event): if not hasattr(event.sender, "selection"): # Something has gone wrong, we should only be here if raised # by the Timeline/Spreadsheet view which gives a selection. return # Set the current selection self._selection = event.sender.selection() # Return if there's no Selection. We won't add the Menu. if len(self._selection) == 0: return event.menu.addMenu(self) # Add the "Set Status" context menu to Timeline and Spreadsheet if kAddStatusMenu: setStatusMenu = SetStatusMenu() if kAssignArtistMenu: assignArtistMenu = AssignArtistMenu() # Register our custom columns hiero.ui.customColumn = CustomSpreadsheetColumns() ================================================ FILE: openpype/hosts/hiero/api/startup/Python/StartupUI/Purge.py ================================================ # Purge Unused Clips - Removes any unused Clips from a Project # Usage: Copy to ~/.hiero/Python/StartupUI # Demonstrates the use of hiero.core.find_items module. # Usage: Right-click on an item in the Bin View > "Purge Unused Clips" # Result: Any Clips not used in a Sequence in the active project will be removed # Requires Hiero 1.5v1 or later. # Version 1.1 import hiero import hiero.core.find_items try: from PySide.QtGui import * from PySide.QtCore import * except: from PySide2.QtGui import * from PySide2.QtWidgets import * from PySide2.QtCore import * class PurgeUnusedAction(QAction): def __init__(self): QAction.__init__(self, "Purge Unused Clips", None) self.triggered.connect(self.PurgeUnused) hiero.core.events.registerInterest("kShowContextMenu/kBin", self.eventHandler) self.setIcon(QIcon("icons:TagDelete.png")) # Method to return whether a Bin is empty... def binIsEmpty(self, b): numBinItems = 0 bItems = b.items() empty = False if len(bItems) == 0: empty = True return empty else: for b in bItems: if isinstance(b, hiero.core.BinItem) or isinstance( b, hiero.core.Bin): numBinItems += 1 if numBinItems == 0: empty = True return empty def PurgeUnused(self): #Get selected items item = self.selectedItem proj = item.project() # Build a list of Projects SEQS = hiero.core.findItems(proj, "Sequences") # Build a list of Clips CLIPSTOREMOVE = hiero.core.findItems(proj, "Clips") if len(SEQS) == 0: # Present Dialog Asking if User wants to remove Clips msgBox = QMessageBox() msgBox.setText("Purge Unused Clips") msgBox.setInformativeText( "You have no Sequences in this Project. Do you want to remove all Clips (%i) from Project: %s?" % (len(CLIPSTOREMOVE), proj.name())) msgBox.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) msgBox.setDefaultButton(QMessageBox.Ok) ret = msgBox.exec_() if ret == QMessageBox.Cancel: print("Not purging anything.") elif ret == QMessageBox.Ok: with proj.beginUndo("Purge Unused Clips"): BINS = [] for clip in CLIPSTOREMOVE: BI = clip.binItem() B = BI.parentBin() BINS += [B] print("Removing: {}".format(BI)) try: B.removeItem(BI) except: print("Unable to remove: {}".format(BI)) return # For each sequence, iterate through each track Item, see if the Clip is in the CLIPS list. # Remaining items in CLIPS will be removed for seq in SEQS: #Loop through selected and make folders for track in seq: for trackitem in track: if trackitem.source() in CLIPSTOREMOVE: CLIPSTOREMOVE.remove(trackitem.source()) # Present Dialog Asking if User wants to remove Clips msgBox = QMessageBox() msgBox.setText("Purge Unused Clips") msgBox.setInformativeText("Remove %i unused Clips from Project %s?" % (len(CLIPSTOREMOVE), proj.name())) msgBox.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) msgBox.setDefaultButton(QMessageBox.Ok) ret = msgBox.exec_() if ret == QMessageBox.Cancel: print("Cancel") return elif ret == QMessageBox.Ok: BINS = [] with proj.beginUndo("Purge Unused Clips"): # Delete the rest of the Clips for clip in CLIPSTOREMOVE: BI = clip.binItem() B = BI.parentBin() BINS += [B] print("Removing: {}".format(BI)) try: B.removeItem(BI) except: print("Unable to remove: {}".format(BI)) def eventHandler(self, event): if not hasattr(event.sender, "selection"): # Something has gone wrong, we shouldn't only be here if raised # by the Bin view which will give a selection. return self.selectedItem = None s = event.sender.selection() if len(s) >= 1: self.selectedItem = s[0] title = "Purge Unused Clips" self.setText(title) event.menu.addAction(self) return # Instantiate the action to get it to register itself. PurgeUnusedAction = PurgeUnusedAction() ================================================ FILE: openpype/hosts/hiero/api/startup/Python/StartupUI/nukeStyleKeyboardShortcuts.py ================================================ # nukeStyleKeyboardShortcuts, v1, 30/07/2012, Ant Nasce. # A few Nuke-Style File menu shortcuts for those whose muscle memory has set in... # Usage: Copy this file to ~/.hiero/Python/StartupUI/ import hiero.ui try: from PySide.QtGui import * from PySide.QtCore import * except: from PySide2.QtGui import * from PySide2.QtWidgets import * from PySide2.QtCore import * #---------------------------------------------- a = hiero.ui.findMenuAction('Import File(s)...') # Note: You probably best to make this 'Ctrl+R' - currently conflicts with "Red" in the Viewer! a.setShortcut(QKeySequence("R")) #---------------------------------------------- a = hiero.ui.findMenuAction('Import Folder(s)...') a.setShortcut(QKeySequence('Shift+R')) #---------------------------------------------- a = hiero.ui.findMenuAction("Import EDL/XML/AAF...") a.setShortcut(QKeySequence('Ctrl+Shift+O')) #---------------------------------------------- a = hiero.ui.findMenuAction("Metadata View") a.setShortcut(QKeySequence("I")) #---------------------------------------------- a = hiero.ui.findMenuAction("Edit Settings") a.setShortcut(QKeySequence("S")) #---------------------------------------------- a = hiero.ui.findMenuAction("Monitor Output") if a: a.setShortcut(QKeySequence('Ctrl+U')) #---------------------------------------------- ================================================ FILE: openpype/hosts/hiero/api/startup/Python/StartupUI/otioimporter/OTIOImport.py ================================================ # MIT License # # Copyright (c) 2018 Daniel Flehner Heen (Storm Studios) # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import os import sys import hiero.core import hiero.ui try: from urllib import unquote except ImportError: from urllib.parse import unquote # lint:ok import opentimelineio as otio def get_transition_type(otio_item, otio_track): _in, _out = otio_track.neighbors_of(otio_item) if isinstance(_in, otio.schema.Gap): _in = None if isinstance(_out, otio.schema.Gap): _out = None if _in and _out: return "dissolve" elif _in and not _out: return "fade_out" elif not _in and _out: return "fade_in" else: return "unknown" def find_trackitem(name, hiero_track): for item in hiero_track.items(): if item.name() == name: return item return None def get_neighboring_trackitems(otio_item, otio_track, hiero_track): _in, _out = otio_track.neighbors_of(otio_item) trackitem_in = None trackitem_out = None if _in: trackitem_in = find_trackitem(_in.name, hiero_track) if _out: trackitem_out = find_trackitem(_out.name, hiero_track) return trackitem_in, trackitem_out def apply_transition(otio_track, otio_item, track): # Figure out type of transition transition_type = get_transition_type(otio_item, otio_track) # Figure out track kind for getattr below if isinstance(track, hiero.core.VideoTrack): kind = "" else: kind = "Audio" try: # Gather TrackItems involved in trasition item_in, item_out = get_neighboring_trackitems( otio_item, otio_track, track ) # Create transition object if transition_type == "dissolve": transition_func = getattr( hiero.core.Transition, 'create{kind}DissolveTransition'.format(kind=kind) ) transition = transition_func( item_in, item_out, otio_item.in_offset.value, otio_item.out_offset.value ) elif transition_type == "fade_in": transition_func = getattr( hiero.core.Transition, 'create{kind}FadeInTransition'.format(kind=kind) ) transition = transition_func( item_out, otio_item.out_offset.value ) elif transition_type == "fade_out": transition_func = getattr( hiero.core.Transition, 'create{kind}FadeOutTransition'.format(kind=kind) ) transition = transition_func( item_in, otio_item.in_offset.value ) else: # Unknown transition return # Apply transition to track track.addTransition(transition) except Exception, e: sys.stderr.write( 'Unable to apply transition "{t}": "{e}"\n'.format( t=otio_item, e=e ) ) def prep_url(url_in): url = unquote(url_in) if url.startswith("file://localhost/"): return url.replace("file://localhost/", "") url = '{url}'.format( sep=url.startswith(os.sep) and "" or os.sep, url=url.startswith(os.sep) and url[1:] or url ) return url def create_offline_mediasource(otio_clip, path=None): hiero_rate = hiero.core.TimeBase( otio_clip.source_range.start_time.rate ) if isinstance(otio_clip.media_reference, otio.schema.ExternalReference): source_range = otio_clip.available_range() else: source_range = otio_clip.source_range if path is None: path = otio_clip.name media = hiero.core.MediaSource.createOfflineVideoMediaSource( prep_url(path), source_range.start_time.value, source_range.duration.value, hiero_rate, source_range.start_time.value ) return media def load_otio(otio_file): otio_timeline = otio.adapters.read_from_file(otio_file) build_sequence(otio_timeline) marker_color_map = { "PINK": "Magenta", "RED": "Red", "ORANGE": "Yellow", "YELLOW": "Yellow", "GREEN": "Green", "CYAN": "Cyan", "BLUE": "Blue", "PURPLE": "Magenta", "MAGENTA": "Magenta", "BLACK": "Blue", "WHITE": "Green", "MINT": "Cyan" } def get_tag(tagname, tagsbin): for tag in tagsbin.items(): if tag.name() == tagname: return tag if isinstance(tag, hiero.core.Bin): tag = get_tag(tagname, tag) if tag is not None: return tag return None def add_metadata(metadata, hiero_item): for key, value in metadata.items(): if isinstance(value, dict): add_metadata(value, hiero_item) continue if value is not None: if not key.startswith("tag."): key = "tag." + key hiero_item.metadata().setValue(key, str(value)) def add_markers(otio_item, hiero_item, tagsbin): if isinstance(otio_item, (otio.schema.Stack, otio.schema.Clip)): markers = otio_item.markers elif isinstance(otio_item, otio.schema.Timeline): markers = otio_item.tracks.markers else: markers = [] for marker in markers: marker_color = marker.color _tag = get_tag(marker.name, tagsbin) if _tag is None: _tag = get_tag(marker_color_map[marker_color], tagsbin) if _tag is None: _tag = hiero.core.Tag(marker_color_map[marker.color]) start = marker.marked_range.start_time.value end = ( marker.marked_range.start_time.value + marker.marked_range.duration.value ) tag = hiero_item.addTag(_tag) tag.setName(marker.name or marker_color_map[marker_color]) # Add metadata add_metadata(marker.metadata, tag) def create_track(otio_track, tracknum, track_kind): # Add track kind when dealing with nested stacks if isinstance(otio_track, otio.schema.Stack): otio_track.kind = track_kind # Create a Track if otio_track.kind == otio.schema.TrackKind.Video: track = hiero.core.VideoTrack( otio_track.name or 'Video{n}'.format(n=tracknum) ) else: track = hiero.core.AudioTrack( otio_track.name or 'Audio{n}'.format(n=tracknum) ) return track def create_clip(otio_clip): # Create MediaSource otio_media = otio_clip.media_reference if isinstance(otio_media, otio.schema.ExternalReference): url = prep_url(otio_media.target_url) media = hiero.core.MediaSource(url) if media.isOffline(): media = create_offline_mediasource(otio_clip, url) else: media = create_offline_mediasource(otio_clip) # Create Clip clip = hiero.core.Clip(media) return clip def create_trackitem(playhead, track, otio_clip, clip, tagsbin): source_range = otio_clip.source_range trackitem = track.createTrackItem(otio_clip.name) trackitem.setPlaybackSpeed(source_range.start_time.rate) trackitem.setSource(clip) # Check for speed effects and adjust playback speed accordingly for effect in otio_clip.effects: if isinstance(effect, otio.schema.LinearTimeWarp): trackitem.setPlaybackSpeed( trackitem.playbackSpeed() * effect.time_scalar ) # If reverse playback speed swap source in and out if trackitem.playbackSpeed() < 0: source_out = source_range.start_time.value source_in = ( source_range.start_time.value + source_range.duration.value ) - 1 timeline_in = playhead + source_out timeline_out = ( timeline_in + source_range.duration.value ) - 1 else: # Normal playback speed source_in = source_range.start_time.value source_out = ( source_range.start_time.value + source_range.duration.value ) - 1 timeline_in = playhead timeline_out = ( timeline_in + source_range.duration.value ) - 1 # Set source and timeline in/out points trackitem.setSourceIn(source_in) trackitem.setSourceOut(source_out) trackitem.setTimelineIn(timeline_in) trackitem.setTimelineOut(timeline_out) # Add markers add_markers(otio_clip, trackitem, tagsbin) return trackitem def build_sequence( otio_timeline, project=None, sequence=None, track_kind=None): if project is None: if sequence: project = sequence.project() else: # Per version 12.1v2 there is no way of getting active project project = hiero.core.projects(hiero.core.Project.kUserProjects)[-1] projectbin = project.clipsBin() if not sequence: # Create a Sequence sequence = hiero.core.Sequence(otio_timeline.name or "OTIOSequence") # Set sequence settings from otio timeline if available if hasattr(otio_timeline, "global_start_time"): if otio_timeline.global_start_time: start_time = otio_timeline.global_start_time sequence.setFramerate(start_time.rate) sequence.setTimecodeStart(start_time.value) # Create a Bin to hold clips projectbin.addItem(hiero.core.BinItem(sequence)) sequencebin = hiero.core.Bin(sequence.name()) projectbin.addItem(sequencebin) else: sequencebin = projectbin # Get tagsBin tagsbin = hiero.core.project("Tag Presets").tagsBin() # Add timeline markers add_markers(otio_timeline, sequence, tagsbin) if isinstance(otio_timeline, otio.schema.Timeline): tracks = otio_timeline.tracks else: tracks = [otio_timeline] for tracknum, otio_track in enumerate(tracks): playhead = 0 _transitions = [] # Add track to sequence track = create_track(otio_track, tracknum, track_kind) sequence.addTrack(track) # iterate over items in track for itemnum, otio_clip in enumerate(otio_track): if isinstance(otio_clip, otio.schema.Stack): bar = hiero.ui.mainWindow().statusBar() bar.showMessage( "Nested sequences are created separately.", timeout=3000 ) build_sequence(otio_clip, project, otio_track.kind) elif isinstance(otio_clip, otio.schema.Clip): # Create a Clip clip = create_clip(otio_clip) # Add Clip to a Bin sequencebin.addItem(hiero.core.BinItem(clip)) # Create TrackItem trackitem = create_trackitem( playhead, track, otio_clip, clip, tagsbin ) # Add trackitem to track track.addTrackItem(trackitem) # Update playhead playhead = trackitem.timelineOut() + 1 elif isinstance(otio_clip, otio.schema.Transition): # Store transitions for when all clips in the track are created _transitions.append((otio_track, otio_clip)) elif isinstance(otio_clip, otio.schema.Gap): # Hiero has no fillers, slugs or blanks at the moment playhead += otio_clip.source_range.duration.value # Apply transitions we stored earlier now that all clips are present for otio_track, otio_item in _transitions: apply_transition(otio_track, otio_item, track) ================================================ FILE: openpype/hosts/hiero/api/startup/Python/StartupUI/otioimporter/__init__.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- __author__ = "Daniel Flehner Heen" __credits__ = ["Jakub Jezek", "Daniel Flehner Heen"] import hiero.ui import hiero.core import PySide2.QtWidgets as qw from openpype.hosts.hiero.api.otio.hiero_import import load_otio class OTIOProjectSelect(qw.QDialog): def __init__(self, projects, *args, **kwargs): super(OTIOProjectSelect, self).__init__(*args, **kwargs) self.setWindowTitle("Please select active project") self.layout = qw.QVBoxLayout() self.label = qw.QLabel( "Unable to determine which project to import sequence to.\n" "Please select one." ) self.layout.addWidget(self.label) self.projects = qw.QComboBox() self.projects.addItems(map(lambda p: p.name(), projects)) self.layout.addWidget(self.projects) QBtn = qw.QDialogButtonBox.Ok | qw.QDialogButtonBox.Cancel self.buttonBox = qw.QDialogButtonBox(QBtn) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) self.layout.addWidget(self.buttonBox) self.setLayout(self.layout) def get_sequence(view): sequence = None if isinstance(view, hiero.ui.TimelineEditor): sequence = view.sequence() elif isinstance(view, hiero.ui.BinView): for item in view.selection(): if not hasattr(item, "acitveItem"): continue if isinstance(item.activeItem(), hiero.core.Sequence): sequence = item.activeItem() return sequence def OTIO_menu_action(event): # Menu actions otio_import_action = hiero.ui.createMenuAction( "Import OTIO...", open_otio_file, icon=None ) otio_add_track_action = hiero.ui.createMenuAction( "New Track(s) from OTIO...", open_otio_file, icon=None ) otio_add_track_action.setEnabled(False) hiero.ui.registerAction(otio_import_action) hiero.ui.registerAction(otio_add_track_action) view = hiero.ui.currentContextMenuView() if view: sequence = get_sequence(view) if sequence: otio_add_track_action.setEnabled(True) for action in event.menu.actions(): if action.text() == "Import": action.menu().addAction(otio_import_action) action.menu().addAction(otio_add_track_action) elif action.text() == "New Track": action.menu().addAction(otio_add_track_action) def open_otio_file(): files = hiero.ui.openFileBrowser( caption="Please select an OTIO file of choice", pattern="*.otio", requiredExtension=".otio" ) selection = None sequence = None view = hiero.ui.currentContextMenuView() if view: sequence = get_sequence(view) selection = view.selection() if sequence: project = sequence.project() elif selection: project = selection[0].project() elif len(hiero.core.projects()) > 1: dialog = OTIOProjectSelect(hiero.core.projects()) if dialog.exec_(): project = hiero.core.projects()[dialog.projects.currentIndex()] else: bar = hiero.ui.mainWindow().statusBar() bar.showMessage( "OTIO Import aborted by user", timeout=3000 ) return else: project = hiero.core.projects()[-1] for otio_file in files: load_otio(otio_file, project, sequence) # HieroPlayer is quite limited and can't create transitions etc. if not hiero.core.isHieroPlayer(): hiero.core.events.registerInterest( "kShowContextMenu/kBin", OTIO_menu_action ) hiero.core.events.registerInterest( "kShowContextMenu/kTimeline", OTIO_menu_action ) ================================================ FILE: openpype/hosts/hiero/api/startup/Python/StartupUI/setPosterFrame.py ================================================ import hiero.core import hiero.ui try: from PySide.QtGui import * from PySide.QtCore import * except: from PySide2.QtGui import * from PySide2.QtWidgets import * from PySide2.QtCore import * def setPosterFrame(posterFrame=.5): """ Update the poster frame of the given clipItmes posterFrame = .5 uses the centre frame, a value of 0 uses the first frame, a value of 1 uses the last frame """ view = hiero.ui.activeView() selectedBinItems = view.selection() selectedClipItems = [(item.activeItem() if hasattr(item, "activeItem") else item) for item in selectedBinItems] for clip in selectedClipItems: centreFrame = int(clip.duration() * posterFrame) clip.setPosterFrame(centreFrame) class SetPosterFrameAction(QAction): def __init__(self): QAction.__init__(self, "Set Poster Frame (centre)", None) self._selection = None self.triggered.connect(lambda: setPosterFrame(.5)) hiero.core.events.registerInterest("kShowContextMenu/kBin", self.eventHandler) def eventHandler(self, event): view = event.sender # Add the Menu to the right-click menu event.menu.addAction(self) # The act of initialising the action adds it to the right-click menu... SetPosterFrameAction() ================================================ FILE: openpype/hosts/hiero/api/startup/TaskPresets/10.5/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml ================================================ 991 //10.11.0.184/171001_ftrack/tgbvfx/editorial/hiero/workspace/ 1 True 3 {shot}/editorial_raw.%04d.{fileext} default exr False all False False False False True 8 bit (auto detect) True False False None None None None None None None None None Zip (16 scanline) 32 bit float False False False channels, layers and views 45.0 False all metadata Write_{ext} Cubic None 1.0
True
width
False Blend
{shot}/editorial.%04d.{ext} default exr False all False False False False True 8 bit (auto detect) True False True None None None None None None None None None Zip (16 scanline) 16 bit half False False False channels, layers and views 45.0 False all metadata Write_{ext} Cubic To Sequence Resolution 1.0
True
width
False Blend
{shot}/editorial.nk True default mov rgb False False False False True True {shot}/editorial_raw.%04d.{fileext} Cubic None 1.0
True
width
False Blend False True True 0 40000000 12 31 2 avc1 H.264 Auto mov32 20000 False True True False False {shot}/editorial_raw.%04d.{fileext} None None None None None None None None None 8 bit (auto detect) True False Write_{ext} False
False Custom True 10
================================================ FILE: openpype/hosts/hiero/api/startup/TaskPresets/11.1/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml ================================================ 991 //10.11.0.184/171001_ftrack/tgbvfx/editorial/hiero/workspace/ 1 True 3 {shot}/editorial_raw.%04d.{fileext} default exr False all False False False False True 8 bit (auto detect) True False False None None None None None None None None None Zip (16 scanline) 32 bit float False False False channels, layers and views 45.0 False all metadata Write_{ext} Cubic None 1.0
True
width
False Blend
{shot}/editorial.%04d.{ext} default exr False all False False False False True 8 bit (auto detect) True False True None None None None None None None None None Zip (16 scanline) 16 bit half False False False channels, layers and views 45.0 False all metadata Write_{ext} Cubic To Sequence Resolution 1.0
True
width
False Blend
{shot}/editorial.nk True default mov rgb False False False False True True {shot}/editorial_raw.%04d.{fileext} Cubic None 1.0
True
width
False Blend False True True 0 40000000 12 31 2 avc1 H.264 Auto mov32 20000 False True True False False {shot}/editorial_raw.%04d.{fileext} None None None None None None None None None 8 bit (auto detect) True False Write_{ext} False
False Custom True 10
================================================ FILE: openpype/hosts/hiero/api/startup/TaskPresets/11.2/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml ================================================ 991 //10.11.0.184/171001_ftrack/tgbvfx/editorial/hiero/workspace/ 1 True 3 {shot}/editorial_raw.%04d.{fileext} default exr False all False False False False True 8 bit (auto detect) True False False None None None None None None None None None Zip (16 scanline) 32 bit float False False False channels, layers and views 45.0 False all metadata Write_{ext} Cubic None 1.0
True
width
False Blend
{shot}/editorial.%04d.{ext} default exr False all False False False False True 8 bit (auto detect) True False True None None None None None None None None None Zip (16 scanline) 16 bit half False False False channels, layers and views 45.0 False all metadata Write_{ext} Cubic To Sequence Resolution 1.0
True
width
False Blend
{shot}/editorial.nk True default mov rgb False False False False True True {shot}/editorial_raw.%04d.{fileext} Cubic None 1.0
True
width
False Blend False True True 0 40000000 12 31 2 avc1 H.264 Auto mov32 20000 False True True False False {shot}/editorial_raw.%04d.{fileext} None None None None None None None None None 8 bit (auto detect) True False Write_{ext} False
False Custom True 10
================================================ FILE: openpype/hosts/hiero/api/style.css ================================================ QWidget { font-size: 13px; } QSpinBox { padding: 2; max-width: 8em; } QLineEdit { padding: 2; min-width: 15em; } QVBoxLayout { min-width: 15em; background-color: #201f1f; } QComboBox { min-width: 8em; } #sectionContent { background-color: #2E2D2D; } ================================================ FILE: openpype/hosts/hiero/api/tags.py ================================================ import json import re import os import hiero from openpype.client import get_project, get_assets from openpype.lib import Logger from openpype.pipeline import get_current_project_name log = Logger.get_logger(__name__) def tag_data(): return { "[Lenses]": { "Set lense here": { "editable": "1", "note": "Adjust parameters of your lense and then drop to clip. Remember! You can always overwrite on clip", # noqa "icon": "lense.png", "metadata": { "focalLengthMm": 57 } } }, # "NukeScript": { # "editable": "1", # "note": "Collecting track items to Nuke scripts.", # "icon": "icons:TagNuke.png", # "metadata": { # "family": "nukescript", # "subset": "main" # } # }, "Comment": { "editable": "1", "note": "Comment on a shot.", "icon": "icons:TagComment.png", "metadata": { "family": "comment", "subset": "main" } }, "FrameMain": { "editable": "1", "note": "Publishing a frame subset.", "icon": "z_layer_main.png", "metadata": { "family": "frame", "subset": "main", "format": "png" } } } def create_tag(key, data): """ Creating Tag object. Args: key (str): name of tag data (dict): parameters of tag Returns: object: Tag object """ tag = hiero.core.Tag(str(key)) return update_tag(tag, data) def update_tag(tag, data): """ Fixing Tag object. Args: tag (obj): Tag object data (dict): parameters of tag """ # set icon if any available in input data if data.get("icon"): tag.setIcon(str(data["icon"])) # get metadata of tag mtd = tag.metadata() # get metadata key from data data_mtd = data.get("metadata", {}) # set all data metadata to tag metadata for _k, _v in data_mtd.items(): value = str(_v) if type(_v) == dict: value = json.dumps(_v) # set the value mtd.setValue( "tag.{}".format(str(_k)), value ) # set note description of tag tag.setNote(str(data["note"])) return tag def add_tags_to_workfile(): """ Will create default tags from presets. """ from .lib import get_current_project def add_tag_to_bin(root_bin, name, data): # for Tags to be created in root level Bin # at first check if any of input data tag is not already created done_tag = next((t for t in root_bin.items() if str(name) in t.name()), None) if not done_tag: # create Tag tag = create_tag(name, data) tag.setName(str(name)) log.debug("__ creating tag: {}".format(tag)) # adding Tag to Root Bin root_bin.addItem(tag) else: # update only non hierarchy tags update_tag(done_tag, data) done_tag.setName(str(name)) log.debug("__ updating tag: {}".format(done_tag)) # get project and root bin object project = get_current_project() root_bin = project.tagsBin() if "Tag Presets" in project.name(): return log.debug("Setting default tags on project: {}".format(project.name())) # get hiero tags.json nks_pres_tags = tag_data() # Get project task types. project_name = get_current_project_name() project_doc = get_project(project_name) tasks = project_doc["config"]["tasks"] nks_pres_tags["[Tasks]"] = {} log.debug("__ tasks: {}".format(tasks)) for task_type in tasks.keys(): nks_pres_tags["[Tasks]"][task_type.lower()] = { "editable": "1", "note": task_type, "icon": "icons:TagGood.png", "metadata": { "family": "task", "type": task_type } } # Get project assets. Currently Ftrack specific to differentiate between # asset builds and shots. if int(os.getenv("TAG_ASSETBUILD_STARTUP", 0)) == 1: nks_pres_tags["[AssetBuilds]"] = {} for asset in get_assets( project_name, fields=["name", "data.entityType"] ): if asset["data"]["entityType"] == "AssetBuild": nks_pres_tags["[AssetBuilds]"][asset["name"]] = { "editable": "1", "note": "", "icon": { "path": "icons:TagActor.png" }, "metadata": { "family": "assetbuild" } } # loop through tag data dict and create deep tag structure for _k, _val in nks_pres_tags.items(): # check if key is not decorated with [] so it is defined as bin bin_find = None pattern = re.compile(r"\[(.*)\]") _bin_finds = pattern.findall(_k) # if there is available any then pop it to string if _bin_finds: bin_find = _bin_finds.pop() # if bin was found then create or update if bin_find: root_add = False # first check if in root lever is not already created bins bins = [b for b in root_bin.items() if b.name() in str(bin_find)] if bins: bin = bins.pop() else: root_add = True # create Bin object for processing bin = hiero.core.Bin(str(bin_find)) # update or create tags in the bin for __k, __v in _val.items(): add_tag_to_bin(bin, __k, __v) # finally add the Bin object to the root level Bin if root_add: # adding Tag to Root Bin root_bin.addItem(bin) else: add_tag_to_bin(root_bin, _k, _val) log.info("Default Tags were set...") ================================================ FILE: openpype/hosts/hiero/api/workio.py ================================================ import os import hiero from openpype.lib import Logger log = Logger.get_logger(__name__) def file_extensions(): return [".hrox"] def has_unsaved_changes(): # There are no methods for querying unsaved changes to a project, so # enforcing to always save. # but we could at least check if a current open script has a path project = hiero.core.projects()[-1] if project.path(): return True else: return False def save_file(filepath): file = os.path.basename(filepath) project = hiero.core.projects()[-1] if project: log.info("Saving project: `{}` as '{}'".format(project.name(), file)) project.saveAs(filepath) else: log.info("Creating new project...") project = hiero.core.newProject() project.saveAs(filepath) def open_file(filepath): """Manually fire the kBeforeProjectLoad event in order to work around a bug in Hiero. The Foundry has logged this bug as: Bug 40413 - Python API - kBeforeProjectLoad event type is not triggered when calling hiero.core.openProject() (only triggered through UI) It exists in all versions of Hiero through (at least) v1.9v1b12. Once this bug is fixed, a version check will need to be added here in order to prevent accidentally firing this event twice. The following commented-out code is just an example, and will need to be updated when the bug is fixed to catch the correct versions.""" # if (hiero.core.env['VersionMajor'] < 1 or # hiero.core.env['VersionMajor'] == 1 and hiero.core.env['VersionMinor'] < 10: hiero.core.events.sendEvent("kBeforeProjectLoad", None) project = hiero.core.projects()[-1] # open project file hiero.core.openProject(filepath.replace(os.path.sep, "/")) # close previous project project.close() return True def current_file(): current_file = hiero.core.projects()[-1].path() if not current_file: return None return os.path.normpath(current_file) def work_root(session): return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") ================================================ FILE: openpype/hosts/hiero/plugins/create/create_shot_clip.py ================================================ from copy import deepcopy import openpype.hosts.hiero.api as phiero # from openpype.hosts.hiero.api import plugin, lib # reload(lib) # reload(plugin) # reload(phiero) class CreateShotClip(phiero.Creator): """Publishable clip""" label = "Create Publishable Clip" family = "clip" icon = "film" defaults = ["Main"] gui_tracks = [track.name() for track in phiero.get_current_sequence().videoTracks()] gui_name = "Pype publish attributes creator" gui_info = "Define sequential rename and fill hierarchy data." gui_inputs = { "renameHierarchy": { "type": "section", "label": "Shot Hierarchy And Rename Settings", "target": "ui", "order": 0, "value": { "hierarchy": { "value": "{folder}/{sequence}", "type": "QLineEdit", "label": "Shot Parent Hierarchy", "target": "tag", "toolTip": "Parents folder for shot root folder, Template filled with `Hierarchy Data` section", # noqa "order": 0}, "clipRename": { "value": False, "type": "QCheckBox", "label": "Rename clips", "target": "ui", "toolTip": "Renaming selected clips on fly", # noqa "order": 1}, "clipName": { "value": "{sequence}{shot}", "type": "QLineEdit", "label": "Clip Name Template", "target": "ui", "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa "order": 2}, "countFrom": { "value": 10, "type": "QSpinBox", "label": "Count sequence from", "target": "ui", "toolTip": "Set when the sequence number stafrom", # noqa "order": 3}, "countSteps": { "value": 10, "type": "QSpinBox", "label": "Stepping number", "target": "ui", "toolTip": "What number is adding every new step", # noqa "order": 4}, } }, "hierarchyData": { "type": "dict", "label": "Shot Template Keywords", "target": "tag", "order": 1, "value": { "folder": { "value": "shots", "type": "QLineEdit", "label": "{folder}", "target": "tag", "toolTip": "Name of folder used for root of generated shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 0}, "episode": { "value": "ep01", "type": "QLineEdit", "label": "{episode}", "target": "tag", "toolTip": "Name of episode.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 1}, "sequence": { "value": "sq01", "type": "QLineEdit", "label": "{sequence}", "target": "tag", "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 2}, "track": { "value": "{_track_}", "type": "QLineEdit", "label": "{track}", "target": "tag", "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 3}, "shot": { "value": "sh###", "type": "QLineEdit", "label": "{shot}", "target": "tag", "toolTip": "Name of shot. `#` is converted to paded number. \nAlso could be used with usable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 4} } }, "verticalSync": { "type": "section", "label": "Vertical Synchronization Of Attributes", "target": "ui", "order": 2, "value": { "vSyncOn": { "value": True, "type": "QCheckBox", "label": "Enable Vertical Sync", "target": "ui", "toolTip": "Switch on if you want clips above each other to share its attributes", # noqa "order": 0}, "vSyncTrack": { "value": gui_tracks, # noqa "type": "QComboBox", "label": "Hero track", "target": "ui", "toolTip": "Select driving track name which should be hero for all others", # noqa "order": 1} } }, "publishSettings": { "type": "section", "label": "Publish Settings", "target": "ui", "order": 3, "value": { "subsetName": { "value": ["", "main", "bg", "fg", "bg", "animatic"], "type": "QComboBox", "label": "Subset Name", "target": "ui", "toolTip": "chose subset name pattern, if is selected, name of track layer will be used", # noqa "order": 0}, "subsetFamily": { "value": ["plate", "take"], "type": "QComboBox", "label": "Subset Family", "target": "ui", "toolTip": "What use of this subset is for", # noqa "order": 1}, "reviewTrack": { "value": ["< none >"] + gui_tracks, "type": "QComboBox", "label": "Use Review Track", "target": "ui", "toolTip": "Generate preview videos on fly, if `< none >` is defined nothing will be generated.", # noqa "order": 2}, "audio": { "value": False, "type": "QCheckBox", "label": "Include audio", "target": "tag", "toolTip": "Process subsets with corresponding audio", # noqa "order": 3}, "sourceResolution": { "value": False, "type": "QCheckBox", "label": "Source resolution", "target": "tag", "toolTip": "Is resloution taken from timeline or source?", # noqa "order": 4}, } }, "frameRangeAttr": { "type": "section", "label": "Shot Attributes", "target": "ui", "order": 4, "value": { "workfileFrameStart": { "value": 1001, "type": "QSpinBox", "label": "Workfiles Start Frame", "target": "tag", "toolTip": "Set workfile starting frame number", # noqa "order": 0 }, "handleStart": { "value": 0, "type": "QSpinBox", "label": "Handle Start", "target": "tag", "toolTip": "Handle at start of clip", # noqa "order": 1 }, "handleEnd": { "value": 0, "type": "QSpinBox", "label": "Handle End", "target": "tag", "toolTip": "Handle at end of clip", # noqa "order": 2 } } } } presets = None def process(self): # Creator copy of object attributes that are modified during `process` presets = deepcopy(self.presets) gui_inputs = deepcopy(self.gui_inputs) # get key pares from presets and match it on ui inputs for k, v in gui_inputs.items(): if v["type"] in ("dict", "section"): # nested dictionary (only one level allowed # for sections and dict) for _k, _v in v["value"].items(): if presets.get(_k): gui_inputs[k][ "value"][_k]["value"] = presets[_k] if presets.get(k): gui_inputs[k]["value"] = presets[k] # open widget for plugins inputs widget = self.widget(self.gui_name, self.gui_info, gui_inputs) widget.exec_() if len(self.selected) < 1: return if not widget.result: print("Operation aborted") return self.rename_add = 0 # get ui output for track name for vertical sync v_sync_track = widget.result["vSyncTrack"]["value"] # sort selected trackItems by sorted_selected_track_items = list() unsorted_selected_track_items = list() for _ti in self.selected: if _ti.parent().name() in v_sync_track: sorted_selected_track_items.append(_ti) else: unsorted_selected_track_items.append(_ti) sorted_selected_track_items.extend(unsorted_selected_track_items) kwargs = { "ui_inputs": widget.result, "avalon": self.data } for i, track_item in enumerate(sorted_selected_track_items): self.rename_index = i # convert track item to timeline media pool item phiero.PublishClip(self, track_item, **kwargs).convert() ================================================ FILE: openpype/hosts/hiero/plugins/load/load_clip.py ================================================ from openpype.client import ( get_version_by_id, get_last_version_by_subset_id ) from openpype.pipeline import ( get_representation_path, get_current_project_name, ) from openpype.lib.transcoding import ( VIDEO_EXTENSIONS, IMAGE_EXTENSIONS ) import openpype.hosts.hiero.api as phiero class LoadClip(phiero.SequenceLoader): """Load a subset to timeline as clip Place clip to timeline on its asset origin timings collected during conforming to project """ families = ["render2d", "source", "plate", "render", "review"] representations = ["*"] extensions = set( ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) ) label = "Load as clip" order = -10 icon = "code-fork" color = "orange" # for loader multiselection sequence = None track = None # presets clip_color_last = "green" clip_color = "red" clip_name_template = "{asset}_{subset}_{representation}" @classmethod def apply_settings(cls, project_settings, system_settings): plugin_type_settings = ( project_settings .get("hiero", {}) .get("load", {}) ) if not plugin_type_settings: return plugin_name = cls.__name__ plugin_settings = None # Look for plugin settings in host specific settings if plugin_name in plugin_type_settings: plugin_settings = plugin_type_settings[plugin_name] if not plugin_settings: return print(">>> We have preset for {}".format(plugin_name)) for option, value in plugin_settings.items(): if option == "enabled" and value is False: print(" - is disabled by preset") elif option == "representations": continue else: print(" - setting `{}`: `{}`".format(option, value)) setattr(cls, option, value) def load(self, context, name, namespace, options): # add clip name template to options options.update({ "clipNameTemplate": self.clip_name_template }) # in case loader uses multiselection if self.track and self.sequence: options.update({ "sequence": self.sequence, "track": self.track, "clipNameTemplate": self.clip_name_template }) # load clip to timeline and get main variables path = self.filepath_from_context(context) track_item = phiero.ClipLoader(self, context, path, **options).load() namespace = namespace or track_item.name() version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) colorspace = version_data.get("colorspace", None) object_name = self.clip_name_template.format( **context["representation"]["context"]) # set colorspace if colorspace: track_item.source().setSourceMediaColourTransform(colorspace) # add additional metadata from the version to imprint Avalon knob add_keys = [ "frameStart", "frameEnd", "source", "author", "fps", "handleStart", "handleEnd" ] # move all version data keys to tag data data_imprint = {} for key in add_keys: data_imprint.update({ key: version_data.get(key, str(None)) }) # add variables related to version context data_imprint.update({ "version": version_name, "colorspace": colorspace, "objectName": object_name }) # update color of clip regarding the version order self.set_item_color(track_item, version) # deal with multiselection self.multiselection(track_item) self.log.info("Loader done: `{}`".format(name)) return phiero.containerise( track_item, name, namespace, context, self.__class__.__name__, data_imprint) def switch(self, container, representation): self.update(container, representation) def update(self, container, representation): """ Updating previously loaded clips """ # load clip to timeline and get main variables name = container['name'] namespace = container['namespace'] track_item = phiero.get_track_items( track_item_name=namespace).pop() project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) version_data = version_doc.get("data", {}) version_name = version_doc.get("name", None) colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) file = get_representation_path(representation).replace("\\", "/") clip = track_item.source() # reconnect media to new path clip.reconnectMedia(file) # set colorspace if colorspace: clip.setSourceMediaColourTransform(colorspace) # add additional metadata from the version to imprint Avalon knob add_keys = [ "frameStart", "frameEnd", "source", "author", "fps", "handleStart", "handleEnd" ] # move all version data keys to tag data data_imprint = {} for key in add_keys: data_imprint.update({ key: version_data.get(key, str(None)) }) # add variables related to version context data_imprint.update({ "representation": str(representation["_id"]), "version": version_name, "colorspace": colorspace, "objectName": object_name }) # update color of clip regarding the version order self.set_item_color(track_item, version_doc) return phiero.update_container(track_item, data_imprint) def remove(self, container): """ Removing previously loaded clips """ # load clip to timeline and get main variables namespace = container['namespace'] track_item = phiero.get_track_items( track_item_name=namespace).pop() track = track_item.parent() # remove track item from track track.removeItem(track_item) @classmethod def multiselection(cls, track_item): if not cls.track: cls.track = track_item.parent() cls.sequence = cls.track.parent() @classmethod def set_item_color(cls, track_item, version_doc): project_name = get_current_project_name() last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] ) clip = track_item.source() # set clip colour if version_doc["_id"] == last_version_doc["_id"]: clip.binItem().setColor(cls.clip_color_last) else: clip.binItem().setColor(cls.clip_color) ================================================ FILE: openpype/hosts/hiero/plugins/load/load_effects.py ================================================ import json from collections import OrderedDict import six from openpype.client import ( get_version_by_id ) from openpype.pipeline import ( AVALON_CONTAINER_ID, load, get_representation_path, get_current_project_name ) from openpype.hosts.hiero import api as phiero from openpype.lib import Logger class LoadEffects(load.LoaderPlugin): """Loading colorspace soft effect exported from nukestudio""" families = ["effect"] representations = ["*"] extension = {"json"} label = "Load Effects" order = 0 icon = "cc" color = "white" log = Logger.get_logger(__name__) def load(self, context, name, namespace, data): """ Loading function to get the soft effects to particular read node Arguments: context (dict): context of version name (str): name of the version namespace (str): asset name data (dict): compulsory attribute > not used Returns: nuke node: containerised nuke node object """ active_sequence = phiero.get_current_sequence() active_track = phiero.get_current_track( active_sequence, "Loaded_{}".format(name)) # get main variables namespace = namespace or context["asset"]["name"] object_name = "{}_{}".format(name, namespace) clip_in = context["asset"]["data"]["clipIn"] clip_out = context["asset"]["data"]["clipOut"] data_imprint = { "objectName": object_name, "children_names": [] } # getting file path file = self.filepath_from_context(context) file = file.replace("\\", "/") if self._shared_loading( file, active_track, clip_in, clip_out, data_imprint ): self.containerise( active_track, name=name, namespace=namespace, object_name=object_name, context=context, loader=self.__class__.__name__, data=data_imprint) def _shared_loading( self, file, active_track, clip_in, clip_out, data_imprint, update=False ): # getting data from json file with unicode conversion with open(file, "r") as f: json_f = {self.byteify(key): self.byteify(value) for key, value in json.load(f).items()} # get correct order of nodes by positions on track and subtrack nodes_order = self.reorder_nodes(json_f) used_subtracks = { stitem.name(): stitem for stitem in phiero.flatten(active_track.subTrackItems()) } loaded = False for index_order, (ef_name, ef_val) in enumerate(nodes_order.items()): new_name = "{}_loaded".format(ef_name) if new_name not in used_subtracks: effect_track_item = active_track.createEffect( effectType=ef_val["class"], timelineIn=clip_in, timelineOut=clip_out, subTrackIndex=index_order ) effect_track_item.setName(new_name) else: effect_track_item = used_subtracks[new_name] node = effect_track_item.node() for knob_name, knob_value in ef_val["node"].items(): if ( not knob_value or knob_name == "name" ): continue try: # assume list means animation # except 4 values could be RGBA or vector if isinstance(knob_value, list) and len(knob_value) > 4: node[knob_name].setAnimated() for i, value in enumerate(knob_value): if isinstance(value, list): # list can have vector animation for ci, cv in enumerate(value): node[knob_name].setValueAt( cv, (clip_in + i), ci ) else: # list is single values node[knob_name].setValueAt( value, (clip_in + i) ) else: node[knob_name].setValue(knob_value) except NameError: self.log.warning("Knob: {} cannot be set".format( knob_name)) # register all loaded children data_imprint["children_names"].append(new_name) # make sure containerisation will happen loaded = True return loaded def update(self, container, representation): """ Updating previously loaded effects """ active_track = container["_item"] file = get_representation_path(representation).replace("\\", "/") # get main variables name = container['name'] namespace = container['namespace'] # get timeline in out data project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) version_data = version_doc["data"] clip_in = version_data["clipIn"] clip_out = version_data["clipOut"] object_name = "{}_{}".format(name, namespace) # Disable previously created nodes used_subtracks = { stitem.name(): stitem for stitem in phiero.flatten(active_track.subTrackItems()) } container = phiero.get_track_openpype_data( active_track, object_name ) loaded_subtrack_items = container["children_names"] for loaded_stitem in loaded_subtrack_items: if loaded_stitem not in used_subtracks: continue item_to_remove = used_subtracks.pop(loaded_stitem) # TODO: find a way to erase nodes self.log.debug( "This node needs to be removed: {}".format(item_to_remove)) data_imprint = { "objectName": object_name, "name": name, "representation": str(representation["_id"]), "children_names": [] } if self._shared_loading( file, active_track, clip_in, clip_out, data_imprint, update=True ): return phiero.update_container(active_track, data_imprint) def reorder_nodes(self, data): new_order = OrderedDict() trackNums = [v["trackIndex"] for k, v in data.items() if isinstance(v, dict)] subTrackNums = [v["subTrackIndex"] for k, v in data.items() if isinstance(v, dict)] for trackIndex in range( min(trackNums), max(trackNums) + 1): for subTrackIndex in range( min(subTrackNums), max(subTrackNums) + 1): item = self.get_item(data, trackIndex, subTrackIndex) if item is not {}: new_order.update(item) return new_order def get_item(self, data, trackIndex, subTrackIndex): return {key: val for key, val in data.items() if isinstance(val, dict) if subTrackIndex == val["subTrackIndex"] if trackIndex == val["trackIndex"]} def byteify(self, input): """ Converts unicode strings to strings It goes through all dictionary Arguments: input (dict/str): input Returns: dict: with fixed values and keys """ if isinstance(input, dict): return {self.byteify(key): self.byteify(value) for key, value in input.items()} elif isinstance(input, list): return [self.byteify(element) for element in input] elif isinstance(input, six.text_type): return str(input) else: return input def switch(self, container, representation): self.update(container, representation) def remove(self, container): pass def containerise( self, track, name, namespace, object_name, context, loader=None, data=None ): """Bundle Hiero's object into an assembly and imprint it with metadata Containerisation enables a tracking of version, author and origin for loaded assets. Arguments: track (hiero.core.VideoTrack): object to imprint as container name (str): Name of resulting assembly namespace (str): Namespace under which to host container object_name (str): name of container context (dict): Asset information loader (str, optional): Name of node used to produce this container. Returns: track_item (hiero.core.TrackItem): containerised object """ data_imprint = { object_name: { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": str(name), "namespace": str(namespace), "loader": str(loader), "representation": str(context["representation"]["_id"]), } } if data: for k, v in data.items(): data_imprint[object_name].update({k: v}) self.log.debug("_ data_imprint: {}".format(data_imprint)) phiero.set_track_openpype_tag(track, data_imprint) ================================================ FILE: openpype/hosts/hiero/plugins/publish/collect_clip_effects.py ================================================ import re import pyblish.api class CollectClipEffects(pyblish.api.InstancePlugin): """Collect soft effects instances.""" order = pyblish.api.CollectorOrder - 0.078 label = "Collect Clip Effects Instances" families = ["clip"] effect_categories = [] def process(self, instance): family = "effect" effects = {} review = instance.data.get("review") review_track_index = instance.context.data.get("reviewTrackIndex") item = instance.data["item"] if "audio" in instance.data["family"]: return # frame range self.handle_start = instance.data["handleStart"] self.handle_end = instance.data["handleEnd"] self.clip_in = int(item.timelineIn()) self.clip_out = int(item.timelineOut()) self.clip_in_h = self.clip_in - self.handle_start self.clip_out_h = self.clip_out + self.handle_end track_item = instance.data["item"] track = track_item.parent() track_index = track.trackIndex() tracks_effect_items = instance.context.data.get("tracksEffectItems") clip_effect_items = instance.data.get("clipEffectItems") # add clips effects to track's: if clip_effect_items: tracks_effect_items[track_index] = clip_effect_items # process all effects and divide them to instance for _track_index, sub_track_items in tracks_effect_items.items(): # skip if track index is the same as review track index if review and review_track_index == _track_index: continue for sitem in sub_track_items: # make sure this subtrack item is relative of track item if ((track_item not in sitem.linkedItems()) and (len(sitem.linkedItems()) > 0)): continue if not (track_index <= _track_index): continue effect = self.add_effect(_track_index, sitem) if effect: effects.update(effect) # skip any without effects if not effects: return subset = instance.data.get("subset") effects.update({"assignTo": subset}) subset_split = re.findall(r'[A-Z][^A-Z]*', subset) if len(subset_split) > 0: root_name = subset.replace(subset_split[0], "") subset_split.insert(0, root_name.capitalize()) subset_split.insert(0, "effect") # Need to convert to dict for AYON settings. This isinstance check can # be removed in the future when OpenPype is no longer. effect_categories = self.effect_categories if isinstance(self.effect_categories, list): effect_categories = { x["name"]: x["effect_classes"] for x in self.effect_categories } category_by_effect = {"": ""} for key, values in effect_categories.items(): for cls in values: category_by_effect[cls] = key effects_categorized = {k: {} for k in effect_categories.keys()} effects_categorized[""] = {} for key, value in effects.items(): if key == "assignTo": continue # Some classes can have a number in them. Like Text2. found_cls = "" for cls in category_by_effect.keys(): if cls in value["class"]: found_cls = cls effects_categorized[category_by_effect[found_cls]][key] = value categories = list(effects_categorized.keys()) for category in categories: if not effects_categorized[category]: effects_categorized.pop(category) continue effects_categorized[category]["assignTo"] = effects["assignTo"] for category, effects in effects_categorized.items(): name = "".join(subset_split) name += category.capitalize() # create new instance and inherit data data = {} for key, value in instance.data.items(): if "clipEffectItems" in key: continue data[key] = value # change names data["subset"] = name data["family"] = family data["families"] = [family] data["name"] = data["subset"] + "_" + data["asset"] data["label"] = "{} - {}".format( data['asset'], data["subset"] ) data["effects"] = effects # create new instance _instance = instance.context.create_instance(**data) self.log.info("Created instance `{}`".format(_instance)) self.log.debug("instance.data `{}`".format(_instance.data)) def test_overlap(self, effect_t_in, effect_t_out): covering_exp = bool( (effect_t_in <= self.clip_in) and (effect_t_out >= self.clip_out) ) overlaying_right_exp = bool( (effect_t_in < self.clip_out) and (effect_t_out >= self.clip_out) ) overlaying_left_exp = bool( (effect_t_out > self.clip_in) and (effect_t_in <= self.clip_in) ) return any(( covering_exp, overlaying_right_exp, overlaying_left_exp )) def add_effect(self, track_index, sitem): track = sitem.parentTrack().name() # node serialization node = sitem.node() node_serialized = self.node_serialization(node) node_name = sitem.name() node_class = node.Class() # collect timelineIn/Out effect_t_in = int(sitem.timelineIn()) effect_t_out = int(sitem.timelineOut()) if not self.test_overlap(effect_t_in, effect_t_out): return self.log.debug("node_name: `{}`".format(node_name)) self.log.debug("node_class: `{}`".format(node_class)) return {node_name: { "class": node_class, "timelineIn": effect_t_in, "timelineOut": effect_t_out, "subTrackIndex": sitem.subTrackIndex(), "trackIndex": track_index, "track": track, "node": node_serialized }} def node_serialization(self, node): node_serialized = {} # adding ignoring knob keys _ignoring_keys = ['invert_mask', 'help', 'mask', 'xpos', 'ypos', 'layer', 'process_mask', 'channel', 'channels', 'maskChannelMask', 'maskChannelInput', 'note_font', 'note_font_size', 'unpremult', 'postage_stamp_frame', 'maskChannel', 'export_cc', 'select_cccid', 'mix', 'version', 'matrix'] # loop through all knobs and collect not ignored # and any with any value for knob in node.knobs().keys(): # skip nodes in ignore keys if knob in _ignoring_keys: continue # get animation if node is animated if node[knob].isAnimated(): # grab animation including handles knob_anim = [node[knob].getValueAt(i) for i in range( self.clip_in_h, self.clip_out_h + 1)] node_serialized[knob] = knob_anim else: node_serialized[knob] = node[knob].value() return node_serialized ================================================ FILE: openpype/hosts/hiero/plugins/publish/collect_frame_tag_instances.py ================================================ from pprint import pformat import re import ast import json import pyblish.api from openpype.client import get_asset_name_identifier class CollectFrameTagInstances(pyblish.api.ContextPlugin): """Collect frames from tags. Tag is expected to have metadata: { "family": "frame" "subset": "main" } """ order = pyblish.api.CollectorOrder label = "Collect Frames" hosts = ["hiero"] def process(self, context): self._context = context # collect all sequence tags subset_data = self._create_frame_subset_data_sequence(context) self.log.debug("__ subset_data: {}".format( pformat(subset_data) )) # create instances self._create_instances(subset_data) def _get_tag_data(self, tag): data = {} # get tag metadata attribute tag_data = tag.metadata() # convert tag metadata to normal keys names and values to correct types for k, v in dict(tag_data).items(): key = k.replace("tag.", "") try: # capture exceptions which are related to strings only if re.match(r"^[\d]+$", v): value = int(v) elif re.match(r"^True$", v): value = True elif re.match(r"^False$", v): value = False elif re.match(r"^None$", v): value = None elif re.match(r"^[\w\d_]+$", v): value = v else: value = ast.literal_eval(v) except (ValueError, SyntaxError): value = v data[key] = value return data def _create_frame_subset_data_sequence(self, context): sequence_tags = [] sequence = context.data["activeTimeline"] # get all publishable sequence frames publish_frames = range(int(sequence.duration() + 1)) self.log.debug("__ publish_frames: {}".format( pformat(publish_frames) )) # get all sequence tags for tag in sequence.tags(): tag_data = self._get_tag_data(tag) self.log.debug("__ tag_data: {}".format( pformat(tag_data) )) if not tag_data: continue if "family" not in tag_data: continue if tag_data["family"] != "frame": continue sequence_tags.append(tag_data) self.log.debug("__ sequence_tags: {}".format( pformat(sequence_tags) )) # first collect all available subset tag frames subset_data = {} context_asset_doc = context.data["assetEntity"] context_asset_name = get_asset_name_identifier(context_asset_doc) for tag_data in sequence_tags: frame = int(tag_data["start"]) if frame not in publish_frames: continue subset = tag_data["subset"] if subset in subset_data: # update existing subset key subset_data[subset]["frames"].append(frame) else: # create new subset key subset_data[subset] = { "frames": [frame], "format": tag_data["format"], "asset": context_asset_name } return subset_data def _create_instances(self, subset_data): # create instance per subset for subset_name, subset_data in subset_data.items(): name = "frame" + subset_name.title() data = { "name": name, "label": "{} {}".format(name, subset_data["frames"]), "family": "image", "families": ["frame"], "asset": subset_data["asset"], "subset": name, "format": subset_data["format"], "frames": subset_data["frames"] } self._context.create_instance(**data) self.log.info( "Created instance: {}".format( json.dumps(data, sort_keys=True, indent=4) ) ) ================================================ FILE: openpype/hosts/hiero/plugins/publish/collect_tag_tasks.py ================================================ from pyblish import api class CollectClipTagTasks(api.InstancePlugin): """Collect Tags from selected track items.""" order = api.CollectorOrder - 0.077 label = "Collect Tag Tasks" hosts = ["hiero"] families = ["shot"] def process(self, instance): # gets tags tags = instance.data["tags"] tasks = {} for tag in tags: t_metadata = dict(tag.metadata()) t_family = t_metadata.get("tag.family", "") # gets only task family tags and collect labels if "task" in t_family: t_task_name = t_metadata.get("tag.label", "") t_task_type = t_metadata.get("tag.type", "") tasks[t_task_name] = {"type": t_task_type} instance.data["tasks"] = tasks self.log.info("Collected Tasks from Tags: `{}`".format( instance.data["tasks"])) return ================================================ FILE: openpype/hosts/hiero/plugins/publish/extract_clip_effects.py ================================================ # from openpype import plugins import os import json import pyblish.api from openpype.pipeline import publish class ExtractClipEffects(publish.Extractor): """Extract clip effects instances.""" order = pyblish.api.ExtractorOrder label = "Export Clip Effects" families = ["effect"] def process(self, instance): item = instance.data["item"] effects = instance.data.get("effects") # skip any without effects if not effects: return subset = instance.data.get("subset") family = instance.data["family"] self.log.debug("creating staging dir") staging_dir = self.staging_dir(instance) transfers = list() if "transfers" not in instance.data: instance.data["transfers"] = list() ext = "json" file = subset + "." + ext # when instance is created during collection part resources_dir = instance.data["resourcesDir"] # change paths in effects to files for k, effect in effects.items(): if "assignTo" in k: continue trn = self.copy_linked_files(effect, resources_dir) if trn: transfers.append((trn[0], trn[1])) instance.data["transfers"].extend(transfers) self.log.debug("_ transfers: `{}`".format( instance.data["transfers"])) # create representations instance.data["representations"] = list() transfer_data = [ "handleStart", "handleEnd", "sourceStart", "sourceStartH", "sourceEnd", "sourceEndH", "frameStart", "frameEnd", "clipIn", "clipOut", "clipInH", "clipOutH", "asset", "version" ] # pass data to version version_data = dict() version_data.update({k: instance.data[k] for k in transfer_data}) # add to data of representation version_data.update({ "colorspace": item.sourceMediaColourTransform(), "colorspaceScript": instance.context.data["colorspace"], "families": [family, "plate"], "subset": subset, "fps": instance.context.data["fps"] }) instance.data["versionData"] = version_data representation = { 'files': file, 'stagingDir': staging_dir, 'name': family + ext.title(), 'ext': ext } instance.data["representations"].append(representation) self.log.debug("_ representations: `{}`".format( instance.data["representations"])) self.log.debug("_ version_data: `{}`".format( instance.data["versionData"])) with open(os.path.join(staging_dir, file), "w") as outfile: outfile.write(json.dumps(effects, indent=4, sort_keys=True)) def copy_linked_files(self, effect, dst_dir): for k, v in effect["node"].items(): if k in "file" and v != '': base_name = os.path.basename(v) dst = os.path.join(dst_dir, base_name).replace("\\", "/") # add it to the json effect["node"][k] = dst return (v, dst) ================================================ FILE: openpype/hosts/hiero/plugins/publish/extract_frames.py ================================================ import os import pyblish.api from openpype.lib import ( get_oiio_tool_args, run_subprocess, ) from openpype.pipeline import publish class ExtractFrames(publish.Extractor): """Extracts frames""" order = pyblish.api.ExtractorOrder label = "Extract Frames" hosts = ["hiero"] families = ["frame"] movie_extensions = ["mov", "mp4"] def process(self, instance): oiio_tool_args = get_oiio_tool_args("oiiotool") staging_dir = self.staging_dir(instance) output_template = os.path.join(staging_dir, instance.data["name"]) sequence = instance.context.data["activeTimeline"] files = [] for frame in instance.data["frames"]: track_item = sequence.trackItemAt(frame) media_source = track_item.source().mediaSource() input_path = media_source.fileinfos()[0].filename() input_frame = ( track_item.mapTimelineToSource(frame) + track_item.source().mediaSource().startTime() ) output_ext = instance.data["format"] output_path = output_template output_path += ".{:04d}.{}".format(int(frame), output_ext) args = list(oiio_tool_args) ext = os.path.splitext(input_path)[1][1:] if ext in self.movie_extensions: args.extend(["--subimage", str(int(input_frame))]) else: args.extend(["--frames", str(int(input_frame))]) if ext == "exr": args.extend(["--powc", "0.45,0.45,0.45,1.0"]) args.extend([input_path, "-o", output_path]) output = run_subprocess(args) failed_output = "oiiotool produced no output." if failed_output in output: raise ValueError( "oiiotool processing failed. Args: {}".format(args) ) files.append(output_path) # Feedback to user because "oiiotool" can make the publishing # appear unresponsive. self.log.info( "Processed {} of {} frames".format( instance.data["frames"].index(frame) + 1, len(instance.data["frames"]) ) ) if len(files) == 1: instance.data["representations"] = [ { "name": output_ext, "ext": output_ext, "files": os.path.basename(files[0]), "stagingDir": staging_dir } ] else: instance.data["representations"] = [ { "name": output_ext, "ext": output_ext, "files": [os.path.basename(x) for x in files], "stagingDir": staging_dir } ] ================================================ FILE: openpype/hosts/hiero/plugins/publish/extract_thumbnail.py ================================================ import os import pyblish.api from openpype.pipeline import publish class ExtractThumnail(publish.Extractor): """ Extractor for track item's tumnails """ label = "Extract Thumnail" order = pyblish.api.ExtractorOrder families = ["plate", "take"] hosts = ["hiero"] def process(self, instance): # create representation data if "representations" not in instance.data: instance.data["representations"] = [] staging_dir = self.staging_dir(instance) self.create_thumbnail(staging_dir, instance) def create_thumbnail(self, staging_dir, instance): track_item = instance.data["item"] track_item_name = track_item.name() # frames duration = track_item.sourceDuration() frame_start = track_item.sourceIn() self.log.debug( "__ frame_start: `{}`, duration: `{}`".format( frame_start, duration)) # get thumbnail frame from the middle thumb_frame = int(frame_start + (duration / 2)) thumb_file = "{}thumbnail{}{}".format( track_item_name, thumb_frame, ".png") thumb_path = os.path.join(staging_dir, thumb_file) thumbnail = track_item.thumbnail(thumb_frame, "colour").save( thumb_path, format='png' ) self.log.debug( "__ thumb_path: `{}`, frame: `{}`".format(thumbnail, thumb_frame)) self.log.info("Thumnail was generated to: {}".format(thumb_path)) thumb_representation = { 'files': thumb_file, 'stagingDir': staging_dir, 'name': "thumbnail", 'thumbnail': True, 'ext': "png" } instance.data["representations"].append( thumb_representation) ================================================ FILE: openpype/hosts/hiero/plugins/publish/integrate_version_up_workfile.py ================================================ from pyblish import api from openpype.lib import version_up class IntegrateVersionUpWorkfile(api.ContextPlugin): """Save as new workfile version""" order = api.IntegratorOrder + 10.1 label = "Version-up Workfile" hosts = ["hiero"] optional = True active = True def process(self, context): project = context.data["activeProject"] path = context.data.get("currentFile") new_path = version_up(path) if project: project.saveAs(new_path) self.log.info("Project workfile was versioned up") ================================================ FILE: openpype/hosts/hiero/plugins/publish/precollect_instances.py ================================================ import pyblish from openpype import AYON_SERVER_ENABLED from openpype.pipeline.editorial import is_overlapping_otio_ranges from openpype.hosts.hiero import api as phiero from openpype.hosts.hiero.api.otio import hiero_export import hiero # # developer reload modules from pprint import pformat class PrecollectInstances(pyblish.api.ContextPlugin): """Collect all Track items selection.""" order = pyblish.api.CollectorOrder - 0.49 label = "Precollect Instances" hosts = ["hiero"] audio_track_items = [] def process(self, context): self.otio_timeline = context.data["otioTimeline"] timeline_selection = phiero.get_timeline_selection() selected_timeline_items = phiero.get_track_items( selection=timeline_selection, check_tagged=True, check_enabled=True ) # only return enabled track items if not selected_timeline_items: selected_timeline_items = phiero.get_track_items( check_enabled=True, check_tagged=True) self.log.info( "Processing enabled track items: {}".format( selected_timeline_items)) # add all tracks subtreck effect items to context all_tracks = hiero.ui.activeSequence().videoTracks() tracks_effect_items = self.collect_sub_track_items(all_tracks) context.data["tracksEffectItems"] = tracks_effect_items # process all sellected timeline track items for track_item in selected_timeline_items: data = {} clip_name = track_item.name() source_clip = track_item.source() self.log.debug("clip_name: {}".format(clip_name)) # get openpype tag data tag_data = phiero.get_trackitem_openpype_data(track_item) self.log.debug("__ tag_data: {}".format(pformat(tag_data))) if not tag_data: continue if tag_data.get("id") != "pyblish.avalon.instance": continue # get clips subtracks and anotations annotations = self.clip_annotations(source_clip) subtracks = self.clip_subtrack(track_item) self.log.debug("Annotations: {}".format(annotations)) self.log.debug(">> Subtracks: {}".format(subtracks)) # solve handles length tag_data["handleStart"] = min( tag_data["handleStart"], int(track_item.handleInLength())) tag_data["handleEnd"] = min( tag_data["handleEnd"], int(track_item.handleOutLength())) # add audio to families with_audio = False if tag_data.pop("audio"): with_audio = True # add tag data to instance data data.update({ k: v for k, v in tag_data.items() if k not in ("id", "applieswhole", "label") }) asset, asset_name = self._get_asset_data(tag_data) subset = tag_data["subset"] # insert family into families families = [str(f) for f in tag_data["families"]] # form label label = "{} -".format(asset) if asset_name != clip_name: label += " ({})".format(clip_name) label += " {}".format(subset) data.update({ "name": "{}_{}".format(asset, subset), "label": label, "asset": asset, "asset_name": asset_name, "item": track_item, "families": families, "publish": tag_data["publish"], "fps": context.data["fps"], # clip's effect "clipEffectItems": subtracks, "clipAnnotations": annotations, # add all additional tags "tags": phiero.get_track_item_tags(track_item), "newAssetPublishing": True }) # otio clip data otio_data = self.get_otio_clip_instance_data(track_item) or {} self.log.debug("__ otio_data: {}".format(pformat(otio_data))) data.update(otio_data) self.log.debug("__ data: {}".format(pformat(data))) # add resolution self.get_resolution_to_data(data, context) # create instance instance = context.create_instance(**data) # add colorspace data instance.data.update({ "versionData": { "colorspace": track_item.sourceMediaColourTransform(), } }) # create shot instance for shot attributes create/update self.create_shot_instance(context, **data) self.log.info("Creating instance: {}".format(instance)) self.log.info( "_ instance.data: {}".format(pformat(instance.data))) if not with_audio: continue # create audio subset instance self.create_audio_instance(context, **data) # add audioReview attribute to plate instance data # if reviewTrack is on if tag_data.get("reviewTrack") is not None: instance.data["reviewAudio"] = True def get_resolution_to_data(self, data, context): assert data.get("otioClip"), "Missing `otioClip` data" # solve source resolution option if data.get("sourceResolution", None): otio_clip_metadata = data[ "otioClip"].media_reference.metadata data.update({ "resolutionWidth": otio_clip_metadata[ "openpype.source.width"], "resolutionHeight": otio_clip_metadata[ "openpype.source.height"], "pixelAspect": otio_clip_metadata[ "openpype.source.pixelAspect"] }) else: otio_tl_metadata = context.data["otioTimeline"].metadata data.update({ "resolutionWidth": otio_tl_metadata["openpype.timeline.width"], "resolutionHeight": otio_tl_metadata[ "openpype.timeline.height"], "pixelAspect": otio_tl_metadata[ "openpype.timeline.pixelAspect"] }) def create_shot_instance(self, context, **data): subset = "shotMain" master_layer = data.get("heroTrack") hierarchy_data = data.get("hierarchyData") item = data.get("item") clip_name = item.name() if not master_layer: return if not hierarchy_data: return asset = data["asset"] asset_name = data["asset_name"] # insert family into families family = "shot" # form label label = "{} -".format(asset) if asset_name != clip_name: label += " ({}) ".format(clip_name) label += " {}".format(subset) data.update({ "name": "{}_{}".format(asset, subset), "label": label, "subset": subset, "family": family, "families": [] }) instance = context.create_instance(**data) self.log.info("Creating instance: {}".format(instance)) self.log.debug( "_ instance.data: {}".format(pformat(instance.data))) def _get_asset_data(self, data): folder_path = data.pop("folderPath", None) if data.get("asset_name"): asset_name = data["asset_name"] else: asset_name = data["asset"] # backward compatibility for clip tags # which are missing folderPath key # TODO remove this in future versions if not folder_path: hierarchy_path = data["hierarchy"] folder_path = "/{}/{}".format( hierarchy_path, asset_name ) if AYON_SERVER_ENABLED: asset = folder_path else: asset = asset_name return asset, asset_name def create_audio_instance(self, context, **data): subset = "audioMain" master_layer = data.get("heroTrack") if not master_layer: return asset = data.get("asset") item = data.get("item") clip_name = item.name() # test if any audio clips if not self.test_any_audio(item): return asset = data["asset"] asset_name = data["asset_name"] # insert family into families family = "audio" # form label label = "{} -".format(asset) if asset_name != clip_name: label += " ({}) ".format(clip_name) label += " {}".format(subset) data.update({ "name": "{}_{}".format(asset, subset), "label": label, "subset": subset, "family": family, "families": ["clip"] }) # remove review track attr if any data.pop("reviewTrack") # create instance instance = context.create_instance(**data) self.log.info("Creating instance: {}".format(instance)) self.log.debug( "_ instance.data: {}".format(pformat(instance.data))) def test_any_audio(self, track_item): # collect all audio tracks to class variable if not self.audio_track_items: for otio_clip in self.otio_timeline.each_clip(): if otio_clip.parent().kind != "Audio": continue self.audio_track_items.append(otio_clip) # get track item timeline range timeline_range = self.create_otio_time_range_from_timeline_item_data( track_item) # loop through audio track items and search for overlapping clip for otio_audio in self.audio_track_items: parent_range = otio_audio.range_in_parent() # if any overaling clip found then return True if is_overlapping_otio_ranges( parent_range, timeline_range, strict=False): return True def get_otio_clip_instance_data(self, track_item): """ Return otio objects for timeline, track and clip Args: timeline_item_data (dict): timeline_item_data from list returned by resolve.get_current_timeline_items() otio_timeline (otio.schema.Timeline): otio object Returns: dict: otio clip object """ ti_track_name = track_item.parent().name() timeline_range = self.create_otio_time_range_from_timeline_item_data( track_item) for otio_clip in self.otio_timeline.each_clip(): track_name = otio_clip.parent().name parent_range = otio_clip.range_in_parent() if ti_track_name != track_name: continue if otio_clip.name != track_item.name(): continue self.log.debug("__ parent_range: {}".format(parent_range)) self.log.debug("__ timeline_range: {}".format(timeline_range)) if is_overlapping_otio_ranges( parent_range, timeline_range, strict=True): # add pypedata marker to otio_clip metadata for marker in otio_clip.markers: if phiero.OPENPYPE_TAG_NAME in marker.name: otio_clip.metadata.update(marker.metadata) return {"otioClip": otio_clip} return None @staticmethod def create_otio_time_range_from_timeline_item_data(track_item): timeline = phiero.get_current_sequence() frame_start = int(track_item.timelineIn()) frame_duration = int(track_item.duration()) fps = timeline.framerate().toFloat() return hiero_export.create_otio_time_range( frame_start, frame_duration, fps) def collect_sub_track_items(self, tracks): """ Returns dictionary with track index as key and list of subtracks """ # collect all subtrack items sub_track_items = {} for track in tracks: items = track.items() effet_items = track.subTrackItems() # skip if no clips on track > need track with effect only if not effet_items: continue # skip all disabled tracks if not track.isEnabled(): continue track_index = track.trackIndex() _sub_track_items = phiero.flatten(effet_items) _sub_track_items = list(_sub_track_items) # continue only if any subtrack items are collected if not _sub_track_items: continue enabled_sti = [] # loop all found subtrack items and check if they are enabled for _sti in _sub_track_items: # checking if not enabled if not _sti.isEnabled(): continue if isinstance(_sti, hiero.core.Annotation): continue # collect the subtrack item enabled_sti.append(_sti) # continue only if any subtrack items are collected if not enabled_sti: continue # add collection of subtrackitems to dict sub_track_items[track_index] = enabled_sti return sub_track_items @staticmethod def clip_annotations(clip): """ Returns list of Clip's hiero.core.Annotation """ annotations = [] subTrackItems = phiero.flatten(clip.subTrackItems()) annotations += [item for item in subTrackItems if isinstance( item, hiero.core.Annotation)] return annotations @staticmethod def clip_subtrack(clip): """ Returns list of Clip's hiero.core.SubTrackItem """ subtracks = [] subTrackItems = phiero.flatten(clip.parent().subTrackItems()) for item in subTrackItems: if "TimeWarp" in item.name(): continue # avoid all anotation if isinstance(item, hiero.core.Annotation): continue # # avoid all not anaibled if not item.isEnabled(): continue subtracks.append(item) return subtracks ================================================ FILE: openpype/hosts/hiero/plugins/publish/precollect_workfile.py ================================================ import os import tempfile from pprint import pformat import pyblish.api from qtpy.QtGui import QPixmap import hiero.ui from openpype import AYON_SERVER_ENABLED from openpype.hosts.hiero.api.otio import hiero_export class PrecollectWorkfile(pyblish.api.ContextPlugin): """Inject the current working file into context""" label = "Precollect Workfile" order = pyblish.api.CollectorOrder - 0.491 def process(self, context): asset = context.data["asset"] asset_name = asset if AYON_SERVER_ENABLED: asset_name = asset_name.split("/")[-1] active_timeline = hiero.ui.activeSequence() project = active_timeline.project() fps = active_timeline.framerate().toFloat() # adding otio timeline to context otio_timeline = hiero_export.create_otio_timeline() # get workfile thumbnail paths tmp_staging = tempfile.mkdtemp(prefix="pyblish_tmp_") thumbnail_name = "workfile_thumbnail.png" thumbnail_path = os.path.join(tmp_staging, thumbnail_name) # search for all windows with name of actual sequence _windows = [w for w in hiero.ui.windowManager().windows() if active_timeline.name() in w.windowTitle()] # export window to thumb path QPixmap.grabWidget(_windows[-1]).save(thumbnail_path, 'png') # thumbnail thumb_representation = { 'files': thumbnail_name, 'stagingDir': tmp_staging, 'name': "thumbnail", 'thumbnail': True, 'ext': "png" } # get workfile paths current_file = project.path() staging_dir, base_name = os.path.split(current_file) # creating workfile representation workfile_representation = { 'name': 'hrox', 'ext': 'hrox', 'files': base_name, "stagingDir": staging_dir, } family = "workfile" instance_data = { "label": "{} - {}Main".format( asset, family), "name": "{}_{}".format(asset_name, family), "asset": context.data["asset"], # TODO use 'get_subset_name' "subset": "{}{}Main".format(asset_name, family.capitalize()), "item": project, "family": family, "families": [], "representations": [workfile_representation, thumb_representation] } # create instance with workfile instance = context.create_instance(**instance_data) # update context with main project attributes context_data = { "activeProject": project, "activeTimeline": active_timeline, "otioTimeline": otio_timeline, "currentFile": current_file, "colorspace": self.get_colorspace(project), "fps": fps } self.log.debug("__ context_data: {}".format(pformat(context_data))) context.data.update(context_data) self.log.info("Creating instance: {}".format(instance)) self.log.debug("__ instance.data: {}".format(pformat(instance.data))) self.log.debug("__ context_data: {}".format(pformat(context_data))) def get_colorspace(self, project): # get workfile's colorspace properties return { "useOCIOEnvironmentOverride": project.useOCIOEnvironmentOverride(), "lutSetting16Bit": project.lutSetting16Bit(), "lutSetting8Bit": project.lutSetting8Bit(), "lutSettingFloat": project.lutSettingFloat(), "lutSettingLog": project.lutSettingLog(), "lutSettingViewer": project.lutSettingViewer(), "lutSettingWorkingSpace": project.lutSettingWorkingSpace(), "lutUseOCIOForExport": project.lutUseOCIOForExport(), "ocioConfigName": project.ocioConfigName(), "ocioConfigPath": project.ocioConfigPath() } ================================================ FILE: openpype/hosts/hiero/plugins/publish_old_workflow/collect_assetbuilds.py ================================================ from pyblish import api from openpype.client import get_assets, get_asset_name_identifier class CollectAssetBuilds(api.ContextPlugin): """Collect asset from tags. Tag is expected to have name of the asset and metadata: { "family": "assetbuild" } """ # Run just after CollectClip order = api.CollectorOrder + 0.02 label = "Collect AssetBuilds" hosts = ["hiero"] def process(self, context): project_name = context.data["projectName"] asset_builds = {} for asset_doc in get_assets(project_name): if asset_doc["data"].get("entityType") != "AssetBuild": continue asset_name = get_asset_name_identifier(asset_doc) self.log.debug("Found \"{}\" in database.".format(asset_doc)) asset_builds[asset_name] = asset_doc for instance in context: if instance.data["family"] != "clip": continue # Exclude non-tagged instances. tagged = False asset_names = [] for tag in instance.data["tags"]: t_metadata = dict(tag.metadata()) t_family = t_metadata.get("tag.family", "") if t_family.lower() == "assetbuild": asset_names.append(tag["name"]) tagged = True if not tagged: self.log.debug( "Skipping \"{}\" because its not tagged with " "\"assetbuild\"".format(instance) ) continue # Collect asset builds. data = {"assetbuilds": []} for name in asset_names: data["assetbuilds"].append(asset_builds[name]) self.log.debug( "Found asset builds: {}".format(data["assetbuilds"]) ) instance.data.update(data) ================================================ FILE: openpype/hosts/hiero/plugins/publish_old_workflow/collect_tag_comments.py ================================================ from pyblish import api class CollectClipTagComments(api.InstancePlugin): """Collect comments from tags on selected track items and their sources.""" order = api.CollectorOrder + 0.013 label = "Collect Comments" hosts = ["hiero"] families = ["clip"] def process(self, instance): # Collect comments. instance.data["comments"] = [] # Exclude non-tagged instances. for tag in instance.data["tags"]: if tag["name"].lower() == "comment": instance.data["comments"].append( tag["metadata"]["tag.note"] ) # Find tags on the source clip. tags = instance.data["item"].source().tags() for tag in tags: if tag.name().lower() == "comment": instance.data["comments"].append( tag.metadata().dict()["tag.note"] ) # Update label with comments counter. instance.data["label"] = "{} - comments:{}".format( instance.data["label"], len(instance.data["comments"]) ) ================================================ FILE: openpype/hosts/hiero/plugins/publish_old_workflow/precollect_retime.py ================================================ from pyblish import api import hiero import math from openpype.hosts.hiero.api.otio.hiero_export import create_otio_time_range class PrecollectRetime(api.InstancePlugin): """Calculate Retiming of selected track items.""" order = api.CollectorOrder - 0.578 label = "Precollect Retime" hosts = ["hiero"] families = ['retime_'] def process(self, instance): if not instance.data.get("versionData"): instance.data["versionData"] = {} # get basic variables otio_clip = instance.data["otioClip"] source_range = otio_clip.source_range oc_source_fps = source_range.start_time.rate oc_source_in = source_range.start_time.value handle_start = instance.data["handleStart"] handle_end = instance.data["handleEnd"] frame_start = instance.data["frameStart"] track_item = instance.data["item"] # define basic clip frame range variables timeline_in = int(track_item.timelineIn()) timeline_out = int(track_item.timelineOut()) source_in = int(track_item.sourceIn()) source_out = int(track_item.sourceOut()) speed = track_item.playbackSpeed() # calculate available material before retime available_in = int(track_item.handleInLength() * speed) available_out = int(track_item.handleOutLength() * speed) self.log.debug(( "_BEFORE: \n timeline_in: `{0}`,\n timeline_out: `{1}`, \n " "source_in: `{2}`,\n source_out: `{3}`,\n speed: `{4}`,\n " "handle_start: `{5}`,\n handle_end: `{6}`").format( timeline_in, timeline_out, source_in, source_out, speed, handle_start, handle_end )) # loop within subtrack items time_warp_nodes = [] source_in_change = 0 source_out_change = 0 for s_track_item in track_item.linkedItems(): if isinstance(s_track_item, hiero.core.EffectTrackItem) \ and "TimeWarp" in s_track_item.node().Class(): # adding timewarp attribute to instance time_warp_nodes = [] # ignore item if not enabled if s_track_item.isEnabled(): node = s_track_item.node() name = node["name"].value() look_up = node["lookup"].value() animated = node["lookup"].isAnimated() if animated: look_up = [ ((node["lookup"].getValueAt(i)) - i) for i in range( (timeline_in - handle_start), (timeline_out + handle_end) + 1) ] # calculate difference diff_in = (node["lookup"].getValueAt( timeline_in)) - timeline_in diff_out = (node["lookup"].getValueAt( timeline_out)) - timeline_out # calculate source source_in_change += diff_in source_out_change += diff_out # calculate speed speed_in = (node["lookup"].getValueAt(timeline_in) / ( float(timeline_in) * .01)) * .01 speed_out = (node["lookup"].getValueAt(timeline_out) / ( float(timeline_out) * .01)) * .01 # calculate handles handle_start = int( math.ceil( (handle_start * speed_in * 1000) / 1000.0) ) handle_end = int( math.ceil( (handle_end * speed_out * 1000) / 1000.0) ) self.log.debug( ("diff_in, diff_out", diff_in, diff_out)) self.log.debug( ("source_in_change, source_out_change", source_in_change, source_out_change)) time_warp_nodes.append({ "Class": "TimeWarp", "name": name, "lookup": look_up }) self.log.debug( "timewarp source in changes: in {}, out {}".format( source_in_change, source_out_change)) # recalculate handles by the speed handle_start *= speed handle_end *= speed self.log.debug("speed: handle_start: '{0}', handle_end: '{1}'".format( handle_start, handle_end)) # recalculate source with timewarp and by the speed source_in += int(source_in_change) source_out += int(source_out_change * speed) source_in_h = int(source_in - math.ceil( (handle_start * 1000) / 1000.0)) source_out_h = int(source_out + math.ceil( (handle_end * 1000) / 1000.0)) self.log.debug( "retimed: source_in_h: '{0}', source_out_h: '{1}'".format( source_in_h, source_out_h)) # add all data to Instance instance.data["handleStart"] = handle_start instance.data["handleEnd"] = handle_end instance.data["sourceIn"] = source_in instance.data["sourceOut"] = source_out instance.data["sourceInH"] = source_in_h instance.data["sourceOutH"] = source_out_h instance.data["speed"] = speed source_handle_start = source_in_h - source_in # frame_start = instance.data["frameStart"] + source_handle_start duration = source_out_h - source_in_h frame_end = int(frame_start + duration - (handle_start + handle_end)) instance.data["versionData"].update({ "retime": True, "speed": speed, "timewarps": time_warp_nodes, "frameStart": frame_start, "frameEnd": frame_end, "handleStart": abs(source_handle_start), "handleEnd": source_out_h - source_out }) self.log.debug("versionData: {}".format(instance.data["versionData"])) self.log.debug("sourceIn: {}".format(instance.data["sourceIn"])) self.log.debug("sourceOut: {}".format(instance.data["sourceOut"])) self.log.debug("speed: {}".format(instance.data["speed"])) # change otio clip data instance.data["otioClip"].source_range = create_otio_time_range( oc_source_in, (source_out - source_in + 1), oc_source_fps) self.log.debug("otioClip: {}".format(instance.data["otioClip"])) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/__init__.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # Copyright 2007 Google Inc. All Rights Reserved. __version__ = '3.20.1' ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/any_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/any.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19google/protobuf/any.proto\x12\x0fgoogle.protobuf\"&\n\x03\x41ny\x12\x10\n\x08type_url\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c\x42v\n\x13\x63om.google.protobufB\x08\x41nyProtoP\x01Z,google.golang.org/protobuf/types/known/anypb\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.any_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\010AnyProtoP\001Z,google.golang.org/protobuf/types/known/anypb\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _ANY._serialized_start=46 _ANY._serialized_end=84 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/api_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/api.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() from google.protobuf import source_context_pb2 as google_dot_protobuf_dot_source__context__pb2 from google.protobuf import type_pb2 as google_dot_protobuf_dot_type__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19google/protobuf/api.proto\x12\x0fgoogle.protobuf\x1a$google/protobuf/source_context.proto\x1a\x1agoogle/protobuf/type.proto\"\x81\x02\n\x03\x41pi\x12\x0c\n\x04name\x18\x01 \x01(\t\x12(\n\x07methods\x18\x02 \x03(\x0b\x32\x17.google.protobuf.Method\x12(\n\x07options\x18\x03 \x03(\x0b\x32\x17.google.protobuf.Option\x12\x0f\n\x07version\x18\x04 \x01(\t\x12\x36\n\x0esource_context\x18\x05 \x01(\x0b\x32\x1e.google.protobuf.SourceContext\x12&\n\x06mixins\x18\x06 \x03(\x0b\x32\x16.google.protobuf.Mixin\x12\'\n\x06syntax\x18\x07 \x01(\x0e\x32\x17.google.protobuf.Syntax\"\xd5\x01\n\x06Method\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x18\n\x10request_type_url\x18\x02 \x01(\t\x12\x19\n\x11request_streaming\x18\x03 \x01(\x08\x12\x19\n\x11response_type_url\x18\x04 \x01(\t\x12\x1a\n\x12response_streaming\x18\x05 \x01(\x08\x12(\n\x07options\x18\x06 \x03(\x0b\x32\x17.google.protobuf.Option\x12\'\n\x06syntax\x18\x07 \x01(\x0e\x32\x17.google.protobuf.Syntax\"#\n\x05Mixin\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04root\x18\x02 \x01(\tBv\n\x13\x63om.google.protobufB\x08\x41piProtoP\x01Z,google.golang.org/protobuf/types/known/apipb\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.api_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\010ApiProtoP\001Z,google.golang.org/protobuf/types/known/apipb\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _API._serialized_start=113 _API._serialized_end=370 _METHOD._serialized_start=373 _METHOD._serialized_end=586 _MIXIN._serialized_start=588 _MIXIN._serialized_end=623 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/compiler/__init__.py ================================================ ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/compiler/plugin_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/compiler/plugin.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%google/protobuf/compiler/plugin.proto\x12\x18google.protobuf.compiler\x1a google/protobuf/descriptor.proto\"F\n\x07Version\x12\r\n\x05major\x18\x01 \x01(\x05\x12\r\n\x05minor\x18\x02 \x01(\x05\x12\r\n\x05patch\x18\x03 \x01(\x05\x12\x0e\n\x06suffix\x18\x04 \x01(\t\"\xba\x01\n\x14\x43odeGeneratorRequest\x12\x18\n\x10\x66ile_to_generate\x18\x01 \x03(\t\x12\x11\n\tparameter\x18\x02 \x01(\t\x12\x38\n\nproto_file\x18\x0f \x03(\x0b\x32$.google.protobuf.FileDescriptorProto\x12;\n\x10\x63ompiler_version\x18\x03 \x01(\x0b\x32!.google.protobuf.compiler.Version\"\xc1\x02\n\x15\x43odeGeneratorResponse\x12\r\n\x05\x65rror\x18\x01 \x01(\t\x12\x1a\n\x12supported_features\x18\x02 \x01(\x04\x12\x42\n\x04\x66ile\x18\x0f \x03(\x0b\x32\x34.google.protobuf.compiler.CodeGeneratorResponse.File\x1a\x7f\n\x04\x46ile\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0finsertion_point\x18\x02 \x01(\t\x12\x0f\n\x07\x63ontent\x18\x0f \x01(\t\x12?\n\x13generated_code_info\x18\x10 \x01(\x0b\x32\".google.protobuf.GeneratedCodeInfo\"8\n\x07\x46\x65\x61ture\x12\x10\n\x0c\x46\x45\x41TURE_NONE\x10\x00\x12\x1b\n\x17\x46\x45\x41TURE_PROTO3_OPTIONAL\x10\x01\x42W\n\x1c\x63om.google.protobuf.compilerB\x0cPluginProtosZ)google.golang.org/protobuf/types/pluginpb') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.compiler.plugin_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\034com.google.protobuf.compilerB\014PluginProtosZ)google.golang.org/protobuf/types/pluginpb' _VERSION._serialized_start=101 _VERSION._serialized_end=171 _CODEGENERATORREQUEST._serialized_start=174 _CODEGENERATORREQUEST._serialized_end=360 _CODEGENERATORRESPONSE._serialized_start=363 _CODEGENERATORRESPONSE._serialized_end=684 _CODEGENERATORRESPONSE_FILE._serialized_start=499 _CODEGENERATORRESPONSE_FILE._serialized_end=626 _CODEGENERATORRESPONSE_FEATURE._serialized_start=628 _CODEGENERATORRESPONSE_FEATURE._serialized_end=684 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/descriptor.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Descriptors essentially contain exactly the information found in a .proto file, in types that make this information accessible in Python. """ __author__ = 'robinson@google.com (Will Robinson)' import threading import warnings from google.protobuf.internal import api_implementation _USE_C_DESCRIPTORS = False if api_implementation.Type() == 'cpp': # Used by MakeDescriptor in cpp mode import binascii import os from google.protobuf.pyext import _message _USE_C_DESCRIPTORS = True class Error(Exception): """Base error for this module.""" class TypeTransformationError(Error): """Error transforming between python proto type and corresponding C++ type.""" if _USE_C_DESCRIPTORS: # This metaclass allows to override the behavior of code like # isinstance(my_descriptor, FieldDescriptor) # and make it return True when the descriptor is an instance of the extension # type written in C++. class DescriptorMetaclass(type): def __instancecheck__(cls, obj): if super(DescriptorMetaclass, cls).__instancecheck__(obj): return True if isinstance(obj, cls._C_DESCRIPTOR_CLASS): return True return False else: # The standard metaclass; nothing changes. DescriptorMetaclass = type class _Lock(object): """Wrapper class of threading.Lock(), which is allowed by 'with'.""" def __new__(cls): self = object.__new__(cls) self._lock = threading.Lock() # pylint: disable=protected-access return self def __enter__(self): self._lock.acquire() def __exit__(self, exc_type, exc_value, exc_tb): self._lock.release() _lock = threading.Lock() def _Deprecated(name): if _Deprecated.count > 0: _Deprecated.count -= 1 warnings.warn( 'Call to deprecated create function %s(). Note: Create unlinked ' 'descriptors is going to go away. Please use get/find descriptors from ' 'generated code or query the descriptor_pool.' % name, category=DeprecationWarning, stacklevel=3) # Deprecated warnings will print 100 times at most which should be enough for # users to notice and do not cause timeout. _Deprecated.count = 100 _internal_create_key = object() class DescriptorBase(metaclass=DescriptorMetaclass): """Descriptors base class. This class is the base of all descriptor classes. It provides common options related functionality. Attributes: has_options: True if the descriptor has non-default options. Usually it is not necessary to read this -- just call GetOptions() which will happily return the default instance. However, it's sometimes useful for efficiency, and also useful inside the protobuf implementation to avoid some bootstrapping issues. """ if _USE_C_DESCRIPTORS: # The class, or tuple of classes, that are considered as "virtual # subclasses" of this descriptor class. _C_DESCRIPTOR_CLASS = () def __init__(self, options, serialized_options, options_class_name): """Initialize the descriptor given its options message and the name of the class of the options message. The name of the class is required in case the options message is None and has to be created. """ self._options = options self._options_class_name = options_class_name self._serialized_options = serialized_options # Does this descriptor have non-default options? self.has_options = (options is not None) or (serialized_options is not None) def _SetOptions(self, options, options_class_name): """Sets the descriptor's options This function is used in generated proto2 files to update descriptor options. It must not be used outside proto2. """ self._options = options self._options_class_name = options_class_name # Does this descriptor have non-default options? self.has_options = options is not None def GetOptions(self): """Retrieves descriptor options. This method returns the options set or creates the default options for the descriptor. """ if self._options: return self._options from google.protobuf import descriptor_pb2 try: options_class = getattr(descriptor_pb2, self._options_class_name) except AttributeError: raise RuntimeError('Unknown options class name %s!' % (self._options_class_name)) with _lock: if self._serialized_options is None: self._options = options_class() else: self._options = _ParseOptions(options_class(), self._serialized_options) return self._options class _NestedDescriptorBase(DescriptorBase): """Common class for descriptors that can be nested.""" def __init__(self, options, options_class_name, name, full_name, file, containing_type, serialized_start=None, serialized_end=None, serialized_options=None): """Constructor. Args: options: Protocol message options or None to use default message options. options_class_name (str): The class name of the above options. name (str): Name of this protocol message type. full_name (str): Fully-qualified name of this protocol message type, which will include protocol "package" name and the name of any enclosing types. file (FileDescriptor): Reference to file info. containing_type: if provided, this is a nested descriptor, with this descriptor as parent, otherwise None. serialized_start: The start index (inclusive) in block in the file.serialized_pb that describes this descriptor. serialized_end: The end index (exclusive) in block in the file.serialized_pb that describes this descriptor. serialized_options: Protocol message serialized options or None. """ super(_NestedDescriptorBase, self).__init__( options, serialized_options, options_class_name) self.name = name # TODO(falk): Add function to calculate full_name instead of having it in # memory? self.full_name = full_name self.file = file self.containing_type = containing_type self._serialized_start = serialized_start self._serialized_end = serialized_end def CopyToProto(self, proto): """Copies this to the matching proto in descriptor_pb2. Args: proto: An empty proto instance from descriptor_pb2. Raises: Error: If self couldn't be serialized, due to to few constructor arguments. """ if (self.file is not None and self._serialized_start is not None and self._serialized_end is not None): proto.ParseFromString(self.file.serialized_pb[ self._serialized_start:self._serialized_end]) else: raise Error('Descriptor does not contain serialization.') class Descriptor(_NestedDescriptorBase): """Descriptor for a protocol message type. Attributes: name (str): Name of this protocol message type. full_name (str): Fully-qualified name of this protocol message type, which will include protocol "package" name and the name of any enclosing types. containing_type (Descriptor): Reference to the descriptor of the type containing us, or None if this is top-level. fields (list[FieldDescriptor]): Field descriptors for all fields in this type. fields_by_number (dict(int, FieldDescriptor)): Same :class:`FieldDescriptor` objects as in :attr:`fields`, but indexed by "number" attribute in each FieldDescriptor. fields_by_name (dict(str, FieldDescriptor)): Same :class:`FieldDescriptor` objects as in :attr:`fields`, but indexed by "name" attribute in each :class:`FieldDescriptor`. nested_types (list[Descriptor]): Descriptor references for all protocol message types nested within this one. nested_types_by_name (dict(str, Descriptor)): Same Descriptor objects as in :attr:`nested_types`, but indexed by "name" attribute in each Descriptor. enum_types (list[EnumDescriptor]): :class:`EnumDescriptor` references for all enums contained within this type. enum_types_by_name (dict(str, EnumDescriptor)): Same :class:`EnumDescriptor` objects as in :attr:`enum_types`, but indexed by "name" attribute in each EnumDescriptor. enum_values_by_name (dict(str, EnumValueDescriptor)): Dict mapping from enum value name to :class:`EnumValueDescriptor` for that value. extensions (list[FieldDescriptor]): All extensions defined directly within this message type (NOT within a nested type). extensions_by_name (dict(str, FieldDescriptor)): Same FieldDescriptor objects as :attr:`extensions`, but indexed by "name" attribute of each FieldDescriptor. is_extendable (bool): Does this type define any extension ranges? oneofs (list[OneofDescriptor]): The list of descriptors for oneof fields in this message. oneofs_by_name (dict(str, OneofDescriptor)): Same objects as in :attr:`oneofs`, but indexed by "name" attribute. file (FileDescriptor): Reference to file descriptor. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.Descriptor def __new__( cls, name=None, full_name=None, filename=None, containing_type=None, fields=None, nested_types=None, enum_types=None, extensions=None, options=None, serialized_options=None, is_extendable=True, extension_ranges=None, oneofs=None, file=None, # pylint: disable=redefined-builtin serialized_start=None, serialized_end=None, syntax=None, create_key=None): _message.Message._CheckCalledFromGeneratedFile() return _message.default_pool.FindMessageTypeByName(full_name) # NOTE(tmarek): The file argument redefining a builtin is nothing we can # fix right now since we don't know how many clients already rely on the # name of the argument. def __init__(self, name, full_name, filename, containing_type, fields, nested_types, enum_types, extensions, options=None, serialized_options=None, is_extendable=True, extension_ranges=None, oneofs=None, file=None, serialized_start=None, serialized_end=None, # pylint: disable=redefined-builtin syntax=None, create_key=None): """Arguments to __init__() are as described in the description of Descriptor fields above. Note that filename is an obsolete argument, that is not used anymore. Please use file.name to access this as an attribute. """ if create_key is not _internal_create_key: _Deprecated('Descriptor') super(Descriptor, self).__init__( options, 'MessageOptions', name, full_name, file, containing_type, serialized_start=serialized_start, serialized_end=serialized_end, serialized_options=serialized_options) # We have fields in addition to fields_by_name and fields_by_number, # so that: # 1. Clients can index fields by "order in which they're listed." # 2. Clients can easily iterate over all fields with the terse # syntax: for f in descriptor.fields: ... self.fields = fields for field in self.fields: field.containing_type = self self.fields_by_number = dict((f.number, f) for f in fields) self.fields_by_name = dict((f.name, f) for f in fields) self._fields_by_camelcase_name = None self.nested_types = nested_types for nested_type in nested_types: nested_type.containing_type = self self.nested_types_by_name = dict((t.name, t) for t in nested_types) self.enum_types = enum_types for enum_type in self.enum_types: enum_type.containing_type = self self.enum_types_by_name = dict((t.name, t) for t in enum_types) self.enum_values_by_name = dict( (v.name, v) for t in enum_types for v in t.values) self.extensions = extensions for extension in self.extensions: extension.extension_scope = self self.extensions_by_name = dict((f.name, f) for f in extensions) self.is_extendable = is_extendable self.extension_ranges = extension_ranges self.oneofs = oneofs if oneofs is not None else [] self.oneofs_by_name = dict((o.name, o) for o in self.oneofs) for oneof in self.oneofs: oneof.containing_type = self self.syntax = syntax or "proto2" @property def fields_by_camelcase_name(self): """Same FieldDescriptor objects as in :attr:`fields`, but indexed by :attr:`FieldDescriptor.camelcase_name`. """ if self._fields_by_camelcase_name is None: self._fields_by_camelcase_name = dict( (f.camelcase_name, f) for f in self.fields) return self._fields_by_camelcase_name def EnumValueName(self, enum, value): """Returns the string name of an enum value. This is just a small helper method to simplify a common operation. Args: enum: string name of the Enum. value: int, value of the enum. Returns: string name of the enum value. Raises: KeyError if either the Enum doesn't exist or the value is not a valid value for the enum. """ return self.enum_types_by_name[enum].values_by_number[value].name def CopyToProto(self, proto): """Copies this to a descriptor_pb2.DescriptorProto. Args: proto: An empty descriptor_pb2.DescriptorProto. """ # This function is overridden to give a better doc comment. super(Descriptor, self).CopyToProto(proto) # TODO(robinson): We should have aggressive checking here, # for example: # * If you specify a repeated field, you should not be allowed # to specify a default value. # * [Other examples here as needed]. # # TODO(robinson): for this and other *Descriptor classes, we # might also want to lock things down aggressively (e.g., # prevent clients from setting the attributes). Having # stronger invariants here in general will reduce the number # of runtime checks we must do in reflection.py... class FieldDescriptor(DescriptorBase): """Descriptor for a single field in a .proto file. Attributes: name (str): Name of this field, exactly as it appears in .proto. full_name (str): Name of this field, including containing scope. This is particularly relevant for extensions. index (int): Dense, 0-indexed index giving the order that this field textually appears within its message in the .proto file. number (int): Tag number declared for this field in the .proto file. type (int): (One of the TYPE_* constants below) Declared type. cpp_type (int): (One of the CPPTYPE_* constants below) C++ type used to represent this field. label (int): (One of the LABEL_* constants below) Tells whether this field is optional, required, or repeated. has_default_value (bool): True if this field has a default value defined, otherwise false. default_value (Varies): Default value of this field. Only meaningful for non-repeated scalar fields. Repeated fields should always set this to [], and non-repeated composite fields should always set this to None. containing_type (Descriptor): Descriptor of the protocol message type that contains this field. Set by the Descriptor constructor if we're passed into one. Somewhat confusingly, for extension fields, this is the descriptor of the EXTENDED message, not the descriptor of the message containing this field. (See is_extension and extension_scope below). message_type (Descriptor): If a composite field, a descriptor of the message type contained in this field. Otherwise, this is None. enum_type (EnumDescriptor): If this field contains an enum, a descriptor of that enum. Otherwise, this is None. is_extension: True iff this describes an extension field. extension_scope (Descriptor): Only meaningful if is_extension is True. Gives the message that immediately contains this extension field. Will be None iff we're a top-level (file-level) extension field. options (descriptor_pb2.FieldOptions): Protocol message field options or None to use default field options. containing_oneof (OneofDescriptor): If the field is a member of a oneof union, contains its descriptor. Otherwise, None. file (FileDescriptor): Reference to file descriptor. """ # Must be consistent with C++ FieldDescriptor::Type enum in # descriptor.h. # # TODO(robinson): Find a way to eliminate this repetition. TYPE_DOUBLE = 1 TYPE_FLOAT = 2 TYPE_INT64 = 3 TYPE_UINT64 = 4 TYPE_INT32 = 5 TYPE_FIXED64 = 6 TYPE_FIXED32 = 7 TYPE_BOOL = 8 TYPE_STRING = 9 TYPE_GROUP = 10 TYPE_MESSAGE = 11 TYPE_BYTES = 12 TYPE_UINT32 = 13 TYPE_ENUM = 14 TYPE_SFIXED32 = 15 TYPE_SFIXED64 = 16 TYPE_SINT32 = 17 TYPE_SINT64 = 18 MAX_TYPE = 18 # Must be consistent with C++ FieldDescriptor::CppType enum in # descriptor.h. # # TODO(robinson): Find a way to eliminate this repetition. CPPTYPE_INT32 = 1 CPPTYPE_INT64 = 2 CPPTYPE_UINT32 = 3 CPPTYPE_UINT64 = 4 CPPTYPE_DOUBLE = 5 CPPTYPE_FLOAT = 6 CPPTYPE_BOOL = 7 CPPTYPE_ENUM = 8 CPPTYPE_STRING = 9 CPPTYPE_MESSAGE = 10 MAX_CPPTYPE = 10 _PYTHON_TO_CPP_PROTO_TYPE_MAP = { TYPE_DOUBLE: CPPTYPE_DOUBLE, TYPE_FLOAT: CPPTYPE_FLOAT, TYPE_ENUM: CPPTYPE_ENUM, TYPE_INT64: CPPTYPE_INT64, TYPE_SINT64: CPPTYPE_INT64, TYPE_SFIXED64: CPPTYPE_INT64, TYPE_UINT64: CPPTYPE_UINT64, TYPE_FIXED64: CPPTYPE_UINT64, TYPE_INT32: CPPTYPE_INT32, TYPE_SFIXED32: CPPTYPE_INT32, TYPE_SINT32: CPPTYPE_INT32, TYPE_UINT32: CPPTYPE_UINT32, TYPE_FIXED32: CPPTYPE_UINT32, TYPE_BYTES: CPPTYPE_STRING, TYPE_STRING: CPPTYPE_STRING, TYPE_BOOL: CPPTYPE_BOOL, TYPE_MESSAGE: CPPTYPE_MESSAGE, TYPE_GROUP: CPPTYPE_MESSAGE } # Must be consistent with C++ FieldDescriptor::Label enum in # descriptor.h. # # TODO(robinson): Find a way to eliminate this repetition. LABEL_OPTIONAL = 1 LABEL_REQUIRED = 2 LABEL_REPEATED = 3 MAX_LABEL = 3 # Must be consistent with C++ constants kMaxNumber, kFirstReservedNumber, # and kLastReservedNumber in descriptor.h MAX_FIELD_NUMBER = (1 << 29) - 1 FIRST_RESERVED_FIELD_NUMBER = 19000 LAST_RESERVED_FIELD_NUMBER = 19999 if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.FieldDescriptor def __new__(cls, name, full_name, index, number, type, cpp_type, label, default_value, message_type, enum_type, containing_type, is_extension, extension_scope, options=None, serialized_options=None, has_default_value=True, containing_oneof=None, json_name=None, file=None, create_key=None): # pylint: disable=redefined-builtin _message.Message._CheckCalledFromGeneratedFile() if is_extension: return _message.default_pool.FindExtensionByName(full_name) else: return _message.default_pool.FindFieldByName(full_name) def __init__(self, name, full_name, index, number, type, cpp_type, label, default_value, message_type, enum_type, containing_type, is_extension, extension_scope, options=None, serialized_options=None, has_default_value=True, containing_oneof=None, json_name=None, file=None, create_key=None): # pylint: disable=redefined-builtin """The arguments are as described in the description of FieldDescriptor attributes above. Note that containing_type may be None, and may be set later if necessary (to deal with circular references between message types, for example). Likewise for extension_scope. """ if create_key is not _internal_create_key: _Deprecated('FieldDescriptor') super(FieldDescriptor, self).__init__( options, serialized_options, 'FieldOptions') self.name = name self.full_name = full_name self.file = file self._camelcase_name = None if json_name is None: self.json_name = _ToJsonName(name) else: self.json_name = json_name self.index = index self.number = number self.type = type self.cpp_type = cpp_type self.label = label self.has_default_value = has_default_value self.default_value = default_value self.containing_type = containing_type self.message_type = message_type self.enum_type = enum_type self.is_extension = is_extension self.extension_scope = extension_scope self.containing_oneof = containing_oneof if api_implementation.Type() == 'cpp': if is_extension: self._cdescriptor = _message.default_pool.FindExtensionByName(full_name) else: self._cdescriptor = _message.default_pool.FindFieldByName(full_name) else: self._cdescriptor = None @property def camelcase_name(self): """Camelcase name of this field. Returns: str: the name in CamelCase. """ if self._camelcase_name is None: self._camelcase_name = _ToCamelCase(self.name) return self._camelcase_name @property def has_presence(self): """Whether the field distinguishes between unpopulated and default values. Raises: RuntimeError: singular field that is not linked with message nor file. """ if self.label == FieldDescriptor.LABEL_REPEATED: return False if (self.cpp_type == FieldDescriptor.CPPTYPE_MESSAGE or self.containing_oneof): return True if hasattr(self.file, 'syntax'): return self.file.syntax == 'proto2' if hasattr(self.message_type, 'syntax'): return self.message_type.syntax == 'proto2' raise RuntimeError( 'has_presence is not ready to use because field %s is not' ' linked with message type nor file' % self.full_name) @staticmethod def ProtoTypeToCppProtoType(proto_type): """Converts from a Python proto type to a C++ Proto Type. The Python ProtocolBuffer classes specify both the 'Python' datatype and the 'C++' datatype - and they're not the same. This helper method should translate from one to another. Args: proto_type: the Python proto type (descriptor.FieldDescriptor.TYPE_*) Returns: int: descriptor.FieldDescriptor.CPPTYPE_*, the C++ type. Raises: TypeTransformationError: when the Python proto type isn't known. """ try: return FieldDescriptor._PYTHON_TO_CPP_PROTO_TYPE_MAP[proto_type] except KeyError: raise TypeTransformationError('Unknown proto_type: %s' % proto_type) class EnumDescriptor(_NestedDescriptorBase): """Descriptor for an enum defined in a .proto file. Attributes: name (str): Name of the enum type. full_name (str): Full name of the type, including package name and any enclosing type(s). values (list[EnumValueDescriptor]): List of the values in this enum. values_by_name (dict(str, EnumValueDescriptor)): Same as :attr:`values`, but indexed by the "name" field of each EnumValueDescriptor. values_by_number (dict(int, EnumValueDescriptor)): Same as :attr:`values`, but indexed by the "number" field of each EnumValueDescriptor. containing_type (Descriptor): Descriptor of the immediate containing type of this enum, or None if this is an enum defined at the top level in a .proto file. Set by Descriptor's constructor if we're passed into one. file (FileDescriptor): Reference to file descriptor. options (descriptor_pb2.EnumOptions): Enum options message or None to use default enum options. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.EnumDescriptor def __new__(cls, name, full_name, filename, values, containing_type=None, options=None, serialized_options=None, file=None, # pylint: disable=redefined-builtin serialized_start=None, serialized_end=None, create_key=None): _message.Message._CheckCalledFromGeneratedFile() return _message.default_pool.FindEnumTypeByName(full_name) def __init__(self, name, full_name, filename, values, containing_type=None, options=None, serialized_options=None, file=None, # pylint: disable=redefined-builtin serialized_start=None, serialized_end=None, create_key=None): """Arguments are as described in the attribute description above. Note that filename is an obsolete argument, that is not used anymore. Please use file.name to access this as an attribute. """ if create_key is not _internal_create_key: _Deprecated('EnumDescriptor') super(EnumDescriptor, self).__init__( options, 'EnumOptions', name, full_name, file, containing_type, serialized_start=serialized_start, serialized_end=serialized_end, serialized_options=serialized_options) self.values = values for value in self.values: value.type = self self.values_by_name = dict((v.name, v) for v in values) # Values are reversed to ensure that the first alias is retained. self.values_by_number = dict((v.number, v) for v in reversed(values)) def CopyToProto(self, proto): """Copies this to a descriptor_pb2.EnumDescriptorProto. Args: proto (descriptor_pb2.EnumDescriptorProto): An empty descriptor proto. """ # This function is overridden to give a better doc comment. super(EnumDescriptor, self).CopyToProto(proto) class EnumValueDescriptor(DescriptorBase): """Descriptor for a single value within an enum. Attributes: name (str): Name of this value. index (int): Dense, 0-indexed index giving the order that this value appears textually within its enum in the .proto file. number (int): Actual number assigned to this enum value. type (EnumDescriptor): :class:`EnumDescriptor` to which this value belongs. Set by :class:`EnumDescriptor`'s constructor if we're passed into one. options (descriptor_pb2.EnumValueOptions): Enum value options message or None to use default enum value options options. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.EnumValueDescriptor def __new__(cls, name, index, number, type=None, # pylint: disable=redefined-builtin options=None, serialized_options=None, create_key=None): _message.Message._CheckCalledFromGeneratedFile() # There is no way we can build a complete EnumValueDescriptor with the # given parameters (the name of the Enum is not known, for example). # Fortunately generated files just pass it to the EnumDescriptor() # constructor, which will ignore it, so returning None is good enough. return None def __init__(self, name, index, number, type=None, # pylint: disable=redefined-builtin options=None, serialized_options=None, create_key=None): """Arguments are as described in the attribute description above.""" if create_key is not _internal_create_key: _Deprecated('EnumValueDescriptor') super(EnumValueDescriptor, self).__init__( options, serialized_options, 'EnumValueOptions') self.name = name self.index = index self.number = number self.type = type class OneofDescriptor(DescriptorBase): """Descriptor for a oneof field. Attributes: name (str): Name of the oneof field. full_name (str): Full name of the oneof field, including package name. index (int): 0-based index giving the order of the oneof field inside its containing type. containing_type (Descriptor): :class:`Descriptor` of the protocol message type that contains this field. Set by the :class:`Descriptor` constructor if we're passed into one. fields (list[FieldDescriptor]): The list of field descriptors this oneof can contain. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.OneofDescriptor def __new__( cls, name, full_name, index, containing_type, fields, options=None, serialized_options=None, create_key=None): _message.Message._CheckCalledFromGeneratedFile() return _message.default_pool.FindOneofByName(full_name) def __init__( self, name, full_name, index, containing_type, fields, options=None, serialized_options=None, create_key=None): """Arguments are as described in the attribute description above.""" if create_key is not _internal_create_key: _Deprecated('OneofDescriptor') super(OneofDescriptor, self).__init__( options, serialized_options, 'OneofOptions') self.name = name self.full_name = full_name self.index = index self.containing_type = containing_type self.fields = fields class ServiceDescriptor(_NestedDescriptorBase): """Descriptor for a service. Attributes: name (str): Name of the service. full_name (str): Full name of the service, including package name. index (int): 0-indexed index giving the order that this services definition appears within the .proto file. methods (list[MethodDescriptor]): List of methods provided by this service. methods_by_name (dict(str, MethodDescriptor)): Same :class:`MethodDescriptor` objects as in :attr:`methods_by_name`, but indexed by "name" attribute in each :class:`MethodDescriptor`. options (descriptor_pb2.ServiceOptions): Service options message or None to use default service options. file (FileDescriptor): Reference to file info. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.ServiceDescriptor def __new__( cls, name=None, full_name=None, index=None, methods=None, options=None, serialized_options=None, file=None, # pylint: disable=redefined-builtin serialized_start=None, serialized_end=None, create_key=None): _message.Message._CheckCalledFromGeneratedFile() # pylint: disable=protected-access return _message.default_pool.FindServiceByName(full_name) def __init__(self, name, full_name, index, methods, options=None, serialized_options=None, file=None, # pylint: disable=redefined-builtin serialized_start=None, serialized_end=None, create_key=None): if create_key is not _internal_create_key: _Deprecated('ServiceDescriptor') super(ServiceDescriptor, self).__init__( options, 'ServiceOptions', name, full_name, file, None, serialized_start=serialized_start, serialized_end=serialized_end, serialized_options=serialized_options) self.index = index self.methods = methods self.methods_by_name = dict((m.name, m) for m in methods) # Set the containing service for each method in this service. for method in self.methods: method.containing_service = self def FindMethodByName(self, name): """Searches for the specified method, and returns its descriptor. Args: name (str): Name of the method. Returns: MethodDescriptor or None: the descriptor for the requested method, if found. """ return self.methods_by_name.get(name, None) def CopyToProto(self, proto): """Copies this to a descriptor_pb2.ServiceDescriptorProto. Args: proto (descriptor_pb2.ServiceDescriptorProto): An empty descriptor proto. """ # This function is overridden to give a better doc comment. super(ServiceDescriptor, self).CopyToProto(proto) class MethodDescriptor(DescriptorBase): """Descriptor for a method in a service. Attributes: name (str): Name of the method within the service. full_name (str): Full name of method. index (int): 0-indexed index of the method inside the service. containing_service (ServiceDescriptor): The service that contains this method. input_type (Descriptor): The descriptor of the message that this method accepts. output_type (Descriptor): The descriptor of the message that this method returns. client_streaming (bool): Whether this method uses client streaming. server_streaming (bool): Whether this method uses server streaming. options (descriptor_pb2.MethodOptions or None): Method options message, or None to use default method options. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.MethodDescriptor def __new__(cls, name, full_name, index, containing_service, input_type, output_type, client_streaming=False, server_streaming=False, options=None, serialized_options=None, create_key=None): _message.Message._CheckCalledFromGeneratedFile() # pylint: disable=protected-access return _message.default_pool.FindMethodByName(full_name) def __init__(self, name, full_name, index, containing_service, input_type, output_type, client_streaming=False, server_streaming=False, options=None, serialized_options=None, create_key=None): """The arguments are as described in the description of MethodDescriptor attributes above. Note that containing_service may be None, and may be set later if necessary. """ if create_key is not _internal_create_key: _Deprecated('MethodDescriptor') super(MethodDescriptor, self).__init__( options, serialized_options, 'MethodOptions') self.name = name self.full_name = full_name self.index = index self.containing_service = containing_service self.input_type = input_type self.output_type = output_type self.client_streaming = client_streaming self.server_streaming = server_streaming def CopyToProto(self, proto): """Copies this to a descriptor_pb2.MethodDescriptorProto. Args: proto (descriptor_pb2.MethodDescriptorProto): An empty descriptor proto. Raises: Error: If self couldn't be serialized, due to too few constructor arguments. """ if self.containing_service is not None: from google.protobuf import descriptor_pb2 service_proto = descriptor_pb2.ServiceDescriptorProto() self.containing_service.CopyToProto(service_proto) proto.CopyFrom(service_proto.method[self.index]) else: raise Error('Descriptor does not contain a service.') class FileDescriptor(DescriptorBase): """Descriptor for a file. Mimics the descriptor_pb2.FileDescriptorProto. Note that :attr:`enum_types_by_name`, :attr:`extensions_by_name`, and :attr:`dependencies` fields are only set by the :py:mod:`google.protobuf.message_factory` module, and not by the generated proto code. Attributes: name (str): Name of file, relative to root of source tree. package (str): Name of the package syntax (str): string indicating syntax of the file (can be "proto2" or "proto3") serialized_pb (bytes): Byte string of serialized :class:`descriptor_pb2.FileDescriptorProto`. dependencies (list[FileDescriptor]): List of other :class:`FileDescriptor` objects this :class:`FileDescriptor` depends on. public_dependencies (list[FileDescriptor]): A subset of :attr:`dependencies`, which were declared as "public". message_types_by_name (dict(str, Descriptor)): Mapping from message names to their :class:`Descriptor`. enum_types_by_name (dict(str, EnumDescriptor)): Mapping from enum names to their :class:`EnumDescriptor`. extensions_by_name (dict(str, FieldDescriptor)): Mapping from extension names declared at file scope to their :class:`FieldDescriptor`. services_by_name (dict(str, ServiceDescriptor)): Mapping from services' names to their :class:`ServiceDescriptor`. pool (DescriptorPool): The pool this descriptor belongs to. When not passed to the constructor, the global default pool is used. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.FileDescriptor def __new__(cls, name, package, options=None, serialized_options=None, serialized_pb=None, dependencies=None, public_dependencies=None, syntax=None, pool=None, create_key=None): # FileDescriptor() is called from various places, not only from generated # files, to register dynamic proto files and messages. # pylint: disable=g-explicit-bool-comparison if serialized_pb == b'': # Cpp generated code must be linked in if serialized_pb is '' try: return _message.default_pool.FindFileByName(name) except KeyError: raise RuntimeError('Please link in cpp generated lib for %s' % (name)) elif serialized_pb: return _message.default_pool.AddSerializedFile(serialized_pb) else: return super(FileDescriptor, cls).__new__(cls) def __init__(self, name, package, options=None, serialized_options=None, serialized_pb=None, dependencies=None, public_dependencies=None, syntax=None, pool=None, create_key=None): """Constructor.""" if create_key is not _internal_create_key: _Deprecated('FileDescriptor') super(FileDescriptor, self).__init__( options, serialized_options, 'FileOptions') if pool is None: from google.protobuf import descriptor_pool pool = descriptor_pool.Default() self.pool = pool self.message_types_by_name = {} self.name = name self.package = package self.syntax = syntax or "proto2" self.serialized_pb = serialized_pb self.enum_types_by_name = {} self.extensions_by_name = {} self.services_by_name = {} self.dependencies = (dependencies or []) self.public_dependencies = (public_dependencies or []) def CopyToProto(self, proto): """Copies this to a descriptor_pb2.FileDescriptorProto. Args: proto: An empty descriptor_pb2.FileDescriptorProto. """ proto.ParseFromString(self.serialized_pb) def _ParseOptions(message, string): """Parses serialized options. This helper function is used to parse serialized options in generated proto2 files. It must not be used outside proto2. """ message.ParseFromString(string) return message def _ToCamelCase(name): """Converts name to camel-case and returns it.""" capitalize_next = False result = [] for c in name: if c == '_': if result: capitalize_next = True elif capitalize_next: result.append(c.upper()) capitalize_next = False else: result += c # Lower-case the first letter. if result and result[0].isupper(): result[0] = result[0].lower() return ''.join(result) def _OptionsOrNone(descriptor_proto): """Returns the value of the field `options`, or None if it is not set.""" if descriptor_proto.HasField('options'): return descriptor_proto.options else: return None def _ToJsonName(name): """Converts name to Json name and returns it.""" capitalize_next = False result = [] for c in name: if c == '_': capitalize_next = True elif capitalize_next: result.append(c.upper()) capitalize_next = False else: result += c return ''.join(result) def MakeDescriptor(desc_proto, package='', build_file_if_cpp=True, syntax=None): """Make a protobuf Descriptor given a DescriptorProto protobuf. Handles nested descriptors. Note that this is limited to the scope of defining a message inside of another message. Composite fields can currently only be resolved if the message is defined in the same scope as the field. Args: desc_proto: The descriptor_pb2.DescriptorProto protobuf message. package: Optional package name for the new message Descriptor (string). build_file_if_cpp: Update the C++ descriptor pool if api matches. Set to False on recursion, so no duplicates are created. syntax: The syntax/semantics that should be used. Set to "proto3" to get proto3 field presence semantics. Returns: A Descriptor for protobuf messages. """ if api_implementation.Type() == 'cpp' and build_file_if_cpp: # The C++ implementation requires all descriptors to be backed by the same # definition in the C++ descriptor pool. To do this, we build a # FileDescriptorProto with the same definition as this descriptor and build # it into the pool. from google.protobuf import descriptor_pb2 file_descriptor_proto = descriptor_pb2.FileDescriptorProto() file_descriptor_proto.message_type.add().MergeFrom(desc_proto) # Generate a random name for this proto file to prevent conflicts with any # imported ones. We need to specify a file name so the descriptor pool # accepts our FileDescriptorProto, but it is not important what that file # name is actually set to. proto_name = binascii.hexlify(os.urandom(16)).decode('ascii') if package: file_descriptor_proto.name = os.path.join(package.replace('.', '/'), proto_name + '.proto') file_descriptor_proto.package = package else: file_descriptor_proto.name = proto_name + '.proto' _message.default_pool.Add(file_descriptor_proto) result = _message.default_pool.FindFileByName(file_descriptor_proto.name) if _USE_C_DESCRIPTORS: return result.message_types_by_name[desc_proto.name] full_message_name = [desc_proto.name] if package: full_message_name.insert(0, package) # Create Descriptors for enum types enum_types = {} for enum_proto in desc_proto.enum_type: full_name = '.'.join(full_message_name + [enum_proto.name]) enum_desc = EnumDescriptor( enum_proto.name, full_name, None, [ EnumValueDescriptor(enum_val.name, ii, enum_val.number, create_key=_internal_create_key) for ii, enum_val in enumerate(enum_proto.value)], create_key=_internal_create_key) enum_types[full_name] = enum_desc # Create Descriptors for nested types nested_types = {} for nested_proto in desc_proto.nested_type: full_name = '.'.join(full_message_name + [nested_proto.name]) # Nested types are just those defined inside of the message, not all types # used by fields in the message, so no loops are possible here. nested_desc = MakeDescriptor(nested_proto, package='.'.join(full_message_name), build_file_if_cpp=False, syntax=syntax) nested_types[full_name] = nested_desc fields = [] for field_proto in desc_proto.field: full_name = '.'.join(full_message_name + [field_proto.name]) enum_desc = None nested_desc = None if field_proto.json_name: json_name = field_proto.json_name else: json_name = None if field_proto.HasField('type_name'): type_name = field_proto.type_name full_type_name = '.'.join(full_message_name + [type_name[type_name.rfind('.')+1:]]) if full_type_name in nested_types: nested_desc = nested_types[full_type_name] elif full_type_name in enum_types: enum_desc = enum_types[full_type_name] # Else type_name references a non-local type, which isn't implemented field = FieldDescriptor( field_proto.name, full_name, field_proto.number - 1, field_proto.number, field_proto.type, FieldDescriptor.ProtoTypeToCppProtoType(field_proto.type), field_proto.label, None, nested_desc, enum_desc, None, False, None, options=_OptionsOrNone(field_proto), has_default_value=False, json_name=json_name, create_key=_internal_create_key) fields.append(field) desc_name = '.'.join(full_message_name) return Descriptor(desc_proto.name, desc_name, None, None, fields, list(nested_types.values()), list(enum_types.values()), [], options=_OptionsOrNone(desc_proto), create_key=_internal_create_key) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/descriptor_database.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Provides a container for DescriptorProtos.""" __author__ = 'matthewtoia@google.com (Matt Toia)' import warnings class Error(Exception): pass class DescriptorDatabaseConflictingDefinitionError(Error): """Raised when a proto is added with the same name & different descriptor.""" class DescriptorDatabase(object): """A container accepting FileDescriptorProtos and maps DescriptorProtos.""" def __init__(self): self._file_desc_protos_by_file = {} self._file_desc_protos_by_symbol = {} def Add(self, file_desc_proto): """Adds the FileDescriptorProto and its types to this database. Args: file_desc_proto: The FileDescriptorProto to add. Raises: DescriptorDatabaseConflictingDefinitionError: if an attempt is made to add a proto with the same name but different definition than an existing proto in the database. """ proto_name = file_desc_proto.name if proto_name not in self._file_desc_protos_by_file: self._file_desc_protos_by_file[proto_name] = file_desc_proto elif self._file_desc_protos_by_file[proto_name] != file_desc_proto: raise DescriptorDatabaseConflictingDefinitionError( '%s already added, but with different descriptor.' % proto_name) else: return # Add all the top-level descriptors to the index. package = file_desc_proto.package for message in file_desc_proto.message_type: for name in _ExtractSymbols(message, package): self._AddSymbol(name, file_desc_proto) for enum in file_desc_proto.enum_type: self._AddSymbol(('.'.join((package, enum.name))), file_desc_proto) for enum_value in enum.value: self._file_desc_protos_by_symbol[ '.'.join((package, enum_value.name))] = file_desc_proto for extension in file_desc_proto.extension: self._AddSymbol(('.'.join((package, extension.name))), file_desc_proto) for service in file_desc_proto.service: self._AddSymbol(('.'.join((package, service.name))), file_desc_proto) def FindFileByName(self, name): """Finds the file descriptor proto by file name. Typically the file name is a relative path ending to a .proto file. The proto with the given name will have to have been added to this database using the Add method or else an error will be raised. Args: name: The file name to find. Returns: The file descriptor proto matching the name. Raises: KeyError if no file by the given name was added. """ return self._file_desc_protos_by_file[name] def FindFileContainingSymbol(self, symbol): """Finds the file descriptor proto containing the specified symbol. The symbol should be a fully qualified name including the file descriptor's package and any containing messages. Some examples: 'some.package.name.Message' 'some.package.name.Message.NestedEnum' 'some.package.name.Message.some_field' The file descriptor proto containing the specified symbol must be added to this database using the Add method or else an error will be raised. Args: symbol: The fully qualified symbol name. Returns: The file descriptor proto containing the symbol. Raises: KeyError if no file contains the specified symbol. """ try: return self._file_desc_protos_by_symbol[symbol] except KeyError: # Fields, enum values, and nested extensions are not in # _file_desc_protos_by_symbol. Try to find the top level # descriptor. Non-existent nested symbol under a valid top level # descriptor can also be found. The behavior is the same with # protobuf C++. top_level, _, _ = symbol.rpartition('.') try: return self._file_desc_protos_by_symbol[top_level] except KeyError: # Raise the original symbol as a KeyError for better diagnostics. raise KeyError(symbol) def FindFileContainingExtension(self, extendee_name, extension_number): # TODO(jieluo): implement this API. return None def FindAllExtensionNumbers(self, extendee_name): # TODO(jieluo): implement this API. return [] def _AddSymbol(self, name, file_desc_proto): if name in self._file_desc_protos_by_symbol: warn_msg = ('Conflict register for file "' + file_desc_proto.name + '": ' + name + ' is already defined in file "' + self._file_desc_protos_by_symbol[name].name + '"') warnings.warn(warn_msg, RuntimeWarning) self._file_desc_protos_by_symbol[name] = file_desc_proto def _ExtractSymbols(desc_proto, package): """Pulls out all the symbols from a descriptor proto. Args: desc_proto: The proto to extract symbols from. package: The package containing the descriptor type. Yields: The fully qualified name found in the descriptor. """ message_name = package + '.' + desc_proto.name if package else desc_proto.name yield message_name for nested_type in desc_proto.nested_type: for symbol in _ExtractSymbols(nested_type, message_name): yield symbol for enum_type in desc_proto.enum_type: yield '.'.join((message_name, enum_type.name)) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/descriptor_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/descriptor.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR = _descriptor.FileDescriptor( name='google/protobuf/descriptor.proto', package='google.protobuf', syntax='proto2', serialized_options=None, create_key=_descriptor._internal_create_key, serialized_pb=b'\n google/protobuf/descriptor.proto\x12\x0fgoogle.protobuf\"G\n\x11\x46ileDescriptorSet\x12\x32\n\x04\x66ile\x18\x01 \x03(\x0b\x32$.google.protobuf.FileDescriptorProto\"\xdb\x03\n\x13\x46ileDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07package\x18\x02 \x01(\t\x12\x12\n\ndependency\x18\x03 \x03(\t\x12\x19\n\x11public_dependency\x18\n \x03(\x05\x12\x17\n\x0fweak_dependency\x18\x0b \x03(\x05\x12\x36\n\x0cmessage_type\x18\x04 \x03(\x0b\x32 .google.protobuf.DescriptorProto\x12\x37\n\tenum_type\x18\x05 \x03(\x0b\x32$.google.protobuf.EnumDescriptorProto\x12\x38\n\x07service\x18\x06 \x03(\x0b\x32\'.google.protobuf.ServiceDescriptorProto\x12\x38\n\textension\x18\x07 \x03(\x0b\x32%.google.protobuf.FieldDescriptorProto\x12-\n\x07options\x18\x08 \x01(\x0b\x32\x1c.google.protobuf.FileOptions\x12\x39\n\x10source_code_info\x18\t \x01(\x0b\x32\x1f.google.protobuf.SourceCodeInfo\x12\x0e\n\x06syntax\x18\x0c \x01(\t\"\xa9\x05\n\x0f\x44\x65scriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x34\n\x05\x66ield\x18\x02 \x03(\x0b\x32%.google.protobuf.FieldDescriptorProto\x12\x38\n\textension\x18\x06 \x03(\x0b\x32%.google.protobuf.FieldDescriptorProto\x12\x35\n\x0bnested_type\x18\x03 \x03(\x0b\x32 .google.protobuf.DescriptorProto\x12\x37\n\tenum_type\x18\x04 \x03(\x0b\x32$.google.protobuf.EnumDescriptorProto\x12H\n\x0f\x65xtension_range\x18\x05 \x03(\x0b\x32/.google.protobuf.DescriptorProto.ExtensionRange\x12\x39\n\noneof_decl\x18\x08 \x03(\x0b\x32%.google.protobuf.OneofDescriptorProto\x12\x30\n\x07options\x18\x07 \x01(\x0b\x32\x1f.google.protobuf.MessageOptions\x12\x46\n\x0ereserved_range\x18\t \x03(\x0b\x32..google.protobuf.DescriptorProto.ReservedRange\x12\x15\n\rreserved_name\x18\n \x03(\t\x1a\x65\n\x0e\x45xtensionRange\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\x12\x37\n\x07options\x18\x03 \x01(\x0b\x32&.google.protobuf.ExtensionRangeOptions\x1a+\n\rReservedRange\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\"g\n\x15\x45xtensionRangeOptions\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\xd5\x05\n\x14\x46ieldDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06number\x18\x03 \x01(\x05\x12:\n\x05label\x18\x04 \x01(\x0e\x32+.google.protobuf.FieldDescriptorProto.Label\x12\x38\n\x04type\x18\x05 \x01(\x0e\x32*.google.protobuf.FieldDescriptorProto.Type\x12\x11\n\ttype_name\x18\x06 \x01(\t\x12\x10\n\x08\x65xtendee\x18\x02 \x01(\t\x12\x15\n\rdefault_value\x18\x07 \x01(\t\x12\x13\n\x0boneof_index\x18\t \x01(\x05\x12\x11\n\tjson_name\x18\n \x01(\t\x12.\n\x07options\x18\x08 \x01(\x0b\x32\x1d.google.protobuf.FieldOptions\x12\x17\n\x0fproto3_optional\x18\x11 \x01(\x08\"\xb6\x02\n\x04Type\x12\x0f\n\x0bTYPE_DOUBLE\x10\x01\x12\x0e\n\nTYPE_FLOAT\x10\x02\x12\x0e\n\nTYPE_INT64\x10\x03\x12\x0f\n\x0bTYPE_UINT64\x10\x04\x12\x0e\n\nTYPE_INT32\x10\x05\x12\x10\n\x0cTYPE_FIXED64\x10\x06\x12\x10\n\x0cTYPE_FIXED32\x10\x07\x12\r\n\tTYPE_BOOL\x10\x08\x12\x0f\n\x0bTYPE_STRING\x10\t\x12\x0e\n\nTYPE_GROUP\x10\n\x12\x10\n\x0cTYPE_MESSAGE\x10\x0b\x12\x0e\n\nTYPE_BYTES\x10\x0c\x12\x0f\n\x0bTYPE_UINT32\x10\r\x12\r\n\tTYPE_ENUM\x10\x0e\x12\x11\n\rTYPE_SFIXED32\x10\x0f\x12\x11\n\rTYPE_SFIXED64\x10\x10\x12\x0f\n\x0bTYPE_SINT32\x10\x11\x12\x0f\n\x0bTYPE_SINT64\x10\x12\"C\n\x05Label\x12\x12\n\x0eLABEL_OPTIONAL\x10\x01\x12\x12\n\x0eLABEL_REQUIRED\x10\x02\x12\x12\n\x0eLABEL_REPEATED\x10\x03\"T\n\x14OneofDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12.\n\x07options\x18\x02 \x01(\x0b\x32\x1d.google.protobuf.OneofOptions\"\xa4\x02\n\x13\x45numDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x38\n\x05value\x18\x02 \x03(\x0b\x32).google.protobuf.EnumValueDescriptorProto\x12-\n\x07options\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.EnumOptions\x12N\n\x0ereserved_range\x18\x04 \x03(\x0b\x32\x36.google.protobuf.EnumDescriptorProto.EnumReservedRange\x12\x15\n\rreserved_name\x18\x05 \x03(\t\x1a/\n\x11\x45numReservedRange\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\"l\n\x18\x45numValueDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06number\x18\x02 \x01(\x05\x12\x32\n\x07options\x18\x03 \x01(\x0b\x32!.google.protobuf.EnumValueOptions\"\x90\x01\n\x16ServiceDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x36\n\x06method\x18\x02 \x03(\x0b\x32&.google.protobuf.MethodDescriptorProto\x12\x30\n\x07options\x18\x03 \x01(\x0b\x32\x1f.google.protobuf.ServiceOptions\"\xc1\x01\n\x15MethodDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ninput_type\x18\x02 \x01(\t\x12\x13\n\x0boutput_type\x18\x03 \x01(\t\x12/\n\x07options\x18\x04 \x01(\x0b\x32\x1e.google.protobuf.MethodOptions\x12\x1f\n\x10\x63lient_streaming\x18\x05 \x01(\x08:\x05\x66\x61lse\x12\x1f\n\x10server_streaming\x18\x06 \x01(\x08:\x05\x66\x61lse\"\xa5\x06\n\x0b\x46ileOptions\x12\x14\n\x0cjava_package\x18\x01 \x01(\t\x12\x1c\n\x14java_outer_classname\x18\x08 \x01(\t\x12\"\n\x13java_multiple_files\x18\n \x01(\x08:\x05\x66\x61lse\x12)\n\x1djava_generate_equals_and_hash\x18\x14 \x01(\x08\x42\x02\x18\x01\x12%\n\x16java_string_check_utf8\x18\x1b \x01(\x08:\x05\x66\x61lse\x12\x46\n\x0coptimize_for\x18\t \x01(\x0e\x32).google.protobuf.FileOptions.OptimizeMode:\x05SPEED\x12\x12\n\ngo_package\x18\x0b \x01(\t\x12\"\n\x13\x63\x63_generic_services\x18\x10 \x01(\x08:\x05\x66\x61lse\x12$\n\x15java_generic_services\x18\x11 \x01(\x08:\x05\x66\x61lse\x12\"\n\x13py_generic_services\x18\x12 \x01(\x08:\x05\x66\x61lse\x12#\n\x14php_generic_services\x18* \x01(\x08:\x05\x66\x61lse\x12\x19\n\ndeprecated\x18\x17 \x01(\x08:\x05\x66\x61lse\x12\x1e\n\x10\x63\x63_enable_arenas\x18\x1f \x01(\x08:\x04true\x12\x19\n\x11objc_class_prefix\x18$ \x01(\t\x12\x18\n\x10\x63sharp_namespace\x18% \x01(\t\x12\x14\n\x0cswift_prefix\x18\' \x01(\t\x12\x18\n\x10php_class_prefix\x18( \x01(\t\x12\x15\n\rphp_namespace\x18) \x01(\t\x12\x1e\n\x16php_metadata_namespace\x18, \x01(\t\x12\x14\n\x0cruby_package\x18- \x01(\t\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption\":\n\x0cOptimizeMode\x12\t\n\x05SPEED\x10\x01\x12\r\n\tCODE_SIZE\x10\x02\x12\x10\n\x0cLITE_RUNTIME\x10\x03*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08&\x10\'\"\x84\x02\n\x0eMessageOptions\x12&\n\x17message_set_wire_format\x18\x01 \x01(\x08:\x05\x66\x61lse\x12.\n\x1fno_standard_descriptor_accessor\x18\x02 \x01(\x08:\x05\x66\x61lse\x12\x19\n\ndeprecated\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\x11\n\tmap_entry\x18\x07 \x01(\x08\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08\x04\x10\x05J\x04\x08\x05\x10\x06J\x04\x08\x06\x10\x07J\x04\x08\x08\x10\tJ\x04\x08\t\x10\n\"\xbe\x03\n\x0c\x46ieldOptions\x12:\n\x05\x63type\x18\x01 \x01(\x0e\x32#.google.protobuf.FieldOptions.CType:\x06STRING\x12\x0e\n\x06packed\x18\x02 \x01(\x08\x12?\n\x06jstype\x18\x06 \x01(\x0e\x32$.google.protobuf.FieldOptions.JSType:\tJS_NORMAL\x12\x13\n\x04lazy\x18\x05 \x01(\x08:\x05\x66\x61lse\x12\x1e\n\x0funverified_lazy\x18\x0f \x01(\x08:\x05\x66\x61lse\x12\x19\n\ndeprecated\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\x13\n\x04weak\x18\n \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption\"/\n\x05\x43Type\x12\n\n\x06STRING\x10\x00\x12\x08\n\x04\x43ORD\x10\x01\x12\x10\n\x0cSTRING_PIECE\x10\x02\"5\n\x06JSType\x12\r\n\tJS_NORMAL\x10\x00\x12\r\n\tJS_STRING\x10\x01\x12\r\n\tJS_NUMBER\x10\x02*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08\x04\x10\x05\"^\n\x0cOneofOptions\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\x93\x01\n\x0b\x45numOptions\x12\x13\n\x0b\x61llow_alias\x18\x02 \x01(\x08\x12\x19\n\ndeprecated\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08\x05\x10\x06\"}\n\x10\x45numValueOptions\x12\x19\n\ndeprecated\x18\x01 \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"{\n\x0eServiceOptions\x12\x19\n\ndeprecated\x18! \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\xad\x02\n\rMethodOptions\x12\x19\n\ndeprecated\x18! \x01(\x08:\x05\x66\x61lse\x12_\n\x11idempotency_level\x18\" \x01(\x0e\x32/.google.protobuf.MethodOptions.IdempotencyLevel:\x13IDEMPOTENCY_UNKNOWN\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption\"P\n\x10IdempotencyLevel\x12\x17\n\x13IDEMPOTENCY_UNKNOWN\x10\x00\x12\x13\n\x0fNO_SIDE_EFFECTS\x10\x01\x12\x0e\n\nIDEMPOTENT\x10\x02*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\x9e\x02\n\x13UninterpretedOption\x12;\n\x04name\x18\x02 \x03(\x0b\x32-.google.protobuf.UninterpretedOption.NamePart\x12\x18\n\x10identifier_value\x18\x03 \x01(\t\x12\x1a\n\x12positive_int_value\x18\x04 \x01(\x04\x12\x1a\n\x12negative_int_value\x18\x05 \x01(\x03\x12\x14\n\x0c\x64ouble_value\x18\x06 \x01(\x01\x12\x14\n\x0cstring_value\x18\x07 \x01(\x0c\x12\x17\n\x0f\x61ggregate_value\x18\x08 \x01(\t\x1a\x33\n\x08NamePart\x12\x11\n\tname_part\x18\x01 \x02(\t\x12\x14\n\x0cis_extension\x18\x02 \x02(\x08\"\xd5\x01\n\x0eSourceCodeInfo\x12:\n\x08location\x18\x01 \x03(\x0b\x32(.google.protobuf.SourceCodeInfo.Location\x1a\x86\x01\n\x08Location\x12\x10\n\x04path\x18\x01 \x03(\x05\x42\x02\x10\x01\x12\x10\n\x04span\x18\x02 \x03(\x05\x42\x02\x10\x01\x12\x18\n\x10leading_comments\x18\x03 \x01(\t\x12\x19\n\x11trailing_comments\x18\x04 \x01(\t\x12!\n\x19leading_detached_comments\x18\x06 \x03(\t\"\xa7\x01\n\x11GeneratedCodeInfo\x12\x41\n\nannotation\x18\x01 \x03(\x0b\x32-.google.protobuf.GeneratedCodeInfo.Annotation\x1aO\n\nAnnotation\x12\x10\n\x04path\x18\x01 \x03(\x05\x42\x02\x10\x01\x12\x13\n\x0bsource_file\x18\x02 \x01(\t\x12\r\n\x05\x62\x65gin\x18\x03 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x04 \x01(\x05\x42~\n\x13\x63om.google.protobufB\x10\x44\x65scriptorProtosH\x01Z-google.golang.org/protobuf/types/descriptorpb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1aGoogle.Protobuf.Reflection' ) else: DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n google/protobuf/descriptor.proto\x12\x0fgoogle.protobuf\"G\n\x11\x46ileDescriptorSet\x12\x32\n\x04\x66ile\x18\x01 \x03(\x0b\x32$.google.protobuf.FileDescriptorProto\"\xdb\x03\n\x13\x46ileDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07package\x18\x02 \x01(\t\x12\x12\n\ndependency\x18\x03 \x03(\t\x12\x19\n\x11public_dependency\x18\n \x03(\x05\x12\x17\n\x0fweak_dependency\x18\x0b \x03(\x05\x12\x36\n\x0cmessage_type\x18\x04 \x03(\x0b\x32 .google.protobuf.DescriptorProto\x12\x37\n\tenum_type\x18\x05 \x03(\x0b\x32$.google.protobuf.EnumDescriptorProto\x12\x38\n\x07service\x18\x06 \x03(\x0b\x32\'.google.protobuf.ServiceDescriptorProto\x12\x38\n\textension\x18\x07 \x03(\x0b\x32%.google.protobuf.FieldDescriptorProto\x12-\n\x07options\x18\x08 \x01(\x0b\x32\x1c.google.protobuf.FileOptions\x12\x39\n\x10source_code_info\x18\t \x01(\x0b\x32\x1f.google.protobuf.SourceCodeInfo\x12\x0e\n\x06syntax\x18\x0c \x01(\t\"\xa9\x05\n\x0f\x44\x65scriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x34\n\x05\x66ield\x18\x02 \x03(\x0b\x32%.google.protobuf.FieldDescriptorProto\x12\x38\n\textension\x18\x06 \x03(\x0b\x32%.google.protobuf.FieldDescriptorProto\x12\x35\n\x0bnested_type\x18\x03 \x03(\x0b\x32 .google.protobuf.DescriptorProto\x12\x37\n\tenum_type\x18\x04 \x03(\x0b\x32$.google.protobuf.EnumDescriptorProto\x12H\n\x0f\x65xtension_range\x18\x05 \x03(\x0b\x32/.google.protobuf.DescriptorProto.ExtensionRange\x12\x39\n\noneof_decl\x18\x08 \x03(\x0b\x32%.google.protobuf.OneofDescriptorProto\x12\x30\n\x07options\x18\x07 \x01(\x0b\x32\x1f.google.protobuf.MessageOptions\x12\x46\n\x0ereserved_range\x18\t \x03(\x0b\x32..google.protobuf.DescriptorProto.ReservedRange\x12\x15\n\rreserved_name\x18\n \x03(\t\x1a\x65\n\x0e\x45xtensionRange\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\x12\x37\n\x07options\x18\x03 \x01(\x0b\x32&.google.protobuf.ExtensionRangeOptions\x1a+\n\rReservedRange\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\"g\n\x15\x45xtensionRangeOptions\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\xd5\x05\n\x14\x46ieldDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06number\x18\x03 \x01(\x05\x12:\n\x05label\x18\x04 \x01(\x0e\x32+.google.protobuf.FieldDescriptorProto.Label\x12\x38\n\x04type\x18\x05 \x01(\x0e\x32*.google.protobuf.FieldDescriptorProto.Type\x12\x11\n\ttype_name\x18\x06 \x01(\t\x12\x10\n\x08\x65xtendee\x18\x02 \x01(\t\x12\x15\n\rdefault_value\x18\x07 \x01(\t\x12\x13\n\x0boneof_index\x18\t \x01(\x05\x12\x11\n\tjson_name\x18\n \x01(\t\x12.\n\x07options\x18\x08 \x01(\x0b\x32\x1d.google.protobuf.FieldOptions\x12\x17\n\x0fproto3_optional\x18\x11 \x01(\x08\"\xb6\x02\n\x04Type\x12\x0f\n\x0bTYPE_DOUBLE\x10\x01\x12\x0e\n\nTYPE_FLOAT\x10\x02\x12\x0e\n\nTYPE_INT64\x10\x03\x12\x0f\n\x0bTYPE_UINT64\x10\x04\x12\x0e\n\nTYPE_INT32\x10\x05\x12\x10\n\x0cTYPE_FIXED64\x10\x06\x12\x10\n\x0cTYPE_FIXED32\x10\x07\x12\r\n\tTYPE_BOOL\x10\x08\x12\x0f\n\x0bTYPE_STRING\x10\t\x12\x0e\n\nTYPE_GROUP\x10\n\x12\x10\n\x0cTYPE_MESSAGE\x10\x0b\x12\x0e\n\nTYPE_BYTES\x10\x0c\x12\x0f\n\x0bTYPE_UINT32\x10\r\x12\r\n\tTYPE_ENUM\x10\x0e\x12\x11\n\rTYPE_SFIXED32\x10\x0f\x12\x11\n\rTYPE_SFIXED64\x10\x10\x12\x0f\n\x0bTYPE_SINT32\x10\x11\x12\x0f\n\x0bTYPE_SINT64\x10\x12\"C\n\x05Label\x12\x12\n\x0eLABEL_OPTIONAL\x10\x01\x12\x12\n\x0eLABEL_REQUIRED\x10\x02\x12\x12\n\x0eLABEL_REPEATED\x10\x03\"T\n\x14OneofDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12.\n\x07options\x18\x02 \x01(\x0b\x32\x1d.google.protobuf.OneofOptions\"\xa4\x02\n\x13\x45numDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x38\n\x05value\x18\x02 \x03(\x0b\x32).google.protobuf.EnumValueDescriptorProto\x12-\n\x07options\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.EnumOptions\x12N\n\x0ereserved_range\x18\x04 \x03(\x0b\x32\x36.google.protobuf.EnumDescriptorProto.EnumReservedRange\x12\x15\n\rreserved_name\x18\x05 \x03(\t\x1a/\n\x11\x45numReservedRange\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\"l\n\x18\x45numValueDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06number\x18\x02 \x01(\x05\x12\x32\n\x07options\x18\x03 \x01(\x0b\x32!.google.protobuf.EnumValueOptions\"\x90\x01\n\x16ServiceDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x36\n\x06method\x18\x02 \x03(\x0b\x32&.google.protobuf.MethodDescriptorProto\x12\x30\n\x07options\x18\x03 \x01(\x0b\x32\x1f.google.protobuf.ServiceOptions\"\xc1\x01\n\x15MethodDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ninput_type\x18\x02 \x01(\t\x12\x13\n\x0boutput_type\x18\x03 \x01(\t\x12/\n\x07options\x18\x04 \x01(\x0b\x32\x1e.google.protobuf.MethodOptions\x12\x1f\n\x10\x63lient_streaming\x18\x05 \x01(\x08:\x05\x66\x61lse\x12\x1f\n\x10server_streaming\x18\x06 \x01(\x08:\x05\x66\x61lse\"\xa5\x06\n\x0b\x46ileOptions\x12\x14\n\x0cjava_package\x18\x01 \x01(\t\x12\x1c\n\x14java_outer_classname\x18\x08 \x01(\t\x12\"\n\x13java_multiple_files\x18\n \x01(\x08:\x05\x66\x61lse\x12)\n\x1djava_generate_equals_and_hash\x18\x14 \x01(\x08\x42\x02\x18\x01\x12%\n\x16java_string_check_utf8\x18\x1b \x01(\x08:\x05\x66\x61lse\x12\x46\n\x0coptimize_for\x18\t \x01(\x0e\x32).google.protobuf.FileOptions.OptimizeMode:\x05SPEED\x12\x12\n\ngo_package\x18\x0b \x01(\t\x12\"\n\x13\x63\x63_generic_services\x18\x10 \x01(\x08:\x05\x66\x61lse\x12$\n\x15java_generic_services\x18\x11 \x01(\x08:\x05\x66\x61lse\x12\"\n\x13py_generic_services\x18\x12 \x01(\x08:\x05\x66\x61lse\x12#\n\x14php_generic_services\x18* \x01(\x08:\x05\x66\x61lse\x12\x19\n\ndeprecated\x18\x17 \x01(\x08:\x05\x66\x61lse\x12\x1e\n\x10\x63\x63_enable_arenas\x18\x1f \x01(\x08:\x04true\x12\x19\n\x11objc_class_prefix\x18$ \x01(\t\x12\x18\n\x10\x63sharp_namespace\x18% \x01(\t\x12\x14\n\x0cswift_prefix\x18\' \x01(\t\x12\x18\n\x10php_class_prefix\x18( \x01(\t\x12\x15\n\rphp_namespace\x18) \x01(\t\x12\x1e\n\x16php_metadata_namespace\x18, \x01(\t\x12\x14\n\x0cruby_package\x18- \x01(\t\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption\":\n\x0cOptimizeMode\x12\t\n\x05SPEED\x10\x01\x12\r\n\tCODE_SIZE\x10\x02\x12\x10\n\x0cLITE_RUNTIME\x10\x03*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08&\x10\'\"\x84\x02\n\x0eMessageOptions\x12&\n\x17message_set_wire_format\x18\x01 \x01(\x08:\x05\x66\x61lse\x12.\n\x1fno_standard_descriptor_accessor\x18\x02 \x01(\x08:\x05\x66\x61lse\x12\x19\n\ndeprecated\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\x11\n\tmap_entry\x18\x07 \x01(\x08\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08\x04\x10\x05J\x04\x08\x05\x10\x06J\x04\x08\x06\x10\x07J\x04\x08\x08\x10\tJ\x04\x08\t\x10\n\"\xbe\x03\n\x0c\x46ieldOptions\x12:\n\x05\x63type\x18\x01 \x01(\x0e\x32#.google.protobuf.FieldOptions.CType:\x06STRING\x12\x0e\n\x06packed\x18\x02 \x01(\x08\x12?\n\x06jstype\x18\x06 \x01(\x0e\x32$.google.protobuf.FieldOptions.JSType:\tJS_NORMAL\x12\x13\n\x04lazy\x18\x05 \x01(\x08:\x05\x66\x61lse\x12\x1e\n\x0funverified_lazy\x18\x0f \x01(\x08:\x05\x66\x61lse\x12\x19\n\ndeprecated\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\x13\n\x04weak\x18\n \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption\"/\n\x05\x43Type\x12\n\n\x06STRING\x10\x00\x12\x08\n\x04\x43ORD\x10\x01\x12\x10\n\x0cSTRING_PIECE\x10\x02\"5\n\x06JSType\x12\r\n\tJS_NORMAL\x10\x00\x12\r\n\tJS_STRING\x10\x01\x12\r\n\tJS_NUMBER\x10\x02*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08\x04\x10\x05\"^\n\x0cOneofOptions\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\x93\x01\n\x0b\x45numOptions\x12\x13\n\x0b\x61llow_alias\x18\x02 \x01(\x08\x12\x19\n\ndeprecated\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08\x05\x10\x06\"}\n\x10\x45numValueOptions\x12\x19\n\ndeprecated\x18\x01 \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"{\n\x0eServiceOptions\x12\x19\n\ndeprecated\x18! \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\xad\x02\n\rMethodOptions\x12\x19\n\ndeprecated\x18! \x01(\x08:\x05\x66\x61lse\x12_\n\x11idempotency_level\x18\" \x01(\x0e\x32/.google.protobuf.MethodOptions.IdempotencyLevel:\x13IDEMPOTENCY_UNKNOWN\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption\"P\n\x10IdempotencyLevel\x12\x17\n\x13IDEMPOTENCY_UNKNOWN\x10\x00\x12\x13\n\x0fNO_SIDE_EFFECTS\x10\x01\x12\x0e\n\nIDEMPOTENT\x10\x02*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\x9e\x02\n\x13UninterpretedOption\x12;\n\x04name\x18\x02 \x03(\x0b\x32-.google.protobuf.UninterpretedOption.NamePart\x12\x18\n\x10identifier_value\x18\x03 \x01(\t\x12\x1a\n\x12positive_int_value\x18\x04 \x01(\x04\x12\x1a\n\x12negative_int_value\x18\x05 \x01(\x03\x12\x14\n\x0c\x64ouble_value\x18\x06 \x01(\x01\x12\x14\n\x0cstring_value\x18\x07 \x01(\x0c\x12\x17\n\x0f\x61ggregate_value\x18\x08 \x01(\t\x1a\x33\n\x08NamePart\x12\x11\n\tname_part\x18\x01 \x02(\t\x12\x14\n\x0cis_extension\x18\x02 \x02(\x08\"\xd5\x01\n\x0eSourceCodeInfo\x12:\n\x08location\x18\x01 \x03(\x0b\x32(.google.protobuf.SourceCodeInfo.Location\x1a\x86\x01\n\x08Location\x12\x10\n\x04path\x18\x01 \x03(\x05\x42\x02\x10\x01\x12\x10\n\x04span\x18\x02 \x03(\x05\x42\x02\x10\x01\x12\x18\n\x10leading_comments\x18\x03 \x01(\t\x12\x19\n\x11trailing_comments\x18\x04 \x01(\t\x12!\n\x19leading_detached_comments\x18\x06 \x03(\t\"\xa7\x01\n\x11GeneratedCodeInfo\x12\x41\n\nannotation\x18\x01 \x03(\x0b\x32-.google.protobuf.GeneratedCodeInfo.Annotation\x1aO\n\nAnnotation\x12\x10\n\x04path\x18\x01 \x03(\x05\x42\x02\x10\x01\x12\x13\n\x0bsource_file\x18\x02 \x01(\t\x12\r\n\x05\x62\x65gin\x18\x03 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x04 \x01(\x05\x42~\n\x13\x63om.google.protobufB\x10\x44\x65scriptorProtosH\x01Z-google.golang.org/protobuf/types/descriptorpb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1aGoogle.Protobuf.Reflection') if _descriptor._USE_C_DESCRIPTORS == False: _FIELDDESCRIPTORPROTO_TYPE = _descriptor.EnumDescriptor( name='Type', full_name='google.protobuf.FieldDescriptorProto.Type', filename=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key, values=[ _descriptor.EnumValueDescriptor( name='TYPE_DOUBLE', index=0, number=1, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_FLOAT', index=1, number=2, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_INT64', index=2, number=3, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_UINT64', index=3, number=4, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_INT32', index=4, number=5, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_FIXED64', index=5, number=6, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_FIXED32', index=6, number=7, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_BOOL', index=7, number=8, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_STRING', index=8, number=9, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_GROUP', index=9, number=10, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_MESSAGE', index=10, number=11, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_BYTES', index=11, number=12, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_UINT32', index=12, number=13, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_ENUM', index=13, number=14, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_SFIXED32', index=14, number=15, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_SFIXED64', index=15, number=16, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_SINT32', index=16, number=17, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_SINT64', index=17, number=18, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, ) _sym_db.RegisterEnumDescriptor(_FIELDDESCRIPTORPROTO_TYPE) _FIELDDESCRIPTORPROTO_LABEL = _descriptor.EnumDescriptor( name='Label', full_name='google.protobuf.FieldDescriptorProto.Label', filename=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key, values=[ _descriptor.EnumValueDescriptor( name='LABEL_OPTIONAL', index=0, number=1, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='LABEL_REQUIRED', index=1, number=2, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='LABEL_REPEATED', index=2, number=3, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, ) _sym_db.RegisterEnumDescriptor(_FIELDDESCRIPTORPROTO_LABEL) _FILEOPTIONS_OPTIMIZEMODE = _descriptor.EnumDescriptor( name='OptimizeMode', full_name='google.protobuf.FileOptions.OptimizeMode', filename=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key, values=[ _descriptor.EnumValueDescriptor( name='SPEED', index=0, number=1, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='CODE_SIZE', index=1, number=2, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='LITE_RUNTIME', index=2, number=3, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, ) _sym_db.RegisterEnumDescriptor(_FILEOPTIONS_OPTIMIZEMODE) _FIELDOPTIONS_CTYPE = _descriptor.EnumDescriptor( name='CType', full_name='google.protobuf.FieldOptions.CType', filename=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key, values=[ _descriptor.EnumValueDescriptor( name='STRING', index=0, number=0, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='CORD', index=1, number=1, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='STRING_PIECE', index=2, number=2, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, ) _sym_db.RegisterEnumDescriptor(_FIELDOPTIONS_CTYPE) _FIELDOPTIONS_JSTYPE = _descriptor.EnumDescriptor( name='JSType', full_name='google.protobuf.FieldOptions.JSType', filename=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key, values=[ _descriptor.EnumValueDescriptor( name='JS_NORMAL', index=0, number=0, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='JS_STRING', index=1, number=1, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='JS_NUMBER', index=2, number=2, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, ) _sym_db.RegisterEnumDescriptor(_FIELDOPTIONS_JSTYPE) _METHODOPTIONS_IDEMPOTENCYLEVEL = _descriptor.EnumDescriptor( name='IdempotencyLevel', full_name='google.protobuf.MethodOptions.IdempotencyLevel', filename=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key, values=[ _descriptor.EnumValueDescriptor( name='IDEMPOTENCY_UNKNOWN', index=0, number=0, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='NO_SIDE_EFFECTS', index=1, number=1, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='IDEMPOTENT', index=2, number=2, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, ) _sym_db.RegisterEnumDescriptor(_METHODOPTIONS_IDEMPOTENCYLEVEL) _FILEDESCRIPTORSET = _descriptor.Descriptor( name='FileDescriptorSet', full_name='google.protobuf.FileDescriptorSet', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='file', full_name='google.protobuf.FileDescriptorSet.file', index=0, number=1, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _FILEDESCRIPTORPROTO = _descriptor.Descriptor( name='FileDescriptorProto', full_name='google.protobuf.FileDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.FileDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='package', full_name='google.protobuf.FileDescriptorProto.package', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='dependency', full_name='google.protobuf.FileDescriptorProto.dependency', index=2, number=3, type=9, cpp_type=9, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='public_dependency', full_name='google.protobuf.FileDescriptorProto.public_dependency', index=3, number=10, type=5, cpp_type=1, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='weak_dependency', full_name='google.protobuf.FileDescriptorProto.weak_dependency', index=4, number=11, type=5, cpp_type=1, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='message_type', full_name='google.protobuf.FileDescriptorProto.message_type', index=5, number=4, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='enum_type', full_name='google.protobuf.FileDescriptorProto.enum_type', index=6, number=5, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='service', full_name='google.protobuf.FileDescriptorProto.service', index=7, number=6, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='extension', full_name='google.protobuf.FileDescriptorProto.extension', index=8, number=7, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.FileDescriptorProto.options', index=9, number=8, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='source_code_info', full_name='google.protobuf.FileDescriptorProto.source_code_info', index=10, number=9, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='syntax', full_name='google.protobuf.FileDescriptorProto.syntax', index=11, number=12, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _DESCRIPTORPROTO_EXTENSIONRANGE = _descriptor.Descriptor( name='ExtensionRange', full_name='google.protobuf.DescriptorProto.ExtensionRange', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='start', full_name='google.protobuf.DescriptorProto.ExtensionRange.start', index=0, number=1, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='end', full_name='google.protobuf.DescriptorProto.ExtensionRange.end', index=1, number=2, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.DescriptorProto.ExtensionRange.options', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _DESCRIPTORPROTO_RESERVEDRANGE = _descriptor.Descriptor( name='ReservedRange', full_name='google.protobuf.DescriptorProto.ReservedRange', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='start', full_name='google.protobuf.DescriptorProto.ReservedRange.start', index=0, number=1, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='end', full_name='google.protobuf.DescriptorProto.ReservedRange.end', index=1, number=2, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _DESCRIPTORPROTO = _descriptor.Descriptor( name='DescriptorProto', full_name='google.protobuf.DescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.DescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='field', full_name='google.protobuf.DescriptorProto.field', index=1, number=2, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='extension', full_name='google.protobuf.DescriptorProto.extension', index=2, number=6, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='nested_type', full_name='google.protobuf.DescriptorProto.nested_type', index=3, number=3, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='enum_type', full_name='google.protobuf.DescriptorProto.enum_type', index=4, number=4, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='extension_range', full_name='google.protobuf.DescriptorProto.extension_range', index=5, number=5, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='oneof_decl', full_name='google.protobuf.DescriptorProto.oneof_decl', index=6, number=8, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.DescriptorProto.options', index=7, number=7, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='reserved_range', full_name='google.protobuf.DescriptorProto.reserved_range', index=8, number=9, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='reserved_name', full_name='google.protobuf.DescriptorProto.reserved_name', index=9, number=10, type=9, cpp_type=9, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[_DESCRIPTORPROTO_EXTENSIONRANGE, _DESCRIPTORPROTO_RESERVEDRANGE, ], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _EXTENSIONRANGEOPTIONS = _descriptor.Descriptor( name='ExtensionRangeOptions', full_name='google.protobuf.ExtensionRangeOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.ExtensionRangeOptions.uninterpreted_option', index=0, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _FIELDDESCRIPTORPROTO = _descriptor.Descriptor( name='FieldDescriptorProto', full_name='google.protobuf.FieldDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.FieldDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='number', full_name='google.protobuf.FieldDescriptorProto.number', index=1, number=3, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='label', full_name='google.protobuf.FieldDescriptorProto.label', index=2, number=4, type=14, cpp_type=8, label=1, has_default_value=False, default_value=1, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='type', full_name='google.protobuf.FieldDescriptorProto.type', index=3, number=5, type=14, cpp_type=8, label=1, has_default_value=False, default_value=1, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='type_name', full_name='google.protobuf.FieldDescriptorProto.type_name', index=4, number=6, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='extendee', full_name='google.protobuf.FieldDescriptorProto.extendee', index=5, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='default_value', full_name='google.protobuf.FieldDescriptorProto.default_value', index=6, number=7, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='oneof_index', full_name='google.protobuf.FieldDescriptorProto.oneof_index', index=7, number=9, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='json_name', full_name='google.protobuf.FieldDescriptorProto.json_name', index=8, number=10, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.FieldDescriptorProto.options', index=9, number=8, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='proto3_optional', full_name='google.protobuf.FieldDescriptorProto.proto3_optional', index=10, number=17, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ _FIELDDESCRIPTORPROTO_TYPE, _FIELDDESCRIPTORPROTO_LABEL, ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _ONEOFDESCRIPTORPROTO = _descriptor.Descriptor( name='OneofDescriptorProto', full_name='google.protobuf.OneofDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.OneofDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.OneofDescriptorProto.options', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _ENUMDESCRIPTORPROTO_ENUMRESERVEDRANGE = _descriptor.Descriptor( name='EnumReservedRange', full_name='google.protobuf.EnumDescriptorProto.EnumReservedRange', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='start', full_name='google.protobuf.EnumDescriptorProto.EnumReservedRange.start', index=0, number=1, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='end', full_name='google.protobuf.EnumDescriptorProto.EnumReservedRange.end', index=1, number=2, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _ENUMDESCRIPTORPROTO = _descriptor.Descriptor( name='EnumDescriptorProto', full_name='google.protobuf.EnumDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.EnumDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='value', full_name='google.protobuf.EnumDescriptorProto.value', index=1, number=2, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.EnumDescriptorProto.options', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='reserved_range', full_name='google.protobuf.EnumDescriptorProto.reserved_range', index=3, number=4, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='reserved_name', full_name='google.protobuf.EnumDescriptorProto.reserved_name', index=4, number=5, type=9, cpp_type=9, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[_ENUMDESCRIPTORPROTO_ENUMRESERVEDRANGE, ], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _ENUMVALUEDESCRIPTORPROTO = _descriptor.Descriptor( name='EnumValueDescriptorProto', full_name='google.protobuf.EnumValueDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.EnumValueDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='number', full_name='google.protobuf.EnumValueDescriptorProto.number', index=1, number=2, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.EnumValueDescriptorProto.options', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _SERVICEDESCRIPTORPROTO = _descriptor.Descriptor( name='ServiceDescriptorProto', full_name='google.protobuf.ServiceDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.ServiceDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='method', full_name='google.protobuf.ServiceDescriptorProto.method', index=1, number=2, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.ServiceDescriptorProto.options', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _METHODDESCRIPTORPROTO = _descriptor.Descriptor( name='MethodDescriptorProto', full_name='google.protobuf.MethodDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.MethodDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='input_type', full_name='google.protobuf.MethodDescriptorProto.input_type', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='output_type', full_name='google.protobuf.MethodDescriptorProto.output_type', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.MethodDescriptorProto.options', index=3, number=4, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='client_streaming', full_name='google.protobuf.MethodDescriptorProto.client_streaming', index=4, number=5, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='server_streaming', full_name='google.protobuf.MethodDescriptorProto.server_streaming', index=5, number=6, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _FILEOPTIONS = _descriptor.Descriptor( name='FileOptions', full_name='google.protobuf.FileOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='java_package', full_name='google.protobuf.FileOptions.java_package', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='java_outer_classname', full_name='google.protobuf.FileOptions.java_outer_classname', index=1, number=8, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='java_multiple_files', full_name='google.protobuf.FileOptions.java_multiple_files', index=2, number=10, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='java_generate_equals_and_hash', full_name='google.protobuf.FileOptions.java_generate_equals_and_hash', index=3, number=20, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='java_string_check_utf8', full_name='google.protobuf.FileOptions.java_string_check_utf8', index=4, number=27, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='optimize_for', full_name='google.protobuf.FileOptions.optimize_for', index=5, number=9, type=14, cpp_type=8, label=1, has_default_value=True, default_value=1, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='go_package', full_name='google.protobuf.FileOptions.go_package', index=6, number=11, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='cc_generic_services', full_name='google.protobuf.FileOptions.cc_generic_services', index=7, number=16, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='java_generic_services', full_name='google.protobuf.FileOptions.java_generic_services', index=8, number=17, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='py_generic_services', full_name='google.protobuf.FileOptions.py_generic_services', index=9, number=18, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='php_generic_services', full_name='google.protobuf.FileOptions.php_generic_services', index=10, number=42, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.FileOptions.deprecated', index=11, number=23, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='cc_enable_arenas', full_name='google.protobuf.FileOptions.cc_enable_arenas', index=12, number=31, type=8, cpp_type=7, label=1, has_default_value=True, default_value=True, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='objc_class_prefix', full_name='google.protobuf.FileOptions.objc_class_prefix', index=13, number=36, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='csharp_namespace', full_name='google.protobuf.FileOptions.csharp_namespace', index=14, number=37, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='swift_prefix', full_name='google.protobuf.FileOptions.swift_prefix', index=15, number=39, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='php_class_prefix', full_name='google.protobuf.FileOptions.php_class_prefix', index=16, number=40, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='php_namespace', full_name='google.protobuf.FileOptions.php_namespace', index=17, number=41, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='php_metadata_namespace', full_name='google.protobuf.FileOptions.php_metadata_namespace', index=18, number=44, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='ruby_package', full_name='google.protobuf.FileOptions.ruby_package', index=19, number=45, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.FileOptions.uninterpreted_option', index=20, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ _FILEOPTIONS_OPTIMIZEMODE, ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _MESSAGEOPTIONS = _descriptor.Descriptor( name='MessageOptions', full_name='google.protobuf.MessageOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='message_set_wire_format', full_name='google.protobuf.MessageOptions.message_set_wire_format', index=0, number=1, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='no_standard_descriptor_accessor', full_name='google.protobuf.MessageOptions.no_standard_descriptor_accessor', index=1, number=2, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.MessageOptions.deprecated', index=2, number=3, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='map_entry', full_name='google.protobuf.MessageOptions.map_entry', index=3, number=7, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.MessageOptions.uninterpreted_option', index=4, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _FIELDOPTIONS = _descriptor.Descriptor( name='FieldOptions', full_name='google.protobuf.FieldOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='ctype', full_name='google.protobuf.FieldOptions.ctype', index=0, number=1, type=14, cpp_type=8, label=1, has_default_value=True, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='packed', full_name='google.protobuf.FieldOptions.packed', index=1, number=2, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='jstype', full_name='google.protobuf.FieldOptions.jstype', index=2, number=6, type=14, cpp_type=8, label=1, has_default_value=True, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='lazy', full_name='google.protobuf.FieldOptions.lazy', index=3, number=5, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='unverified_lazy', full_name='google.protobuf.FieldOptions.unverified_lazy', index=4, number=15, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.FieldOptions.deprecated', index=5, number=3, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='weak', full_name='google.protobuf.FieldOptions.weak', index=6, number=10, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.FieldOptions.uninterpreted_option', index=7, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ _FIELDOPTIONS_CTYPE, _FIELDOPTIONS_JSTYPE, ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _ONEOFOPTIONS = _descriptor.Descriptor( name='OneofOptions', full_name='google.protobuf.OneofOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.OneofOptions.uninterpreted_option', index=0, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _ENUMOPTIONS = _descriptor.Descriptor( name='EnumOptions', full_name='google.protobuf.EnumOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='allow_alias', full_name='google.protobuf.EnumOptions.allow_alias', index=0, number=2, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.EnumOptions.deprecated', index=1, number=3, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.EnumOptions.uninterpreted_option', index=2, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _ENUMVALUEOPTIONS = _descriptor.Descriptor( name='EnumValueOptions', full_name='google.protobuf.EnumValueOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.EnumValueOptions.deprecated', index=0, number=1, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.EnumValueOptions.uninterpreted_option', index=1, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _SERVICEOPTIONS = _descriptor.Descriptor( name='ServiceOptions', full_name='google.protobuf.ServiceOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.ServiceOptions.deprecated', index=0, number=33, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.ServiceOptions.uninterpreted_option', index=1, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _METHODOPTIONS = _descriptor.Descriptor( name='MethodOptions', full_name='google.protobuf.MethodOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.MethodOptions.deprecated', index=0, number=33, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='idempotency_level', full_name='google.protobuf.MethodOptions.idempotency_level', index=1, number=34, type=14, cpp_type=8, label=1, has_default_value=True, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.MethodOptions.uninterpreted_option', index=2, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ _METHODOPTIONS_IDEMPOTENCYLEVEL, ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _UNINTERPRETEDOPTION_NAMEPART = _descriptor.Descriptor( name='NamePart', full_name='google.protobuf.UninterpretedOption.NamePart', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name_part', full_name='google.protobuf.UninterpretedOption.NamePart.name_part', index=0, number=1, type=9, cpp_type=9, label=2, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='is_extension', full_name='google.protobuf.UninterpretedOption.NamePart.is_extension', index=1, number=2, type=8, cpp_type=7, label=2, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _UNINTERPRETEDOPTION = _descriptor.Descriptor( name='UninterpretedOption', full_name='google.protobuf.UninterpretedOption', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.UninterpretedOption.name', index=0, number=2, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='identifier_value', full_name='google.protobuf.UninterpretedOption.identifier_value', index=1, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='positive_int_value', full_name='google.protobuf.UninterpretedOption.positive_int_value', index=2, number=4, type=4, cpp_type=4, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='negative_int_value', full_name='google.protobuf.UninterpretedOption.negative_int_value', index=3, number=5, type=3, cpp_type=2, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='double_value', full_name='google.protobuf.UninterpretedOption.double_value', index=4, number=6, type=1, cpp_type=5, label=1, has_default_value=False, default_value=float(0), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='string_value', full_name='google.protobuf.UninterpretedOption.string_value', index=5, number=7, type=12, cpp_type=9, label=1, has_default_value=False, default_value=b"", message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='aggregate_value', full_name='google.protobuf.UninterpretedOption.aggregate_value', index=6, number=8, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[_UNINTERPRETEDOPTION_NAMEPART, ], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _SOURCECODEINFO_LOCATION = _descriptor.Descriptor( name='Location', full_name='google.protobuf.SourceCodeInfo.Location', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='path', full_name='google.protobuf.SourceCodeInfo.Location.path', index=0, number=1, type=5, cpp_type=1, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='span', full_name='google.protobuf.SourceCodeInfo.Location.span', index=1, number=2, type=5, cpp_type=1, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='leading_comments', full_name='google.protobuf.SourceCodeInfo.Location.leading_comments', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='trailing_comments', full_name='google.protobuf.SourceCodeInfo.Location.trailing_comments', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='leading_detached_comments', full_name='google.protobuf.SourceCodeInfo.Location.leading_detached_comments', index=4, number=6, type=9, cpp_type=9, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _SOURCECODEINFO = _descriptor.Descriptor( name='SourceCodeInfo', full_name='google.protobuf.SourceCodeInfo', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='location', full_name='google.protobuf.SourceCodeInfo.location', index=0, number=1, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[_SOURCECODEINFO_LOCATION, ], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _GENERATEDCODEINFO_ANNOTATION = _descriptor.Descriptor( name='Annotation', full_name='google.protobuf.GeneratedCodeInfo.Annotation', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='path', full_name='google.protobuf.GeneratedCodeInfo.Annotation.path', index=0, number=1, type=5, cpp_type=1, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='source_file', full_name='google.protobuf.GeneratedCodeInfo.Annotation.source_file', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='begin', full_name='google.protobuf.GeneratedCodeInfo.Annotation.begin', index=2, number=3, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='end', full_name='google.protobuf.GeneratedCodeInfo.Annotation.end', index=3, number=4, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _GENERATEDCODEINFO = _descriptor.Descriptor( name='GeneratedCodeInfo', full_name='google.protobuf.GeneratedCodeInfo', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='annotation', full_name='google.protobuf.GeneratedCodeInfo.annotation', index=0, number=1, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[_GENERATEDCODEINFO_ANNOTATION, ], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _FILEDESCRIPTORSET.fields_by_name['file'].message_type = _FILEDESCRIPTORPROTO _FILEDESCRIPTORPROTO.fields_by_name['message_type'].message_type = _DESCRIPTORPROTO _FILEDESCRIPTORPROTO.fields_by_name['enum_type'].message_type = _ENUMDESCRIPTORPROTO _FILEDESCRIPTORPROTO.fields_by_name['service'].message_type = _SERVICEDESCRIPTORPROTO _FILEDESCRIPTORPROTO.fields_by_name['extension'].message_type = _FIELDDESCRIPTORPROTO _FILEDESCRIPTORPROTO.fields_by_name['options'].message_type = _FILEOPTIONS _FILEDESCRIPTORPROTO.fields_by_name['source_code_info'].message_type = _SOURCECODEINFO _DESCRIPTORPROTO_EXTENSIONRANGE.fields_by_name['options'].message_type = _EXTENSIONRANGEOPTIONS _DESCRIPTORPROTO_EXTENSIONRANGE.containing_type = _DESCRIPTORPROTO _DESCRIPTORPROTO_RESERVEDRANGE.containing_type = _DESCRIPTORPROTO _DESCRIPTORPROTO.fields_by_name['field'].message_type = _FIELDDESCRIPTORPROTO _DESCRIPTORPROTO.fields_by_name['extension'].message_type = _FIELDDESCRIPTORPROTO _DESCRIPTORPROTO.fields_by_name['nested_type'].message_type = _DESCRIPTORPROTO _DESCRIPTORPROTO.fields_by_name['enum_type'].message_type = _ENUMDESCRIPTORPROTO _DESCRIPTORPROTO.fields_by_name['extension_range'].message_type = _DESCRIPTORPROTO_EXTENSIONRANGE _DESCRIPTORPROTO.fields_by_name['oneof_decl'].message_type = _ONEOFDESCRIPTORPROTO _DESCRIPTORPROTO.fields_by_name['options'].message_type = _MESSAGEOPTIONS _DESCRIPTORPROTO.fields_by_name['reserved_range'].message_type = _DESCRIPTORPROTO_RESERVEDRANGE _EXTENSIONRANGEOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _FIELDDESCRIPTORPROTO.fields_by_name['label'].enum_type = _FIELDDESCRIPTORPROTO_LABEL _FIELDDESCRIPTORPROTO.fields_by_name['type'].enum_type = _FIELDDESCRIPTORPROTO_TYPE _FIELDDESCRIPTORPROTO.fields_by_name['options'].message_type = _FIELDOPTIONS _FIELDDESCRIPTORPROTO_TYPE.containing_type = _FIELDDESCRIPTORPROTO _FIELDDESCRIPTORPROTO_LABEL.containing_type = _FIELDDESCRIPTORPROTO _ONEOFDESCRIPTORPROTO.fields_by_name['options'].message_type = _ONEOFOPTIONS _ENUMDESCRIPTORPROTO_ENUMRESERVEDRANGE.containing_type = _ENUMDESCRIPTORPROTO _ENUMDESCRIPTORPROTO.fields_by_name['value'].message_type = _ENUMVALUEDESCRIPTORPROTO _ENUMDESCRIPTORPROTO.fields_by_name['options'].message_type = _ENUMOPTIONS _ENUMDESCRIPTORPROTO.fields_by_name['reserved_range'].message_type = _ENUMDESCRIPTORPROTO_ENUMRESERVEDRANGE _ENUMVALUEDESCRIPTORPROTO.fields_by_name['options'].message_type = _ENUMVALUEOPTIONS _SERVICEDESCRIPTORPROTO.fields_by_name['method'].message_type = _METHODDESCRIPTORPROTO _SERVICEDESCRIPTORPROTO.fields_by_name['options'].message_type = _SERVICEOPTIONS _METHODDESCRIPTORPROTO.fields_by_name['options'].message_type = _METHODOPTIONS _FILEOPTIONS.fields_by_name['optimize_for'].enum_type = _FILEOPTIONS_OPTIMIZEMODE _FILEOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _FILEOPTIONS_OPTIMIZEMODE.containing_type = _FILEOPTIONS _MESSAGEOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _FIELDOPTIONS.fields_by_name['ctype'].enum_type = _FIELDOPTIONS_CTYPE _FIELDOPTIONS.fields_by_name['jstype'].enum_type = _FIELDOPTIONS_JSTYPE _FIELDOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _FIELDOPTIONS_CTYPE.containing_type = _FIELDOPTIONS _FIELDOPTIONS_JSTYPE.containing_type = _FIELDOPTIONS _ONEOFOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _ENUMOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _ENUMVALUEOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _SERVICEOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _METHODOPTIONS.fields_by_name['idempotency_level'].enum_type = _METHODOPTIONS_IDEMPOTENCYLEVEL _METHODOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _METHODOPTIONS_IDEMPOTENCYLEVEL.containing_type = _METHODOPTIONS _UNINTERPRETEDOPTION_NAMEPART.containing_type = _UNINTERPRETEDOPTION _UNINTERPRETEDOPTION.fields_by_name['name'].message_type = _UNINTERPRETEDOPTION_NAMEPART _SOURCECODEINFO_LOCATION.containing_type = _SOURCECODEINFO _SOURCECODEINFO.fields_by_name['location'].message_type = _SOURCECODEINFO_LOCATION _GENERATEDCODEINFO_ANNOTATION.containing_type = _GENERATEDCODEINFO _GENERATEDCODEINFO.fields_by_name['annotation'].message_type = _GENERATEDCODEINFO_ANNOTATION DESCRIPTOR.message_types_by_name['FileDescriptorSet'] = _FILEDESCRIPTORSET DESCRIPTOR.message_types_by_name['FileDescriptorProto'] = _FILEDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['DescriptorProto'] = _DESCRIPTORPROTO DESCRIPTOR.message_types_by_name['ExtensionRangeOptions'] = _EXTENSIONRANGEOPTIONS DESCRIPTOR.message_types_by_name['FieldDescriptorProto'] = _FIELDDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['OneofDescriptorProto'] = _ONEOFDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['EnumDescriptorProto'] = _ENUMDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['EnumValueDescriptorProto'] = _ENUMVALUEDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['ServiceDescriptorProto'] = _SERVICEDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['MethodDescriptorProto'] = _METHODDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['FileOptions'] = _FILEOPTIONS DESCRIPTOR.message_types_by_name['MessageOptions'] = _MESSAGEOPTIONS DESCRIPTOR.message_types_by_name['FieldOptions'] = _FIELDOPTIONS DESCRIPTOR.message_types_by_name['OneofOptions'] = _ONEOFOPTIONS DESCRIPTOR.message_types_by_name['EnumOptions'] = _ENUMOPTIONS DESCRIPTOR.message_types_by_name['EnumValueOptions'] = _ENUMVALUEOPTIONS DESCRIPTOR.message_types_by_name['ServiceOptions'] = _SERVICEOPTIONS DESCRIPTOR.message_types_by_name['MethodOptions'] = _METHODOPTIONS DESCRIPTOR.message_types_by_name['UninterpretedOption'] = _UNINTERPRETEDOPTION DESCRIPTOR.message_types_by_name['SourceCodeInfo'] = _SOURCECODEINFO DESCRIPTOR.message_types_by_name['GeneratedCodeInfo'] = _GENERATEDCODEINFO _sym_db.RegisterFileDescriptor(DESCRIPTOR) else: _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.descriptor_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None _FILEDESCRIPTORSET._serialized_start=53 _FILEDESCRIPTORSET._serialized_end=124 _FILEDESCRIPTORPROTO._serialized_start=127 _FILEDESCRIPTORPROTO._serialized_end=602 _DESCRIPTORPROTO._serialized_start=605 _DESCRIPTORPROTO._serialized_end=1286 _DESCRIPTORPROTO_EXTENSIONRANGE._serialized_start=1140 _DESCRIPTORPROTO_EXTENSIONRANGE._serialized_end=1241 _DESCRIPTORPROTO_RESERVEDRANGE._serialized_start=1243 _DESCRIPTORPROTO_RESERVEDRANGE._serialized_end=1286 _EXTENSIONRANGEOPTIONS._serialized_start=1288 _EXTENSIONRANGEOPTIONS._serialized_end=1391 _FIELDDESCRIPTORPROTO._serialized_start=1394 _FIELDDESCRIPTORPROTO._serialized_end=2119 _FIELDDESCRIPTORPROTO_TYPE._serialized_start=1740 _FIELDDESCRIPTORPROTO_TYPE._serialized_end=2050 _FIELDDESCRIPTORPROTO_LABEL._serialized_start=2052 _FIELDDESCRIPTORPROTO_LABEL._serialized_end=2119 _ONEOFDESCRIPTORPROTO._serialized_start=2121 _ONEOFDESCRIPTORPROTO._serialized_end=2205 _ENUMDESCRIPTORPROTO._serialized_start=2208 _ENUMDESCRIPTORPROTO._serialized_end=2500 _ENUMDESCRIPTORPROTO_ENUMRESERVEDRANGE._serialized_start=2453 _ENUMDESCRIPTORPROTO_ENUMRESERVEDRANGE._serialized_end=2500 _ENUMVALUEDESCRIPTORPROTO._serialized_start=2502 _ENUMVALUEDESCRIPTORPROTO._serialized_end=2610 _SERVICEDESCRIPTORPROTO._serialized_start=2613 _SERVICEDESCRIPTORPROTO._serialized_end=2757 _METHODDESCRIPTORPROTO._serialized_start=2760 _METHODDESCRIPTORPROTO._serialized_end=2953 _FILEOPTIONS._serialized_start=2956 _FILEOPTIONS._serialized_end=3761 _FILEOPTIONS_OPTIMIZEMODE._serialized_start=3686 _FILEOPTIONS_OPTIMIZEMODE._serialized_end=3744 _MESSAGEOPTIONS._serialized_start=3764 _MESSAGEOPTIONS._serialized_end=4024 _FIELDOPTIONS._serialized_start=4027 _FIELDOPTIONS._serialized_end=4473 _FIELDOPTIONS_CTYPE._serialized_start=4354 _FIELDOPTIONS_CTYPE._serialized_end=4401 _FIELDOPTIONS_JSTYPE._serialized_start=4403 _FIELDOPTIONS_JSTYPE._serialized_end=4456 _ONEOFOPTIONS._serialized_start=4475 _ONEOFOPTIONS._serialized_end=4569 _ENUMOPTIONS._serialized_start=4572 _ENUMOPTIONS._serialized_end=4719 _ENUMVALUEOPTIONS._serialized_start=4721 _ENUMVALUEOPTIONS._serialized_end=4846 _SERVICEOPTIONS._serialized_start=4848 _SERVICEOPTIONS._serialized_end=4971 _METHODOPTIONS._serialized_start=4974 _METHODOPTIONS._serialized_end=5275 _METHODOPTIONS_IDEMPOTENCYLEVEL._serialized_start=5184 _METHODOPTIONS_IDEMPOTENCYLEVEL._serialized_end=5264 _UNINTERPRETEDOPTION._serialized_start=5278 _UNINTERPRETEDOPTION._serialized_end=5564 _UNINTERPRETEDOPTION_NAMEPART._serialized_start=5513 _UNINTERPRETEDOPTION_NAMEPART._serialized_end=5564 _SOURCECODEINFO._serialized_start=5567 _SOURCECODEINFO._serialized_end=5780 _SOURCECODEINFO_LOCATION._serialized_start=5646 _SOURCECODEINFO_LOCATION._serialized_end=5780 _GENERATEDCODEINFO._serialized_start=5783 _GENERATEDCODEINFO._serialized_end=5950 _GENERATEDCODEINFO_ANNOTATION._serialized_start=5871 _GENERATEDCODEINFO_ANNOTATION._serialized_end=5950 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/descriptor_pool.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Provides DescriptorPool to use as a container for proto2 descriptors. The DescriptorPool is used in conjection with a DescriptorDatabase to maintain a collection of protocol buffer descriptors for use when dynamically creating message types at runtime. For most applications protocol buffers should be used via modules generated by the protocol buffer compiler tool. This should only be used when the type of protocol buffers used in an application or library cannot be predetermined. Below is a straightforward example on how to use this class:: pool = DescriptorPool() file_descriptor_protos = [ ... ] for file_descriptor_proto in file_descriptor_protos: pool.Add(file_descriptor_proto) my_message_descriptor = pool.FindMessageTypeByName('some.package.MessageType') The message descriptor can be used in conjunction with the message_factory module in order to create a protocol buffer class that can be encoded and decoded. If you want to get a Python class for the specified proto, use the helper functions inside google.protobuf.message_factory directly instead of this class. """ __author__ = 'matthewtoia@google.com (Matt Toia)' import collections import warnings from google.protobuf import descriptor from google.protobuf import descriptor_database from google.protobuf import text_encoding _USE_C_DESCRIPTORS = descriptor._USE_C_DESCRIPTORS # pylint: disable=protected-access def _Deprecated(func): """Mark functions as deprecated.""" def NewFunc(*args, **kwargs): warnings.warn( 'Call to deprecated function %s(). Note: Do add unlinked descriptors ' 'to descriptor_pool is wrong. Use Add() or AddSerializedFile() ' 'instead.' % func.__name__, category=DeprecationWarning) return func(*args, **kwargs) NewFunc.__name__ = func.__name__ NewFunc.__doc__ = func.__doc__ NewFunc.__dict__.update(func.__dict__) return NewFunc def _NormalizeFullyQualifiedName(name): """Remove leading period from fully-qualified type name. Due to b/13860351 in descriptor_database.py, types in the root namespace are generated with a leading period. This function removes that prefix. Args: name (str): The fully-qualified symbol name. Returns: str: The normalized fully-qualified symbol name. """ return name.lstrip('.') def _OptionsOrNone(descriptor_proto): """Returns the value of the field `options`, or None if it is not set.""" if descriptor_proto.HasField('options'): return descriptor_proto.options else: return None def _IsMessageSetExtension(field): return (field.is_extension and field.containing_type.has_options and field.containing_type.GetOptions().message_set_wire_format and field.type == descriptor.FieldDescriptor.TYPE_MESSAGE and field.label == descriptor.FieldDescriptor.LABEL_OPTIONAL) class DescriptorPool(object): """A collection of protobufs dynamically constructed by descriptor protos.""" if _USE_C_DESCRIPTORS: def __new__(cls, descriptor_db=None): # pylint: disable=protected-access return descriptor._message.DescriptorPool(descriptor_db) def __init__(self, descriptor_db=None): """Initializes a Pool of proto buffs. The descriptor_db argument to the constructor is provided to allow specialized file descriptor proto lookup code to be triggered on demand. An example would be an implementation which will read and compile a file specified in a call to FindFileByName() and not require the call to Add() at all. Results from this database will be cached internally here as well. Args: descriptor_db: A secondary source of file descriptors. """ self._internal_db = descriptor_database.DescriptorDatabase() self._descriptor_db = descriptor_db self._descriptors = {} self._enum_descriptors = {} self._service_descriptors = {} self._file_descriptors = {} self._toplevel_extensions = {} # TODO(jieluo): Remove _file_desc_by_toplevel_extension after # maybe year 2020 for compatibility issue (with 3.4.1 only). self._file_desc_by_toplevel_extension = {} self._top_enum_values = {} # We store extensions in two two-level mappings: The first key is the # descriptor of the message being extended, the second key is the extension # full name or its tag number. self._extensions_by_name = collections.defaultdict(dict) self._extensions_by_number = collections.defaultdict(dict) def _CheckConflictRegister(self, desc, desc_name, file_name): """Check if the descriptor name conflicts with another of the same name. Args: desc: Descriptor of a message, enum, service, extension or enum value. desc_name (str): the full name of desc. file_name (str): The file name of descriptor. """ for register, descriptor_type in [ (self._descriptors, descriptor.Descriptor), (self._enum_descriptors, descriptor.EnumDescriptor), (self._service_descriptors, descriptor.ServiceDescriptor), (self._toplevel_extensions, descriptor.FieldDescriptor), (self._top_enum_values, descriptor.EnumValueDescriptor)]: if desc_name in register: old_desc = register[desc_name] if isinstance(old_desc, descriptor.EnumValueDescriptor): old_file = old_desc.type.file.name else: old_file = old_desc.file.name if not isinstance(desc, descriptor_type) or ( old_file != file_name): error_msg = ('Conflict register for file "' + file_name + '": ' + desc_name + ' is already defined in file "' + old_file + '". Please fix the conflict by adding ' 'package name on the proto file, or use different ' 'name for the duplication.') if isinstance(desc, descriptor.EnumValueDescriptor): error_msg += ('\nNote: enum values appear as ' 'siblings of the enum type instead of ' 'children of it.') raise TypeError(error_msg) return def Add(self, file_desc_proto): """Adds the FileDescriptorProto and its types to this pool. Args: file_desc_proto (FileDescriptorProto): The file descriptor to add. """ self._internal_db.Add(file_desc_proto) def AddSerializedFile(self, serialized_file_desc_proto): """Adds the FileDescriptorProto and its types to this pool. Args: serialized_file_desc_proto (bytes): A bytes string, serialization of the :class:`FileDescriptorProto` to add. Returns: FileDescriptor: Descriptor for the added file. """ # pylint: disable=g-import-not-at-top from google.protobuf import descriptor_pb2 file_desc_proto = descriptor_pb2.FileDescriptorProto.FromString( serialized_file_desc_proto) file_desc = self._ConvertFileProtoToFileDescriptor(file_desc_proto) file_desc.serialized_pb = serialized_file_desc_proto return file_desc # Add Descriptor to descriptor pool is dreprecated. Please use Add() # or AddSerializedFile() to add a FileDescriptorProto instead. @_Deprecated def AddDescriptor(self, desc): self._AddDescriptor(desc) # Never call this method. It is for internal usage only. def _AddDescriptor(self, desc): """Adds a Descriptor to the pool, non-recursively. If the Descriptor contains nested messages or enums, the caller must explicitly register them. This method also registers the FileDescriptor associated with the message. Args: desc: A Descriptor. """ if not isinstance(desc, descriptor.Descriptor): raise TypeError('Expected instance of descriptor.Descriptor.') self._CheckConflictRegister(desc, desc.full_name, desc.file.name) self._descriptors[desc.full_name] = desc self._AddFileDescriptor(desc.file) # Add EnumDescriptor to descriptor pool is dreprecated. Please use Add() # or AddSerializedFile() to add a FileDescriptorProto instead. @_Deprecated def AddEnumDescriptor(self, enum_desc): self._AddEnumDescriptor(enum_desc) # Never call this method. It is for internal usage only. def _AddEnumDescriptor(self, enum_desc): """Adds an EnumDescriptor to the pool. This method also registers the FileDescriptor associated with the enum. Args: enum_desc: An EnumDescriptor. """ if not isinstance(enum_desc, descriptor.EnumDescriptor): raise TypeError('Expected instance of descriptor.EnumDescriptor.') file_name = enum_desc.file.name self._CheckConflictRegister(enum_desc, enum_desc.full_name, file_name) self._enum_descriptors[enum_desc.full_name] = enum_desc # Top enum values need to be indexed. # Count the number of dots to see whether the enum is toplevel or nested # in a message. We cannot use enum_desc.containing_type at this stage. if enum_desc.file.package: top_level = (enum_desc.full_name.count('.') - enum_desc.file.package.count('.') == 1) else: top_level = enum_desc.full_name.count('.') == 0 if top_level: file_name = enum_desc.file.name package = enum_desc.file.package for enum_value in enum_desc.values: full_name = _NormalizeFullyQualifiedName( '.'.join((package, enum_value.name))) self._CheckConflictRegister(enum_value, full_name, file_name) self._top_enum_values[full_name] = enum_value self._AddFileDescriptor(enum_desc.file) # Add ServiceDescriptor to descriptor pool is dreprecated. Please use Add() # or AddSerializedFile() to add a FileDescriptorProto instead. @_Deprecated def AddServiceDescriptor(self, service_desc): self._AddServiceDescriptor(service_desc) # Never call this method. It is for internal usage only. def _AddServiceDescriptor(self, service_desc): """Adds a ServiceDescriptor to the pool. Args: service_desc: A ServiceDescriptor. """ if not isinstance(service_desc, descriptor.ServiceDescriptor): raise TypeError('Expected instance of descriptor.ServiceDescriptor.') self._CheckConflictRegister(service_desc, service_desc.full_name, service_desc.file.name) self._service_descriptors[service_desc.full_name] = service_desc # Add ExtensionDescriptor to descriptor pool is dreprecated. Please use Add() # or AddSerializedFile() to add a FileDescriptorProto instead. @_Deprecated def AddExtensionDescriptor(self, extension): self._AddExtensionDescriptor(extension) # Never call this method. It is for internal usage only. def _AddExtensionDescriptor(self, extension): """Adds a FieldDescriptor describing an extension to the pool. Args: extension: A FieldDescriptor. Raises: AssertionError: when another extension with the same number extends the same message. TypeError: when the specified extension is not a descriptor.FieldDescriptor. """ if not (isinstance(extension, descriptor.FieldDescriptor) and extension.is_extension): raise TypeError('Expected an extension descriptor.') if extension.extension_scope is None: self._toplevel_extensions[extension.full_name] = extension try: existing_desc = self._extensions_by_number[ extension.containing_type][extension.number] except KeyError: pass else: if extension is not existing_desc: raise AssertionError( 'Extensions "%s" and "%s" both try to extend message type "%s" ' 'with field number %d.' % (extension.full_name, existing_desc.full_name, extension.containing_type.full_name, extension.number)) self._extensions_by_number[extension.containing_type][ extension.number] = extension self._extensions_by_name[extension.containing_type][ extension.full_name] = extension # Also register MessageSet extensions with the type name. if _IsMessageSetExtension(extension): self._extensions_by_name[extension.containing_type][ extension.message_type.full_name] = extension @_Deprecated def AddFileDescriptor(self, file_desc): self._InternalAddFileDescriptor(file_desc) # Never call this method. It is for internal usage only. def _InternalAddFileDescriptor(self, file_desc): """Adds a FileDescriptor to the pool, non-recursively. If the FileDescriptor contains messages or enums, the caller must explicitly register them. Args: file_desc: A FileDescriptor. """ self._AddFileDescriptor(file_desc) # TODO(jieluo): This is a temporary solution for FieldDescriptor.file. # FieldDescriptor.file is added in code gen. Remove this solution after # maybe 2020 for compatibility reason (with 3.4.1 only). for extension in file_desc.extensions_by_name.values(): self._file_desc_by_toplevel_extension[ extension.full_name] = file_desc def _AddFileDescriptor(self, file_desc): """Adds a FileDescriptor to the pool, non-recursively. If the FileDescriptor contains messages or enums, the caller must explicitly register them. Args: file_desc: A FileDescriptor. """ if not isinstance(file_desc, descriptor.FileDescriptor): raise TypeError('Expected instance of descriptor.FileDescriptor.') self._file_descriptors[file_desc.name] = file_desc def FindFileByName(self, file_name): """Gets a FileDescriptor by file name. Args: file_name (str): The path to the file to get a descriptor for. Returns: FileDescriptor: The descriptor for the named file. Raises: KeyError: if the file cannot be found in the pool. """ try: return self._file_descriptors[file_name] except KeyError: pass try: file_proto = self._internal_db.FindFileByName(file_name) except KeyError as error: if self._descriptor_db: file_proto = self._descriptor_db.FindFileByName(file_name) else: raise error if not file_proto: raise KeyError('Cannot find a file named %s' % file_name) return self._ConvertFileProtoToFileDescriptor(file_proto) def FindFileContainingSymbol(self, symbol): """Gets the FileDescriptor for the file containing the specified symbol. Args: symbol (str): The name of the symbol to search for. Returns: FileDescriptor: Descriptor for the file that contains the specified symbol. Raises: KeyError: if the file cannot be found in the pool. """ symbol = _NormalizeFullyQualifiedName(symbol) try: return self._InternalFindFileContainingSymbol(symbol) except KeyError: pass try: # Try fallback database. Build and find again if possible. self._FindFileContainingSymbolInDb(symbol) return self._InternalFindFileContainingSymbol(symbol) except KeyError: raise KeyError('Cannot find a file containing %s' % symbol) def _InternalFindFileContainingSymbol(self, symbol): """Gets the already built FileDescriptor containing the specified symbol. Args: symbol (str): The name of the symbol to search for. Returns: FileDescriptor: Descriptor for the file that contains the specified symbol. Raises: KeyError: if the file cannot be found in the pool. """ try: return self._descriptors[symbol].file except KeyError: pass try: return self._enum_descriptors[symbol].file except KeyError: pass try: return self._service_descriptors[symbol].file except KeyError: pass try: return self._top_enum_values[symbol].type.file except KeyError: pass try: return self._file_desc_by_toplevel_extension[symbol] except KeyError: pass # Try fields, enum values and nested extensions inside a message. top_name, _, sub_name = symbol.rpartition('.') try: message = self.FindMessageTypeByName(top_name) assert (sub_name in message.extensions_by_name or sub_name in message.fields_by_name or sub_name in message.enum_values_by_name) return message.file except (KeyError, AssertionError): raise KeyError('Cannot find a file containing %s' % symbol) def FindMessageTypeByName(self, full_name): """Loads the named descriptor from the pool. Args: full_name (str): The full name of the descriptor to load. Returns: Descriptor: The descriptor for the named type. Raises: KeyError: if the message cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) if full_name not in self._descriptors: self._FindFileContainingSymbolInDb(full_name) return self._descriptors[full_name] def FindEnumTypeByName(self, full_name): """Loads the named enum descriptor from the pool. Args: full_name (str): The full name of the enum descriptor to load. Returns: EnumDescriptor: The enum descriptor for the named type. Raises: KeyError: if the enum cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) if full_name not in self._enum_descriptors: self._FindFileContainingSymbolInDb(full_name) return self._enum_descriptors[full_name] def FindFieldByName(self, full_name): """Loads the named field descriptor from the pool. Args: full_name (str): The full name of the field descriptor to load. Returns: FieldDescriptor: The field descriptor for the named field. Raises: KeyError: if the field cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) message_name, _, field_name = full_name.rpartition('.') message_descriptor = self.FindMessageTypeByName(message_name) return message_descriptor.fields_by_name[field_name] def FindOneofByName(self, full_name): """Loads the named oneof descriptor from the pool. Args: full_name (str): The full name of the oneof descriptor to load. Returns: OneofDescriptor: The oneof descriptor for the named oneof. Raises: KeyError: if the oneof cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) message_name, _, oneof_name = full_name.rpartition('.') message_descriptor = self.FindMessageTypeByName(message_name) return message_descriptor.oneofs_by_name[oneof_name] def FindExtensionByName(self, full_name): """Loads the named extension descriptor from the pool. Args: full_name (str): The full name of the extension descriptor to load. Returns: FieldDescriptor: The field descriptor for the named extension. Raises: KeyError: if the extension cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) try: # The proto compiler does not give any link between the FileDescriptor # and top-level extensions unless the FileDescriptorProto is added to # the DescriptorDatabase, but this can impact memory usage. # So we registered these extensions by name explicitly. return self._toplevel_extensions[full_name] except KeyError: pass message_name, _, extension_name = full_name.rpartition('.') try: # Most extensions are nested inside a message. scope = self.FindMessageTypeByName(message_name) except KeyError: # Some extensions are defined at file scope. scope = self._FindFileContainingSymbolInDb(full_name) return scope.extensions_by_name[extension_name] def FindExtensionByNumber(self, message_descriptor, number): """Gets the extension of the specified message with the specified number. Extensions have to be registered to this pool by calling :func:`Add` or :func:`AddExtensionDescriptor`. Args: message_descriptor (Descriptor): descriptor of the extended message. number (int): Number of the extension field. Returns: FieldDescriptor: The descriptor for the extension. Raises: KeyError: when no extension with the given number is known for the specified message. """ try: return self._extensions_by_number[message_descriptor][number] except KeyError: self._TryLoadExtensionFromDB(message_descriptor, number) return self._extensions_by_number[message_descriptor][number] def FindAllExtensions(self, message_descriptor): """Gets all the known extensions of a given message. Extensions have to be registered to this pool by build related :func:`Add` or :func:`AddExtensionDescriptor`. Args: message_descriptor (Descriptor): Descriptor of the extended message. Returns: list[FieldDescriptor]: Field descriptors describing the extensions. """ # Fallback to descriptor db if FindAllExtensionNumbers is provided. if self._descriptor_db and hasattr( self._descriptor_db, 'FindAllExtensionNumbers'): full_name = message_descriptor.full_name all_numbers = self._descriptor_db.FindAllExtensionNumbers(full_name) for number in all_numbers: if number in self._extensions_by_number[message_descriptor]: continue self._TryLoadExtensionFromDB(message_descriptor, number) return list(self._extensions_by_number[message_descriptor].values()) def _TryLoadExtensionFromDB(self, message_descriptor, number): """Try to Load extensions from descriptor db. Args: message_descriptor: descriptor of the extended message. number: the extension number that needs to be loaded. """ if not self._descriptor_db: return # Only supported when FindFileContainingExtension is provided. if not hasattr( self._descriptor_db, 'FindFileContainingExtension'): return full_name = message_descriptor.full_name file_proto = self._descriptor_db.FindFileContainingExtension( full_name, number) if file_proto is None: return try: self._ConvertFileProtoToFileDescriptor(file_proto) except: warn_msg = ('Unable to load proto file %s for extension number %d.' % (file_proto.name, number)) warnings.warn(warn_msg, RuntimeWarning) def FindServiceByName(self, full_name): """Loads the named service descriptor from the pool. Args: full_name (str): The full name of the service descriptor to load. Returns: ServiceDescriptor: The service descriptor for the named service. Raises: KeyError: if the service cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) if full_name not in self._service_descriptors: self._FindFileContainingSymbolInDb(full_name) return self._service_descriptors[full_name] def FindMethodByName(self, full_name): """Loads the named service method descriptor from the pool. Args: full_name (str): The full name of the method descriptor to load. Returns: MethodDescriptor: The method descriptor for the service method. Raises: KeyError: if the method cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) service_name, _, method_name = full_name.rpartition('.') service_descriptor = self.FindServiceByName(service_name) return service_descriptor.methods_by_name[method_name] def _FindFileContainingSymbolInDb(self, symbol): """Finds the file in descriptor DB containing the specified symbol. Args: symbol (str): The name of the symbol to search for. Returns: FileDescriptor: The file that contains the specified symbol. Raises: KeyError: if the file cannot be found in the descriptor database. """ try: file_proto = self._internal_db.FindFileContainingSymbol(symbol) except KeyError as error: if self._descriptor_db: file_proto = self._descriptor_db.FindFileContainingSymbol(symbol) else: raise error if not file_proto: raise KeyError('Cannot find a file containing %s' % symbol) return self._ConvertFileProtoToFileDescriptor(file_proto) def _ConvertFileProtoToFileDescriptor(self, file_proto): """Creates a FileDescriptor from a proto or returns a cached copy. This method also has the side effect of loading all the symbols found in the file into the appropriate dictionaries in the pool. Args: file_proto: The proto to convert. Returns: A FileDescriptor matching the passed in proto. """ if file_proto.name not in self._file_descriptors: built_deps = list(self._GetDeps(file_proto.dependency)) direct_deps = [self.FindFileByName(n) for n in file_proto.dependency] public_deps = [direct_deps[i] for i in file_proto.public_dependency] file_descriptor = descriptor.FileDescriptor( pool=self, name=file_proto.name, package=file_proto.package, syntax=file_proto.syntax, options=_OptionsOrNone(file_proto), serialized_pb=file_proto.SerializeToString(), dependencies=direct_deps, public_dependencies=public_deps, # pylint: disable=protected-access create_key=descriptor._internal_create_key) scope = {} # This loop extracts all the message and enum types from all the # dependencies of the file_proto. This is necessary to create the # scope of available message types when defining the passed in # file proto. for dependency in built_deps: scope.update(self._ExtractSymbols( dependency.message_types_by_name.values())) scope.update((_PrefixWithDot(enum.full_name), enum) for enum in dependency.enum_types_by_name.values()) for message_type in file_proto.message_type: message_desc = self._ConvertMessageDescriptor( message_type, file_proto.package, file_descriptor, scope, file_proto.syntax) file_descriptor.message_types_by_name[message_desc.name] = ( message_desc) for enum_type in file_proto.enum_type: file_descriptor.enum_types_by_name[enum_type.name] = ( self._ConvertEnumDescriptor(enum_type, file_proto.package, file_descriptor, None, scope, True)) for index, extension_proto in enumerate(file_proto.extension): extension_desc = self._MakeFieldDescriptor( extension_proto, file_proto.package, index, file_descriptor, is_extension=True) extension_desc.containing_type = self._GetTypeFromScope( file_descriptor.package, extension_proto.extendee, scope) self._SetFieldType(extension_proto, extension_desc, file_descriptor.package, scope) file_descriptor.extensions_by_name[extension_desc.name] = ( extension_desc) self._file_desc_by_toplevel_extension[extension_desc.full_name] = ( file_descriptor) for desc_proto in file_proto.message_type: self._SetAllFieldTypes(file_proto.package, desc_proto, scope) if file_proto.package: desc_proto_prefix = _PrefixWithDot(file_proto.package) else: desc_proto_prefix = '' for desc_proto in file_proto.message_type: desc = self._GetTypeFromScope( desc_proto_prefix, desc_proto.name, scope) file_descriptor.message_types_by_name[desc_proto.name] = desc for index, service_proto in enumerate(file_proto.service): file_descriptor.services_by_name[service_proto.name] = ( self._MakeServiceDescriptor(service_proto, index, scope, file_proto.package, file_descriptor)) self._file_descriptors[file_proto.name] = file_descriptor # Add extensions to the pool file_desc = self._file_descriptors[file_proto.name] for extension in file_desc.extensions_by_name.values(): self._AddExtensionDescriptor(extension) for message_type in file_desc.message_types_by_name.values(): for extension in message_type.extensions: self._AddExtensionDescriptor(extension) return file_desc def _ConvertMessageDescriptor(self, desc_proto, package=None, file_desc=None, scope=None, syntax=None): """Adds the proto to the pool in the specified package. Args: desc_proto: The descriptor_pb2.DescriptorProto protobuf message. package: The package the proto should be located in. file_desc: The file containing this message. scope: Dict mapping short and full symbols to message and enum types. syntax: string indicating syntax of the file ("proto2" or "proto3") Returns: The added descriptor. """ if package: desc_name = '.'.join((package, desc_proto.name)) else: desc_name = desc_proto.name if file_desc is None: file_name = None else: file_name = file_desc.name if scope is None: scope = {} nested = [ self._ConvertMessageDescriptor( nested, desc_name, file_desc, scope, syntax) for nested in desc_proto.nested_type] enums = [ self._ConvertEnumDescriptor(enum, desc_name, file_desc, None, scope, False) for enum in desc_proto.enum_type] fields = [self._MakeFieldDescriptor(field, desc_name, index, file_desc) for index, field in enumerate(desc_proto.field)] extensions = [ self._MakeFieldDescriptor(extension, desc_name, index, file_desc, is_extension=True) for index, extension in enumerate(desc_proto.extension)] oneofs = [ # pylint: disable=g-complex-comprehension descriptor.OneofDescriptor( desc.name, '.'.join((desc_name, desc.name)), index, None, [], _OptionsOrNone(desc), # pylint: disable=protected-access create_key=descriptor._internal_create_key) for index, desc in enumerate(desc_proto.oneof_decl) ] extension_ranges = [(r.start, r.end) for r in desc_proto.extension_range] if extension_ranges: is_extendable = True else: is_extendable = False desc = descriptor.Descriptor( name=desc_proto.name, full_name=desc_name, filename=file_name, containing_type=None, fields=fields, oneofs=oneofs, nested_types=nested, enum_types=enums, extensions=extensions, options=_OptionsOrNone(desc_proto), is_extendable=is_extendable, extension_ranges=extension_ranges, file=file_desc, serialized_start=None, serialized_end=None, syntax=syntax, # pylint: disable=protected-access create_key=descriptor._internal_create_key) for nested in desc.nested_types: nested.containing_type = desc for enum in desc.enum_types: enum.containing_type = desc for field_index, field_desc in enumerate(desc_proto.field): if field_desc.HasField('oneof_index'): oneof_index = field_desc.oneof_index oneofs[oneof_index].fields.append(fields[field_index]) fields[field_index].containing_oneof = oneofs[oneof_index] scope[_PrefixWithDot(desc_name)] = desc self._CheckConflictRegister(desc, desc.full_name, desc.file.name) self._descriptors[desc_name] = desc return desc def _ConvertEnumDescriptor(self, enum_proto, package=None, file_desc=None, containing_type=None, scope=None, top_level=False): """Make a protobuf EnumDescriptor given an EnumDescriptorProto protobuf. Args: enum_proto: The descriptor_pb2.EnumDescriptorProto protobuf message. package: Optional package name for the new message EnumDescriptor. file_desc: The file containing the enum descriptor. containing_type: The type containing this enum. scope: Scope containing available types. top_level: If True, the enum is a top level symbol. If False, the enum is defined inside a message. Returns: The added descriptor """ if package: enum_name = '.'.join((package, enum_proto.name)) else: enum_name = enum_proto.name if file_desc is None: file_name = None else: file_name = file_desc.name values = [self._MakeEnumValueDescriptor(value, index) for index, value in enumerate(enum_proto.value)] desc = descriptor.EnumDescriptor(name=enum_proto.name, full_name=enum_name, filename=file_name, file=file_desc, values=values, containing_type=containing_type, options=_OptionsOrNone(enum_proto), # pylint: disable=protected-access create_key=descriptor._internal_create_key) scope['.%s' % enum_name] = desc self._CheckConflictRegister(desc, desc.full_name, desc.file.name) self._enum_descriptors[enum_name] = desc # Add top level enum values. if top_level: for value in values: full_name = _NormalizeFullyQualifiedName( '.'.join((package, value.name))) self._CheckConflictRegister(value, full_name, file_name) self._top_enum_values[full_name] = value return desc def _MakeFieldDescriptor(self, field_proto, message_name, index, file_desc, is_extension=False): """Creates a field descriptor from a FieldDescriptorProto. For message and enum type fields, this method will do a look up in the pool for the appropriate descriptor for that type. If it is unavailable, it will fall back to the _source function to create it. If this type is still unavailable, construction will fail. Args: field_proto: The proto describing the field. message_name: The name of the containing message. index: Index of the field file_desc: The file containing the field descriptor. is_extension: Indication that this field is for an extension. Returns: An initialized FieldDescriptor object """ if message_name: full_name = '.'.join((message_name, field_proto.name)) else: full_name = field_proto.name if field_proto.json_name: json_name = field_proto.json_name else: json_name = None return descriptor.FieldDescriptor( name=field_proto.name, full_name=full_name, index=index, number=field_proto.number, type=field_proto.type, cpp_type=None, message_type=None, enum_type=None, containing_type=None, label=field_proto.label, has_default_value=False, default_value=None, is_extension=is_extension, extension_scope=None, options=_OptionsOrNone(field_proto), json_name=json_name, file=file_desc, # pylint: disable=protected-access create_key=descriptor._internal_create_key) def _SetAllFieldTypes(self, package, desc_proto, scope): """Sets all the descriptor's fields's types. This method also sets the containing types on any extensions. Args: package: The current package of desc_proto. desc_proto: The message descriptor to update. scope: Enclosing scope of available types. """ package = _PrefixWithDot(package) main_desc = self._GetTypeFromScope(package, desc_proto.name, scope) if package == '.': nested_package = _PrefixWithDot(desc_proto.name) else: nested_package = '.'.join([package, desc_proto.name]) for field_proto, field_desc in zip(desc_proto.field, main_desc.fields): self._SetFieldType(field_proto, field_desc, nested_package, scope) for extension_proto, extension_desc in ( zip(desc_proto.extension, main_desc.extensions)): extension_desc.containing_type = self._GetTypeFromScope( nested_package, extension_proto.extendee, scope) self._SetFieldType(extension_proto, extension_desc, nested_package, scope) for nested_type in desc_proto.nested_type: self._SetAllFieldTypes(nested_package, nested_type, scope) def _SetFieldType(self, field_proto, field_desc, package, scope): """Sets the field's type, cpp_type, message_type and enum_type. Args: field_proto: Data about the field in proto format. field_desc: The descriptor to modify. package: The package the field's container is in. scope: Enclosing scope of available types. """ if field_proto.type_name: desc = self._GetTypeFromScope(package, field_proto.type_name, scope) else: desc = None if not field_proto.HasField('type'): if isinstance(desc, descriptor.Descriptor): field_proto.type = descriptor.FieldDescriptor.TYPE_MESSAGE else: field_proto.type = descriptor.FieldDescriptor.TYPE_ENUM field_desc.cpp_type = descriptor.FieldDescriptor.ProtoTypeToCppProtoType( field_proto.type) if (field_proto.type == descriptor.FieldDescriptor.TYPE_MESSAGE or field_proto.type == descriptor.FieldDescriptor.TYPE_GROUP): field_desc.message_type = desc if field_proto.type == descriptor.FieldDescriptor.TYPE_ENUM: field_desc.enum_type = desc if field_proto.label == descriptor.FieldDescriptor.LABEL_REPEATED: field_desc.has_default_value = False field_desc.default_value = [] elif field_proto.HasField('default_value'): field_desc.has_default_value = True if (field_proto.type == descriptor.FieldDescriptor.TYPE_DOUBLE or field_proto.type == descriptor.FieldDescriptor.TYPE_FLOAT): field_desc.default_value = float(field_proto.default_value) elif field_proto.type == descriptor.FieldDescriptor.TYPE_STRING: field_desc.default_value = field_proto.default_value elif field_proto.type == descriptor.FieldDescriptor.TYPE_BOOL: field_desc.default_value = field_proto.default_value.lower() == 'true' elif field_proto.type == descriptor.FieldDescriptor.TYPE_ENUM: field_desc.default_value = field_desc.enum_type.values_by_name[ field_proto.default_value].number elif field_proto.type == descriptor.FieldDescriptor.TYPE_BYTES: field_desc.default_value = text_encoding.CUnescape( field_proto.default_value) elif field_proto.type == descriptor.FieldDescriptor.TYPE_MESSAGE: field_desc.default_value = None else: # All other types are of the "int" type. field_desc.default_value = int(field_proto.default_value) else: field_desc.has_default_value = False if (field_proto.type == descriptor.FieldDescriptor.TYPE_DOUBLE or field_proto.type == descriptor.FieldDescriptor.TYPE_FLOAT): field_desc.default_value = 0.0 elif field_proto.type == descriptor.FieldDescriptor.TYPE_STRING: field_desc.default_value = u'' elif field_proto.type == descriptor.FieldDescriptor.TYPE_BOOL: field_desc.default_value = False elif field_proto.type == descriptor.FieldDescriptor.TYPE_ENUM: field_desc.default_value = field_desc.enum_type.values[0].number elif field_proto.type == descriptor.FieldDescriptor.TYPE_BYTES: field_desc.default_value = b'' elif field_proto.type == descriptor.FieldDescriptor.TYPE_MESSAGE: field_desc.default_value = None elif field_proto.type == descriptor.FieldDescriptor.TYPE_GROUP: field_desc.default_value = None else: # All other types are of the "int" type. field_desc.default_value = 0 field_desc.type = field_proto.type def _MakeEnumValueDescriptor(self, value_proto, index): """Creates a enum value descriptor object from a enum value proto. Args: value_proto: The proto describing the enum value. index: The index of the enum value. Returns: An initialized EnumValueDescriptor object. """ return descriptor.EnumValueDescriptor( name=value_proto.name, index=index, number=value_proto.number, options=_OptionsOrNone(value_proto), type=None, # pylint: disable=protected-access create_key=descriptor._internal_create_key) def _MakeServiceDescriptor(self, service_proto, service_index, scope, package, file_desc): """Make a protobuf ServiceDescriptor given a ServiceDescriptorProto. Args: service_proto: The descriptor_pb2.ServiceDescriptorProto protobuf message. service_index: The index of the service in the File. scope: Dict mapping short and full symbols to message and enum types. package: Optional package name for the new message EnumDescriptor. file_desc: The file containing the service descriptor. Returns: The added descriptor. """ if package: service_name = '.'.join((package, service_proto.name)) else: service_name = service_proto.name methods = [self._MakeMethodDescriptor(method_proto, service_name, package, scope, index) for index, method_proto in enumerate(service_proto.method)] desc = descriptor.ServiceDescriptor( name=service_proto.name, full_name=service_name, index=service_index, methods=methods, options=_OptionsOrNone(service_proto), file=file_desc, # pylint: disable=protected-access create_key=descriptor._internal_create_key) self._CheckConflictRegister(desc, desc.full_name, desc.file.name) self._service_descriptors[service_name] = desc return desc def _MakeMethodDescriptor(self, method_proto, service_name, package, scope, index): """Creates a method descriptor from a MethodDescriptorProto. Args: method_proto: The proto describing the method. service_name: The name of the containing service. package: Optional package name to look up for types. scope: Scope containing available types. index: Index of the method in the service. Returns: An initialized MethodDescriptor object. """ full_name = '.'.join((service_name, method_proto.name)) input_type = self._GetTypeFromScope( package, method_proto.input_type, scope) output_type = self._GetTypeFromScope( package, method_proto.output_type, scope) return descriptor.MethodDescriptor( name=method_proto.name, full_name=full_name, index=index, containing_service=None, input_type=input_type, output_type=output_type, client_streaming=method_proto.client_streaming, server_streaming=method_proto.server_streaming, options=_OptionsOrNone(method_proto), # pylint: disable=protected-access create_key=descriptor._internal_create_key) def _ExtractSymbols(self, descriptors): """Pulls out all the symbols from descriptor protos. Args: descriptors: The messages to extract descriptors from. Yields: A two element tuple of the type name and descriptor object. """ for desc in descriptors: yield (_PrefixWithDot(desc.full_name), desc) for symbol in self._ExtractSymbols(desc.nested_types): yield symbol for enum in desc.enum_types: yield (_PrefixWithDot(enum.full_name), enum) def _GetDeps(self, dependencies, visited=None): """Recursively finds dependencies for file protos. Args: dependencies: The names of the files being depended on. visited: The names of files already found. Yields: Each direct and indirect dependency. """ visited = visited or set() for dependency in dependencies: if dependency not in visited: visited.add(dependency) dep_desc = self.FindFileByName(dependency) yield dep_desc public_files = [d.name for d in dep_desc.public_dependencies] yield from self._GetDeps(public_files, visited) def _GetTypeFromScope(self, package, type_name, scope): """Finds a given type name in the current scope. Args: package: The package the proto should be located in. type_name: The name of the type to be found in the scope. scope: Dict mapping short and full symbols to message and enum types. Returns: The descriptor for the requested type. """ if type_name not in scope: components = _PrefixWithDot(package).split('.') while components: possible_match = '.'.join(components + [type_name]) if possible_match in scope: type_name = possible_match break else: components.pop(-1) return scope[type_name] def _PrefixWithDot(name): return name if name.startswith('.') else '.%s' % name if _USE_C_DESCRIPTORS: # TODO(amauryfa): This pool could be constructed from Python code, when we # support a flag like 'use_cpp_generated_pool=True'. # pylint: disable=protected-access _DEFAULT = descriptor._message.default_pool else: _DEFAULT = DescriptorPool() def Default(): return _DEFAULT ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/duration_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/duration.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1egoogle/protobuf/duration.proto\x12\x0fgoogle.protobuf\"*\n\x08\x44uration\x12\x0f\n\x07seconds\x18\x01 \x01(\x03\x12\r\n\x05nanos\x18\x02 \x01(\x05\x42\x83\x01\n\x13\x63om.google.protobufB\rDurationProtoP\x01Z1google.golang.org/protobuf/types/known/durationpb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.duration_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\rDurationProtoP\001Z1google.golang.org/protobuf/types/known/durationpb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _DURATION._serialized_start=51 _DURATION._serialized_end=93 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/empty_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/empty.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bgoogle/protobuf/empty.proto\x12\x0fgoogle.protobuf\"\x07\n\x05\x45mptyB}\n\x13\x63om.google.protobufB\nEmptyProtoP\x01Z.google.golang.org/protobuf/types/known/emptypb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.empty_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\nEmptyProtoP\001Z.google.golang.org/protobuf/types/known/emptypb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _EMPTY._serialized_start=48 _EMPTY._serialized_end=55 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/field_mask_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/field_mask.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n google/protobuf/field_mask.proto\x12\x0fgoogle.protobuf\"\x1a\n\tFieldMask\x12\r\n\x05paths\x18\x01 \x03(\tB\x85\x01\n\x13\x63om.google.protobufB\x0e\x46ieldMaskProtoP\x01Z2google.golang.org/protobuf/types/known/fieldmaskpb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.field_mask_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\016FieldMaskProtoP\001Z2google.golang.org/protobuf/types/known/fieldmaskpb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _FIELDMASK._serialized_start=53 _FIELDMASK._serialized_end=79 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/__init__.py ================================================ ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/_parameterized.py ================================================ #! /usr/bin/env python # # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Adds support for parameterized tests to Python's unittest TestCase class. A parameterized test is a method in a test case that is invoked with different argument tuples. A simple example: class AdditionExample(parameterized.TestCase): @parameterized.parameters( (1, 2, 3), (4, 5, 9), (1, 1, 3)) def testAddition(self, op1, op2, result): self.assertEqual(result, op1 + op2) Each invocation is a separate test case and properly isolated just like a normal test method, with its own setUp/tearDown cycle. In the example above, there are three separate testcases, one of which will fail due to an assertion error (1 + 1 != 3). Parameters for individual test cases can be tuples (with positional parameters) or dictionaries (with named parameters): class AdditionExample(parameterized.TestCase): @parameterized.parameters( {'op1': 1, 'op2': 2, 'result': 3}, {'op1': 4, 'op2': 5, 'result': 9}, ) def testAddition(self, op1, op2, result): self.assertEqual(result, op1 + op2) If a parameterized test fails, the error message will show the original test name (which is modified internally) and the arguments for the specific invocation, which are part of the string returned by the shortDescription() method on test cases. The id method of the test, used internally by the unittest framework, is also modified to show the arguments. To make sure that test names stay the same across several invocations, object representations like >>> class Foo(object): ... pass >>> repr(Foo()) '<__main__.Foo object at 0x23d8610>' are turned into '<__main__.Foo>'. For even more descriptive names, especially in test logs, you can use the named_parameters decorator. In this case, only tuples are supported, and the first parameters has to be a string (or an object that returns an apt name when converted via str()): class NamedExample(parameterized.TestCase): @parameterized.named_parameters( ('Normal', 'aa', 'aaa', True), ('EmptyPrefix', '', 'abc', True), ('BothEmpty', '', '', True)) def testStartsWith(self, prefix, string, result): self.assertEqual(result, strings.startswith(prefix)) Named tests also have the benefit that they can be run individually from the command line: $ testmodule.py NamedExample.testStartsWithNormal . -------------------------------------------------------------------- Ran 1 test in 0.000s OK Parameterized Classes ===================== If invocation arguments are shared across test methods in a single TestCase class, instead of decorating all test methods individually, the class itself can be decorated: @parameterized.parameters( (1, 2, 3) (4, 5, 9)) class ArithmeticTest(parameterized.TestCase): def testAdd(self, arg1, arg2, result): self.assertEqual(arg1 + arg2, result) def testSubtract(self, arg2, arg2, result): self.assertEqual(result - arg1, arg2) Inputs from Iterables ===================== If parameters should be shared across several test cases, or are dynamically created from other sources, a single non-tuple iterable can be passed into the decorator. This iterable will be used to obtain the test cases: class AdditionExample(parameterized.TestCase): @parameterized.parameters( c.op1, c.op2, c.result for c in testcases ) def testAddition(self, op1, op2, result): self.assertEqual(result, op1 + op2) Single-Argument Test Methods ============================ If a test method takes only one argument, the single argument does not need to be wrapped into a tuple: class NegativeNumberExample(parameterized.TestCase): @parameterized.parameters( -1, -3, -4, -5 ) def testIsNegative(self, arg): self.assertTrue(IsNegative(arg)) """ __author__ = 'tmarek@google.com (Torsten Marek)' import functools import re import types import unittest import uuid try: # Since python 3 import collections.abc as collections_abc except ImportError: # Won't work after python 3.8 import collections as collections_abc ADDR_RE = re.compile(r'\<([a-zA-Z0-9_\-\.]+) object at 0x[a-fA-F0-9]+\>') _SEPARATOR = uuid.uuid1().hex _FIRST_ARG = object() _ARGUMENT_REPR = object() def _CleanRepr(obj): return ADDR_RE.sub(r'<\1>', repr(obj)) # Helper function formerly from the unittest module, removed from it in # Python 2.7. def _StrClass(cls): return '%s.%s' % (cls.__module__, cls.__name__) def _NonStringIterable(obj): return (isinstance(obj, collections_abc.Iterable) and not isinstance(obj, str)) def _FormatParameterList(testcase_params): if isinstance(testcase_params, collections_abc.Mapping): return ', '.join('%s=%s' % (argname, _CleanRepr(value)) for argname, value in testcase_params.items()) elif _NonStringIterable(testcase_params): return ', '.join(map(_CleanRepr, testcase_params)) else: return _FormatParameterList((testcase_params,)) class _ParameterizedTestIter(object): """Callable and iterable class for producing new test cases.""" def __init__(self, test_method, testcases, naming_type): """Returns concrete test functions for a test and a list of parameters. The naming_type is used to determine the name of the concrete functions as reported by the unittest framework. If naming_type is _FIRST_ARG, the testcases must be tuples, and the first element must have a string representation that is a valid Python identifier. Args: test_method: The decorated test method. testcases: (list of tuple/dict) A list of parameter tuples/dicts for individual test invocations. naming_type: The test naming type, either _NAMED or _ARGUMENT_REPR. """ self._test_method = test_method self.testcases = testcases self._naming_type = naming_type def __call__(self, *args, **kwargs): raise RuntimeError('You appear to be running a parameterized test case ' 'without having inherited from parameterized.' 'TestCase. This is bad because none of ' 'your test cases are actually being run.') def __iter__(self): test_method = self._test_method naming_type = self._naming_type def MakeBoundParamTest(testcase_params): @functools.wraps(test_method) def BoundParamTest(self): if isinstance(testcase_params, collections_abc.Mapping): test_method(self, **testcase_params) elif _NonStringIterable(testcase_params): test_method(self, *testcase_params) else: test_method(self, testcase_params) if naming_type is _FIRST_ARG: # Signal the metaclass that the name of the test function is unique # and descriptive. BoundParamTest.__x_use_name__ = True BoundParamTest.__name__ += str(testcase_params[0]) testcase_params = testcase_params[1:] elif naming_type is _ARGUMENT_REPR: # __x_extra_id__ is used to pass naming information to the __new__ # method of TestGeneratorMetaclass. # The metaclass will make sure to create a unique, but nondescriptive # name for this test. BoundParamTest.__x_extra_id__ = '(%s)' % ( _FormatParameterList(testcase_params),) else: raise RuntimeError('%s is not a valid naming type.' % (naming_type,)) BoundParamTest.__doc__ = '%s(%s)' % ( BoundParamTest.__name__, _FormatParameterList(testcase_params)) if test_method.__doc__: BoundParamTest.__doc__ += '\n%s' % (test_method.__doc__,) return BoundParamTest return (MakeBoundParamTest(c) for c in self.testcases) def _IsSingletonList(testcases): """True iff testcases contains only a single non-tuple element.""" return len(testcases) == 1 and not isinstance(testcases[0], tuple) def _ModifyClass(class_object, testcases, naming_type): assert not getattr(class_object, '_id_suffix', None), ( 'Cannot add parameters to %s,' ' which already has parameterized methods.' % (class_object,)) class_object._id_suffix = id_suffix = {} # We change the size of __dict__ while we iterate over it, # which Python 3.x will complain about, so use copy(). for name, obj in class_object.__dict__.copy().items(): if (name.startswith(unittest.TestLoader.testMethodPrefix) and isinstance(obj, types.FunctionType)): delattr(class_object, name) methods = {} _UpdateClassDictForParamTestCase( methods, id_suffix, name, _ParameterizedTestIter(obj, testcases, naming_type)) for name, meth in methods.items(): setattr(class_object, name, meth) def _ParameterDecorator(naming_type, testcases): """Implementation of the parameterization decorators. Args: naming_type: The naming type. testcases: Testcase parameters. Returns: A function for modifying the decorated object. """ def _Apply(obj): if isinstance(obj, type): _ModifyClass( obj, list(testcases) if not isinstance(testcases, collections_abc.Sequence) else testcases, naming_type) return obj else: return _ParameterizedTestIter(obj, testcases, naming_type) if _IsSingletonList(testcases): assert _NonStringIterable(testcases[0]), ( 'Single parameter argument must be a non-string iterable') testcases = testcases[0] return _Apply def parameters(*testcases): # pylint: disable=invalid-name """A decorator for creating parameterized tests. See the module docstring for a usage example. Args: *testcases: Parameters for the decorated method, either a single iterable, or a list of tuples/dicts/objects (for tests with only one argument). Returns: A test generator to be handled by TestGeneratorMetaclass. """ return _ParameterDecorator(_ARGUMENT_REPR, testcases) def named_parameters(*testcases): # pylint: disable=invalid-name """A decorator for creating parameterized tests. See the module docstring for a usage example. The first element of each parameter tuple should be a string and will be appended to the name of the test method. Args: *testcases: Parameters for the decorated method, either a single iterable, or a list of tuples. Returns: A test generator to be handled by TestGeneratorMetaclass. """ return _ParameterDecorator(_FIRST_ARG, testcases) class TestGeneratorMetaclass(type): """Metaclass for test cases with test generators. A test generator is an iterable in a testcase that produces callables. These callables must be single-argument methods. These methods are injected into the class namespace and the original iterable is removed. If the name of the iterable conforms to the test pattern, the injected methods will be picked up as tests by the unittest framework. In general, it is supposed to be used in conjunction with the parameters decorator. """ def __new__(mcs, class_name, bases, dct): dct['_id_suffix'] = id_suffix = {} for name, obj in dct.copy().items(): if (name.startswith(unittest.TestLoader.testMethodPrefix) and _NonStringIterable(obj)): iterator = iter(obj) dct.pop(name) _UpdateClassDictForParamTestCase(dct, id_suffix, name, iterator) return type.__new__(mcs, class_name, bases, dct) def _UpdateClassDictForParamTestCase(dct, id_suffix, name, iterator): """Adds individual test cases to a dictionary. Args: dct: The target dictionary. id_suffix: The dictionary for mapping names to test IDs. name: The original name of the test case. iterator: The iterator generating the individual test cases. """ for idx, func in enumerate(iterator): assert callable(func), 'Test generators must yield callables, got %r' % ( func,) if getattr(func, '__x_use_name__', False): new_name = func.__name__ else: new_name = '%s%s%d' % (name, _SEPARATOR, idx) assert new_name not in dct, ( 'Name of parameterized test case "%s" not unique' % (new_name,)) dct[new_name] = func id_suffix[new_name] = getattr(func, '__x_extra_id__', '') class TestCase(unittest.TestCase, metaclass=TestGeneratorMetaclass): """Base class for test cases using the parameters decorator.""" def _OriginalName(self): return self._testMethodName.split(_SEPARATOR)[0] def __str__(self): return '%s (%s)' % (self._OriginalName(), _StrClass(self.__class__)) def id(self): # pylint: disable=invalid-name """Returns the descriptive ID of the test. This is used internally by the unittesting framework to get a name for the test to be used in reports. Returns: The test id. """ return '%s.%s%s' % (_StrClass(self.__class__), self._OriginalName(), self._id_suffix.get(self._testMethodName, '')) def CoopTestCase(other_base_class): """Returns a new base class with a cooperative metaclass base. This enables the TestCase to be used in combination with other base classes that have custom metaclasses, such as mox.MoxTestBase. Only works with metaclasses that do not override type.__new__. Example: import google3 import mox from google3.testing.pybase import parameterized class ExampleTest(parameterized.CoopTestCase(mox.MoxTestBase)): ... Args: other_base_class: (class) A test case base class. Returns: A new class object. """ metaclass = type( 'CoopMetaclass', (other_base_class.__metaclass__, TestGeneratorMetaclass), {}) return metaclass( 'CoopTestCase', (other_base_class, TestCase), {}) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/api_implementation.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Determine which implementation of the protobuf API is used in this process. """ import os import sys import warnings try: # pylint: disable=g-import-not-at-top from google.protobuf.internal import _api_implementation # The compile-time constants in the _api_implementation module can be used to # switch to a certain implementation of the Python API at build time. _api_version = _api_implementation.api_version except ImportError: _api_version = -1 # Unspecified by compiler flags. if _api_version == 1: raise ValueError('api_version=1 is no longer supported.') _default_implementation_type = ('cpp' if _api_version > 0 else 'python') # This environment variable can be used to switch to a certain implementation # of the Python API, overriding the compile-time constants in the # _api_implementation module. Right now only 'python' and 'cpp' are valid # values. Any other value will be ignored. _implementation_type = os.getenv('PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION', _default_implementation_type) if _implementation_type != 'python': _implementation_type = 'cpp' if 'PyPy' in sys.version and _implementation_type == 'cpp': warnings.warn('PyPy does not work yet with cpp protocol buffers. ' 'Falling back to the python implementation.') _implementation_type = 'python' # Detect if serialization should be deterministic by default try: # The presence of this module in a build allows the proto implementation to # be upgraded merely via build deps. # # NOTE: Merely importing this automatically enables deterministic proto # serialization for C++ code, but we still need to export it as a boolean so # that we can do the same for `_implementation_type == 'python'`. # # NOTE2: It is possible for C++ code to enable deterministic serialization by # default _without_ affecting Python code, if the C++ implementation is not in # use by this module. That is intended behavior, so we don't actually expose # this boolean outside of this module. # # pylint: disable=g-import-not-at-top,unused-import from google.protobuf import enable_deterministic_proto_serialization _python_deterministic_proto_serialization = True except ImportError: _python_deterministic_proto_serialization = False # Usage of this function is discouraged. Clients shouldn't care which # implementation of the API is in use. Note that there is no guarantee # that differences between APIs will be maintained. # Please don't use this function if possible. def Type(): return _implementation_type def _SetType(implementation_type): """Never use! Only for protobuf benchmark.""" global _implementation_type _implementation_type = implementation_type # See comment on 'Type' above. def Version(): return 2 # For internal use only def IsPythonDefaultSerializationDeterministic(): return _python_deterministic_proto_serialization ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/builder.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Builds descriptors, message classes and services for generated _pb2.py. This file is only called in python generated _pb2.py files. It builds descriptors, message classes and services that users can directly use in generated code. """ __author__ = 'jieluo@google.com (Jie Luo)' from google.protobuf.internal import enum_type_wrapper from google.protobuf import message as _message from google.protobuf import reflection as _reflection from google.protobuf import symbol_database as _symbol_database _sym_db = _symbol_database.Default() def BuildMessageAndEnumDescriptors(file_des, module): """Builds message and enum descriptors. Args: file_des: FileDescriptor of the .proto file module: Generated _pb2 module """ def BuildNestedDescriptors(msg_des, prefix): for (name, nested_msg) in msg_des.nested_types_by_name.items(): module_name = prefix + name.upper() module[module_name] = nested_msg BuildNestedDescriptors(nested_msg, module_name + '_') for enum_des in msg_des.enum_types: module[prefix + enum_des.name.upper()] = enum_des for (name, msg_des) in file_des.message_types_by_name.items(): module_name = '_' + name.upper() module[module_name] = msg_des BuildNestedDescriptors(msg_des, module_name + '_') def BuildTopDescriptorsAndMessages(file_des, module_name, module): """Builds top level descriptors and message classes. Args: file_des: FileDescriptor of the .proto file module_name: str, the name of generated _pb2 module module: Generated _pb2 module """ def BuildMessage(msg_des): create_dict = {} for (name, nested_msg) in msg_des.nested_types_by_name.items(): create_dict[name] = BuildMessage(nested_msg) create_dict['DESCRIPTOR'] = msg_des create_dict['__module__'] = module_name message_class = _reflection.GeneratedProtocolMessageType( msg_des.name, (_message.Message,), create_dict) _sym_db.RegisterMessage(message_class) return message_class # top level enums for (name, enum_des) in file_des.enum_types_by_name.items(): module['_' + name.upper()] = enum_des module[name] = enum_type_wrapper.EnumTypeWrapper(enum_des) for enum_value in enum_des.values: module[enum_value.name] = enum_value.number # top level extensions for (name, extension_des) in file_des.extensions_by_name.items(): module[name.upper() + '_FIELD_NUMBER'] = extension_des.number module[name] = extension_des # services for (name, service) in file_des.services_by_name.items(): module['_' + name.upper()] = service # Build messages. for (name, msg_des) in file_des.message_types_by_name.items(): module[name] = BuildMessage(msg_des) def BuildServices(file_des, module_name, module): """Builds services classes and services stub class. Args: file_des: FileDescriptor of the .proto file module_name: str, the name of generated _pb2 module module: Generated _pb2 module """ # pylint: disable=g-import-not-at-top from google.protobuf import service as _service from google.protobuf import service_reflection # pylint: enable=g-import-not-at-top for (name, service) in file_des.services_by_name.items(): module[name] = service_reflection.GeneratedServiceType( name, (_service.Service,), dict(DESCRIPTOR=service, __module__=module_name)) stub_name = name + '_Stub' module[stub_name] = service_reflection.GeneratedServiceStubType( stub_name, (module[name],), dict(DESCRIPTOR=service, __module__=module_name)) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/containers.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Contains container classes to represent different protocol buffer types. This file defines container classes which represent categories of protocol buffer field types which need extra maintenance. Currently these categories are: - Repeated scalar fields - These are all repeated fields which aren't composite (e.g. they are of simple types like int32, string, etc). - Repeated composite fields - Repeated fields which are composite. This includes groups and nested messages. """ import collections.abc import copy import pickle from typing import ( Any, Iterable, Iterator, List, MutableMapping, MutableSequence, NoReturn, Optional, Sequence, TypeVar, Union, overload, ) _T = TypeVar('_T') _K = TypeVar('_K') _V = TypeVar('_V') class BaseContainer(Sequence[_T]): """Base container class.""" # Minimizes memory usage and disallows assignment to other attributes. __slots__ = ['_message_listener', '_values'] def __init__(self, message_listener: Any) -> None: """ Args: message_listener: A MessageListener implementation. The RepeatedScalarFieldContainer will call this object's Modified() method when it is modified. """ self._message_listener = message_listener self._values = [] @overload def __getitem__(self, key: int) -> _T: ... @overload def __getitem__(self, key: slice) -> List[_T]: ... def __getitem__(self, key): """Retrieves item by the specified key.""" return self._values[key] def __len__(self) -> int: """Returns the number of elements in the container.""" return len(self._values) def __ne__(self, other: Any) -> bool: """Checks if another instance isn't equal to this one.""" # The concrete classes should define __eq__. return not self == other __hash__ = None def __repr__(self) -> str: return repr(self._values) def sort(self, *args, **kwargs) -> None: # Continue to support the old sort_function keyword argument. # This is expected to be a rare occurrence, so use LBYL to avoid # the overhead of actually catching KeyError. if 'sort_function' in kwargs: kwargs['cmp'] = kwargs.pop('sort_function') self._values.sort(*args, **kwargs) def reverse(self) -> None: self._values.reverse() # TODO(slebedev): Remove this. BaseContainer does *not* conform to # MutableSequence, only its subclasses do. collections.abc.MutableSequence.register(BaseContainer) class RepeatedScalarFieldContainer(BaseContainer[_T], MutableSequence[_T]): """Simple, type-checked, list-like container for holding repeated scalars.""" # Disallows assignment to other attributes. __slots__ = ['_type_checker'] def __init__( self, message_listener: Any, type_checker: Any, ) -> None: """Args: message_listener: A MessageListener implementation. The RepeatedScalarFieldContainer will call this object's Modified() method when it is modified. type_checker: A type_checkers.ValueChecker instance to run on elements inserted into this container. """ super().__init__(message_listener) self._type_checker = type_checker def append(self, value: _T) -> None: """Appends an item to the list. Similar to list.append().""" self._values.append(self._type_checker.CheckValue(value)) if not self._message_listener.dirty: self._message_listener.Modified() def insert(self, key: int, value: _T) -> None: """Inserts the item at the specified position. Similar to list.insert().""" self._values.insert(key, self._type_checker.CheckValue(value)) if not self._message_listener.dirty: self._message_listener.Modified() def extend(self, elem_seq: Iterable[_T]) -> None: """Extends by appending the given iterable. Similar to list.extend().""" if elem_seq is None: return try: elem_seq_iter = iter(elem_seq) except TypeError: if not elem_seq: # silently ignore falsy inputs :-/. # TODO(ptucker): Deprecate this behavior. b/18413862 return raise new_values = [self._type_checker.CheckValue(elem) for elem in elem_seq_iter] if new_values: self._values.extend(new_values) self._message_listener.Modified() def MergeFrom( self, other: Union['RepeatedScalarFieldContainer[_T]', Iterable[_T]], ) -> None: """Appends the contents of another repeated field of the same type to this one. We do not check the types of the individual fields. """ self._values.extend(other) self._message_listener.Modified() def remove(self, elem: _T): """Removes an item from the list. Similar to list.remove().""" self._values.remove(elem) self._message_listener.Modified() def pop(self, key: Optional[int] = -1) -> _T: """Removes and returns an item at a given index. Similar to list.pop().""" value = self._values[key] self.__delitem__(key) return value @overload def __setitem__(self, key: int, value: _T) -> None: ... @overload def __setitem__(self, key: slice, value: Iterable[_T]) -> None: ... def __setitem__(self, key, value) -> None: """Sets the item on the specified position.""" if isinstance(key, slice): if key.step is not None: raise ValueError('Extended slices not supported') self._values[key] = map(self._type_checker.CheckValue, value) self._message_listener.Modified() else: self._values[key] = self._type_checker.CheckValue(value) self._message_listener.Modified() def __delitem__(self, key: Union[int, slice]) -> None: """Deletes the item at the specified position.""" del self._values[key] self._message_listener.Modified() def __eq__(self, other: Any) -> bool: """Compares the current instance with another one.""" if self is other: return True # Special case for the same type which should be common and fast. if isinstance(other, self.__class__): return other._values == self._values # We are presumably comparing against some other sequence type. return other == self._values def __deepcopy__( self, unused_memo: Any = None, ) -> 'RepeatedScalarFieldContainer[_T]': clone = RepeatedScalarFieldContainer( copy.deepcopy(self._message_listener), self._type_checker) clone.MergeFrom(self) return clone def __reduce__(self, **kwargs) -> NoReturn: raise pickle.PickleError( "Can't pickle repeated scalar fields, convert to list first") # TODO(slebedev): Constrain T to be a subtype of Message. class RepeatedCompositeFieldContainer(BaseContainer[_T], MutableSequence[_T]): """Simple, list-like container for holding repeated composite fields.""" # Disallows assignment to other attributes. __slots__ = ['_message_descriptor'] def __init__(self, message_listener: Any, message_descriptor: Any) -> None: """ Note that we pass in a descriptor instead of the generated directly, since at the time we construct a _RepeatedCompositeFieldContainer we haven't yet necessarily initialized the type that will be contained in the container. Args: message_listener: A MessageListener implementation. The RepeatedCompositeFieldContainer will call this object's Modified() method when it is modified. message_descriptor: A Descriptor instance describing the protocol type that should be present in this container. We'll use the _concrete_class field of this descriptor when the client calls add(). """ super().__init__(message_listener) self._message_descriptor = message_descriptor def add(self, **kwargs: Any) -> _T: """Adds a new element at the end of the list and returns it. Keyword arguments may be used to initialize the element. """ new_element = self._message_descriptor._concrete_class(**kwargs) new_element._SetListener(self._message_listener) self._values.append(new_element) if not self._message_listener.dirty: self._message_listener.Modified() return new_element def append(self, value: _T) -> None: """Appends one element by copying the message.""" new_element = self._message_descriptor._concrete_class() new_element._SetListener(self._message_listener) new_element.CopyFrom(value) self._values.append(new_element) if not self._message_listener.dirty: self._message_listener.Modified() def insert(self, key: int, value: _T) -> None: """Inserts the item at the specified position by copying.""" new_element = self._message_descriptor._concrete_class() new_element._SetListener(self._message_listener) new_element.CopyFrom(value) self._values.insert(key, new_element) if not self._message_listener.dirty: self._message_listener.Modified() def extend(self, elem_seq: Iterable[_T]) -> None: """Extends by appending the given sequence of elements of the same type as this one, copying each individual message. """ message_class = self._message_descriptor._concrete_class listener = self._message_listener values = self._values for message in elem_seq: new_element = message_class() new_element._SetListener(listener) new_element.MergeFrom(message) values.append(new_element) listener.Modified() def MergeFrom( self, other: Union['RepeatedCompositeFieldContainer[_T]', Iterable[_T]], ) -> None: """Appends the contents of another repeated field of the same type to this one, copying each individual message. """ self.extend(other) def remove(self, elem: _T) -> None: """Removes an item from the list. Similar to list.remove().""" self._values.remove(elem) self._message_listener.Modified() def pop(self, key: Optional[int] = -1) -> _T: """Removes and returns an item at a given index. Similar to list.pop().""" value = self._values[key] self.__delitem__(key) return value @overload def __setitem__(self, key: int, value: _T) -> None: ... @overload def __setitem__(self, key: slice, value: Iterable[_T]) -> None: ... def __setitem__(self, key, value): # This method is implemented to make RepeatedCompositeFieldContainer # structurally compatible with typing.MutableSequence. It is # otherwise unsupported and will always raise an error. raise TypeError( f'{self.__class__.__name__} object does not support item assignment') def __delitem__(self, key: Union[int, slice]) -> None: """Deletes the item at the specified position.""" del self._values[key] self._message_listener.Modified() def __eq__(self, other: Any) -> bool: """Compares the current instance with another one.""" if self is other: return True if not isinstance(other, self.__class__): raise TypeError('Can only compare repeated composite fields against ' 'other repeated composite fields.') return self._values == other._values class ScalarMap(MutableMapping[_K, _V]): """Simple, type-checked, dict-like container for holding repeated scalars.""" # Disallows assignment to other attributes. __slots__ = ['_key_checker', '_value_checker', '_values', '_message_listener', '_entry_descriptor'] def __init__( self, message_listener: Any, key_checker: Any, value_checker: Any, entry_descriptor: Any, ) -> None: """ Args: message_listener: A MessageListener implementation. The ScalarMap will call this object's Modified() method when it is modified. key_checker: A type_checkers.ValueChecker instance to run on keys inserted into this container. value_checker: A type_checkers.ValueChecker instance to run on values inserted into this container. entry_descriptor: The MessageDescriptor of a map entry: key and value. """ self._message_listener = message_listener self._key_checker = key_checker self._value_checker = value_checker self._entry_descriptor = entry_descriptor self._values = {} def __getitem__(self, key: _K) -> _V: try: return self._values[key] except KeyError: key = self._key_checker.CheckValue(key) val = self._value_checker.DefaultValue() self._values[key] = val return val def __contains__(self, item: _K) -> bool: # We check the key's type to match the strong-typing flavor of the API. # Also this makes it easier to match the behavior of the C++ implementation. self._key_checker.CheckValue(item) return item in self._values @overload def get(self, key: _K) -> Optional[_V]: ... @overload def get(self, key: _K, default: _T) -> Union[_V, _T]: ... # We need to override this explicitly, because our defaultdict-like behavior # will make the default implementation (from our base class) always insert # the key. def get(self, key, default=None): if key in self: return self[key] else: return default def __setitem__(self, key: _K, value: _V) -> _T: checked_key = self._key_checker.CheckValue(key) checked_value = self._value_checker.CheckValue(value) self._values[checked_key] = checked_value self._message_listener.Modified() def __delitem__(self, key: _K) -> None: del self._values[key] self._message_listener.Modified() def __len__(self) -> int: return len(self._values) def __iter__(self) -> Iterator[_K]: return iter(self._values) def __repr__(self) -> str: return repr(self._values) def MergeFrom(self, other: 'ScalarMap[_K, _V]') -> None: self._values.update(other._values) self._message_listener.Modified() def InvalidateIterators(self) -> None: # It appears that the only way to reliably invalidate iterators to # self._values is to ensure that its size changes. original = self._values self._values = original.copy() original[None] = None # This is defined in the abstract base, but we can do it much more cheaply. def clear(self) -> None: self._values.clear() self._message_listener.Modified() def GetEntryClass(self) -> Any: return self._entry_descriptor._concrete_class class MessageMap(MutableMapping[_K, _V]): """Simple, type-checked, dict-like container for with submessage values.""" # Disallows assignment to other attributes. __slots__ = ['_key_checker', '_values', '_message_listener', '_message_descriptor', '_entry_descriptor'] def __init__( self, message_listener: Any, message_descriptor: Any, key_checker: Any, entry_descriptor: Any, ) -> None: """ Args: message_listener: A MessageListener implementation. The ScalarMap will call this object's Modified() method when it is modified. key_checker: A type_checkers.ValueChecker instance to run on keys inserted into this container. value_checker: A type_checkers.ValueChecker instance to run on values inserted into this container. entry_descriptor: The MessageDescriptor of a map entry: key and value. """ self._message_listener = message_listener self._message_descriptor = message_descriptor self._key_checker = key_checker self._entry_descriptor = entry_descriptor self._values = {} def __getitem__(self, key: _K) -> _V: key = self._key_checker.CheckValue(key) try: return self._values[key] except KeyError: new_element = self._message_descriptor._concrete_class() new_element._SetListener(self._message_listener) self._values[key] = new_element self._message_listener.Modified() return new_element def get_or_create(self, key: _K) -> _V: """get_or_create() is an alias for getitem (ie. map[key]). Args: key: The key to get or create in the map. This is useful in cases where you want to be explicit that the call is mutating the map. This can avoid lint errors for statements like this that otherwise would appear to be pointless statements: msg.my_map[key] """ return self[key] @overload def get(self, key: _K) -> Optional[_V]: ... @overload def get(self, key: _K, default: _T) -> Union[_V, _T]: ... # We need to override this explicitly, because our defaultdict-like behavior # will make the default implementation (from our base class) always insert # the key. def get(self, key, default=None): if key in self: return self[key] else: return default def __contains__(self, item: _K) -> bool: item = self._key_checker.CheckValue(item) return item in self._values def __setitem__(self, key: _K, value: _V) -> NoReturn: raise ValueError('May not set values directly, call my_map[key].foo = 5') def __delitem__(self, key: _K) -> None: key = self._key_checker.CheckValue(key) del self._values[key] self._message_listener.Modified() def __len__(self) -> int: return len(self._values) def __iter__(self) -> Iterator[_K]: return iter(self._values) def __repr__(self) -> str: return repr(self._values) def MergeFrom(self, other: 'MessageMap[_K, _V]') -> None: # pylint: disable=protected-access for key in other._values: # According to documentation: "When parsing from the wire or when merging, # if there are duplicate map keys the last key seen is used". if key in self: del self[key] self[key].CopyFrom(other[key]) # self._message_listener.Modified() not required here, because # mutations to submessages already propagate. def InvalidateIterators(self) -> None: # It appears that the only way to reliably invalidate iterators to # self._values is to ensure that its size changes. original = self._values self._values = original.copy() original[None] = None # This is defined in the abstract base, but we can do it much more cheaply. def clear(self) -> None: self._values.clear() self._message_listener.Modified() def GetEntryClass(self) -> Any: return self._entry_descriptor._concrete_class class _UnknownField: """A parsed unknown field.""" # Disallows assignment to other attributes. __slots__ = ['_field_number', '_wire_type', '_data'] def __init__(self, field_number, wire_type, data): self._field_number = field_number self._wire_type = wire_type self._data = data return def __lt__(self, other): # pylint: disable=protected-access return self._field_number < other._field_number def __eq__(self, other): if self is other: return True # pylint: disable=protected-access return (self._field_number == other._field_number and self._wire_type == other._wire_type and self._data == other._data) class UnknownFieldRef: # pylint: disable=missing-class-docstring def __init__(self, parent, index): self._parent = parent self._index = index def _check_valid(self): if not self._parent: raise ValueError('UnknownField does not exist. ' 'The parent message might be cleared.') if self._index >= len(self._parent): raise ValueError('UnknownField does not exist. ' 'The parent message might be cleared.') @property def field_number(self): self._check_valid() # pylint: disable=protected-access return self._parent._internal_get(self._index)._field_number @property def wire_type(self): self._check_valid() # pylint: disable=protected-access return self._parent._internal_get(self._index)._wire_type @property def data(self): self._check_valid() # pylint: disable=protected-access return self._parent._internal_get(self._index)._data class UnknownFieldSet: """UnknownField container""" # Disallows assignment to other attributes. __slots__ = ['_values'] def __init__(self): self._values = [] def __getitem__(self, index): if self._values is None: raise ValueError('UnknownFields does not exist. ' 'The parent message might be cleared.') size = len(self._values) if index < 0: index += size if index < 0 or index >= size: raise IndexError('index %d out of range'.index) return UnknownFieldRef(self, index) def _internal_get(self, index): return self._values[index] def __len__(self): if self._values is None: raise ValueError('UnknownFields does not exist. ' 'The parent message might be cleared.') return len(self._values) def _add(self, field_number, wire_type, data): unknown_field = _UnknownField(field_number, wire_type, data) self._values.append(unknown_field) return unknown_field def __iter__(self): for i in range(len(self)): yield UnknownFieldRef(self, i) def _extend(self, other): if other is None: return # pylint: disable=protected-access self._values.extend(other._values) def __eq__(self, other): if self is other: return True # Sort unknown fields because their order shouldn't # affect equality test. values = list(self._values) if other is None: return not values values.sort() # pylint: disable=protected-access other_values = sorted(other._values) return values == other_values def _clear(self): for value in self._values: # pylint: disable=protected-access if isinstance(value._data, UnknownFieldSet): value._data._clear() # pylint: disable=protected-access self._values = None ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/decoder.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Code for decoding protocol buffer primitives. This code is very similar to encoder.py -- read the docs for that module first. A "decoder" is a function with the signature: Decode(buffer, pos, end, message, field_dict) The arguments are: buffer: The string containing the encoded message. pos: The current position in the string. end: The position in the string where the current message ends. May be less than len(buffer) if we're reading a sub-message. message: The message object into which we're parsing. field_dict: message._fields (avoids a hashtable lookup). The decoder reads the field and stores it into field_dict, returning the new buffer position. A decoder for a repeated field may proactively decode all of the elements of that field, if they appear consecutively. Note that decoders may throw any of the following: IndexError: Indicates a truncated message. struct.error: Unpacking of a fixed-width field failed. message.DecodeError: Other errors. Decoders are expected to raise an exception if they are called with pos > end. This allows callers to be lax about bounds checking: it's fineto read past "end" as long as you are sure that someone else will notice and throw an exception later on. Something up the call stack is expected to catch IndexError and struct.error and convert them to message.DecodeError. Decoders are constructed using decoder constructors with the signature: MakeDecoder(field_number, is_repeated, is_packed, key, new_default) The arguments are: field_number: The field number of the field we want to decode. is_repeated: Is the field a repeated field? (bool) is_packed: Is the field a packed field? (bool) key: The key to use when looking up the field within field_dict. (This is actually the FieldDescriptor but nothing in this file should depend on that.) new_default: A function which takes a message object as a parameter and returns a new instance of the default value for this field. (This is called for repeated fields and sub-messages, when an instance does not already exist.) As with encoders, we define a decoder constructor for every type of field. Then, for every field of every message class we construct an actual decoder. That decoder goes into a dict indexed by tag, so when we decode a message we repeatedly read a tag, look up the corresponding decoder, and invoke it. """ __author__ = 'kenton@google.com (Kenton Varda)' import math import struct from google.protobuf.internal import containers from google.protobuf.internal import encoder from google.protobuf.internal import wire_format from google.protobuf import message # This is not for optimization, but rather to avoid conflicts with local # variables named "message". _DecodeError = message.DecodeError def _VarintDecoder(mask, result_type): """Return an encoder for a basic varint value (does not include tag). Decoded values will be bitwise-anded with the given mask before being returned, e.g. to limit them to 32 bits. The returned decoder does not take the usual "end" parameter -- the caller is expected to do bounds checking after the fact (often the caller can defer such checking until later). The decoder returns a (value, new_pos) pair. """ def DecodeVarint(buffer, pos): result = 0 shift = 0 while 1: b = buffer[pos] result |= ((b & 0x7f) << shift) pos += 1 if not (b & 0x80): result &= mask result = result_type(result) return (result, pos) shift += 7 if shift >= 64: raise _DecodeError('Too many bytes when decoding varint.') return DecodeVarint def _SignedVarintDecoder(bits, result_type): """Like _VarintDecoder() but decodes signed values.""" signbit = 1 << (bits - 1) mask = (1 << bits) - 1 def DecodeVarint(buffer, pos): result = 0 shift = 0 while 1: b = buffer[pos] result |= ((b & 0x7f) << shift) pos += 1 if not (b & 0x80): result &= mask result = (result ^ signbit) - signbit result = result_type(result) return (result, pos) shift += 7 if shift >= 64: raise _DecodeError('Too many bytes when decoding varint.') return DecodeVarint # All 32-bit and 64-bit values are represented as int. _DecodeVarint = _VarintDecoder((1 << 64) - 1, int) _DecodeSignedVarint = _SignedVarintDecoder(64, int) # Use these versions for values which must be limited to 32 bits. _DecodeVarint32 = _VarintDecoder((1 << 32) - 1, int) _DecodeSignedVarint32 = _SignedVarintDecoder(32, int) def ReadTag(buffer, pos): """Read a tag from the memoryview, and return a (tag_bytes, new_pos) tuple. We return the raw bytes of the tag rather than decoding them. The raw bytes can then be used to look up the proper decoder. This effectively allows us to trade some work that would be done in pure-python (decoding a varint) for work that is done in C (searching for a byte string in a hash table). In a low-level language it would be much cheaper to decode the varint and use that, but not in Python. Args: buffer: memoryview object of the encoded bytes pos: int of the current position to start from Returns: Tuple[bytes, int] of the tag data and new position. """ start = pos while buffer[pos] & 0x80: pos += 1 pos += 1 tag_bytes = buffer[start:pos].tobytes() return tag_bytes, pos # -------------------------------------------------------------------- def _SimpleDecoder(wire_type, decode_value): """Return a constructor for a decoder for fields of a particular type. Args: wire_type: The field's wire type. decode_value: A function which decodes an individual value, e.g. _DecodeVarint() """ def SpecificDecoder(field_number, is_repeated, is_packed, key, new_default, clear_if_default=False): if is_packed: local_DecodeVarint = _DecodeVarint def DecodePackedField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) (endpoint, pos) = local_DecodeVarint(buffer, pos) endpoint += pos if endpoint > end: raise _DecodeError('Truncated message.') while pos < endpoint: (element, pos) = decode_value(buffer, pos) value.append(element) if pos > endpoint: del value[-1] # Discard corrupt value. raise _DecodeError('Packed element was truncated.') return pos return DecodePackedField elif is_repeated: tag_bytes = encoder.TagBytes(field_number, wire_type) tag_len = len(tag_bytes) def DecodeRepeatedField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) while 1: (element, new_pos) = decode_value(buffer, pos) value.append(element) # Predict that the next tag is another copy of the same repeated # field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos >= end: # Prediction failed. Return. if new_pos > end: raise _DecodeError('Truncated message.') return new_pos return DecodeRepeatedField else: def DecodeField(buffer, pos, end, message, field_dict): (new_value, pos) = decode_value(buffer, pos) if pos > end: raise _DecodeError('Truncated message.') if clear_if_default and not new_value: field_dict.pop(key, None) else: field_dict[key] = new_value return pos return DecodeField return SpecificDecoder def _ModifiedDecoder(wire_type, decode_value, modify_value): """Like SimpleDecoder but additionally invokes modify_value on every value before storing it. Usually modify_value is ZigZagDecode. """ # Reusing _SimpleDecoder is slightly slower than copying a bunch of code, but # not enough to make a significant difference. def InnerDecode(buffer, pos): (result, new_pos) = decode_value(buffer, pos) return (modify_value(result), new_pos) return _SimpleDecoder(wire_type, InnerDecode) def _StructPackDecoder(wire_type, format): """Return a constructor for a decoder for a fixed-width field. Args: wire_type: The field's wire type. format: The format string to pass to struct.unpack(). """ value_size = struct.calcsize(format) local_unpack = struct.unpack # Reusing _SimpleDecoder is slightly slower than copying a bunch of code, but # not enough to make a significant difference. # Note that we expect someone up-stack to catch struct.error and convert # it to _DecodeError -- this way we don't have to set up exception- # handling blocks every time we parse one value. def InnerDecode(buffer, pos): new_pos = pos + value_size result = local_unpack(format, buffer[pos:new_pos])[0] return (result, new_pos) return _SimpleDecoder(wire_type, InnerDecode) def _FloatDecoder(): """Returns a decoder for a float field. This code works around a bug in struct.unpack for non-finite 32-bit floating-point values. """ local_unpack = struct.unpack def InnerDecode(buffer, pos): """Decode serialized float to a float and new position. Args: buffer: memoryview of the serialized bytes pos: int, position in the memory view to start at. Returns: Tuple[float, int] of the deserialized float value and new position in the serialized data. """ # We expect a 32-bit value in little-endian byte order. Bit 1 is the sign # bit, bits 2-9 represent the exponent, and bits 10-32 are the significand. new_pos = pos + 4 float_bytes = buffer[pos:new_pos].tobytes() # If this value has all its exponent bits set, then it's non-finite. # In Python 2.4, struct.unpack will convert it to a finite 64-bit value. # To avoid that, we parse it specially. if (float_bytes[3:4] in b'\x7F\xFF' and float_bytes[2:3] >= b'\x80'): # If at least one significand bit is set... if float_bytes[0:3] != b'\x00\x00\x80': return (math.nan, new_pos) # If sign bit is set... if float_bytes[3:4] == b'\xFF': return (-math.inf, new_pos) return (math.inf, new_pos) # Note that we expect someone up-stack to catch struct.error and convert # it to _DecodeError -- this way we don't have to set up exception- # handling blocks every time we parse one value. result = local_unpack('= b'\xF0') and (double_bytes[0:7] != b'\x00\x00\x00\x00\x00\x00\xF0')): return (math.nan, new_pos) # Note that we expect someone up-stack to catch struct.error and convert # it to _DecodeError -- this way we don't have to set up exception- # handling blocks every time we parse one value. result = local_unpack(' end: raise _DecodeError('Truncated message.') while pos < endpoint: value_start_pos = pos (element, pos) = _DecodeSignedVarint32(buffer, pos) # pylint: disable=protected-access if element in enum_type.values_by_number: value.append(element) else: if not message._unknown_fields: message._unknown_fields = [] tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_VARINT) message._unknown_fields.append( (tag_bytes, buffer[value_start_pos:pos].tobytes())) if message._unknown_field_set is None: message._unknown_field_set = containers.UnknownFieldSet() message._unknown_field_set._add( field_number, wire_format.WIRETYPE_VARINT, element) # pylint: enable=protected-access if pos > endpoint: if element in enum_type.values_by_number: del value[-1] # Discard corrupt value. else: del message._unknown_fields[-1] # pylint: disable=protected-access del message._unknown_field_set._values[-1] # pylint: enable=protected-access raise _DecodeError('Packed element was truncated.') return pos return DecodePackedField elif is_repeated: tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_VARINT) tag_len = len(tag_bytes) def DecodeRepeatedField(buffer, pos, end, message, field_dict): """Decode serialized repeated enum to its value and a new position. Args: buffer: memoryview of the serialized bytes. pos: int, position in the memory view to start at. end: int, end position of serialized data message: Message object to store unknown fields in field_dict: Map[Descriptor, Any] to store decoded values in. Returns: int, new position in serialized data. """ value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) while 1: (element, new_pos) = _DecodeSignedVarint32(buffer, pos) # pylint: disable=protected-access if element in enum_type.values_by_number: value.append(element) else: if not message._unknown_fields: message._unknown_fields = [] message._unknown_fields.append( (tag_bytes, buffer[pos:new_pos].tobytes())) if message._unknown_field_set is None: message._unknown_field_set = containers.UnknownFieldSet() message._unknown_field_set._add( field_number, wire_format.WIRETYPE_VARINT, element) # pylint: enable=protected-access # Predict that the next tag is another copy of the same repeated # field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos >= end: # Prediction failed. Return. if new_pos > end: raise _DecodeError('Truncated message.') return new_pos return DecodeRepeatedField else: def DecodeField(buffer, pos, end, message, field_dict): """Decode serialized repeated enum to its value and a new position. Args: buffer: memoryview of the serialized bytes. pos: int, position in the memory view to start at. end: int, end position of serialized data message: Message object to store unknown fields in field_dict: Map[Descriptor, Any] to store decoded values in. Returns: int, new position in serialized data. """ value_start_pos = pos (enum_value, pos) = _DecodeSignedVarint32(buffer, pos) if pos > end: raise _DecodeError('Truncated message.') if clear_if_default and not enum_value: field_dict.pop(key, None) return pos # pylint: disable=protected-access if enum_value in enum_type.values_by_number: field_dict[key] = enum_value else: if not message._unknown_fields: message._unknown_fields = [] tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_VARINT) message._unknown_fields.append( (tag_bytes, buffer[value_start_pos:pos].tobytes())) if message._unknown_field_set is None: message._unknown_field_set = containers.UnknownFieldSet() message._unknown_field_set._add( field_number, wire_format.WIRETYPE_VARINT, enum_value) # pylint: enable=protected-access return pos return DecodeField # -------------------------------------------------------------------- Int32Decoder = _SimpleDecoder( wire_format.WIRETYPE_VARINT, _DecodeSignedVarint32) Int64Decoder = _SimpleDecoder( wire_format.WIRETYPE_VARINT, _DecodeSignedVarint) UInt32Decoder = _SimpleDecoder(wire_format.WIRETYPE_VARINT, _DecodeVarint32) UInt64Decoder = _SimpleDecoder(wire_format.WIRETYPE_VARINT, _DecodeVarint) SInt32Decoder = _ModifiedDecoder( wire_format.WIRETYPE_VARINT, _DecodeVarint32, wire_format.ZigZagDecode) SInt64Decoder = _ModifiedDecoder( wire_format.WIRETYPE_VARINT, _DecodeVarint, wire_format.ZigZagDecode) # Note that Python conveniently guarantees that when using the '<' prefix on # formats, they will also have the same size across all platforms (as opposed # to without the prefix, where their sizes depend on the C compiler's basic # type sizes). Fixed32Decoder = _StructPackDecoder(wire_format.WIRETYPE_FIXED32, ' end: raise _DecodeError('Truncated string.') value.append(_ConvertToUnicode(buffer[pos:new_pos])) # Predict that the next tag is another copy of the same repeated field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos == end: # Prediction failed. Return. return new_pos return DecodeRepeatedField else: def DecodeField(buffer, pos, end, message, field_dict): (size, pos) = local_DecodeVarint(buffer, pos) new_pos = pos + size if new_pos > end: raise _DecodeError('Truncated string.') if clear_if_default and not size: field_dict.pop(key, None) else: field_dict[key] = _ConvertToUnicode(buffer[pos:new_pos]) return new_pos return DecodeField def BytesDecoder(field_number, is_repeated, is_packed, key, new_default, clear_if_default=False): """Returns a decoder for a bytes field.""" local_DecodeVarint = _DecodeVarint assert not is_packed if is_repeated: tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_LENGTH_DELIMITED) tag_len = len(tag_bytes) def DecodeRepeatedField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) while 1: (size, pos) = local_DecodeVarint(buffer, pos) new_pos = pos + size if new_pos > end: raise _DecodeError('Truncated string.') value.append(buffer[pos:new_pos].tobytes()) # Predict that the next tag is another copy of the same repeated field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos == end: # Prediction failed. Return. return new_pos return DecodeRepeatedField else: def DecodeField(buffer, pos, end, message, field_dict): (size, pos) = local_DecodeVarint(buffer, pos) new_pos = pos + size if new_pos > end: raise _DecodeError('Truncated string.') if clear_if_default and not size: field_dict.pop(key, None) else: field_dict[key] = buffer[pos:new_pos].tobytes() return new_pos return DecodeField def GroupDecoder(field_number, is_repeated, is_packed, key, new_default): """Returns a decoder for a group field.""" end_tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_END_GROUP) end_tag_len = len(end_tag_bytes) assert not is_packed if is_repeated: tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_START_GROUP) tag_len = len(tag_bytes) def DecodeRepeatedField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) while 1: value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) # Read sub-message. pos = value.add()._InternalParse(buffer, pos, end) # Read end tag. new_pos = pos+end_tag_len if buffer[pos:new_pos] != end_tag_bytes or new_pos > end: raise _DecodeError('Missing group end tag.') # Predict that the next tag is another copy of the same repeated field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos == end: # Prediction failed. Return. return new_pos return DecodeRepeatedField else: def DecodeField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) # Read sub-message. pos = value._InternalParse(buffer, pos, end) # Read end tag. new_pos = pos+end_tag_len if buffer[pos:new_pos] != end_tag_bytes or new_pos > end: raise _DecodeError('Missing group end tag.') return new_pos return DecodeField def MessageDecoder(field_number, is_repeated, is_packed, key, new_default): """Returns a decoder for a message field.""" local_DecodeVarint = _DecodeVarint assert not is_packed if is_repeated: tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_LENGTH_DELIMITED) tag_len = len(tag_bytes) def DecodeRepeatedField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) while 1: # Read length. (size, pos) = local_DecodeVarint(buffer, pos) new_pos = pos + size if new_pos > end: raise _DecodeError('Truncated message.') # Read sub-message. if value.add()._InternalParse(buffer, pos, new_pos) != new_pos: # The only reason _InternalParse would return early is if it # encountered an end-group tag. raise _DecodeError('Unexpected end-group tag.') # Predict that the next tag is another copy of the same repeated field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos == end: # Prediction failed. Return. return new_pos return DecodeRepeatedField else: def DecodeField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) # Read length. (size, pos) = local_DecodeVarint(buffer, pos) new_pos = pos + size if new_pos > end: raise _DecodeError('Truncated message.') # Read sub-message. if value._InternalParse(buffer, pos, new_pos) != new_pos: # The only reason _InternalParse would return early is if it encountered # an end-group tag. raise _DecodeError('Unexpected end-group tag.') return new_pos return DecodeField # -------------------------------------------------------------------- MESSAGE_SET_ITEM_TAG = encoder.TagBytes(1, wire_format.WIRETYPE_START_GROUP) def MessageSetItemDecoder(descriptor): """Returns a decoder for a MessageSet item. The parameter is the message Descriptor. The message set message looks like this: message MessageSet { repeated group Item = 1 { required int32 type_id = 2; required string message = 3; } } """ type_id_tag_bytes = encoder.TagBytes(2, wire_format.WIRETYPE_VARINT) message_tag_bytes = encoder.TagBytes(3, wire_format.WIRETYPE_LENGTH_DELIMITED) item_end_tag_bytes = encoder.TagBytes(1, wire_format.WIRETYPE_END_GROUP) local_ReadTag = ReadTag local_DecodeVarint = _DecodeVarint local_SkipField = SkipField def DecodeItem(buffer, pos, end, message, field_dict): """Decode serialized message set to its value and new position. Args: buffer: memoryview of the serialized bytes. pos: int, position in the memory view to start at. end: int, end position of serialized data message: Message object to store unknown fields in field_dict: Map[Descriptor, Any] to store decoded values in. Returns: int, new position in serialized data. """ message_set_item_start = pos type_id = -1 message_start = -1 message_end = -1 # Technically, type_id and message can appear in any order, so we need # a little loop here. while 1: (tag_bytes, pos) = local_ReadTag(buffer, pos) if tag_bytes == type_id_tag_bytes: (type_id, pos) = local_DecodeVarint(buffer, pos) elif tag_bytes == message_tag_bytes: (size, message_start) = local_DecodeVarint(buffer, pos) pos = message_end = message_start + size elif tag_bytes == item_end_tag_bytes: break else: pos = SkipField(buffer, pos, end, tag_bytes) if pos == -1: raise _DecodeError('Missing group end tag.') if pos > end: raise _DecodeError('Truncated message.') if type_id == -1: raise _DecodeError('MessageSet item missing type_id.') if message_start == -1: raise _DecodeError('MessageSet item missing message.') extension = message.Extensions._FindExtensionByNumber(type_id) # pylint: disable=protected-access if extension is not None: value = field_dict.get(extension) if value is None: message_type = extension.message_type if not hasattr(message_type, '_concrete_class'): # pylint: disable=protected-access message._FACTORY.GetPrototype(message_type) value = field_dict.setdefault( extension, message_type._concrete_class()) if value._InternalParse(buffer, message_start,message_end) != message_end: # The only reason _InternalParse would return early is if it encountered # an end-group tag. raise _DecodeError('Unexpected end-group tag.') else: if not message._unknown_fields: message._unknown_fields = [] message._unknown_fields.append( (MESSAGE_SET_ITEM_TAG, buffer[message_set_item_start:pos].tobytes())) if message._unknown_field_set is None: message._unknown_field_set = containers.UnknownFieldSet() message._unknown_field_set._add( type_id, wire_format.WIRETYPE_LENGTH_DELIMITED, buffer[message_start:message_end].tobytes()) # pylint: enable=protected-access return pos return DecodeItem # -------------------------------------------------------------------- def MapDecoder(field_descriptor, new_default, is_message_map): """Returns a decoder for a map field.""" key = field_descriptor tag_bytes = encoder.TagBytes(field_descriptor.number, wire_format.WIRETYPE_LENGTH_DELIMITED) tag_len = len(tag_bytes) local_DecodeVarint = _DecodeVarint # Can't read _concrete_class yet; might not be initialized. message_type = field_descriptor.message_type def DecodeMap(buffer, pos, end, message, field_dict): submsg = message_type._concrete_class() value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) while 1: # Read length. (size, pos) = local_DecodeVarint(buffer, pos) new_pos = pos + size if new_pos > end: raise _DecodeError('Truncated message.') # Read sub-message. submsg.Clear() if submsg._InternalParse(buffer, pos, new_pos) != new_pos: # The only reason _InternalParse would return early is if it # encountered an end-group tag. raise _DecodeError('Unexpected end-group tag.') if is_message_map: value[submsg.key].CopyFrom(submsg.value) else: value[submsg.key] = submsg.value # Predict that the next tag is another copy of the same repeated field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos == end: # Prediction failed. Return. return new_pos return DecodeMap # -------------------------------------------------------------------- # Optimization is not as heavy here because calls to SkipField() are rare, # except for handling end-group tags. def _SkipVarint(buffer, pos, end): """Skip a varint value. Returns the new position.""" # Previously ord(buffer[pos]) raised IndexError when pos is out of range. # With this code, ord(b'') raises TypeError. Both are handled in # python_message.py to generate a 'Truncated message' error. while ord(buffer[pos:pos+1].tobytes()) & 0x80: pos += 1 pos += 1 if pos > end: raise _DecodeError('Truncated message.') return pos def _SkipFixed64(buffer, pos, end): """Skip a fixed64 value. Returns the new position.""" pos += 8 if pos > end: raise _DecodeError('Truncated message.') return pos def _DecodeFixed64(buffer, pos): """Decode a fixed64.""" new_pos = pos + 8 return (struct.unpack(' end: raise _DecodeError('Truncated message.') return pos def _SkipGroup(buffer, pos, end): """Skip sub-group. Returns the new position.""" while 1: (tag_bytes, pos) = ReadTag(buffer, pos) new_pos = SkipField(buffer, pos, end, tag_bytes) if new_pos == -1: return pos pos = new_pos def _DecodeUnknownFieldSet(buffer, pos, end_pos=None): """Decode UnknownFieldSet. Returns the UnknownFieldSet and new position.""" unknown_field_set = containers.UnknownFieldSet() while end_pos is None or pos < end_pos: (tag_bytes, pos) = ReadTag(buffer, pos) (tag, _) = _DecodeVarint(tag_bytes, 0) field_number, wire_type = wire_format.UnpackTag(tag) if wire_type == wire_format.WIRETYPE_END_GROUP: break (data, pos) = _DecodeUnknownField(buffer, pos, wire_type) # pylint: disable=protected-access unknown_field_set._add(field_number, wire_type, data) return (unknown_field_set, pos) def _DecodeUnknownField(buffer, pos, wire_type): """Decode a unknown field. Returns the UnknownField and new position.""" if wire_type == wire_format.WIRETYPE_VARINT: (data, pos) = _DecodeVarint(buffer, pos) elif wire_type == wire_format.WIRETYPE_FIXED64: (data, pos) = _DecodeFixed64(buffer, pos) elif wire_type == wire_format.WIRETYPE_FIXED32: (data, pos) = _DecodeFixed32(buffer, pos) elif wire_type == wire_format.WIRETYPE_LENGTH_DELIMITED: (size, pos) = _DecodeVarint(buffer, pos) data = buffer[pos:pos+size].tobytes() pos += size elif wire_type == wire_format.WIRETYPE_START_GROUP: (data, pos) = _DecodeUnknownFieldSet(buffer, pos) elif wire_type == wire_format.WIRETYPE_END_GROUP: return (0, -1) else: raise _DecodeError('Wrong wire type in tag.') return (data, pos) def _EndGroup(buffer, pos, end): """Skipping an END_GROUP tag returns -1 to tell the parent loop to break.""" return -1 def _SkipFixed32(buffer, pos, end): """Skip a fixed32 value. Returns the new position.""" pos += 4 if pos > end: raise _DecodeError('Truncated message.') return pos def _DecodeFixed32(buffer, pos): """Decode a fixed32.""" new_pos = pos + 4 return (struct.unpack('B').pack def EncodeVarint(write, value, unused_deterministic=None): bits = value & 0x7f value >>= 7 while value: write(local_int2byte(0x80|bits)) bits = value & 0x7f value >>= 7 return write(local_int2byte(bits)) return EncodeVarint def _SignedVarintEncoder(): """Return an encoder for a basic signed varint value (does not include tag).""" local_int2byte = struct.Struct('>B').pack def EncodeSignedVarint(write, value, unused_deterministic=None): if value < 0: value += (1 << 64) bits = value & 0x7f value >>= 7 while value: write(local_int2byte(0x80|bits)) bits = value & 0x7f value >>= 7 return write(local_int2byte(bits)) return EncodeSignedVarint _EncodeVarint = _VarintEncoder() _EncodeSignedVarint = _SignedVarintEncoder() def _VarintBytes(value): """Encode the given integer as a varint and return the bytes. This is only called at startup time so it doesn't need to be fast.""" pieces = [] _EncodeVarint(pieces.append, value, True) return b"".join(pieces) def TagBytes(field_number, wire_type): """Encode the given tag and return the bytes. Only called at startup.""" return bytes(_VarintBytes(wire_format.PackTag(field_number, wire_type))) # -------------------------------------------------------------------- # As with sizers (see above), we have a number of common encoder # implementations. def _SimpleEncoder(wire_type, encode_value, compute_value_size): """Return a constructor for an encoder for fields of a particular type. Args: wire_type: The field's wire type, for encoding tags. encode_value: A function which encodes an individual value, e.g. _EncodeVarint(). compute_value_size: A function which computes the size of an individual value, e.g. _VarintSize(). """ def SpecificEncoder(field_number, is_repeated, is_packed): if is_packed: tag_bytes = TagBytes(field_number, wire_format.WIRETYPE_LENGTH_DELIMITED) local_EncodeVarint = _EncodeVarint def EncodePackedField(write, value, deterministic): write(tag_bytes) size = 0 for element in value: size += compute_value_size(element) local_EncodeVarint(write, size, deterministic) for element in value: encode_value(write, element, deterministic) return EncodePackedField elif is_repeated: tag_bytes = TagBytes(field_number, wire_type) def EncodeRepeatedField(write, value, deterministic): for element in value: write(tag_bytes) encode_value(write, element, deterministic) return EncodeRepeatedField else: tag_bytes = TagBytes(field_number, wire_type) def EncodeField(write, value, deterministic): write(tag_bytes) return encode_value(write, value, deterministic) return EncodeField return SpecificEncoder def _ModifiedEncoder(wire_type, encode_value, compute_value_size, modify_value): """Like SimpleEncoder but additionally invokes modify_value on every value before passing it to encode_value. Usually modify_value is ZigZagEncode.""" def SpecificEncoder(field_number, is_repeated, is_packed): if is_packed: tag_bytes = TagBytes(field_number, wire_format.WIRETYPE_LENGTH_DELIMITED) local_EncodeVarint = _EncodeVarint def EncodePackedField(write, value, deterministic): write(tag_bytes) size = 0 for element in value: size += compute_value_size(modify_value(element)) local_EncodeVarint(write, size, deterministic) for element in value: encode_value(write, modify_value(element), deterministic) return EncodePackedField elif is_repeated: tag_bytes = TagBytes(field_number, wire_type) def EncodeRepeatedField(write, value, deterministic): for element in value: write(tag_bytes) encode_value(write, modify_value(element), deterministic) return EncodeRepeatedField else: tag_bytes = TagBytes(field_number, wire_type) def EncodeField(write, value, deterministic): write(tag_bytes) return encode_value(write, modify_value(value), deterministic) return EncodeField return SpecificEncoder def _StructPackEncoder(wire_type, format): """Return a constructor for an encoder for a fixed-width field. Args: wire_type: The field's wire type, for encoding tags. format: The format string to pass to struct.pack(). """ value_size = struct.calcsize(format) def SpecificEncoder(field_number, is_repeated, is_packed): local_struct_pack = struct.pack if is_packed: tag_bytes = TagBytes(field_number, wire_format.WIRETYPE_LENGTH_DELIMITED) local_EncodeVarint = _EncodeVarint def EncodePackedField(write, value, deterministic): write(tag_bytes) local_EncodeVarint(write, len(value) * value_size, deterministic) for element in value: write(local_struct_pack(format, element)) return EncodePackedField elif is_repeated: tag_bytes = TagBytes(field_number, wire_type) def EncodeRepeatedField(write, value, unused_deterministic=None): for element in value: write(tag_bytes) write(local_struct_pack(format, element)) return EncodeRepeatedField else: tag_bytes = TagBytes(field_number, wire_type) def EncodeField(write, value, unused_deterministic=None): write(tag_bytes) return write(local_struct_pack(format, value)) return EncodeField return SpecificEncoder def _FloatingPointEncoder(wire_type, format): """Return a constructor for an encoder for float fields. This is like StructPackEncoder, but catches errors that may be due to passing non-finite floating-point values to struct.pack, and makes a second attempt to encode those values. Args: wire_type: The field's wire type, for encoding tags. format: The format string to pass to struct.pack(). """ value_size = struct.calcsize(format) if value_size == 4: def EncodeNonFiniteOrRaise(write, value): # Remember that the serialized form uses little-endian byte order. if value == _POS_INF: write(b'\x00\x00\x80\x7F') elif value == _NEG_INF: write(b'\x00\x00\x80\xFF') elif value != value: # NaN write(b'\x00\x00\xC0\x7F') else: raise elif value_size == 8: def EncodeNonFiniteOrRaise(write, value): if value == _POS_INF: write(b'\x00\x00\x00\x00\x00\x00\xF0\x7F') elif value == _NEG_INF: write(b'\x00\x00\x00\x00\x00\x00\xF0\xFF') elif value != value: # NaN write(b'\x00\x00\x00\x00\x00\x00\xF8\x7F') else: raise else: raise ValueError('Can\'t encode floating-point values that are ' '%d bytes long (only 4 or 8)' % value_size) def SpecificEncoder(field_number, is_repeated, is_packed): local_struct_pack = struct.pack if is_packed: tag_bytes = TagBytes(field_number, wire_format.WIRETYPE_LENGTH_DELIMITED) local_EncodeVarint = _EncodeVarint def EncodePackedField(write, value, deterministic): write(tag_bytes) local_EncodeVarint(write, len(value) * value_size, deterministic) for element in value: # This try/except block is going to be faster than any code that # we could write to check whether element is finite. try: write(local_struct_pack(format, element)) except SystemError: EncodeNonFiniteOrRaise(write, element) return EncodePackedField elif is_repeated: tag_bytes = TagBytes(field_number, wire_type) def EncodeRepeatedField(write, value, unused_deterministic=None): for element in value: write(tag_bytes) try: write(local_struct_pack(format, element)) except SystemError: EncodeNonFiniteOrRaise(write, element) return EncodeRepeatedField else: tag_bytes = TagBytes(field_number, wire_type) def EncodeField(write, value, unused_deterministic=None): write(tag_bytes) try: write(local_struct_pack(format, value)) except SystemError: EncodeNonFiniteOrRaise(write, value) return EncodeField return SpecificEncoder # ==================================================================== # Here we declare an encoder constructor for each field type. These work # very similarly to sizer constructors, described earlier. Int32Encoder = Int64Encoder = EnumEncoder = _SimpleEncoder( wire_format.WIRETYPE_VARINT, _EncodeSignedVarint, _SignedVarintSize) UInt32Encoder = UInt64Encoder = _SimpleEncoder( wire_format.WIRETYPE_VARINT, _EncodeVarint, _VarintSize) SInt32Encoder = SInt64Encoder = _ModifiedEncoder( wire_format.WIRETYPE_VARINT, _EncodeVarint, _VarintSize, wire_format.ZigZagEncode) # Note that Python conveniently guarantees that when using the '<' prefix on # formats, they will also have the same size across all platforms (as opposed # to without the prefix, where their sizes depend on the C compiler's basic # type sizes). Fixed32Encoder = _StructPackEncoder(wire_format.WIRETYPE_FIXED32, ' str ValueType = int def __init__(self, enum_type): """Inits EnumTypeWrapper with an EnumDescriptor.""" self._enum_type = enum_type self.DESCRIPTOR = enum_type # pylint: disable=invalid-name def Name(self, number): # pylint: disable=invalid-name """Returns a string containing the name of an enum value.""" try: return self._enum_type.values_by_number[number].name except KeyError: pass # fall out to break exception chaining if not isinstance(number, int): raise TypeError( 'Enum value for {} must be an int, but got {} {!r}.'.format( self._enum_type.name, type(number), number)) else: # repr here to handle the odd case when you pass in a boolean. raise ValueError('Enum {} has no name defined for value {!r}'.format( self._enum_type.name, number)) def Value(self, name): # pylint: disable=invalid-name """Returns the value corresponding to the given enum name.""" try: return self._enum_type.values_by_name[name].number except KeyError: pass # fall out to break exception chaining raise ValueError('Enum {} has no value defined for name {!r}'.format( self._enum_type.name, name)) def keys(self): """Return a list of the string names in the enum. Returns: A list of strs, in the order they were defined in the .proto file. """ return [value_descriptor.name for value_descriptor in self._enum_type.values] def values(self): """Return a list of the integer values in the enum. Returns: A list of ints, in the order they were defined in the .proto file. """ return [value_descriptor.number for value_descriptor in self._enum_type.values] def items(self): """Return a list of the (name, value) pairs of the enum. Returns: A list of (str, int) pairs, in the order they were defined in the .proto file. """ return [(value_descriptor.name, value_descriptor.number) for value_descriptor in self._enum_type.values] def __getattr__(self, name): """Returns the value corresponding to the given enum name.""" try: return super( EnumTypeWrapper, self).__getattribute__('_enum_type').values_by_name[name].number except KeyError: pass # fall out to break exception chaining raise AttributeError('Enum {} has no value defined for name {!r}'.format( self._enum_type.name, name)) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/extension_dict.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Contains _ExtensionDict class to represent extensions. """ from google.protobuf.internal import type_checkers from google.protobuf.descriptor import FieldDescriptor def _VerifyExtensionHandle(message, extension_handle): """Verify that the given extension handle is valid.""" if not isinstance(extension_handle, FieldDescriptor): raise KeyError('HasExtension() expects an extension handle, got: %s' % extension_handle) if not extension_handle.is_extension: raise KeyError('"%s" is not an extension.' % extension_handle.full_name) if not extension_handle.containing_type: raise KeyError('"%s" is missing a containing_type.' % extension_handle.full_name) if extension_handle.containing_type is not message.DESCRIPTOR: raise KeyError('Extension "%s" extends message type "%s", but this ' 'message is of type "%s".' % (extension_handle.full_name, extension_handle.containing_type.full_name, message.DESCRIPTOR.full_name)) # TODO(robinson): Unify error handling of "unknown extension" crap. # TODO(robinson): Support iteritems()-style iteration over all # extensions with the "has" bits turned on? class _ExtensionDict(object): """Dict-like container for Extension fields on proto instances. Note that in all cases we expect extension handles to be FieldDescriptors. """ def __init__(self, extended_message): """ Args: extended_message: Message instance for which we are the Extensions dict. """ self._extended_message = extended_message def __getitem__(self, extension_handle): """Returns the current value of the given extension handle.""" _VerifyExtensionHandle(self._extended_message, extension_handle) result = self._extended_message._fields.get(extension_handle) if result is not None: return result if extension_handle.label == FieldDescriptor.LABEL_REPEATED: result = extension_handle._default_constructor(self._extended_message) elif extension_handle.cpp_type == FieldDescriptor.CPPTYPE_MESSAGE: message_type = extension_handle.message_type if not hasattr(message_type, '_concrete_class'): # pylint: disable=protected-access self._extended_message._FACTORY.GetPrototype(message_type) assert getattr(extension_handle.message_type, '_concrete_class', None), ( 'Uninitialized concrete class found for field %r (message type %r)' % (extension_handle.full_name, extension_handle.message_type.full_name)) result = extension_handle.message_type._concrete_class() try: result._SetListener(self._extended_message._listener_for_children) except ReferenceError: pass else: # Singular scalar -- just return the default without inserting into the # dict. return extension_handle.default_value # Atomically check if another thread has preempted us and, if not, swap # in the new object we just created. If someone has preempted us, we # take that object and discard ours. # WARNING: We are relying on setdefault() being atomic. This is true # in CPython but we haven't investigated others. This warning appears # in several other locations in this file. result = self._extended_message._fields.setdefault( extension_handle, result) return result def __eq__(self, other): if not isinstance(other, self.__class__): return False my_fields = self._extended_message.ListFields() other_fields = other._extended_message.ListFields() # Get rid of non-extension fields. my_fields = [field for field in my_fields if field.is_extension] other_fields = [field for field in other_fields if field.is_extension] return my_fields == other_fields def __ne__(self, other): return not self == other def __len__(self): fields = self._extended_message.ListFields() # Get rid of non-extension fields. extension_fields = [field for field in fields if field[0].is_extension] return len(extension_fields) def __hash__(self): raise TypeError('unhashable object') # Note that this is only meaningful for non-repeated, scalar extension # fields. Note also that we may have to call _Modified() when we do # successfully set a field this way, to set any necessary "has" bits in the # ancestors of the extended message. def __setitem__(self, extension_handle, value): """If extension_handle specifies a non-repeated, scalar extension field, sets the value of that field. """ _VerifyExtensionHandle(self._extended_message, extension_handle) if (extension_handle.label == FieldDescriptor.LABEL_REPEATED or extension_handle.cpp_type == FieldDescriptor.CPPTYPE_MESSAGE): raise TypeError( 'Cannot assign to extension "%s" because it is a repeated or ' 'composite type.' % extension_handle.full_name) # It's slightly wasteful to lookup the type checker each time, # but we expect this to be a vanishingly uncommon case anyway. type_checker = type_checkers.GetTypeChecker(extension_handle) # pylint: disable=protected-access self._extended_message._fields[extension_handle] = ( type_checker.CheckValue(value)) self._extended_message._Modified() def __delitem__(self, extension_handle): self._extended_message.ClearExtension(extension_handle) def _FindExtensionByName(self, name): """Tries to find a known extension with the specified name. Args: name: Extension full name. Returns: Extension field descriptor. """ return self._extended_message._extensions_by_name.get(name, None) def _FindExtensionByNumber(self, number): """Tries to find a known extension with the field number. Args: number: Extension field number. Returns: Extension field descriptor. """ return self._extended_message._extensions_by_number.get(number, None) def __iter__(self): # Return a generator over the populated extension fields return (f[0] for f in self._extended_message.ListFields() if f[0].is_extension) def __contains__(self, extension_handle): _VerifyExtensionHandle(self._extended_message, extension_handle) if extension_handle not in self._extended_message._fields: return False if extension_handle.label == FieldDescriptor.LABEL_REPEATED: return bool(self._extended_message._fields.get(extension_handle)) if extension_handle.cpp_type == FieldDescriptor.CPPTYPE_MESSAGE: value = self._extended_message._fields.get(extension_handle) # pylint: disable=protected-access return value is not None and value._is_present_in_parent return True ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/message_listener.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Defines a listener interface for observing certain state transitions on Message objects. Also defines a null implementation of this interface. """ __author__ = 'robinson@google.com (Will Robinson)' class MessageListener(object): """Listens for modifications made to a message. Meant to be registered via Message._SetListener(). Attributes: dirty: If True, then calling Modified() would be a no-op. This can be used to avoid these calls entirely in the common case. """ def Modified(self): """Called every time the message is modified in such a way that the parent message may need to be updated. This currently means either: (a) The message was modified for the first time, so the parent message should henceforth mark the message as present. (b) The message's cached byte size became dirty -- i.e. the message was modified for the first time after a previous call to ByteSize(). Therefore the parent should also mark its byte size as dirty. Note that (a) implies (b), since new objects start out with a client cached size (zero). However, we document (a) explicitly because it is important. Modified() will *only* be called in response to one of these two events -- not every time the sub-message is modified. Note that if the listener's |dirty| attribute is true, then calling Modified at the moment would be a no-op, so it can be skipped. Performance- sensitive callers should check this attribute directly before calling since it will be true most of the time. """ raise NotImplementedError class NullMessageListener(object): """No-op MessageListener implementation.""" def Modified(self): pass ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/message_set_extensions_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/internal/message_set_extensions.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n5google/protobuf/internal/message_set_extensions.proto\x12\x18google.protobuf.internal\"\x1e\n\x0eTestMessageSet*\x08\x08\x04\x10\xff\xff\xff\xff\x07:\x02\x08\x01\"\xa5\x01\n\x18TestMessageSetExtension1\x12\t\n\x01i\x18\x0f \x01(\x05\x32~\n\x15message_set_extension\x12(.google.protobuf.internal.TestMessageSet\x18\xab\xff\xf6. \x01(\x0b\x32\x32.google.protobuf.internal.TestMessageSetExtension1\"\xa7\x01\n\x18TestMessageSetExtension2\x12\x0b\n\x03str\x18\x19 \x01(\t2~\n\x15message_set_extension\x12(.google.protobuf.internal.TestMessageSet\x18\xca\xff\xf6. \x01(\x0b\x32\x32.google.protobuf.internal.TestMessageSetExtension2\"(\n\x18TestMessageSetExtension3\x12\x0c\n\x04text\x18# \x01(\t:\x7f\n\x16message_set_extension3\x12(.google.protobuf.internal.TestMessageSet\x18\xdf\xff\xf6. \x01(\x0b\x32\x32.google.protobuf.internal.TestMessageSetExtension3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.internal.message_set_extensions_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: TestMessageSet.RegisterExtension(message_set_extension3) TestMessageSet.RegisterExtension(_TESTMESSAGESETEXTENSION1.extensions_by_name['message_set_extension']) TestMessageSet.RegisterExtension(_TESTMESSAGESETEXTENSION2.extensions_by_name['message_set_extension']) DESCRIPTOR._options = None _TESTMESSAGESET._options = None _TESTMESSAGESET._serialized_options = b'\010\001' _TESTMESSAGESET._serialized_start=83 _TESTMESSAGESET._serialized_end=113 _TESTMESSAGESETEXTENSION1._serialized_start=116 _TESTMESSAGESETEXTENSION1._serialized_end=281 _TESTMESSAGESETEXTENSION2._serialized_start=284 _TESTMESSAGESETEXTENSION2._serialized_end=451 _TESTMESSAGESETEXTENSION3._serialized_start=453 _TESTMESSAGESETEXTENSION3._serialized_end=493 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/missing_enum_values_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/internal/missing_enum_values.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n2google/protobuf/internal/missing_enum_values.proto\x12\x1fgoogle.protobuf.python.internal\"\xc1\x02\n\x0eTestEnumValues\x12X\n\x14optional_nested_enum\x18\x01 \x01(\x0e\x32:.google.protobuf.python.internal.TestEnumValues.NestedEnum\x12X\n\x14repeated_nested_enum\x18\x02 \x03(\x0e\x32:.google.protobuf.python.internal.TestEnumValues.NestedEnum\x12Z\n\x12packed_nested_enum\x18\x03 \x03(\x0e\x32:.google.protobuf.python.internal.TestEnumValues.NestedEnumB\x02\x10\x01\"\x1f\n\nNestedEnum\x12\x08\n\x04ZERO\x10\x00\x12\x07\n\x03ONE\x10\x01\"\xd3\x02\n\x15TestMissingEnumValues\x12_\n\x14optional_nested_enum\x18\x01 \x01(\x0e\x32\x41.google.protobuf.python.internal.TestMissingEnumValues.NestedEnum\x12_\n\x14repeated_nested_enum\x18\x02 \x03(\x0e\x32\x41.google.protobuf.python.internal.TestMissingEnumValues.NestedEnum\x12\x61\n\x12packed_nested_enum\x18\x03 \x03(\x0e\x32\x41.google.protobuf.python.internal.TestMissingEnumValues.NestedEnumB\x02\x10\x01\"\x15\n\nNestedEnum\x12\x07\n\x03TWO\x10\x02\"\x1b\n\nJustString\x12\r\n\x05\x64ummy\x18\x01 \x02(\t') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.internal.missing_enum_values_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None _TESTENUMVALUES.fields_by_name['packed_nested_enum']._options = None _TESTENUMVALUES.fields_by_name['packed_nested_enum']._serialized_options = b'\020\001' _TESTMISSINGENUMVALUES.fields_by_name['packed_nested_enum']._options = None _TESTMISSINGENUMVALUES.fields_by_name['packed_nested_enum']._serialized_options = b'\020\001' _TESTENUMVALUES._serialized_start=88 _TESTENUMVALUES._serialized_end=409 _TESTENUMVALUES_NESTEDENUM._serialized_start=378 _TESTENUMVALUES_NESTEDENUM._serialized_end=409 _TESTMISSINGENUMVALUES._serialized_start=412 _TESTMISSINGENUMVALUES._serialized_end=751 _TESTMISSINGENUMVALUES_NESTEDENUM._serialized_start=730 _TESTMISSINGENUMVALUES_NESTEDENUM._serialized_end=751 _JUSTSTRING._serialized_start=753 _JUSTSTRING._serialized_end=780 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/more_extensions_dynamic_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/internal/more_extensions_dynamic.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() from google.protobuf.internal import more_extensions_pb2 as google_dot_protobuf_dot_internal_dot_more__extensions__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n6google/protobuf/internal/more_extensions_dynamic.proto\x12\x18google.protobuf.internal\x1a.google/protobuf/internal/more_extensions.proto\"\x1f\n\x12\x44ynamicMessageType\x12\t\n\x01\x61\x18\x01 \x01(\x05:J\n\x17\x64ynamic_int32_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x64 \x01(\x05:z\n\x19\x64ynamic_message_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x65 \x01(\x0b\x32,.google.protobuf.internal.DynamicMessageType:\x83\x01\n\"repeated_dynamic_message_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x66 \x03(\x0b\x32,.google.protobuf.internal.DynamicMessageType') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.internal.more_extensions_dynamic_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: google_dot_protobuf_dot_internal_dot_more__extensions__pb2.ExtendedMessage.RegisterExtension(dynamic_int32_extension) google_dot_protobuf_dot_internal_dot_more__extensions__pb2.ExtendedMessage.RegisterExtension(dynamic_message_extension) google_dot_protobuf_dot_internal_dot_more__extensions__pb2.ExtendedMessage.RegisterExtension(repeated_dynamic_message_extension) DESCRIPTOR._options = None _DYNAMICMESSAGETYPE._serialized_start=132 _DYNAMICMESSAGETYPE._serialized_end=163 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/more_extensions_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/internal/more_extensions.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n.google/protobuf/internal/more_extensions.proto\x12\x18google.protobuf.internal\"\x99\x01\n\x0fTopLevelMessage\x12\x41\n\nsubmessage\x18\x01 \x01(\x0b\x32).google.protobuf.internal.ExtendedMessageB\x02(\x01\x12\x43\n\x0enested_message\x18\x02 \x01(\x0b\x32\'.google.protobuf.internal.NestedMessageB\x02(\x01\"R\n\rNestedMessage\x12\x41\n\nsubmessage\x18\x01 \x01(\x0b\x32).google.protobuf.internal.ExtendedMessageB\x02(\x01\"K\n\x0f\x45xtendedMessage\x12\x17\n\x0eoptional_int32\x18\xe9\x07 \x01(\x05\x12\x18\n\x0frepeated_string\x18\xea\x07 \x03(\t*\x05\x08\x01\x10\xe8\x07\"-\n\x0e\x46oreignMessage\x12\x1b\n\x13\x66oreign_message_int\x18\x01 \x01(\x05:I\n\x16optional_int_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x01 \x01(\x05:w\n\x1aoptional_message_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x02 \x01(\x0b\x32(.google.protobuf.internal.ForeignMessage:I\n\x16repeated_int_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x03 \x03(\x05:w\n\x1arepeated_message_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x04 \x03(\x0b\x32(.google.protobuf.internal.ForeignMessage') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.internal.more_extensions_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: ExtendedMessage.RegisterExtension(optional_int_extension) ExtendedMessage.RegisterExtension(optional_message_extension) ExtendedMessage.RegisterExtension(repeated_int_extension) ExtendedMessage.RegisterExtension(repeated_message_extension) DESCRIPTOR._options = None _TOPLEVELMESSAGE.fields_by_name['submessage']._options = None _TOPLEVELMESSAGE.fields_by_name['submessage']._serialized_options = b'(\001' _TOPLEVELMESSAGE.fields_by_name['nested_message']._options = None _TOPLEVELMESSAGE.fields_by_name['nested_message']._serialized_options = b'(\001' _NESTEDMESSAGE.fields_by_name['submessage']._options = None _NESTEDMESSAGE.fields_by_name['submessage']._serialized_options = b'(\001' _TOPLEVELMESSAGE._serialized_start=77 _TOPLEVELMESSAGE._serialized_end=230 _NESTEDMESSAGE._serialized_start=232 _NESTEDMESSAGE._serialized_end=314 _EXTENDEDMESSAGE._serialized_start=316 _EXTENDEDMESSAGE._serialized_end=391 _FOREIGNMESSAGE._serialized_start=393 _FOREIGNMESSAGE._serialized_end=438 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/more_messages_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/internal/more_messages.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n,google/protobuf/internal/more_messages.proto\x12\x18google.protobuf.internal\"h\n\x10OutOfOrderFields\x12\x17\n\x0foptional_sint32\x18\x05 \x01(\x11\x12\x17\n\x0foptional_uint32\x18\x03 \x01(\r\x12\x16\n\x0eoptional_int32\x18\x01 \x01(\x05*\x04\x08\x04\x10\x05*\x04\x08\x02\x10\x03\"\xcd\x02\n\x05\x63lass\x12\x1b\n\tint_field\x18\x01 \x01(\x05R\x08json_int\x12\n\n\x02if\x18\x02 \x01(\x05\x12(\n\x02\x61s\x18\x03 \x01(\x0e\x32\x1c.google.protobuf.internal.is\x12\x30\n\nenum_field\x18\x04 \x01(\x0e\x32\x1c.google.protobuf.internal.is\x12>\n\x11nested_enum_field\x18\x05 \x01(\x0e\x32#.google.protobuf.internal.class.for\x12;\n\x0enested_message\x18\x06 \x01(\x0b\x32#.google.protobuf.internal.class.try\x1a\x1c\n\x03try\x12\r\n\x05\x66ield\x18\x01 \x01(\x05*\x06\x08\xe7\x07\x10\x90N\"\x1c\n\x03\x66or\x12\x0b\n\x07\x64\x65\x66\x61ult\x10\x00\x12\x08\n\x04True\x10\x01*\x06\x08\xe7\x07\x10\x90N\"?\n\x0b\x45xtendClass20\n\x06return\x12\x1f.google.protobuf.internal.class\x18\xea\x07 \x01(\x05\"~\n\x0fTestFullKeyword\x12:\n\x06\x66ield1\x18\x01 \x01(\x0b\x32*.google.protobuf.internal.OutOfOrderFields\x12/\n\x06\x66ield2\x18\x02 \x01(\x0b\x32\x1f.google.protobuf.internal.class\"\xa5\x0f\n\x11LotsNestedMessage\x1a\x04\n\x02\x42\x30\x1a\x04\n\x02\x42\x31\x1a\x04\n\x02\x42\x32\x1a\x04\n\x02\x42\x33\x1a\x04\n\x02\x42\x34\x1a\x04\n\x02\x42\x35\x1a\x04\n\x02\x42\x36\x1a\x04\n\x02\x42\x37\x1a\x04\n\x02\x42\x38\x1a\x04\n\x02\x42\x39\x1a\x05\n\x03\x42\x31\x30\x1a\x05\n\x03\x42\x31\x31\x1a\x05\n\x03\x42\x31\x32\x1a\x05\n\x03\x42\x31\x33\x1a\x05\n\x03\x42\x31\x34\x1a\x05\n\x03\x42\x31\x35\x1a\x05\n\x03\x42\x31\x36\x1a\x05\n\x03\x42\x31\x37\x1a\x05\n\x03\x42\x31\x38\x1a\x05\n\x03\x42\x31\x39\x1a\x05\n\x03\x42\x32\x30\x1a\x05\n\x03\x42\x32\x31\x1a\x05\n\x03\x42\x32\x32\x1a\x05\n\x03\x42\x32\x33\x1a\x05\n\x03\x42\x32\x34\x1a\x05\n\x03\x42\x32\x35\x1a\x05\n\x03\x42\x32\x36\x1a\x05\n\x03\x42\x32\x37\x1a\x05\n\x03\x42\x32\x38\x1a\x05\n\x03\x42\x32\x39\x1a\x05\n\x03\x42\x33\x30\x1a\x05\n\x03\x42\x33\x31\x1a\x05\n\x03\x42\x33\x32\x1a\x05\n\x03\x42\x33\x33\x1a\x05\n\x03\x42\x33\x34\x1a\x05\n\x03\x42\x33\x35\x1a\x05\n\x03\x42\x33\x36\x1a\x05\n\x03\x42\x33\x37\x1a\x05\n\x03\x42\x33\x38\x1a\x05\n\x03\x42\x33\x39\x1a\x05\n\x03\x42\x34\x30\x1a\x05\n\x03\x42\x34\x31\x1a\x05\n\x03\x42\x34\x32\x1a\x05\n\x03\x42\x34\x33\x1a\x05\n\x03\x42\x34\x34\x1a\x05\n\x03\x42\x34\x35\x1a\x05\n\x03\x42\x34\x36\x1a\x05\n\x03\x42\x34\x37\x1a\x05\n\x03\x42\x34\x38\x1a\x05\n\x03\x42\x34\x39\x1a\x05\n\x03\x42\x35\x30\x1a\x05\n\x03\x42\x35\x31\x1a\x05\n\x03\x42\x35\x32\x1a\x05\n\x03\x42\x35\x33\x1a\x05\n\x03\x42\x35\x34\x1a\x05\n\x03\x42\x35\x35\x1a\x05\n\x03\x42\x35\x36\x1a\x05\n\x03\x42\x35\x37\x1a\x05\n\x03\x42\x35\x38\x1a\x05\n\x03\x42\x35\x39\x1a\x05\n\x03\x42\x36\x30\x1a\x05\n\x03\x42\x36\x31\x1a\x05\n\x03\x42\x36\x32\x1a\x05\n\x03\x42\x36\x33\x1a\x05\n\x03\x42\x36\x34\x1a\x05\n\x03\x42\x36\x35\x1a\x05\n\x03\x42\x36\x36\x1a\x05\n\x03\x42\x36\x37\x1a\x05\n\x03\x42\x36\x38\x1a\x05\n\x03\x42\x36\x39\x1a\x05\n\x03\x42\x37\x30\x1a\x05\n\x03\x42\x37\x31\x1a\x05\n\x03\x42\x37\x32\x1a\x05\n\x03\x42\x37\x33\x1a\x05\n\x03\x42\x37\x34\x1a\x05\n\x03\x42\x37\x35\x1a\x05\n\x03\x42\x37\x36\x1a\x05\n\x03\x42\x37\x37\x1a\x05\n\x03\x42\x37\x38\x1a\x05\n\x03\x42\x37\x39\x1a\x05\n\x03\x42\x38\x30\x1a\x05\n\x03\x42\x38\x31\x1a\x05\n\x03\x42\x38\x32\x1a\x05\n\x03\x42\x38\x33\x1a\x05\n\x03\x42\x38\x34\x1a\x05\n\x03\x42\x38\x35\x1a\x05\n\x03\x42\x38\x36\x1a\x05\n\x03\x42\x38\x37\x1a\x05\n\x03\x42\x38\x38\x1a\x05\n\x03\x42\x38\x39\x1a\x05\n\x03\x42\x39\x30\x1a\x05\n\x03\x42\x39\x31\x1a\x05\n\x03\x42\x39\x32\x1a\x05\n\x03\x42\x39\x33\x1a\x05\n\x03\x42\x39\x34\x1a\x05\n\x03\x42\x39\x35\x1a\x05\n\x03\x42\x39\x36\x1a\x05\n\x03\x42\x39\x37\x1a\x05\n\x03\x42\x39\x38\x1a\x05\n\x03\x42\x39\x39\x1a\x06\n\x04\x42\x31\x30\x30\x1a\x06\n\x04\x42\x31\x30\x31\x1a\x06\n\x04\x42\x31\x30\x32\x1a\x06\n\x04\x42\x31\x30\x33\x1a\x06\n\x04\x42\x31\x30\x34\x1a\x06\n\x04\x42\x31\x30\x35\x1a\x06\n\x04\x42\x31\x30\x36\x1a\x06\n\x04\x42\x31\x30\x37\x1a\x06\n\x04\x42\x31\x30\x38\x1a\x06\n\x04\x42\x31\x30\x39\x1a\x06\n\x04\x42\x31\x31\x30\x1a\x06\n\x04\x42\x31\x31\x31\x1a\x06\n\x04\x42\x31\x31\x32\x1a\x06\n\x04\x42\x31\x31\x33\x1a\x06\n\x04\x42\x31\x31\x34\x1a\x06\n\x04\x42\x31\x31\x35\x1a\x06\n\x04\x42\x31\x31\x36\x1a\x06\n\x04\x42\x31\x31\x37\x1a\x06\n\x04\x42\x31\x31\x38\x1a\x06\n\x04\x42\x31\x31\x39\x1a\x06\n\x04\x42\x31\x32\x30\x1a\x06\n\x04\x42\x31\x32\x31\x1a\x06\n\x04\x42\x31\x32\x32\x1a\x06\n\x04\x42\x31\x32\x33\x1a\x06\n\x04\x42\x31\x32\x34\x1a\x06\n\x04\x42\x31\x32\x35\x1a\x06\n\x04\x42\x31\x32\x36\x1a\x06\n\x04\x42\x31\x32\x37\x1a\x06\n\x04\x42\x31\x32\x38\x1a\x06\n\x04\x42\x31\x32\x39\x1a\x06\n\x04\x42\x31\x33\x30\x1a\x06\n\x04\x42\x31\x33\x31\x1a\x06\n\x04\x42\x31\x33\x32\x1a\x06\n\x04\x42\x31\x33\x33\x1a\x06\n\x04\x42\x31\x33\x34\x1a\x06\n\x04\x42\x31\x33\x35\x1a\x06\n\x04\x42\x31\x33\x36\x1a\x06\n\x04\x42\x31\x33\x37\x1a\x06\n\x04\x42\x31\x33\x38\x1a\x06\n\x04\x42\x31\x33\x39\x1a\x06\n\x04\x42\x31\x34\x30\x1a\x06\n\x04\x42\x31\x34\x31\x1a\x06\n\x04\x42\x31\x34\x32\x1a\x06\n\x04\x42\x31\x34\x33\x1a\x06\n\x04\x42\x31\x34\x34\x1a\x06\n\x04\x42\x31\x34\x35\x1a\x06\n\x04\x42\x31\x34\x36\x1a\x06\n\x04\x42\x31\x34\x37\x1a\x06\n\x04\x42\x31\x34\x38\x1a\x06\n\x04\x42\x31\x34\x39\x1a\x06\n\x04\x42\x31\x35\x30\x1a\x06\n\x04\x42\x31\x35\x31\x1a\x06\n\x04\x42\x31\x35\x32\x1a\x06\n\x04\x42\x31\x35\x33\x1a\x06\n\x04\x42\x31\x35\x34\x1a\x06\n\x04\x42\x31\x35\x35\x1a\x06\n\x04\x42\x31\x35\x36\x1a\x06\n\x04\x42\x31\x35\x37\x1a\x06\n\x04\x42\x31\x35\x38\x1a\x06\n\x04\x42\x31\x35\x39\x1a\x06\n\x04\x42\x31\x36\x30\x1a\x06\n\x04\x42\x31\x36\x31\x1a\x06\n\x04\x42\x31\x36\x32\x1a\x06\n\x04\x42\x31\x36\x33\x1a\x06\n\x04\x42\x31\x36\x34\x1a\x06\n\x04\x42\x31\x36\x35\x1a\x06\n\x04\x42\x31\x36\x36\x1a\x06\n\x04\x42\x31\x36\x37\x1a\x06\n\x04\x42\x31\x36\x38\x1a\x06\n\x04\x42\x31\x36\x39\x1a\x06\n\x04\x42\x31\x37\x30\x1a\x06\n\x04\x42\x31\x37\x31\x1a\x06\n\x04\x42\x31\x37\x32\x1a\x06\n\x04\x42\x31\x37\x33\x1a\x06\n\x04\x42\x31\x37\x34\x1a\x06\n\x04\x42\x31\x37\x35\x1a\x06\n\x04\x42\x31\x37\x36\x1a\x06\n\x04\x42\x31\x37\x37\x1a\x06\n\x04\x42\x31\x37\x38\x1a\x06\n\x04\x42\x31\x37\x39\x1a\x06\n\x04\x42\x31\x38\x30\x1a\x06\n\x04\x42\x31\x38\x31\x1a\x06\n\x04\x42\x31\x38\x32\x1a\x06\n\x04\x42\x31\x38\x33\x1a\x06\n\x04\x42\x31\x38\x34\x1a\x06\n\x04\x42\x31\x38\x35\x1a\x06\n\x04\x42\x31\x38\x36\x1a\x06\n\x04\x42\x31\x38\x37\x1a\x06\n\x04\x42\x31\x38\x38\x1a\x06\n\x04\x42\x31\x38\x39\x1a\x06\n\x04\x42\x31\x39\x30\x1a\x06\n\x04\x42\x31\x39\x31\x1a\x06\n\x04\x42\x31\x39\x32\x1a\x06\n\x04\x42\x31\x39\x33\x1a\x06\n\x04\x42\x31\x39\x34\x1a\x06\n\x04\x42\x31\x39\x35\x1a\x06\n\x04\x42\x31\x39\x36\x1a\x06\n\x04\x42\x31\x39\x37\x1a\x06\n\x04\x42\x31\x39\x38\x1a\x06\n\x04\x42\x31\x39\x39\x1a\x06\n\x04\x42\x32\x30\x30\x1a\x06\n\x04\x42\x32\x30\x31\x1a\x06\n\x04\x42\x32\x30\x32\x1a\x06\n\x04\x42\x32\x30\x33\x1a\x06\n\x04\x42\x32\x30\x34\x1a\x06\n\x04\x42\x32\x30\x35\x1a\x06\n\x04\x42\x32\x30\x36\x1a\x06\n\x04\x42\x32\x30\x37\x1a\x06\n\x04\x42\x32\x30\x38\x1a\x06\n\x04\x42\x32\x30\x39\x1a\x06\n\x04\x42\x32\x31\x30\x1a\x06\n\x04\x42\x32\x31\x31\x1a\x06\n\x04\x42\x32\x31\x32\x1a\x06\n\x04\x42\x32\x31\x33\x1a\x06\n\x04\x42\x32\x31\x34\x1a\x06\n\x04\x42\x32\x31\x35\x1a\x06\n\x04\x42\x32\x31\x36\x1a\x06\n\x04\x42\x32\x31\x37\x1a\x06\n\x04\x42\x32\x31\x38\x1a\x06\n\x04\x42\x32\x31\x39\x1a\x06\n\x04\x42\x32\x32\x30\x1a\x06\n\x04\x42\x32\x32\x31\x1a\x06\n\x04\x42\x32\x32\x32\x1a\x06\n\x04\x42\x32\x32\x33\x1a\x06\n\x04\x42\x32\x32\x34\x1a\x06\n\x04\x42\x32\x32\x35\x1a\x06\n\x04\x42\x32\x32\x36\x1a\x06\n\x04\x42\x32\x32\x37\x1a\x06\n\x04\x42\x32\x32\x38\x1a\x06\n\x04\x42\x32\x32\x39\x1a\x06\n\x04\x42\x32\x33\x30\x1a\x06\n\x04\x42\x32\x33\x31\x1a\x06\n\x04\x42\x32\x33\x32\x1a\x06\n\x04\x42\x32\x33\x33\x1a\x06\n\x04\x42\x32\x33\x34\x1a\x06\n\x04\x42\x32\x33\x35\x1a\x06\n\x04\x42\x32\x33\x36\x1a\x06\n\x04\x42\x32\x33\x37\x1a\x06\n\x04\x42\x32\x33\x38\x1a\x06\n\x04\x42\x32\x33\x39\x1a\x06\n\x04\x42\x32\x34\x30\x1a\x06\n\x04\x42\x32\x34\x31\x1a\x06\n\x04\x42\x32\x34\x32\x1a\x06\n\x04\x42\x32\x34\x33\x1a\x06\n\x04\x42\x32\x34\x34\x1a\x06\n\x04\x42\x32\x34\x35\x1a\x06\n\x04\x42\x32\x34\x36\x1a\x06\n\x04\x42\x32\x34\x37\x1a\x06\n\x04\x42\x32\x34\x38\x1a\x06\n\x04\x42\x32\x34\x39\x1a\x06\n\x04\x42\x32\x35\x30\x1a\x06\n\x04\x42\x32\x35\x31\x1a\x06\n\x04\x42\x32\x35\x32\x1a\x06\n\x04\x42\x32\x35\x33\x1a\x06\n\x04\x42\x32\x35\x34\x1a\x06\n\x04\x42\x32\x35\x35*\x1b\n\x02is\x12\x0b\n\x07\x64\x65\x66\x61ult\x10\x00\x12\x08\n\x04\x65lse\x10\x01:C\n\x0foptional_uint64\x12*.google.protobuf.internal.OutOfOrderFields\x18\x04 \x01(\x04:B\n\x0eoptional_int64\x12*.google.protobuf.internal.OutOfOrderFields\x18\x02 \x01(\x03:2\n\x08\x63ontinue\x12\x1f.google.protobuf.internal.class\x18\xe9\x07 \x01(\x05:2\n\x04with\x12#.google.protobuf.internal.class.try\x18\xe9\x07 \x01(\x05') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.internal.more_messages_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: OutOfOrderFields.RegisterExtension(optional_uint64) OutOfOrderFields.RegisterExtension(optional_int64) globals()['class'].RegisterExtension(globals()['continue']) getattr(globals()['class'], 'try').RegisterExtension(globals()['with']) globals()['class'].RegisterExtension(_EXTENDCLASS.extensions_by_name['return']) DESCRIPTOR._options = None _IS._serialized_start=2669 _IS._serialized_end=2696 _OUTOFORDERFIELDS._serialized_start=74 _OUTOFORDERFIELDS._serialized_end=178 _CLASS._serialized_start=181 _CLASS._serialized_end=514 _CLASS_TRY._serialized_start=448 _CLASS_TRY._serialized_end=476 _CLASS_FOR._serialized_start=478 _CLASS_FOR._serialized_end=506 _EXTENDCLASS._serialized_start=516 _EXTENDCLASS._serialized_end=579 _TESTFULLKEYWORD._serialized_start=581 _TESTFULLKEYWORD._serialized_end=707 _LOTSNESTEDMESSAGE._serialized_start=710 _LOTSNESTEDMESSAGE._serialized_end=2667 _LOTSNESTEDMESSAGE_B0._serialized_start=731 _LOTSNESTEDMESSAGE_B0._serialized_end=735 _LOTSNESTEDMESSAGE_B1._serialized_start=737 _LOTSNESTEDMESSAGE_B1._serialized_end=741 _LOTSNESTEDMESSAGE_B2._serialized_start=743 _LOTSNESTEDMESSAGE_B2._serialized_end=747 _LOTSNESTEDMESSAGE_B3._serialized_start=749 _LOTSNESTEDMESSAGE_B3._serialized_end=753 _LOTSNESTEDMESSAGE_B4._serialized_start=755 _LOTSNESTEDMESSAGE_B4._serialized_end=759 _LOTSNESTEDMESSAGE_B5._serialized_start=761 _LOTSNESTEDMESSAGE_B5._serialized_end=765 _LOTSNESTEDMESSAGE_B6._serialized_start=767 _LOTSNESTEDMESSAGE_B6._serialized_end=771 _LOTSNESTEDMESSAGE_B7._serialized_start=773 _LOTSNESTEDMESSAGE_B7._serialized_end=777 _LOTSNESTEDMESSAGE_B8._serialized_start=779 _LOTSNESTEDMESSAGE_B8._serialized_end=783 _LOTSNESTEDMESSAGE_B9._serialized_start=785 _LOTSNESTEDMESSAGE_B9._serialized_end=789 _LOTSNESTEDMESSAGE_B10._serialized_start=791 _LOTSNESTEDMESSAGE_B10._serialized_end=796 _LOTSNESTEDMESSAGE_B11._serialized_start=798 _LOTSNESTEDMESSAGE_B11._serialized_end=803 _LOTSNESTEDMESSAGE_B12._serialized_start=805 _LOTSNESTEDMESSAGE_B12._serialized_end=810 _LOTSNESTEDMESSAGE_B13._serialized_start=812 _LOTSNESTEDMESSAGE_B13._serialized_end=817 _LOTSNESTEDMESSAGE_B14._serialized_start=819 _LOTSNESTEDMESSAGE_B14._serialized_end=824 _LOTSNESTEDMESSAGE_B15._serialized_start=826 _LOTSNESTEDMESSAGE_B15._serialized_end=831 _LOTSNESTEDMESSAGE_B16._serialized_start=833 _LOTSNESTEDMESSAGE_B16._serialized_end=838 _LOTSNESTEDMESSAGE_B17._serialized_start=840 _LOTSNESTEDMESSAGE_B17._serialized_end=845 _LOTSNESTEDMESSAGE_B18._serialized_start=847 _LOTSNESTEDMESSAGE_B18._serialized_end=852 _LOTSNESTEDMESSAGE_B19._serialized_start=854 _LOTSNESTEDMESSAGE_B19._serialized_end=859 _LOTSNESTEDMESSAGE_B20._serialized_start=861 _LOTSNESTEDMESSAGE_B20._serialized_end=866 _LOTSNESTEDMESSAGE_B21._serialized_start=868 _LOTSNESTEDMESSAGE_B21._serialized_end=873 _LOTSNESTEDMESSAGE_B22._serialized_start=875 _LOTSNESTEDMESSAGE_B22._serialized_end=880 _LOTSNESTEDMESSAGE_B23._serialized_start=882 _LOTSNESTEDMESSAGE_B23._serialized_end=887 _LOTSNESTEDMESSAGE_B24._serialized_start=889 _LOTSNESTEDMESSAGE_B24._serialized_end=894 _LOTSNESTEDMESSAGE_B25._serialized_start=896 _LOTSNESTEDMESSAGE_B25._serialized_end=901 _LOTSNESTEDMESSAGE_B26._serialized_start=903 _LOTSNESTEDMESSAGE_B26._serialized_end=908 _LOTSNESTEDMESSAGE_B27._serialized_start=910 _LOTSNESTEDMESSAGE_B27._serialized_end=915 _LOTSNESTEDMESSAGE_B28._serialized_start=917 _LOTSNESTEDMESSAGE_B28._serialized_end=922 _LOTSNESTEDMESSAGE_B29._serialized_start=924 _LOTSNESTEDMESSAGE_B29._serialized_end=929 _LOTSNESTEDMESSAGE_B30._serialized_start=931 _LOTSNESTEDMESSAGE_B30._serialized_end=936 _LOTSNESTEDMESSAGE_B31._serialized_start=938 _LOTSNESTEDMESSAGE_B31._serialized_end=943 _LOTSNESTEDMESSAGE_B32._serialized_start=945 _LOTSNESTEDMESSAGE_B32._serialized_end=950 _LOTSNESTEDMESSAGE_B33._serialized_start=952 _LOTSNESTEDMESSAGE_B33._serialized_end=957 _LOTSNESTEDMESSAGE_B34._serialized_start=959 _LOTSNESTEDMESSAGE_B34._serialized_end=964 _LOTSNESTEDMESSAGE_B35._serialized_start=966 _LOTSNESTEDMESSAGE_B35._serialized_end=971 _LOTSNESTEDMESSAGE_B36._serialized_start=973 _LOTSNESTEDMESSAGE_B36._serialized_end=978 _LOTSNESTEDMESSAGE_B37._serialized_start=980 _LOTSNESTEDMESSAGE_B37._serialized_end=985 _LOTSNESTEDMESSAGE_B38._serialized_start=987 _LOTSNESTEDMESSAGE_B38._serialized_end=992 _LOTSNESTEDMESSAGE_B39._serialized_start=994 _LOTSNESTEDMESSAGE_B39._serialized_end=999 _LOTSNESTEDMESSAGE_B40._serialized_start=1001 _LOTSNESTEDMESSAGE_B40._serialized_end=1006 _LOTSNESTEDMESSAGE_B41._serialized_start=1008 _LOTSNESTEDMESSAGE_B41._serialized_end=1013 _LOTSNESTEDMESSAGE_B42._serialized_start=1015 _LOTSNESTEDMESSAGE_B42._serialized_end=1020 _LOTSNESTEDMESSAGE_B43._serialized_start=1022 _LOTSNESTEDMESSAGE_B43._serialized_end=1027 _LOTSNESTEDMESSAGE_B44._serialized_start=1029 _LOTSNESTEDMESSAGE_B44._serialized_end=1034 _LOTSNESTEDMESSAGE_B45._serialized_start=1036 _LOTSNESTEDMESSAGE_B45._serialized_end=1041 _LOTSNESTEDMESSAGE_B46._serialized_start=1043 _LOTSNESTEDMESSAGE_B46._serialized_end=1048 _LOTSNESTEDMESSAGE_B47._serialized_start=1050 _LOTSNESTEDMESSAGE_B47._serialized_end=1055 _LOTSNESTEDMESSAGE_B48._serialized_start=1057 _LOTSNESTEDMESSAGE_B48._serialized_end=1062 _LOTSNESTEDMESSAGE_B49._serialized_start=1064 _LOTSNESTEDMESSAGE_B49._serialized_end=1069 _LOTSNESTEDMESSAGE_B50._serialized_start=1071 _LOTSNESTEDMESSAGE_B50._serialized_end=1076 _LOTSNESTEDMESSAGE_B51._serialized_start=1078 _LOTSNESTEDMESSAGE_B51._serialized_end=1083 _LOTSNESTEDMESSAGE_B52._serialized_start=1085 _LOTSNESTEDMESSAGE_B52._serialized_end=1090 _LOTSNESTEDMESSAGE_B53._serialized_start=1092 _LOTSNESTEDMESSAGE_B53._serialized_end=1097 _LOTSNESTEDMESSAGE_B54._serialized_start=1099 _LOTSNESTEDMESSAGE_B54._serialized_end=1104 _LOTSNESTEDMESSAGE_B55._serialized_start=1106 _LOTSNESTEDMESSAGE_B55._serialized_end=1111 _LOTSNESTEDMESSAGE_B56._serialized_start=1113 _LOTSNESTEDMESSAGE_B56._serialized_end=1118 _LOTSNESTEDMESSAGE_B57._serialized_start=1120 _LOTSNESTEDMESSAGE_B57._serialized_end=1125 _LOTSNESTEDMESSAGE_B58._serialized_start=1127 _LOTSNESTEDMESSAGE_B58._serialized_end=1132 _LOTSNESTEDMESSAGE_B59._serialized_start=1134 _LOTSNESTEDMESSAGE_B59._serialized_end=1139 _LOTSNESTEDMESSAGE_B60._serialized_start=1141 _LOTSNESTEDMESSAGE_B60._serialized_end=1146 _LOTSNESTEDMESSAGE_B61._serialized_start=1148 _LOTSNESTEDMESSAGE_B61._serialized_end=1153 _LOTSNESTEDMESSAGE_B62._serialized_start=1155 _LOTSNESTEDMESSAGE_B62._serialized_end=1160 _LOTSNESTEDMESSAGE_B63._serialized_start=1162 _LOTSNESTEDMESSAGE_B63._serialized_end=1167 _LOTSNESTEDMESSAGE_B64._serialized_start=1169 _LOTSNESTEDMESSAGE_B64._serialized_end=1174 _LOTSNESTEDMESSAGE_B65._serialized_start=1176 _LOTSNESTEDMESSAGE_B65._serialized_end=1181 _LOTSNESTEDMESSAGE_B66._serialized_start=1183 _LOTSNESTEDMESSAGE_B66._serialized_end=1188 _LOTSNESTEDMESSAGE_B67._serialized_start=1190 _LOTSNESTEDMESSAGE_B67._serialized_end=1195 _LOTSNESTEDMESSAGE_B68._serialized_start=1197 _LOTSNESTEDMESSAGE_B68._serialized_end=1202 _LOTSNESTEDMESSAGE_B69._serialized_start=1204 _LOTSNESTEDMESSAGE_B69._serialized_end=1209 _LOTSNESTEDMESSAGE_B70._serialized_start=1211 _LOTSNESTEDMESSAGE_B70._serialized_end=1216 _LOTSNESTEDMESSAGE_B71._serialized_start=1218 _LOTSNESTEDMESSAGE_B71._serialized_end=1223 _LOTSNESTEDMESSAGE_B72._serialized_start=1225 _LOTSNESTEDMESSAGE_B72._serialized_end=1230 _LOTSNESTEDMESSAGE_B73._serialized_start=1232 _LOTSNESTEDMESSAGE_B73._serialized_end=1237 _LOTSNESTEDMESSAGE_B74._serialized_start=1239 _LOTSNESTEDMESSAGE_B74._serialized_end=1244 _LOTSNESTEDMESSAGE_B75._serialized_start=1246 _LOTSNESTEDMESSAGE_B75._serialized_end=1251 _LOTSNESTEDMESSAGE_B76._serialized_start=1253 _LOTSNESTEDMESSAGE_B76._serialized_end=1258 _LOTSNESTEDMESSAGE_B77._serialized_start=1260 _LOTSNESTEDMESSAGE_B77._serialized_end=1265 _LOTSNESTEDMESSAGE_B78._serialized_start=1267 _LOTSNESTEDMESSAGE_B78._serialized_end=1272 _LOTSNESTEDMESSAGE_B79._serialized_start=1274 _LOTSNESTEDMESSAGE_B79._serialized_end=1279 _LOTSNESTEDMESSAGE_B80._serialized_start=1281 _LOTSNESTEDMESSAGE_B80._serialized_end=1286 _LOTSNESTEDMESSAGE_B81._serialized_start=1288 _LOTSNESTEDMESSAGE_B81._serialized_end=1293 _LOTSNESTEDMESSAGE_B82._serialized_start=1295 _LOTSNESTEDMESSAGE_B82._serialized_end=1300 _LOTSNESTEDMESSAGE_B83._serialized_start=1302 _LOTSNESTEDMESSAGE_B83._serialized_end=1307 _LOTSNESTEDMESSAGE_B84._serialized_start=1309 _LOTSNESTEDMESSAGE_B84._serialized_end=1314 _LOTSNESTEDMESSAGE_B85._serialized_start=1316 _LOTSNESTEDMESSAGE_B85._serialized_end=1321 _LOTSNESTEDMESSAGE_B86._serialized_start=1323 _LOTSNESTEDMESSAGE_B86._serialized_end=1328 _LOTSNESTEDMESSAGE_B87._serialized_start=1330 _LOTSNESTEDMESSAGE_B87._serialized_end=1335 _LOTSNESTEDMESSAGE_B88._serialized_start=1337 _LOTSNESTEDMESSAGE_B88._serialized_end=1342 _LOTSNESTEDMESSAGE_B89._serialized_start=1344 _LOTSNESTEDMESSAGE_B89._serialized_end=1349 _LOTSNESTEDMESSAGE_B90._serialized_start=1351 _LOTSNESTEDMESSAGE_B90._serialized_end=1356 _LOTSNESTEDMESSAGE_B91._serialized_start=1358 _LOTSNESTEDMESSAGE_B91._serialized_end=1363 _LOTSNESTEDMESSAGE_B92._serialized_start=1365 _LOTSNESTEDMESSAGE_B92._serialized_end=1370 _LOTSNESTEDMESSAGE_B93._serialized_start=1372 _LOTSNESTEDMESSAGE_B93._serialized_end=1377 _LOTSNESTEDMESSAGE_B94._serialized_start=1379 _LOTSNESTEDMESSAGE_B94._serialized_end=1384 _LOTSNESTEDMESSAGE_B95._serialized_start=1386 _LOTSNESTEDMESSAGE_B95._serialized_end=1391 _LOTSNESTEDMESSAGE_B96._serialized_start=1393 _LOTSNESTEDMESSAGE_B96._serialized_end=1398 _LOTSNESTEDMESSAGE_B97._serialized_start=1400 _LOTSNESTEDMESSAGE_B97._serialized_end=1405 _LOTSNESTEDMESSAGE_B98._serialized_start=1407 _LOTSNESTEDMESSAGE_B98._serialized_end=1412 _LOTSNESTEDMESSAGE_B99._serialized_start=1414 _LOTSNESTEDMESSAGE_B99._serialized_end=1419 _LOTSNESTEDMESSAGE_B100._serialized_start=1421 _LOTSNESTEDMESSAGE_B100._serialized_end=1427 _LOTSNESTEDMESSAGE_B101._serialized_start=1429 _LOTSNESTEDMESSAGE_B101._serialized_end=1435 _LOTSNESTEDMESSAGE_B102._serialized_start=1437 _LOTSNESTEDMESSAGE_B102._serialized_end=1443 _LOTSNESTEDMESSAGE_B103._serialized_start=1445 _LOTSNESTEDMESSAGE_B103._serialized_end=1451 _LOTSNESTEDMESSAGE_B104._serialized_start=1453 _LOTSNESTEDMESSAGE_B104._serialized_end=1459 _LOTSNESTEDMESSAGE_B105._serialized_start=1461 _LOTSNESTEDMESSAGE_B105._serialized_end=1467 _LOTSNESTEDMESSAGE_B106._serialized_start=1469 _LOTSNESTEDMESSAGE_B106._serialized_end=1475 _LOTSNESTEDMESSAGE_B107._serialized_start=1477 _LOTSNESTEDMESSAGE_B107._serialized_end=1483 _LOTSNESTEDMESSAGE_B108._serialized_start=1485 _LOTSNESTEDMESSAGE_B108._serialized_end=1491 _LOTSNESTEDMESSAGE_B109._serialized_start=1493 _LOTSNESTEDMESSAGE_B109._serialized_end=1499 _LOTSNESTEDMESSAGE_B110._serialized_start=1501 _LOTSNESTEDMESSAGE_B110._serialized_end=1507 _LOTSNESTEDMESSAGE_B111._serialized_start=1509 _LOTSNESTEDMESSAGE_B111._serialized_end=1515 _LOTSNESTEDMESSAGE_B112._serialized_start=1517 _LOTSNESTEDMESSAGE_B112._serialized_end=1523 _LOTSNESTEDMESSAGE_B113._serialized_start=1525 _LOTSNESTEDMESSAGE_B113._serialized_end=1531 _LOTSNESTEDMESSAGE_B114._serialized_start=1533 _LOTSNESTEDMESSAGE_B114._serialized_end=1539 _LOTSNESTEDMESSAGE_B115._serialized_start=1541 _LOTSNESTEDMESSAGE_B115._serialized_end=1547 _LOTSNESTEDMESSAGE_B116._serialized_start=1549 _LOTSNESTEDMESSAGE_B116._serialized_end=1555 _LOTSNESTEDMESSAGE_B117._serialized_start=1557 _LOTSNESTEDMESSAGE_B117._serialized_end=1563 _LOTSNESTEDMESSAGE_B118._serialized_start=1565 _LOTSNESTEDMESSAGE_B118._serialized_end=1571 _LOTSNESTEDMESSAGE_B119._serialized_start=1573 _LOTSNESTEDMESSAGE_B119._serialized_end=1579 _LOTSNESTEDMESSAGE_B120._serialized_start=1581 _LOTSNESTEDMESSAGE_B120._serialized_end=1587 _LOTSNESTEDMESSAGE_B121._serialized_start=1589 _LOTSNESTEDMESSAGE_B121._serialized_end=1595 _LOTSNESTEDMESSAGE_B122._serialized_start=1597 _LOTSNESTEDMESSAGE_B122._serialized_end=1603 _LOTSNESTEDMESSAGE_B123._serialized_start=1605 _LOTSNESTEDMESSAGE_B123._serialized_end=1611 _LOTSNESTEDMESSAGE_B124._serialized_start=1613 _LOTSNESTEDMESSAGE_B124._serialized_end=1619 _LOTSNESTEDMESSAGE_B125._serialized_start=1621 _LOTSNESTEDMESSAGE_B125._serialized_end=1627 _LOTSNESTEDMESSAGE_B126._serialized_start=1629 _LOTSNESTEDMESSAGE_B126._serialized_end=1635 _LOTSNESTEDMESSAGE_B127._serialized_start=1637 _LOTSNESTEDMESSAGE_B127._serialized_end=1643 _LOTSNESTEDMESSAGE_B128._serialized_start=1645 _LOTSNESTEDMESSAGE_B128._serialized_end=1651 _LOTSNESTEDMESSAGE_B129._serialized_start=1653 _LOTSNESTEDMESSAGE_B129._serialized_end=1659 _LOTSNESTEDMESSAGE_B130._serialized_start=1661 _LOTSNESTEDMESSAGE_B130._serialized_end=1667 _LOTSNESTEDMESSAGE_B131._serialized_start=1669 _LOTSNESTEDMESSAGE_B131._serialized_end=1675 _LOTSNESTEDMESSAGE_B132._serialized_start=1677 _LOTSNESTEDMESSAGE_B132._serialized_end=1683 _LOTSNESTEDMESSAGE_B133._serialized_start=1685 _LOTSNESTEDMESSAGE_B133._serialized_end=1691 _LOTSNESTEDMESSAGE_B134._serialized_start=1693 _LOTSNESTEDMESSAGE_B134._serialized_end=1699 _LOTSNESTEDMESSAGE_B135._serialized_start=1701 _LOTSNESTEDMESSAGE_B135._serialized_end=1707 _LOTSNESTEDMESSAGE_B136._serialized_start=1709 _LOTSNESTEDMESSAGE_B136._serialized_end=1715 _LOTSNESTEDMESSAGE_B137._serialized_start=1717 _LOTSNESTEDMESSAGE_B137._serialized_end=1723 _LOTSNESTEDMESSAGE_B138._serialized_start=1725 _LOTSNESTEDMESSAGE_B138._serialized_end=1731 _LOTSNESTEDMESSAGE_B139._serialized_start=1733 _LOTSNESTEDMESSAGE_B139._serialized_end=1739 _LOTSNESTEDMESSAGE_B140._serialized_start=1741 _LOTSNESTEDMESSAGE_B140._serialized_end=1747 _LOTSNESTEDMESSAGE_B141._serialized_start=1749 _LOTSNESTEDMESSAGE_B141._serialized_end=1755 _LOTSNESTEDMESSAGE_B142._serialized_start=1757 _LOTSNESTEDMESSAGE_B142._serialized_end=1763 _LOTSNESTEDMESSAGE_B143._serialized_start=1765 _LOTSNESTEDMESSAGE_B143._serialized_end=1771 _LOTSNESTEDMESSAGE_B144._serialized_start=1773 _LOTSNESTEDMESSAGE_B144._serialized_end=1779 _LOTSNESTEDMESSAGE_B145._serialized_start=1781 _LOTSNESTEDMESSAGE_B145._serialized_end=1787 _LOTSNESTEDMESSAGE_B146._serialized_start=1789 _LOTSNESTEDMESSAGE_B146._serialized_end=1795 _LOTSNESTEDMESSAGE_B147._serialized_start=1797 _LOTSNESTEDMESSAGE_B147._serialized_end=1803 _LOTSNESTEDMESSAGE_B148._serialized_start=1805 _LOTSNESTEDMESSAGE_B148._serialized_end=1811 _LOTSNESTEDMESSAGE_B149._serialized_start=1813 _LOTSNESTEDMESSAGE_B149._serialized_end=1819 _LOTSNESTEDMESSAGE_B150._serialized_start=1821 _LOTSNESTEDMESSAGE_B150._serialized_end=1827 _LOTSNESTEDMESSAGE_B151._serialized_start=1829 _LOTSNESTEDMESSAGE_B151._serialized_end=1835 _LOTSNESTEDMESSAGE_B152._serialized_start=1837 _LOTSNESTEDMESSAGE_B152._serialized_end=1843 _LOTSNESTEDMESSAGE_B153._serialized_start=1845 _LOTSNESTEDMESSAGE_B153._serialized_end=1851 _LOTSNESTEDMESSAGE_B154._serialized_start=1853 _LOTSNESTEDMESSAGE_B154._serialized_end=1859 _LOTSNESTEDMESSAGE_B155._serialized_start=1861 _LOTSNESTEDMESSAGE_B155._serialized_end=1867 _LOTSNESTEDMESSAGE_B156._serialized_start=1869 _LOTSNESTEDMESSAGE_B156._serialized_end=1875 _LOTSNESTEDMESSAGE_B157._serialized_start=1877 _LOTSNESTEDMESSAGE_B157._serialized_end=1883 _LOTSNESTEDMESSAGE_B158._serialized_start=1885 _LOTSNESTEDMESSAGE_B158._serialized_end=1891 _LOTSNESTEDMESSAGE_B159._serialized_start=1893 _LOTSNESTEDMESSAGE_B159._serialized_end=1899 _LOTSNESTEDMESSAGE_B160._serialized_start=1901 _LOTSNESTEDMESSAGE_B160._serialized_end=1907 _LOTSNESTEDMESSAGE_B161._serialized_start=1909 _LOTSNESTEDMESSAGE_B161._serialized_end=1915 _LOTSNESTEDMESSAGE_B162._serialized_start=1917 _LOTSNESTEDMESSAGE_B162._serialized_end=1923 _LOTSNESTEDMESSAGE_B163._serialized_start=1925 _LOTSNESTEDMESSAGE_B163._serialized_end=1931 _LOTSNESTEDMESSAGE_B164._serialized_start=1933 _LOTSNESTEDMESSAGE_B164._serialized_end=1939 _LOTSNESTEDMESSAGE_B165._serialized_start=1941 _LOTSNESTEDMESSAGE_B165._serialized_end=1947 _LOTSNESTEDMESSAGE_B166._serialized_start=1949 _LOTSNESTEDMESSAGE_B166._serialized_end=1955 _LOTSNESTEDMESSAGE_B167._serialized_start=1957 _LOTSNESTEDMESSAGE_B167._serialized_end=1963 _LOTSNESTEDMESSAGE_B168._serialized_start=1965 _LOTSNESTEDMESSAGE_B168._serialized_end=1971 _LOTSNESTEDMESSAGE_B169._serialized_start=1973 _LOTSNESTEDMESSAGE_B169._serialized_end=1979 _LOTSNESTEDMESSAGE_B170._serialized_start=1981 _LOTSNESTEDMESSAGE_B170._serialized_end=1987 _LOTSNESTEDMESSAGE_B171._serialized_start=1989 _LOTSNESTEDMESSAGE_B171._serialized_end=1995 _LOTSNESTEDMESSAGE_B172._serialized_start=1997 _LOTSNESTEDMESSAGE_B172._serialized_end=2003 _LOTSNESTEDMESSAGE_B173._serialized_start=2005 _LOTSNESTEDMESSAGE_B173._serialized_end=2011 _LOTSNESTEDMESSAGE_B174._serialized_start=2013 _LOTSNESTEDMESSAGE_B174._serialized_end=2019 _LOTSNESTEDMESSAGE_B175._serialized_start=2021 _LOTSNESTEDMESSAGE_B175._serialized_end=2027 _LOTSNESTEDMESSAGE_B176._serialized_start=2029 _LOTSNESTEDMESSAGE_B176._serialized_end=2035 _LOTSNESTEDMESSAGE_B177._serialized_start=2037 _LOTSNESTEDMESSAGE_B177._serialized_end=2043 _LOTSNESTEDMESSAGE_B178._serialized_start=2045 _LOTSNESTEDMESSAGE_B178._serialized_end=2051 _LOTSNESTEDMESSAGE_B179._serialized_start=2053 _LOTSNESTEDMESSAGE_B179._serialized_end=2059 _LOTSNESTEDMESSAGE_B180._serialized_start=2061 _LOTSNESTEDMESSAGE_B180._serialized_end=2067 _LOTSNESTEDMESSAGE_B181._serialized_start=2069 _LOTSNESTEDMESSAGE_B181._serialized_end=2075 _LOTSNESTEDMESSAGE_B182._serialized_start=2077 _LOTSNESTEDMESSAGE_B182._serialized_end=2083 _LOTSNESTEDMESSAGE_B183._serialized_start=2085 _LOTSNESTEDMESSAGE_B183._serialized_end=2091 _LOTSNESTEDMESSAGE_B184._serialized_start=2093 _LOTSNESTEDMESSAGE_B184._serialized_end=2099 _LOTSNESTEDMESSAGE_B185._serialized_start=2101 _LOTSNESTEDMESSAGE_B185._serialized_end=2107 _LOTSNESTEDMESSAGE_B186._serialized_start=2109 _LOTSNESTEDMESSAGE_B186._serialized_end=2115 _LOTSNESTEDMESSAGE_B187._serialized_start=2117 _LOTSNESTEDMESSAGE_B187._serialized_end=2123 _LOTSNESTEDMESSAGE_B188._serialized_start=2125 _LOTSNESTEDMESSAGE_B188._serialized_end=2131 _LOTSNESTEDMESSAGE_B189._serialized_start=2133 _LOTSNESTEDMESSAGE_B189._serialized_end=2139 _LOTSNESTEDMESSAGE_B190._serialized_start=2141 _LOTSNESTEDMESSAGE_B190._serialized_end=2147 _LOTSNESTEDMESSAGE_B191._serialized_start=2149 _LOTSNESTEDMESSAGE_B191._serialized_end=2155 _LOTSNESTEDMESSAGE_B192._serialized_start=2157 _LOTSNESTEDMESSAGE_B192._serialized_end=2163 _LOTSNESTEDMESSAGE_B193._serialized_start=2165 _LOTSNESTEDMESSAGE_B193._serialized_end=2171 _LOTSNESTEDMESSAGE_B194._serialized_start=2173 _LOTSNESTEDMESSAGE_B194._serialized_end=2179 _LOTSNESTEDMESSAGE_B195._serialized_start=2181 _LOTSNESTEDMESSAGE_B195._serialized_end=2187 _LOTSNESTEDMESSAGE_B196._serialized_start=2189 _LOTSNESTEDMESSAGE_B196._serialized_end=2195 _LOTSNESTEDMESSAGE_B197._serialized_start=2197 _LOTSNESTEDMESSAGE_B197._serialized_end=2203 _LOTSNESTEDMESSAGE_B198._serialized_start=2205 _LOTSNESTEDMESSAGE_B198._serialized_end=2211 _LOTSNESTEDMESSAGE_B199._serialized_start=2213 _LOTSNESTEDMESSAGE_B199._serialized_end=2219 _LOTSNESTEDMESSAGE_B200._serialized_start=2221 _LOTSNESTEDMESSAGE_B200._serialized_end=2227 _LOTSNESTEDMESSAGE_B201._serialized_start=2229 _LOTSNESTEDMESSAGE_B201._serialized_end=2235 _LOTSNESTEDMESSAGE_B202._serialized_start=2237 _LOTSNESTEDMESSAGE_B202._serialized_end=2243 _LOTSNESTEDMESSAGE_B203._serialized_start=2245 _LOTSNESTEDMESSAGE_B203._serialized_end=2251 _LOTSNESTEDMESSAGE_B204._serialized_start=2253 _LOTSNESTEDMESSAGE_B204._serialized_end=2259 _LOTSNESTEDMESSAGE_B205._serialized_start=2261 _LOTSNESTEDMESSAGE_B205._serialized_end=2267 _LOTSNESTEDMESSAGE_B206._serialized_start=2269 _LOTSNESTEDMESSAGE_B206._serialized_end=2275 _LOTSNESTEDMESSAGE_B207._serialized_start=2277 _LOTSNESTEDMESSAGE_B207._serialized_end=2283 _LOTSNESTEDMESSAGE_B208._serialized_start=2285 _LOTSNESTEDMESSAGE_B208._serialized_end=2291 _LOTSNESTEDMESSAGE_B209._serialized_start=2293 _LOTSNESTEDMESSAGE_B209._serialized_end=2299 _LOTSNESTEDMESSAGE_B210._serialized_start=2301 _LOTSNESTEDMESSAGE_B210._serialized_end=2307 _LOTSNESTEDMESSAGE_B211._serialized_start=2309 _LOTSNESTEDMESSAGE_B211._serialized_end=2315 _LOTSNESTEDMESSAGE_B212._serialized_start=2317 _LOTSNESTEDMESSAGE_B212._serialized_end=2323 _LOTSNESTEDMESSAGE_B213._serialized_start=2325 _LOTSNESTEDMESSAGE_B213._serialized_end=2331 _LOTSNESTEDMESSAGE_B214._serialized_start=2333 _LOTSNESTEDMESSAGE_B214._serialized_end=2339 _LOTSNESTEDMESSAGE_B215._serialized_start=2341 _LOTSNESTEDMESSAGE_B215._serialized_end=2347 _LOTSNESTEDMESSAGE_B216._serialized_start=2349 _LOTSNESTEDMESSAGE_B216._serialized_end=2355 _LOTSNESTEDMESSAGE_B217._serialized_start=2357 _LOTSNESTEDMESSAGE_B217._serialized_end=2363 _LOTSNESTEDMESSAGE_B218._serialized_start=2365 _LOTSNESTEDMESSAGE_B218._serialized_end=2371 _LOTSNESTEDMESSAGE_B219._serialized_start=2373 _LOTSNESTEDMESSAGE_B219._serialized_end=2379 _LOTSNESTEDMESSAGE_B220._serialized_start=2381 _LOTSNESTEDMESSAGE_B220._serialized_end=2387 _LOTSNESTEDMESSAGE_B221._serialized_start=2389 _LOTSNESTEDMESSAGE_B221._serialized_end=2395 _LOTSNESTEDMESSAGE_B222._serialized_start=2397 _LOTSNESTEDMESSAGE_B222._serialized_end=2403 _LOTSNESTEDMESSAGE_B223._serialized_start=2405 _LOTSNESTEDMESSAGE_B223._serialized_end=2411 _LOTSNESTEDMESSAGE_B224._serialized_start=2413 _LOTSNESTEDMESSAGE_B224._serialized_end=2419 _LOTSNESTEDMESSAGE_B225._serialized_start=2421 _LOTSNESTEDMESSAGE_B225._serialized_end=2427 _LOTSNESTEDMESSAGE_B226._serialized_start=2429 _LOTSNESTEDMESSAGE_B226._serialized_end=2435 _LOTSNESTEDMESSAGE_B227._serialized_start=2437 _LOTSNESTEDMESSAGE_B227._serialized_end=2443 _LOTSNESTEDMESSAGE_B228._serialized_start=2445 _LOTSNESTEDMESSAGE_B228._serialized_end=2451 _LOTSNESTEDMESSAGE_B229._serialized_start=2453 _LOTSNESTEDMESSAGE_B229._serialized_end=2459 _LOTSNESTEDMESSAGE_B230._serialized_start=2461 _LOTSNESTEDMESSAGE_B230._serialized_end=2467 _LOTSNESTEDMESSAGE_B231._serialized_start=2469 _LOTSNESTEDMESSAGE_B231._serialized_end=2475 _LOTSNESTEDMESSAGE_B232._serialized_start=2477 _LOTSNESTEDMESSAGE_B232._serialized_end=2483 _LOTSNESTEDMESSAGE_B233._serialized_start=2485 _LOTSNESTEDMESSAGE_B233._serialized_end=2491 _LOTSNESTEDMESSAGE_B234._serialized_start=2493 _LOTSNESTEDMESSAGE_B234._serialized_end=2499 _LOTSNESTEDMESSAGE_B235._serialized_start=2501 _LOTSNESTEDMESSAGE_B235._serialized_end=2507 _LOTSNESTEDMESSAGE_B236._serialized_start=2509 _LOTSNESTEDMESSAGE_B236._serialized_end=2515 _LOTSNESTEDMESSAGE_B237._serialized_start=2517 _LOTSNESTEDMESSAGE_B237._serialized_end=2523 _LOTSNESTEDMESSAGE_B238._serialized_start=2525 _LOTSNESTEDMESSAGE_B238._serialized_end=2531 _LOTSNESTEDMESSAGE_B239._serialized_start=2533 _LOTSNESTEDMESSAGE_B239._serialized_end=2539 _LOTSNESTEDMESSAGE_B240._serialized_start=2541 _LOTSNESTEDMESSAGE_B240._serialized_end=2547 _LOTSNESTEDMESSAGE_B241._serialized_start=2549 _LOTSNESTEDMESSAGE_B241._serialized_end=2555 _LOTSNESTEDMESSAGE_B242._serialized_start=2557 _LOTSNESTEDMESSAGE_B242._serialized_end=2563 _LOTSNESTEDMESSAGE_B243._serialized_start=2565 _LOTSNESTEDMESSAGE_B243._serialized_end=2571 _LOTSNESTEDMESSAGE_B244._serialized_start=2573 _LOTSNESTEDMESSAGE_B244._serialized_end=2579 _LOTSNESTEDMESSAGE_B245._serialized_start=2581 _LOTSNESTEDMESSAGE_B245._serialized_end=2587 _LOTSNESTEDMESSAGE_B246._serialized_start=2589 _LOTSNESTEDMESSAGE_B246._serialized_end=2595 _LOTSNESTEDMESSAGE_B247._serialized_start=2597 _LOTSNESTEDMESSAGE_B247._serialized_end=2603 _LOTSNESTEDMESSAGE_B248._serialized_start=2605 _LOTSNESTEDMESSAGE_B248._serialized_end=2611 _LOTSNESTEDMESSAGE_B249._serialized_start=2613 _LOTSNESTEDMESSAGE_B249._serialized_end=2619 _LOTSNESTEDMESSAGE_B250._serialized_start=2621 _LOTSNESTEDMESSAGE_B250._serialized_end=2627 _LOTSNESTEDMESSAGE_B251._serialized_start=2629 _LOTSNESTEDMESSAGE_B251._serialized_end=2635 _LOTSNESTEDMESSAGE_B252._serialized_start=2637 _LOTSNESTEDMESSAGE_B252._serialized_end=2643 _LOTSNESTEDMESSAGE_B253._serialized_start=2645 _LOTSNESTEDMESSAGE_B253._serialized_end=2651 _LOTSNESTEDMESSAGE_B254._serialized_start=2653 _LOTSNESTEDMESSAGE_B254._serialized_end=2659 _LOTSNESTEDMESSAGE_B255._serialized_start=2661 _LOTSNESTEDMESSAGE_B255._serialized_end=2667 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/no_package_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/internal/no_package.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)google/protobuf/internal/no_package.proto\";\n\x10NoPackageMessage\x12\'\n\x0fno_package_enum\x18\x01 \x01(\x0e\x32\x0e.NoPackageEnum*?\n\rNoPackageEnum\x12\x16\n\x12NO_PACKAGE_VALUE_0\x10\x00\x12\x16\n\x12NO_PACKAGE_VALUE_1\x10\x01') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.internal.no_package_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None _NOPACKAGEENUM._serialized_start=106 _NOPACKAGEENUM._serialized_end=169 _NOPACKAGEMESSAGE._serialized_start=45 _NOPACKAGEMESSAGE._serialized_end=104 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/python_message.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # This code is meant to work on Python 2.4 and above only. # # TODO(robinson): Helpers for verbose, common checks like seeing if a # descriptor's cpp_type is CPPTYPE_MESSAGE. """Contains a metaclass and helper functions used to create protocol message classes from Descriptor objects at runtime. Recall that a metaclass is the "type" of a class. (A class is to a metaclass what an instance is to a class.) In this case, we use the GeneratedProtocolMessageType metaclass to inject all the useful functionality into the classes output by the protocol compiler at compile-time. The upshot of all this is that the real implementation details for ALL pure-Python protocol buffers are *here in this file*. """ __author__ = 'robinson@google.com (Will Robinson)' from io import BytesIO import struct import sys import weakref # We use "as" to avoid name collisions with variables. from google.protobuf.internal import api_implementation from google.protobuf.internal import containers from google.protobuf.internal import decoder from google.protobuf.internal import encoder from google.protobuf.internal import enum_type_wrapper from google.protobuf.internal import extension_dict from google.protobuf.internal import message_listener as message_listener_mod from google.protobuf.internal import type_checkers from google.protobuf.internal import well_known_types from google.protobuf.internal import wire_format from google.protobuf import descriptor as descriptor_mod from google.protobuf import message as message_mod from google.protobuf import text_format _FieldDescriptor = descriptor_mod.FieldDescriptor _AnyFullTypeName = 'google.protobuf.Any' _ExtensionDict = extension_dict._ExtensionDict class GeneratedProtocolMessageType(type): """Metaclass for protocol message classes created at runtime from Descriptors. We add implementations for all methods described in the Message class. We also create properties to allow getting/setting all fields in the protocol message. Finally, we create slots to prevent users from accidentally "setting" nonexistent fields in the protocol message, which then wouldn't get serialized / deserialized properly. The protocol compiler currently uses this metaclass to create protocol message classes at runtime. Clients can also manually create their own classes at runtime, as in this example: mydescriptor = Descriptor(.....) factory = symbol_database.Default() factory.pool.AddDescriptor(mydescriptor) MyProtoClass = factory.GetPrototype(mydescriptor) myproto_instance = MyProtoClass() myproto.foo_field = 23 ... """ # Must be consistent with the protocol-compiler code in # proto2/compiler/internal/generator.*. _DESCRIPTOR_KEY = 'DESCRIPTOR' def __new__(cls, name, bases, dictionary): """Custom allocation for runtime-generated class types. We override __new__ because this is apparently the only place where we can meaningfully set __slots__ on the class we're creating(?). (The interplay between metaclasses and slots is not very well-documented). Args: name: Name of the class (ignored, but required by the metaclass protocol). bases: Base classes of the class we're constructing. (Should be message.Message). We ignore this field, but it's required by the metaclass protocol dictionary: The class dictionary of the class we're constructing. dictionary[_DESCRIPTOR_KEY] must contain a Descriptor object describing this protocol message type. Returns: Newly-allocated class. Raises: RuntimeError: Generated code only work with python cpp extension. """ descriptor = dictionary[GeneratedProtocolMessageType._DESCRIPTOR_KEY] if isinstance(descriptor, str): raise RuntimeError('The generated code only work with python cpp ' 'extension, but it is using pure python runtime.') # If a concrete class already exists for this descriptor, don't try to # create another. Doing so will break any messages that already exist with # the existing class. # # The C++ implementation appears to have its own internal `PyMessageFactory` # to achieve similar results. # # This most commonly happens in `text_format.py` when using descriptors from # a custom pool; it calls symbol_database.Global().getPrototype() on a # descriptor which already has an existing concrete class. new_class = getattr(descriptor, '_concrete_class', None) if new_class: return new_class if descriptor.full_name in well_known_types.WKTBASES: bases += (well_known_types.WKTBASES[descriptor.full_name],) _AddClassAttributesForNestedExtensions(descriptor, dictionary) _AddSlots(descriptor, dictionary) superclass = super(GeneratedProtocolMessageType, cls) new_class = superclass.__new__(cls, name, bases, dictionary) return new_class def __init__(cls, name, bases, dictionary): """Here we perform the majority of our work on the class. We add enum getters, an __init__ method, implementations of all Message methods, and properties for all fields in the protocol type. Args: name: Name of the class (ignored, but required by the metaclass protocol). bases: Base classes of the class we're constructing. (Should be message.Message). We ignore this field, but it's required by the metaclass protocol dictionary: The class dictionary of the class we're constructing. dictionary[_DESCRIPTOR_KEY] must contain a Descriptor object describing this protocol message type. """ descriptor = dictionary[GeneratedProtocolMessageType._DESCRIPTOR_KEY] # If this is an _existing_ class looked up via `_concrete_class` in the # __new__ method above, then we don't need to re-initialize anything. existing_class = getattr(descriptor, '_concrete_class', None) if existing_class: assert existing_class is cls, ( 'Duplicate `GeneratedProtocolMessageType` created for descriptor %r' % (descriptor.full_name)) return cls._decoders_by_tag = {} if (descriptor.has_options and descriptor.GetOptions().message_set_wire_format): cls._decoders_by_tag[decoder.MESSAGE_SET_ITEM_TAG] = ( decoder.MessageSetItemDecoder(descriptor), None) # Attach stuff to each FieldDescriptor for quick lookup later on. for field in descriptor.fields: _AttachFieldHelpers(cls, field) descriptor._concrete_class = cls # pylint: disable=protected-access _AddEnumValues(descriptor, cls) _AddInitMethod(descriptor, cls) _AddPropertiesForFields(descriptor, cls) _AddPropertiesForExtensions(descriptor, cls) _AddStaticMethods(cls) _AddMessageMethods(descriptor, cls) _AddPrivateHelperMethods(descriptor, cls) superclass = super(GeneratedProtocolMessageType, cls) superclass.__init__(name, bases, dictionary) # Stateless helpers for GeneratedProtocolMessageType below. # Outside clients should not access these directly. # # I opted not to make any of these methods on the metaclass, to make it more # clear that I'm not really using any state there and to keep clients from # thinking that they have direct access to these construction helpers. def _PropertyName(proto_field_name): """Returns the name of the public property attribute which clients can use to get and (in some cases) set the value of a protocol message field. Args: proto_field_name: The protocol message field name, exactly as it appears (or would appear) in a .proto file. """ # TODO(robinson): Escape Python keywords (e.g., yield), and test this support. # nnorwitz makes my day by writing: # """ # FYI. See the keyword module in the stdlib. This could be as simple as: # # if keyword.iskeyword(proto_field_name): # return proto_field_name + "_" # return proto_field_name # """ # Kenton says: The above is a BAD IDEA. People rely on being able to use # getattr() and setattr() to reflectively manipulate field values. If we # rename the properties, then every such user has to also make sure to apply # the same transformation. Note that currently if you name a field "yield", # you can still access it just fine using getattr/setattr -- it's not even # that cumbersome to do so. # TODO(kenton): Remove this method entirely if/when everyone agrees with my # position. return proto_field_name def _AddSlots(message_descriptor, dictionary): """Adds a __slots__ entry to dictionary, containing the names of all valid attributes for this message type. Args: message_descriptor: A Descriptor instance describing this message type. dictionary: Class dictionary to which we'll add a '__slots__' entry. """ dictionary['__slots__'] = ['_cached_byte_size', '_cached_byte_size_dirty', '_fields', '_unknown_fields', '_unknown_field_set', '_is_present_in_parent', '_listener', '_listener_for_children', '__weakref__', '_oneofs'] def _IsMessageSetExtension(field): return (field.is_extension and field.containing_type.has_options and field.containing_type.GetOptions().message_set_wire_format and field.type == _FieldDescriptor.TYPE_MESSAGE and field.label == _FieldDescriptor.LABEL_OPTIONAL) def _IsMapField(field): return (field.type == _FieldDescriptor.TYPE_MESSAGE and field.message_type.has_options and field.message_type.GetOptions().map_entry) def _IsMessageMapField(field): value_type = field.message_type.fields_by_name['value'] return value_type.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE def _AttachFieldHelpers(cls, field_descriptor): is_repeated = (field_descriptor.label == _FieldDescriptor.LABEL_REPEATED) is_packable = (is_repeated and wire_format.IsTypePackable(field_descriptor.type)) is_proto3 = field_descriptor.containing_type.syntax == 'proto3' if not is_packable: is_packed = False elif field_descriptor.containing_type.syntax == 'proto2': is_packed = (field_descriptor.has_options and field_descriptor.GetOptions().packed) else: has_packed_false = (field_descriptor.has_options and field_descriptor.GetOptions().HasField('packed') and field_descriptor.GetOptions().packed == False) is_packed = not has_packed_false is_map_entry = _IsMapField(field_descriptor) if is_map_entry: field_encoder = encoder.MapEncoder(field_descriptor) sizer = encoder.MapSizer(field_descriptor, _IsMessageMapField(field_descriptor)) elif _IsMessageSetExtension(field_descriptor): field_encoder = encoder.MessageSetItemEncoder(field_descriptor.number) sizer = encoder.MessageSetItemSizer(field_descriptor.number) else: field_encoder = type_checkers.TYPE_TO_ENCODER[field_descriptor.type]( field_descriptor.number, is_repeated, is_packed) sizer = type_checkers.TYPE_TO_SIZER[field_descriptor.type]( field_descriptor.number, is_repeated, is_packed) field_descriptor._encoder = field_encoder field_descriptor._sizer = sizer field_descriptor._default_constructor = _DefaultValueConstructorForField( field_descriptor) def AddDecoder(wiretype, is_packed): tag_bytes = encoder.TagBytes(field_descriptor.number, wiretype) decode_type = field_descriptor.type if (decode_type == _FieldDescriptor.TYPE_ENUM and type_checkers.SupportsOpenEnums(field_descriptor)): decode_type = _FieldDescriptor.TYPE_INT32 oneof_descriptor = None clear_if_default = False if field_descriptor.containing_oneof is not None: oneof_descriptor = field_descriptor elif (is_proto3 and not is_repeated and field_descriptor.cpp_type != _FieldDescriptor.CPPTYPE_MESSAGE): clear_if_default = True if is_map_entry: is_message_map = _IsMessageMapField(field_descriptor) field_decoder = decoder.MapDecoder( field_descriptor, _GetInitializeDefaultForMap(field_descriptor), is_message_map) elif decode_type == _FieldDescriptor.TYPE_STRING: field_decoder = decoder.StringDecoder( field_descriptor.number, is_repeated, is_packed, field_descriptor, field_descriptor._default_constructor, clear_if_default) elif field_descriptor.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: field_decoder = type_checkers.TYPE_TO_DECODER[decode_type]( field_descriptor.number, is_repeated, is_packed, field_descriptor, field_descriptor._default_constructor) else: field_decoder = type_checkers.TYPE_TO_DECODER[decode_type]( field_descriptor.number, is_repeated, is_packed, # pylint: disable=protected-access field_descriptor, field_descriptor._default_constructor, clear_if_default) cls._decoders_by_tag[tag_bytes] = (field_decoder, oneof_descriptor) AddDecoder(type_checkers.FIELD_TYPE_TO_WIRE_TYPE[field_descriptor.type], False) if is_repeated and wire_format.IsTypePackable(field_descriptor.type): # To support wire compatibility of adding packed = true, add a decoder for # packed values regardless of the field's options. AddDecoder(wire_format.WIRETYPE_LENGTH_DELIMITED, True) def _AddClassAttributesForNestedExtensions(descriptor, dictionary): extensions = descriptor.extensions_by_name for extension_name, extension_field in extensions.items(): assert extension_name not in dictionary dictionary[extension_name] = extension_field def _AddEnumValues(descriptor, cls): """Sets class-level attributes for all enum fields defined in this message. Also exporting a class-level object that can name enum values. Args: descriptor: Descriptor object for this message type. cls: Class we're constructing for this message type. """ for enum_type in descriptor.enum_types: setattr(cls, enum_type.name, enum_type_wrapper.EnumTypeWrapper(enum_type)) for enum_value in enum_type.values: setattr(cls, enum_value.name, enum_value.number) def _GetInitializeDefaultForMap(field): if field.label != _FieldDescriptor.LABEL_REPEATED: raise ValueError('map_entry set on non-repeated field %s' % ( field.name)) fields_by_name = field.message_type.fields_by_name key_checker = type_checkers.GetTypeChecker(fields_by_name['key']) value_field = fields_by_name['value'] if _IsMessageMapField(field): def MakeMessageMapDefault(message): return containers.MessageMap( message._listener_for_children, value_field.message_type, key_checker, field.message_type) return MakeMessageMapDefault else: value_checker = type_checkers.GetTypeChecker(value_field) def MakePrimitiveMapDefault(message): return containers.ScalarMap( message._listener_for_children, key_checker, value_checker, field.message_type) return MakePrimitiveMapDefault def _DefaultValueConstructorForField(field): """Returns a function which returns a default value for a field. Args: field: FieldDescriptor object for this field. The returned function has one argument: message: Message instance containing this field, or a weakref proxy of same. That function in turn returns a default value for this field. The default value may refer back to |message| via a weak reference. """ if _IsMapField(field): return _GetInitializeDefaultForMap(field) if field.label == _FieldDescriptor.LABEL_REPEATED: if field.has_default_value and field.default_value != []: raise ValueError('Repeated field default value not empty list: %s' % ( field.default_value)) if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: # We can't look at _concrete_class yet since it might not have # been set. (Depends on order in which we initialize the classes). message_type = field.message_type def MakeRepeatedMessageDefault(message): return containers.RepeatedCompositeFieldContainer( message._listener_for_children, field.message_type) return MakeRepeatedMessageDefault else: type_checker = type_checkers.GetTypeChecker(field) def MakeRepeatedScalarDefault(message): return containers.RepeatedScalarFieldContainer( message._listener_for_children, type_checker) return MakeRepeatedScalarDefault if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: # _concrete_class may not yet be initialized. message_type = field.message_type def MakeSubMessageDefault(message): assert getattr(message_type, '_concrete_class', None), ( 'Uninitialized concrete class found for field %r (message type %r)' % (field.full_name, message_type.full_name)) result = message_type._concrete_class() result._SetListener( _OneofListener(message, field) if field.containing_oneof is not None else message._listener_for_children) return result return MakeSubMessageDefault def MakeScalarDefault(message): # TODO(protobuf-team): This may be broken since there may not be # default_value. Combine with has_default_value somehow. return field.default_value return MakeScalarDefault def _ReraiseTypeErrorWithFieldName(message_name, field_name): """Re-raise the currently-handled TypeError with the field name added.""" exc = sys.exc_info()[1] if len(exc.args) == 1 and type(exc) is TypeError: # simple TypeError; add field name to exception message exc = TypeError('%s for field %s.%s' % (str(exc), message_name, field_name)) # re-raise possibly-amended exception with original traceback: raise exc.with_traceback(sys.exc_info()[2]) def _AddInitMethod(message_descriptor, cls): """Adds an __init__ method to cls.""" def _GetIntegerEnumValue(enum_type, value): """Convert a string or integer enum value to an integer. If the value is a string, it is converted to the enum value in enum_type with the same name. If the value is not a string, it's returned as-is. (No conversion or bounds-checking is done.) """ if isinstance(value, str): try: return enum_type.values_by_name[value].number except KeyError: raise ValueError('Enum type %s: unknown label "%s"' % ( enum_type.full_name, value)) return value def init(self, **kwargs): self._cached_byte_size = 0 self._cached_byte_size_dirty = len(kwargs) > 0 self._fields = {} # Contains a mapping from oneof field descriptors to the descriptor # of the currently set field in that oneof field. self._oneofs = {} # _unknown_fields is () when empty for efficiency, and will be turned into # a list if fields are added. self._unknown_fields = () # _unknown_field_set is None when empty for efficiency, and will be # turned into UnknownFieldSet struct if fields are added. self._unknown_field_set = None # pylint: disable=protected-access self._is_present_in_parent = False self._listener = message_listener_mod.NullMessageListener() self._listener_for_children = _Listener(self) for field_name, field_value in kwargs.items(): field = _GetFieldByName(message_descriptor, field_name) if field is None: raise TypeError('%s() got an unexpected keyword argument "%s"' % (message_descriptor.name, field_name)) if field_value is None: # field=None is the same as no field at all. continue if field.label == _FieldDescriptor.LABEL_REPEATED: copy = field._default_constructor(self) if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: # Composite if _IsMapField(field): if _IsMessageMapField(field): for key in field_value: copy[key].MergeFrom(field_value[key]) else: copy.update(field_value) else: for val in field_value: if isinstance(val, dict): copy.add(**val) else: copy.add().MergeFrom(val) else: # Scalar if field.cpp_type == _FieldDescriptor.CPPTYPE_ENUM: field_value = [_GetIntegerEnumValue(field.enum_type, val) for val in field_value] copy.extend(field_value) self._fields[field] = copy elif field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: copy = field._default_constructor(self) new_val = field_value if isinstance(field_value, dict): new_val = field.message_type._concrete_class(**field_value) try: copy.MergeFrom(new_val) except TypeError: _ReraiseTypeErrorWithFieldName(message_descriptor.name, field_name) self._fields[field] = copy else: if field.cpp_type == _FieldDescriptor.CPPTYPE_ENUM: field_value = _GetIntegerEnumValue(field.enum_type, field_value) try: setattr(self, field_name, field_value) except TypeError: _ReraiseTypeErrorWithFieldName(message_descriptor.name, field_name) init.__module__ = None init.__doc__ = None cls.__init__ = init def _GetFieldByName(message_descriptor, field_name): """Returns a field descriptor by field name. Args: message_descriptor: A Descriptor describing all fields in message. field_name: The name of the field to retrieve. Returns: The field descriptor associated with the field name. """ try: return message_descriptor.fields_by_name[field_name] except KeyError: raise ValueError('Protocol message %s has no "%s" field.' % (message_descriptor.name, field_name)) def _AddPropertiesForFields(descriptor, cls): """Adds properties for all fields in this protocol message type.""" for field in descriptor.fields: _AddPropertiesForField(field, cls) if descriptor.is_extendable: # _ExtensionDict is just an adaptor with no state so we allocate a new one # every time it is accessed. cls.Extensions = property(lambda self: _ExtensionDict(self)) def _AddPropertiesForField(field, cls): """Adds a public property for a protocol message field. Clients can use this property to get and (in the case of non-repeated scalar fields) directly set the value of a protocol message field. Args: field: A FieldDescriptor for this field. cls: The class we're constructing. """ # Catch it if we add other types that we should # handle specially here. assert _FieldDescriptor.MAX_CPPTYPE == 10 constant_name = field.name.upper() + '_FIELD_NUMBER' setattr(cls, constant_name, field.number) if field.label == _FieldDescriptor.LABEL_REPEATED: _AddPropertiesForRepeatedField(field, cls) elif field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: _AddPropertiesForNonRepeatedCompositeField(field, cls) else: _AddPropertiesForNonRepeatedScalarField(field, cls) class _FieldProperty(property): __slots__ = ('DESCRIPTOR',) def __init__(self, descriptor, getter, setter, doc): property.__init__(self, getter, setter, doc=doc) self.DESCRIPTOR = descriptor def _AddPropertiesForRepeatedField(field, cls): """Adds a public property for a "repeated" protocol message field. Clients can use this property to get the value of the field, which will be either a RepeatedScalarFieldContainer or RepeatedCompositeFieldContainer (see below). Note that when clients add values to these containers, we perform type-checking in the case of repeated scalar fields, and we also set any necessary "has" bits as a side-effect. Args: field: A FieldDescriptor for this field. cls: The class we're constructing. """ proto_field_name = field.name property_name = _PropertyName(proto_field_name) def getter(self): field_value = self._fields.get(field) if field_value is None: # Construct a new object to represent this field. field_value = field._default_constructor(self) # Atomically check if another thread has preempted us and, if not, swap # in the new object we just created. If someone has preempted us, we # take that object and discard ours. # WARNING: We are relying on setdefault() being atomic. This is true # in CPython but we haven't investigated others. This warning appears # in several other locations in this file. field_value = self._fields.setdefault(field, field_value) return field_value getter.__module__ = None getter.__doc__ = 'Getter for %s.' % proto_field_name # We define a setter just so we can throw an exception with a more # helpful error message. def setter(self, new_value): raise AttributeError('Assignment not allowed to repeated field ' '"%s" in protocol message object.' % proto_field_name) doc = 'Magic attribute generated for "%s" proto field.' % proto_field_name setattr(cls, property_name, _FieldProperty(field, getter, setter, doc=doc)) def _AddPropertiesForNonRepeatedScalarField(field, cls): """Adds a public property for a nonrepeated, scalar protocol message field. Clients can use this property to get and directly set the value of the field. Note that when the client sets the value of a field by using this property, all necessary "has" bits are set as a side-effect, and we also perform type-checking. Args: field: A FieldDescriptor for this field. cls: The class we're constructing. """ proto_field_name = field.name property_name = _PropertyName(proto_field_name) type_checker = type_checkers.GetTypeChecker(field) default_value = field.default_value is_proto3 = field.containing_type.syntax == 'proto3' def getter(self): # TODO(protobuf-team): This may be broken since there may not be # default_value. Combine with has_default_value somehow. return self._fields.get(field, default_value) getter.__module__ = None getter.__doc__ = 'Getter for %s.' % proto_field_name clear_when_set_to_default = is_proto3 and not field.containing_oneof def field_setter(self, new_value): # pylint: disable=protected-access # Testing the value for truthiness captures all of the proto3 defaults # (0, 0.0, enum 0, and False). try: new_value = type_checker.CheckValue(new_value) except TypeError as e: raise TypeError( 'Cannot set %s to %.1024r: %s' % (field.full_name, new_value, e)) if clear_when_set_to_default and not new_value: self._fields.pop(field, None) else: self._fields[field] = new_value # Check _cached_byte_size_dirty inline to improve performance, since scalar # setters are called frequently. if not self._cached_byte_size_dirty: self._Modified() if field.containing_oneof: def setter(self, new_value): field_setter(self, new_value) self._UpdateOneofState(field) else: setter = field_setter setter.__module__ = None setter.__doc__ = 'Setter for %s.' % proto_field_name # Add a property to encapsulate the getter/setter. doc = 'Magic attribute generated for "%s" proto field.' % proto_field_name setattr(cls, property_name, _FieldProperty(field, getter, setter, doc=doc)) def _AddPropertiesForNonRepeatedCompositeField(field, cls): """Adds a public property for a nonrepeated, composite protocol message field. A composite field is a "group" or "message" field. Clients can use this property to get the value of the field, but cannot assign to the property directly. Args: field: A FieldDescriptor for this field. cls: The class we're constructing. """ # TODO(robinson): Remove duplication with similar method # for non-repeated scalars. proto_field_name = field.name property_name = _PropertyName(proto_field_name) def getter(self): field_value = self._fields.get(field) if field_value is None: # Construct a new object to represent this field. field_value = field._default_constructor(self) # Atomically check if another thread has preempted us and, if not, swap # in the new object we just created. If someone has preempted us, we # take that object and discard ours. # WARNING: We are relying on setdefault() being atomic. This is true # in CPython but we haven't investigated others. This warning appears # in several other locations in this file. field_value = self._fields.setdefault(field, field_value) return field_value getter.__module__ = None getter.__doc__ = 'Getter for %s.' % proto_field_name # We define a setter just so we can throw an exception with a more # helpful error message. def setter(self, new_value): raise AttributeError('Assignment not allowed to composite field ' '"%s" in protocol message object.' % proto_field_name) # Add a property to encapsulate the getter. doc = 'Magic attribute generated for "%s" proto field.' % proto_field_name setattr(cls, property_name, _FieldProperty(field, getter, setter, doc=doc)) def _AddPropertiesForExtensions(descriptor, cls): """Adds properties for all fields in this protocol message type.""" extensions = descriptor.extensions_by_name for extension_name, extension_field in extensions.items(): constant_name = extension_name.upper() + '_FIELD_NUMBER' setattr(cls, constant_name, extension_field.number) # TODO(amauryfa): Migrate all users of these attributes to functions like # pool.FindExtensionByNumber(descriptor). if descriptor.file is not None: # TODO(amauryfa): Use cls.MESSAGE_FACTORY.pool when available. pool = descriptor.file.pool cls._extensions_by_number = pool._extensions_by_number[descriptor] cls._extensions_by_name = pool._extensions_by_name[descriptor] def _AddStaticMethods(cls): # TODO(robinson): This probably needs to be thread-safe(?) def RegisterExtension(extension_handle): extension_handle.containing_type = cls.DESCRIPTOR # TODO(amauryfa): Use cls.MESSAGE_FACTORY.pool when available. # pylint: disable=protected-access cls.DESCRIPTOR.file.pool._AddExtensionDescriptor(extension_handle) _AttachFieldHelpers(cls, extension_handle) cls.RegisterExtension = staticmethod(RegisterExtension) def FromString(s): message = cls() message.MergeFromString(s) return message cls.FromString = staticmethod(FromString) def _IsPresent(item): """Given a (FieldDescriptor, value) tuple from _fields, return true if the value should be included in the list returned by ListFields().""" if item[0].label == _FieldDescriptor.LABEL_REPEATED: return bool(item[1]) elif item[0].cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: return item[1]._is_present_in_parent else: return True def _AddListFieldsMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def ListFields(self): all_fields = [item for item in self._fields.items() if _IsPresent(item)] all_fields.sort(key = lambda item: item[0].number) return all_fields cls.ListFields = ListFields _PROTO3_ERROR_TEMPLATE = \ ('Protocol message %s has no non-repeated submessage field "%s" ' 'nor marked as optional') _PROTO2_ERROR_TEMPLATE = 'Protocol message %s has no non-repeated field "%s"' def _AddHasFieldMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" is_proto3 = (message_descriptor.syntax == "proto3") error_msg = _PROTO3_ERROR_TEMPLATE if is_proto3 else _PROTO2_ERROR_TEMPLATE hassable_fields = {} for field in message_descriptor.fields: if field.label == _FieldDescriptor.LABEL_REPEATED: continue # For proto3, only submessages and fields inside a oneof have presence. if (is_proto3 and field.cpp_type != _FieldDescriptor.CPPTYPE_MESSAGE and not field.containing_oneof): continue hassable_fields[field.name] = field # Has methods are supported for oneof descriptors. for oneof in message_descriptor.oneofs: hassable_fields[oneof.name] = oneof def HasField(self, field_name): try: field = hassable_fields[field_name] except KeyError: raise ValueError(error_msg % (message_descriptor.full_name, field_name)) if isinstance(field, descriptor_mod.OneofDescriptor): try: return HasField(self, self._oneofs[field].name) except KeyError: return False else: if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: value = self._fields.get(field) return value is not None and value._is_present_in_parent else: return field in self._fields cls.HasField = HasField def _AddClearFieldMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def ClearField(self, field_name): try: field = message_descriptor.fields_by_name[field_name] except KeyError: try: field = message_descriptor.oneofs_by_name[field_name] if field in self._oneofs: field = self._oneofs[field] else: return except KeyError: raise ValueError('Protocol message %s has no "%s" field.' % (message_descriptor.name, field_name)) if field in self._fields: # To match the C++ implementation, we need to invalidate iterators # for map fields when ClearField() happens. if hasattr(self._fields[field], 'InvalidateIterators'): self._fields[field].InvalidateIterators() # Note: If the field is a sub-message, its listener will still point # at us. That's fine, because the worst than can happen is that it # will call _Modified() and invalidate our byte size. Big deal. del self._fields[field] if self._oneofs.get(field.containing_oneof, None) is field: del self._oneofs[field.containing_oneof] # Always call _Modified() -- even if nothing was changed, this is # a mutating method, and thus calling it should cause the field to become # present in the parent message. self._Modified() cls.ClearField = ClearField def _AddClearExtensionMethod(cls): """Helper for _AddMessageMethods().""" def ClearExtension(self, extension_handle): extension_dict._VerifyExtensionHandle(self, extension_handle) # Similar to ClearField(), above. if extension_handle in self._fields: del self._fields[extension_handle] self._Modified() cls.ClearExtension = ClearExtension def _AddHasExtensionMethod(cls): """Helper for _AddMessageMethods().""" def HasExtension(self, extension_handle): extension_dict._VerifyExtensionHandle(self, extension_handle) if extension_handle.label == _FieldDescriptor.LABEL_REPEATED: raise KeyError('"%s" is repeated.' % extension_handle.full_name) if extension_handle.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: value = self._fields.get(extension_handle) return value is not None and value._is_present_in_parent else: return extension_handle in self._fields cls.HasExtension = HasExtension def _InternalUnpackAny(msg): """Unpacks Any message and returns the unpacked message. This internal method is different from public Any Unpack method which takes the target message as argument. _InternalUnpackAny method does not have target message type and need to find the message type in descriptor pool. Args: msg: An Any message to be unpacked. Returns: The unpacked message. """ # TODO(amauryfa): Don't use the factory of generated messages. # To make Any work with custom factories, use the message factory of the # parent message. # pylint: disable=g-import-not-at-top from google.protobuf import symbol_database factory = symbol_database.Default() type_url = msg.type_url if not type_url: return None # TODO(haberman): For now we just strip the hostname. Better logic will be # required. type_name = type_url.split('/')[-1] descriptor = factory.pool.FindMessageTypeByName(type_name) if descriptor is None: return None message_class = factory.GetPrototype(descriptor) message = message_class() message.ParseFromString(msg.value) return message def _AddEqualsMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def __eq__(self, other): if (not isinstance(other, message_mod.Message) or other.DESCRIPTOR != self.DESCRIPTOR): return False if self is other: return True if self.DESCRIPTOR.full_name == _AnyFullTypeName: any_a = _InternalUnpackAny(self) any_b = _InternalUnpackAny(other) if any_a and any_b: return any_a == any_b if not self.ListFields() == other.ListFields(): return False # TODO(jieluo): Fix UnknownFieldSet to consider MessageSet extensions, # then use it for the comparison. unknown_fields = list(self._unknown_fields) unknown_fields.sort() other_unknown_fields = list(other._unknown_fields) other_unknown_fields.sort() return unknown_fields == other_unknown_fields cls.__eq__ = __eq__ def _AddStrMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def __str__(self): return text_format.MessageToString(self) cls.__str__ = __str__ def _AddReprMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def __repr__(self): return text_format.MessageToString(self) cls.__repr__ = __repr__ def _AddUnicodeMethod(unused_message_descriptor, cls): """Helper for _AddMessageMethods().""" def __unicode__(self): return text_format.MessageToString(self, as_utf8=True).decode('utf-8') cls.__unicode__ = __unicode__ def _BytesForNonRepeatedElement(value, field_number, field_type): """Returns the number of bytes needed to serialize a non-repeated element. The returned byte count includes space for tag information and any other additional space associated with serializing value. Args: value: Value we're serializing. field_number: Field number of this value. (Since the field number is stored as part of a varint-encoded tag, this has an impact on the total bytes required to serialize the value). field_type: The type of the field. One of the TYPE_* constants within FieldDescriptor. """ try: fn = type_checkers.TYPE_TO_BYTE_SIZE_FN[field_type] return fn(field_number, value) except KeyError: raise message_mod.EncodeError('Unrecognized field type: %d' % field_type) def _AddByteSizeMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def ByteSize(self): if not self._cached_byte_size_dirty: return self._cached_byte_size size = 0 descriptor = self.DESCRIPTOR if descriptor.GetOptions().map_entry: # Fields of map entry should always be serialized. size = descriptor.fields_by_name['key']._sizer(self.key) size += descriptor.fields_by_name['value']._sizer(self.value) else: for field_descriptor, field_value in self.ListFields(): size += field_descriptor._sizer(field_value) for tag_bytes, value_bytes in self._unknown_fields: size += len(tag_bytes) + len(value_bytes) self._cached_byte_size = size self._cached_byte_size_dirty = False self._listener_for_children.dirty = False return size cls.ByteSize = ByteSize def _AddSerializeToStringMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def SerializeToString(self, **kwargs): # Check if the message has all of its required fields set. if not self.IsInitialized(): raise message_mod.EncodeError( 'Message %s is missing required fields: %s' % ( self.DESCRIPTOR.full_name, ','.join(self.FindInitializationErrors()))) return self.SerializePartialToString(**kwargs) cls.SerializeToString = SerializeToString def _AddSerializePartialToStringMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def SerializePartialToString(self, **kwargs): out = BytesIO() self._InternalSerialize(out.write, **kwargs) return out.getvalue() cls.SerializePartialToString = SerializePartialToString def InternalSerialize(self, write_bytes, deterministic=None): if deterministic is None: deterministic = ( api_implementation.IsPythonDefaultSerializationDeterministic()) else: deterministic = bool(deterministic) descriptor = self.DESCRIPTOR if descriptor.GetOptions().map_entry: # Fields of map entry should always be serialized. descriptor.fields_by_name['key']._encoder( write_bytes, self.key, deterministic) descriptor.fields_by_name['value']._encoder( write_bytes, self.value, deterministic) else: for field_descriptor, field_value in self.ListFields(): field_descriptor._encoder(write_bytes, field_value, deterministic) for tag_bytes, value_bytes in self._unknown_fields: write_bytes(tag_bytes) write_bytes(value_bytes) cls._InternalSerialize = InternalSerialize def _AddMergeFromStringMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def MergeFromString(self, serialized): serialized = memoryview(serialized) length = len(serialized) try: if self._InternalParse(serialized, 0, length) != length: # The only reason _InternalParse would return early is if it # encountered an end-group tag. raise message_mod.DecodeError('Unexpected end-group tag.') except (IndexError, TypeError): # Now ord(buf[p:p+1]) == ord('') gets TypeError. raise message_mod.DecodeError('Truncated message.') except struct.error as e: raise message_mod.DecodeError(e) return length # Return this for legacy reasons. cls.MergeFromString = MergeFromString local_ReadTag = decoder.ReadTag local_SkipField = decoder.SkipField decoders_by_tag = cls._decoders_by_tag def InternalParse(self, buffer, pos, end): """Create a message from serialized bytes. Args: self: Message, instance of the proto message object. buffer: memoryview of the serialized data. pos: int, position to start in the serialized data. end: int, end position of the serialized data. Returns: Message object. """ # Guard against internal misuse, since this function is called internally # quite extensively, and its easy to accidentally pass bytes. assert isinstance(buffer, memoryview) self._Modified() field_dict = self._fields # pylint: disable=protected-access unknown_field_set = self._unknown_field_set while pos != end: (tag_bytes, new_pos) = local_ReadTag(buffer, pos) field_decoder, field_desc = decoders_by_tag.get(tag_bytes, (None, None)) if field_decoder is None: if not self._unknown_fields: # pylint: disable=protected-access self._unknown_fields = [] # pylint: disable=protected-access if unknown_field_set is None: # pylint: disable=protected-access self._unknown_field_set = containers.UnknownFieldSet() # pylint: disable=protected-access unknown_field_set = self._unknown_field_set # pylint: disable=protected-access (tag, _) = decoder._DecodeVarint(tag_bytes, 0) field_number, wire_type = wire_format.UnpackTag(tag) if field_number == 0: raise message_mod.DecodeError('Field number 0 is illegal.') # TODO(jieluo): remove old_pos. old_pos = new_pos (data, new_pos) = decoder._DecodeUnknownField( buffer, new_pos, wire_type) # pylint: disable=protected-access if new_pos == -1: return pos # pylint: disable=protected-access unknown_field_set._add(field_number, wire_type, data) # TODO(jieluo): remove _unknown_fields. new_pos = local_SkipField(buffer, old_pos, end, tag_bytes) if new_pos == -1: return pos self._unknown_fields.append( (tag_bytes, buffer[old_pos:new_pos].tobytes())) pos = new_pos else: pos = field_decoder(buffer, new_pos, end, self, field_dict) if field_desc: self._UpdateOneofState(field_desc) return pos cls._InternalParse = InternalParse def _AddIsInitializedMethod(message_descriptor, cls): """Adds the IsInitialized and FindInitializationError methods to the protocol message class.""" required_fields = [field for field in message_descriptor.fields if field.label == _FieldDescriptor.LABEL_REQUIRED] def IsInitialized(self, errors=None): """Checks if all required fields of a message are set. Args: errors: A list which, if provided, will be populated with the field paths of all missing required fields. Returns: True iff the specified message has all required fields set. """ # Performance is critical so we avoid HasField() and ListFields(). for field in required_fields: if (field not in self._fields or (field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE and not self._fields[field]._is_present_in_parent)): if errors is not None: errors.extend(self.FindInitializationErrors()) return False for field, value in list(self._fields.items()): # dict can change size! if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: if field.label == _FieldDescriptor.LABEL_REPEATED: if (field.message_type.has_options and field.message_type.GetOptions().map_entry): continue for element in value: if not element.IsInitialized(): if errors is not None: errors.extend(self.FindInitializationErrors()) return False elif value._is_present_in_parent and not value.IsInitialized(): if errors is not None: errors.extend(self.FindInitializationErrors()) return False return True cls.IsInitialized = IsInitialized def FindInitializationErrors(self): """Finds required fields which are not initialized. Returns: A list of strings. Each string is a path to an uninitialized field from the top-level message, e.g. "foo.bar[5].baz". """ errors = [] # simplify things for field in required_fields: if not self.HasField(field.name): errors.append(field.name) for field, value in self.ListFields(): if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: if field.is_extension: name = '(%s)' % field.full_name else: name = field.name if _IsMapField(field): if _IsMessageMapField(field): for key in value: element = value[key] prefix = '%s[%s].' % (name, key) sub_errors = element.FindInitializationErrors() errors += [prefix + error for error in sub_errors] else: # ScalarMaps can't have any initialization errors. pass elif field.label == _FieldDescriptor.LABEL_REPEATED: for i in range(len(value)): element = value[i] prefix = '%s[%d].' % (name, i) sub_errors = element.FindInitializationErrors() errors += [prefix + error for error in sub_errors] else: prefix = name + '.' sub_errors = value.FindInitializationErrors() errors += [prefix + error for error in sub_errors] return errors cls.FindInitializationErrors = FindInitializationErrors def _FullyQualifiedClassName(klass): module = klass.__module__ name = getattr(klass, '__qualname__', klass.__name__) if module in (None, 'builtins', '__builtin__'): return name return module + '.' + name def _AddMergeFromMethod(cls): LABEL_REPEATED = _FieldDescriptor.LABEL_REPEATED CPPTYPE_MESSAGE = _FieldDescriptor.CPPTYPE_MESSAGE def MergeFrom(self, msg): if not isinstance(msg, cls): raise TypeError( 'Parameter to MergeFrom() must be instance of same class: ' 'expected %s got %s.' % (_FullyQualifiedClassName(cls), _FullyQualifiedClassName(msg.__class__))) assert msg is not self self._Modified() fields = self._fields for field, value in msg._fields.items(): if field.label == LABEL_REPEATED: field_value = fields.get(field) if field_value is None: # Construct a new object to represent this field. field_value = field._default_constructor(self) fields[field] = field_value field_value.MergeFrom(value) elif field.cpp_type == CPPTYPE_MESSAGE: if value._is_present_in_parent: field_value = fields.get(field) if field_value is None: # Construct a new object to represent this field. field_value = field._default_constructor(self) fields[field] = field_value field_value.MergeFrom(value) else: self._fields[field] = value if field.containing_oneof: self._UpdateOneofState(field) if msg._unknown_fields: if not self._unknown_fields: self._unknown_fields = [] self._unknown_fields.extend(msg._unknown_fields) # pylint: disable=protected-access if self._unknown_field_set is None: self._unknown_field_set = containers.UnknownFieldSet() self._unknown_field_set._extend(msg._unknown_field_set) cls.MergeFrom = MergeFrom def _AddWhichOneofMethod(message_descriptor, cls): def WhichOneof(self, oneof_name): """Returns the name of the currently set field inside a oneof, or None.""" try: field = message_descriptor.oneofs_by_name[oneof_name] except KeyError: raise ValueError( 'Protocol message has no oneof "%s" field.' % oneof_name) nested_field = self._oneofs.get(field, None) if nested_field is not None and self.HasField(nested_field.name): return nested_field.name else: return None cls.WhichOneof = WhichOneof def _Clear(self): # Clear fields. self._fields = {} self._unknown_fields = () # pylint: disable=protected-access if self._unknown_field_set is not None: self._unknown_field_set._clear() self._unknown_field_set = None self._oneofs = {} self._Modified() def _UnknownFields(self): if self._unknown_field_set is None: # pylint: disable=protected-access # pylint: disable=protected-access self._unknown_field_set = containers.UnknownFieldSet() return self._unknown_field_set # pylint: disable=protected-access def _DiscardUnknownFields(self): self._unknown_fields = [] self._unknown_field_set = None # pylint: disable=protected-access for field, value in self.ListFields(): if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: if _IsMapField(field): if _IsMessageMapField(field): for key in value: value[key].DiscardUnknownFields() elif field.label == _FieldDescriptor.LABEL_REPEATED: for sub_message in value: sub_message.DiscardUnknownFields() else: value.DiscardUnknownFields() def _SetListener(self, listener): if listener is None: self._listener = message_listener_mod.NullMessageListener() else: self._listener = listener def _AddMessageMethods(message_descriptor, cls): """Adds implementations of all Message methods to cls.""" _AddListFieldsMethod(message_descriptor, cls) _AddHasFieldMethod(message_descriptor, cls) _AddClearFieldMethod(message_descriptor, cls) if message_descriptor.is_extendable: _AddClearExtensionMethod(cls) _AddHasExtensionMethod(cls) _AddEqualsMethod(message_descriptor, cls) _AddStrMethod(message_descriptor, cls) _AddReprMethod(message_descriptor, cls) _AddUnicodeMethod(message_descriptor, cls) _AddByteSizeMethod(message_descriptor, cls) _AddSerializeToStringMethod(message_descriptor, cls) _AddSerializePartialToStringMethod(message_descriptor, cls) _AddMergeFromStringMethod(message_descriptor, cls) _AddIsInitializedMethod(message_descriptor, cls) _AddMergeFromMethod(cls) _AddWhichOneofMethod(message_descriptor, cls) # Adds methods which do not depend on cls. cls.Clear = _Clear cls.UnknownFields = _UnknownFields cls.DiscardUnknownFields = _DiscardUnknownFields cls._SetListener = _SetListener def _AddPrivateHelperMethods(message_descriptor, cls): """Adds implementation of private helper methods to cls.""" def Modified(self): """Sets the _cached_byte_size_dirty bit to true, and propagates this to our listener iff this was a state change. """ # Note: Some callers check _cached_byte_size_dirty before calling # _Modified() as an extra optimization. So, if this method is ever # changed such that it does stuff even when _cached_byte_size_dirty is # already true, the callers need to be updated. if not self._cached_byte_size_dirty: self._cached_byte_size_dirty = True self._listener_for_children.dirty = True self._is_present_in_parent = True self._listener.Modified() def _UpdateOneofState(self, field): """Sets field as the active field in its containing oneof. Will also delete currently active field in the oneof, if it is different from the argument. Does not mark the message as modified. """ other_field = self._oneofs.setdefault(field.containing_oneof, field) if other_field is not field: del self._fields[other_field] self._oneofs[field.containing_oneof] = field cls._Modified = Modified cls.SetInParent = Modified cls._UpdateOneofState = _UpdateOneofState class _Listener(object): """MessageListener implementation that a parent message registers with its child message. In order to support semantics like: foo.bar.baz.qux = 23 assert foo.HasField('bar') ...child objects must have back references to their parents. This helper class is at the heart of this support. """ def __init__(self, parent_message): """Args: parent_message: The message whose _Modified() method we should call when we receive Modified() messages. """ # This listener establishes a back reference from a child (contained) object # to its parent (containing) object. We make this a weak reference to avoid # creating cyclic garbage when the client finishes with the 'parent' object # in the tree. if isinstance(parent_message, weakref.ProxyType): self._parent_message_weakref = parent_message else: self._parent_message_weakref = weakref.proxy(parent_message) # As an optimization, we also indicate directly on the listener whether # or not the parent message is dirty. This way we can avoid traversing # up the tree in the common case. self.dirty = False def Modified(self): if self.dirty: return try: # Propagate the signal to our parents iff this is the first field set. self._parent_message_weakref._Modified() except ReferenceError: # We can get here if a client has kept a reference to a child object, # and is now setting a field on it, but the child's parent has been # garbage-collected. This is not an error. pass class _OneofListener(_Listener): """Special listener implementation for setting composite oneof fields.""" def __init__(self, parent_message, field): """Args: parent_message: The message whose _Modified() method we should call when we receive Modified() messages. field: The descriptor of the field being set in the parent message. """ super(_OneofListener, self).__init__(parent_message) self._field = field def Modified(self): """Also updates the state of the containing oneof in the parent message.""" try: self._parent_message_weakref._UpdateOneofState(self._field) super(_OneofListener, self).Modified() except ReferenceError: pass ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/type_checkers.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Provides type checking routines. This module defines type checking utilities in the forms of dictionaries: VALUE_CHECKERS: A dictionary of field types and a value validation object. TYPE_TO_BYTE_SIZE_FN: A dictionary with field types and a size computing function. TYPE_TO_SERIALIZE_METHOD: A dictionary with field types and serialization function. FIELD_TYPE_TO_WIRE_TYPE: A dictionary with field typed and their corresponding wire types. TYPE_TO_DESERIALIZE_METHOD: A dictionary with field types and deserialization function. """ __author__ = 'robinson@google.com (Will Robinson)' import ctypes import numbers from google.protobuf.internal import decoder from google.protobuf.internal import encoder from google.protobuf.internal import wire_format from google.protobuf import descriptor _FieldDescriptor = descriptor.FieldDescriptor def TruncateToFourByteFloat(original): return ctypes.c_float(original).value def ToShortestFloat(original): """Returns the shortest float that has same value in wire.""" # All 4 byte floats have between 6 and 9 significant digits, so we # start with 6 as the lower bound. # It has to be iterative because use '.9g' directly can not get rid # of the noises for most values. For example if set a float_field=0.9 # use '.9g' will print 0.899999976. precision = 6 rounded = float('{0:.{1}g}'.format(original, precision)) while TruncateToFourByteFloat(rounded) != original: precision += 1 rounded = float('{0:.{1}g}'.format(original, precision)) return rounded def SupportsOpenEnums(field_descriptor): return field_descriptor.containing_type.syntax == 'proto3' def GetTypeChecker(field): """Returns a type checker for a message field of the specified types. Args: field: FieldDescriptor object for this field. Returns: An instance of TypeChecker which can be used to verify the types of values assigned to a field of the specified type. """ if (field.cpp_type == _FieldDescriptor.CPPTYPE_STRING and field.type == _FieldDescriptor.TYPE_STRING): return UnicodeValueChecker() if field.cpp_type == _FieldDescriptor.CPPTYPE_ENUM: if SupportsOpenEnums(field): # When open enums are supported, any int32 can be assigned. return _VALUE_CHECKERS[_FieldDescriptor.CPPTYPE_INT32] else: return EnumValueChecker(field.enum_type) return _VALUE_CHECKERS[field.cpp_type] # None of the typecheckers below make any attempt to guard against people # subclassing builtin types and doing weird things. We're not trying to # protect against malicious clients here, just people accidentally shooting # themselves in the foot in obvious ways. class TypeChecker(object): """Type checker used to catch type errors as early as possible when the client is setting scalar fields in protocol messages. """ def __init__(self, *acceptable_types): self._acceptable_types = acceptable_types def CheckValue(self, proposed_value): """Type check the provided value and return it. The returned value might have been normalized to another type. """ if not isinstance(proposed_value, self._acceptable_types): message = ('%.1024r has type %s, but expected one of: %s' % (proposed_value, type(proposed_value), self._acceptable_types)) raise TypeError(message) return proposed_value class TypeCheckerWithDefault(TypeChecker): def __init__(self, default_value, *acceptable_types): TypeChecker.__init__(self, *acceptable_types) self._default_value = default_value def DefaultValue(self): return self._default_value class BoolValueChecker(object): """Type checker used for bool fields.""" def CheckValue(self, proposed_value): if not hasattr(proposed_value, '__index__') or ( type(proposed_value).__module__ == 'numpy' and type(proposed_value).__name__ == 'ndarray'): message = ('%.1024r has type %s, but expected one of: %s' % (proposed_value, type(proposed_value), (bool, int))) raise TypeError(message) return bool(proposed_value) def DefaultValue(self): return False # IntValueChecker and its subclasses perform integer type-checks # and bounds-checks. class IntValueChecker(object): """Checker used for integer fields. Performs type-check and range check.""" def CheckValue(self, proposed_value): if not hasattr(proposed_value, '__index__') or ( type(proposed_value).__module__ == 'numpy' and type(proposed_value).__name__ == 'ndarray'): message = ('%.1024r has type %s, but expected one of: %s' % (proposed_value, type(proposed_value), (int,))) raise TypeError(message) if not self._MIN <= int(proposed_value) <= self._MAX: raise ValueError('Value out of range: %d' % proposed_value) # We force all values to int to make alternate implementations where the # distinction is more significant (e.g. the C++ implementation) simpler. proposed_value = int(proposed_value) return proposed_value def DefaultValue(self): return 0 class EnumValueChecker(object): """Checker used for enum fields. Performs type-check and range check.""" def __init__(self, enum_type): self._enum_type = enum_type def CheckValue(self, proposed_value): if not isinstance(proposed_value, numbers.Integral): message = ('%.1024r has type %s, but expected one of: %s' % (proposed_value, type(proposed_value), (int,))) raise TypeError(message) if int(proposed_value) not in self._enum_type.values_by_number: raise ValueError('Unknown enum value: %d' % proposed_value) return proposed_value def DefaultValue(self): return self._enum_type.values[0].number class UnicodeValueChecker(object): """Checker used for string fields. Always returns a unicode value, even if the input is of type str. """ def CheckValue(self, proposed_value): if not isinstance(proposed_value, (bytes, str)): message = ('%.1024r has type %s, but expected one of: %s' % (proposed_value, type(proposed_value), (bytes, str))) raise TypeError(message) # If the value is of type 'bytes' make sure that it is valid UTF-8 data. if isinstance(proposed_value, bytes): try: proposed_value = proposed_value.decode('utf-8') except UnicodeDecodeError: raise ValueError('%.1024r has type bytes, but isn\'t valid UTF-8 ' 'encoding. Non-UTF-8 strings must be converted to ' 'unicode objects before being added.' % (proposed_value)) else: try: proposed_value.encode('utf8') except UnicodeEncodeError: raise ValueError('%.1024r isn\'t a valid unicode string and ' 'can\'t be encoded in UTF-8.'% (proposed_value)) return proposed_value def DefaultValue(self): return u"" class Int32ValueChecker(IntValueChecker): # We're sure to use ints instead of longs here since comparison may be more # efficient. _MIN = -2147483648 _MAX = 2147483647 class Uint32ValueChecker(IntValueChecker): _MIN = 0 _MAX = (1 << 32) - 1 class Int64ValueChecker(IntValueChecker): _MIN = -(1 << 63) _MAX = (1 << 63) - 1 class Uint64ValueChecker(IntValueChecker): _MIN = 0 _MAX = (1 << 64) - 1 # The max 4 bytes float is about 3.4028234663852886e+38 _FLOAT_MAX = float.fromhex('0x1.fffffep+127') _FLOAT_MIN = -_FLOAT_MAX _INF = float('inf') _NEG_INF = float('-inf') class DoubleValueChecker(object): """Checker used for double fields. Performs type-check and range check. """ def CheckValue(self, proposed_value): """Check and convert proposed_value to float.""" if (not hasattr(proposed_value, '__float__') and not hasattr(proposed_value, '__index__')) or ( type(proposed_value).__module__ == 'numpy' and type(proposed_value).__name__ == 'ndarray'): message = ('%.1024r has type %s, but expected one of: int, float' % (proposed_value, type(proposed_value))) raise TypeError(message) return float(proposed_value) def DefaultValue(self): return 0.0 class FloatValueChecker(DoubleValueChecker): """Checker used for float fields. Performs type-check and range check. Values exceeding a 32-bit float will be converted to inf/-inf. """ def CheckValue(self, proposed_value): """Check and convert proposed_value to float.""" converted_value = super().CheckValue(proposed_value) # This inf rounding matches the C++ proto SafeDoubleToFloat logic. if converted_value > _FLOAT_MAX: return _INF if converted_value < _FLOAT_MIN: return _NEG_INF return TruncateToFourByteFloat(converted_value) # Type-checkers for all scalar CPPTYPEs. _VALUE_CHECKERS = { _FieldDescriptor.CPPTYPE_INT32: Int32ValueChecker(), _FieldDescriptor.CPPTYPE_INT64: Int64ValueChecker(), _FieldDescriptor.CPPTYPE_UINT32: Uint32ValueChecker(), _FieldDescriptor.CPPTYPE_UINT64: Uint64ValueChecker(), _FieldDescriptor.CPPTYPE_DOUBLE: DoubleValueChecker(), _FieldDescriptor.CPPTYPE_FLOAT: FloatValueChecker(), _FieldDescriptor.CPPTYPE_BOOL: BoolValueChecker(), _FieldDescriptor.CPPTYPE_STRING: TypeCheckerWithDefault(b'', bytes), } # Map from field type to a function F, such that F(field_num, value) # gives the total byte size for a value of the given type. This # byte size includes tag information and any other additional space # associated with serializing "value". TYPE_TO_BYTE_SIZE_FN = { _FieldDescriptor.TYPE_DOUBLE: wire_format.DoubleByteSize, _FieldDescriptor.TYPE_FLOAT: wire_format.FloatByteSize, _FieldDescriptor.TYPE_INT64: wire_format.Int64ByteSize, _FieldDescriptor.TYPE_UINT64: wire_format.UInt64ByteSize, _FieldDescriptor.TYPE_INT32: wire_format.Int32ByteSize, _FieldDescriptor.TYPE_FIXED64: wire_format.Fixed64ByteSize, _FieldDescriptor.TYPE_FIXED32: wire_format.Fixed32ByteSize, _FieldDescriptor.TYPE_BOOL: wire_format.BoolByteSize, _FieldDescriptor.TYPE_STRING: wire_format.StringByteSize, _FieldDescriptor.TYPE_GROUP: wire_format.GroupByteSize, _FieldDescriptor.TYPE_MESSAGE: wire_format.MessageByteSize, _FieldDescriptor.TYPE_BYTES: wire_format.BytesByteSize, _FieldDescriptor.TYPE_UINT32: wire_format.UInt32ByteSize, _FieldDescriptor.TYPE_ENUM: wire_format.EnumByteSize, _FieldDescriptor.TYPE_SFIXED32: wire_format.SFixed32ByteSize, _FieldDescriptor.TYPE_SFIXED64: wire_format.SFixed64ByteSize, _FieldDescriptor.TYPE_SINT32: wire_format.SInt32ByteSize, _FieldDescriptor.TYPE_SINT64: wire_format.SInt64ByteSize } # Maps from field types to encoder constructors. TYPE_TO_ENCODER = { _FieldDescriptor.TYPE_DOUBLE: encoder.DoubleEncoder, _FieldDescriptor.TYPE_FLOAT: encoder.FloatEncoder, _FieldDescriptor.TYPE_INT64: encoder.Int64Encoder, _FieldDescriptor.TYPE_UINT64: encoder.UInt64Encoder, _FieldDescriptor.TYPE_INT32: encoder.Int32Encoder, _FieldDescriptor.TYPE_FIXED64: encoder.Fixed64Encoder, _FieldDescriptor.TYPE_FIXED32: encoder.Fixed32Encoder, _FieldDescriptor.TYPE_BOOL: encoder.BoolEncoder, _FieldDescriptor.TYPE_STRING: encoder.StringEncoder, _FieldDescriptor.TYPE_GROUP: encoder.GroupEncoder, _FieldDescriptor.TYPE_MESSAGE: encoder.MessageEncoder, _FieldDescriptor.TYPE_BYTES: encoder.BytesEncoder, _FieldDescriptor.TYPE_UINT32: encoder.UInt32Encoder, _FieldDescriptor.TYPE_ENUM: encoder.EnumEncoder, _FieldDescriptor.TYPE_SFIXED32: encoder.SFixed32Encoder, _FieldDescriptor.TYPE_SFIXED64: encoder.SFixed64Encoder, _FieldDescriptor.TYPE_SINT32: encoder.SInt32Encoder, _FieldDescriptor.TYPE_SINT64: encoder.SInt64Encoder, } # Maps from field types to sizer constructors. TYPE_TO_SIZER = { _FieldDescriptor.TYPE_DOUBLE: encoder.DoubleSizer, _FieldDescriptor.TYPE_FLOAT: encoder.FloatSizer, _FieldDescriptor.TYPE_INT64: encoder.Int64Sizer, _FieldDescriptor.TYPE_UINT64: encoder.UInt64Sizer, _FieldDescriptor.TYPE_INT32: encoder.Int32Sizer, _FieldDescriptor.TYPE_FIXED64: encoder.Fixed64Sizer, _FieldDescriptor.TYPE_FIXED32: encoder.Fixed32Sizer, _FieldDescriptor.TYPE_BOOL: encoder.BoolSizer, _FieldDescriptor.TYPE_STRING: encoder.StringSizer, _FieldDescriptor.TYPE_GROUP: encoder.GroupSizer, _FieldDescriptor.TYPE_MESSAGE: encoder.MessageSizer, _FieldDescriptor.TYPE_BYTES: encoder.BytesSizer, _FieldDescriptor.TYPE_UINT32: encoder.UInt32Sizer, _FieldDescriptor.TYPE_ENUM: encoder.EnumSizer, _FieldDescriptor.TYPE_SFIXED32: encoder.SFixed32Sizer, _FieldDescriptor.TYPE_SFIXED64: encoder.SFixed64Sizer, _FieldDescriptor.TYPE_SINT32: encoder.SInt32Sizer, _FieldDescriptor.TYPE_SINT64: encoder.SInt64Sizer, } # Maps from field type to a decoder constructor. TYPE_TO_DECODER = { _FieldDescriptor.TYPE_DOUBLE: decoder.DoubleDecoder, _FieldDescriptor.TYPE_FLOAT: decoder.FloatDecoder, _FieldDescriptor.TYPE_INT64: decoder.Int64Decoder, _FieldDescriptor.TYPE_UINT64: decoder.UInt64Decoder, _FieldDescriptor.TYPE_INT32: decoder.Int32Decoder, _FieldDescriptor.TYPE_FIXED64: decoder.Fixed64Decoder, _FieldDescriptor.TYPE_FIXED32: decoder.Fixed32Decoder, _FieldDescriptor.TYPE_BOOL: decoder.BoolDecoder, _FieldDescriptor.TYPE_STRING: decoder.StringDecoder, _FieldDescriptor.TYPE_GROUP: decoder.GroupDecoder, _FieldDescriptor.TYPE_MESSAGE: decoder.MessageDecoder, _FieldDescriptor.TYPE_BYTES: decoder.BytesDecoder, _FieldDescriptor.TYPE_UINT32: decoder.UInt32Decoder, _FieldDescriptor.TYPE_ENUM: decoder.EnumDecoder, _FieldDescriptor.TYPE_SFIXED32: decoder.SFixed32Decoder, _FieldDescriptor.TYPE_SFIXED64: decoder.SFixed64Decoder, _FieldDescriptor.TYPE_SINT32: decoder.SInt32Decoder, _FieldDescriptor.TYPE_SINT64: decoder.SInt64Decoder, } # Maps from field type to expected wiretype. FIELD_TYPE_TO_WIRE_TYPE = { _FieldDescriptor.TYPE_DOUBLE: wire_format.WIRETYPE_FIXED64, _FieldDescriptor.TYPE_FLOAT: wire_format.WIRETYPE_FIXED32, _FieldDescriptor.TYPE_INT64: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_UINT64: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_INT32: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_FIXED64: wire_format.WIRETYPE_FIXED64, _FieldDescriptor.TYPE_FIXED32: wire_format.WIRETYPE_FIXED32, _FieldDescriptor.TYPE_BOOL: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_STRING: wire_format.WIRETYPE_LENGTH_DELIMITED, _FieldDescriptor.TYPE_GROUP: wire_format.WIRETYPE_START_GROUP, _FieldDescriptor.TYPE_MESSAGE: wire_format.WIRETYPE_LENGTH_DELIMITED, _FieldDescriptor.TYPE_BYTES: wire_format.WIRETYPE_LENGTH_DELIMITED, _FieldDescriptor.TYPE_UINT32: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_ENUM: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_SFIXED32: wire_format.WIRETYPE_FIXED32, _FieldDescriptor.TYPE_SFIXED64: wire_format.WIRETYPE_FIXED64, _FieldDescriptor.TYPE_SINT32: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_SINT64: wire_format.WIRETYPE_VARINT, } ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/well_known_types.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Contains well known classes. This files defines well known classes which need extra maintenance including: - Any - Duration - FieldMask - Struct - Timestamp """ __author__ = 'jieluo@google.com (Jie Luo)' import calendar import collections.abc import datetime from google.protobuf.descriptor import FieldDescriptor _TIMESTAMPFOMAT = '%Y-%m-%dT%H:%M:%S' _NANOS_PER_SECOND = 1000000000 _NANOS_PER_MILLISECOND = 1000000 _NANOS_PER_MICROSECOND = 1000 _MILLIS_PER_SECOND = 1000 _MICROS_PER_SECOND = 1000000 _SECONDS_PER_DAY = 24 * 3600 _DURATION_SECONDS_MAX = 315576000000 class Any(object): """Class for Any Message type.""" __slots__ = () def Pack(self, msg, type_url_prefix='type.googleapis.com/', deterministic=None): """Packs the specified message into current Any message.""" if len(type_url_prefix) < 1 or type_url_prefix[-1] != '/': self.type_url = '%s/%s' % (type_url_prefix, msg.DESCRIPTOR.full_name) else: self.type_url = '%s%s' % (type_url_prefix, msg.DESCRIPTOR.full_name) self.value = msg.SerializeToString(deterministic=deterministic) def Unpack(self, msg): """Unpacks the current Any message into specified message.""" descriptor = msg.DESCRIPTOR if not self.Is(descriptor): return False msg.ParseFromString(self.value) return True def TypeName(self): """Returns the protobuf type name of the inner message.""" # Only last part is to be used: b/25630112 return self.type_url.split('/')[-1] def Is(self, descriptor): """Checks if this Any represents the given protobuf type.""" return '/' in self.type_url and self.TypeName() == descriptor.full_name _EPOCH_DATETIME_NAIVE = datetime.datetime.utcfromtimestamp(0) _EPOCH_DATETIME_AWARE = datetime.datetime.fromtimestamp( 0, tz=datetime.timezone.utc) class Timestamp(object): """Class for Timestamp message type.""" __slots__ = () def ToJsonString(self): """Converts Timestamp to RFC 3339 date string format. Returns: A string converted from timestamp. The string is always Z-normalized and uses 3, 6 or 9 fractional digits as required to represent the exact time. Example of the return format: '1972-01-01T10:00:20.021Z' """ nanos = self.nanos % _NANOS_PER_SECOND total_sec = self.seconds + (self.nanos - nanos) // _NANOS_PER_SECOND seconds = total_sec % _SECONDS_PER_DAY days = (total_sec - seconds) // _SECONDS_PER_DAY dt = datetime.datetime(1970, 1, 1) + datetime.timedelta(days, seconds) result = dt.isoformat() if (nanos % 1e9) == 0: # If there are 0 fractional digits, the fractional # point '.' should be omitted when serializing. return result + 'Z' if (nanos % 1e6) == 0: # Serialize 3 fractional digits. return result + '.%03dZ' % (nanos / 1e6) if (nanos % 1e3) == 0: # Serialize 6 fractional digits. return result + '.%06dZ' % (nanos / 1e3) # Serialize 9 fractional digits. return result + '.%09dZ' % nanos def FromJsonString(self, value): """Parse a RFC 3339 date string format to Timestamp. Args: value: A date string. Any fractional digits (or none) and any offset are accepted as long as they fit into nano-seconds precision. Example of accepted format: '1972-01-01T10:00:20.021-05:00' Raises: ValueError: On parsing problems. """ if not isinstance(value, str): raise ValueError('Timestamp JSON value not a string: {!r}'.format(value)) timezone_offset = value.find('Z') if timezone_offset == -1: timezone_offset = value.find('+') if timezone_offset == -1: timezone_offset = value.rfind('-') if timezone_offset == -1: raise ValueError( 'Failed to parse timestamp: missing valid timezone offset.') time_value = value[0:timezone_offset] # Parse datetime and nanos. point_position = time_value.find('.') if point_position == -1: second_value = time_value nano_value = '' else: second_value = time_value[:point_position] nano_value = time_value[point_position + 1:] if 't' in second_value: raise ValueError( 'time data \'{0}\' does not match format \'%Y-%m-%dT%H:%M:%S\', ' 'lowercase \'t\' is not accepted'.format(second_value)) date_object = datetime.datetime.strptime(second_value, _TIMESTAMPFOMAT) td = date_object - datetime.datetime(1970, 1, 1) seconds = td.seconds + td.days * _SECONDS_PER_DAY if len(nano_value) > 9: raise ValueError( 'Failed to parse Timestamp: nanos {0} more than ' '9 fractional digits.'.format(nano_value)) if nano_value: nanos = round(float('0.' + nano_value) * 1e9) else: nanos = 0 # Parse timezone offsets. if value[timezone_offset] == 'Z': if len(value) != timezone_offset + 1: raise ValueError('Failed to parse timestamp: invalid trailing' ' data {0}.'.format(value)) else: timezone = value[timezone_offset:] pos = timezone.find(':') if pos == -1: raise ValueError( 'Invalid timezone offset value: {0}.'.format(timezone)) if timezone[0] == '+': seconds -= (int(timezone[1:pos])*60+int(timezone[pos+1:]))*60 else: seconds += (int(timezone[1:pos])*60+int(timezone[pos+1:]))*60 # Set seconds and nanos self.seconds = int(seconds) self.nanos = int(nanos) def GetCurrentTime(self): """Get the current UTC into Timestamp.""" self.FromDatetime(datetime.datetime.utcnow()) def ToNanoseconds(self): """Converts Timestamp to nanoseconds since epoch.""" return self.seconds * _NANOS_PER_SECOND + self.nanos def ToMicroseconds(self): """Converts Timestamp to microseconds since epoch.""" return (self.seconds * _MICROS_PER_SECOND + self.nanos // _NANOS_PER_MICROSECOND) def ToMilliseconds(self): """Converts Timestamp to milliseconds since epoch.""" return (self.seconds * _MILLIS_PER_SECOND + self.nanos // _NANOS_PER_MILLISECOND) def ToSeconds(self): """Converts Timestamp to seconds since epoch.""" return self.seconds def FromNanoseconds(self, nanos): """Converts nanoseconds since epoch to Timestamp.""" self.seconds = nanos // _NANOS_PER_SECOND self.nanos = nanos % _NANOS_PER_SECOND def FromMicroseconds(self, micros): """Converts microseconds since epoch to Timestamp.""" self.seconds = micros // _MICROS_PER_SECOND self.nanos = (micros % _MICROS_PER_SECOND) * _NANOS_PER_MICROSECOND def FromMilliseconds(self, millis): """Converts milliseconds since epoch to Timestamp.""" self.seconds = millis // _MILLIS_PER_SECOND self.nanos = (millis % _MILLIS_PER_SECOND) * _NANOS_PER_MILLISECOND def FromSeconds(self, seconds): """Converts seconds since epoch to Timestamp.""" self.seconds = seconds self.nanos = 0 def ToDatetime(self, tzinfo=None): """Converts Timestamp to a datetime. Args: tzinfo: A datetime.tzinfo subclass; defaults to None. Returns: If tzinfo is None, returns a timezone-naive UTC datetime (with no timezone information, i.e. not aware that it's UTC). Otherwise, returns a timezone-aware datetime in the input timezone. """ delta = datetime.timedelta( seconds=self.seconds, microseconds=_RoundTowardZero(self.nanos, _NANOS_PER_MICROSECOND)) if tzinfo is None: return _EPOCH_DATETIME_NAIVE + delta else: return _EPOCH_DATETIME_AWARE.astimezone(tzinfo) + delta def FromDatetime(self, dt): """Converts datetime to Timestamp. Args: dt: A datetime. If it's timezone-naive, it's assumed to be in UTC. """ # Using this guide: http://wiki.python.org/moin/WorkingWithTime # And this conversion guide: http://docs.python.org/library/time.html # Turn the date parameter into a tuple (struct_time) that can then be # manipulated into a long value of seconds. During the conversion from # struct_time to long, the source date in UTC, and so it follows that the # correct transformation is calendar.timegm() self.seconds = calendar.timegm(dt.utctimetuple()) self.nanos = dt.microsecond * _NANOS_PER_MICROSECOND class Duration(object): """Class for Duration message type.""" __slots__ = () def ToJsonString(self): """Converts Duration to string format. Returns: A string converted from self. The string format will contains 3, 6, or 9 fractional digits depending on the precision required to represent the exact Duration value. For example: "1s", "1.010s", "1.000000100s", "-3.100s" """ _CheckDurationValid(self.seconds, self.nanos) if self.seconds < 0 or self.nanos < 0: result = '-' seconds = - self.seconds + int((0 - self.nanos) // 1e9) nanos = (0 - self.nanos) % 1e9 else: result = '' seconds = self.seconds + int(self.nanos // 1e9) nanos = self.nanos % 1e9 result += '%d' % seconds if (nanos % 1e9) == 0: # If there are 0 fractional digits, the fractional # point '.' should be omitted when serializing. return result + 's' if (nanos % 1e6) == 0: # Serialize 3 fractional digits. return result + '.%03ds' % (nanos / 1e6) if (nanos % 1e3) == 0: # Serialize 6 fractional digits. return result + '.%06ds' % (nanos / 1e3) # Serialize 9 fractional digits. return result + '.%09ds' % nanos def FromJsonString(self, value): """Converts a string to Duration. Args: value: A string to be converted. The string must end with 's'. Any fractional digits (or none) are accepted as long as they fit into precision. For example: "1s", "1.01s", "1.0000001s", "-3.100s Raises: ValueError: On parsing problems. """ if not isinstance(value, str): raise ValueError('Duration JSON value not a string: {!r}'.format(value)) if len(value) < 1 or value[-1] != 's': raise ValueError( 'Duration must end with letter "s": {0}.'.format(value)) try: pos = value.find('.') if pos == -1: seconds = int(value[:-1]) nanos = 0 else: seconds = int(value[:pos]) if value[0] == '-': nanos = int(round(float('-0{0}'.format(value[pos: -1])) *1e9)) else: nanos = int(round(float('0{0}'.format(value[pos: -1])) *1e9)) _CheckDurationValid(seconds, nanos) self.seconds = seconds self.nanos = nanos except ValueError as e: raise ValueError( 'Couldn\'t parse duration: {0} : {1}.'.format(value, e)) def ToNanoseconds(self): """Converts a Duration to nanoseconds.""" return self.seconds * _NANOS_PER_SECOND + self.nanos def ToMicroseconds(self): """Converts a Duration to microseconds.""" micros = _RoundTowardZero(self.nanos, _NANOS_PER_MICROSECOND) return self.seconds * _MICROS_PER_SECOND + micros def ToMilliseconds(self): """Converts a Duration to milliseconds.""" millis = _RoundTowardZero(self.nanos, _NANOS_PER_MILLISECOND) return self.seconds * _MILLIS_PER_SECOND + millis def ToSeconds(self): """Converts a Duration to seconds.""" return self.seconds def FromNanoseconds(self, nanos): """Converts nanoseconds to Duration.""" self._NormalizeDuration(nanos // _NANOS_PER_SECOND, nanos % _NANOS_PER_SECOND) def FromMicroseconds(self, micros): """Converts microseconds to Duration.""" self._NormalizeDuration( micros // _MICROS_PER_SECOND, (micros % _MICROS_PER_SECOND) * _NANOS_PER_MICROSECOND) def FromMilliseconds(self, millis): """Converts milliseconds to Duration.""" self._NormalizeDuration( millis // _MILLIS_PER_SECOND, (millis % _MILLIS_PER_SECOND) * _NANOS_PER_MILLISECOND) def FromSeconds(self, seconds): """Converts seconds to Duration.""" self.seconds = seconds self.nanos = 0 def ToTimedelta(self): """Converts Duration to timedelta.""" return datetime.timedelta( seconds=self.seconds, microseconds=_RoundTowardZero( self.nanos, _NANOS_PER_MICROSECOND)) def FromTimedelta(self, td): """Converts timedelta to Duration.""" self._NormalizeDuration(td.seconds + td.days * _SECONDS_PER_DAY, td.microseconds * _NANOS_PER_MICROSECOND) def _NormalizeDuration(self, seconds, nanos): """Set Duration by seconds and nanos.""" # Force nanos to be negative if the duration is negative. if seconds < 0 and nanos > 0: seconds += 1 nanos -= _NANOS_PER_SECOND self.seconds = seconds self.nanos = nanos def _CheckDurationValid(seconds, nanos): if seconds < -_DURATION_SECONDS_MAX or seconds > _DURATION_SECONDS_MAX: raise ValueError( 'Duration is not valid: Seconds {0} must be in range ' '[-315576000000, 315576000000].'.format(seconds)) if nanos <= -_NANOS_PER_SECOND or nanos >= _NANOS_PER_SECOND: raise ValueError( 'Duration is not valid: Nanos {0} must be in range ' '[-999999999, 999999999].'.format(nanos)) if (nanos < 0 and seconds > 0) or (nanos > 0 and seconds < 0): raise ValueError( 'Duration is not valid: Sign mismatch.') def _RoundTowardZero(value, divider): """Truncates the remainder part after division.""" # For some languages, the sign of the remainder is implementation # dependent if any of the operands is negative. Here we enforce # "rounded toward zero" semantics. For example, for (-5) / 2 an # implementation may give -3 as the result with the remainder being # 1. This function ensures we always return -2 (closer to zero). result = value // divider remainder = value % divider if result < 0 and remainder > 0: return result + 1 else: return result class FieldMask(object): """Class for FieldMask message type.""" __slots__ = () def ToJsonString(self): """Converts FieldMask to string according to proto3 JSON spec.""" camelcase_paths = [] for path in self.paths: camelcase_paths.append(_SnakeCaseToCamelCase(path)) return ','.join(camelcase_paths) def FromJsonString(self, value): """Converts string to FieldMask according to proto3 JSON spec.""" if not isinstance(value, str): raise ValueError('FieldMask JSON value not a string: {!r}'.format(value)) self.Clear() if value: for path in value.split(','): self.paths.append(_CamelCaseToSnakeCase(path)) def IsValidForDescriptor(self, message_descriptor): """Checks whether the FieldMask is valid for Message Descriptor.""" for path in self.paths: if not _IsValidPath(message_descriptor, path): return False return True def AllFieldsFromDescriptor(self, message_descriptor): """Gets all direct fields of Message Descriptor to FieldMask.""" self.Clear() for field in message_descriptor.fields: self.paths.append(field.name) def CanonicalFormFromMask(self, mask): """Converts a FieldMask to the canonical form. Removes paths that are covered by another path. For example, "foo.bar" is covered by "foo" and will be removed if "foo" is also in the FieldMask. Then sorts all paths in alphabetical order. Args: mask: The original FieldMask to be converted. """ tree = _FieldMaskTree(mask) tree.ToFieldMask(self) def Union(self, mask1, mask2): """Merges mask1 and mask2 into this FieldMask.""" _CheckFieldMaskMessage(mask1) _CheckFieldMaskMessage(mask2) tree = _FieldMaskTree(mask1) tree.MergeFromFieldMask(mask2) tree.ToFieldMask(self) def Intersect(self, mask1, mask2): """Intersects mask1 and mask2 into this FieldMask.""" _CheckFieldMaskMessage(mask1) _CheckFieldMaskMessage(mask2) tree = _FieldMaskTree(mask1) intersection = _FieldMaskTree() for path in mask2.paths: tree.IntersectPath(path, intersection) intersection.ToFieldMask(self) def MergeMessage( self, source, destination, replace_message_field=False, replace_repeated_field=False): """Merges fields specified in FieldMask from source to destination. Args: source: Source message. destination: The destination message to be merged into. replace_message_field: Replace message field if True. Merge message field if False. replace_repeated_field: Replace repeated field if True. Append elements of repeated field if False. """ tree = _FieldMaskTree(self) tree.MergeMessage( source, destination, replace_message_field, replace_repeated_field) def _IsValidPath(message_descriptor, path): """Checks whether the path is valid for Message Descriptor.""" parts = path.split('.') last = parts.pop() for name in parts: field = message_descriptor.fields_by_name.get(name) if (field is None or field.label == FieldDescriptor.LABEL_REPEATED or field.type != FieldDescriptor.TYPE_MESSAGE): return False message_descriptor = field.message_type return last in message_descriptor.fields_by_name def _CheckFieldMaskMessage(message): """Raises ValueError if message is not a FieldMask.""" message_descriptor = message.DESCRIPTOR if (message_descriptor.name != 'FieldMask' or message_descriptor.file.name != 'google/protobuf/field_mask.proto'): raise ValueError('Message {0} is not a FieldMask.'.format( message_descriptor.full_name)) def _SnakeCaseToCamelCase(path_name): """Converts a path name from snake_case to camelCase.""" result = [] after_underscore = False for c in path_name: if c.isupper(): raise ValueError( 'Fail to print FieldMask to Json string: Path name ' '{0} must not contain uppercase letters.'.format(path_name)) if after_underscore: if c.islower(): result.append(c.upper()) after_underscore = False else: raise ValueError( 'Fail to print FieldMask to Json string: The ' 'character after a "_" must be a lowercase letter ' 'in path name {0}.'.format(path_name)) elif c == '_': after_underscore = True else: result += c if after_underscore: raise ValueError('Fail to print FieldMask to Json string: Trailing "_" ' 'in path name {0}.'.format(path_name)) return ''.join(result) def _CamelCaseToSnakeCase(path_name): """Converts a field name from camelCase to snake_case.""" result = [] for c in path_name: if c == '_': raise ValueError('Fail to parse FieldMask: Path name ' '{0} must not contain "_"s.'.format(path_name)) if c.isupper(): result += '_' result += c.lower() else: result += c return ''.join(result) class _FieldMaskTree(object): """Represents a FieldMask in a tree structure. For example, given a FieldMask "foo.bar,foo.baz,bar.baz", the FieldMaskTree will be: [_root] -+- foo -+- bar | | | +- baz | +- bar --- baz In the tree, each leaf node represents a field path. """ __slots__ = ('_root',) def __init__(self, field_mask=None): """Initializes the tree by FieldMask.""" self._root = {} if field_mask: self.MergeFromFieldMask(field_mask) def MergeFromFieldMask(self, field_mask): """Merges a FieldMask to the tree.""" for path in field_mask.paths: self.AddPath(path) def AddPath(self, path): """Adds a field path into the tree. If the field path to add is a sub-path of an existing field path in the tree (i.e., a leaf node), it means the tree already matches the given path so nothing will be added to the tree. If the path matches an existing non-leaf node in the tree, that non-leaf node will be turned into a leaf node with all its children removed because the path matches all the node's children. Otherwise, a new path will be added. Args: path: The field path to add. """ node = self._root for name in path.split('.'): if name not in node: node[name] = {} elif not node[name]: # Pre-existing empty node implies we already have this entire tree. return node = node[name] # Remove any sub-trees we might have had. node.clear() def ToFieldMask(self, field_mask): """Converts the tree to a FieldMask.""" field_mask.Clear() _AddFieldPaths(self._root, '', field_mask) def IntersectPath(self, path, intersection): """Calculates the intersection part of a field path with this tree. Args: path: The field path to calculates. intersection: The out tree to record the intersection part. """ node = self._root for name in path.split('.'): if name not in node: return elif not node[name]: intersection.AddPath(path) return node = node[name] intersection.AddLeafNodes(path, node) def AddLeafNodes(self, prefix, node): """Adds leaf nodes begin with prefix to this tree.""" if not node: self.AddPath(prefix) for name in node: child_path = prefix + '.' + name self.AddLeafNodes(child_path, node[name]) def MergeMessage( self, source, destination, replace_message, replace_repeated): """Merge all fields specified by this tree from source to destination.""" _MergeMessage( self._root, source, destination, replace_message, replace_repeated) def _StrConvert(value): """Converts value to str if it is not.""" # This file is imported by c extension and some methods like ClearField # requires string for the field name. py2/py3 has different text # type and may use unicode. if not isinstance(value, str): return value.encode('utf-8') return value def _MergeMessage( node, source, destination, replace_message, replace_repeated): """Merge all fields specified by a sub-tree from source to destination.""" source_descriptor = source.DESCRIPTOR for name in node: child = node[name] field = source_descriptor.fields_by_name[name] if field is None: raise ValueError('Error: Can\'t find field {0} in message {1}.'.format( name, source_descriptor.full_name)) if child: # Sub-paths are only allowed for singular message fields. if (field.label == FieldDescriptor.LABEL_REPEATED or field.cpp_type != FieldDescriptor.CPPTYPE_MESSAGE): raise ValueError('Error: Field {0} in message {1} is not a singular ' 'message field and cannot have sub-fields.'.format( name, source_descriptor.full_name)) if source.HasField(name): _MergeMessage( child, getattr(source, name), getattr(destination, name), replace_message, replace_repeated) continue if field.label == FieldDescriptor.LABEL_REPEATED: if replace_repeated: destination.ClearField(_StrConvert(name)) repeated_source = getattr(source, name) repeated_destination = getattr(destination, name) repeated_destination.MergeFrom(repeated_source) else: if field.cpp_type == FieldDescriptor.CPPTYPE_MESSAGE: if replace_message: destination.ClearField(_StrConvert(name)) if source.HasField(name): getattr(destination, name).MergeFrom(getattr(source, name)) else: setattr(destination, name, getattr(source, name)) def _AddFieldPaths(node, prefix, field_mask): """Adds the field paths descended from node to field_mask.""" if not node and prefix: field_mask.paths.append(prefix) return for name in sorted(node): if prefix: child_path = prefix + '.' + name else: child_path = name _AddFieldPaths(node[name], child_path, field_mask) def _SetStructValue(struct_value, value): if value is None: struct_value.null_value = 0 elif isinstance(value, bool): # Note: this check must come before the number check because in Python # True and False are also considered numbers. struct_value.bool_value = value elif isinstance(value, str): struct_value.string_value = value elif isinstance(value, (int, float)): struct_value.number_value = value elif isinstance(value, (dict, Struct)): struct_value.struct_value.Clear() struct_value.struct_value.update(value) elif isinstance(value, (list, ListValue)): struct_value.list_value.Clear() struct_value.list_value.extend(value) else: raise ValueError('Unexpected type') def _GetStructValue(struct_value): which = struct_value.WhichOneof('kind') if which == 'struct_value': return struct_value.struct_value elif which == 'null_value': return None elif which == 'number_value': return struct_value.number_value elif which == 'string_value': return struct_value.string_value elif which == 'bool_value': return struct_value.bool_value elif which == 'list_value': return struct_value.list_value elif which is None: raise ValueError('Value not set') class Struct(object): """Class for Struct message type.""" __slots__ = () def __getitem__(self, key): return _GetStructValue(self.fields[key]) def __contains__(self, item): return item in self.fields def __setitem__(self, key, value): _SetStructValue(self.fields[key], value) def __delitem__(self, key): del self.fields[key] def __len__(self): return len(self.fields) def __iter__(self): return iter(self.fields) def keys(self): # pylint: disable=invalid-name return self.fields.keys() def values(self): # pylint: disable=invalid-name return [self[key] for key in self] def items(self): # pylint: disable=invalid-name return [(key, self[key]) for key in self] def get_or_create_list(self, key): """Returns a list for this key, creating if it didn't exist already.""" if not self.fields[key].HasField('list_value'): # Clear will mark list_value modified which will indeed create a list. self.fields[key].list_value.Clear() return self.fields[key].list_value def get_or_create_struct(self, key): """Returns a struct for this key, creating if it didn't exist already.""" if not self.fields[key].HasField('struct_value'): # Clear will mark struct_value modified which will indeed create a struct. self.fields[key].struct_value.Clear() return self.fields[key].struct_value def update(self, dictionary): # pylint: disable=invalid-name for key, value in dictionary.items(): _SetStructValue(self.fields[key], value) collections.abc.MutableMapping.register(Struct) class ListValue(object): """Class for ListValue message type.""" __slots__ = () def __len__(self): return len(self.values) def append(self, value): _SetStructValue(self.values.add(), value) def extend(self, elem_seq): for value in elem_seq: self.append(value) def __getitem__(self, index): """Retrieves item by the specified index.""" return _GetStructValue(self.values.__getitem__(index)) def __setitem__(self, index, value): _SetStructValue(self.values.__getitem__(index), value) def __delitem__(self, key): del self.values[key] def items(self): for i in range(len(self)): yield self[i] def add_struct(self): """Appends and returns a struct value as the next value in the list.""" struct_value = self.values.add().struct_value # Clear will mark struct_value modified which will indeed create a struct. struct_value.Clear() return struct_value def add_list(self): """Appends and returns a list value as the next value in the list.""" list_value = self.values.add().list_value # Clear will mark list_value modified which will indeed create a list. list_value.Clear() return list_value collections.abc.MutableSequence.register(ListValue) WKTBASES = { 'google.protobuf.Any': Any, 'google.protobuf.Duration': Duration, 'google.protobuf.FieldMask': FieldMask, 'google.protobuf.ListValue': ListValue, 'google.protobuf.Struct': Struct, 'google.protobuf.Timestamp': Timestamp, } ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/internal/wire_format.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Constants and static functions to support protocol buffer wire format.""" __author__ = 'robinson@google.com (Will Robinson)' import struct from google.protobuf import descriptor from google.protobuf import message TAG_TYPE_BITS = 3 # Number of bits used to hold type info in a proto tag. TAG_TYPE_MASK = (1 << TAG_TYPE_BITS) - 1 # 0x7 # These numbers identify the wire type of a protocol buffer value. # We use the least-significant TAG_TYPE_BITS bits of the varint-encoded # tag-and-type to store one of these WIRETYPE_* constants. # These values must match WireType enum in google/protobuf/wire_format.h. WIRETYPE_VARINT = 0 WIRETYPE_FIXED64 = 1 WIRETYPE_LENGTH_DELIMITED = 2 WIRETYPE_START_GROUP = 3 WIRETYPE_END_GROUP = 4 WIRETYPE_FIXED32 = 5 _WIRETYPE_MAX = 5 # Bounds for various integer types. INT32_MAX = int((1 << 31) - 1) INT32_MIN = int(-(1 << 31)) UINT32_MAX = (1 << 32) - 1 INT64_MAX = (1 << 63) - 1 INT64_MIN = -(1 << 63) UINT64_MAX = (1 << 64) - 1 # "struct" format strings that will encode/decode the specified formats. FORMAT_UINT32_LITTLE_ENDIAN = '> TAG_TYPE_BITS), (tag & TAG_TYPE_MASK) def ZigZagEncode(value): """ZigZag Transform: Encodes signed integers so that they can be effectively used with varint encoding. See wire_format.h for more details. """ if value >= 0: return value << 1 return (value << 1) ^ (~0) def ZigZagDecode(value): """Inverse of ZigZagEncode().""" if not value & 0x1: return value >> 1 return (value >> 1) ^ (~0) # The *ByteSize() functions below return the number of bytes required to # serialize "field number + type" information and then serialize the value. def Int32ByteSize(field_number, int32): return Int64ByteSize(field_number, int32) def Int32ByteSizeNoTag(int32): return _VarUInt64ByteSizeNoTag(0xffffffffffffffff & int32) def Int64ByteSize(field_number, int64): # Have to convert to uint before calling UInt64ByteSize(). return UInt64ByteSize(field_number, 0xffffffffffffffff & int64) def UInt32ByteSize(field_number, uint32): return UInt64ByteSize(field_number, uint32) def UInt64ByteSize(field_number, uint64): return TagByteSize(field_number) + _VarUInt64ByteSizeNoTag(uint64) def SInt32ByteSize(field_number, int32): return UInt32ByteSize(field_number, ZigZagEncode(int32)) def SInt64ByteSize(field_number, int64): return UInt64ByteSize(field_number, ZigZagEncode(int64)) def Fixed32ByteSize(field_number, fixed32): return TagByteSize(field_number) + 4 def Fixed64ByteSize(field_number, fixed64): return TagByteSize(field_number) + 8 def SFixed32ByteSize(field_number, sfixed32): return TagByteSize(field_number) + 4 def SFixed64ByteSize(field_number, sfixed64): return TagByteSize(field_number) + 8 def FloatByteSize(field_number, flt): return TagByteSize(field_number) + 4 def DoubleByteSize(field_number, double): return TagByteSize(field_number) + 8 def BoolByteSize(field_number, b): return TagByteSize(field_number) + 1 def EnumByteSize(field_number, enum): return UInt32ByteSize(field_number, enum) def StringByteSize(field_number, string): return BytesByteSize(field_number, string.encode('utf-8')) def BytesByteSize(field_number, b): return (TagByteSize(field_number) + _VarUInt64ByteSizeNoTag(len(b)) + len(b)) def GroupByteSize(field_number, message): return (2 * TagByteSize(field_number) # START and END group. + message.ByteSize()) def MessageByteSize(field_number, message): return (TagByteSize(field_number) + _VarUInt64ByteSizeNoTag(message.ByteSize()) + message.ByteSize()) def MessageSetItemByteSize(field_number, msg): # First compute the sizes of the tags. # There are 2 tags for the beginning and ending of the repeated group, that # is field number 1, one with field number 2 (type_id) and one with field # number 3 (message). total_size = (2 * TagByteSize(1) + TagByteSize(2) + TagByteSize(3)) # Add the number of bytes for type_id. total_size += _VarUInt64ByteSizeNoTag(field_number) message_size = msg.ByteSize() # The number of bytes for encoding the length of the message. total_size += _VarUInt64ByteSizeNoTag(message_size) # The size of the message. total_size += message_size return total_size def TagByteSize(field_number): """Returns the bytes required to serialize a tag with this field number.""" # Just pass in type 0, since the type won't affect the tag+type size. return _VarUInt64ByteSizeNoTag(PackTag(field_number, 0)) # Private helper function for the *ByteSize() functions above. def _VarUInt64ByteSizeNoTag(uint64): """Returns the number of bytes required to serialize a single varint using boundary value comparisons. (unrolled loop optimization -WPierce) uint64 must be unsigned. """ if uint64 <= 0x7f: return 1 if uint64 <= 0x3fff: return 2 if uint64 <= 0x1fffff: return 3 if uint64 <= 0xfffffff: return 4 if uint64 <= 0x7ffffffff: return 5 if uint64 <= 0x3ffffffffff: return 6 if uint64 <= 0x1ffffffffffff: return 7 if uint64 <= 0xffffffffffffff: return 8 if uint64 <= 0x7fffffffffffffff: return 9 if uint64 > UINT64_MAX: raise message.EncodeError('Value out of range: %d' % uint64) return 10 NON_PACKABLE_TYPES = ( descriptor.FieldDescriptor.TYPE_STRING, descriptor.FieldDescriptor.TYPE_GROUP, descriptor.FieldDescriptor.TYPE_MESSAGE, descriptor.FieldDescriptor.TYPE_BYTES ) def IsTypePackable(field_type): """Return true iff packable = true is valid for fields of this type. Args: field_type: a FieldDescriptor::Type value. Returns: True iff fields of this type are packable. """ return field_type not in NON_PACKABLE_TYPES ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/json_format.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Contains routines for printing protocol messages in JSON format. Simple usage example: # Create a proto object and serialize it to a json format string. message = my_proto_pb2.MyMessage(foo='bar') json_string = json_format.MessageToJson(message) # Parse a json format string to proto object. message = json_format.Parse(json_string, my_proto_pb2.MyMessage()) """ __author__ = 'jieluo@google.com (Jie Luo)' import base64 from collections import OrderedDict import json import math from operator import methodcaller import re import sys from google.protobuf.internal import type_checkers from google.protobuf import descriptor from google.protobuf import symbol_database _TIMESTAMPFOMAT = '%Y-%m-%dT%H:%M:%S' _INT_TYPES = frozenset([descriptor.FieldDescriptor.CPPTYPE_INT32, descriptor.FieldDescriptor.CPPTYPE_UINT32, descriptor.FieldDescriptor.CPPTYPE_INT64, descriptor.FieldDescriptor.CPPTYPE_UINT64]) _INT64_TYPES = frozenset([descriptor.FieldDescriptor.CPPTYPE_INT64, descriptor.FieldDescriptor.CPPTYPE_UINT64]) _FLOAT_TYPES = frozenset([descriptor.FieldDescriptor.CPPTYPE_FLOAT, descriptor.FieldDescriptor.CPPTYPE_DOUBLE]) _INFINITY = 'Infinity' _NEG_INFINITY = '-Infinity' _NAN = 'NaN' _UNPAIRED_SURROGATE_PATTERN = re.compile( u'[\ud800-\udbff](?![\udc00-\udfff])|(? self.max_recursion_depth: raise ParseError('Message too deep. Max recursion depth is {0}'.format( self.max_recursion_depth)) message_descriptor = message.DESCRIPTOR full_name = message_descriptor.full_name if not path: path = message_descriptor.name if _IsWrapperMessage(message_descriptor): self._ConvertWrapperMessage(value, message, path) elif full_name in _WKTJSONMETHODS: methodcaller(_WKTJSONMETHODS[full_name][1], value, message, path)(self) else: self._ConvertFieldValuePair(value, message, path) self.recursion_depth -= 1 def _ConvertFieldValuePair(self, js, message, path): """Convert field value pairs into regular message. Args: js: A JSON object to convert the field value pairs. message: A regular protocol message to record the data. path: parent path to log parse error info. Raises: ParseError: In case of problems converting. """ names = [] message_descriptor = message.DESCRIPTOR fields_by_json_name = dict((f.json_name, f) for f in message_descriptor.fields) for name in js: try: field = fields_by_json_name.get(name, None) if not field: field = message_descriptor.fields_by_name.get(name, None) if not field and _VALID_EXTENSION_NAME.match(name): if not message_descriptor.is_extendable: raise ParseError( 'Message type {0} does not have extensions at {1}'.format( message_descriptor.full_name, path)) identifier = name[1:-1] # strip [] brackets # pylint: disable=protected-access field = message.Extensions._FindExtensionByName(identifier) # pylint: enable=protected-access if not field: # Try looking for extension by the message type name, dropping the # field name following the final . separator in full_name. identifier = '.'.join(identifier.split('.')[:-1]) # pylint: disable=protected-access field = message.Extensions._FindExtensionByName(identifier) # pylint: enable=protected-access if not field: if self.ignore_unknown_fields: continue raise ParseError( ('Message type "{0}" has no field named "{1}" at "{2}".\n' ' Available Fields(except extensions): "{3}"').format( message_descriptor.full_name, name, path, [f.json_name for f in message_descriptor.fields])) if name in names: raise ParseError('Message type "{0}" should not have multiple ' '"{1}" fields at "{2}".'.format( message.DESCRIPTOR.full_name, name, path)) names.append(name) value = js[name] # Check no other oneof field is parsed. if field.containing_oneof is not None and value is not None: oneof_name = field.containing_oneof.name if oneof_name in names: raise ParseError('Message type "{0}" should not have multiple ' '"{1}" oneof fields at "{2}".'.format( message.DESCRIPTOR.full_name, oneof_name, path)) names.append(oneof_name) if value is None: if (field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_MESSAGE and field.message_type.full_name == 'google.protobuf.Value'): sub_message = getattr(message, field.name) sub_message.null_value = 0 elif (field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_ENUM and field.enum_type.full_name == 'google.protobuf.NullValue'): setattr(message, field.name, 0) else: message.ClearField(field.name) continue # Parse field value. if _IsMapEntry(field): message.ClearField(field.name) self._ConvertMapFieldValue(value, message, field, '{0}.{1}'.format(path, name)) elif field.label == descriptor.FieldDescriptor.LABEL_REPEATED: message.ClearField(field.name) if not isinstance(value, list): raise ParseError('repeated field {0} must be in [] which is ' '{1} at {2}'.format(name, value, path)) if field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_MESSAGE: # Repeated message field. for index, item in enumerate(value): sub_message = getattr(message, field.name).add() # None is a null_value in Value. if (item is None and sub_message.DESCRIPTOR.full_name != 'google.protobuf.Value'): raise ParseError('null is not allowed to be used as an element' ' in a repeated field at {0}.{1}[{2}]'.format( path, name, index)) self.ConvertMessage(item, sub_message, '{0}.{1}[{2}]'.format(path, name, index)) else: # Repeated scalar field. for index, item in enumerate(value): if item is None: raise ParseError('null is not allowed to be used as an element' ' in a repeated field at {0}.{1}[{2}]'.format( path, name, index)) getattr(message, field.name).append( _ConvertScalarFieldValue( item, field, '{0}.{1}[{2}]'.format(path, name, index))) elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_MESSAGE: if field.is_extension: sub_message = message.Extensions[field] else: sub_message = getattr(message, field.name) sub_message.SetInParent() self.ConvertMessage(value, sub_message, '{0}.{1}'.format(path, name)) else: if field.is_extension: message.Extensions[field] = _ConvertScalarFieldValue( value, field, '{0}.{1}'.format(path, name)) else: setattr( message, field.name, _ConvertScalarFieldValue(value, field, '{0}.{1}'.format(path, name))) except ParseError as e: if field and field.containing_oneof is None: raise ParseError('Failed to parse {0} field: {1}.'.format(name, e)) else: raise ParseError(str(e)) except ValueError as e: raise ParseError('Failed to parse {0} field: {1}.'.format(name, e)) except TypeError as e: raise ParseError('Failed to parse {0} field: {1}.'.format(name, e)) def _ConvertAnyMessage(self, value, message, path): """Convert a JSON representation into Any message.""" if isinstance(value, dict) and not value: return try: type_url = value['@type'] except KeyError: raise ParseError( '@type is missing when parsing any message at {0}'.format(path)) try: sub_message = _CreateMessageFromTypeUrl(type_url, self.descriptor_pool) except TypeError as e: raise ParseError('{0} at {1}'.format(e, path)) message_descriptor = sub_message.DESCRIPTOR full_name = message_descriptor.full_name if _IsWrapperMessage(message_descriptor): self._ConvertWrapperMessage(value['value'], sub_message, '{0}.value'.format(path)) elif full_name in _WKTJSONMETHODS: methodcaller(_WKTJSONMETHODS[full_name][1], value['value'], sub_message, '{0}.value'.format(path))( self) else: del value['@type'] self._ConvertFieldValuePair(value, sub_message, path) value['@type'] = type_url # Sets Any message message.value = sub_message.SerializeToString() message.type_url = type_url def _ConvertGenericMessage(self, value, message, path): """Convert a JSON representation into message with FromJsonString.""" # Duration, Timestamp, FieldMask have a FromJsonString method to do the # conversion. Users can also call the method directly. try: message.FromJsonString(value) except ValueError as e: raise ParseError('{0} at {1}'.format(e, path)) def _ConvertValueMessage(self, value, message, path): """Convert a JSON representation into Value message.""" if isinstance(value, dict): self._ConvertStructMessage(value, message.struct_value, path) elif isinstance(value, list): self._ConvertListValueMessage(value, message.list_value, path) elif value is None: message.null_value = 0 elif isinstance(value, bool): message.bool_value = value elif isinstance(value, str): message.string_value = value elif isinstance(value, _INT_OR_FLOAT): message.number_value = value else: raise ParseError('Value {0} has unexpected type {1} at {2}'.format( value, type(value), path)) def _ConvertListValueMessage(self, value, message, path): """Convert a JSON representation into ListValue message.""" if not isinstance(value, list): raise ParseError('ListValue must be in [] which is {0} at {1}'.format( value, path)) message.ClearField('values') for index, item in enumerate(value): self._ConvertValueMessage(item, message.values.add(), '{0}[{1}]'.format(path, index)) def _ConvertStructMessage(self, value, message, path): """Convert a JSON representation into Struct message.""" if not isinstance(value, dict): raise ParseError('Struct must be in a dict which is {0} at {1}'.format( value, path)) # Clear will mark the struct as modified so it will be created even if # there are no values. message.Clear() for key in value: self._ConvertValueMessage(value[key], message.fields[key], '{0}.{1}'.format(path, key)) return def _ConvertWrapperMessage(self, value, message, path): """Convert a JSON representation into Wrapper message.""" field = message.DESCRIPTOR.fields_by_name['value'] setattr( message, 'value', _ConvertScalarFieldValue(value, field, path='{0}.value'.format(path))) def _ConvertMapFieldValue(self, value, message, field, path): """Convert map field value for a message map field. Args: value: A JSON object to convert the map field value. message: A protocol message to record the converted data. field: The descriptor of the map field to be converted. path: parent path to log parse error info. Raises: ParseError: In case of convert problems. """ if not isinstance(value, dict): raise ParseError( 'Map field {0} must be in a dict which is {1} at {2}'.format( field.name, value, path)) key_field = field.message_type.fields_by_name['key'] value_field = field.message_type.fields_by_name['value'] for key in value: key_value = _ConvertScalarFieldValue(key, key_field, '{0}.key'.format(path), True) if value_field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_MESSAGE: self.ConvertMessage(value[key], getattr(message, field.name)[key_value], '{0}[{1}]'.format(path, key_value)) else: getattr(message, field.name)[key_value] = _ConvertScalarFieldValue( value[key], value_field, path='{0}[{1}]'.format(path, key_value)) def _ConvertScalarFieldValue(value, field, path, require_str=False): """Convert a single scalar field value. Args: value: A scalar value to convert the scalar field value. field: The descriptor of the field to convert. path: parent path to log parse error info. require_str: If True, the field value must be a str. Returns: The converted scalar field value Raises: ParseError: In case of convert problems. """ try: if field.cpp_type in _INT_TYPES: return _ConvertInteger(value) elif field.cpp_type in _FLOAT_TYPES: return _ConvertFloat(value, field) elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_BOOL: return _ConvertBool(value, require_str) elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_STRING: if field.type == descriptor.FieldDescriptor.TYPE_BYTES: if isinstance(value, str): encoded = value.encode('utf-8') else: encoded = value # Add extra padding '=' padded_value = encoded + b'=' * (4 - len(encoded) % 4) return base64.urlsafe_b64decode(padded_value) else: # Checking for unpaired surrogates appears to be unreliable, # depending on the specific Python version, so we check manually. if _UNPAIRED_SURROGATE_PATTERN.search(value): raise ParseError('Unpaired surrogate') return value elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_ENUM: # Convert an enum value. enum_value = field.enum_type.values_by_name.get(value, None) if enum_value is None: try: number = int(value) enum_value = field.enum_type.values_by_number.get(number, None) except ValueError: raise ParseError('Invalid enum value {0} for enum type {1}'.format( value, field.enum_type.full_name)) if enum_value is None: if field.file.syntax == 'proto3': # Proto3 accepts unknown enums. return number raise ParseError('Invalid enum value {0} for enum type {1}'.format( value, field.enum_type.full_name)) return enum_value.number except ParseError as e: raise ParseError('{0} at {1}'.format(e, path)) def _ConvertInteger(value): """Convert an integer. Args: value: A scalar value to convert. Returns: The integer value. Raises: ParseError: If an integer couldn't be consumed. """ if isinstance(value, float) and not value.is_integer(): raise ParseError('Couldn\'t parse integer: {0}'.format(value)) if isinstance(value, str) and value.find(' ') != -1: raise ParseError('Couldn\'t parse integer: "{0}"'.format(value)) if isinstance(value, bool): raise ParseError('Bool value {0} is not acceptable for ' 'integer field'.format(value)) return int(value) def _ConvertFloat(value, field): """Convert an floating point number.""" if isinstance(value, float): if math.isnan(value): raise ParseError('Couldn\'t parse NaN, use quoted "NaN" instead') if math.isinf(value): if value > 0: raise ParseError('Couldn\'t parse Infinity or value too large, ' 'use quoted "Infinity" instead') else: raise ParseError('Couldn\'t parse -Infinity or value too small, ' 'use quoted "-Infinity" instead') if field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_FLOAT: # pylint: disable=protected-access if value > type_checkers._FLOAT_MAX: raise ParseError('Float value too large') # pylint: disable=protected-access if value < type_checkers._FLOAT_MIN: raise ParseError('Float value too small') if value == 'nan': raise ParseError('Couldn\'t parse float "nan", use "NaN" instead') try: # Assume Python compatible syntax. return float(value) except ValueError: # Check alternative spellings. if value == _NEG_INFINITY: return float('-inf') elif value == _INFINITY: return float('inf') elif value == _NAN: return float('nan') else: raise ParseError('Couldn\'t parse float: {0}'.format(value)) def _ConvertBool(value, require_str): """Convert a boolean value. Args: value: A scalar value to convert. require_str: If True, value must be a str. Returns: The bool parsed. Raises: ParseError: If a boolean value couldn't be consumed. """ if require_str: if value == 'true': return True elif value == 'false': return False else: raise ParseError('Expected "true" or "false", not {0}'.format(value)) if not isinstance(value, bool): raise ParseError('Expected true or false without quotes') return value _WKTJSONMETHODS = { 'google.protobuf.Any': ['_AnyMessageToJsonObject', '_ConvertAnyMessage'], 'google.protobuf.Duration': ['_GenericMessageToJsonObject', '_ConvertGenericMessage'], 'google.protobuf.FieldMask': ['_GenericMessageToJsonObject', '_ConvertGenericMessage'], 'google.protobuf.ListValue': ['_ListValueMessageToJsonObject', '_ConvertListValueMessage'], 'google.protobuf.Struct': ['_StructMessageToJsonObject', '_ConvertStructMessage'], 'google.protobuf.Timestamp': ['_GenericMessageToJsonObject', '_ConvertGenericMessage'], 'google.protobuf.Value': ['_ValueMessageToJsonObject', '_ConvertValueMessage'] } ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/message.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # TODO(robinson): We should just make these methods all "pure-virtual" and move # all implementation out, into reflection.py for now. """Contains an abstract base class for protocol messages.""" __author__ = 'robinson@google.com (Will Robinson)' class Error(Exception): """Base error type for this module.""" pass class DecodeError(Error): """Exception raised when deserializing messages.""" pass class EncodeError(Error): """Exception raised when serializing messages.""" pass class Message(object): """Abstract base class for protocol messages. Protocol message classes are almost always generated by the protocol compiler. These generated types subclass Message and implement the methods shown below. """ # TODO(robinson): Link to an HTML document here. # TODO(robinson): Document that instances of this class will also # have an Extensions attribute with __getitem__ and __setitem__. # Again, not sure how to best convey this. # TODO(robinson): Document that the class must also have a static # RegisterExtension(extension_field) method. # Not sure how to best express at this point. # TODO(robinson): Document these fields and methods. __slots__ = [] #: The :class:`google.protobuf.descriptor.Descriptor` for this message type. DESCRIPTOR = None def __deepcopy__(self, memo=None): clone = type(self)() clone.MergeFrom(self) return clone def __eq__(self, other_msg): """Recursively compares two messages by value and structure.""" raise NotImplementedError def __ne__(self, other_msg): # Can't just say self != other_msg, since that would infinitely recurse. :) return not self == other_msg def __hash__(self): raise TypeError('unhashable object') def __str__(self): """Outputs a human-readable representation of the message.""" raise NotImplementedError def __unicode__(self): """Outputs a human-readable representation of the message.""" raise NotImplementedError def MergeFrom(self, other_msg): """Merges the contents of the specified message into current message. This method merges the contents of the specified message into the current message. Singular fields that are set in the specified message overwrite the corresponding fields in the current message. Repeated fields are appended. Singular sub-messages and groups are recursively merged. Args: other_msg (Message): A message to merge into the current message. """ raise NotImplementedError def CopyFrom(self, other_msg): """Copies the content of the specified message into the current message. The method clears the current message and then merges the specified message using MergeFrom. Args: other_msg (Message): A message to copy into the current one. """ if self is other_msg: return self.Clear() self.MergeFrom(other_msg) def Clear(self): """Clears all data that was set in the message.""" raise NotImplementedError def SetInParent(self): """Mark this as present in the parent. This normally happens automatically when you assign a field of a sub-message, but sometimes you want to make the sub-message present while keeping it empty. If you find yourself using this, you may want to reconsider your design. """ raise NotImplementedError def IsInitialized(self): """Checks if the message is initialized. Returns: bool: The method returns True if the message is initialized (i.e. all of its required fields are set). """ raise NotImplementedError # TODO(robinson): MergeFromString() should probably return None and be # implemented in terms of a helper that returns the # of bytes read. Our # deserialization routines would use the helper when recursively # deserializing, but the end user would almost always just want the no-return # MergeFromString(). def MergeFromString(self, serialized): """Merges serialized protocol buffer data into this message. When we find a field in `serialized` that is already present in this message: - If it's a "repeated" field, we append to the end of our list. - Else, if it's a scalar, we overwrite our field. - Else, (it's a nonrepeated composite), we recursively merge into the existing composite. Args: serialized (bytes): Any object that allows us to call ``memoryview(serialized)`` to access a string of bytes using the buffer interface. Returns: int: The number of bytes read from `serialized`. For non-group messages, this will always be `len(serialized)`, but for messages which are actually groups, this will generally be less than `len(serialized)`, since we must stop when we reach an ``END_GROUP`` tag. Note that if we *do* stop because of an ``END_GROUP`` tag, the number of bytes returned does not include the bytes for the ``END_GROUP`` tag information. Raises: DecodeError: if the input cannot be parsed. """ # TODO(robinson): Document handling of unknown fields. # TODO(robinson): When we switch to a helper, this will return None. raise NotImplementedError def ParseFromString(self, serialized): """Parse serialized protocol buffer data into this message. Like :func:`MergeFromString()`, except we clear the object first. Raises: message.DecodeError if the input cannot be parsed. """ self.Clear() return self.MergeFromString(serialized) def SerializeToString(self, **kwargs): """Serializes the protocol message to a binary string. Keyword Args: deterministic (bool): If true, requests deterministic serialization of the protobuf, with predictable ordering of map keys. Returns: A binary string representation of the message if all of the required fields in the message are set (i.e. the message is initialized). Raises: EncodeError: if the message isn't initialized (see :func:`IsInitialized`). """ raise NotImplementedError def SerializePartialToString(self, **kwargs): """Serializes the protocol message to a binary string. This method is similar to SerializeToString but doesn't check if the message is initialized. Keyword Args: deterministic (bool): If true, requests deterministic serialization of the protobuf, with predictable ordering of map keys. Returns: bytes: A serialized representation of the partial message. """ raise NotImplementedError # TODO(robinson): Decide whether we like these better # than auto-generated has_foo() and clear_foo() methods # on the instances themselves. This way is less consistent # with C++, but it makes reflection-type access easier and # reduces the number of magically autogenerated things. # # TODO(robinson): Be sure to document (and test) exactly # which field names are accepted here. Are we case-sensitive? # What do we do with fields that share names with Python keywords # like 'lambda' and 'yield'? # # nnorwitz says: # """ # Typically (in python), an underscore is appended to names that are # keywords. So they would become lambda_ or yield_. # """ def ListFields(self): """Returns a list of (FieldDescriptor, value) tuples for present fields. A message field is non-empty if HasField() would return true. A singular primitive field is non-empty if HasField() would return true in proto2 or it is non zero in proto3. A repeated field is non-empty if it contains at least one element. The fields are ordered by field number. Returns: list[tuple(FieldDescriptor, value)]: field descriptors and values for all fields in the message which are not empty. The values vary by field type. """ raise NotImplementedError def HasField(self, field_name): """Checks if a certain field is set for the message. For a oneof group, checks if any field inside is set. Note that if the field_name is not defined in the message descriptor, :exc:`ValueError` will be raised. Args: field_name (str): The name of the field to check for presence. Returns: bool: Whether a value has been set for the named field. Raises: ValueError: if the `field_name` is not a member of this message. """ raise NotImplementedError def ClearField(self, field_name): """Clears the contents of a given field. Inside a oneof group, clears the field set. If the name neither refers to a defined field or oneof group, :exc:`ValueError` is raised. Args: field_name (str): The name of the field to check for presence. Raises: ValueError: if the `field_name` is not a member of this message. """ raise NotImplementedError def WhichOneof(self, oneof_group): """Returns the name of the field that is set inside a oneof group. If no field is set, returns None. Args: oneof_group (str): the name of the oneof group to check. Returns: str or None: The name of the group that is set, or None. Raises: ValueError: no group with the given name exists """ raise NotImplementedError def HasExtension(self, extension_handle): """Checks if a certain extension is present for this message. Extensions are retrieved using the :attr:`Extensions` mapping (if present). Args: extension_handle: The handle for the extension to check. Returns: bool: Whether the extension is present for this message. Raises: KeyError: if the extension is repeated. Similar to repeated fields, there is no separate notion of presence: a "not present" repeated extension is an empty list. """ raise NotImplementedError def ClearExtension(self, extension_handle): """Clears the contents of a given extension. Args: extension_handle: The handle for the extension to clear. """ raise NotImplementedError def UnknownFields(self): """Returns the UnknownFieldSet. Returns: UnknownFieldSet: The unknown fields stored in this message. """ raise NotImplementedError def DiscardUnknownFields(self): """Clears all fields in the :class:`UnknownFieldSet`. This operation is recursive for nested message. """ raise NotImplementedError def ByteSize(self): """Returns the serialized size of this message. Recursively calls ByteSize() on all contained messages. Returns: int: The number of bytes required to serialize this message. """ raise NotImplementedError @classmethod def FromString(cls, s): raise NotImplementedError @staticmethod def RegisterExtension(extension_handle): raise NotImplementedError def _SetListener(self, message_listener): """Internal method used by the protocol message implementation. Clients should not call this directly. Sets a listener that this message will call on certain state transitions. The purpose of this method is to register back-edges from children to parents at runtime, for the purpose of setting "has" bits and byte-size-dirty bits in the parent and ancestor objects whenever a child or descendant object is modified. If the client wants to disconnect this Message from the object tree, she explicitly sets callback to None. If message_listener is None, unregisters any existing listener. Otherwise, message_listener must implement the MessageListener interface in internal/message_listener.py, and we discard any listener registered via a previous _SetListener() call. """ raise NotImplementedError def __getstate__(self): """Support the pickle protocol.""" return dict(serialized=self.SerializePartialToString()) def __setstate__(self, state): """Support the pickle protocol.""" self.__init__() serialized = state['serialized'] # On Python 3, using encoding='latin1' is required for unpickling # protos pickled by Python 2. if not isinstance(serialized, bytes): serialized = serialized.encode('latin1') self.ParseFromString(serialized) def __reduce__(self): message_descriptor = self.DESCRIPTOR if message_descriptor.containing_type is None: return type(self), (), self.__getstate__() # the message type must be nested. # Python does not pickle nested classes; use the symbol_database on the # receiving end. container = message_descriptor return (_InternalConstructMessage, (container.full_name,), self.__getstate__()) def _InternalConstructMessage(full_name): """Constructs a nested message.""" from google.protobuf import symbol_database # pylint:disable=g-import-not-at-top return symbol_database.Default().GetSymbol(full_name)() ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/message_factory.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Provides a factory class for generating dynamic messages. The easiest way to use this class is if you have access to the FileDescriptor protos containing the messages you want to create you can just do the following: message_classes = message_factory.GetMessages(iterable_of_file_descriptors) my_proto_instance = message_classes['some.proto.package.MessageName']() """ __author__ = 'matthewtoia@google.com (Matt Toia)' from google.protobuf.internal import api_implementation from google.protobuf import descriptor_pool from google.protobuf import message if api_implementation.Type() == 'cpp': from google.protobuf.pyext import cpp_message as message_impl else: from google.protobuf.internal import python_message as message_impl # The type of all Message classes. _GENERATED_PROTOCOL_MESSAGE_TYPE = message_impl.GeneratedProtocolMessageType class MessageFactory(object): """Factory for creating Proto2 messages from descriptors in a pool.""" def __init__(self, pool=None): """Initializes a new factory.""" self.pool = pool or descriptor_pool.DescriptorPool() # local cache of all classes built from protobuf descriptors self._classes = {} def GetPrototype(self, descriptor): """Obtains a proto2 message class based on the passed in descriptor. Passing a descriptor with a fully qualified name matching a previous invocation will cause the same class to be returned. Args: descriptor: The descriptor to build from. Returns: A class describing the passed in descriptor. """ if descriptor not in self._classes: result_class = self.CreatePrototype(descriptor) # The assignment to _classes is redundant for the base implementation, but # might avoid confusion in cases where CreatePrototype gets overridden and # does not call the base implementation. self._classes[descriptor] = result_class return result_class return self._classes[descriptor] def CreatePrototype(self, descriptor): """Builds a proto2 message class based on the passed in descriptor. Don't call this function directly, it always creates a new class. Call GetPrototype() instead. This method is meant to be overridden in subblasses to perform additional operations on the newly constructed class. Args: descriptor: The descriptor to build from. Returns: A class describing the passed in descriptor. """ descriptor_name = descriptor.name result_class = _GENERATED_PROTOCOL_MESSAGE_TYPE( descriptor_name, (message.Message,), { 'DESCRIPTOR': descriptor, # If module not set, it wrongly points to message_factory module. '__module__': None, }) result_class._FACTORY = self # pylint: disable=protected-access # Assign in _classes before doing recursive calls to avoid infinite # recursion. self._classes[descriptor] = result_class for field in descriptor.fields: if field.message_type: self.GetPrototype(field.message_type) for extension in result_class.DESCRIPTOR.extensions: if extension.containing_type not in self._classes: self.GetPrototype(extension.containing_type) extended_class = self._classes[extension.containing_type] extended_class.RegisterExtension(extension) return result_class def GetMessages(self, files): """Gets all the messages from a specified file. This will find and resolve dependencies, failing if the descriptor pool cannot satisfy them. Args: files: The file names to extract messages from. Returns: A dictionary mapping proto names to the message classes. This will include any dependent messages as well as any messages defined in the same file as a specified message. """ result = {} for file_name in files: file_desc = self.pool.FindFileByName(file_name) for desc in file_desc.message_types_by_name.values(): result[desc.full_name] = self.GetPrototype(desc) # While the extension FieldDescriptors are created by the descriptor pool, # the python classes created in the factory need them to be registered # explicitly, which is done below. # # The call to RegisterExtension will specifically check if the # extension was already registered on the object and either # ignore the registration if the original was the same, or raise # an error if they were different. for extension in file_desc.extensions_by_name.values(): if extension.containing_type not in self._classes: self.GetPrototype(extension.containing_type) extended_class = self._classes[extension.containing_type] extended_class.RegisterExtension(extension) return result _FACTORY = MessageFactory() def GetMessages(file_protos): """Builds a dictionary of all the messages available in a set of files. Args: file_protos: Iterable of FileDescriptorProto to build messages out of. Returns: A dictionary mapping proto names to the message classes. This will include any dependent messages as well as any messages defined in the same file as a specified message. """ # The cpp implementation of the protocol buffer library requires to add the # message in topological order of the dependency graph. file_by_name = {file_proto.name: file_proto for file_proto in file_protos} def _AddFile(file_proto): for dependency in file_proto.dependency: if dependency in file_by_name: # Remove from elements to be visited, in order to cut cycles. _AddFile(file_by_name.pop(dependency)) _FACTORY.pool.Add(file_proto) while file_by_name: _AddFile(file_by_name.popitem()[1]) return _FACTORY.GetMessages([file_proto.name for file_proto in file_protos]) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/proto_builder.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Dynamic Protobuf class creator.""" from collections import OrderedDict import hashlib import os from google.protobuf import descriptor_pb2 from google.protobuf import descriptor from google.protobuf import message_factory def _GetMessageFromFactory(factory, full_name): """Get a proto class from the MessageFactory by name. Args: factory: a MessageFactory instance. full_name: str, the fully qualified name of the proto type. Returns: A class, for the type identified by full_name. Raises: KeyError, if the proto is not found in the factory's descriptor pool. """ proto_descriptor = factory.pool.FindMessageTypeByName(full_name) proto_cls = factory.GetPrototype(proto_descriptor) return proto_cls def MakeSimpleProtoClass(fields, full_name=None, pool=None): """Create a Protobuf class whose fields are basic types. Note: this doesn't validate field names! Args: fields: dict of {name: field_type} mappings for each field in the proto. If this is an OrderedDict the order will be maintained, otherwise the fields will be sorted by name. full_name: optional str, the fully-qualified name of the proto type. pool: optional DescriptorPool instance. Returns: a class, the new protobuf class with a FileDescriptor. """ factory = message_factory.MessageFactory(pool=pool) if full_name is not None: try: proto_cls = _GetMessageFromFactory(factory, full_name) return proto_cls except KeyError: # The factory's DescriptorPool doesn't know about this class yet. pass # Get a list of (name, field_type) tuples from the fields dict. If fields was # an OrderedDict we keep the order, but otherwise we sort the field to ensure # consistent ordering. field_items = fields.items() if not isinstance(fields, OrderedDict): field_items = sorted(field_items) # Use a consistent file name that is unlikely to conflict with any imported # proto files. fields_hash = hashlib.sha1() for f_name, f_type in field_items: fields_hash.update(f_name.encode('utf-8')) fields_hash.update(str(f_type).encode('utf-8')) proto_file_name = fields_hash.hexdigest() + '.proto' # If the proto is anonymous, use the same hash to name it. if full_name is None: full_name = ('net.proto2.python.public.proto_builder.AnonymousProto_' + fields_hash.hexdigest()) try: proto_cls = _GetMessageFromFactory(factory, full_name) return proto_cls except KeyError: # The factory's DescriptorPool doesn't know about this class yet. pass # This is the first time we see this proto: add a new descriptor to the pool. factory.pool.Add( _MakeFileDescriptorProto(proto_file_name, full_name, field_items)) return _GetMessageFromFactory(factory, full_name) def _MakeFileDescriptorProto(proto_file_name, full_name, field_items): """Populate FileDescriptorProto for MessageFactory's DescriptorPool.""" package, name = full_name.rsplit('.', 1) file_proto = descriptor_pb2.FileDescriptorProto() file_proto.name = os.path.join(package.replace('.', '/'), proto_file_name) file_proto.package = package desc_proto = file_proto.message_type.add() desc_proto.name = name for f_number, (f_name, f_type) in enumerate(field_items, 1): field_proto = desc_proto.field.add() field_proto.name = f_name # # If the number falls in the reserved range, reassign it to the correct # # number after the range. if f_number >= descriptor.FieldDescriptor.FIRST_RESERVED_FIELD_NUMBER: f_number += ( descriptor.FieldDescriptor.LAST_RESERVED_FIELD_NUMBER - descriptor.FieldDescriptor.FIRST_RESERVED_FIELD_NUMBER + 1) field_proto.number = f_number field_proto.label = descriptor_pb2.FieldDescriptorProto.LABEL_OPTIONAL field_proto.type = f_type return file_proto ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/pyext/__init__.py ================================================ ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/pyext/cpp_message.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Protocol message implementation hooks for C++ implementation. Contains helper functions used to create protocol message classes from Descriptor objects at runtime backed by the protocol buffer C++ API. """ __author__ = 'tibell@google.com (Johan Tibell)' from google.protobuf.pyext import _message class GeneratedProtocolMessageType(_message.MessageMeta): """Metaclass for protocol message classes created at runtime from Descriptors. The protocol compiler currently uses this metaclass to create protocol message classes at runtime. Clients can also manually create their own classes at runtime, as in this example: mydescriptor = Descriptor(.....) factory = symbol_database.Default() factory.pool.AddDescriptor(mydescriptor) MyProtoClass = factory.GetPrototype(mydescriptor) myproto_instance = MyProtoClass() myproto.foo_field = 23 ... The above example will not work for nested types. If you wish to include them, use reflection.MakeClass() instead of manually instantiating the class in order to create the appropriate class structure. """ # Must be consistent with the protocol-compiler code in # proto2/compiler/internal/generator.*. _DESCRIPTOR_KEY = 'DESCRIPTOR' ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/pyext/python_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/pyext/python.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"google/protobuf/pyext/python.proto\x12\x1fgoogle.protobuf.python.internal\"\xbc\x02\n\x0cTestAllTypes\x12\\\n\x17repeated_nested_message\x18\x01 \x03(\x0b\x32;.google.protobuf.python.internal.TestAllTypes.NestedMessage\x12\\\n\x17optional_nested_message\x18\x02 \x01(\x0b\x32;.google.protobuf.python.internal.TestAllTypes.NestedMessage\x12\x16\n\x0eoptional_int32\x18\x03 \x01(\x05\x1aX\n\rNestedMessage\x12\n\n\x02\x62\x62\x18\x01 \x01(\x05\x12;\n\x02\x63\x63\x18\x02 \x01(\x0b\x32/.google.protobuf.python.internal.ForeignMessage\"&\n\x0e\x46oreignMessage\x12\t\n\x01\x63\x18\x01 \x01(\x05\x12\t\n\x01\x64\x18\x02 \x03(\x05\"\x1d\n\x11TestAllExtensions*\x08\x08\x01\x10\x80\x80\x80\x80\x02:\x9a\x01\n!optional_nested_message_extension\x12\x32.google.protobuf.python.internal.TestAllExtensions\x18\x01 \x01(\x0b\x32;.google.protobuf.python.internal.TestAllTypes.NestedMessage:\x9a\x01\n!repeated_nested_message_extension\x12\x32.google.protobuf.python.internal.TestAllExtensions\x18\x02 \x03(\x0b\x32;.google.protobuf.python.internal.TestAllTypes.NestedMessageB\x02H\x01') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.pyext.python_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: TestAllExtensions.RegisterExtension(optional_nested_message_extension) TestAllExtensions.RegisterExtension(repeated_nested_message_extension) DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'H\001' _TESTALLTYPES._serialized_start=72 _TESTALLTYPES._serialized_end=388 _TESTALLTYPES_NESTEDMESSAGE._serialized_start=300 _TESTALLTYPES_NESTEDMESSAGE._serialized_end=388 _FOREIGNMESSAGE._serialized_start=390 _FOREIGNMESSAGE._serialized_end=428 _TESTALLEXTENSIONS._serialized_start=430 _TESTALLEXTENSIONS._serialized_end=459 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/reflection.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # This code is meant to work on Python 2.4 and above only. """Contains a metaclass and helper functions used to create protocol message classes from Descriptor objects at runtime. Recall that a metaclass is the "type" of a class. (A class is to a metaclass what an instance is to a class.) In this case, we use the GeneratedProtocolMessageType metaclass to inject all the useful functionality into the classes output by the protocol compiler at compile-time. The upshot of all this is that the real implementation details for ALL pure-Python protocol buffers are *here in this file*. """ __author__ = 'robinson@google.com (Will Robinson)' from google.protobuf import message_factory from google.protobuf import symbol_database # The type of all Message classes. # Part of the public interface, but normally only used by message factories. GeneratedProtocolMessageType = message_factory._GENERATED_PROTOCOL_MESSAGE_TYPE MESSAGE_CLASS_CACHE = {} # Deprecated. Please NEVER use reflection.ParseMessage(). def ParseMessage(descriptor, byte_str): """Generate a new Message instance from this Descriptor and a byte string. DEPRECATED: ParseMessage is deprecated because it is using MakeClass(). Please use MessageFactory.GetPrototype() instead. Args: descriptor: Protobuf Descriptor object byte_str: Serialized protocol buffer byte string Returns: Newly created protobuf Message object. """ result_class = MakeClass(descriptor) new_msg = result_class() new_msg.ParseFromString(byte_str) return new_msg # Deprecated. Please NEVER use reflection.MakeClass(). def MakeClass(descriptor): """Construct a class object for a protobuf described by descriptor. DEPRECATED: use MessageFactory.GetPrototype() instead. Args: descriptor: A descriptor.Descriptor object describing the protobuf. Returns: The Message class object described by the descriptor. """ # Original implementation leads to duplicate message classes, which won't play # well with extensions. Message factory info is also missing. # Redirect to message_factory. return symbol_database.Default().GetPrototype(descriptor) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/service.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """DEPRECATED: Declares the RPC service interfaces. This module declares the abstract interfaces underlying proto2 RPC services. These are intended to be independent of any particular RPC implementation, so that proto2 services can be used on top of a variety of implementations. Starting with version 2.3.0, RPC implementations should not try to build on these, but should instead provide code generator plugins which generate code specific to the particular RPC implementation. This way the generated code can be more appropriate for the implementation in use and can avoid unnecessary layers of indirection. """ __author__ = 'petar@google.com (Petar Petrov)' class RpcException(Exception): """Exception raised on failed blocking RPC method call.""" pass class Service(object): """Abstract base interface for protocol-buffer-based RPC services. Services themselves are abstract classes (implemented either by servers or as stubs), but they subclass this base interface. The methods of this interface can be used to call the methods of the service without knowing its exact type at compile time (analogous to the Message interface). """ def GetDescriptor(): """Retrieves this service's descriptor.""" raise NotImplementedError def CallMethod(self, method_descriptor, rpc_controller, request, done): """Calls a method of the service specified by method_descriptor. If "done" is None then the call is blocking and the response message will be returned directly. Otherwise the call is asynchronous and "done" will later be called with the response value. In the blocking case, RpcException will be raised on error. Preconditions: * method_descriptor.service == GetDescriptor * request is of the exact same classes as returned by GetRequestClass(method). * After the call has started, the request must not be modified. * "rpc_controller" is of the correct type for the RPC implementation being used by this Service. For stubs, the "correct type" depends on the RpcChannel which the stub is using. Postconditions: * "done" will be called when the method is complete. This may be before CallMethod() returns or it may be at some point in the future. * If the RPC failed, the response value passed to "done" will be None. Further details about the failure can be found by querying the RpcController. """ raise NotImplementedError def GetRequestClass(self, method_descriptor): """Returns the class of the request message for the specified method. CallMethod() requires that the request is of a particular subclass of Message. GetRequestClass() gets the default instance of this required type. Example: method = service.GetDescriptor().FindMethodByName("Foo") request = stub.GetRequestClass(method)() request.ParseFromString(input) service.CallMethod(method, request, callback) """ raise NotImplementedError def GetResponseClass(self, method_descriptor): """Returns the class of the response message for the specified method. This method isn't really needed, as the RpcChannel's CallMethod constructs the response protocol message. It's provided anyway in case it is useful for the caller to know the response type in advance. """ raise NotImplementedError class RpcController(object): """An RpcController mediates a single method call. The primary purpose of the controller is to provide a way to manipulate settings specific to the RPC implementation and to find out about RPC-level errors. The methods provided by the RpcController interface are intended to be a "least common denominator" set of features which we expect all implementations to support. Specific implementations may provide more advanced features (e.g. deadline propagation). """ # Client-side methods below def Reset(self): """Resets the RpcController to its initial state. After the RpcController has been reset, it may be reused in a new call. Must not be called while an RPC is in progress. """ raise NotImplementedError def Failed(self): """Returns true if the call failed. After a call has finished, returns true if the call failed. The possible reasons for failure depend on the RPC implementation. Failed() must not be called before a call has finished. If Failed() returns true, the contents of the response message are undefined. """ raise NotImplementedError def ErrorText(self): """If Failed is true, returns a human-readable description of the error.""" raise NotImplementedError def StartCancel(self): """Initiate cancellation. Advises the RPC system that the caller desires that the RPC call be canceled. The RPC system may cancel it immediately, may wait awhile and then cancel it, or may not even cancel the call at all. If the call is canceled, the "done" callback will still be called and the RpcController will indicate that the call failed at that time. """ raise NotImplementedError # Server-side methods below def SetFailed(self, reason): """Sets a failure reason. Causes Failed() to return true on the client side. "reason" will be incorporated into the message returned by ErrorText(). If you find you need to return machine-readable information about failures, you should incorporate it into your response protocol buffer and should NOT call SetFailed(). """ raise NotImplementedError def IsCanceled(self): """Checks if the client cancelled the RPC. If true, indicates that the client canceled the RPC, so the server may as well give up on replying to it. The server should still call the final "done" callback. """ raise NotImplementedError def NotifyOnCancel(self, callback): """Sets a callback to invoke on cancel. Asks that the given callback be called when the RPC is canceled. The callback will always be called exactly once. If the RPC completes without being canceled, the callback will be called after completion. If the RPC has already been canceled when NotifyOnCancel() is called, the callback will be called immediately. NotifyOnCancel() must be called no more than once per request. """ raise NotImplementedError class RpcChannel(object): """Abstract interface for an RPC channel. An RpcChannel represents a communication line to a service which can be used to call that service's methods. The service may be running on another machine. Normally, you should not use an RpcChannel directly, but instead construct a stub {@link Service} wrapping it. Example: Example: RpcChannel channel = rpcImpl.Channel("remotehost.example.com:1234") RpcController controller = rpcImpl.Controller() MyService service = MyService_Stub(channel) service.MyMethod(controller, request, callback) """ def CallMethod(self, method_descriptor, rpc_controller, request, response_class, done): """Calls the method identified by the descriptor. Call the given method of the remote service. The signature of this procedure looks the same as Service.CallMethod(), but the requirements are less strict in one important way: the request object doesn't have to be of any specific class as long as its descriptor is method.input_type. """ raise NotImplementedError ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/service_reflection.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Contains metaclasses used to create protocol service and service stub classes from ServiceDescriptor objects at runtime. The GeneratedServiceType and GeneratedServiceStubType metaclasses are used to inject all useful functionality into the classes output by the protocol compiler at compile-time. """ __author__ = 'petar@google.com (Petar Petrov)' class GeneratedServiceType(type): """Metaclass for service classes created at runtime from ServiceDescriptors. Implementations for all methods described in the Service class are added here by this class. We also create properties to allow getting/setting all fields in the protocol message. The protocol compiler currently uses this metaclass to create protocol service classes at runtime. Clients can also manually create their own classes at runtime, as in this example:: mydescriptor = ServiceDescriptor(.....) class MyProtoService(service.Service): __metaclass__ = GeneratedServiceType DESCRIPTOR = mydescriptor myservice_instance = MyProtoService() # ... """ _DESCRIPTOR_KEY = 'DESCRIPTOR' def __init__(cls, name, bases, dictionary): """Creates a message service class. Args: name: Name of the class (ignored, but required by the metaclass protocol). bases: Base classes of the class being constructed. dictionary: The class dictionary of the class being constructed. dictionary[_DESCRIPTOR_KEY] must contain a ServiceDescriptor object describing this protocol service type. """ # Don't do anything if this class doesn't have a descriptor. This happens # when a service class is subclassed. if GeneratedServiceType._DESCRIPTOR_KEY not in dictionary: return descriptor = dictionary[GeneratedServiceType._DESCRIPTOR_KEY] service_builder = _ServiceBuilder(descriptor) service_builder.BuildService(cls) cls.DESCRIPTOR = descriptor class GeneratedServiceStubType(GeneratedServiceType): """Metaclass for service stubs created at runtime from ServiceDescriptors. This class has similar responsibilities as GeneratedServiceType, except that it creates the service stub classes. """ _DESCRIPTOR_KEY = 'DESCRIPTOR' def __init__(cls, name, bases, dictionary): """Creates a message service stub class. Args: name: Name of the class (ignored, here). bases: Base classes of the class being constructed. dictionary: The class dictionary of the class being constructed. dictionary[_DESCRIPTOR_KEY] must contain a ServiceDescriptor object describing this protocol service type. """ super(GeneratedServiceStubType, cls).__init__(name, bases, dictionary) # Don't do anything if this class doesn't have a descriptor. This happens # when a service stub is subclassed. if GeneratedServiceStubType._DESCRIPTOR_KEY not in dictionary: return descriptor = dictionary[GeneratedServiceStubType._DESCRIPTOR_KEY] service_stub_builder = _ServiceStubBuilder(descriptor) service_stub_builder.BuildServiceStub(cls) class _ServiceBuilder(object): """This class constructs a protocol service class using a service descriptor. Given a service descriptor, this class constructs a class that represents the specified service descriptor. One service builder instance constructs exactly one service class. That means all instances of that class share the same builder. """ def __init__(self, service_descriptor): """Initializes an instance of the service class builder. Args: service_descriptor: ServiceDescriptor to use when constructing the service class. """ self.descriptor = service_descriptor def BuildService(builder, cls): """Constructs the service class. Args: cls: The class that will be constructed. """ # CallMethod needs to operate with an instance of the Service class. This # internal wrapper function exists only to be able to pass the service # instance to the method that does the real CallMethod work. # Making sure to use exact argument names from the abstract interface in # service.py to match the type signature def _WrapCallMethod(self, method_descriptor, rpc_controller, request, done): return builder._CallMethod(self, method_descriptor, rpc_controller, request, done) def _WrapGetRequestClass(self, method_descriptor): return builder._GetRequestClass(method_descriptor) def _WrapGetResponseClass(self, method_descriptor): return builder._GetResponseClass(method_descriptor) builder.cls = cls cls.CallMethod = _WrapCallMethod cls.GetDescriptor = staticmethod(lambda: builder.descriptor) cls.GetDescriptor.__doc__ = 'Returns the service descriptor.' cls.GetRequestClass = _WrapGetRequestClass cls.GetResponseClass = _WrapGetResponseClass for method in builder.descriptor.methods: setattr(cls, method.name, builder._GenerateNonImplementedMethod(method)) def _CallMethod(self, srvc, method_descriptor, rpc_controller, request, callback): """Calls the method described by a given method descriptor. Args: srvc: Instance of the service for which this method is called. method_descriptor: Descriptor that represent the method to call. rpc_controller: RPC controller to use for this method's execution. request: Request protocol message. callback: A callback to invoke after the method has completed. """ if method_descriptor.containing_service != self.descriptor: raise RuntimeError( 'CallMethod() given method descriptor for wrong service type.') method = getattr(srvc, method_descriptor.name) return method(rpc_controller, request, callback) def _GetRequestClass(self, method_descriptor): """Returns the class of the request protocol message. Args: method_descriptor: Descriptor of the method for which to return the request protocol message class. Returns: A class that represents the input protocol message of the specified method. """ if method_descriptor.containing_service != self.descriptor: raise RuntimeError( 'GetRequestClass() given method descriptor for wrong service type.') return method_descriptor.input_type._concrete_class def _GetResponseClass(self, method_descriptor): """Returns the class of the response protocol message. Args: method_descriptor: Descriptor of the method for which to return the response protocol message class. Returns: A class that represents the output protocol message of the specified method. """ if method_descriptor.containing_service != self.descriptor: raise RuntimeError( 'GetResponseClass() given method descriptor for wrong service type.') return method_descriptor.output_type._concrete_class def _GenerateNonImplementedMethod(self, method): """Generates and returns a method that can be set for a service methods. Args: method: Descriptor of the service method for which a method is to be generated. Returns: A method that can be added to the service class. """ return lambda inst, rpc_controller, request, callback: ( self._NonImplementedMethod(method.name, rpc_controller, callback)) def _NonImplementedMethod(self, method_name, rpc_controller, callback): """The body of all methods in the generated service class. Args: method_name: Name of the method being executed. rpc_controller: RPC controller used to execute this method. callback: A callback which will be invoked when the method finishes. """ rpc_controller.SetFailed('Method %s not implemented.' % method_name) callback(None) class _ServiceStubBuilder(object): """Constructs a protocol service stub class using a service descriptor. Given a service descriptor, this class constructs a suitable stub class. A stub is just a type-safe wrapper around an RpcChannel which emulates a local implementation of the service. One service stub builder instance constructs exactly one class. It means all instances of that class share the same service stub builder. """ def __init__(self, service_descriptor): """Initializes an instance of the service stub class builder. Args: service_descriptor: ServiceDescriptor to use when constructing the stub class. """ self.descriptor = service_descriptor def BuildServiceStub(self, cls): """Constructs the stub class. Args: cls: The class that will be constructed. """ def _ServiceStubInit(stub, rpc_channel): stub.rpc_channel = rpc_channel self.cls = cls cls.__init__ = _ServiceStubInit for method in self.descriptor.methods: setattr(cls, method.name, self._GenerateStubMethod(method)) def _GenerateStubMethod(self, method): return (lambda inst, rpc_controller, request, callback=None: self._StubMethod(inst, method, rpc_controller, request, callback)) def _StubMethod(self, stub, method_descriptor, rpc_controller, request, callback): """The body of all service methods in the generated stub class. Args: stub: Stub instance. method_descriptor: Descriptor of the invoked method. rpc_controller: Rpc controller to execute the method. request: Request protocol message. callback: A callback to execute when the method finishes. Returns: Response message (in case of blocking call). """ return stub.rpc_channel.CallMethod( method_descriptor, rpc_controller, request, method_descriptor.output_type._concrete_class, callback) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/source_context_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/source_context.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$google/protobuf/source_context.proto\x12\x0fgoogle.protobuf\"\"\n\rSourceContext\x12\x11\n\tfile_name\x18\x01 \x01(\tB\x8a\x01\n\x13\x63om.google.protobufB\x12SourceContextProtoP\x01Z6google.golang.org/protobuf/types/known/sourcecontextpb\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.source_context_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\022SourceContextProtoP\001Z6google.golang.org/protobuf/types/known/sourcecontextpb\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _SOURCECONTEXT._serialized_start=57 _SOURCECONTEXT._serialized_end=91 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/struct_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/struct.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cgoogle/protobuf/struct.proto\x12\x0fgoogle.protobuf\"\x84\x01\n\x06Struct\x12\x33\n\x06\x66ields\x18\x01 \x03(\x0b\x32#.google.protobuf.Struct.FieldsEntry\x1a\x45\n\x0b\x46ieldsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12%\n\x05value\x18\x02 \x01(\x0b\x32\x16.google.protobuf.Value:\x02\x38\x01\"\xea\x01\n\x05Value\x12\x30\n\nnull_value\x18\x01 \x01(\x0e\x32\x1a.google.protobuf.NullValueH\x00\x12\x16\n\x0cnumber_value\x18\x02 \x01(\x01H\x00\x12\x16\n\x0cstring_value\x18\x03 \x01(\tH\x00\x12\x14\n\nbool_value\x18\x04 \x01(\x08H\x00\x12/\n\x0cstruct_value\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x12\x30\n\nlist_value\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.ListValueH\x00\x42\x06\n\x04kind\"3\n\tListValue\x12&\n\x06values\x18\x01 \x03(\x0b\x32\x16.google.protobuf.Value*\x1b\n\tNullValue\x12\x0e\n\nNULL_VALUE\x10\x00\x42\x7f\n\x13\x63om.google.protobufB\x0bStructProtoP\x01Z/google.golang.org/protobuf/types/known/structpb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.struct_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\013StructProtoP\001Z/google.golang.org/protobuf/types/known/structpb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _STRUCT_FIELDSENTRY._options = None _STRUCT_FIELDSENTRY._serialized_options = b'8\001' _NULLVALUE._serialized_start=474 _NULLVALUE._serialized_end=501 _STRUCT._serialized_start=50 _STRUCT._serialized_end=182 _STRUCT_FIELDSENTRY._serialized_start=113 _STRUCT_FIELDSENTRY._serialized_end=182 _VALUE._serialized_start=185 _VALUE._serialized_end=419 _LISTVALUE._serialized_start=421 _LISTVALUE._serialized_end=472 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/symbol_database.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """A database of Python protocol buffer generated symbols. SymbolDatabase is the MessageFactory for messages generated at compile time, and makes it easy to create new instances of a registered type, given only the type's protocol buffer symbol name. Example usage:: db = symbol_database.SymbolDatabase() # Register symbols of interest, from one or multiple files. db.RegisterFileDescriptor(my_proto_pb2.DESCRIPTOR) db.RegisterMessage(my_proto_pb2.MyMessage) db.RegisterEnumDescriptor(my_proto_pb2.MyEnum.DESCRIPTOR) # The database can be used as a MessageFactory, to generate types based on # their name: types = db.GetMessages(['my_proto.proto']) my_message_instance = types['MyMessage']() # The database's underlying descriptor pool can be queried, so it's not # necessary to know a type's filename to be able to generate it: filename = db.pool.FindFileContainingSymbol('MyMessage') my_message_instance = db.GetMessages([filename])['MyMessage']() # This functionality is also provided directly via a convenience method: my_message_instance = db.GetSymbol('MyMessage')() """ from google.protobuf.internal import api_implementation from google.protobuf import descriptor_pool from google.protobuf import message_factory class SymbolDatabase(message_factory.MessageFactory): """A database of Python generated symbols.""" def RegisterMessage(self, message): """Registers the given message type in the local database. Calls to GetSymbol() and GetMessages() will return messages registered here. Args: message: A :class:`google.protobuf.message.Message` subclass (or instance); its descriptor will be registered. Returns: The provided message. """ desc = message.DESCRIPTOR self._classes[desc] = message self.RegisterMessageDescriptor(desc) return message def RegisterMessageDescriptor(self, message_descriptor): """Registers the given message descriptor in the local database. Args: message_descriptor (Descriptor): the message descriptor to add. """ if api_implementation.Type() == 'python': # pylint: disable=protected-access self.pool._AddDescriptor(message_descriptor) def RegisterEnumDescriptor(self, enum_descriptor): """Registers the given enum descriptor in the local database. Args: enum_descriptor (EnumDescriptor): The enum descriptor to register. Returns: EnumDescriptor: The provided descriptor. """ if api_implementation.Type() == 'python': # pylint: disable=protected-access self.pool._AddEnumDescriptor(enum_descriptor) return enum_descriptor def RegisterServiceDescriptor(self, service_descriptor): """Registers the given service descriptor in the local database. Args: service_descriptor (ServiceDescriptor): the service descriptor to register. """ if api_implementation.Type() == 'python': # pylint: disable=protected-access self.pool._AddServiceDescriptor(service_descriptor) def RegisterFileDescriptor(self, file_descriptor): """Registers the given file descriptor in the local database. Args: file_descriptor (FileDescriptor): The file descriptor to register. """ if api_implementation.Type() == 'python': # pylint: disable=protected-access self.pool._InternalAddFileDescriptor(file_descriptor) def GetSymbol(self, symbol): """Tries to find a symbol in the local database. Currently, this method only returns message.Message instances, however, if may be extended in future to support other symbol types. Args: symbol (str): a protocol buffer symbol. Returns: A Python class corresponding to the symbol. Raises: KeyError: if the symbol could not be found. """ return self._classes[self.pool.FindMessageTypeByName(symbol)] def GetMessages(self, files): # TODO(amauryfa): Fix the differences with MessageFactory. """Gets all registered messages from a specified file. Only messages already created and registered will be returned; (this is the case for imported _pb2 modules) But unlike MessageFactory, this version also returns already defined nested messages, but does not register any message extensions. Args: files (list[str]): The file names to extract messages from. Returns: A dictionary mapping proto names to the message classes. Raises: KeyError: if a file could not be found. """ def _GetAllMessages(desc): """Walk a message Descriptor and recursively yields all message names.""" yield desc for msg_desc in desc.nested_types: for nested_desc in _GetAllMessages(msg_desc): yield nested_desc result = {} for file_name in files: file_desc = self.pool.FindFileByName(file_name) for msg_desc in file_desc.message_types_by_name.values(): for desc in _GetAllMessages(msg_desc): try: result[desc.full_name] = self._classes[desc] except KeyError: # This descriptor has no registered class, skip it. pass return result _DEFAULT = SymbolDatabase(pool=descriptor_pool.Default()) def Default(): """Returns the default SymbolDatabase.""" return _DEFAULT ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/text_encoding.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Encoding related utilities.""" import re _cescape_chr_to_symbol_map = {} _cescape_chr_to_symbol_map[9] = r'\t' # optional escape _cescape_chr_to_symbol_map[10] = r'\n' # optional escape _cescape_chr_to_symbol_map[13] = r'\r' # optional escape _cescape_chr_to_symbol_map[34] = r'\"' # necessary escape _cescape_chr_to_symbol_map[39] = r"\'" # optional escape _cescape_chr_to_symbol_map[92] = r'\\' # necessary escape # Lookup table for unicode _cescape_unicode_to_str = [chr(i) for i in range(0, 256)] for byte, string in _cescape_chr_to_symbol_map.items(): _cescape_unicode_to_str[byte] = string # Lookup table for non-utf8, with necessary escapes at (o >= 127 or o < 32) _cescape_byte_to_str = ([r'\%03o' % i for i in range(0, 32)] + [chr(i) for i in range(32, 127)] + [r'\%03o' % i for i in range(127, 256)]) for byte, string in _cescape_chr_to_symbol_map.items(): _cescape_byte_to_str[byte] = string del byte, string def CEscape(text, as_utf8): # type: (...) -> str """Escape a bytes string for use in an text protocol buffer. Args: text: A byte string to be escaped. as_utf8: Specifies if result may contain non-ASCII characters. In Python 3 this allows unescaped non-ASCII Unicode characters. In Python 2 the return value will be valid UTF-8 rather than only ASCII. Returns: Escaped string (str). """ # Python's text.encode() 'string_escape' or 'unicode_escape' codecs do not # satisfy our needs; they encodes unprintable characters using two-digit hex # escapes whereas our C++ unescaping function allows hex escapes to be any # length. So, "\0011".encode('string_escape') ends up being "\\x011", which # will be decoded in C++ as a single-character string with char code 0x11. text_is_unicode = isinstance(text, str) if as_utf8 and text_is_unicode: # We're already unicode, no processing beyond control char escapes. return text.translate(_cescape_chr_to_symbol_map) ord_ = ord if text_is_unicode else lambda x: x # bytes iterate as ints. if as_utf8: return ''.join(_cescape_unicode_to_str[ord_(c)] for c in text) return ''.join(_cescape_byte_to_str[ord_(c)] for c in text) _CUNESCAPE_HEX = re.compile(r'(\\+)x([0-9a-fA-F])(?![0-9a-fA-F])') def CUnescape(text): # type: (str) -> bytes """Unescape a text string with C-style escape sequences to UTF-8 bytes. Args: text: The data to parse in a str. Returns: A byte string. """ def ReplaceHex(m): # Only replace the match if the number of leading back slashes is odd. i.e. # the slash itself is not escaped. if len(m.group(1)) & 1: return m.group(1) + 'x0' + m.group(2) return m.group(0) # This is required because the 'string_escape' encoding doesn't # allow single-digit hex escapes (like '\xf'). result = _CUNESCAPE_HEX.sub(ReplaceHex, text) return (result.encode('utf-8') # Make it bytes to allow decode. .decode('unicode_escape') # Make it bytes again to return the proper type. .encode('raw_unicode_escape')) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/text_format.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Contains routines for printing protocol messages in text format. Simple usage example:: # Create a proto object and serialize it to a text proto string. message = my_proto_pb2.MyMessage(foo='bar') text_proto = text_format.MessageToString(message) # Parse a text proto string. message = text_format.Parse(text_proto, my_proto_pb2.MyMessage()) """ __author__ = 'kenton@google.com (Kenton Varda)' # TODO(b/129989314) Import thread contention leads to test failures. import encodings.raw_unicode_escape # pylint: disable=unused-import import encodings.unicode_escape # pylint: disable=unused-import import io import math import re from google.protobuf.internal import decoder from google.protobuf.internal import type_checkers from google.protobuf import descriptor from google.protobuf import text_encoding # pylint: disable=g-import-not-at-top __all__ = ['MessageToString', 'Parse', 'PrintMessage', 'PrintField', 'PrintFieldValue', 'Merge', 'MessageToBytes'] _INTEGER_CHECKERS = (type_checkers.Uint32ValueChecker(), type_checkers.Int32ValueChecker(), type_checkers.Uint64ValueChecker(), type_checkers.Int64ValueChecker()) _FLOAT_INFINITY = re.compile('-?inf(?:inity)?f?$', re.IGNORECASE) _FLOAT_NAN = re.compile('nanf?$', re.IGNORECASE) _QUOTES = frozenset(("'", '"')) _ANY_FULL_TYPE_NAME = 'google.protobuf.Any' class Error(Exception): """Top-level module error for text_format.""" class ParseError(Error): """Thrown in case of text parsing or tokenizing error.""" def __init__(self, message=None, line=None, column=None): if message is not None and line is not None: loc = str(line) if column is not None: loc += ':{0}'.format(column) message = '{0} : {1}'.format(loc, message) if message is not None: super(ParseError, self).__init__(message) else: super(ParseError, self).__init__() self._line = line self._column = column def GetLine(self): return self._line def GetColumn(self): return self._column class TextWriter(object): def __init__(self, as_utf8): self._writer = io.StringIO() def write(self, val): return self._writer.write(val) def close(self): return self._writer.close() def getvalue(self): return self._writer.getvalue() def MessageToString( message, as_utf8=False, as_one_line=False, use_short_repeated_primitives=False, pointy_brackets=False, use_index_order=False, float_format=None, double_format=None, use_field_number=False, descriptor_pool=None, indent=0, message_formatter=None, print_unknown_fields=False, force_colon=False): # type: (...) -> str """Convert protobuf message to text format. Double values can be formatted compactly with 15 digits of precision (which is the most that IEEE 754 "double" can guarantee) using double_format='.15g'. To ensure that converting to text and back to a proto will result in an identical value, double_format='.17g' should be used. Args: message: The protocol buffers message. as_utf8: Return unescaped Unicode for non-ASCII characters. In Python 3 actual Unicode characters may appear as is in strings. In Python 2 the return value will be valid UTF-8 rather than only ASCII. as_one_line: Don't introduce newlines between fields. use_short_repeated_primitives: Use short repeated format for primitives. pointy_brackets: If True, use angle brackets instead of curly braces for nesting. use_index_order: If True, fields of a proto message will be printed using the order defined in source code instead of the field number, extensions will be printed at the end of the message and their relative order is determined by the extension number. By default, use the field number order. float_format (str): If set, use this to specify float field formatting (per the "Format Specification Mini-Language"); otherwise, shortest float that has same value in wire will be printed. Also affect double field if double_format is not set but float_format is set. double_format (str): If set, use this to specify double field formatting (per the "Format Specification Mini-Language"); if it is not set but float_format is set, use float_format. Otherwise, use ``str()`` use_field_number: If True, print field numbers instead of names. descriptor_pool (DescriptorPool): Descriptor pool used to resolve Any types. indent (int): The initial indent level, in terms of spaces, for pretty print. message_formatter (function(message, indent, as_one_line) -> unicode|None): Custom formatter for selected sub-messages (usually based on message type). Use to pretty print parts of the protobuf for easier diffing. print_unknown_fields: If True, unknown fields will be printed. force_colon: If set, a colon will be added after the field name even if the field is a proto message. Returns: str: A string of the text formatted protocol buffer message. """ out = TextWriter(as_utf8) printer = _Printer( out, indent, as_utf8, as_one_line, use_short_repeated_primitives, pointy_brackets, use_index_order, float_format, double_format, use_field_number, descriptor_pool, message_formatter, print_unknown_fields=print_unknown_fields, force_colon=force_colon) printer.PrintMessage(message) result = out.getvalue() out.close() if as_one_line: return result.rstrip() return result def MessageToBytes(message, **kwargs): # type: (...) -> bytes """Convert protobuf message to encoded text format. See MessageToString.""" text = MessageToString(message, **kwargs) if isinstance(text, bytes): return text codec = 'utf-8' if kwargs.get('as_utf8') else 'ascii' return text.encode(codec) def _IsMapEntry(field): return (field.type == descriptor.FieldDescriptor.TYPE_MESSAGE and field.message_type.has_options and field.message_type.GetOptions().map_entry) def PrintMessage(message, out, indent=0, as_utf8=False, as_one_line=False, use_short_repeated_primitives=False, pointy_brackets=False, use_index_order=False, float_format=None, double_format=None, use_field_number=False, descriptor_pool=None, message_formatter=None, print_unknown_fields=False, force_colon=False): printer = _Printer( out=out, indent=indent, as_utf8=as_utf8, as_one_line=as_one_line, use_short_repeated_primitives=use_short_repeated_primitives, pointy_brackets=pointy_brackets, use_index_order=use_index_order, float_format=float_format, double_format=double_format, use_field_number=use_field_number, descriptor_pool=descriptor_pool, message_formatter=message_formatter, print_unknown_fields=print_unknown_fields, force_colon=force_colon) printer.PrintMessage(message) def PrintField(field, value, out, indent=0, as_utf8=False, as_one_line=False, use_short_repeated_primitives=False, pointy_brackets=False, use_index_order=False, float_format=None, double_format=None, message_formatter=None, print_unknown_fields=False, force_colon=False): """Print a single field name/value pair.""" printer = _Printer(out, indent, as_utf8, as_one_line, use_short_repeated_primitives, pointy_brackets, use_index_order, float_format, double_format, message_formatter=message_formatter, print_unknown_fields=print_unknown_fields, force_colon=force_colon) printer.PrintField(field, value) def PrintFieldValue(field, value, out, indent=0, as_utf8=False, as_one_line=False, use_short_repeated_primitives=False, pointy_brackets=False, use_index_order=False, float_format=None, double_format=None, message_formatter=None, print_unknown_fields=False, force_colon=False): """Print a single field value (not including name).""" printer = _Printer(out, indent, as_utf8, as_one_line, use_short_repeated_primitives, pointy_brackets, use_index_order, float_format, double_format, message_formatter=message_formatter, print_unknown_fields=print_unknown_fields, force_colon=force_colon) printer.PrintFieldValue(field, value) def _BuildMessageFromTypeName(type_name, descriptor_pool): """Returns a protobuf message instance. Args: type_name: Fully-qualified protobuf message type name string. descriptor_pool: DescriptorPool instance. Returns: A Message instance of type matching type_name, or None if the a Descriptor wasn't found matching type_name. """ # pylint: disable=g-import-not-at-top if descriptor_pool is None: from google.protobuf import descriptor_pool as pool_mod descriptor_pool = pool_mod.Default() from google.protobuf import symbol_database database = symbol_database.Default() try: message_descriptor = descriptor_pool.FindMessageTypeByName(type_name) except KeyError: return None message_type = database.GetPrototype(message_descriptor) return message_type() # These values must match WireType enum in google/protobuf/wire_format.h. WIRETYPE_LENGTH_DELIMITED = 2 WIRETYPE_START_GROUP = 3 class _Printer(object): """Text format printer for protocol message.""" def __init__( self, out, indent=0, as_utf8=False, as_one_line=False, use_short_repeated_primitives=False, pointy_brackets=False, use_index_order=False, float_format=None, double_format=None, use_field_number=False, descriptor_pool=None, message_formatter=None, print_unknown_fields=False, force_colon=False): """Initialize the Printer. Double values can be formatted compactly with 15 digits of precision (which is the most that IEEE 754 "double" can guarantee) using double_format='.15g'. To ensure that converting to text and back to a proto will result in an identical value, double_format='.17g' should be used. Args: out: To record the text format result. indent: The initial indent level for pretty print. as_utf8: Return unescaped Unicode for non-ASCII characters. In Python 3 actual Unicode characters may appear as is in strings. In Python 2 the return value will be valid UTF-8 rather than ASCII. as_one_line: Don't introduce newlines between fields. use_short_repeated_primitives: Use short repeated format for primitives. pointy_brackets: If True, use angle brackets instead of curly braces for nesting. use_index_order: If True, print fields of a proto message using the order defined in source code instead of the field number. By default, use the field number order. float_format: If set, use this to specify float field formatting (per the "Format Specification Mini-Language"); otherwise, shortest float that has same value in wire will be printed. Also affect double field if double_format is not set but float_format is set. double_format: If set, use this to specify double field formatting (per the "Format Specification Mini-Language"); if it is not set but float_format is set, use float_format. Otherwise, str() is used. use_field_number: If True, print field numbers instead of names. descriptor_pool: A DescriptorPool used to resolve Any types. message_formatter: A function(message, indent, as_one_line): unicode|None to custom format selected sub-messages (usually based on message type). Use to pretty print parts of the protobuf for easier diffing. print_unknown_fields: If True, unknown fields will be printed. force_colon: If set, a colon will be added after the field name even if the field is a proto message. """ self.out = out self.indent = indent self.as_utf8 = as_utf8 self.as_one_line = as_one_line self.use_short_repeated_primitives = use_short_repeated_primitives self.pointy_brackets = pointy_brackets self.use_index_order = use_index_order self.float_format = float_format if double_format is not None: self.double_format = double_format else: self.double_format = float_format self.use_field_number = use_field_number self.descriptor_pool = descriptor_pool self.message_formatter = message_formatter self.print_unknown_fields = print_unknown_fields self.force_colon = force_colon def _TryPrintAsAnyMessage(self, message): """Serializes if message is a google.protobuf.Any field.""" if '/' not in message.type_url: return False packed_message = _BuildMessageFromTypeName(message.TypeName(), self.descriptor_pool) if packed_message: packed_message.MergeFromString(message.value) colon = ':' if self.force_colon else '' self.out.write('%s[%s]%s ' % (self.indent * ' ', message.type_url, colon)) self._PrintMessageFieldValue(packed_message) self.out.write(' ' if self.as_one_line else '\n') return True else: return False def _TryCustomFormatMessage(self, message): formatted = self.message_formatter(message, self.indent, self.as_one_line) if formatted is None: return False out = self.out out.write(' ' * self.indent) out.write(formatted) out.write(' ' if self.as_one_line else '\n') return True def PrintMessage(self, message): """Convert protobuf message to text format. Args: message: The protocol buffers message. """ if self.message_formatter and self._TryCustomFormatMessage(message): return if (message.DESCRIPTOR.full_name == _ANY_FULL_TYPE_NAME and self._TryPrintAsAnyMessage(message)): return fields = message.ListFields() if self.use_index_order: fields.sort( key=lambda x: x[0].number if x[0].is_extension else x[0].index) for field, value in fields: if _IsMapEntry(field): for key in sorted(value): # This is slow for maps with submessage entries because it copies the # entire tree. Unfortunately this would take significant refactoring # of this file to work around. # # TODO(haberman): refactor and optimize if this becomes an issue. entry_submsg = value.GetEntryClass()(key=key, value=value[key]) self.PrintField(field, entry_submsg) elif field.label == descriptor.FieldDescriptor.LABEL_REPEATED: if (self.use_short_repeated_primitives and field.cpp_type != descriptor.FieldDescriptor.CPPTYPE_MESSAGE and field.cpp_type != descriptor.FieldDescriptor.CPPTYPE_STRING): self._PrintShortRepeatedPrimitivesValue(field, value) else: for element in value: self.PrintField(field, element) else: self.PrintField(field, value) if self.print_unknown_fields: self._PrintUnknownFields(message.UnknownFields()) def _PrintUnknownFields(self, unknown_fields): """Print unknown fields.""" out = self.out for field in unknown_fields: out.write(' ' * self.indent) out.write(str(field.field_number)) if field.wire_type == WIRETYPE_START_GROUP: if self.as_one_line: out.write(' { ') else: out.write(' {\n') self.indent += 2 self._PrintUnknownFields(field.data) if self.as_one_line: out.write('} ') else: self.indent -= 2 out.write(' ' * self.indent + '}\n') elif field.wire_type == WIRETYPE_LENGTH_DELIMITED: try: # If this field is parseable as a Message, it is probably # an embedded message. # pylint: disable=protected-access (embedded_unknown_message, pos) = decoder._DecodeUnknownFieldSet( memoryview(field.data), 0, len(field.data)) except Exception: # pylint: disable=broad-except pos = 0 if pos == len(field.data): if self.as_one_line: out.write(' { ') else: out.write(' {\n') self.indent += 2 self._PrintUnknownFields(embedded_unknown_message) if self.as_one_line: out.write('} ') else: self.indent -= 2 out.write(' ' * self.indent + '}\n') else: # A string or bytes field. self.as_utf8 may not work. out.write(': \"') out.write(text_encoding.CEscape(field.data, False)) out.write('\" ' if self.as_one_line else '\"\n') else: # varint, fixed32, fixed64 out.write(': ') out.write(str(field.data)) out.write(' ' if self.as_one_line else '\n') def _PrintFieldName(self, field): """Print field name.""" out = self.out out.write(' ' * self.indent) if self.use_field_number: out.write(str(field.number)) else: if field.is_extension: out.write('[') if (field.containing_type.GetOptions().message_set_wire_format and field.type == descriptor.FieldDescriptor.TYPE_MESSAGE and field.label == descriptor.FieldDescriptor.LABEL_OPTIONAL): out.write(field.message_type.full_name) else: out.write(field.full_name) out.write(']') elif field.type == descriptor.FieldDescriptor.TYPE_GROUP: # For groups, use the capitalized name. out.write(field.message_type.name) else: out.write(field.name) if (self.force_colon or field.cpp_type != descriptor.FieldDescriptor.CPPTYPE_MESSAGE): # The colon is optional in this case, but our cross-language golden files # don't include it. Here, the colon is only included if force_colon is # set to True out.write(':') def PrintField(self, field, value): """Print a single field name/value pair.""" self._PrintFieldName(field) self.out.write(' ') self.PrintFieldValue(field, value) self.out.write(' ' if self.as_one_line else '\n') def _PrintShortRepeatedPrimitivesValue(self, field, value): """"Prints short repeated primitives value.""" # Note: this is called only when value has at least one element. self._PrintFieldName(field) self.out.write(' [') for i in range(len(value) - 1): self.PrintFieldValue(field, value[i]) self.out.write(', ') self.PrintFieldValue(field, value[-1]) self.out.write(']') self.out.write(' ' if self.as_one_line else '\n') def _PrintMessageFieldValue(self, value): if self.pointy_brackets: openb = '<' closeb = '>' else: openb = '{' closeb = '}' if self.as_one_line: self.out.write('%s ' % openb) self.PrintMessage(value) self.out.write(closeb) else: self.out.write('%s\n' % openb) self.indent += 2 self.PrintMessage(value) self.indent -= 2 self.out.write(' ' * self.indent + closeb) def PrintFieldValue(self, field, value): """Print a single field value (not including name). For repeated fields, the value should be a single element. Args: field: The descriptor of the field to be printed. value: The value of the field. """ out = self.out if field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_MESSAGE: self._PrintMessageFieldValue(value) elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_ENUM: enum_value = field.enum_type.values_by_number.get(value, None) if enum_value is not None: out.write(enum_value.name) else: out.write(str(value)) elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_STRING: out.write('\"') if isinstance(value, str) and not self.as_utf8: out_value = value.encode('utf-8') else: out_value = value if field.type == descriptor.FieldDescriptor.TYPE_BYTES: # We always need to escape all binary data in TYPE_BYTES fields. out_as_utf8 = False else: out_as_utf8 = self.as_utf8 out.write(text_encoding.CEscape(out_value, out_as_utf8)) out.write('\"') elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_BOOL: if value: out.write('true') else: out.write('false') elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_FLOAT: if self.float_format is not None: out.write('{1:{0}}'.format(self.float_format, value)) else: if math.isnan(value): out.write(str(value)) else: out.write(str(type_checkers.ToShortestFloat(value))) elif (field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_DOUBLE and self.double_format is not None): out.write('{1:{0}}'.format(self.double_format, value)) else: out.write(str(value)) def Parse(text, message, allow_unknown_extension=False, allow_field_number=False, descriptor_pool=None, allow_unknown_field=False): """Parses a text representation of a protocol message into a message. NOTE: for historical reasons this function does not clear the input message. This is different from what the binary msg.ParseFrom(...) does. If text contains a field already set in message, the value is appended if the field is repeated. Otherwise, an error is raised. Example:: a = MyProto() a.repeated_field.append('test') b = MyProto() # Repeated fields are combined text_format.Parse(repr(a), b) text_format.Parse(repr(a), b) # repeated_field contains ["test", "test"] # Non-repeated fields cannot be overwritten a.singular_field = 1 b.singular_field = 2 text_format.Parse(repr(a), b) # ParseError # Binary version: b.ParseFromString(a.SerializeToString()) # repeated_field is now "test" Caller is responsible for clearing the message as needed. Args: text (str): Message text representation. message (Message): A protocol buffer message to merge into. allow_unknown_extension: if True, skip over missing extensions and keep parsing allow_field_number: if True, both field number and field name are allowed. descriptor_pool (DescriptorPool): Descriptor pool used to resolve Any types. allow_unknown_field: if True, skip over unknown field and keep parsing. Avoid to use this option if possible. It may hide some errors (e.g. spelling error on field name) Returns: Message: The same message passed as argument. Raises: ParseError: On text parsing problems. """ return ParseLines(text.split(b'\n' if isinstance(text, bytes) else u'\n'), message, allow_unknown_extension, allow_field_number, descriptor_pool=descriptor_pool, allow_unknown_field=allow_unknown_field) def Merge(text, message, allow_unknown_extension=False, allow_field_number=False, descriptor_pool=None, allow_unknown_field=False): """Parses a text representation of a protocol message into a message. Like Parse(), but allows repeated values for a non-repeated field, and uses the last one. This means any non-repeated, top-level fields specified in text replace those in the message. Args: text (str): Message text representation. message (Message): A protocol buffer message to merge into. allow_unknown_extension: if True, skip over missing extensions and keep parsing allow_field_number: if True, both field number and field name are allowed. descriptor_pool (DescriptorPool): Descriptor pool used to resolve Any types. allow_unknown_field: if True, skip over unknown field and keep parsing. Avoid to use this option if possible. It may hide some errors (e.g. spelling error on field name) Returns: Message: The same message passed as argument. Raises: ParseError: On text parsing problems. """ return MergeLines( text.split(b'\n' if isinstance(text, bytes) else u'\n'), message, allow_unknown_extension, allow_field_number, descriptor_pool=descriptor_pool, allow_unknown_field=allow_unknown_field) def ParseLines(lines, message, allow_unknown_extension=False, allow_field_number=False, descriptor_pool=None, allow_unknown_field=False): """Parses a text representation of a protocol message into a message. See Parse() for caveats. Args: lines: An iterable of lines of a message's text representation. message: A protocol buffer message to merge into. allow_unknown_extension: if True, skip over missing extensions and keep parsing allow_field_number: if True, both field number and field name are allowed. descriptor_pool: A DescriptorPool used to resolve Any types. allow_unknown_field: if True, skip over unknown field and keep parsing. Avoid to use this option if possible. It may hide some errors (e.g. spelling error on field name) Returns: The same message passed as argument. Raises: ParseError: On text parsing problems. """ parser = _Parser(allow_unknown_extension, allow_field_number, descriptor_pool=descriptor_pool, allow_unknown_field=allow_unknown_field) return parser.ParseLines(lines, message) def MergeLines(lines, message, allow_unknown_extension=False, allow_field_number=False, descriptor_pool=None, allow_unknown_field=False): """Parses a text representation of a protocol message into a message. See Merge() for more details. Args: lines: An iterable of lines of a message's text representation. message: A protocol buffer message to merge into. allow_unknown_extension: if True, skip over missing extensions and keep parsing allow_field_number: if True, both field number and field name are allowed. descriptor_pool: A DescriptorPool used to resolve Any types. allow_unknown_field: if True, skip over unknown field and keep parsing. Avoid to use this option if possible. It may hide some errors (e.g. spelling error on field name) Returns: The same message passed as argument. Raises: ParseError: On text parsing problems. """ parser = _Parser(allow_unknown_extension, allow_field_number, descriptor_pool=descriptor_pool, allow_unknown_field=allow_unknown_field) return parser.MergeLines(lines, message) class _Parser(object): """Text format parser for protocol message.""" def __init__(self, allow_unknown_extension=False, allow_field_number=False, descriptor_pool=None, allow_unknown_field=False): self.allow_unknown_extension = allow_unknown_extension self.allow_field_number = allow_field_number self.descriptor_pool = descriptor_pool self.allow_unknown_field = allow_unknown_field def ParseLines(self, lines, message): """Parses a text representation of a protocol message into a message.""" self._allow_multiple_scalars = False self._ParseOrMerge(lines, message) return message def MergeLines(self, lines, message): """Merges a text representation of a protocol message into a message.""" self._allow_multiple_scalars = True self._ParseOrMerge(lines, message) return message def _ParseOrMerge(self, lines, message): """Converts a text representation of a protocol message into a message. Args: lines: Lines of a message's text representation. message: A protocol buffer message to merge into. Raises: ParseError: On text parsing problems. """ # Tokenize expects native str lines. str_lines = ( line if isinstance(line, str) else line.decode('utf-8') for line in lines) tokenizer = Tokenizer(str_lines) while not tokenizer.AtEnd(): self._MergeField(tokenizer, message) def _MergeField(self, tokenizer, message): """Merges a single protocol message field into a message. Args: tokenizer: A tokenizer to parse the field name and values. message: A protocol message to record the data. Raises: ParseError: In case of text parsing problems. """ message_descriptor = message.DESCRIPTOR if (message_descriptor.full_name == _ANY_FULL_TYPE_NAME and tokenizer.TryConsume('[')): type_url_prefix, packed_type_name = self._ConsumeAnyTypeUrl(tokenizer) tokenizer.Consume(']') tokenizer.TryConsume(':') if tokenizer.TryConsume('<'): expanded_any_end_token = '>' else: tokenizer.Consume('{') expanded_any_end_token = '}' expanded_any_sub_message = _BuildMessageFromTypeName(packed_type_name, self.descriptor_pool) if not expanded_any_sub_message: raise ParseError('Type %s not found in descriptor pool' % packed_type_name) while not tokenizer.TryConsume(expanded_any_end_token): if tokenizer.AtEnd(): raise tokenizer.ParseErrorPreviousToken('Expected "%s".' % (expanded_any_end_token,)) self._MergeField(tokenizer, expanded_any_sub_message) deterministic = False message.Pack(expanded_any_sub_message, type_url_prefix=type_url_prefix, deterministic=deterministic) return if tokenizer.TryConsume('['): name = [tokenizer.ConsumeIdentifier()] while tokenizer.TryConsume('.'): name.append(tokenizer.ConsumeIdentifier()) name = '.'.join(name) if not message_descriptor.is_extendable: raise tokenizer.ParseErrorPreviousToken( 'Message type "%s" does not have extensions.' % message_descriptor.full_name) # pylint: disable=protected-access field = message.Extensions._FindExtensionByName(name) # pylint: enable=protected-access if not field: if self.allow_unknown_extension: field = None else: raise tokenizer.ParseErrorPreviousToken( 'Extension "%s" not registered. ' 'Did you import the _pb2 module which defines it? ' 'If you are trying to place the extension in the MessageSet ' 'field of another message that is in an Any or MessageSet field, ' 'that message\'s _pb2 module must be imported as well' % name) elif message_descriptor != field.containing_type: raise tokenizer.ParseErrorPreviousToken( 'Extension "%s" does not extend message type "%s".' % (name, message_descriptor.full_name)) tokenizer.Consume(']') else: name = tokenizer.ConsumeIdentifierOrNumber() if self.allow_field_number and name.isdigit(): number = ParseInteger(name, True, True) field = message_descriptor.fields_by_number.get(number, None) if not field and message_descriptor.is_extendable: field = message.Extensions._FindExtensionByNumber(number) else: field = message_descriptor.fields_by_name.get(name, None) # Group names are expected to be capitalized as they appear in the # .proto file, which actually matches their type names, not their field # names. if not field: field = message_descriptor.fields_by_name.get(name.lower(), None) if field and field.type != descriptor.FieldDescriptor.TYPE_GROUP: field = None if (field and field.type == descriptor.FieldDescriptor.TYPE_GROUP and field.message_type.name != name): field = None if not field and not self.allow_unknown_field: raise tokenizer.ParseErrorPreviousToken( 'Message type "%s" has no field named "%s".' % (message_descriptor.full_name, name)) if field: if not self._allow_multiple_scalars and field.containing_oneof: # Check if there's a different field set in this oneof. # Note that we ignore the case if the same field was set before, and we # apply _allow_multiple_scalars to non-scalar fields as well. which_oneof = message.WhichOneof(field.containing_oneof.name) if which_oneof is not None and which_oneof != field.name: raise tokenizer.ParseErrorPreviousToken( 'Field "%s" is specified along with field "%s", another member ' 'of oneof "%s" for message type "%s".' % (field.name, which_oneof, field.containing_oneof.name, message_descriptor.full_name)) if field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_MESSAGE: tokenizer.TryConsume(':') merger = self._MergeMessageField else: tokenizer.Consume(':') merger = self._MergeScalarField if (field.label == descriptor.FieldDescriptor.LABEL_REPEATED and tokenizer.TryConsume('[')): # Short repeated format, e.g. "foo: [1, 2, 3]" if not tokenizer.TryConsume(']'): while True: merger(tokenizer, message, field) if tokenizer.TryConsume(']'): break tokenizer.Consume(',') else: merger(tokenizer, message, field) else: # Proto field is unknown. assert (self.allow_unknown_extension or self.allow_unknown_field) _SkipFieldContents(tokenizer) # For historical reasons, fields may optionally be separated by commas or # semicolons. if not tokenizer.TryConsume(','): tokenizer.TryConsume(';') def _ConsumeAnyTypeUrl(self, tokenizer): """Consumes a google.protobuf.Any type URL and returns the type name.""" # Consume "type.googleapis.com/". prefix = [tokenizer.ConsumeIdentifier()] tokenizer.Consume('.') prefix.append(tokenizer.ConsumeIdentifier()) tokenizer.Consume('.') prefix.append(tokenizer.ConsumeIdentifier()) tokenizer.Consume('/') # Consume the fully-qualified type name. name = [tokenizer.ConsumeIdentifier()] while tokenizer.TryConsume('.'): name.append(tokenizer.ConsumeIdentifier()) return '.'.join(prefix), '.'.join(name) def _MergeMessageField(self, tokenizer, message, field): """Merges a single scalar field into a message. Args: tokenizer: A tokenizer to parse the field value. message: The message of which field is a member. field: The descriptor of the field to be merged. Raises: ParseError: In case of text parsing problems. """ is_map_entry = _IsMapEntry(field) if tokenizer.TryConsume('<'): end_token = '>' else: tokenizer.Consume('{') end_token = '}' if field.label == descriptor.FieldDescriptor.LABEL_REPEATED: if field.is_extension: sub_message = message.Extensions[field].add() elif is_map_entry: sub_message = getattr(message, field.name).GetEntryClass()() else: sub_message = getattr(message, field.name).add() else: if field.is_extension: if (not self._allow_multiple_scalars and message.HasExtension(field)): raise tokenizer.ParseErrorPreviousToken( 'Message type "%s" should not have multiple "%s" extensions.' % (message.DESCRIPTOR.full_name, field.full_name)) sub_message = message.Extensions[field] else: # Also apply _allow_multiple_scalars to message field. # TODO(jieluo): Change to _allow_singular_overwrites. if (not self._allow_multiple_scalars and message.HasField(field.name)): raise tokenizer.ParseErrorPreviousToken( 'Message type "%s" should not have multiple "%s" fields.' % (message.DESCRIPTOR.full_name, field.name)) sub_message = getattr(message, field.name) sub_message.SetInParent() while not tokenizer.TryConsume(end_token): if tokenizer.AtEnd(): raise tokenizer.ParseErrorPreviousToken('Expected "%s".' % (end_token,)) self._MergeField(tokenizer, sub_message) if is_map_entry: value_cpptype = field.message_type.fields_by_name['value'].cpp_type if value_cpptype == descriptor.FieldDescriptor.CPPTYPE_MESSAGE: value = getattr(message, field.name)[sub_message.key] value.CopyFrom(sub_message.value) else: getattr(message, field.name)[sub_message.key] = sub_message.value @staticmethod def _IsProto3Syntax(message): message_descriptor = message.DESCRIPTOR return (hasattr(message_descriptor, 'syntax') and message_descriptor.syntax == 'proto3') def _MergeScalarField(self, tokenizer, message, field): """Merges a single scalar field into a message. Args: tokenizer: A tokenizer to parse the field value. message: A protocol message to record the data. field: The descriptor of the field to be merged. Raises: ParseError: In case of text parsing problems. RuntimeError: On runtime errors. """ _ = self.allow_unknown_extension value = None if field.type in (descriptor.FieldDescriptor.TYPE_INT32, descriptor.FieldDescriptor.TYPE_SINT32, descriptor.FieldDescriptor.TYPE_SFIXED32): value = _ConsumeInt32(tokenizer) elif field.type in (descriptor.FieldDescriptor.TYPE_INT64, descriptor.FieldDescriptor.TYPE_SINT64, descriptor.FieldDescriptor.TYPE_SFIXED64): value = _ConsumeInt64(tokenizer) elif field.type in (descriptor.FieldDescriptor.TYPE_UINT32, descriptor.FieldDescriptor.TYPE_FIXED32): value = _ConsumeUint32(tokenizer) elif field.type in (descriptor.FieldDescriptor.TYPE_UINT64, descriptor.FieldDescriptor.TYPE_FIXED64): value = _ConsumeUint64(tokenizer) elif field.type in (descriptor.FieldDescriptor.TYPE_FLOAT, descriptor.FieldDescriptor.TYPE_DOUBLE): value = tokenizer.ConsumeFloat() elif field.type == descriptor.FieldDescriptor.TYPE_BOOL: value = tokenizer.ConsumeBool() elif field.type == descriptor.FieldDescriptor.TYPE_STRING: value = tokenizer.ConsumeString() elif field.type == descriptor.FieldDescriptor.TYPE_BYTES: value = tokenizer.ConsumeByteString() elif field.type == descriptor.FieldDescriptor.TYPE_ENUM: value = tokenizer.ConsumeEnum(field) else: raise RuntimeError('Unknown field type %d' % field.type) if field.label == descriptor.FieldDescriptor.LABEL_REPEATED: if field.is_extension: message.Extensions[field].append(value) else: getattr(message, field.name).append(value) else: if field.is_extension: if (not self._allow_multiple_scalars and not self._IsProto3Syntax(message) and message.HasExtension(field)): raise tokenizer.ParseErrorPreviousToken( 'Message type "%s" should not have multiple "%s" extensions.' % (message.DESCRIPTOR.full_name, field.full_name)) else: message.Extensions[field] = value else: duplicate_error = False if not self._allow_multiple_scalars: if self._IsProto3Syntax(message): # Proto3 doesn't represent presence so we try best effort to check # multiple scalars by compare to default values. duplicate_error = bool(getattr(message, field.name)) else: duplicate_error = message.HasField(field.name) if duplicate_error: raise tokenizer.ParseErrorPreviousToken( 'Message type "%s" should not have multiple "%s" fields.' % (message.DESCRIPTOR.full_name, field.name)) else: setattr(message, field.name, value) def _SkipFieldContents(tokenizer): """Skips over contents (value or message) of a field. Args: tokenizer: A tokenizer to parse the field name and values. """ # Try to guess the type of this field. # If this field is not a message, there should be a ":" between the # field name and the field value and also the field value should not # start with "{" or "<" which indicates the beginning of a message body. # If there is no ":" or there is a "{" or "<" after ":", this field has # to be a message or the input is ill-formed. if tokenizer.TryConsume(':') and not tokenizer.LookingAt( '{') and not tokenizer.LookingAt('<'): _SkipFieldValue(tokenizer) else: _SkipFieldMessage(tokenizer) def _SkipField(tokenizer): """Skips over a complete field (name and value/message). Args: tokenizer: A tokenizer to parse the field name and values. """ if tokenizer.TryConsume('['): # Consume extension name. tokenizer.ConsumeIdentifier() while tokenizer.TryConsume('.'): tokenizer.ConsumeIdentifier() tokenizer.Consume(']') else: tokenizer.ConsumeIdentifierOrNumber() _SkipFieldContents(tokenizer) # For historical reasons, fields may optionally be separated by commas or # semicolons. if not tokenizer.TryConsume(','): tokenizer.TryConsume(';') def _SkipFieldMessage(tokenizer): """Skips over a field message. Args: tokenizer: A tokenizer to parse the field name and values. """ if tokenizer.TryConsume('<'): delimiter = '>' else: tokenizer.Consume('{') delimiter = '}' while not tokenizer.LookingAt('>') and not tokenizer.LookingAt('}'): _SkipField(tokenizer) tokenizer.Consume(delimiter) def _SkipFieldValue(tokenizer): """Skips over a field value. Args: tokenizer: A tokenizer to parse the field name and values. Raises: ParseError: In case an invalid field value is found. """ # String/bytes tokens can come in multiple adjacent string literals. # If we can consume one, consume as many as we can. if tokenizer.TryConsumeByteString(): while tokenizer.TryConsumeByteString(): pass return if (not tokenizer.TryConsumeIdentifier() and not _TryConsumeInt64(tokenizer) and not _TryConsumeUint64(tokenizer) and not tokenizer.TryConsumeFloat()): raise ParseError('Invalid field value: ' + tokenizer.token) class Tokenizer(object): """Protocol buffer text representation tokenizer. This class handles the lower level string parsing by splitting it into meaningful tokens. It was directly ported from the Java protocol buffer API. """ _WHITESPACE = re.compile(r'\s+') _COMMENT = re.compile(r'(\s*#.*$)', re.MULTILINE) _WHITESPACE_OR_COMMENT = re.compile(r'(\s|(#.*$))+', re.MULTILINE) _TOKEN = re.compile('|'.join([ r'[a-zA-Z_][0-9a-zA-Z_+-]*', # an identifier r'([0-9+-]|(\.[0-9]))[0-9a-zA-Z_.+-]*', # a number ] + [ # quoted str for each quote mark # Avoid backtracking! https://stackoverflow.com/a/844267 r'{qt}[^{qt}\n\\]*((\\.)+[^{qt}\n\\]*)*({qt}|\\?$)'.format(qt=mark) for mark in _QUOTES ])) _IDENTIFIER = re.compile(r'[^\d\W]\w*') _IDENTIFIER_OR_NUMBER = re.compile(r'\w+') def __init__(self, lines, skip_comments=True): self._position = 0 self._line = -1 self._column = 0 self._token_start = None self.token = '' self._lines = iter(lines) self._current_line = '' self._previous_line = 0 self._previous_column = 0 self._more_lines = True self._skip_comments = skip_comments self._whitespace_pattern = (skip_comments and self._WHITESPACE_OR_COMMENT or self._WHITESPACE) self._SkipWhitespace() self.NextToken() def LookingAt(self, token): return self.token == token def AtEnd(self): """Checks the end of the text was reached. Returns: True iff the end was reached. """ return not self.token def _PopLine(self): while len(self._current_line) <= self._column: try: self._current_line = next(self._lines) except StopIteration: self._current_line = '' self._more_lines = False return else: self._line += 1 self._column = 0 def _SkipWhitespace(self): while True: self._PopLine() match = self._whitespace_pattern.match(self._current_line, self._column) if not match: break length = len(match.group(0)) self._column += length def TryConsume(self, token): """Tries to consume a given piece of text. Args: token: Text to consume. Returns: True iff the text was consumed. """ if self.token == token: self.NextToken() return True return False def Consume(self, token): """Consumes a piece of text. Args: token: Text to consume. Raises: ParseError: If the text couldn't be consumed. """ if not self.TryConsume(token): raise self.ParseError('Expected "%s".' % token) def ConsumeComment(self): result = self.token if not self._COMMENT.match(result): raise self.ParseError('Expected comment.') self.NextToken() return result def ConsumeCommentOrTrailingComment(self): """Consumes a comment, returns a 2-tuple (trailing bool, comment str).""" # Tokenizer initializes _previous_line and _previous_column to 0. As the # tokenizer starts, it looks like there is a previous token on the line. just_started = self._line == 0 and self._column == 0 before_parsing = self._previous_line comment = self.ConsumeComment() # A trailing comment is a comment on the same line than the previous token. trailing = (self._previous_line == before_parsing and not just_started) return trailing, comment def TryConsumeIdentifier(self): try: self.ConsumeIdentifier() return True except ParseError: return False def ConsumeIdentifier(self): """Consumes protocol message field identifier. Returns: Identifier string. Raises: ParseError: If an identifier couldn't be consumed. """ result = self.token if not self._IDENTIFIER.match(result): raise self.ParseError('Expected identifier.') self.NextToken() return result def TryConsumeIdentifierOrNumber(self): try: self.ConsumeIdentifierOrNumber() return True except ParseError: return False def ConsumeIdentifierOrNumber(self): """Consumes protocol message field identifier. Returns: Identifier string. Raises: ParseError: If an identifier couldn't be consumed. """ result = self.token if not self._IDENTIFIER_OR_NUMBER.match(result): raise self.ParseError('Expected identifier or number, got %s.' % result) self.NextToken() return result def TryConsumeInteger(self): try: self.ConsumeInteger() return True except ParseError: return False def ConsumeInteger(self): """Consumes an integer number. Returns: The integer parsed. Raises: ParseError: If an integer couldn't be consumed. """ try: result = _ParseAbstractInteger(self.token) except ValueError as e: raise self.ParseError(str(e)) self.NextToken() return result def TryConsumeFloat(self): try: self.ConsumeFloat() return True except ParseError: return False def ConsumeFloat(self): """Consumes an floating point number. Returns: The number parsed. Raises: ParseError: If a floating point number couldn't be consumed. """ try: result = ParseFloat(self.token) except ValueError as e: raise self.ParseError(str(e)) self.NextToken() return result def ConsumeBool(self): """Consumes a boolean value. Returns: The bool parsed. Raises: ParseError: If a boolean value couldn't be consumed. """ try: result = ParseBool(self.token) except ValueError as e: raise self.ParseError(str(e)) self.NextToken() return result def TryConsumeByteString(self): try: self.ConsumeByteString() return True except ParseError: return False def ConsumeString(self): """Consumes a string value. Returns: The string parsed. Raises: ParseError: If a string value couldn't be consumed. """ the_bytes = self.ConsumeByteString() try: return str(the_bytes, 'utf-8') except UnicodeDecodeError as e: raise self._StringParseError(e) def ConsumeByteString(self): """Consumes a byte array value. Returns: The array parsed (as a string). Raises: ParseError: If a byte array value couldn't be consumed. """ the_list = [self._ConsumeSingleByteString()] while self.token and self.token[0] in _QUOTES: the_list.append(self._ConsumeSingleByteString()) return b''.join(the_list) def _ConsumeSingleByteString(self): """Consume one token of a string literal. String literals (whether bytes or text) can come in multiple adjacent tokens which are automatically concatenated, like in C or Python. This method only consumes one token. Returns: The token parsed. Raises: ParseError: When the wrong format data is found. """ text = self.token if len(text) < 1 or text[0] not in _QUOTES: raise self.ParseError('Expected string but found: %r' % (text,)) if len(text) < 2 or text[-1] != text[0]: raise self.ParseError('String missing ending quote: %r' % (text,)) try: result = text_encoding.CUnescape(text[1:-1]) except ValueError as e: raise self.ParseError(str(e)) self.NextToken() return result def ConsumeEnum(self, field): try: result = ParseEnum(field, self.token) except ValueError as e: raise self.ParseError(str(e)) self.NextToken() return result def ParseErrorPreviousToken(self, message): """Creates and *returns* a ParseError for the previously read token. Args: message: A message to set for the exception. Returns: A ParseError instance. """ return ParseError(message, self._previous_line + 1, self._previous_column + 1) def ParseError(self, message): """Creates and *returns* a ParseError for the current token.""" return ParseError('\'' + self._current_line + '\': ' + message, self._line + 1, self._column + 1) def _StringParseError(self, e): return self.ParseError('Couldn\'t parse string: ' + str(e)) def NextToken(self): """Reads the next meaningful token.""" self._previous_line = self._line self._previous_column = self._column self._column += len(self.token) self._SkipWhitespace() if not self._more_lines: self.token = '' return match = self._TOKEN.match(self._current_line, self._column) if not match and not self._skip_comments: match = self._COMMENT.match(self._current_line, self._column) if match: token = match.group(0) self.token = token else: self.token = self._current_line[self._column] # Aliased so it can still be accessed by current visibility violators. # TODO(dbarnett): Migrate violators to textformat_tokenizer. _Tokenizer = Tokenizer # pylint: disable=invalid-name def _ConsumeInt32(tokenizer): """Consumes a signed 32bit integer number from tokenizer. Args: tokenizer: A tokenizer used to parse the number. Returns: The integer parsed. Raises: ParseError: If a signed 32bit integer couldn't be consumed. """ return _ConsumeInteger(tokenizer, is_signed=True, is_long=False) def _ConsumeUint32(tokenizer): """Consumes an unsigned 32bit integer number from tokenizer. Args: tokenizer: A tokenizer used to parse the number. Returns: The integer parsed. Raises: ParseError: If an unsigned 32bit integer couldn't be consumed. """ return _ConsumeInteger(tokenizer, is_signed=False, is_long=False) def _TryConsumeInt64(tokenizer): try: _ConsumeInt64(tokenizer) return True except ParseError: return False def _ConsumeInt64(tokenizer): """Consumes a signed 32bit integer number from tokenizer. Args: tokenizer: A tokenizer used to parse the number. Returns: The integer parsed. Raises: ParseError: If a signed 32bit integer couldn't be consumed. """ return _ConsumeInteger(tokenizer, is_signed=True, is_long=True) def _TryConsumeUint64(tokenizer): try: _ConsumeUint64(tokenizer) return True except ParseError: return False def _ConsumeUint64(tokenizer): """Consumes an unsigned 64bit integer number from tokenizer. Args: tokenizer: A tokenizer used to parse the number. Returns: The integer parsed. Raises: ParseError: If an unsigned 64bit integer couldn't be consumed. """ return _ConsumeInteger(tokenizer, is_signed=False, is_long=True) def _ConsumeInteger(tokenizer, is_signed=False, is_long=False): """Consumes an integer number from tokenizer. Args: tokenizer: A tokenizer used to parse the number. is_signed: True if a signed integer must be parsed. is_long: True if a long integer must be parsed. Returns: The integer parsed. Raises: ParseError: If an integer with given characteristics couldn't be consumed. """ try: result = ParseInteger(tokenizer.token, is_signed=is_signed, is_long=is_long) except ValueError as e: raise tokenizer.ParseError(str(e)) tokenizer.NextToken() return result def ParseInteger(text, is_signed=False, is_long=False): """Parses an integer. Args: text: The text to parse. is_signed: True if a signed integer must be parsed. is_long: True if a long integer must be parsed. Returns: The integer value. Raises: ValueError: Thrown Iff the text is not a valid integer. """ # Do the actual parsing. Exception handling is propagated to caller. result = _ParseAbstractInteger(text) # Check if the integer is sane. Exceptions handled by callers. checker = _INTEGER_CHECKERS[2 * int(is_long) + int(is_signed)] checker.CheckValue(result) return result def _ParseAbstractInteger(text): """Parses an integer without checking size/signedness. Args: text: The text to parse. Returns: The integer value. Raises: ValueError: Thrown Iff the text is not a valid integer. """ # Do the actual parsing. Exception handling is propagated to caller. orig_text = text c_octal_match = re.match(r'(-?)0(\d+)$', text) if c_octal_match: # Python 3 no longer supports 0755 octal syntax without the 'o', so # we always use the '0o' prefix for multi-digit numbers starting with 0. text = c_octal_match.group(1) + '0o' + c_octal_match.group(2) try: return int(text, 0) except ValueError: raise ValueError('Couldn\'t parse integer: %s' % orig_text) def ParseFloat(text): """Parse a floating point number. Args: text: Text to parse. Returns: The number parsed. Raises: ValueError: If a floating point number couldn't be parsed. """ try: # Assume Python compatible syntax. return float(text) except ValueError: # Check alternative spellings. if _FLOAT_INFINITY.match(text): if text[0] == '-': return float('-inf') else: return float('inf') elif _FLOAT_NAN.match(text): return float('nan') else: # assume '1.0f' format try: return float(text.rstrip('f')) except ValueError: raise ValueError('Couldn\'t parse float: %s' % text) def ParseBool(text): """Parse a boolean value. Args: text: Text to parse. Returns: Boolean values parsed Raises: ValueError: If text is not a valid boolean. """ if text in ('true', 't', '1', 'True'): return True elif text in ('false', 'f', '0', 'False'): return False else: raise ValueError('Expected "true" or "false".') def ParseEnum(field, value): """Parse an enum value. The value can be specified by a number (the enum value), or by a string literal (the enum name). Args: field: Enum field descriptor. value: String value. Returns: Enum value number. Raises: ValueError: If the enum value could not be parsed. """ enum_descriptor = field.enum_type try: number = int(value, 0) except ValueError: # Identifier. enum_value = enum_descriptor.values_by_name.get(value, None) if enum_value is None: raise ValueError('Enum type "%s" has no value named %s.' % (enum_descriptor.full_name, value)) else: # Numeric value. if hasattr(field.file, 'syntax'): # Attribute is checked for compatibility. if field.file.syntax == 'proto3': # Proto3 accept numeric unknown enums. return number enum_value = enum_descriptor.values_by_number.get(number, None) if enum_value is None: raise ValueError('Enum type "%s" has no value with number %d.' % (enum_descriptor.full_name, number)) return enum_value.number ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/timestamp_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/timestamp.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1fgoogle/protobuf/timestamp.proto\x12\x0fgoogle.protobuf\"+\n\tTimestamp\x12\x0f\n\x07seconds\x18\x01 \x01(\x03\x12\r\n\x05nanos\x18\x02 \x01(\x05\x42\x85\x01\n\x13\x63om.google.protobufB\x0eTimestampProtoP\x01Z2google.golang.org/protobuf/types/known/timestamppb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.timestamp_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\016TimestampProtoP\001Z2google.golang.org/protobuf/types/known/timestamppb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _TIMESTAMP._serialized_start=52 _TIMESTAMP._serialized_end=95 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/type_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/type.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2 from google.protobuf import source_context_pb2 as google_dot_protobuf_dot_source__context__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1agoogle/protobuf/type.proto\x12\x0fgoogle.protobuf\x1a\x19google/protobuf/any.proto\x1a$google/protobuf/source_context.proto\"\xd7\x01\n\x04Type\x12\x0c\n\x04name\x18\x01 \x01(\t\x12&\n\x06\x66ields\x18\x02 \x03(\x0b\x32\x16.google.protobuf.Field\x12\x0e\n\x06oneofs\x18\x03 \x03(\t\x12(\n\x07options\x18\x04 \x03(\x0b\x32\x17.google.protobuf.Option\x12\x36\n\x0esource_context\x18\x05 \x01(\x0b\x32\x1e.google.protobuf.SourceContext\x12\'\n\x06syntax\x18\x06 \x01(\x0e\x32\x17.google.protobuf.Syntax\"\xd5\x05\n\x05\x46ield\x12)\n\x04kind\x18\x01 \x01(\x0e\x32\x1b.google.protobuf.Field.Kind\x12\x37\n\x0b\x63\x61rdinality\x18\x02 \x01(\x0e\x32\".google.protobuf.Field.Cardinality\x12\x0e\n\x06number\x18\x03 \x01(\x05\x12\x0c\n\x04name\x18\x04 \x01(\t\x12\x10\n\x08type_url\x18\x06 \x01(\t\x12\x13\n\x0boneof_index\x18\x07 \x01(\x05\x12\x0e\n\x06packed\x18\x08 \x01(\x08\x12(\n\x07options\x18\t \x03(\x0b\x32\x17.google.protobuf.Option\x12\x11\n\tjson_name\x18\n \x01(\t\x12\x15\n\rdefault_value\x18\x0b \x01(\t\"\xc8\x02\n\x04Kind\x12\x10\n\x0cTYPE_UNKNOWN\x10\x00\x12\x0f\n\x0bTYPE_DOUBLE\x10\x01\x12\x0e\n\nTYPE_FLOAT\x10\x02\x12\x0e\n\nTYPE_INT64\x10\x03\x12\x0f\n\x0bTYPE_UINT64\x10\x04\x12\x0e\n\nTYPE_INT32\x10\x05\x12\x10\n\x0cTYPE_FIXED64\x10\x06\x12\x10\n\x0cTYPE_FIXED32\x10\x07\x12\r\n\tTYPE_BOOL\x10\x08\x12\x0f\n\x0bTYPE_STRING\x10\t\x12\x0e\n\nTYPE_GROUP\x10\n\x12\x10\n\x0cTYPE_MESSAGE\x10\x0b\x12\x0e\n\nTYPE_BYTES\x10\x0c\x12\x0f\n\x0bTYPE_UINT32\x10\r\x12\r\n\tTYPE_ENUM\x10\x0e\x12\x11\n\rTYPE_SFIXED32\x10\x0f\x12\x11\n\rTYPE_SFIXED64\x10\x10\x12\x0f\n\x0bTYPE_SINT32\x10\x11\x12\x0f\n\x0bTYPE_SINT64\x10\x12\"t\n\x0b\x43\x61rdinality\x12\x17\n\x13\x43\x41RDINALITY_UNKNOWN\x10\x00\x12\x18\n\x14\x43\x41RDINALITY_OPTIONAL\x10\x01\x12\x18\n\x14\x43\x41RDINALITY_REQUIRED\x10\x02\x12\x18\n\x14\x43\x41RDINALITY_REPEATED\x10\x03\"\xce\x01\n\x04\x45num\x12\x0c\n\x04name\x18\x01 \x01(\t\x12-\n\tenumvalue\x18\x02 \x03(\x0b\x32\x1a.google.protobuf.EnumValue\x12(\n\x07options\x18\x03 \x03(\x0b\x32\x17.google.protobuf.Option\x12\x36\n\x0esource_context\x18\x04 \x01(\x0b\x32\x1e.google.protobuf.SourceContext\x12\'\n\x06syntax\x18\x05 \x01(\x0e\x32\x17.google.protobuf.Syntax\"S\n\tEnumValue\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06number\x18\x02 \x01(\x05\x12(\n\x07options\x18\x03 \x03(\x0b\x32\x17.google.protobuf.Option\";\n\x06Option\x12\x0c\n\x04name\x18\x01 \x01(\t\x12#\n\x05value\x18\x02 \x01(\x0b\x32\x14.google.protobuf.Any*.\n\x06Syntax\x12\x11\n\rSYNTAX_PROTO2\x10\x00\x12\x11\n\rSYNTAX_PROTO3\x10\x01\x42{\n\x13\x63om.google.protobufB\tTypeProtoP\x01Z-google.golang.org/protobuf/types/known/typepb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.type_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\tTypeProtoP\001Z-google.golang.org/protobuf/types/known/typepb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _SYNTAX._serialized_start=1413 _SYNTAX._serialized_end=1459 _TYPE._serialized_start=113 _TYPE._serialized_end=328 _FIELD._serialized_start=331 _FIELD._serialized_end=1056 _FIELD_KIND._serialized_start=610 _FIELD_KIND._serialized_end=938 _FIELD_CARDINALITY._serialized_start=940 _FIELD_CARDINALITY._serialized_end=1056 _ENUM._serialized_start=1059 _ENUM._serialized_end=1265 _ENUMVALUE._serialized_start=1267 _ENUMVALUE._serialized_end=1350 _OPTION._serialized_start=1352 _OPTION._serialized_end=1411 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/util/__init__.py ================================================ ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/util/json_format_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/util/json_format.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n&google/protobuf/util/json_format.proto\x12\x11protobuf_unittest\"\x89\x01\n\x13TestFlagsAndStrings\x12\t\n\x01\x41\x18\x01 \x02(\x05\x12K\n\rrepeatedgroup\x18\x02 \x03(\n24.protobuf_unittest.TestFlagsAndStrings.RepeatedGroup\x1a\x1a\n\rRepeatedGroup\x12\t\n\x01\x66\x18\x03 \x02(\t\"!\n\x14TestBase64ByteArrays\x12\t\n\x01\x61\x18\x01 \x02(\x0c\"G\n\x12TestJavaScriptJSON\x12\t\n\x01\x61\x18\x01 \x01(\x05\x12\r\n\x05\x66inal\x18\x02 \x01(\x02\x12\n\n\x02in\x18\x03 \x01(\t\x12\x0b\n\x03Var\x18\x04 \x01(\t\"Q\n\x18TestJavaScriptOrderJSON1\x12\t\n\x01\x64\x18\x01 \x01(\x05\x12\t\n\x01\x63\x18\x02 \x01(\x05\x12\t\n\x01x\x18\x03 \x01(\x08\x12\t\n\x01\x62\x18\x04 \x01(\x05\x12\t\n\x01\x61\x18\x05 \x01(\x05\"\x89\x01\n\x18TestJavaScriptOrderJSON2\x12\t\n\x01\x64\x18\x01 \x01(\x05\x12\t\n\x01\x63\x18\x02 \x01(\x05\x12\t\n\x01x\x18\x03 \x01(\x08\x12\t\n\x01\x62\x18\x04 \x01(\x05\x12\t\n\x01\x61\x18\x05 \x01(\x05\x12\x36\n\x01z\x18\x06 \x03(\x0b\x32+.protobuf_unittest.TestJavaScriptOrderJSON1\"$\n\x0cTestLargeInt\x12\t\n\x01\x61\x18\x01 \x02(\x03\x12\t\n\x01\x62\x18\x02 \x02(\x04\"\xa0\x01\n\x0bTestNumbers\x12\x30\n\x01\x61\x18\x01 \x01(\x0e\x32%.protobuf_unittest.TestNumbers.MyType\x12\t\n\x01\x62\x18\x02 \x01(\x05\x12\t\n\x01\x63\x18\x03 \x01(\x02\x12\t\n\x01\x64\x18\x04 \x01(\x08\x12\t\n\x01\x65\x18\x05 \x01(\x01\x12\t\n\x01\x66\x18\x06 \x01(\r\"(\n\x06MyType\x12\x06\n\x02OK\x10\x00\x12\x0b\n\x07WARNING\x10\x01\x12\t\n\x05\x45RROR\x10\x02\"T\n\rTestCamelCase\x12\x14\n\x0cnormal_field\x18\x01 \x01(\t\x12\x15\n\rCAPITAL_FIELD\x18\x02 \x01(\x05\x12\x16\n\x0e\x43\x61melCaseField\x18\x03 \x01(\x05\"|\n\x0bTestBoolMap\x12=\n\x08\x62ool_map\x18\x01 \x03(\x0b\x32+.protobuf_unittest.TestBoolMap.BoolMapEntry\x1a.\n\x0c\x42oolMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x08\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\"O\n\rTestRecursion\x12\r\n\x05value\x18\x01 \x01(\x05\x12/\n\x05\x63hild\x18\x02 \x01(\x0b\x32 .protobuf_unittest.TestRecursion\"\x86\x01\n\rTestStringMap\x12\x43\n\nstring_map\x18\x01 \x03(\x0b\x32/.protobuf_unittest.TestStringMap.StringMapEntry\x1a\x30\n\x0eStringMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xc4\x01\n\x14TestStringSerializer\x12\x15\n\rscalar_string\x18\x01 \x01(\t\x12\x17\n\x0frepeated_string\x18\x02 \x03(\t\x12J\n\nstring_map\x18\x03 \x03(\x0b\x32\x36.protobuf_unittest.TestStringSerializer.StringMapEntry\x1a\x30\n\x0eStringMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"$\n\x18TestMessageWithExtension*\x08\x08\x64\x10\x80\x80\x80\x80\x02\"z\n\rTestExtension\x12\r\n\x05value\x18\x01 \x01(\t2Z\n\x03\x65xt\x12+.protobuf_unittest.TestMessageWithExtension\x18\x64 \x01(\x0b\x32 .protobuf_unittest.TestExtension\"Q\n\x14TestDefaultEnumValue\x12\x39\n\nenum_value\x18\x01 \x01(\x0e\x32\x1c.protobuf_unittest.EnumValue:\x07\x44\x45\x46\x41ULT*2\n\tEnumValue\x12\x0c\n\x08PROTOCOL\x10\x00\x12\n\n\x06\x42UFFER\x10\x01\x12\x0b\n\x07\x44\x45\x46\x41ULT\x10\x02') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.util.json_format_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: TestMessageWithExtension.RegisterExtension(_TESTEXTENSION.extensions_by_name['ext']) DESCRIPTOR._options = None _TESTBOOLMAP_BOOLMAPENTRY._options = None _TESTBOOLMAP_BOOLMAPENTRY._serialized_options = b'8\001' _TESTSTRINGMAP_STRINGMAPENTRY._options = None _TESTSTRINGMAP_STRINGMAPENTRY._serialized_options = b'8\001' _TESTSTRINGSERIALIZER_STRINGMAPENTRY._options = None _TESTSTRINGSERIALIZER_STRINGMAPENTRY._serialized_options = b'8\001' _ENUMVALUE._serialized_start=1607 _ENUMVALUE._serialized_end=1657 _TESTFLAGSANDSTRINGS._serialized_start=62 _TESTFLAGSANDSTRINGS._serialized_end=199 _TESTFLAGSANDSTRINGS_REPEATEDGROUP._serialized_start=173 _TESTFLAGSANDSTRINGS_REPEATEDGROUP._serialized_end=199 _TESTBASE64BYTEARRAYS._serialized_start=201 _TESTBASE64BYTEARRAYS._serialized_end=234 _TESTJAVASCRIPTJSON._serialized_start=236 _TESTJAVASCRIPTJSON._serialized_end=307 _TESTJAVASCRIPTORDERJSON1._serialized_start=309 _TESTJAVASCRIPTORDERJSON1._serialized_end=390 _TESTJAVASCRIPTORDERJSON2._serialized_start=393 _TESTJAVASCRIPTORDERJSON2._serialized_end=530 _TESTLARGEINT._serialized_start=532 _TESTLARGEINT._serialized_end=568 _TESTNUMBERS._serialized_start=571 _TESTNUMBERS._serialized_end=731 _TESTNUMBERS_MYTYPE._serialized_start=691 _TESTNUMBERS_MYTYPE._serialized_end=731 _TESTCAMELCASE._serialized_start=733 _TESTCAMELCASE._serialized_end=817 _TESTBOOLMAP._serialized_start=819 _TESTBOOLMAP._serialized_end=943 _TESTBOOLMAP_BOOLMAPENTRY._serialized_start=897 _TESTBOOLMAP_BOOLMAPENTRY._serialized_end=943 _TESTRECURSION._serialized_start=945 _TESTRECURSION._serialized_end=1024 _TESTSTRINGMAP._serialized_start=1027 _TESTSTRINGMAP._serialized_end=1161 _TESTSTRINGMAP_STRINGMAPENTRY._serialized_start=1113 _TESTSTRINGMAP_STRINGMAPENTRY._serialized_end=1161 _TESTSTRINGSERIALIZER._serialized_start=1164 _TESTSTRINGSERIALIZER._serialized_end=1360 _TESTSTRINGSERIALIZER_STRINGMAPENTRY._serialized_start=1113 _TESTSTRINGSERIALIZER_STRINGMAPENTRY._serialized_end=1161 _TESTMESSAGEWITHEXTENSION._serialized_start=1362 _TESTMESSAGEWITHEXTENSION._serialized_end=1398 _TESTEXTENSION._serialized_start=1400 _TESTEXTENSION._serialized_end=1522 _TESTDEFAULTENUMVALUE._serialized_start=1524 _TESTDEFAULTENUMVALUE._serialized_end=1605 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/util/json_format_proto3_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/util/json_format_proto3.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2 from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb2 from google.protobuf import field_mask_pb2 as google_dot_protobuf_dot_field__mask__pb2 from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2 from google.protobuf import unittest_pb2 as google_dot_protobuf_dot_unittest__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n-google/protobuf/util/json_format_proto3.proto\x12\x06proto3\x1a\x19google/protobuf/any.proto\x1a\x1egoogle/protobuf/duration.proto\x1a google/protobuf/field_mask.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\x1a\x1egoogle/protobuf/unittest.proto\"\x1c\n\x0bMessageType\x12\r\n\x05value\x18\x01 \x01(\x05\"\x94\x05\n\x0bTestMessage\x12\x12\n\nbool_value\x18\x01 \x01(\x08\x12\x13\n\x0bint32_value\x18\x02 \x01(\x05\x12\x13\n\x0bint64_value\x18\x03 \x01(\x03\x12\x14\n\x0cuint32_value\x18\x04 \x01(\r\x12\x14\n\x0cuint64_value\x18\x05 \x01(\x04\x12\x13\n\x0b\x66loat_value\x18\x06 \x01(\x02\x12\x14\n\x0c\x64ouble_value\x18\x07 \x01(\x01\x12\x14\n\x0cstring_value\x18\x08 \x01(\t\x12\x13\n\x0b\x62ytes_value\x18\t \x01(\x0c\x12$\n\nenum_value\x18\n \x01(\x0e\x32\x10.proto3.EnumType\x12*\n\rmessage_value\x18\x0b \x01(\x0b\x32\x13.proto3.MessageType\x12\x1b\n\x13repeated_bool_value\x18\x15 \x03(\x08\x12\x1c\n\x14repeated_int32_value\x18\x16 \x03(\x05\x12\x1c\n\x14repeated_int64_value\x18\x17 \x03(\x03\x12\x1d\n\x15repeated_uint32_value\x18\x18 \x03(\r\x12\x1d\n\x15repeated_uint64_value\x18\x19 \x03(\x04\x12\x1c\n\x14repeated_float_value\x18\x1a \x03(\x02\x12\x1d\n\x15repeated_double_value\x18\x1b \x03(\x01\x12\x1d\n\x15repeated_string_value\x18\x1c \x03(\t\x12\x1c\n\x14repeated_bytes_value\x18\x1d \x03(\x0c\x12-\n\x13repeated_enum_value\x18\x1e \x03(\x0e\x32\x10.proto3.EnumType\x12\x33\n\x16repeated_message_value\x18\x1f \x03(\x0b\x32\x13.proto3.MessageType\"\x8c\x02\n\tTestOneof\x12\x1b\n\x11oneof_int32_value\x18\x01 \x01(\x05H\x00\x12\x1c\n\x12oneof_string_value\x18\x02 \x01(\tH\x00\x12\x1b\n\x11oneof_bytes_value\x18\x03 \x01(\x0cH\x00\x12,\n\x10oneof_enum_value\x18\x04 \x01(\x0e\x32\x10.proto3.EnumTypeH\x00\x12\x32\n\x13oneof_message_value\x18\x05 \x01(\x0b\x32\x13.proto3.MessageTypeH\x00\x12\x36\n\x10oneof_null_value\x18\x06 \x01(\x0e\x32\x1a.google.protobuf.NullValueH\x00\x42\r\n\x0boneof_value\"\xe1\x04\n\x07TestMap\x12.\n\x08\x62ool_map\x18\x01 \x03(\x0b\x32\x1c.proto3.TestMap.BoolMapEntry\x12\x30\n\tint32_map\x18\x02 \x03(\x0b\x32\x1d.proto3.TestMap.Int32MapEntry\x12\x30\n\tint64_map\x18\x03 \x03(\x0b\x32\x1d.proto3.TestMap.Int64MapEntry\x12\x32\n\nuint32_map\x18\x04 \x03(\x0b\x32\x1e.proto3.TestMap.Uint32MapEntry\x12\x32\n\nuint64_map\x18\x05 \x03(\x0b\x32\x1e.proto3.TestMap.Uint64MapEntry\x12\x32\n\nstring_map\x18\x06 \x03(\x0b\x32\x1e.proto3.TestMap.StringMapEntry\x1a.\n\x0c\x42oolMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x08\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a/\n\rInt32MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a/\n\rInt64MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x03\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x30\n\x0eUint32MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x30\n\x0eUint64MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x04\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x30\n\x0eStringMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\"\x85\x06\n\rTestNestedMap\x12\x34\n\x08\x62ool_map\x18\x01 \x03(\x0b\x32\".proto3.TestNestedMap.BoolMapEntry\x12\x36\n\tint32_map\x18\x02 \x03(\x0b\x32#.proto3.TestNestedMap.Int32MapEntry\x12\x36\n\tint64_map\x18\x03 \x03(\x0b\x32#.proto3.TestNestedMap.Int64MapEntry\x12\x38\n\nuint32_map\x18\x04 \x03(\x0b\x32$.proto3.TestNestedMap.Uint32MapEntry\x12\x38\n\nuint64_map\x18\x05 \x03(\x0b\x32$.proto3.TestNestedMap.Uint64MapEntry\x12\x38\n\nstring_map\x18\x06 \x03(\x0b\x32$.proto3.TestNestedMap.StringMapEntry\x12\x32\n\x07map_map\x18\x07 \x03(\x0b\x32!.proto3.TestNestedMap.MapMapEntry\x1a.\n\x0c\x42oolMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x08\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a/\n\rInt32MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a/\n\rInt64MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x03\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x30\n\x0eUint32MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x30\n\x0eUint64MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x04\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x30\n\x0eStringMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x44\n\x0bMapMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12$\n\x05value\x18\x02 \x01(\x0b\x32\x15.proto3.TestNestedMap:\x02\x38\x01\"{\n\rTestStringMap\x12\x38\n\nstring_map\x18\x01 \x03(\x0b\x32$.proto3.TestStringMap.StringMapEntry\x1a\x30\n\x0eStringMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xee\x07\n\x0bTestWrapper\x12.\n\nbool_value\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.BoolValue\x12\x30\n\x0bint32_value\x18\x02 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x30\n\x0bint64_value\x18\x03 \x01(\x0b\x32\x1b.google.protobuf.Int64Value\x12\x32\n\x0cuint32_value\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x32\n\x0cuint64_value\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.UInt64Value\x12\x30\n\x0b\x66loat_value\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.FloatValue\x12\x32\n\x0c\x64ouble_value\x18\x07 \x01(\x0b\x32\x1c.google.protobuf.DoubleValue\x12\x32\n\x0cstring_value\x18\x08 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12\x30\n\x0b\x62ytes_value\x18\t \x01(\x0b\x32\x1b.google.protobuf.BytesValue\x12\x37\n\x13repeated_bool_value\x18\x0b \x03(\x0b\x32\x1a.google.protobuf.BoolValue\x12\x39\n\x14repeated_int32_value\x18\x0c \x03(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x39\n\x14repeated_int64_value\x18\r \x03(\x0b\x32\x1b.google.protobuf.Int64Value\x12;\n\x15repeated_uint32_value\x18\x0e \x03(\x0b\x32\x1c.google.protobuf.UInt32Value\x12;\n\x15repeated_uint64_value\x18\x0f \x03(\x0b\x32\x1c.google.protobuf.UInt64Value\x12\x39\n\x14repeated_float_value\x18\x10 \x03(\x0b\x32\x1b.google.protobuf.FloatValue\x12;\n\x15repeated_double_value\x18\x11 \x03(\x0b\x32\x1c.google.protobuf.DoubleValue\x12;\n\x15repeated_string_value\x18\x12 \x03(\x0b\x32\x1c.google.protobuf.StringValue\x12\x39\n\x14repeated_bytes_value\x18\x13 \x03(\x0b\x32\x1b.google.protobuf.BytesValue\"n\n\rTestTimestamp\x12)\n\x05value\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x32\n\x0erepeated_value\x18\x02 \x03(\x0b\x32\x1a.google.protobuf.Timestamp\"k\n\x0cTestDuration\x12(\n\x05value\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x31\n\x0erepeated_value\x18\x02 \x03(\x0b\x32\x19.google.protobuf.Duration\":\n\rTestFieldMask\x12)\n\x05value\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.FieldMask\"e\n\nTestStruct\x12&\n\x05value\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12/\n\x0erepeated_value\x18\x02 \x03(\x0b\x32\x17.google.protobuf.Struct\"\\\n\x07TestAny\x12#\n\x05value\x18\x01 \x01(\x0b\x32\x14.google.protobuf.Any\x12,\n\x0erepeated_value\x18\x02 \x03(\x0b\x32\x14.google.protobuf.Any\"b\n\tTestValue\x12%\n\x05value\x18\x01 \x01(\x0b\x32\x16.google.protobuf.Value\x12.\n\x0erepeated_value\x18\x02 \x03(\x0b\x32\x16.google.protobuf.Value\"n\n\rTestListValue\x12)\n\x05value\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.ListValue\x12\x32\n\x0erepeated_value\x18\x02 \x03(\x0b\x32\x1a.google.protobuf.ListValue\"\x89\x01\n\rTestBoolValue\x12\x12\n\nbool_value\x18\x01 \x01(\x08\x12\x34\n\x08\x62ool_map\x18\x02 \x03(\x0b\x32\".proto3.TestBoolValue.BoolMapEntry\x1a.\n\x0c\x42oolMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x08\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\"+\n\x12TestCustomJsonName\x12\x15\n\x05value\x18\x01 \x01(\x05R\x06@value\"J\n\x0eTestExtensions\x12\x38\n\nextensions\x18\x01 \x01(\x0b\x32$.protobuf_unittest.TestAllExtensions\"\x84\x01\n\rTestEnumValue\x12%\n\x0b\x65num_value1\x18\x01 \x01(\x0e\x32\x10.proto3.EnumType\x12%\n\x0b\x65num_value2\x18\x02 \x01(\x0e\x32\x10.proto3.EnumType\x12%\n\x0b\x65num_value3\x18\x03 \x01(\x0e\x32\x10.proto3.EnumType*\x1c\n\x08\x45numType\x12\x07\n\x03\x46OO\x10\x00\x12\x07\n\x03\x42\x41R\x10\x01\x42,\n\x18\x63om.google.protobuf.utilB\x10JsonFormatProto3b\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.util.json_format_proto3_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\030com.google.protobuf.utilB\020JsonFormatProto3' _TESTMAP_BOOLMAPENTRY._options = None _TESTMAP_BOOLMAPENTRY._serialized_options = b'8\001' _TESTMAP_INT32MAPENTRY._options = None _TESTMAP_INT32MAPENTRY._serialized_options = b'8\001' _TESTMAP_INT64MAPENTRY._options = None _TESTMAP_INT64MAPENTRY._serialized_options = b'8\001' _TESTMAP_UINT32MAPENTRY._options = None _TESTMAP_UINT32MAPENTRY._serialized_options = b'8\001' _TESTMAP_UINT64MAPENTRY._options = None _TESTMAP_UINT64MAPENTRY._serialized_options = b'8\001' _TESTMAP_STRINGMAPENTRY._options = None _TESTMAP_STRINGMAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_BOOLMAPENTRY._options = None _TESTNESTEDMAP_BOOLMAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_INT32MAPENTRY._options = None _TESTNESTEDMAP_INT32MAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_INT64MAPENTRY._options = None _TESTNESTEDMAP_INT64MAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_UINT32MAPENTRY._options = None _TESTNESTEDMAP_UINT32MAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_UINT64MAPENTRY._options = None _TESTNESTEDMAP_UINT64MAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_STRINGMAPENTRY._options = None _TESTNESTEDMAP_STRINGMAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_MAPMAPENTRY._options = None _TESTNESTEDMAP_MAPMAPENTRY._serialized_options = b'8\001' _TESTSTRINGMAP_STRINGMAPENTRY._options = None _TESTSTRINGMAP_STRINGMAPENTRY._serialized_options = b'8\001' _TESTBOOLVALUE_BOOLMAPENTRY._options = None _TESTBOOLVALUE_BOOLMAPENTRY._serialized_options = b'8\001' _ENUMTYPE._serialized_start=4849 _ENUMTYPE._serialized_end=4877 _MESSAGETYPE._serialized_start=277 _MESSAGETYPE._serialized_end=305 _TESTMESSAGE._serialized_start=308 _TESTMESSAGE._serialized_end=968 _TESTONEOF._serialized_start=971 _TESTONEOF._serialized_end=1239 _TESTMAP._serialized_start=1242 _TESTMAP._serialized_end=1851 _TESTMAP_BOOLMAPENTRY._serialized_start=1557 _TESTMAP_BOOLMAPENTRY._serialized_end=1603 _TESTMAP_INT32MAPENTRY._serialized_start=1605 _TESTMAP_INT32MAPENTRY._serialized_end=1652 _TESTMAP_INT64MAPENTRY._serialized_start=1654 _TESTMAP_INT64MAPENTRY._serialized_end=1701 _TESTMAP_UINT32MAPENTRY._serialized_start=1703 _TESTMAP_UINT32MAPENTRY._serialized_end=1751 _TESTMAP_UINT64MAPENTRY._serialized_start=1753 _TESTMAP_UINT64MAPENTRY._serialized_end=1801 _TESTMAP_STRINGMAPENTRY._serialized_start=1803 _TESTMAP_STRINGMAPENTRY._serialized_end=1851 _TESTNESTEDMAP._serialized_start=1854 _TESTNESTEDMAP._serialized_end=2627 _TESTNESTEDMAP_BOOLMAPENTRY._serialized_start=1557 _TESTNESTEDMAP_BOOLMAPENTRY._serialized_end=1603 _TESTNESTEDMAP_INT32MAPENTRY._serialized_start=1605 _TESTNESTEDMAP_INT32MAPENTRY._serialized_end=1652 _TESTNESTEDMAP_INT64MAPENTRY._serialized_start=1654 _TESTNESTEDMAP_INT64MAPENTRY._serialized_end=1701 _TESTNESTEDMAP_UINT32MAPENTRY._serialized_start=1703 _TESTNESTEDMAP_UINT32MAPENTRY._serialized_end=1751 _TESTNESTEDMAP_UINT64MAPENTRY._serialized_start=1753 _TESTNESTEDMAP_UINT64MAPENTRY._serialized_end=1801 _TESTNESTEDMAP_STRINGMAPENTRY._serialized_start=1803 _TESTNESTEDMAP_STRINGMAPENTRY._serialized_end=1851 _TESTNESTEDMAP_MAPMAPENTRY._serialized_start=2559 _TESTNESTEDMAP_MAPMAPENTRY._serialized_end=2627 _TESTSTRINGMAP._serialized_start=2629 _TESTSTRINGMAP._serialized_end=2752 _TESTSTRINGMAP_STRINGMAPENTRY._serialized_start=2704 _TESTSTRINGMAP_STRINGMAPENTRY._serialized_end=2752 _TESTWRAPPER._serialized_start=2755 _TESTWRAPPER._serialized_end=3761 _TESTTIMESTAMP._serialized_start=3763 _TESTTIMESTAMP._serialized_end=3873 _TESTDURATION._serialized_start=3875 _TESTDURATION._serialized_end=3982 _TESTFIELDMASK._serialized_start=3984 _TESTFIELDMASK._serialized_end=4042 _TESTSTRUCT._serialized_start=4044 _TESTSTRUCT._serialized_end=4145 _TESTANY._serialized_start=4147 _TESTANY._serialized_end=4239 _TESTVALUE._serialized_start=4241 _TESTVALUE._serialized_end=4339 _TESTLISTVALUE._serialized_start=4341 _TESTLISTVALUE._serialized_end=4451 _TESTBOOLVALUE._serialized_start=4454 _TESTBOOLVALUE._serialized_end=4591 _TESTBOOLVALUE_BOOLMAPENTRY._serialized_start=1557 _TESTBOOLVALUE_BOOLMAPENTRY._serialized_end=1603 _TESTCUSTOMJSONNAME._serialized_start=4593 _TESTCUSTOMJSONNAME._serialized_end=4636 _TESTEXTENSIONS._serialized_start=4638 _TESTEXTENSIONS._serialized_end=4712 _TESTENUMVALUE._serialized_start=4715 _TESTENUMVALUE._serialized_end=4847 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/hiero/vendor/google/protobuf/wrappers_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/wrappers.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1egoogle/protobuf/wrappers.proto\x12\x0fgoogle.protobuf\"\x1c\n\x0b\x44oubleValue\x12\r\n\x05value\x18\x01 \x01(\x01\"\x1b\n\nFloatValue\x12\r\n\x05value\x18\x01 \x01(\x02\"\x1b\n\nInt64Value\x12\r\n\x05value\x18\x01 \x01(\x03\"\x1c\n\x0bUInt64Value\x12\r\n\x05value\x18\x01 \x01(\x04\"\x1b\n\nInt32Value\x12\r\n\x05value\x18\x01 \x01(\x05\"\x1c\n\x0bUInt32Value\x12\r\n\x05value\x18\x01 \x01(\r\"\x1a\n\tBoolValue\x12\r\n\x05value\x18\x01 \x01(\x08\"\x1c\n\x0bStringValue\x12\r\n\x05value\x18\x01 \x01(\t\"\x1b\n\nBytesValue\x12\r\n\x05value\x18\x01 \x01(\x0c\x42\x83\x01\n\x13\x63om.google.protobufB\rWrappersProtoP\x01Z1google.golang.org/protobuf/types/known/wrapperspb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.wrappers_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\rWrappersProtoP\001Z1google.golang.org/protobuf/types/known/wrapperspb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _DOUBLEVALUE._serialized_start=51 _DOUBLEVALUE._serialized_end=79 _FLOATVALUE._serialized_start=81 _FLOATVALUE._serialized_end=108 _INT64VALUE._serialized_start=110 _INT64VALUE._serialized_end=137 _UINT64VALUE._serialized_start=139 _UINT64VALUE._serialized_end=167 _INT32VALUE._serialized_start=169 _INT32VALUE._serialized_end=196 _UINT32VALUE._serialized_start=198 _UINT32VALUE._serialized_end=226 _BOOLVALUE._serialized_start=228 _BOOLVALUE._serialized_end=254 _STRINGVALUE._serialized_start=256 _STRINGVALUE._serialized_end=284 _BYTESVALUE._serialized_start=286 _BYTESVALUE._serialized_end=313 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/houdini/__init__.py ================================================ from .addon import ( HoudiniAddon, HOUDINI_HOST_DIR, ) __all__ = ( "HoudiniAddon", "HOUDINI_HOST_DIR", ) ================================================ FILE: openpype/hosts/houdini/addon.py ================================================ import os from openpype.modules import OpenPypeModule, IHostAddon HOUDINI_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) class HoudiniAddon(OpenPypeModule, IHostAddon): name = "houdini" host_name = "houdini" def initialize(self, module_settings): self.enabled = True def add_implementation_envs(self, env, _app): # Add requirements to HOUDINI_PATH and HOUDINI_MENU_PATH startup_path = os.path.join(HOUDINI_HOST_DIR, "startup") new_houdini_path = [startup_path] new_houdini_menu_path = [startup_path] old_houdini_path = env.get("HOUDINI_PATH") or "" old_houdini_menu_path = env.get("HOUDINI_MENU_PATH") or "" for path in old_houdini_path.split(os.pathsep): if not path: continue norm_path = os.path.normpath(path) if norm_path not in new_houdini_path: new_houdini_path.append(norm_path) for path in old_houdini_menu_path.split(os.pathsep): if not path: continue norm_path = os.path.normpath(path) if norm_path not in new_houdini_menu_path: new_houdini_menu_path.append(norm_path) # Add ampersand for unknown reason (Maybe is needed in Houdini?) new_houdini_path.append("&") new_houdini_menu_path.append("&") env["HOUDINI_PATH"] = os.pathsep.join(new_houdini_path) env["HOUDINI_MENU_PATH"] = os.pathsep.join(new_houdini_menu_path) def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] return [ os.path.join(HOUDINI_HOST_DIR, "hooks") ] def get_workfile_extensions(self): return [".hip", ".hiplc", ".hipnc"] ================================================ FILE: openpype/hosts/houdini/api/__init__.py ================================================ from .pipeline import ( HoudiniHost, ls, containerise ) from .plugin import ( Creator, ) from .lib import ( lsattr, lsattrs, read, maintained_selection ) __all__ = [ "HoudiniHost", "ls", "containerise", "Creator", # Utility functions "lsattr", "lsattrs", "read", "maintained_selection" ] ================================================ FILE: openpype/hosts/houdini/api/action.py ================================================ import pyblish.api import hou from openpype.pipeline.publish import get_errored_instances_from_context class SelectInvalidAction(pyblish.api.Action): """Select invalid nodes in Maya when plug-in failed. To retrieve the invalid nodes this assumes a static `get_invalid()` method is available on the plugin. """ label = "Select invalid" on = "failed" # This action is only available on a failed plug-in icon = "search" # Icon from Awesome Icon def process(self, context, plugin): errored_instances = get_errored_instances_from_context(context, plugin=plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes..") invalid = list() for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: if isinstance(invalid_nodes, (list, tuple)): invalid.extend(invalid_nodes) else: self.log.warning("Plug-in returned to be invalid, " "but has no selectable nodes.") hou.clearAllSelected() if invalid: self.log.info("Selecting invalid nodes: {}".format( ", ".join(node.path() for node in invalid) )) for node in invalid: node.setSelected(True) node.setCurrent(True) else: self.log.info("No invalid nodes found.") class SelectROPAction(pyblish.api.Action): """Select ROP. It's used to select the associated ROPs with the errored instances. """ label = "Select ROP" on = "failed" # This action is only available on a failed plug-in icon = "mdi.cursor-default-click" def process(self, context, plugin): errored_instances = get_errored_instances_from_context(context, plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding ROP nodes..") rop_nodes = list() for instance in errored_instances: node_path = instance.data.get("instance_node") if not node_path: continue node = hou.node(node_path) if not node: continue rop_nodes.append(node) hou.clearAllSelected() if rop_nodes: self.log.info("Selecting ROP nodes: {}".format( ", ".join(node.path() for node in rop_nodes) )) for node in rop_nodes: node.setSelected(True) node.setCurrent(True) else: self.log.info("No ROP nodes found.") ================================================ FILE: openpype/hosts/houdini/api/colorspace.py ================================================ import attr import hou from openpype.hosts.houdini.api.lib import get_color_management_preferences from openpype.pipeline.colorspace import get_display_view_colorspace_name @attr.s class LayerMetadata(object): """Data class for Render Layer metadata.""" frameStart = attr.ib() frameEnd = attr.ib() @attr.s class RenderProduct(object): """Getting Colorspace as Specific Render Product Parameter for submitting publish job. """ colorspace = attr.ib() # colorspace view = attr.ib() productName = attr.ib(default=None) class ARenderProduct(object): def __init__(self): """Constructor.""" # Initialize self.layer_data = self._get_layer_data() self.layer_data.products = self.get_colorspace_data() def _get_layer_data(self): return LayerMetadata( frameStart=int(hou.playbar.frameRange()[0]), frameEnd=int(hou.playbar.frameRange()[1]), ) def get_colorspace_data(self): """To be implemented by renderer class. This should return a list of RenderProducts. Returns: list: List of RenderProduct """ data = get_color_management_preferences() colorspace_data = [ RenderProduct( colorspace=data["display"], view=data["view"], productName="" ) ] return colorspace_data def get_default_display_view_colorspace(): """Returns the colorspace attribute of the default (display, view) pair. It's used for 'ociocolorspace' parm in OpenGL Node.""" prefs = get_color_management_preferences() return get_display_view_colorspace_name( config_path=prefs["config"], display=prefs["display"], view=prefs["view"] ) ================================================ FILE: openpype/hosts/houdini/api/creator_node_shelves.py ================================================ """Library to register OpenPype Creators for Houdini TAB node search menu. This can be used to install custom houdini tools for the TAB search menu which will trigger a publish instance to be created interactively. The Creators are automatically registered on launch of Houdini through the Houdini integration's `host.install()` method. """ import contextlib import tempfile import logging import os from openpype.client import get_asset_by_name from openpype.pipeline import registered_host from openpype.pipeline.create import CreateContext from openpype.resources import get_openpype_icon_filepath import hou import stateutils import soptoolutils import loptoolutils import cop2toolutils log = logging.getLogger(__name__) CATEGORY_GENERIC_TOOL = { hou.sopNodeTypeCategory(): soptoolutils.genericTool, hou.cop2NodeTypeCategory(): cop2toolutils.genericTool, hou.lopNodeTypeCategory(): loptoolutils.genericTool } CREATE_SCRIPT = """ from openpype.hosts.houdini.api.creator_node_shelves import create_interactive create_interactive("{identifier}", **kwargs) """ def create_interactive(creator_identifier, **kwargs): """Create a Creator using its identifier interactively. This is used by the generated shelf tools as callback when a user selects the creator from the node tab search menu. The `kwargs` should be what Houdini passes to the tool create scripts context. For more information see: https://www.sidefx.com/docs/houdini/hom/tool_script.html#arguments Args: creator_identifier (str): The creator identifier of the Creator plugin to create. Return: list: The created instances. """ host = registered_host() context = CreateContext(host) creator = context.manual_creators.get(creator_identifier) if not creator: raise RuntimeError("Invalid creator identifier: {}".format( creator_identifier) ) # TODO Use Qt instead result, variant = hou.ui.readInput( "Define variant name", buttons=("Ok", "Cancel"), initial_contents=creator.get_default_variant(), title="Define variant", help="Set the variant for the publish instance", close_choice=1 ) if result == 1: # User interrupted return variant = variant.strip() if not variant: raise RuntimeError("Empty variant value entered.") # TODO: Once more elaborate unique create behavior should exist per Creator # instead of per network editor area then we should move this from here # to a method on the Creators for which this could be the default # implementation. pane = stateutils.activePane(kwargs) if isinstance(pane, hou.NetworkEditor): pwd = pane.pwd() subset_name = creator.get_subset_name( variant=variant, task_name=context.get_current_task_name(), asset_doc=get_asset_by_name( project_name=context.get_current_project_name(), asset_name=context.get_current_asset_name() ), project_name=context.get_current_project_name(), host_name=context.host_name ) tool_fn = CATEGORY_GENERIC_TOOL.get(pwd.childTypeCategory()) if tool_fn is not None: out_null = tool_fn(kwargs, "null") out_null.setName("OUT_{}".format(subset_name), unique_name=True) before = context.instances_by_id.copy() # Create the instance context.create( creator_identifier=creator_identifier, variant=variant, pre_create_data={"use_selection": True} ) # For convenience we set the new node as current since that's much more # familiar to the artist when creating a node interactively # TODO Allow to disable auto-select in studio settings or user preferences after = context.instances_by_id new = set(after) - set(before) if new: # Select the new instance for instance_id in new: instance = after[instance_id] node = hou.node(instance.get("instance_node")) node.setCurrent(True) return list(new) @contextlib.contextmanager def shelves_change_block(): """Write shelf changes at the end of the context.""" hou.shelves.beginChangeBlock() try: yield finally: hou.shelves.endChangeBlock() def install(): """Install the Creator plug-ins to show in Houdini's TAB node search menu. This function is re-entrant and can be called again to reinstall and update the node definitions. For example during development it can be useful to call it manually: >>> from openpype.hosts.houdini.api.creator_node_shelves import install >>> install() Returns: list: List of `hou.Tool` instances """ host = registered_host() # Store the filepath on the host # TODO: Define a less hacky static shelf path for current houdini session filepath_attr = "_creator_node_shelf_filepath" filepath = getattr(host, filepath_attr, None) if filepath is None: f = tempfile.NamedTemporaryFile(prefix="houdini_creator_nodes_", suffix=".shelf", delete=False) f.close() filepath = f.name setattr(host, filepath_attr, filepath) elif os.path.exists(filepath): # Remove any existing shelf file so that we can completey regenerate # and update the tools file if creator identifiers change os.remove(filepath) icon = get_openpype_icon_filepath() tab_menu_label = os.environ.get("AVALON_LABEL") or "AYON" # Create context only to get creator plugins, so we don't reset and only # populate what we need to retrieve the list of creator plugins create_context = CreateContext(host, reset=False) create_context.reset_current_context() create_context._reset_creator_plugins() log.debug("Writing OpenPype Creator nodes to shelf: {}".format(filepath)) tools = [] with shelves_change_block(): for identifier, creator in create_context.manual_creators.items(): # Allow the creator plug-in itself to override the categories # for where they are shown with `Creator.get_network_categories()` if not hasattr(creator, "get_network_categories"): log.debug("Creator {} has no `get_network_categories` method " "and will not be added to TAB search.") continue network_categories = creator.get_network_categories() if not network_categories: continue key = "ayon_create.{}".format(identifier) log.debug(f"Registering {key}") script = CREATE_SCRIPT.format(identifier=identifier) data = { "script": script, "language": hou.scriptLanguage.Python, "icon": icon, "help": "Create Ayon publish instance for {}".format( creator.label ), "help_url": None, "network_categories": network_categories, "viewer_categories": [], "cop_viewer_categories": [], "network_op_type": None, "viewer_op_type": None, "locations": [tab_menu_label] } label = "Create {}".format(creator.label) tool = hou.shelves.tool(key) if tool: tool.setData(**data) tool.setLabel(label) else: tool = hou.shelves.newTool( file_path=filepath, name=key, label=label, **data ) tools.append(tool) # Ensure the shelf is reloaded hou.shelves.loadFile(filepath) return tools ================================================ FILE: openpype/hosts/houdini/api/lib.py ================================================ # -*- coding: utf-8 -*- import sys import os import errno import re import uuid import logging from contextlib import contextmanager import json import six from openpype.lib import StringTemplate from openpype.client import get_project, get_asset_by_name from openpype.settings import get_current_project_settings from openpype.pipeline import ( Anatomy, get_current_project_name, get_current_asset_name, registered_host, get_current_context, get_current_host_name, ) from openpype.pipeline.create import CreateContext from openpype.pipeline.template_data import get_template_data from openpype.pipeline.context_tools import get_current_project_asset from openpype.widgets import popup from openpype.tools.utils.host_tools import get_tool_by_name import hou self = sys.modules[__name__] self._parent = None log = logging.getLogger(__name__) JSON_PREFIX = "JSON:::" def get_asset_fps(asset_doc=None): """Return current asset fps.""" if asset_doc is None: asset_doc = get_current_project_asset(fields=["data.fps"]) return asset_doc["data"]["fps"] def set_id(node, unique_id, overwrite=False): exists = node.parm("id") if not exists: imprint(node, {"id": unique_id}) if not exists and overwrite: node.setParm("id", unique_id) def get_id(node): """Get the `cbId` attribute of the given node. Args: node (hou.Node): the name of the node to retrieve the attribute from Returns: str: cbId attribute of the node. """ if node is not None: return node.parm("id") def generate_ids(nodes, asset_id=None): """Returns new unique ids for the given nodes. Note: This does not assign the new ids, it only generates the values. To assign new ids using this method: >>> nodes = ["a", "b", "c"] >>> for node, id in generate_ids(nodes): >>> set_id(node, id) To also override any existing values (and assign regenerated ids): >>> nodes = ["a", "b", "c"] >>> for node, id in generate_ids(nodes): >>> set_id(node, id, overwrite=True) Args: nodes (list): List of nodes. asset_id (str or bson.ObjectId): The database id for the *asset* to generate for. When None provided the current asset in the active session is used. Returns: list: A list of (node, id) tuples. """ if asset_id is None: project_name = get_current_project_name() asset_name = get_current_asset_name() # Get the asset ID from the database for the asset of current context asset_doc = get_asset_by_name(project_name, asset_name, fields=["_id"]) assert asset_doc, "No current asset found in Session" asset_id = asset_doc['_id'] node_ids = [] for node in nodes: _, uid = str(uuid.uuid4()).rsplit("-", 1) unique_id = "{}:{}".format(asset_id, uid) node_ids.append((node, unique_id)) return node_ids def get_id_required_nodes(): valid_types = ["geometry"] nodes = {n for n in hou.node("/out").children() if n.type().name() in valid_types} return list(nodes) def get_output_parameter(node): """Return the render output parameter of the given node Example: root = hou.node("/obj") my_alembic_node = root.createNode("alembic") get_output_parameter(my_alembic_node) >>> "filename" Notes: I'm using node.type().name() to get on par with the creators, Because the return value of `node.type().name()` is the same string value used in creators e.g. instance_data.update({"node_type": "alembic"}) Rop nodes in different network categories have the same output parameter. So, I took that into consideration as a hint for future development. Args: node(hou.Node): node instance Returns: hou.Parm """ node_type = node.type().name() # Figure out which type of node is being rendered if node_type in {"alembic", "rop_alembic"}: return node.parm("filename") elif node_type == "arnold": if node_type.evalParm("ar_ass_export_enable"): return node.parm("ar_ass_file") return node.parm("ar_picture") elif node_type in { "geometry", "rop_geometry", "filmboxfbx", "rop_fbx" }: return node.parm("sopoutput") elif node_type == "comp": return node.parm("copoutput") elif node_type in {"karma", "opengl"}: return node.parm("picture") elif node_type == "ifd": # Mantra if node.evalParm("soho_outputmode"): return node.parm("soho_diskfile") return node.parm("vm_picture") elif node_type == "Redshift_Proxy_Output": return node.parm("RS_archive_file") elif node_type == "Redshift_ROP": return node.parm("RS_outputFileNamePrefix") elif node_type in {"usd", "usd_rop", "usdexport"}: return node.parm("lopoutput") elif node_type in {"usdrender", "usdrender_rop"}: return node.parm("outputimage") elif node_type == "vray_renderer": return node.parm("SettingsOutput_img_file_path") raise TypeError("Node type '%s' not supported" % node_type) def set_scene_fps(fps): hou.setFps(fps) # Valid FPS def validate_fps(): """Validate current scene FPS and show pop-up when it is incorrect Returns: bool """ fps = get_asset_fps() current_fps = hou.fps() # returns float if current_fps != fps: # Find main window parent = hou.ui.mainQtWindow() if parent is None: pass else: dialog = popup.PopupUpdateKeys(parent=parent) dialog.setModal(True) dialog.setWindowTitle("Houdini scene does not match project FPS") dialog.setMessage("Scene %i FPS does not match project %i FPS" % (current_fps, fps)) dialog.setButtonText("Fix") # on_show is the Fix button clicked callback dialog.on_clicked_state.connect(lambda: set_scene_fps(fps)) dialog.show() return False return True def create_remote_publish_node(force=True): """Function to create a remote publish node in /out This is a hacked "Shell" node that does *nothing* except for triggering `colorbleed.lib.publish_remote()` as pre-render script. All default attributes of the Shell node are hidden to the Artist to avoid confusion. Additionally some custom attributes are added that can be collected by a Collector to set specific settings for the publish, e.g. whether to separate the jobs per instance or process in one single job. """ cmd = "import colorbleed.lib; colorbleed.lib.publish_remote()" existing = hou.node("/out/REMOTE_PUBLISH") if existing: if force: log.warning("Removing existing '/out/REMOTE_PUBLISH' node..") existing.destroy() else: raise RuntimeError("Node already exists /out/REMOTE_PUBLISH. " "Please remove manually or set `force` to " "True.") # Create the shell node out = hou.node("/out") node = out.createNode("shell", node_name="REMOTE_PUBLISH") node.moveToGoodPosition() # Set color make it stand out (avalon/pyblish color) node.setColor(hou.Color(0.439, 0.709, 0.933)) # Set the pre-render script node.setParms({ "prerender": cmd, "lprerender": "python" # command language }) # Lock the attributes to ensure artists won't easily mess things up. node.parm("prerender").lock(True) node.parm("lprerender").lock(True) # Lock up the actual shell command command_parm = node.parm("command") command_parm.set("") command_parm.lock(True) shellexec_parm = node.parm("shellexec") shellexec_parm.set(False) shellexec_parm.lock(True) # Get the node's parm template group so we can customize it template = node.parmTemplateGroup() # Hide default tabs template.hideFolder("Shell", True) template.hideFolder("Scripts", True) # Hide default settings template.hide("execute", True) template.hide("renderdialog", True) template.hide("trange", True) template.hide("f", True) template.hide("take", True) # Add custom settings to this node. parm_folder = hou.FolderParmTemplate("folder", "Submission Settings") # Separate Jobs per Instance parm = hou.ToggleParmTemplate(name="separateJobPerInstance", label="Separate Job per Instance", default_value=False) parm_folder.addParmTemplate(parm) # Add our custom Submission Settings folder template.append(parm_folder) # Apply template back to the node node.setParmTemplateGroup(template) def render_rop(ropnode): """Render ROP node utility for Publishing. This renders a ROP node with the settings we want during Publishing. """ # Print verbose when in batch mode without UI verbose = not hou.isUIAvailable() # Render try: ropnode.render(verbose=verbose, # Allow Deadline to capture completion percentage output_progress=verbose) except hou.Error as exc: # The hou.Error is not inherited from a Python Exception class, # so we explicitly capture the houdini error, otherwise pyblish # will remain hanging. import traceback traceback.print_exc() raise RuntimeError("Render failed: {0}".format(exc)) def imprint(node, data, update=False): """Store attributes with value on a node Depending on the type of attribute it creates the correct parameter template. Houdini uses a template per type, see the docs for more information. http://www.sidefx.com/docs/houdini/hom/hou/ParmTemplate.html Because of some update glitch where you cannot overwrite existing ParmTemplates on node using: `setParmTemplates()` and `parmTuplesInFolder()` update is done in another pass. Args: node(hou.Node): node object from Houdini data(dict): collection of attributes and their value update (bool, optional): flag if imprint should update already existing data or leave them untouched and only add new. Returns: None """ if not data: return if not node: self.log.error("Node is not set, calling imprint on invalid data.") return current_parms = {p.name(): p for p in node.spareParms()} update_parm_templates = [] new_parm_templates = [] for key, value in data.items(): if value is None: continue parm_template = get_template_from_value(key, value) if key in current_parms: if node.evalParm(key) == value: continue if not update: log.debug(f"{key} already exists on {node}") else: log.debug(f"replacing {key}") update_parm_templates.append(parm_template) continue new_parm_templates.append(parm_template) if not new_parm_templates and not update_parm_templates: return parm_group = node.parmTemplateGroup() # Add new parm templates if new_parm_templates: parm_folder = parm_group.findFolder("Extra") # if folder doesn't exist yet, create one and append to it, # else append to existing one if not parm_folder: parm_folder = hou.FolderParmTemplate("folder", "Extra") parm_folder.setParmTemplates(new_parm_templates) parm_group.append(parm_folder) else: # Add to parm template folder instance then replace with updated # one in parm template group for template in new_parm_templates: parm_folder.addParmTemplate(template) parm_group.replace(parm_folder.name(), parm_folder) # Update existing parm templates for parm_template in update_parm_templates: parm_group.replace(parm_template.name(), parm_template) # When replacing a parm with a parm of the same name it preserves its # value if before the replacement the parm was not at the default, # because it has a value override set. Since we're trying to update the # parm by using the new value as `default` we enforce the parm is at # default state node.parm(parm_template.name()).revertToDefaults() node.setParmTemplateGroup(parm_group) def lsattr(attr, value=None, root="/"): """Return nodes that have `attr` When `value` is not None it will only return nodes matching that value for the given attribute. Args: attr (str): Name of the attribute (hou.Parm) value (object, Optional): The value to compare the attribute too. When the default None is provided the value check is skipped. root (str): The root path in Houdini to search in. Returns: list: Matching nodes that have attribute with value. """ if value is None: # Use allSubChildren() as allNodes() errors on nodes without # permission to enter without a means to continue of querying # the rest nodes = hou.node(root).allSubChildren() return [n for n in nodes if n.parm(attr)] return lsattrs({attr: value}) def lsattrs(attrs, root="/"): """Return nodes matching `key` and `value` Arguments: attrs (dict): collection of attribute: value root (str): The root path in Houdini to search in. Example: >> lsattrs({"id": "myId"}) ["myNode"] >> lsattr("id") ["myNode", "myOtherNode"] Returns: list: Matching nodes that have attribute with value. """ matches = set() # Use allSubChildren() as allNodes() errors on nodes without # permission to enter without a means to continue of querying # the rest nodes = hou.node(root).allSubChildren() for node in nodes: for attr in attrs: if not node.parm(attr): continue elif node.evalParm(attr) != attrs[attr]: continue else: matches.add(node) return list(matches) def read(node): """Read the container data in to a dict Args: node(hou.Node): Houdini node Returns: dict """ # `spareParms` returns a tuple of hou.Parm objects data = {} if not node: return data for parameter in node.spareParms(): value = parameter.eval() # test if value is json encoded dict if isinstance(value, six.string_types) and \ value.startswith(JSON_PREFIX): try: value = json.loads(value[len(JSON_PREFIX):]) except json.JSONDecodeError: # not a json pass data[parameter.name()] = value return data @contextmanager def maintained_selection(): """Maintain selection during context Example: >>> with maintained_selection(): ... # Modify selection ... node.setSelected(on=False, clear_all_selected=True) >>> # Selection restored """ previous_selection = hou.selectedNodes() try: yield finally: # Clear the selection # todo: does hou.clearAllSelected() do the same? for node in hou.selectedNodes(): node.setSelected(on=False) if previous_selection: for node in previous_selection: node.setSelected(on=True) def reset_framerange(): """Set frame range and FPS to current asset""" # Get asset data project_name = get_current_project_name() asset_name = get_current_asset_name() # Get the asset ID from the database for the asset of current context asset_doc = get_asset_by_name(project_name, asset_name) asset_data = asset_doc["data"] # Get FPS fps = get_asset_fps(asset_doc) # Get Start and End Frames frame_start = asset_data.get("frameStart") frame_end = asset_data.get("frameEnd") if frame_start is None or frame_end is None: log.warning("No edit information found for %s" % asset_name) return handle_start = asset_data.get("handleStart", 0) handle_end = asset_data.get("handleEnd", 0) frame_start -= int(handle_start) frame_end += int(handle_end) # Set frame range and FPS print("Setting scene FPS to {}".format(int(fps))) set_scene_fps(fps) hou.playbar.setFrameRange(frame_start, frame_end) hou.playbar.setPlaybackRange(frame_start, frame_end) hou.setFrame(frame_start) def get_main_window(): """Acquire Houdini's main window""" if self._parent is None: self._parent = hou.ui.mainQtWindow() return self._parent def get_template_from_value(key, value): if isinstance(value, float): parm = hou.FloatParmTemplate(name=key, label=key, num_components=1, default_value=(value,)) elif isinstance(value, bool): parm = hou.ToggleParmTemplate(name=key, label=key, default_value=value) elif isinstance(value, int): parm = hou.IntParmTemplate(name=key, label=key, num_components=1, default_value=(value,)) elif isinstance(value, six.string_types): parm = hou.StringParmTemplate(name=key, label=key, num_components=1, default_value=(value,)) elif isinstance(value, (dict, list, tuple)): parm = hou.StringParmTemplate(name=key, label=key, num_components=1, default_value=( JSON_PREFIX + json.dumps(value),)) else: raise TypeError("Unsupported type: %r" % type(value)) return parm def get_frame_data(node, log=None): """Get the frame data: `frameStartHandle`, `frameEndHandle` and `byFrameStep`. This function uses Houdini node's `trange`, `t1, `t2` and `t3` parameters as the source of truth for the full inclusive frame range to render, as such these are considered as the frame range including the handles. The non-inclusive frame start and frame end without handles can be computed by subtracting the handles from the inclusive frame range. Args: node (hou.Node): ROP node to retrieve frame range from, the frame range is assumed to be the frame range *including* the start and end handles. Returns: dict: frame data for `frameStartHandle`, `frameEndHandle` and `byFrameStep`. """ if log is None: log = self.log data = {} if node.parm("trange") is None: log.debug( "Node has no 'trange' parameter: {}".format(node.path()) ) return data if node.evalParm("trange") == 0: data["frameStartHandle"] = hou.intFrame() data["frameEndHandle"] = hou.intFrame() data["byFrameStep"] = 1.0 log.info( "Node '{}' has 'Render current frame' set.\n" "Asset Handles are ignored.\n" "frameStart and frameEnd are set to the " "current frame.".format(node.path()) ) else: data["frameStartHandle"] = int(node.evalParm("f1")) data["frameEndHandle"] = int(node.evalParm("f2")) data["byFrameStep"] = node.evalParm("f3") return data def splitext(name, allowed_multidot_extensions): # type: (str, list) -> tuple """Split file name to name and extension. Args: name (str): File name to split. allowed_multidot_extensions (list of str): List of allowed multidot extensions. Returns: tuple: Name and extension. """ for ext in allowed_multidot_extensions: if name.endswith(ext): return name[:-len(ext)], ext return os.path.splitext(name) def get_top_referenced_parm(parm): processed = set() # disallow infinite loop while True: if parm.path() in processed: raise RuntimeError("Parameter references result in cycle.") processed.add(parm.path()) ref = parm.getReferencedParm() if ref.path() == parm.path(): # It returns itself when it doesn't reference # another parameter return ref else: parm = ref def evalParmNoFrame(node, parm, pad_character="#"): parameter = node.parm(parm) assert parameter, "Parameter does not exist: %s.%s" % (node, parm) # If the parameter has a parameter reference, then get that # parameter instead as otherwise `unexpandedString()` fails. parameter = get_top_referenced_parm(parameter) # Substitute out the frame numbering with padded characters try: raw = parameter.unexpandedString() except hou.Error as exc: print("Failed: %s" % parameter) raise RuntimeError(exc) def replace(match): padding = 1 n = match.group(2) if n and int(n): padding = int(n) return pad_character * padding expression = re.sub(r"(\$F([0-9]*))", replace, raw) with hou.ScriptEvalContext(parameter): return hou.expandStringAtFrame(expression, 0) def get_color_management_preferences(): """Get default OCIO preferences""" return { "config": hou.Color.ocio_configPath(), "display": hou.Color.ocio_defaultDisplay(), "view": hou.Color.ocio_defaultView() } def get_obj_node_output(obj_node): """Find output node. If the node has any output node return the output node with the minimum `outputidx`. When no output is present return the node with the display flag set. If no output node is detected then None is returned. Arguments: node (hou.Node): The node to retrieve a single the output node for. Returns: Optional[hou.Node]: The child output node. """ outputs = obj_node.subnetOutputs() if not outputs: return elif len(outputs) == 1: return outputs[0] else: return min(outputs, key=lambda node: node.evalParm('outputidx')) def get_output_children(output_node, include_sops=True): """Recursively return a list of all output nodes contained in this node including this node. It works in a similar manner to output_node.allNodes(). """ out_list = [output_node] if output_node.childTypeCategory() == hou.objNodeTypeCategory(): for child in output_node.children(): out_list += get_output_children(child, include_sops=include_sops) elif include_sops and \ output_node.childTypeCategory() == hou.sopNodeTypeCategory(): out = get_obj_node_output(output_node) if out: out_list += [out] return out_list def get_resolution_from_doc(doc): """Get resolution from the given asset document. """ if not doc or "data" not in doc: print("Entered document is not valid. \"{}\"".format(str(doc))) return None resolution_width = doc["data"].get("resolutionWidth") resolution_height = doc["data"].get("resolutionHeight") # Make sure both width and height are set if resolution_width is None or resolution_height is None: print("No resolution information found for \"{}\"".format(doc["name"])) return None return int(resolution_width), int(resolution_height) def set_camera_resolution(camera, asset_doc=None): """Apply resolution to camera from asset document of the publish""" if not asset_doc: asset_doc = get_current_project_asset() resolution = get_resolution_from_doc(asset_doc) if resolution: print("Setting camera resolution: {} -> {}x{}".format( camera.name(), resolution[0], resolution[1] )) camera.parm("resx").set(resolution[0]) camera.parm("resy").set(resolution[1]) def get_camera_from_container(container): """Get camera from container node. """ cameras = container.recursiveGlob( "*", filter=hou.nodeTypeFilter.ObjCamera, include_subnets=False ) assert len(cameras) == 1, "Camera instance must have only one camera" return cameras[0] def get_current_context_template_data_with_asset_data(): """ TODOs: Support both 'assetData' and 'folderData' in future. """ context = get_current_context() project_name = context["project_name"] asset_name = context["asset_name"] task_name = context["task_name"] host_name = get_current_host_name() anatomy = Anatomy(project_name) project_doc = get_project(project_name) asset_doc = get_asset_by_name(project_name, asset_name) # get context specific vars asset_data = asset_doc["data"] # compute `frameStartHandle` and `frameEndHandle` frame_start = asset_data.get("frameStart") frame_end = asset_data.get("frameEnd") handle_start = asset_data.get("handleStart") handle_end = asset_data.get("handleEnd") if frame_start is not None and handle_start is not None: asset_data["frameStartHandle"] = frame_start - handle_start if frame_end is not None and handle_end is not None: asset_data["frameEndHandle"] = frame_end + handle_end template_data = get_template_data( project_doc, asset_doc, task_name, host_name ) template_data["root"] = anatomy.roots template_data["assetData"] = asset_data return template_data def get_context_var_changes(): """get context var changes.""" houdini_vars_to_update = {} project_settings = get_current_project_settings() houdini_vars_settings = \ project_settings["houdini"]["general"]["update_houdini_var_context"] if not houdini_vars_settings["enabled"]: return houdini_vars_to_update houdini_vars = houdini_vars_settings["houdini_vars"] # No vars specified - nothing to do if not houdini_vars: return houdini_vars_to_update # Get Template data template_data = get_current_context_template_data_with_asset_data() # Set Houdini Vars for item in houdini_vars: # For consistency reasons we always force all vars to be uppercase # Also remove any leading, and trailing whitespaces. var = item["var"].strip().upper() # get and resolve template in value item_value = StringTemplate.format_template( item["value"], template_data ) if var == "JOB" and item_value == "": # sync $JOB to $HIP if $JOB is empty item_value = os.environ["HIP"] if item["is_directory"]: item_value = item_value.replace("\\", "/") current_value = hou.hscript("echo -n `${}`".format(var))[0] if current_value != item_value: houdini_vars_to_update[var] = ( current_value, item_value, item["is_directory"] ) return houdini_vars_to_update def update_houdini_vars_context(): """Update asset context variables""" for var, (_old, new, is_directory) in get_context_var_changes().items(): if is_directory: try: os.makedirs(new) except OSError as e: if e.errno != errno.EEXIST: print( "Failed to create ${} dir. Maybe due to " "insufficient permissions.".format(var) ) hou.hscript("set {}={}".format(var, new)) os.environ[var] = new print("Updated ${} to {}".format(var, new)) def update_houdini_vars_context_dialog(): """Show pop-up to update asset context variables""" update_vars = get_context_var_changes() if not update_vars: # Nothing to change print("Nothing to change, Houdini vars are already up to date.") return message = "\n".join( "${}: {} -> {}".format(var, old or "None", new or "None") for var, (old, new, _is_directory) in update_vars.items() ) # TODO: Use better UI! parent = hou.ui.mainQtWindow() dialog = popup.Popup(parent=parent) dialog.setModal(True) dialog.setWindowTitle("Houdini scene has outdated asset variables") dialog.setMessage(message) dialog.setButtonText("Fix") # on_show is the Fix button clicked callback dialog.on_clicked.connect(update_houdini_vars_context) dialog.show() def publisher_show_and_publish(comment=None): """Open publisher window and trigger publishing action. Args: comment (Optional[str]): Comment to set in publisher window. """ main_window = get_main_window() publisher_window = get_tool_by_name( tool_name="publisher", parent=main_window, ) publisher_window.show_and_publish(comment) def find_rop_input_dependencies(input_tuple): """Self publish from ROP nodes. Arguments: tuple (hou.RopNode.inputDependencies) which can be a nested tuples represents the input dependencies of the ROP node, consisting of ROPs, and the frames that need to be be rendered prior to rendering the ROP. Returns: list of the RopNode.path() that can be found inside the input tuple. """ out_list = [] if isinstance(input_tuple[0], hou.RopNode): return input_tuple[0].path() if isinstance(input_tuple[0], tuple): for item in input_tuple: out_list.append(find_rop_input_dependencies(item)) return out_list def self_publish(): """Self publish from ROP nodes. Firstly, it gets the node and its dependencies. Then, it deactivates all other ROPs And finaly, it triggers the publishing action. """ result, comment = hou.ui.readInput( "Add Publish Comment", buttons=("Publish", "Cancel"), title="Publish comment", close_choice=1 ) if result: return current_node = hou.node(".") inputs_paths = find_rop_input_dependencies( current_node.inputDependencies() ) inputs_paths.append(current_node.path()) host = registered_host() context = CreateContext(host, reset=True) for instance in context.instances: node_path = instance.data.get("instance_node") instance["active"] = node_path and node_path in inputs_paths context.save_changes() publisher_show_and_publish(comment) def add_self_publish_button(node): """Adds a self publish button to the rop node.""" label = os.environ.get("AVALON_LABEL") or "AYON" button_parm = hou.ButtonParmTemplate( "ayon_self_publish", "{} Publish".format(label), script_callback="from openpype.hosts.houdini.api.lib import " "self_publish; self_publish()", script_callback_language=hou.scriptLanguage.Python, join_with_next=True ) template = node.parmTemplateGroup() template.insertBefore((0,), button_parm) node.setParmTemplateGroup(template) ================================================ FILE: openpype/hosts/houdini/api/pipeline.py ================================================ # -*- coding: utf-8 -*- """Pipeline tools for OpenPype Houdini integration.""" import os import sys import logging import hou # noqa from openpype.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost import pyblish.api from openpype.pipeline import ( register_creator_plugin_path, register_loader_plugin_path, register_inventory_action_path, AVALON_CONTAINER_ID, ) from openpype.pipeline.load import any_outdated_containers from openpype.hosts.houdini import HOUDINI_HOST_DIR from openpype.hosts.houdini.api import lib, shelves, creator_node_shelves from openpype.lib import ( register_event_callback, emit_event, ) log = logging.getLogger("openpype.hosts.houdini") AVALON_CONTAINERS = "/obj/AVALON_CONTAINERS" CONTEXT_CONTAINER = "/obj/OpenPypeContext" IS_HEADLESS = not hasattr(hou, "ui") PLUGINS_DIR = os.path.join(HOUDINI_HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): name = "houdini" def __init__(self): super(HoudiniHost, self).__init__() self._op_events = {} self._has_been_setup = False def install(self): pyblish.api.register_host("houdini") pyblish.api.register_host("hython") pyblish.api.register_host("hpython") pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) register_inventory_action_path(INVENTORY_PATH) log.info("Installing callbacks ... ") # register_event_callback("init", on_init) self._register_callbacks() register_event_callback("before.save", before_save) register_event_callback("save", on_save) register_event_callback("open", on_open) register_event_callback("new", on_new) self._has_been_setup = True # Set asset settings for the empty scene directly after launch of # Houdini so it initializes into the correct scene FPS, # Frame Range, etc. # TODO: make sure this doesn't trigger when # opening with last workfile. _set_context_settings() if not IS_HEADLESS: import hdefereval # noqa, hdefereval is only available in ui mode # Defer generation of shelves due to issue on Windows where shelf # initialization during start up delays Houdini UI by minutes # making it extremely slow to launch. hdefereval.executeDeferred(shelves.generate_shelves) if not IS_HEADLESS: import hdefereval # noqa, hdefereval is only available in ui mode hdefereval.executeDeferred(creator_node_shelves.install) def workfile_has_unsaved_changes(self): return hou.hipFile.hasUnsavedChanges() def get_workfile_extensions(self): return [".hip", ".hiplc", ".hipnc"] def save_workfile(self, dst_path=None): # Force forwards slashes to avoid segfault if dst_path: dst_path = dst_path.replace("\\", "/") hou.hipFile.save(file_name=dst_path, save_to_recent_files=True) return dst_path def open_workfile(self, filepath): # Force forwards slashes to avoid segfault filepath = filepath.replace("\\", "/") hou.hipFile.load(filepath, suppress_save_prompt=True, ignore_load_warnings=False) return filepath def get_current_workfile(self): current_filepath = hou.hipFile.path() if (os.path.basename(current_filepath) == "untitled.hip" and not os.path.exists(current_filepath)): # By default a new scene in houdini is saved in the current # working directory as "untitled.hip" so we need to capture # that and consider it 'not saved' when it's in that state. return None return current_filepath def get_containers(self): return ls() def _register_callbacks(self): for event in self._op_events.copy().values(): if event is None: continue try: hou.hipFile.removeEventCallback(event) except RuntimeError as e: log.info(e) self._op_events[on_file_event_callback] = hou.hipFile.addEventCallback( on_file_event_callback ) @staticmethod def create_context_node(): """Helper for creating context holding node. Returns: hou.Node: context node """ obj_network = hou.node("/obj") op_ctx = obj_network.createNode("subnet", node_name="OpenPypeContext", run_init_scripts=False, load_contents=False) op_ctx.moveToGoodPosition() op_ctx.setBuiltExplicitly(False) op_ctx.setCreatorState("OpenPype") op_ctx.setComment("OpenPype node to hold context metadata") op_ctx.setColor(hou.Color((0.081, 0.798, 0.810))) op_ctx.setDisplayFlag(False) op_ctx.hide(True) return op_ctx def update_context_data(self, data, changes): op_ctx = hou.node(CONTEXT_CONTAINER) if not op_ctx: op_ctx = self.create_context_node() lib.imprint(op_ctx, data) def get_context_data(self): op_ctx = hou.node(CONTEXT_CONTAINER) if not op_ctx: op_ctx = self.create_context_node() return lib.read(op_ctx) def save_file(self, dst_path=None): # Force forwards slashes to avoid segfault dst_path = dst_path.replace("\\", "/") hou.hipFile.save(file_name=dst_path, save_to_recent_files=True) def on_file_event_callback(event): if event == hou.hipFileEventType.AfterLoad: emit_event("open") elif event == hou.hipFileEventType.AfterSave: emit_event("save") elif event == hou.hipFileEventType.BeforeSave: emit_event("before.save") elif event == hou.hipFileEventType.AfterClear: emit_event("new") def containerise(name, namespace, nodes, context, loader=None, suffix=""): """Bundle `nodes` into a subnet and imprint it with metadata Containerisation enables a tracking of version, author and origin for loaded assets. Arguments: name (str): Name of resulting assembly namespace (str): Namespace under which to host container nodes (list): Long names of nodes to containerise context (dict): Asset information loader (str, optional): Name of loader used to produce this container. suffix (str, optional): Suffix of container, defaults to `_CON`. Returns: container (str): Name of container assembly """ # Ensure AVALON_CONTAINERS subnet exists subnet = hou.node(AVALON_CONTAINERS) if subnet is None: obj_network = hou.node("/obj") subnet = obj_network.createNode("subnet", node_name="AVALON_CONTAINERS") # Create proper container name container_name = "{}_{}".format(name, suffix or "CON") container = hou.node("/obj/{}".format(name)) container.setName(container_name, unique_name=True) data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace, "loader": str(loader), "representation": str(context["representation"]["_id"]), } lib.imprint(container, data) # "Parent" the container under the container network hou.moveNodesTo([container], subnet) subnet.node(container_name).moveToGoodPosition() return container def parse_container(container): """Return the container node's full container data. Args: container (hou.Node): A container node name. Returns: dict: The container schema data for this container node. """ data = lib.read(container) # Backwards compatibility pre-schemas for containers data["schema"] = data.get("schema", "openpype:container-1.0") # Append transient data data["objectName"] = container.path() data["node"] = container return data def ls(): containers = [] for identifier in (AVALON_CONTAINER_ID, "pyblish.mindbender.container"): containers += lib.lsattr("id", identifier) for container in sorted(containers, # Hou 19+ Python 3 hou.ObjNode are not # sortable due to not supporting greater # than comparisons key=lambda node: node.path()): yield parse_container(container) def before_save(): return lib.validate_fps() def on_save(): log.info("Running callback on save..") # update houdini vars lib.update_houdini_vars_context_dialog() nodes = lib.get_id_required_nodes() for node, new_id in lib.generate_ids(nodes): lib.set_id(node, new_id, overwrite=False) def _show_outdated_content_popup(): # Get main window parent = lib.get_main_window() if parent is None: log.info("Skipping outdated content pop-up " "because Houdini window can't be found.") else: from openpype.widgets import popup # Show outdated pop-up def _on_show_inventory(): from openpype.tools.utils import host_tools host_tools.show_scene_inventory(parent=parent) dialog = popup.Popup(parent=parent) dialog.setWindowTitle("Houdini scene has outdated content") dialog.setMessage("There are outdated containers in " "your Houdini scene.") dialog.on_clicked.connect(_on_show_inventory) dialog.show() def on_open(): if not hou.isUIAvailable(): log.debug("Batch mode detected, ignoring `on_open` callbacks..") return log.info("Running callback on open..") # update houdini vars lib.update_houdini_vars_context_dialog() # Validate FPS after update_task_from_path to # ensure it is using correct FPS for the asset lib.validate_fps() if any_outdated_containers(): parent = lib.get_main_window() if parent is None: # When opening Houdini with last workfile on launch the UI hasn't # initialized yet completely when the `on_open` callback triggers. # We defer the dialog popup to wait for the UI to become available. # We assume it will open because `hou.isUIAvailable()` returns True import hdefereval hdefereval.executeDeferred(_show_outdated_content_popup) else: _show_outdated_content_popup() log.warning("Scene has outdated content.") def on_new(): """Set project resolution and fps when create a new file""" if hou.hipFile.isLoadingHipFile(): # This event also triggers when Houdini opens a file due to the # new event being registered to 'afterClear'. As such we can skip # 'new' logic if the user is opening a file anyway log.debug("Skipping on new callback due to scene being opened.") return log.info("Running callback on new..") _set_context_settings() # It seems that the current frame always gets reset to frame 1 on # new scene. So we enforce current frame to be at the start of the playbar # with execute deferred def _enforce_start_frame(): start = hou.playbar.playbackRange()[0] hou.setFrame(start) if hou.isUIAvailable(): import hdefereval hdefereval.executeDeferred(_enforce_start_frame) else: # Run without execute deferred when no UI is available because # without UI `hdefereval` is not available to import _enforce_start_frame() def _set_context_settings(): """Apply the project settings from the project definition Settings can be overwritten by an asset if the asset.data contains any information regarding those settings. Examples of settings: fps resolution renderer Returns: None """ lib.reset_framerange() lib.update_houdini_vars_context() ================================================ FILE: openpype/hosts/houdini/api/plugin.py ================================================ # -*- coding: utf-8 -*- """Houdini specific Avalon/Pyblish plugin definitions.""" import sys from abc import ( ABCMeta ) import six import hou from openpype import AYON_SERVER_ENABLED from openpype.pipeline import ( CreatorError, LegacyCreator, Creator as NewCreator, CreatedInstance ) from openpype.lib import BoolDef from .lib import imprint, read, lsattr, add_self_publish_button class OpenPypeCreatorError(CreatorError): pass class Creator(LegacyCreator): """Creator plugin to create instances in Houdini To support the wide range of node types for render output (Alembic, VDB, Mantra) the Creator needs a node type to create the correct instance By default, if none is given, is `geometry`. An example of accepted node types: geometry, alembic, ifd (mantra) Please check the Houdini documentation for more node types. Tip: to find the exact node type to create press the `i` left of the node when hovering over a node. The information is visible under the name of the node. Deprecated: This creator is deprecated and will be removed in future version. """ defaults = ['Main'] def __init__(self, *args, **kwargs): super(Creator, self).__init__(*args, **kwargs) self.nodes = [] def process(self): """This is the base functionality to create instances in Houdini The selected nodes are stored in self to be used in an override method. This is currently necessary in order to support the multiple output types in Houdini which can only be rendered through their own node. Default node type if none is given is `geometry` It also makes it easier to apply custom settings per instance type Example of override method for Alembic: def process(self): instance = super(CreateEpicNode, self, process() # Set parameters for Alembic node instance.setParms( {"sop_path": "$HIP/%s.abc" % self.nodes[0]} ) Returns: hou.Node """ try: if (self.options or {}).get("useSelection"): self.nodes = hou.selectedNodes() # Get the node type and remove it from the data, not needed node_type = self.data.pop("node_type", None) if node_type is None: node_type = "geometry" # Get out node out = hou.node("/out") instance = out.createNode(node_type, node_name=self.name) instance.moveToGoodPosition() imprint(instance, self.data) self._process(instance) except hou.Error as er: six.reraise( OpenPypeCreatorError, OpenPypeCreatorError("Creator error: {}".format(er)), sys.exc_info()[2]) class HoudiniCreatorBase(object): @staticmethod def cache_subsets(shared_data): """Cache instances for Creators to shared data. Create `houdini_cached_subsets` key when needed in shared data and fill it with all collected instances from the scene under its respective creator identifiers. Create `houdini_cached_legacy_subsets` key for any legacy instances detected in the scene as instances per family. Args: Dict[str, Any]: Shared data. Return: Dict[str, Any]: Shared data dictionary. """ if shared_data.get("houdini_cached_subsets") is None: cache = dict() cache_legacy = dict() for node in lsattr("id", "pyblish.avalon.instance"): creator_identifier_parm = node.parm("creator_identifier") if creator_identifier_parm: # creator instance creator_id = creator_identifier_parm.eval() cache.setdefault(creator_id, []).append(node) else: # legacy instance family_parm = node.parm("family") if not family_parm: # must be a broken instance continue family = family_parm.eval() cache_legacy.setdefault(family, []).append(node) shared_data["houdini_cached_subsets"] = cache shared_data["houdini_cached_legacy_subsets"] = cache_legacy return shared_data @staticmethod def create_instance_node( asset_name, node_name, parent, node_type="geometry" ): # type: (str, str, str) -> hou.Node """Create node representing instance. Arguments: asset_name (str): Asset name. node_name (str): Name of the new node. parent (str): Name of the parent node. node_type (str, optional): Type of the node. Returns: hou.Node: Newly created instance node. """ parent_node = hou.node(parent) instance_node = parent_node.createNode( node_type, node_name=node_name) instance_node.moveToGoodPosition() return instance_node @six.add_metaclass(ABCMeta) class HoudiniCreator(NewCreator, HoudiniCreatorBase): """Base class for most of the Houdini creator plugins.""" selected_nodes = [] settings_name = None add_publish_button = False def create(self, subset_name, instance_data, pre_create_data): try: self.selected_nodes = [] if pre_create_data.get("use_selection"): self.selected_nodes = hou.selectedNodes() # Get the node type and remove it from the data, not needed node_type = instance_data.pop("node_type", None) if node_type is None: node_type = "geometry" if AYON_SERVER_ENABLED: asset_name = instance_data["folderPath"] else: asset_name = instance_data["asset"] instance_node = self.create_instance_node( asset_name, subset_name, "/out", node_type) self.customize_node_look(instance_node) instance_data["instance_node"] = instance_node.path() instance_data["instance_id"] = instance_node.path() instance = CreatedInstance( self.family, subset_name, instance_data, self) self._add_instance_to_context(instance) self.imprint(instance_node, instance.data_to_store()) if self.add_publish_button: add_self_publish_button(instance_node) return instance except hou.Error as er: six.reraise( OpenPypeCreatorError, OpenPypeCreatorError("Creator error: {}".format(er)), sys.exc_info()[2]) def lock_parameters(self, node, parameters): """Lock list of specified parameters on the node. Args: node (hou.Node): Houdini node to lock parameters on. parameters (list of str): List of parameter names. """ for name in parameters: try: parm = node.parm(name) parm.lock(True) except AttributeError: self.log.debug("missing lock pattern {}".format(name)) def collect_instances(self): # cache instances if missing self.cache_subsets(self.collection_shared_data) for instance in self.collection_shared_data[ "houdini_cached_subsets"].get(self.identifier, []): node_data = read(instance) # Node paths are always the full node path since that is unique # Because it's the node's path it's not written into attributes # but explicitly collected node_path = instance.path() node_data["instance_id"] = node_path node_data["instance_node"] = node_path created_instance = CreatedInstance.from_existing( node_data, self ) self._add_instance_to_context(created_instance) def update_instances(self, update_list): for created_inst, changes in update_list: instance_node = hou.node(created_inst.get("instance_node")) new_values = { key: changes[key].new_value for key in changes.changed_keys } # Update parm templates and values self.imprint( instance_node, new_values, update=True ) def imprint(self, node, values, update=False): # Never store instance node and instance id since that data comes # from the node's path values.pop("instance_node", None) values.pop("instance_id", None) imprint(node, values, update=update) def remove_instances(self, instances): """Remove specified instance from the scene. This is only removing `id` parameter so instance is no longer instance, because it might contain valuable data for artist. """ for instance in instances: instance_node = hou.node(instance.data.get("instance_node")) if instance_node: instance_node.destroy() self._remove_instance_from_context(instance) def get_pre_create_attr_defs(self): return [ BoolDef("use_selection", label="Use selection") ] @staticmethod def customize_node_look( node, color=None, shape="chevron_down"): """Set custom look for instance nodes. Args: node (hou.Node): Node to set look. color (hou.Color, Optional): Color of the node. shape (str, Optional): Shape name of the node. Returns: None """ if not color: color = hou.Color((0.616, 0.871, 0.769)) node.setUserData('nodeshape', shape) node.setColor(color) def get_network_categories(self): """Return in which network view type this creator should show. The node type categories returned here will be used to define where the creator will show up in the TAB search for nodes in Houdini's Network View. This can be overridden in inherited classes to define where that particular Creator should be visible in the TAB search. Returns: list: List of houdini node type categories """ return [hou.ropNodeTypeCategory()] def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" # Apply General Settings houdini_general_settings = project_settings["houdini"]["general"] self.add_publish_button = houdini_general_settings.get( "add_self_publish_button", False) # Apply Creator Settings settings_name = self.settings_name if settings_name is None: settings_name = self.__class__.__name__ settings = project_settings["houdini"]["create"] settings = settings.get(settings_name) if settings is None: self.log.debug( "No settings found for {}".format(self.__class__.__name__) ) return for key, value in settings.items(): setattr(self, key, value) ================================================ FILE: openpype/hosts/houdini/api/shelves.py ================================================ import os import re import logging import platform from openpype.settings import get_project_settings from openpype.pipeline import get_current_project_name from openpype.lib import StringTemplate import hou from .lib import get_current_context_template_data_with_asset_data log = logging.getLogger("openpype.hosts.houdini.shelves") def generate_shelves(): """This function generates complete shelves from shelf set to tools in Houdini from openpype project settings houdini shelf definition. """ current_os = platform.system().lower() # load configuration of houdini shelves project_name = get_current_project_name() project_settings = get_project_settings(project_name) shelves_configs = project_settings["houdini"]["shelves"] if not shelves_configs: log.debug("No custom shelves found in project settings.") return # Get Template data template_data = get_current_context_template_data_with_asset_data() for config in shelves_configs: selected_option = config["options"] shelf_set_config = config[selected_option] shelf_set_filepath = shelf_set_config.get('shelf_set_source_path') if shelf_set_filepath: shelf_set_os_filepath = shelf_set_filepath[current_os] if shelf_set_os_filepath: shelf_set_os_filepath = get_path_using_template_data( shelf_set_os_filepath, template_data ) if not os.path.isfile(shelf_set_os_filepath): log.error("Shelf path doesn't exist - " "{}".format(shelf_set_os_filepath)) continue hou.shelves.loadFile(shelf_set_os_filepath) continue shelf_set_name = shelf_set_config.get('shelf_set_name') if not shelf_set_name: log.warning("No name found in shelf set definition.") continue shelves_definition = shelf_set_config.get('shelf_definition') if not shelves_definition: log.debug( "No shelf definition found for shelf set named '{}'".format( shelf_set_name ) ) continue shelf_set = get_or_create_shelf_set(shelf_set_name) for shelf_definition in shelves_definition: shelf_name = shelf_definition.get('shelf_name') if not shelf_name: log.warning("No name found in shelf definition.") continue shelf = get_or_create_shelf(shelf_name) if not shelf_definition.get('tools_list'): log.debug( "No tool definition found for shelf named {}".format( shelf_name ) ) continue mandatory_attributes = {'label', 'script'} for tool_definition in shelf_definition.get('tools_list'): # We verify that the name and script attributes of the tool # are set if not all( tool_definition[key] for key in mandatory_attributes ): log.warning( "You need to specify at least the name and the " "script path of the tool.") continue tool = get_or_create_tool( tool_definition, shelf, template_data ) if not tool: continue # Add the tool to the shelf if not already in it if tool not in shelf.tools(): shelf.setTools(list(shelf.tools()) + [tool]) # Add the shelf in the shelf set if not already in it if shelf not in shelf_set.shelves(): shelf_set.setShelves(shelf_set.shelves() + (shelf,)) def get_or_create_shelf_set(shelf_set_label): """This function verifies if the shelf set label exists. If not, creates a new shelf set. Arguments: shelf_set_label (str): The label of the shelf set Returns: hou.ShelfSet: The shelf set existing or the new one """ all_shelves_sets = hou.shelves.shelfSets().values() shelf_set = next((shelf for shelf in all_shelves_sets if shelf.label() == shelf_set_label), None) if shelf_set: return shelf_set shelf_set_name = shelf_set_label.replace(' ', '_').lower() new_shelf_set = hou.shelves.newShelfSet( name=shelf_set_name, label=shelf_set_label ) return new_shelf_set def get_or_create_shelf(shelf_label): """This function verifies if the shelf label exists. If not, creates a new shelf. Arguments: shelf_label (str): The label of the shelf Returns: hou.Shelf: The shelf existing or the new one """ all_shelves = hou.shelves.shelves().values() shelf = next((s for s in all_shelves if s.label() == shelf_label), None) if shelf: return shelf shelf_name = shelf_label.replace(' ', '_').lower() new_shelf = hou.shelves.newShelf( name=shelf_name, label=shelf_label ) return new_shelf def get_or_create_tool(tool_definition, shelf, template_data): """This function verifies if the tool exists and updates it. If not, creates a new one. Arguments: tool_definition (dict): Dict with label, script, icon and help shelf (hou.Shelf): The parent shelf of the tool Returns: hou.Tool: The tool updated or the new one """ tool_label = tool_definition.get("label") if not tool_label: log.warning("Skipped shelf without label") return script_path = tool_definition["script"] script_path = get_path_using_template_data(script_path, template_data) if not script_path or not os.path.exists(script_path): log.warning("This path doesn't exist - {}".format(script_path)) return icon_path = tool_definition["icon"] if icon_path: icon_path = get_path_using_template_data(icon_path, template_data) tool_definition["icon"] = icon_path existing_tools = shelf.tools() existing_tool = next( (tool for tool in existing_tools if tool.label() == tool_label), None ) with open(script_path) as stream: script = stream.read() tool_definition["script"] = script if existing_tool: tool_definition.pop("label", None) existing_tool.setData(**tool_definition) return existing_tool tool_name = re.sub(r"[^\w\d]+", "_", tool_label).lower() return hou.shelves.newTool(name=tool_name, **tool_definition) def get_path_using_template_data(path, template_data): path = StringTemplate.format_template(path, template_data) path = path.replace("\\", "/") return path ================================================ FILE: openpype/hosts/houdini/api/usd.py ================================================ """Houdini-specific USD Library functions.""" import contextlib import logging from qtpy import QtWidgets, QtCore, QtGui from openpype import style from openpype.client import get_asset_by_name from openpype.pipeline import legacy_io from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget from pxr import Sdf log = logging.getLogger(__name__) class SelectAssetDialog(QtWidgets.QWidget): """Frameless assets dialog to select asset with double click. Args: parm: Parameter where selected asset name is set. """ def __init__(self, parm): self.setWindowTitle("Pick Asset") self.setWindowFlags(QtCore.Qt.FramelessWindowHint | QtCore.Qt.Popup) assets_widget = SingleSelectAssetsWidget(legacy_io, parent=self) layout = QtWidgets.QHBoxLayout(self) layout.addWidget(assets_widget) assets_widget.double_clicked.connect(self._set_parameter) self._assets_widget = assets_widget self._parm = parm def _set_parameter(self): name = self._assets_widget.get_selected_asset_name() self._parm.set(name) self.close() def _on_show(self): pos = QtGui.QCursor.pos() # Select the current asset if there is any select_id = None name = self._parm.eval() if name: project_name = legacy_io.active_project() db_asset = get_asset_by_name(project_name, name, fields=["_id"]) if db_asset: select_id = db_asset["_id"] # Set stylesheet self.setStyleSheet(style.load_stylesheet()) # Refresh assets (is threaded) self._assets_widget.refresh() # Select asset - must be done after refresh if select_id is not None: self._assets_widget.select_asset(select_id) # Show cursor (top right of window) near cursor self.resize(250, 400) self.move(self.mapFromGlobal(pos) - QtCore.QPoint(self.width(), 0)) def showEvent(self, event): super(SelectAssetDialog, self).showEvent(event) self._on_show() def pick_asset(node): """Show a user interface to select an Asset in the project When double clicking an asset it will set the Asset value in the 'asset' parameter. """ parm = node.parm("asset_name") if not parm: log.error("Node has no 'asset' parameter: %s", node) return # Construct a frameless popup so it automatically # closes when clicked outside of it. global tool tool = SelectAssetDialog(parm) tool.show() def add_usd_output_processor(ropnode, processor): """Add USD Output Processor to USD Rop node. Args: ropnode (hou.RopNode): The USD Rop node. processor (str): The output processor name. This is the basename of the python file that contains the Houdini USD Output Processor. """ import loputils loputils.handleOutputProcessorAdd( { "node": ropnode, "parm": ropnode.parm("outputprocessors"), "script_value": processor, } ) def remove_usd_output_processor(ropnode, processor): """Removes USD Output Processor from USD Rop node. Args: ropnode (hou.RopNode): The USD Rop node. processor (str): The output processor name. This is the basename of the python file that contains the Houdini USD Output Processor. """ import loputils parm = ropnode.parm(processor + "_remove") if not parm: raise RuntimeError( "Output Processor %s does not " "exist on %s" % (processor, ropnode.name()) ) loputils.handleOutputProcessorRemove({"node": ropnode, "parm": parm}) @contextlib.contextmanager def outputprocessors(ropnode, processors=tuple(), disable_all_others=True): """Context manager to temporarily add Output Processors to USD ROP node. Args: ropnode (hou.RopNode): The USD Rop node. processors (tuple or list): The processors to add. disable_all_others (bool, Optional): Whether to disable all output processors currently on the ROP node that are not in the `processors` list passed to this function. """ # TODO: Add support for forcing the correct Order of the processors original = [] prefix = "enableoutputprocessor_" processor_parms = ropnode.globParms(prefix + "*") for parm in processor_parms: original.append((parm, parm.eval())) if disable_all_others: for parm in processor_parms: parm.set(False) added = [] for processor in processors: parm = ropnode.parm(prefix + processor) if parm: # If processor already exists, just enable it parm.set(True) else: # Else add the new processor add_usd_output_processor(ropnode, processor) added.append(processor) try: yield finally: # Remove newly added processors for processor in added: remove_usd_output_processor(ropnode, processor) # Revert to original values for parm, value in original: if parm: parm.set(value) def get_usd_rop_loppath(node): # Get sop path node_type = node.type().name() if node_type == "usd": return node.parm("loppath").evalAsNode() elif node_type in {"usd_rop", "usdrender_rop"}: # Inside Solaris e.g. /stage (not in ROP context) # When incoming connection is present it takes it directly inputs = node.inputs() if inputs: return inputs[0] else: return node.parm("loppath").evalAsNode() def get_layer_save_path(layer): """Get custom HoudiniLayerInfo->HoudiniSavePath from SdfLayer. Args: layer (pxr.Sdf.Layer): The Layer to retrieve the save pah data from. Returns: str or None: Path to save to when data exists. """ hou_layer_info = layer.rootPrims.get("HoudiniLayerInfo") if not hou_layer_info: return save_path = hou_layer_info.customData.get("HoudiniSavePath", None) if save_path: # Unfortunately this doesn't actually resolve the full absolute path return layer.ComputeAbsolutePath(save_path) def get_referenced_layers(layer): """Return SdfLayers for all external references of the current layer Args: layer (pxr.Sdf.Layer): The Layer to retrieve the save pah data from. Returns: list: List of pxr.Sdf.Layer that are external references to this layer """ layers = [] for layer_id in layer.GetExternalReferences(): layer = Sdf.Layer.Find(layer_id) if not layer: # A file may not be in memory and is # referenced from disk. As such it cannot # be found. We will ignore those layers. continue layers.append(layer) return layers def iter_layer_recursive(layer): """Recursively iterate all 'external' referenced layers""" layers = get_referenced_layers(layer) traversed = set(layers) # Avoid recursion to itself (if even possible) traverse = list(layers) for layer in traverse: # Include children layers (recursion) children_layers = get_referenced_layers(layer) children_layers = [x for x in children_layers if x not in traversed] traverse.extend(children_layers) traversed.update(children_layers) yield layer def get_configured_save_layers(usd_rop): lop_node = get_usd_rop_loppath(usd_rop) stage = lop_node.stage(apply_viewport_overrides=False) if not stage: raise RuntimeError( "No valid USD stage for ROP node: " "%s" % usd_rop.path() ) root_layer = stage.GetRootLayer() save_layers = [] for layer in iter_layer_recursive(root_layer): save_path = get_layer_save_path(layer) if save_path is not None: save_layers.append(layer) return save_layers ================================================ FILE: openpype/hosts/houdini/hooks/set_paths.py ================================================ from openpype.lib.applications import PreLaunchHook, LaunchTypes class SetPath(PreLaunchHook): """Set current dir to workdir. Hook `GlobalHostDataHook` must be executed before this hook. """ app_groups = {"houdini"} launch_types = {LaunchTypes.local} def execute(self): workdir = self.launch_context.env.get("AVALON_WORKDIR", "") if not workdir: self.log.warning("BUG: Workdir is not filled.") return self.launch_context.kwargs["cwd"] = workdir ================================================ FILE: openpype/hosts/houdini/plugins/create/convert_legacy.py ================================================ # -*- coding: utf-8 -*- """Converter for legacy Houdini subsets.""" from openpype.pipeline.create.creator_plugins import SubsetConvertorPlugin from openpype.hosts.houdini.api.lib import imprint class HoudiniLegacyConvertor(SubsetConvertorPlugin): """Find and convert any legacy subsets in the scene. This Converter will find all legacy subsets in the scene and will transform them to the current system. Since the old subsets doesn't retain any information about their original creators, the only mapping we can do is based on their families. Its limitation is that you can have multiple creators creating subset of the same family and there is no way to handle it. This code should nevertheless cover all creators that came with OpenPype. """ identifier = "io.openpype.creators.houdini.legacy" family_to_id = { "camera": "io.openpype.creators.houdini.camera", "ass": "io.openpype.creators.houdini.ass", "imagesequence": "io.openpype.creators.houdini.imagesequence", "hda": "io.openpype.creators.houdini.hda", "pointcache": "io.openpype.creators.houdini.pointcache", "redshiftproxy": "io.openpype.creators.houdini.redshiftproxy", "redshift_rop": "io.openpype.creators.houdini.redshift_rop", "usd": "io.openpype.creators.houdini.usd", "usdrender": "io.openpype.creators.houdini.usdrender", "vdbcache": "io.openpype.creators.houdini.vdbcache" } def __init__(self, *args, **kwargs): super(HoudiniLegacyConvertor, self).__init__(*args, **kwargs) self.legacy_subsets = {} def find_instances(self): """Find legacy subsets in the scene. Legacy subsets are the ones that doesn't have `creator_identifier` parameter on them. This is using cached entries done in :py:meth:`~HoudiniCreatorBase.cache_subsets()` """ self.legacy_subsets = self.collection_shared_data.get( "houdini_cached_legacy_subsets") if not self.legacy_subsets: return self.add_convertor_item("Found {} incompatible subset{}.".format( len(self.legacy_subsets), "s" if len(self.legacy_subsets) > 1 else "") ) def convert(self): """Convert all legacy subsets to current. It is enough to add `creator_identifier` and `instance_node`. """ if not self.legacy_subsets: return for family, subsets in self.legacy_subsets.items(): if family in self.family_to_id: for subset in subsets: data = { "creator_identifier": self.family_to_id[family], "instance_node": subset.path() } if family == "pointcache": data["families"] = ["abc"] self.log.info("Converting {} to {}".format( subset.path(), self.family_to_id[family])) imprint(subset, data) ================================================ FILE: openpype/hosts/houdini/plugins/create/create_alembic_camera.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating alembic camera subsets.""" from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance, CreatorError import hou class CreateAlembicCamera(plugin.HoudiniCreator): """Single baked camera from Alembic ROP.""" identifier = "io.openpype.creators.houdini.camera" label = "Camera (Abc)" family = "camera" icon = "camera" def create(self, subset_name, instance_data, pre_create_data): import hou instance_data.pop("active", None) instance_data.update({"node_type": "alembic"}) instance = super(CreateAlembicCamera, self).create( subset_name, instance_data, pre_create_data) # type: CreatedInstance instance_node = hou.node(instance.get("instance_node")) parms = { "filename": hou.text.expandString( "$HIP/pyblish/{}.abc".format(subset_name)), "use_sop_path": False, } if self.selected_nodes: if len(self.selected_nodes) > 1: raise CreatorError("More than one item selected.") path = self.selected_nodes[0].path() # Split the node path into the first root and the remainder # So we can set the root and objects parameters correctly _, root, remainder = path.split("/", 2) parms.update({"root": "/" + root, "objects": remainder}) instance_node.setParms(parms) # Lock the Use Sop Path setting so the # user doesn't accidentally enable it. to_lock = ["use_sop_path"] self.lock_parameters(instance_node, to_lock) instance_node.parm("trange").set(1) def get_network_categories(self): return [ hou.ropNodeTypeCategory(), hou.objNodeTypeCategory() ] ================================================ FILE: openpype/hosts/houdini/plugins/create/create_arnold_ass.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating Arnold ASS files.""" from openpype.hosts.houdini.api import plugin from openpype.lib import BoolDef class CreateArnoldAss(plugin.HoudiniCreator): """Arnold .ass Archive""" identifier = "io.openpype.creators.houdini.ass" label = "Arnold ASS" family = "ass" icon = "magic" # Default extension: `.ass` or `.ass.gz` # however calling HoudiniCreator.create() # will override it by the value in the project settings ext = ".ass" def create(self, subset_name, instance_data, pre_create_data): import hou instance_data.pop("active", None) instance_data.update({"node_type": "arnold"}) creator_attributes = instance_data.setdefault( "creator_attributes", dict()) creator_attributes["farm"] = pre_create_data["farm"] instance = super(CreateArnoldAss, self).create( subset_name, instance_data, pre_create_data) # type: plugin.CreatedInstance instance_node = hou.node(instance.get("instance_node")) # Hide Properties Tab on Arnold ROP since that's used # for rendering instead of .ass Archive Export parm_template_group = instance_node.parmTemplateGroup() parm_template_group.hideFolder("Properties", True) instance_node.setParmTemplateGroup(parm_template_group) filepath = "{}{}".format( hou.text.expandString("$HIP/pyblish/"), "{}.$F4{}".format(subset_name, self.ext) ) parms = { # Render frame range "trange": 1, # Arnold ROP settings "ar_ass_file": filepath, "ar_ass_export_enable": 1 } instance_node.setParms(parms) # Lock any parameters in this list to_lock = ["ar_ass_export_enable", "family", "id"] self.lock_parameters(instance_node, to_lock) def get_instance_attr_defs(self): return [ BoolDef("farm", label="Submitting to Farm", default=False) ] def get_pre_create_attr_defs(self): attrs = super().get_pre_create_attr_defs() # Use same attributes as for instance attributes return attrs + self.get_instance_attr_defs() ================================================ FILE: openpype/hosts/houdini/plugins/create/create_arnold_rop.py ================================================ from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef, BoolDef class CreateArnoldRop(plugin.HoudiniCreator): """Arnold ROP""" identifier = "io.openpype.creators.houdini.arnold_rop" label = "Arnold ROP" family = "arnold_rop" icon = "magic" # Default extension ext = "exr" # Default to split export and render jobs export_job = True def create(self, subset_name, instance_data, pre_create_data): import hou # Remove the active, we are checking the bypass flag of the nodes instance_data.pop("active", None) instance_data.update({"node_type": "arnold"}) # Add chunk size attribute instance_data["chunkSize"] = 1 # Submit for job publishing instance_data["farm"] = pre_create_data.get("farm") instance = super(CreateArnoldRop, self).create( subset_name, instance_data, pre_create_data) # type: plugin.CreatedInstance instance_node = hou.node(instance.get("instance_node")) ext = pre_create_data.get("image_format") filepath = "{renders_dir}{subset_name}/{subset_name}.$F4.{ext}".format( renders_dir=hou.text.expandString("$HIP/pyblish/renders/"), subset_name=subset_name, ext=ext, ) parms = { # Render frame range "trange": 1, # Arnold ROP settings "ar_picture": filepath, "ar_exr_half_precision": 1 # half precision } if pre_create_data.get("export_job"): ass_filepath = \ "{export_dir}{subset_name}/{subset_name}.$F4.ass".format( export_dir=hou.text.expandString("$HIP/pyblish/ass/"), subset_name=subset_name, ) parms["ar_ass_export_enable"] = 1 parms["ar_ass_file"] = ass_filepath instance_node.setParms(parms) # Lock any parameters in this list to_lock = ["family", "id"] self.lock_parameters(instance_node, to_lock) def get_pre_create_attr_defs(self): attrs = super(CreateArnoldRop, self).get_pre_create_attr_defs() image_format_enum = [ "bmp", "cin", "exr", "jpg", "pic", "pic.gz", "png", "rad", "rat", "rta", "sgi", "tga", "tif", ] return attrs + [ BoolDef("farm", label="Submitting to Farm", default=True), BoolDef("export_job", label="Split export and render jobs", default=self.export_job), EnumDef("image_format", image_format_enum, default=self.ext, label="Image Format Options") ] ================================================ FILE: openpype/hosts/houdini/plugins/create/create_bgeo.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating pointcache bgeo files.""" from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance, CreatorError import hou from openpype.lib import EnumDef, BoolDef class CreateBGEO(plugin.HoudiniCreator): """BGEO pointcache creator.""" identifier = "io.openpype.creators.houdini.bgeo" label = "PointCache (Bgeo)" family = "pointcache" icon = "gears" def create(self, subset_name, instance_data, pre_create_data): instance_data.pop("active", None) instance_data.update({"node_type": "geometry"}) creator_attributes = instance_data.setdefault( "creator_attributes", dict()) creator_attributes["farm"] = pre_create_data["farm"] instance = super(CreateBGEO, self).create( subset_name, instance_data, pre_create_data) # type: CreatedInstance instance_node = hou.node(instance.get("instance_node")) file_path = "{}{}".format( hou.text.expandString("$HIP/pyblish/"), "{}.$F4.{}".format( subset_name, pre_create_data.get("bgeo_type") or "bgeo.sc") ) parms = { "sopoutput": file_path } instance_node.parm("trange").set(1) if self.selected_nodes: # if selection is on SOP level, use it if isinstance(self.selected_nodes[0], hou.SopNode): parms["soppath"] = self.selected_nodes[0].path() else: # try to find output node with the lowest index outputs = [ child for child in self.selected_nodes[0].children() if child.type().name() == "output" ] if not outputs: instance_node.setParms(parms) raise CreatorError(( "Missing output node in SOP level for the selection. " "Please select correct SOP path in created instance." )) outputs.sort(key=lambda output: output.evalParm("outputidx")) parms["soppath"] = outputs[0].path() instance_node.setParms(parms) def get_instance_attr_defs(self): return [ BoolDef("farm", label="Submitting to Farm", default=False) ] def get_pre_create_attr_defs(self): attrs = super().get_pre_create_attr_defs() bgeo_enum = [ { "value": "bgeo", "label": "uncompressed bgeo (.bgeo)" }, { "value": "bgeosc", "label": "BLOSC compressed bgeo (.bgeosc)" }, { "value": "bgeo.sc", "label": "BLOSC compressed bgeo (.bgeo.sc)" }, { "value": "bgeo.gz", "label": "GZ compressed bgeo (.bgeo.gz)" }, { "value": "bgeo.lzma", "label": "LZMA compressed bgeo (.bgeo.lzma)" }, { "value": "bgeo.bz2", "label": "BZip2 compressed bgeo (.bgeo.bz2)" } ] return attrs + [ EnumDef("bgeo_type", bgeo_enum, label="BGEO Options"), ] + self.get_instance_attr_defs() def get_network_categories(self): return [ hou.ropNodeTypeCategory(), hou.sopNodeTypeCategory() ] ================================================ FILE: openpype/hosts/houdini/plugins/create/create_composite.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating composite sequences.""" from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance, CreatorError import hou class CreateCompositeSequence(plugin.HoudiniCreator): """Composite ROP to Image Sequence""" identifier = "io.openpype.creators.houdini.imagesequence" label = "Composite (Image Sequence)" family = "imagesequence" icon = "gears" ext = ".exr" def create(self, subset_name, instance_data, pre_create_data): import hou # noqa instance_data.pop("active", None) instance_data.update({"node_type": "comp"}) instance = super(CreateCompositeSequence, self).create( subset_name, instance_data, pre_create_data) # type: CreatedInstance instance_node = hou.node(instance.get("instance_node")) filepath = "{}{}".format( hou.text.expandString("$HIP/pyblish/"), "{}.$F4{}".format(subset_name, self.ext) ) parms = { "trange": 1, "copoutput": filepath } if self.selected_nodes: if len(self.selected_nodes) > 1: raise CreatorError("More than one item selected.") path = self.selected_nodes[0].path() parms["coppath"] = path instance_node.setParms(parms) # Manually set f1 & f2 to $FSTART and $FEND respectively # to match other Houdini nodes default. instance_node.parm("f1").setExpression("$FSTART") instance_node.parm("f2").setExpression("$FEND") # Lock any parameters in this list to_lock = ["prim_to_detail_pattern"] self.lock_parameters(instance_node, to_lock) def get_network_categories(self): return [ hou.ropNodeTypeCategory(), hou.cop2NodeTypeCategory() ] ================================================ FILE: openpype/hosts/houdini/plugins/create/create_hda.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating publishable Houdini Digital Assets.""" from openpype.client import ( get_asset_by_name, get_subsets, ) from openpype.hosts.houdini.api import plugin import hou class CreateHDA(plugin.HoudiniCreator): """Publish Houdini Digital Asset file.""" identifier = "io.openpype.creators.houdini.hda" label = "Houdini Digital Asset (Hda)" family = "hda" icon = "gears" maintain_selection = False def _check_existing(self, asset_name, subset_name): # type: (str) -> bool """Check if existing subset name versions already exists.""" # Get all subsets of the current asset project_name = self.project_name asset_doc = get_asset_by_name( project_name, asset_name, fields=["_id"] ) subset_docs = get_subsets( project_name, asset_ids=[asset_doc["_id"]], fields=["name"] ) existing_subset_names_low = { subset_doc["name"].lower() for subset_doc in subset_docs } return subset_name.lower() in existing_subset_names_low def create_instance_node( self, asset_name, node_name, parent, node_type="geometry" ): parent_node = hou.node("/obj") if self.selected_nodes: # if we have `use selection` enabled, and we have some # selected nodes ... subnet = parent_node.collapseIntoSubnet( self.selected_nodes, subnet_name="{}_subnet".format(node_name)) subnet.moveToGoodPosition() to_hda = subnet else: to_hda = parent_node.createNode( "subnet", node_name="{}_subnet".format(node_name)) if not to_hda.type().definition(): # if node type has not its definition, it is not user # created hda. We test if hda can be created from the node. if not to_hda.canCreateDigitalAsset(): raise plugin.OpenPypeCreatorError( "cannot create hda from node {}".format(to_hda)) hda_node = to_hda.createDigitalAsset( name=node_name, hda_file_name="$HIP/{}.hda".format(node_name) ) hda_node.layoutChildren() elif self._check_existing(asset_name, node_name): raise plugin.OpenPypeCreatorError( ("subset {} is already published with different HDA" "definition.").format(node_name)) else: hda_node = to_hda hda_node.setName(node_name) self.customize_node_look(hda_node) return hda_node def create(self, subset_name, instance_data, pre_create_data): instance_data.pop("active", None) instance = super(CreateHDA, self).create( subset_name, instance_data, pre_create_data) # type: plugin.CreatedInstance return instance def get_network_categories(self): return [ hou.objNodeTypeCategory() ] ================================================ FILE: openpype/hosts/houdini/plugins/create/create_karma_rop.py ================================================ # -*- coding: utf-8 -*- """Creator plugin to create Karma ROP.""" from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance from openpype.lib import BoolDef, EnumDef, NumberDef class CreateKarmaROP(plugin.HoudiniCreator): """Karma ROP""" identifier = "io.openpype.creators.houdini.karma_rop" label = "Karma ROP" family = "karma_rop" icon = "magic" def create(self, subset_name, instance_data, pre_create_data): import hou # noqa instance_data.pop("active", None) instance_data.update({"node_type": "karma"}) # Add chunk size attribute instance_data["chunkSize"] = 10 # Submit for job publishing instance_data["farm"] = pre_create_data.get("farm") instance = super(CreateKarmaROP, self).create( subset_name, instance_data, pre_create_data) # type: CreatedInstance instance_node = hou.node(instance.get("instance_node")) ext = pre_create_data.get("image_format") filepath = "{renders_dir}{subset_name}/{subset_name}.$F4.{ext}".format( renders_dir=hou.text.expandString("$HIP/pyblish/renders/"), subset_name=subset_name, ext=ext, ) checkpoint = "{cp_dir}{subset_name}.$F4.checkpoint".format( cp_dir=hou.text.expandString("$HIP/pyblish/"), subset_name=subset_name ) usd_directory = "{usd_dir}{subset_name}_$RENDERID".format( usd_dir=hou.text.expandString("$HIP/pyblish/renders/usd_renders/"), # noqa subset_name=subset_name ) parms = { # Render Frame Range "trange": 1, # Karma ROP Setting "picture": filepath, # Karma Checkpoint Setting "productName": checkpoint, # USD Output Directory "savetodirectory": usd_directory, } res_x = pre_create_data.get("res_x") res_y = pre_create_data.get("res_y") if self.selected_nodes: # If camera found in selection # we will use as render camera camera = None for node in self.selected_nodes: if node.type().name() == "cam": camera = node.path() has_camera = pre_create_data.get("cam_res") if has_camera: res_x = node.evalParm("resx") res_y = node.evalParm("resy") if not camera: self.log.warning("No render camera found in selection") parms.update({ "camera": camera or "", "resolutionx": res_x, "resolutiony": res_y, }) instance_node.setParms(parms) # Lock some Avalon attributes to_lock = ["family", "id"] self.lock_parameters(instance_node, to_lock) def get_pre_create_attr_defs(self): attrs = super(CreateKarmaROP, self).get_pre_create_attr_defs() image_format_enum = [ "bmp", "cin", "exr", "jpg", "pic", "pic.gz", "png", "rad", "rat", "rta", "sgi", "tga", "tif", ] return attrs + [ BoolDef("farm", label="Submitting to Farm", default=True), EnumDef("image_format", image_format_enum, default="exr", label="Image Format Options"), NumberDef("res_x", label="width", default=1920, decimals=0), NumberDef("res_y", label="height", default=720, decimals=0), BoolDef("cam_res", label="Camera Resolution", default=False) ] ================================================ FILE: openpype/hosts/houdini/plugins/create/create_mantra_ifd.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating pointcache alembics.""" from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance from openpype.lib import BoolDef class CreateMantraIFD(plugin.HoudiniCreator): """Mantra .ifd Archive""" identifier = "io.openpype.creators.houdini.mantraifd" label = "Mantra IFD" family = "mantraifd" icon = "gears" def create(self, subset_name, instance_data, pre_create_data): import hou instance_data.pop("active", None) instance_data.update({"node_type": "ifd"}) creator_attributes = instance_data.setdefault( "creator_attributes", dict()) creator_attributes["farm"] = pre_create_data["farm"] instance = super(CreateMantraIFD, self).create( subset_name, instance_data, pre_create_data) # type: CreatedInstance instance_node = hou.node(instance.get("instance_node")) filepath = "{}{}".format( hou.text.expandString("$HIP/pyblish/"), "{}.$F4.ifd".format(subset_name)) parms = { # Render frame range "trange": 1, # Arnold ROP settings "soho_diskfile": filepath, "soho_outputmode": 1 } instance_node.setParms(parms) # Lock any parameters in this list to_lock = ["soho_outputmode", "family", "id"] self.lock_parameters(instance_node, to_lock) def get_instance_attr_defs(self): return [ BoolDef("farm", label="Submitting to Farm", default=False) ] def get_pre_create_attr_defs(self): attrs = super().get_pre_create_attr_defs() # Use same attributes as for instance attributes return attrs + self.get_instance_attr_defs() ================================================ FILE: openpype/hosts/houdini/plugins/create/create_mantra_rop.py ================================================ # -*- coding: utf-8 -*- """Creator plugin to create Mantra ROP.""" from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance from openpype.lib import EnumDef, BoolDef class CreateMantraROP(plugin.HoudiniCreator): """Mantra ROP""" identifier = "io.openpype.creators.houdini.mantra_rop" label = "Mantra ROP" family = "mantra_rop" icon = "magic" # Default to split export and render jobs export_job = True def create(self, subset_name, instance_data, pre_create_data): import hou # noqa instance_data.pop("active", None) instance_data.update({"node_type": "ifd"}) # Add chunk size attribute instance_data["chunkSize"] = 10 # Submit for job publishing instance_data["farm"] = pre_create_data.get("farm") instance = super(CreateMantraROP, self).create( subset_name, instance_data, pre_create_data) # type: CreatedInstance instance_node = hou.node(instance.get("instance_node")) ext = pre_create_data.get("image_format") filepath = "{renders_dir}{subset_name}/{subset_name}.$F4.{ext}".format( renders_dir=hou.text.expandString("$HIP/pyblish/renders/"), subset_name=subset_name, ext=ext, ) parms = { # Render Frame Range "trange": 1, # Mantra ROP Setting "vm_picture": filepath, } if pre_create_data.get("export_job"): ifd_filepath = \ "{export_dir}{subset_name}/{subset_name}.$F4.ifd".format( export_dir=hou.text.expandString("$HIP/pyblish/ifd/"), subset_name=subset_name, ) parms["soho_outputmode"] = 1 parms["soho_diskfile"] = ifd_filepath if self.selected_nodes: # If camera found in selection # we will use as render camera camera = None for node in self.selected_nodes: if node.type().name() == "cam": camera = node.path() if not camera: self.log.warning("No render camera found in selection") parms.update({"camera": camera or ""}) custom_res = pre_create_data.get("override_resolution") if custom_res: parms.update({"override_camerares": 1}) instance_node.setParms(parms) # Lock some Avalon attributes to_lock = ["family", "id"] self.lock_parameters(instance_node, to_lock) def get_pre_create_attr_defs(self): attrs = super(CreateMantraROP, self).get_pre_create_attr_defs() image_format_enum = [ "bmp", "cin", "exr", "jpg", "pic", "pic.gz", "png", "rad", "rat", "rta", "sgi", "tga", "tif", ] return attrs + [ BoolDef("farm", label="Submitting to Farm", default=True), BoolDef("export_job", label="Split export and render jobs", default=self.export_job), EnumDef("image_format", image_format_enum, default="exr", label="Image Format Options"), BoolDef("override_resolution", label="Override Camera Resolution", tooltip="Override the current camera " "resolution, recommended for IPR.", default=False) ] ================================================ FILE: openpype/hosts/houdini/plugins/create/create_pointcache.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating pointcache alembics.""" from openpype.hosts.houdini.api import plugin from openpype.lib import BoolDef import hou class CreatePointCache(plugin.HoudiniCreator): """Alembic ROP to pointcache""" identifier = "io.openpype.creators.houdini.pointcache" label = "PointCache (Abc)" family = "pointcache" icon = "gears" def create(self, subset_name, instance_data, pre_create_data): instance_data.pop("active", None) instance_data.update({"node_type": "alembic"}) creator_attributes = instance_data.setdefault( "creator_attributes", dict()) creator_attributes["farm"] = pre_create_data["farm"] instance = super(CreatePointCache, self).create( subset_name, instance_data, pre_create_data) instance_node = hou.node(instance.get("instance_node")) parms = { "use_sop_path": True, "build_from_path": True, "path_attrib": "path", "prim_to_detail_pattern": "cbId", "format": 2, "facesets": 0, "filename": hou.text.expandString( "$HIP/pyblish/{}.abc".format(subset_name)) } if self.selected_nodes: selected_node = self.selected_nodes[0] # Although Houdini allows ObjNode path on `sop_path` for the # the ROP node we prefer it set to the SopNode path explicitly # Allow sop level paths (e.g. /obj/geo1/box1) if isinstance(selected_node, hou.SopNode): parms["sop_path"] = selected_node.path() self.log.debug( "Valid SopNode selection, 'SOP Path' in ROP will be set to '%s'." % selected_node.path() ) # Allow object level paths to Geometry nodes (e.g. /obj/geo1) # but do not allow other object level nodes types like cameras, etc. elif isinstance(selected_node, hou.ObjNode) and \ selected_node.type().name() in ["geo"]: # get the output node with the minimum # 'outputidx' or the node with display flag sop_path = self.get_obj_output(selected_node) if sop_path: parms["sop_path"] = sop_path.path() self.log.debug( "Valid ObjNode selection, 'SOP Path' in ROP will be set to " "the child path '%s'." % sop_path.path() ) if not parms.get("sop_path", None): self.log.debug( "Selection isn't valid. 'SOP Path' in ROP will be empty." ) else: self.log.debug( "No Selection. 'SOP Path' in ROP will be empty." ) instance_node.setParms(parms) instance_node.parm("trange").set(1) # Lock any parameters in this list to_lock = ["prim_to_detail_pattern"] self.lock_parameters(instance_node, to_lock) def get_network_categories(self): return [ hou.ropNodeTypeCategory(), hou.sopNodeTypeCategory() ] def get_obj_output(self, obj_node): """Find output node with the smallest 'outputidx'.""" outputs = obj_node.subnetOutputs() # if obj_node is empty if not outputs: return # if obj_node has one output child whether its # sop output node or a node with the render flag elif len(outputs) == 1: return outputs[0] # if there are more than one, then it have multiple ouput nodes # return the one with the minimum 'outputidx' else: return min(outputs, key=lambda node: node.evalParm('outputidx')) def get_instance_attr_defs(self): return [ BoolDef("farm", label="Submitting to Farm", default=False) ] def get_pre_create_attr_defs(self): attrs = super().get_pre_create_attr_defs() # Use same attributes as for instance attributes return attrs + self.get_instance_attr_defs() ================================================ FILE: openpype/hosts/houdini/plugins/create/create_redshift_proxy.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating Redshift proxies.""" from openpype.hosts.houdini.api import plugin import hou from openpype.lib import BoolDef class CreateRedshiftProxy(plugin.HoudiniCreator): """Redshift Proxy""" identifier = "io.openpype.creators.houdini.redshiftproxy" label = "Redshift Proxy" family = "redshiftproxy" icon = "magic" def create(self, subset_name, instance_data, pre_create_data): # Remove the active, we are checking the bypass flag of the nodes instance_data.pop("active", None) # Redshift provides a `Redshift_Proxy_Output` node type which shows # a limited set of parameters by default and is set to extract a # Redshift Proxy. However when "imprinting" extra parameters needed # for OpenPype it starts showing all its parameters again. It's unclear # why this happens. # TODO: Somehow enforce so that it only shows the original limited # attributes of the Redshift_Proxy_Output node type instance_data.update({"node_type": "Redshift_Proxy_Output"}) creator_attributes = instance_data.setdefault( "creator_attributes", dict()) creator_attributes["farm"] = pre_create_data["farm"] instance = super(CreateRedshiftProxy, self).create( subset_name, instance_data, pre_create_data) instance_node = hou.node(instance.get("instance_node")) parms = { "RS_archive_file": '$HIP/pyblish/{}.$F4.rs'.format(subset_name), } if self.selected_nodes: parms["RS_archive_sopPath"] = self.selected_nodes[0].path() instance_node.setParms(parms) # Lock some Avalon attributes to_lock = ["family", "id", "prim_to_detail_pattern"] self.lock_parameters(instance_node, to_lock) def get_network_categories(self): return [ hou.ropNodeTypeCategory(), hou.sopNodeTypeCategory() ] def get_instance_attr_defs(self): return [ BoolDef("farm", label="Submitting to Farm", default=False) ] def get_pre_create_attr_defs(self): attrs = super().get_pre_create_attr_defs() # Use same attributes as for instance attributes return attrs + self.get_instance_attr_defs() ================================================ FILE: openpype/hosts/houdini/plugins/create/create_redshift_rop.py ================================================ # -*- coding: utf-8 -*- """Creator plugin to create Redshift ROP.""" import hou # noqa from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef, BoolDef class CreateRedshiftROP(plugin.HoudiniCreator): """Redshift ROP""" identifier = "io.openpype.creators.houdini.redshift_rop" label = "Redshift ROP" family = "redshift_rop" icon = "magic" ext = "exr" # Default to split export and render jobs split_render = True def create(self, subset_name, instance_data, pre_create_data): instance_data.pop("active", None) instance_data.update({"node_type": "Redshift_ROP"}) # Add chunk size attribute instance_data["chunkSize"] = 10 # Submit for job publishing instance_data["farm"] = pre_create_data.get("farm") instance = super(CreateRedshiftROP, self).create( subset_name, instance_data, pre_create_data) instance_node = hou.node(instance.get("instance_node")) basename = instance_node.name() # Also create the linked Redshift IPR Rop try: ipr_rop = instance_node.parent().createNode( "Redshift_IPR", node_name=f"{basename}_IPR" ) except hou.OperationFailed as e: raise plugin.OpenPypeCreatorError( ( "Cannot create Redshift node. Is Redshift " "installed and enabled?" ) ) from e # Move it to directly under the Redshift ROP ipr_rop.setPosition(instance_node.position() + hou.Vector2(0, -1)) # Set the linked rop to the Redshift ROP ipr_rop.parm("linked_rop").set(instance_node.path()) ext = pre_create_data.get("image_format") filepath = "{renders_dir}{subset_name}/{subset_name}.{fmt}".format( renders_dir=hou.text.expandString("$HIP/pyblish/renders/"), subset_name=subset_name, fmt="${aov}.$F4.{ext}".format(aov="AOV", ext=ext) ) ext_format_index = {"exr": 0, "tif": 1, "jpg": 2, "png": 3} parms = { # Render frame range "trange": 1, # Redshift ROP settings "RS_outputFileNamePrefix": filepath, "RS_outputMultilayerMode": "1", # no multi-layered exr "RS_outputBeautyAOVSuffix": "beauty", "RS_outputFileFormat": ext_format_index[ext], } if self.selected_nodes: # set up the render camera from the selected node camera = None for node in self.selected_nodes: if node.type().name() == "cam": camera = node.path() parms["RS_renderCamera"] = camera or "" export_dir = hou.text.expandString("$HIP/pyblish/rs/") rs_filepath = f"{export_dir}{subset_name}/{subset_name}.$F4.rs" parms["RS_archive_file"] = rs_filepath if pre_create_data.get("split_render", self.split_render): parms["RS_archive_enable"] = 1 instance_node.setParms(parms) # Lock some Avalon attributes to_lock = ["family", "id"] self.lock_parameters(instance_node, to_lock) def remove_instances(self, instances): for instance in instances: node = instance.data.get("instance_node") ipr_node = hou.node(f"{node}_IPR") if ipr_node: ipr_node.destroy() return super(CreateRedshiftROP, self).remove_instances(instances) def get_pre_create_attr_defs(self): attrs = super(CreateRedshiftROP, self).get_pre_create_attr_defs() image_format_enum = [ "exr", "tif", "jpg", "png", ] return attrs + [ BoolDef("farm", label="Submitting to Farm", default=True), BoolDef("split_render", label="Split export and render jobs", default=self.split_render), EnumDef("image_format", image_format_enum, default=self.ext, label="Image Format Options") ] ================================================ FILE: openpype/hosts/houdini/plugins/create/create_review.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating openGL reviews.""" from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef, BoolDef, NumberDef import os import hou class CreateReview(plugin.HoudiniCreator): """Review with OpenGL ROP""" identifier = "io.openpype.creators.houdini.review" label = "Review" family = "review" icon = "video-camera" def create(self, subset_name, instance_data, pre_create_data): instance_data.pop("active", None) instance_data.update({"node_type": "opengl"}) instance_data["imageFormat"] = pre_create_data.get("imageFormat") instance_data["keepImages"] = pre_create_data.get("keepImages") instance = super(CreateReview, self).create( subset_name, instance_data, pre_create_data) instance_node = hou.node(instance.get("instance_node")) frame_range = hou.playbar.frameRange() filepath = "{root}/{subset}/{subset}.$F4.{ext}".format( root=hou.text.expandString("$HIP/pyblish"), subset="`chs(\"subset\")`", # keep dynamic link to subset ext=pre_create_data.get("image_format") or "png" ) parms = { "picture": filepath, "trange": 1, # Unlike many other ROP nodes the opengl node does not default # to expression of $FSTART and $FEND so we preserve that behavior # but do set the range to the frame range of the playbar "f1": frame_range[0], "f2": frame_range[1], } override_resolution = pre_create_data.get("override_resolution") if override_resolution: parms.update({ "tres": override_resolution, "res1": pre_create_data.get("resx"), "res2": pre_create_data.get("resy"), "aspect": pre_create_data.get("aspect"), }) if self.selected_nodes: # The first camera found in selection we will use as camera # Other node types we set in force objects camera = None force_objects = [] for node in self.selected_nodes: path = node.path() if node.type().name() == "cam": if camera: continue camera = path else: force_objects.append(path) if not camera: self.log.warning("No camera found in selection.") parms.update({ "camera": camera or "", "scenepath": "/obj", "forceobjects": " ".join(force_objects), "vobjects": "" # clear candidate objects from '*' value }) instance_node.setParms(parms) # Set OCIO Colorspace to the default output colorspace # if there's OCIO if os.getenv("OCIO"): self.set_colorcorrect_to_default_view_space(instance_node) to_lock = ["id", "family"] self.lock_parameters(instance_node, to_lock) def get_pre_create_attr_defs(self): attrs = super(CreateReview, self).get_pre_create_attr_defs() image_format_enum = [ "bmp", "cin", "exr", "jpg", "pic", "pic.gz", "png", "rad", "rat", "rta", "sgi", "tga", "tif", ] return attrs + [ BoolDef("keepImages", label="Keep Image Sequences", default=False), EnumDef("imageFormat", image_format_enum, default="png", label="Image Format Options"), BoolDef("override_resolution", label="Override resolution", tooltip="When disabled the resolution set on the camera " "is used instead.", default=True), NumberDef("resx", label="Resolution Width", default=1280, minimum=2, decimals=0), NumberDef("resy", label="Resolution Height", default=720, minimum=2, decimals=0), NumberDef("aspect", label="Aspect Ratio", default=1.0, minimum=0.0001, decimals=3) ] def set_colorcorrect_to_default_view_space(self, instance_node): """Set ociocolorspace to the default output space.""" from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa # set Color Correction parameter to OpenColorIO instance_node.setParms({"colorcorrect": 2}) # Get default view space for ociocolorspace parm. default_view_space = get_default_display_view_colorspace() instance_node.setParms( {"ociocolorspace": default_view_space} ) self.log.debug( "'OCIO Colorspace' parm on '{}' has been set to " "the default view color space '{}'" .format(instance_node, default_view_space) ) ================================================ FILE: openpype/hosts/houdini/plugins/create/create_staticmesh.py ================================================ # -*- coding: utf-8 -*- """Creator for Unreal Static Meshes.""" from openpype.hosts.houdini.api import plugin from openpype.lib import BoolDef, EnumDef import hou class CreateStaticMesh(plugin.HoudiniCreator): """Static Meshes as FBX. """ identifier = "io.openpype.creators.houdini.staticmesh.fbx" label = "Static Mesh (FBX)" family = "staticMesh" icon = "fa5s.cubes" default_variants = ["Main"] def create(self, subset_name, instance_data, pre_create_data): instance_data.update({"node_type": "filmboxfbx"}) instance = super(CreateStaticMesh, self).create( subset_name, instance_data, pre_create_data) # get the created rop node instance_node = hou.node(instance.get("instance_node")) # prepare parms output_path = hou.text.expandString( "$HIP/pyblish/{}.fbx".format(subset_name) ) parms = { "startnode": self.get_selection(), "sopoutput": output_path, # vertex cache format "vcformat": pre_create_data.get("vcformat"), "convertunits": pre_create_data.get("convertunits"), # set render range to use frame range start-end frame "trange": 1, "createsubnetroot": pre_create_data.get("createsubnetroot") } # set parms instance_node.setParms(parms) # Lock any parameters in this list to_lock = ["family", "id"] self.lock_parameters(instance_node, to_lock) def get_network_categories(self): return [ hou.ropNodeTypeCategory(), hou.objNodeTypeCategory(), hou.sopNodeTypeCategory() ] def get_pre_create_attr_defs(self): """Add settings for users. """ attrs = super(CreateStaticMesh, self).get_pre_create_attr_defs() createsubnetroot = BoolDef("createsubnetroot", tooltip="Create an extra root for the " "Export node when it's a " "subnetwork. This causes the " "exporting subnetwork node to be " "represented in the FBX file.", default=False, label="Create Root for Subnet") vcformat = EnumDef("vcformat", items={ 0: "Maya Compatible (MC)", 1: "3DS MAX Compatible (PC2)" }, default=0, label="Vertex Cache Format") convert_units = BoolDef("convertunits", tooltip="When on, the FBX is converted" "from the current Houdini " "system units to the native " "FBX unit of centimeters.", default=False, label="Convert Units") return attrs + [createsubnetroot, vcformat, convert_units] def get_dynamic_data( self, variant, task_name, asset_doc, project_name, host_name, instance ): """ The default subset name templates for Unreal include {asset} and thus we should pass that along as dynamic data. """ dynamic_data = super(CreateStaticMesh, self).get_dynamic_data( variant, task_name, asset_doc, project_name, host_name, instance ) dynamic_data["asset"] = asset_doc["name"] return dynamic_data def get_selection(self): """Selection Logic. how self.selected_nodes should be processed to get the desirable node from selection. Returns: str : node path """ selection = "" if self.selected_nodes: selected_node = self.selected_nodes[0] # Accept sop level nodes (e.g. /obj/geo1/box1) if isinstance(selected_node, hou.SopNode): selection = selected_node.path() self.log.debug( "Valid SopNode selection, 'Export' in filmboxfbx" " will be set to '%s'.", selected_node ) # Accept object level nodes (e.g. /obj/geo1) elif isinstance(selected_node, hou.ObjNode): selection = selected_node.path() self.log.debug( "Valid ObjNode selection, 'Export' in filmboxfbx " "will be set to the child path '%s'.", selection ) else: self.log.debug( "Selection isn't valid. 'Export' in " "filmboxfbx will be empty." ) else: self.log.debug( "No Selection. 'Export' in filmboxfbx will be empty." ) return selection ================================================ FILE: openpype/hosts/houdini/plugins/create/create_usd.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating USDs.""" from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance import hou class CreateUSD(plugin.HoudiniCreator): """Universal Scene Description""" identifier = "io.openpype.creators.houdini.usd" label = "USD (experimental)" family = "usd" icon = "gears" enabled = False def create(self, subset_name, instance_data, pre_create_data): instance_data.pop("active", None) instance_data.update({"node_type": "usd"}) instance = super(CreateUSD, self).create( subset_name, instance_data, pre_create_data) # type: CreatedInstance instance_node = hou.node(instance.get("instance_node")) parms = { "lopoutput": "$HIP/pyblish/{}.usd".format(subset_name), "enableoutputprocessor_simplerelativepaths": False, } if self.selected_nodes: parms["loppath"] = self.selected_nodes[0].path() instance_node.setParms(parms) # Lock any parameters in this list to_lock = [ "fileperframe", # Lock some Avalon attributes "family", "id", ] self.lock_parameters(instance_node, to_lock) def get_network_categories(self): return [ hou.ropNodeTypeCategory(), hou.lopNodeTypeCategory() ] ================================================ FILE: openpype/hosts/houdini/plugins/create/create_usdrender.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating USD renders.""" from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance class CreateUSDRender(plugin.HoudiniCreator): """USD Render ROP in /stage""" identifier = "io.openpype.creators.houdini.usdrender" label = "USD Render (experimental)" family = "usdrender" icon = "magic" def create(self, subset_name, instance_data, pre_create_data): import hou # noqa instance_data["parent"] = hou.node("/stage") # Remove the active, we are checking the bypass flag of the nodes instance_data.pop("active", None) instance_data.update({"node_type": "usdrender"}) instance = super(CreateUSDRender, self).create( subset_name, instance_data, pre_create_data) # type: CreatedInstance instance_node = hou.node(instance.get("instance_node")) parms = { # Render frame range "trange": 1 } if self.selected_nodes: parms["loppath"] = self.selected_nodes[0].path() instance_node.setParms(parms) # Lock some Avalon attributes to_lock = ["family", "id"] self.lock_parameters(instance_node, to_lock) ================================================ FILE: openpype/hosts/houdini/plugins/create/create_vbd_cache.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating VDB Caches.""" from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance from openpype.lib import BoolDef import hou class CreateVDBCache(plugin.HoudiniCreator): """OpenVDB from Geometry ROP""" identifier = "io.openpype.creators.houdini.vdbcache" name = "vbdcache" label = "VDB Cache" family = "vdbcache" icon = "cloud" def create(self, subset_name, instance_data, pre_create_data): import hou instance_data.pop("active", None) instance_data.update({"node_type": "geometry"}) creator_attributes = instance_data.setdefault( "creator_attributes", dict()) creator_attributes["farm"] = pre_create_data["farm"] instance = super(CreateVDBCache, self).create( subset_name, instance_data, pre_create_data) # type: CreatedInstance instance_node = hou.node(instance.get("instance_node")) file_path = "{}{}".format( hou.text.expandString("$HIP/pyblish/"), "{}.$F4.vdb".format(subset_name)) parms = { "sopoutput": file_path, "initsim": True, "trange": 1 } if self.selected_nodes: parms["soppath"] = self.get_sop_node_path(self.selected_nodes[0]) instance_node.setParms(parms) def get_network_categories(self): return [ hou.ropNodeTypeCategory(), hou.objNodeTypeCategory(), hou.sopNodeTypeCategory() ] def get_sop_node_path(self, selected_node): """Get Sop Path of the selected node. Although Houdini allows ObjNode path on `sop_path` for the the ROP node, we prefer it set to the SopNode path explicitly. """ # Allow sop level paths (e.g. /obj/geo1/box1) if isinstance(selected_node, hou.SopNode): self.log.debug( "Valid SopNode selection, 'SOP Path' in ROP will" " be set to '%s'.", selected_node.path() ) return selected_node.path() # Allow object level paths to Geometry nodes (e.g. /obj/geo1) # but do not allow other object level nodes types like cameras, etc. elif isinstance(selected_node, hou.ObjNode) and \ selected_node.type().name() == "geo": # Try to find output node. sop_node = self.get_obj_output(selected_node) if sop_node: self.log.debug( "Valid ObjNode selection, 'SOP Path' in ROP will " "be set to the child path '%s'.", sop_node.path() ) return sop_node.path() self.log.debug( "Selection isn't valid. 'SOP Path' in ROP will be empty." ) return "" def get_obj_output(self, obj_node): """Try to find output node. If any output nodes are present, return the output node with the minimum 'outputidx' If no output nodes are present, return the node with display flag If no nodes are present at all, return None """ outputs = obj_node.subnetOutputs() # if obj_node is empty if not outputs: return # if obj_node has one output child whether its # sop output node or a node with the render flag elif len(outputs) == 1: return outputs[0] # if there are more than one, then it has multiple output nodes # return the one with the minimum 'outputidx' else: return min(outputs, key=lambda node: node.evalParm('outputidx')) def get_instance_attr_defs(self): return [ BoolDef("farm", label="Submitting to Farm", default=False) ] def get_pre_create_attr_defs(self): attrs = super().get_pre_create_attr_defs() # Use same attributes as for instance attributes return attrs + self.get_instance_attr_defs() ================================================ FILE: openpype/hosts/houdini/plugins/create/create_vray_rop.py ================================================ # -*- coding: utf-8 -*- """Creator plugin to create VRay ROP.""" import hou from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance from openpype.lib import EnumDef, BoolDef class CreateVrayROP(plugin.HoudiniCreator): """VRay ROP""" identifier = "io.openpype.creators.houdini.vray_rop" label = "VRay ROP" family = "vray_rop" icon = "magic" ext = "exr" # Default to split export and render jobs export_job = True def create(self, subset_name, instance_data, pre_create_data): instance_data.pop("active", None) instance_data.update({"node_type": "vray_renderer"}) # Add chunk size attribute instance_data["chunkSize"] = 10 # Submit for job publishing instance_data["farm"] = pre_create_data.get("farm") instance = super(CreateVrayROP, self).create( subset_name, instance_data, pre_create_data) # type: CreatedInstance instance_node = hou.node(instance.get("instance_node")) # Add IPR for Vray basename = instance_node.name() try: ipr_rop = instance_node.parent().createNode( "vray", node_name=basename + "_IPR" ) except hou.OperationFailed: raise plugin.OpenPypeCreatorError( "Cannot create Vray render node. " "Make sure Vray installed and enabled!" ) ipr_rop.setPosition(instance_node.position() + hou.Vector2(0, -1)) ipr_rop.parm("rop").set(instance_node.path()) parms = { "trange": 1, "SettingsEXR_bits_per_channel": "16" # half precision } if pre_create_data.get("export_job"): scene_filepath = \ "{export_dir}{subset_name}/{subset_name}.$F4.vrscene".format( export_dir=hou.text.expandString("$HIP/pyblish/vrscene/"), subset_name=subset_name, ) # Setting render_export_mode to "2" because that's for # "Export only" ("1" is for "Export & Render") parms["render_export_mode"] = "2" parms["render_export_filepath"] = scene_filepath if self.selected_nodes: # set up the render camera from the selected node camera = None for node in self.selected_nodes: if node.type().name() == "cam": camera = node.path() parms.update({ "render_camera": camera or "" }) # Enable render element ext = pre_create_data.get("image_format") instance_data["RenderElement"] = pre_create_data.get("render_element_enabled") # noqa if pre_create_data.get("render_element_enabled", True): # Vray has its own tag for AOV file output filepath = "{renders_dir}{subset_name}/{subset_name}.{fmt}".format( renders_dir=hou.text.expandString("$HIP/pyblish/renders/"), subset_name=subset_name, fmt="${aov}.$F4.{ext}".format(aov="AOV", ext=ext) ) filepath = "{}{}".format( hou.text.expandString("$HIP/pyblish/renders/"), "{}/{}.${}.$F4.{}".format(subset_name, subset_name, "AOV", ext) ) re_rop = instance_node.parent().createNode( "vray_render_channels", node_name=basename + "_render_element" ) # move the render element node next to the vray renderer node re_rop.setPosition(instance_node.position() + hou.Vector2(0, 1)) re_path = re_rop.path() parms.update({ "use_render_channels": 1, "SettingsOutput_img_file_path": filepath, "render_network_render_channels": re_path }) else: filepath = "{renders_dir}{subset_name}/{subset_name}.{fmt}".format( renders_dir=hou.text.expandString("$HIP/pyblish/renders/"), subset_name=subset_name, fmt="$F4.{ext}".format(ext=ext) ) parms.update({ "use_render_channels": 0, "SettingsOutput_img_file_path": filepath }) custom_res = pre_create_data.get("override_resolution") if custom_res: parms.update({"override_camerares": 1}) instance_node.setParms(parms) # lock parameters from AVALON to_lock = ["family", "id"] self.lock_parameters(instance_node, to_lock) def remove_instances(self, instances): for instance in instances: node = instance.data.get("instance_node") # for the extra render node from the plugins # such as vray and redshift ipr_node = hou.node("{}{}".format(node, "_IPR")) if ipr_node: ipr_node.destroy() re_node = hou.node("{}{}".format(node, "_render_element")) if re_node: re_node.destroy() return super(CreateVrayROP, self).remove_instances(instances) def get_pre_create_attr_defs(self): attrs = super(CreateVrayROP, self).get_pre_create_attr_defs() image_format_enum = [ "bmp", "cin", "exr", "jpg", "pic", "pic.gz", "png", "rad", "rat", "rta", "sgi", "tga", "tif", ] return attrs + [ BoolDef("farm", label="Submitting to Farm", default=True), BoolDef("export_job", label="Split export and render jobs", default=self.export_job), EnumDef("image_format", image_format_enum, default=self.ext, label="Image Format Options"), BoolDef("override_resolution", label="Override Camera Resolution", tooltip="Override the current camera " "resolution, recommended for IPR.", default=False), BoolDef("render_element_enabled", label="Render Element", tooltip="Create Render Element Node " "if enabled", default=False) ] ================================================ FILE: openpype/hosts/houdini/plugins/create/create_workfile.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating workfiles.""" from openpype import AYON_SERVER_ENABLED from openpype.hosts.houdini.api import plugin from openpype.hosts.houdini.api.lib import read, imprint from openpype.hosts.houdini.api.pipeline import CONTEXT_CONTAINER from openpype.pipeline import CreatedInstance, AutoCreator from openpype.client import get_asset_by_name import hou class CreateWorkfile(plugin.HoudiniCreatorBase, AutoCreator): """Workfile auto-creator.""" identifier = "io.openpype.creators.houdini.workfile" label = "Workfile" family = "workfile" icon = "fa5.file" default_variant = "Main" def create(self): variant = self.default_variant current_instance = next( ( instance for instance in self.create_context.instances if instance.creator_identifier == self.identifier ), None) project_name = self.project_name asset_name = self.create_context.get_current_asset_name() task_name = self.create_context.get_current_task_name() host_name = self.host_name if current_instance is None: current_instance_asset = None elif AYON_SERVER_ENABLED: current_instance_asset = current_instance["folderPath"] else: current_instance_asset = current_instance["asset"] if current_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) data = { "task": task_name, "variant": variant } if AYON_SERVER_ENABLED: data["folderPath"] = asset_name else: data["asset"] = asset_name data.update( self.get_dynamic_data( variant, task_name, asset_doc, project_name, host_name, current_instance) ) self.log.info("Auto-creating workfile instance...") current_instance = CreatedInstance( self.family, subset_name, data, self ) self._add_instance_to_context(current_instance) elif ( current_instance_asset != asset_name or current_instance["task"] != task_name ): # Update instance context if is not the same asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) if AYON_SERVER_ENABLED: current_instance["folderPath"] = asset_name else: current_instance["asset"] = asset_name current_instance["task"] = task_name current_instance["subset"] = subset_name # write workfile information to context container. op_ctx = hou.node(CONTEXT_CONTAINER) if not op_ctx: op_ctx = self.create_context_node() workfile_data = {"workfile": current_instance.data_to_store()} imprint(op_ctx, workfile_data) def collect_instances(self): op_ctx = hou.node(CONTEXT_CONTAINER) instance = read(op_ctx) if not instance: return workfile = instance.get("workfile") if not workfile: return created_instance = CreatedInstance.from_existing( workfile, self ) self._add_instance_to_context(created_instance) def update_instances(self, update_list): op_ctx = hou.node(CONTEXT_CONTAINER) for created_inst, _changes in update_list: if created_inst["creator_identifier"] == self.identifier: workfile_data = {"workfile": created_inst.data_to_store()} imprint(op_ctx, workfile_data, update=True) ================================================ FILE: openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py ================================================ from openpype.pipeline import InventoryAction from openpype.hosts.houdini.api.lib import ( get_camera_from_container, set_camera_resolution ) from openpype.pipeline.context_tools import get_current_project_asset class SetCameraResolution(InventoryAction): label = "Set Camera Resolution" icon = "desktop" color = "orange" @staticmethod def is_compatible(container): return ( container.get("loader") == "CameraLoader" ) def process(self, containers): asset_doc = get_current_project_asset() for container in containers: node = container["node"] camera = get_camera_from_container(node) set_camera_resolution(camera, asset_doc) ================================================ FILE: openpype/hosts/houdini/plugins/load/actions.py ================================================ """A module containing generic loader actions that will display in the Loader. """ from openpype.pipeline import load class SetFrameRangeLoader(load.LoaderPlugin): """Set frame range excluding pre- and post-handles""" families = [ "animation", "camera", "pointcache", "vdbcache", "usd", ] representations = ["abc", "vdb", "usd"] label = "Set frame range" order = 11 icon = "clock-o" color = "white" def load(self, context, name, namespace, data): import hou version = context["version"] version_data = version.get("data", {}) start = version_data.get("frameStart", None) end = version_data.get("frameEnd", None) if start is None or end is None: print( "Skipping setting frame range because start or " "end frame data is missing.." ) return hou.playbar.setFrameRange(start, end) hou.playbar.setPlaybackRange(start, end) class SetFrameRangeWithHandlesLoader(load.LoaderPlugin): """Set frame range including pre- and post-handles""" families = [ "animation", "camera", "pointcache", "vdbcache", "usd", ] representations = ["abc", "vdb", "usd"] label = "Set frame range (with handles)" order = 12 icon = "clock-o" color = "white" def load(self, context, name, namespace, data): import hou version = context["version"] version_data = version.get("data", {}) start = version_data.get("frameStart", None) end = version_data.get("frameEnd", None) if start is None or end is None: print( "Skipping setting frame range because start or " "end frame data is missing.." ) return # Include handles start -= version_data.get("handleStart", 0) end += version_data.get("handleEnd", 0) hou.playbar.setFrameRange(start, end) hou.playbar.setPlaybackRange(start, end) ================================================ FILE: openpype/hosts/houdini/plugins/load/load_alembic.py ================================================ import os from openpype.pipeline import ( load, get_representation_path, ) from openpype.hosts.houdini.api import pipeline class AbcLoader(load.LoaderPlugin): """Load Alembic""" families = ["model", "animation", "pointcache", "gpuCache"] label = "Load Alembic" representations = ["abc"] order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): import hou # Format file name, Houdini only wants forward slashes file_path = self.filepath_from_context(context) file_path = os.path.normpath(file_path) file_path = file_path.replace("\\", "/") # Get the root node obj = hou.node("/obj") # Define node name namespace = namespace if namespace else context["asset"]["name"] node_name = "{}_{}".format(namespace, name) if namespace else name # Create a new geo node container = obj.createNode("geo", node_name=node_name) # Remove the file node, it only loads static meshes # Houdini 17 has removed the file node from the geo node file_node = container.node("file1") if file_node: file_node.destroy() # Create an alembic node (supports animation) alembic = container.createNode("alembic", node_name=node_name) alembic.setParms({"fileName": file_path}) # Add unpack node unpack_name = "unpack_{}".format(name) unpack = container.createNode("unpack", node_name=unpack_name) unpack.setInput(0, alembic) unpack.setParms({"transfer_attributes": "path"}) # Add normal to points # Order of menu ['point', 'vertex', 'prim', 'detail'] normal_name = "normal_{}".format(name) normal_node = container.createNode("normal", node_name=normal_name) normal_node.setParms({"type": 0}) normal_node.setInput(0, unpack) null = container.createNode("null", node_name="OUT".format(name)) null.setInput(0, normal_node) # Ensure display flag is on the Alembic input node and not on the OUT # node to optimize "debug" displaying in the viewport. alembic.setDisplayFlag(True) # Set new position for unpack node else it gets cluttered nodes = [container, alembic, unpack, normal_node, null] for nr, node in enumerate(nodes): node.setPosition([0, (0 - nr)]) self[:] = nodes return pipeline.containerise( node_name, namespace, nodes, context, self.__class__.__name__, suffix="", ) def update(self, container, representation): node = container["node"] try: alembic_node = next( n for n in node.children() if n.type().name() == "alembic" ) except StopIteration: self.log.error("Could not find node of type `alembic`") return # Update the file path file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") alembic_node.setParms({"fileName": file_path}) # Update attribute node.setParms({"representation": str(representation["_id"])}) def remove(self, container): node = container["node"] node.destroy() def switch(self, container, representation): self.update(container, representation) ================================================ FILE: openpype/hosts/houdini/plugins/load/load_alembic_archive.py ================================================ import os from openpype.pipeline import ( load, get_representation_path, ) from openpype.hosts.houdini.api import pipeline class AbcArchiveLoader(load.LoaderPlugin): """Load Alembic as full geometry network hierarchy """ families = ["model", "animation", "pointcache", "gpuCache"] label = "Load Alembic as Archive" representations = ["abc"] order = -5 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): import hou # Format file name, Houdini only wants forward slashes file_path = self.filepath_from_context(context) file_path = os.path.normpath(file_path) file_path = file_path.replace("\\", "/") # Get the root node obj = hou.node("/obj") # Define node name namespace = namespace if namespace else context["asset"]["name"] node_name = "{}_{}".format(namespace, name) if namespace else name # Create an Alembic archive node node = obj.createNode("alembicarchive", node_name=node_name) node.moveToGoodPosition() # TODO: add FPS of project / asset node.setParms({"fileName": file_path, "channelRef": True}) # Apply some magic node.parm("buildHierarchy").pressButton() node.moveToGoodPosition() nodes = [node] self[:] = nodes return pipeline.containerise(node_name, namespace, nodes, context, self.__class__.__name__, suffix="") def update(self, container, representation): node = container["node"] # Update the file path file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") # Update attributes node.setParms({"fileName": file_path, "representation": str(representation["_id"])}) # Rebuild node.parm("buildHierarchy").pressButton() def remove(self, container): node = container["node"] node.destroy() def switch(self, container, representation): self.update(container, representation) ================================================ FILE: openpype/hosts/houdini/plugins/load/load_ass.py ================================================ import os import re from openpype.pipeline import ( load, get_representation_path, ) from openpype.hosts.houdini.api import pipeline class AssLoader(load.LoaderPlugin): """Load .ass with Arnold Procedural""" families = ["ass"] label = "Load Arnold Procedural" representations = ["ass"] order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): import hou # Get the root node obj = hou.node("/obj") # Define node name namespace = namespace if namespace else context["asset"]["name"] node_name = "{}_{}".format(namespace, name) if namespace else name # Create a new geo node procedural = obj.createNode("arnold::procedural", node_name=node_name) procedural.setParms( { "ar_filename": self.format_path(context["representation"]) }) nodes = [procedural] self[:] = nodes return pipeline.containerise( node_name, namespace, nodes, context, self.__class__.__name__, suffix="", ) def update(self, container, representation): # Update the file path procedural = container["node"] procedural.setParms({"ar_filename": self.format_path(representation)}) # Update attribute procedural.setParms({"representation": str(representation["_id"])}) def remove(self, container): node = container["node"] node.destroy() @staticmethod def format_path(representation): """Format file path correctly for single ass.* or ass.* sequence. Args: representation (dict): representation to be loaded. Returns: str: Formatted path to be used by the input node. """ path = get_representation_path(representation) if not os.path.exists(path): raise RuntimeError("Path does not exist: {}".format(path)) is_sequence = bool(representation["context"].get("frame")) # The path is either a single file or sequence in a folder. if is_sequence: dir_path, file_name = os.path.split(path) path = os.path.join( dir_path, re.sub(r"(.*)\.(\d+)\.(ass.*)", "\\1.$F4.\\3", file_name) ) return os.path.normpath(path).replace("\\", "/") def switch(self, container, representation): self.update(container, representation) ================================================ FILE: openpype/hosts/houdini/plugins/load/load_bgeo.py ================================================ # -*- coding: utf-8 -*- import os import re from openpype.pipeline import ( load, get_representation_path, ) from openpype.hosts.houdini.api import pipeline class BgeoLoader(load.LoaderPlugin): """Load bgeo files to Houdini.""" label = "Load bgeo" families = ["model", "pointcache", "bgeo"] representations = [ "bgeo", "bgeosc", "bgeogz", "bgeo.sc", "bgeo.gz", "bgeo.lzma", "bgeo.bz2"] order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): import hou # Get the root node obj = hou.node("/obj") # Define node name namespace = namespace if namespace else context["asset"]["name"] node_name = "{}_{}".format(namespace, name) if namespace else name # Create a new geo node container = obj.createNode("geo", node_name=node_name) # Remove the file node, it only loads static meshes # Houdini 17 has removed the file node from the geo node file_node = container.node("file1") if file_node: file_node.destroy() # Explicitly create a file node path = self.filepath_from_context(context) file_node = container.createNode("file", node_name=node_name) file_node.setParms( {"file": self.format_path(path, context["representation"])}) # Set display on last node file_node.setDisplayFlag(True) nodes = [container, file_node] self[:] = nodes return pipeline.containerise( node_name, namespace, nodes, context, self.__class__.__name__, suffix="", ) @staticmethod def format_path(path, representation): """Format file path correctly for single bgeo or bgeo sequence.""" if not os.path.exists(path): raise RuntimeError("Path does not exist: %s" % path) is_sequence = bool(representation["context"].get("frame")) # The path is either a single file or sequence in a folder. if not is_sequence: filename = path else: filename = re.sub(r"(.*)\.(\d+)\.(bgeo.*)", "\\1.$F4.\\3", path) filename = os.path.join(path, filename) filename = os.path.normpath(filename) filename = filename.replace("\\", "/") return filename def update(self, container, representation): node = container["node"] try: file_node = next( n for n in node.children() if n.type().name() == "file" ) except StopIteration: self.log.error("Could not find node of type `alembic`") return # Update the file path file_path = get_representation_path(representation) file_path = self.format_path(file_path, representation) file_node.setParms({"file": file_path}) # Update attribute node.setParms({"representation": str(representation["_id"])}) def remove(self, container): node = container["node"] node.destroy() def switch(self, container, representation): self.update(container, representation) ================================================ FILE: openpype/hosts/houdini/plugins/load/load_camera.py ================================================ from openpype.pipeline import ( load, get_representation_path, ) from openpype.hosts.houdini.api import pipeline from openpype.hosts.houdini.api.lib import ( set_camera_resolution, get_camera_from_container ) import hou ARCHIVE_EXPRESSION = ('__import__("_alembic_hom_extensions")' '.alembicGetCameraDict') def transfer_non_default_values(src, dest, ignore=None): """Copy parm from src to dest. Because the Alembic Archive rebuilds the entire node hierarchy on triggering "Build Hierarchy" we want to preserve any local tweaks made by the user on the camera for ease of use. That could be a background image, a resolution change or even Redshift camera parameters. We try to do so by finding all Parms that exist on both source and destination node, include only those that both are not at their default value, they must be visible, we exclude those that have the special "alembic archive" channel expression and ignore certain Parm types. """ ignore_types = { hou.parmTemplateType.Toggle, hou.parmTemplateType.Menu, hou.parmTemplateType.Button, hou.parmTemplateType.FolderSet, hou.parmTemplateType.Separator, hou.parmTemplateType.Label, } src.updateParmStates() for parm in src.allParms(): if ignore and parm.name() in ignore: continue # If destination parm does not exist, ignore.. dest_parm = dest.parm(parm.name()) if not dest_parm: continue # Ignore values that are currently at default if parm.isAtDefault() and dest_parm.isAtDefault(): continue if not parm.isVisible(): # Ignore hidden parameters, assume they # are implementation details continue expression = None try: expression = parm.expression() except hou.OperationFailed: # No expression present pass if expression is not None and ARCHIVE_EXPRESSION in expression: # Assume it's part of the automated connections that the # Alembic Archive makes on loading of the camera and thus we do # not want to transfer the expression continue # Ignore folders, separators, etc. if parm.parmTemplate().type() in ignore_types: continue print("Preserving attribute: %s" % parm.name()) dest_parm.setFromParm(parm) class CameraLoader(load.LoaderPlugin): """Load camera from an Alembic file""" families = ["camera"] label = "Load Camera (abc)" representations = ["abc"] order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): # Format file name, Houdini only wants forward slashes file_path = self.filepath_from_context(context).replace("\\", "/") # Get the root node obj = hou.node("/obj") # Define node name namespace = namespace if namespace else context["asset"]["name"] node_name = "{}_{}".format(namespace, name) if namespace else name # Create a archive node node = self.create_and_connect(obj, "alembicarchive", node_name) # TODO: add FPS of project / asset node.setParms({"fileName": file_path, "channelRef": True}) # Apply some magic node.parm("buildHierarchy").pressButton() node.moveToGoodPosition() # Create an alembic xform node nodes = [node] camera = get_camera_from_container(node) self._match_maya_render_mask(camera) set_camera_resolution(camera, asset_doc=context["asset"]) self[:] = nodes return pipeline.containerise(node_name, namespace, nodes, context, self.__class__.__name__, suffix="") def update(self, container, representation): node = container["node"] # Update the file path file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") # Update attributes node.setParms({"fileName": file_path, "representation": str(representation["_id"])}) # Store the cam temporarily next to the Alembic Archive # so that we can preserve parm values the user set on it # after build hierarchy was triggered. old_camera = get_camera_from_container(node) temp_camera = old_camera.copyTo(node.parent()) # Rebuild node.parm("buildHierarchy").pressButton() # Apply values to the new camera new_camera = get_camera_from_container(node) transfer_non_default_values(temp_camera, new_camera, # The hidden uniform scale attribute # gets a default connection to # "icon_scale" just skip that completely ignore={"scale"}) self._match_maya_render_mask(new_camera) set_camera_resolution(new_camera) temp_camera.destroy() def remove(self, container): node = container["node"] node.destroy() def create_and_connect(self, node, node_type, name=None): """Create a node within a node which and connect it to the input Args: node(hou.Node): parent of the new node node_type(str) name of the type of node, eg: 'alembic' name(str, Optional): name of the node Returns: hou.Node """ if name: new_node = node.createNode(node_type, node_name=name) else: new_node = node.createNode(node_type) new_node.moveToGoodPosition() return new_node def _match_maya_render_mask(self, camera): """Workaround to match Maya render mask in Houdini""" # print("Setting match maya render mask ") parm = camera.parm("aperture") expression = parm.expression() expression = expression.replace("return ", "aperture = ") expression += """ # Match maya render mask (logic from Houdini's own FBX importer) node = hou.pwd() resx = node.evalParm('resx') resy = node.evalParm('resy') aspect = node.evalParm('aspect') aperture *= min(1, (resx / resy * aspect) / 1.5) return aperture """ parm.setExpression(expression, language=hou.exprLanguage.Python) ================================================ FILE: openpype/hosts/houdini/plugins/load/load_fbx.py ================================================ # -*- coding: utf-8 -*- """Fbx Loader for houdini. """ from openpype.pipeline import ( load, get_representation_path, ) from openpype.hosts.houdini.api import pipeline class FbxLoader(load.LoaderPlugin): """Load fbx files. """ label = "Load FBX" icon = "code-fork" color = "orange" order = -10 families = ["*"] representations = ["*"] extensions = {"fbx"} def load(self, context, name=None, namespace=None, data=None): # get file path from context file_path = self.filepath_from_context(context) file_path = file_path.replace("\\", "/") # get necessary data namespace, node_name = self.get_node_name(context, name, namespace) # create load tree nodes = self.create_load_node_tree(file_path, node_name, name) self[:] = nodes # Call containerise function which does some automations for you # like moving created nodes to the AVALON_CONTAINERS subnetwork containerised_nodes = pipeline.containerise( node_name, namespace, nodes, context, self.__class__.__name__, suffix="", ) return containerised_nodes def update(self, container, representation): node = container["node"] try: file_node = next( n for n in node.children() if n.type().name() == "file" ) except StopIteration: self.log.error("Could not find node of type `file`") return # Update the file path from representation file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") file_node.setParms({"file": file_path}) # Update attribute node.setParms({"representation": str(representation["_id"])}) def remove(self, container): node = container["node"] node.destroy() def switch(self, container, representation): self.update(container, representation) def get_node_name(self, context, name=None, namespace=None): """Define node name.""" if not namespace: namespace = context["asset"]["name"] if namespace: node_name = "{}_{}".format(namespace, name) else: node_name = name return namespace, node_name def create_load_node_tree(self, file_path, node_name, subset_name): """Create Load network. you can start building your tree at any obj level. it'll be much easier to build it in the root obj level. Afterwards, your tree will be automatically moved to '/obj/AVALON_CONTAINERS' subnetwork. """ import hou # Get the root obj level obj = hou.node("/obj") # Create a new obj geo node parent_node = obj.createNode("geo", node_name=node_name) # In older houdini, # when reating a new obj geo node, a default file node will be # automatically created. # so, we will delete it if exists. file_node = parent_node.node("file1") if file_node: file_node.destroy() # Create a new file node file_node = parent_node.createNode("file", node_name=node_name) file_node.setParms({"file": file_path}) # Create attribute delete attribdelete_name = "attribdelete_{}".format(subset_name) attribdelete = parent_node.createNode("attribdelete", node_name=attribdelete_name) attribdelete.setParms({"ptdel": "fbx_*"}) attribdelete.setInput(0, file_node) # Create a Null node null_name = "OUT_{}".format(subset_name) null = parent_node.createNode("null", node_name=null_name) null.setInput(0, attribdelete) # Ensure display flag is on the file_node input node and not on the OUT # node to optimize "debug" displaying in the viewport. file_node.setDisplayFlag(True) # Set new position for children nodes parent_node.layoutChildren() # Return all the nodes return [parent_node, file_node, attribdelete, null] ================================================ FILE: openpype/hosts/houdini/plugins/load/load_hda.py ================================================ # -*- coding: utf-8 -*- import os from openpype.pipeline import ( load, get_representation_path, ) from openpype.hosts.houdini.api import pipeline class HdaLoader(load.LoaderPlugin): """Load Houdini Digital Asset file.""" families = ["hda"] label = "Load Hda" representations = ["hda"] order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): import hou # Format file name, Houdini only wants forward slashes file_path = self.filepath_from_context(context) file_path = os.path.normpath(file_path) file_path = file_path.replace("\\", "/") # Get the root node obj = hou.node("/obj") # Create a unique name counter = 1 namespace = namespace or context["asset"]["name"] formatted = "{}_{}".format(namespace, name) if namespace else name node_name = "{0}_{1:03d}".format(formatted, counter) hou.hda.installFile(file_path) hda_node = obj.createNode(name, node_name) self[:] = [hda_node] return pipeline.containerise( node_name, namespace, [hda_node], context, self.__class__.__name__, suffix="", ) def update(self, container, representation): import hou hda_node = container["node"] file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") hou.hda.installFile(file_path) defs = hda_node.type().allInstalledDefinitions() def_paths = [d.libraryFilePath() for d in defs] new = def_paths.index(file_path) defs[new].setIsPreferred(True) hda_node.setParms({ "representation": str(representation["_id"]) }) def remove(self, container): node = container["node"] node.destroy() ================================================ FILE: openpype/hosts/houdini/plugins/load/load_image.py ================================================ import os from openpype.pipeline import ( load, get_representation_path, AVALON_CONTAINER_ID, ) from openpype.hosts.houdini.api import lib, pipeline import hou def get_image_avalon_container(): """The COP2 files must be in a COP2 network. So we maintain a single entry point within AVALON_CONTAINERS, just for ease of use. """ path = pipeline.AVALON_CONTAINERS avalon_container = hou.node(path) if not avalon_container: # Let's create avalon container secretly # but make sure the pipeline still is built the # way we anticipate it was built, asserting it. assert path == "/obj/AVALON_CONTAINERS" parent = hou.node("/obj") avalon_container = parent.createNode( "subnet", node_name="AVALON_CONTAINERS" ) image_container = hou.node(path + "/IMAGES") if not image_container: image_container = avalon_container.createNode( "cop2net", node_name="IMAGES" ) image_container.moveToGoodPosition() return image_container class ImageLoader(load.LoaderPlugin): """Load images into COP2""" families = ["imagesequence"] label = "Load Image (COP2)" representations = ["*"] order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): # Format file name, Houdini only wants forward slashes file_path = self.filepath_from_context(context) file_path = os.path.normpath(file_path) file_path = file_path.replace("\\", "/") file_path = self._get_file_sequence(file_path) # Get the root node parent = get_image_avalon_container() # Define node name namespace = namespace if namespace else context["asset"]["name"] node_name = "{}_{}".format(namespace, name) if namespace else name node = parent.createNode("file", node_name=node_name) node.moveToGoodPosition() node.setParms({"filename1": file_path}) # Imprint it manually data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": node_name, "namespace": namespace, "loader": str(self.__class__.__name__), "representation": str(context["representation"]["_id"]), } # todo: add folder="Avalon" lib.imprint(node, data) return node def update(self, container, representation): node = container["node"] # Update the file path file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") file_path = self._get_file_sequence(file_path) # Update attributes node.setParms( { "filename1": file_path, "representation": str(representation["_id"]), } ) def remove(self, container): node = container["node"] # Let's clean up the IMAGES COP2 network # if it ends up being empty and we deleted # the last file node. Store the parent # before we delete the node. parent = node.parent() node.destroy() if not parent.children(): parent.destroy() def _get_file_sequence(self, file_path): root = os.path.dirname(file_path) files = sorted(os.listdir(root)) first_fname = files[0] prefix, padding, suffix = first_fname.rsplit(".", 2) fname = ".".join([prefix, "$F{}".format(len(padding)), suffix]) return os.path.join(root, fname).replace("\\", "/") def switch(self, container, representation): self.update(container, representation) ================================================ FILE: openpype/hosts/houdini/plugins/load/load_redshift_proxy.py ================================================ import os import re from openpype.pipeline import ( load, get_representation_path, ) from openpype.hosts.houdini.api import pipeline from openpype.pipeline.load import LoadError import hou class RedshiftProxyLoader(load.LoaderPlugin): """Load Redshift Proxy""" families = ["redshiftproxy"] label = "Load Redshift Proxy" representations = ["rs"] order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): # Get the root node obj = hou.node("/obj") # Define node name namespace = namespace if namespace else context["asset"]["name"] node_name = "{}_{}".format(namespace, name) if namespace else name # Create a new geo node container = obj.createNode("geo", node_name=node_name) # Check whether the Redshift parameters exist - if not, then likely # redshift is not set up or initialized correctly if not container.parm("RS_objprop_proxy_enable"): container.destroy() raise LoadError("Unable to initialize geo node with Redshift " "attributes. Make sure you have the Redshift " "plug-in set up correctly for Houdini.") # Enable by default container.setParms({ "RS_objprop_proxy_enable": True, "RS_objprop_proxy_file": self.format_path( self.filepath_from_context(context), context["representation"]) }) # Remove the file node, it only loads static meshes # Houdini 17 has removed the file node from the geo node file_node = container.node("file1") if file_node: file_node.destroy() # Add this stub node inside so it previews ok proxy_sop = container.createNode("redshift_proxySOP", node_name=node_name) proxy_sop.setDisplayFlag(True) nodes = [container, proxy_sop] self[:] = nodes return pipeline.containerise( node_name, namespace, nodes, context, self.__class__.__name__, suffix="", ) def update(self, container, representation): # Update the file path file_path = get_representation_path(representation) node = container["node"] node.setParms({ "RS_objprop_proxy_file": self.format_path( file_path, representation) }) # Update attribute node.setParms({"representation": str(representation["_id"])}) def remove(self, container): node = container["node"] node.destroy() @staticmethod def format_path(path, representation): """Format file path correctly for single redshift proxy or redshift proxy sequence.""" if not os.path.exists(path): raise RuntimeError("Path does not exist: %s" % path) is_sequence = bool(representation["context"].get("frame")) # The path is either a single file or sequence in a folder. if is_sequence: filename = re.sub(r"(.*)\.(\d+)\.(rs.*)", "\\1.$F4.\\3", path) filename = os.path.join(path, filename) else: filename = path filename = os.path.normpath(filename) filename = filename.replace("\\", "/") return filename ================================================ FILE: openpype/hosts/houdini/plugins/load/load_usd_layer.py ================================================ from openpype.pipeline import ( load, get_representation_path, AVALON_CONTAINER_ID, ) from openpype.hosts.houdini.api import lib class USDSublayerLoader(load.LoaderPlugin): """Sublayer USD file in Solaris""" families = [ "usd", "usdCamera", ] label = "Sublayer USD" representations = ["usd", "usda", "usdlc", "usdnc", "abc"] order = 1 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): import os import hou # Format file name, Houdini only wants forward slashes file_path = self.filepath_from_context(context) file_path = os.path.normpath(file_path) file_path = file_path.replace("\\", "/") # Get the root node stage = hou.node("/stage") # Define node name namespace = namespace if namespace else context["asset"]["name"] node_name = "{}_{}".format(namespace, name) if namespace else name # Create USD reference container = stage.createNode("sublayer", node_name=node_name) container.setParms({"filepath1": file_path}) container.moveToGoodPosition() # Imprint it manually data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": node_name, "namespace": namespace, "loader": str(self.__class__.__name__), "representation": str(context["representation"]["_id"]), } # todo: add folder="Avalon" lib.imprint(container, data) return container def update(self, container, representation): node = container["node"] # Update the file path file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") # Update attributes node.setParms( { "filepath1": file_path, "representation": str(representation["_id"]), } ) # Reload files node.parm("reload").pressButton() def remove(self, container): node = container["node"] node.destroy() def switch(self, container, representation): self.update(container, representation) ================================================ FILE: openpype/hosts/houdini/plugins/load/load_usd_reference.py ================================================ from openpype.pipeline import ( load, get_representation_path, AVALON_CONTAINER_ID, ) from openpype.hosts.houdini.api import lib class USDReferenceLoader(load.LoaderPlugin): """Reference USD file in Solaris""" families = [ "usd", "usdCamera", ] label = "Reference USD" representations = ["usd", "usda", "usdlc", "usdnc", "abc"] order = -8 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): import os import hou # Format file name, Houdini only wants forward slashes file_path = self.filepath_from_context(context) file_path = os.path.normpath(file_path) file_path = file_path.replace("\\", "/") # Get the root node stage = hou.node("/stage") # Define node name namespace = namespace if namespace else context["asset"]["name"] node_name = "{}_{}".format(namespace, name) if namespace else name # Create USD reference container = stage.createNode("reference", node_name=node_name) container.setParms({"filepath1": file_path}) container.moveToGoodPosition() # Imprint it manually data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": node_name, "namespace": namespace, "loader": str(self.__class__.__name__), "representation": str(context["representation"]["_id"]), } # todo: add folder="Avalon" lib.imprint(container, data) return container def update(self, container, representation): node = container["node"] # Update the file path file_path = get_representation_path(representation) file_path = file_path.replace("\\", "/") # Update attributes node.setParms( { "filepath1": file_path, "representation": str(representation["_id"]), } ) # Reload files node.parm("reload").pressButton() def remove(self, container): node = container["node"] node.destroy() def switch(self, container, representation): self.update(container, representation) ================================================ FILE: openpype/hosts/houdini/plugins/load/load_vdb.py ================================================ import os import re from openpype.pipeline import ( load, get_representation_path, ) from openpype.hosts.houdini.api import pipeline class VdbLoader(load.LoaderPlugin): """Load VDB""" families = ["vdbcache"] label = "Load VDB" representations = ["vdb"] order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): import hou # Get the root node obj = hou.node("/obj") # Define node name namespace = namespace if namespace else context["asset"]["name"] node_name = "{}_{}".format(namespace, name) if namespace else name # Create a new geo node container = obj.createNode("geo", node_name=node_name) # Remove the file node, it only loads static meshes # Houdini 17 has removed the file node from the geo node file_node = container.node("file1") if file_node: file_node.destroy() # Explicitly create a file node file_node = container.createNode("file", node_name=node_name) path = self.filepath_from_context(context) file_node.setParms( {"file": self.format_path(path, context["representation"])}) # Set display on last node file_node.setDisplayFlag(True) nodes = [container, file_node] self[:] = nodes return pipeline.containerise( node_name, namespace, nodes, context, self.__class__.__name__, suffix="", ) @staticmethod def format_path(path, representation): """Format file path correctly for single vdb or vdb sequence.""" if not os.path.exists(path): raise RuntimeError("Path does not exist: %s" % path) is_sequence = bool(representation["context"].get("frame")) # The path is either a single file or sequence in a folder. if not is_sequence: filename = path else: filename = re.sub(r"(.*)\.(\d+)\.vdb$", "\\1.$F4.vdb", path) filename = os.path.join(path, filename) filename = os.path.normpath(filename) filename = filename.replace("\\", "/") return filename def update(self, container, representation): node = container["node"] try: file_node = next( n for n in node.children() if n.type().name() == "file" ) except StopIteration: self.log.error("Could not find node of type `alembic`") return # Update the file path file_path = get_representation_path(representation) file_path = self.format_path(file_path, representation) file_node.setParms({"file": file_path}) # Update attribute node.setParms({"representation": str(representation["_id"])}) def remove(self, container): node = container["node"] node.destroy() def switch(self, container, representation): self.update(container, representation) ================================================ FILE: openpype/hosts/houdini/plugins/load/show_usdview.py ================================================ import os import platform import subprocess from openpype.lib.vendor_bin_utils import find_executable from openpype.pipeline import load class ShowInUsdview(load.LoaderPlugin): """Open USD file in usdview""" label = "Show in usdview" representations = ["*"] families = ["*"] extensions = {"usd", "usda", "usdlc", "usdnc", "abc"} order = 15 icon = "code-fork" color = "white" def load(self, context, name=None, namespace=None, data=None): from pathlib import Path if platform.system() == "Windows": executable = "usdview.bat" else: executable = "usdview" usdview = find_executable(executable) if not usdview: raise RuntimeError("Unable to find usdview") # For some reason Windows can return the path like: # C:/PROGRA~1/SIDEEF~1/HOUDIN~1.435/bin/usdview # convert to resolved path so `subprocess` can take it usdview = str(Path(usdview).resolve().as_posix()) filepath = self.filepath_from_context(context) filepath = os.path.normpath(filepath) filepath = filepath.replace("\\", "/") if not os.path.exists(filepath): self.log.error("File does not exist: %s" % filepath) return self.log.info("Start houdini variant of usdview...") subprocess.Popen([usdview, filepath, "--renderer", "GL"]) ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_active_state.py ================================================ import pyblish.api import hou class CollectInstanceActiveState(pyblish.api.InstancePlugin): """Collect default active state for instance from its node bypass state. This is done at the very end of the CollectorOrder so that any required collecting of data iterating over instances (with InstancePlugin) will actually collect the data for when the user enables the state in the UI. Otherwise potentially required data might have skipped collecting. """ order = pyblish.api.CollectorOrder + 0.299 families = ["*"] hosts = ["houdini"] label = "Instance Active State" def process(self, instance): # Must have node to check for bypass state if len(instance) == 0: return # Check bypass state and reverse active = True node = hou.node(instance.data.get("instance_node")) if hasattr(node, "isBypassed"): active = not node.isBypassed() # Set instance active state instance.data.update( { "active": active, # temporarily translation of `active` to `publish` till # issue has been resolved: # https://github.com/pyblish/pyblish-base/issues/307 "publish": active, } ) ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py ================================================ import os import re import hou import pyblish.api from openpype.hosts.houdini.api import colorspace from openpype.hosts.houdini.api.lib import ( evalParmNoFrame, get_color_management_preferences) class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): """Collect Arnold ROP Render Products Collects the instance.data["files"] for the render products. Provides: instance -> files """ label = "Arnold ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["arnold_rop"] def process(self, instance): rop = hou.node(instance.data.get("instance_node")) # Collect chunkSize chunk_size_parm = rop.parm("chunkSize") if chunk_size_parm: chunk_size = int(chunk_size_parm.eval()) instance.data["chunkSize"] = chunk_size self.log.debug("Chunk Size: %s" % chunk_size) default_prefix = evalParmNoFrame(rop, "ar_picture") render_products = [] # Store whether we are splitting the render job (export + render) split_render = bool(rop.parm("ar_ass_export_enable").eval()) instance.data["splitRender"] = split_render export_prefix = None export_products = [] if split_render: export_prefix = evalParmNoFrame( rop, "ar_ass_file", pad_character="0" ) beauty_export_product = self.get_render_product_name( prefix=export_prefix, suffix=None) export_products.append(beauty_export_product) self.log.debug( "Found export product: {}".format(beauty_export_product) ) instance.data["ifdFile"] = beauty_export_product instance.data["exportFiles"] = list(export_products) # Default beauty AOV beauty_product = self.get_render_product_name(prefix=default_prefix, suffix=None) render_products.append(beauty_product) files_by_aov = { "": self.generate_expected_files(instance, beauty_product) } num_aovs = rop.evalParm("ar_aovs") for index in range(1, num_aovs + 1): # Skip disabled AOVs if not rop.evalParm("ar_enable_aov{}".format(index)): continue if rop.evalParm("ar_aov_exr_enable_layer_name{}".format(index)): label = rop.evalParm("ar_aov_exr_layer_name{}".format(index)) else: label = evalParmNoFrame(rop, "ar_aov_label{}".format(index)) aov_product = self.get_render_product_name(default_prefix, suffix=label) render_products.append(aov_product) files_by_aov[label] = self.generate_expected_files(instance, aov_product) for product in render_products: self.log.debug("Found render product: {}".format(product)) instance.data["files"] = list(render_products) instance.data["renderProducts"] = colorspace.ARenderProduct() # For now by default do NOT try to publish the rendered output instance.data["publishJobState"] = "Suspended" instance.data["attachTo"] = [] # stub required data if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() instance.data["expectedFiles"].append(files_by_aov) # update the colorspace data colorspace_data = get_color_management_preferences() instance.data["colorspaceConfig"] = colorspace_data["config"] instance.data["colorspaceDisplay"] = colorspace_data["display"] instance.data["colorspaceView"] = colorspace_data["view"] def get_render_product_name(self, prefix, suffix): """Return the output filename using the AOV prefix and suffix""" # When AOV is explicitly defined in prefix we just swap it out # directly with the AOV suffix to embed it. # Note: ${AOV} seems to be evaluated in the parameter as %AOV% if "%AOV%" in prefix: # It seems that when some special separator characters are present # before the %AOV% token that Redshift will secretly remove it if # there is no suffix for the current product, for example: # foo_%AOV% -> foo.exr pattern = "%AOV%" if suffix else "[._-]?%AOV%" product_name = re.sub(pattern, suffix, prefix, flags=re.IGNORECASE) else: if suffix: # Add ".{suffix}" before the extension prefix_base, ext = os.path.splitext(prefix) product_name = prefix_base + "." + suffix + ext else: product_name = prefix return product_name def generate_expected_files(self, instance, path): """Create expected files in instance data""" dir = os.path.dirname(path) file = os.path.basename(path) if "#" in file: def replace(match): return "%0{}d".format(len(match.group())) file = re.sub("#+", replace, file) if "%" not in file: return path expected_files = [] start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) return expected_files ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_asset_handles.py ================================================ # -*- coding: utf-8 -*- """Collector plugin for frames data on ROP instances.""" import hou # noqa import pyblish.api from openpype.lib import BoolDef from openpype.pipeline import OpenPypePyblishPluginMixin class CollectAssetHandles(pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin): """Apply asset handles. If instance does not have: - frameStart - frameEnd - handleStart - handleEnd But it does have: - frameStartHandle - frameEndHandle Then we will retrieve the asset's handles to compute the exclusive frame range and actual handle ranges. """ hosts = ["houdini"] # This specific order value is used so that # this plugin runs after CollectAnatomyInstanceData order = pyblish.api.CollectorOrder + 0.499 label = "Collect Asset Handles" use_asset_handles = True def process(self, instance): # Only process instances without already existing handles data # but that do have frameStartHandle and frameEndHandle defined # like the data collected from CollectRopFrameRange if "frameStartHandle" not in instance.data: return if "frameEndHandle" not in instance.data: return has_existing_data = { "handleStart", "handleEnd", "frameStart", "frameEnd" }.issubset(instance.data) if has_existing_data: return attr_values = self.get_attr_values_from_data(instance.data) if attr_values.get("use_handles", self.use_asset_handles): asset_data = instance.data["assetEntity"]["data"] handle_start = asset_data.get("handleStart", 0) handle_end = asset_data.get("handleEnd", 0) else: handle_start = 0 handle_end = 0 frame_start = instance.data["frameStartHandle"] + handle_start frame_end = instance.data["frameEndHandle"] - handle_end instance.data.update({ "handleStart": handle_start, "handleEnd": handle_end, "frameStart": frame_start, "frameEnd": frame_end }) # Log debug message about the collected frame range if attr_values.get("use_handles", self.use_asset_handles): self.log.debug( "Full Frame range with Handles " "[{frame_start_handle} - {frame_end_handle}]" .format( frame_start_handle=instance.data["frameStartHandle"], frame_end_handle=instance.data["frameEndHandle"] ) ) else: self.log.debug( "Use handles is deactivated for this instance, " "start and end handles are set to 0." ) # Log collected frame range to the user message = "Frame range [{frame_start} - {frame_end}]".format( frame_start=frame_start, frame_end=frame_end ) if handle_start or handle_end: message += " with handles [{handle_start}]-[{handle_end}]".format( handle_start=handle_start, handle_end=handle_end ) self.log.info(message) if instance.data.get("byFrameStep", 1.0) != 1.0: self.log.info( "Frame steps {}".format(instance.data["byFrameStep"])) # Add frame range to label if the instance has a frame range. label = instance.data.get("label", instance.data["name"]) instance.data["label"] = ( "{label} [{frame_start_handle} - {frame_end_handle}]" .format( label=label, frame_start_handle=instance.data["frameStartHandle"], frame_end_handle=instance.data["frameEndHandle"] ) ) @classmethod def get_attribute_defs(cls): return [ BoolDef("use_handles", tooltip="Disable this if you want the publisher to" " ignore start and end handles specified in the" " asset data for this publish instance", default=cls.use_asset_handles, label="Use asset handles") ] ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_cache_farm.py ================================================ import os import pyblish.api import hou from openpype.hosts.houdini.api import lib class CollectDataforCache(pyblish.api.InstancePlugin): """Collect data for caching to Deadline.""" order = pyblish.api.CollectorOrder + 0.04 families = ["ass", "pointcache", "mantraifd", "redshiftproxy", "vdbcache"] hosts = ["houdini"] targets = ["local", "remote"] label = "Collect Data for Cache" def process(self, instance): creator_attribute = instance.data["creator_attributes"] farm_enabled = creator_attribute["farm"] instance.data["farm"] = farm_enabled if not farm_enabled: self.log.debug("Caching on farm is disabled. " "Skipping farm collecting.") return # Why do we need this particular collector to collect the expected # output files from a ROP node. Don't we have a dedicated collector # for that yet? # Collect expected files ropnode = hou.node(instance.data["instance_node"]) output_parm = lib.get_output_parameter(ropnode) expected_filepath = output_parm.eval() instance.data.setdefault("files", list()) instance.data.setdefault("expectedFiles", list()) if instance.data.get("frames"): files = self.get_files(instance, expected_filepath) # list of files instance.data["files"].extend(files) else: # single file instance.data["files"].append(output_parm.eval()) cache_files = {"_": instance.data["files"]} # Convert instance family to pointcache if it is bgeo or abc # because ??? for family in instance.data["families"]: if family == "bgeo" or "abc": instance.data["family"] = "pointcache" break instance.data.update({ "plugin": "Houdini", "publish": True }) instance.data["families"].append("publish.hou") instance.data["expectedFiles"].append(cache_files) self.log.debug("{}".format(instance.data)) def get_files(self, instance, output_parm): """Get the files with the frame range data Args: instance (_type_): instance output_parm (_type_): path of output parameter Returns: files: a list of files """ directory = os.path.dirname(output_parm) files = [ os.path.join(directory, frame).replace("\\", "/") for frame in instance.data["frames"] ] return files ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_chunk_size.py ================================================ import pyblish.api from openpype.lib import NumberDef from openpype.pipeline import OpenPypePyblishPluginMixin class CollectChunkSize(pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin): """Collect chunk size for cache submission to Deadline.""" order = pyblish.api.CollectorOrder + 0.05 families = ["ass", "pointcache", "vdbcache", "mantraifd", "redshiftproxy"] hosts = ["houdini"] targets = ["local", "remote"] label = "Collect Chunk Size" chunk_size = 999999 def process(self, instance): # need to get the chunk size info from the setting attr_values = self.get_attr_values_from_data(instance.data) instance.data["chunkSize"] = attr_values.get("chunkSize") @classmethod def get_attribute_defs(cls): return [ NumberDef("chunkSize", minimum=1, maximum=999999, decimals=0, default=cls.chunk_size, label="Frame Per Task") ] ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_current_file.py ================================================ import os import hou import pyblish.api class CollectHoudiniCurrentFile(pyblish.api.ContextPlugin): """Inject the current working file into context""" order = pyblish.api.CollectorOrder - 0.1 label = "Houdini Current File" hosts = ["houdini"] def process(self, context): """Inject the current working file""" current_file = hou.hipFile.path() if not os.path.exists(current_file): # By default, Houdini will even point a new scene to a path. # However if the file is not saved at all and does not exist, # we assume the user never set it. current_file = "" elif os.path.basename(current_file) == "untitled.hip": # Due to even a new file being called 'untitled.hip' we are unable # to confirm the current scene was ever saved because the file # could have existed already. We will allow it if the file exists, # but show a warning for this edge case to clarify the potential # false positive. self.log.warning( "Current file is 'untitled.hip' and we are " "unable to detect whether the current scene is " "saved correctly." ) context.data["currentFile"] = current_file self.log.info('Current workfile path: {}'.format(current_file)) ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_frames.py ================================================ # -*- coding: utf-8 -*- """Collector plugin for frames data on ROP instances.""" import os import re import hou # noqa import pyblish.api from openpype.hosts.houdini.api import lib class CollectFrames(pyblish.api.InstancePlugin): """Collect all frames which would be saved from the ROP nodes""" # This specific order value is used so that # this plugin runs after CollectRopFrameRange order = pyblish.api.CollectorOrder + 0.1 label = "Collect Frames" families = ["vdbcache", "imagesequence", "ass", "mantraifd", "redshiftproxy", "review", "bgeo"] def process(self, instance): ropnode = hou.node(instance.data["instance_node"]) start_frame = instance.data.get("frameStartHandle", None) end_frame = instance.data.get("frameEndHandle", None) output_parm = lib.get_output_parameter(ropnode) if start_frame is not None: # When rendering only a single frame still explicitly # get the name for that particular frame instead of current frame output = output_parm.evalAtFrame(start_frame) else: self.log.warning("Using current frame: {}".format(hou.frame())) output = output_parm.eval() _, ext = lib.splitext( output, allowed_multidot_extensions=[ ".ass.gz", ".bgeo.sc", ".bgeo.gz", ".bgeo.lzma", ".bgeo.bz2"]) file_name = os.path.basename(output) result = file_name # Get the filename pattern match from the output # path, so we can compute all frames that would # come out from rendering the ROP node if there # is a frame pattern in the name pattern = r"\w+\.(\d+)" + re.escape(ext) match = re.match(pattern, file_name) if match and start_frame is not None: # Check if frames are bigger than 1 (file collection) # override the result if end_frame - start_frame > 0: result = self.create_file_list( match, int(start_frame), int(end_frame) ) # todo: `frames` currently conflicts with "explicit frames" for a # for a custom frame list. So this should be refactored. instance.data.update({"frames": result}) @staticmethod def create_file_list(match, start_frame, end_frame): """Collect files based on frame range and `regex.match` Args: match(re.match): match object start_frame(int): start of the animation end_frame(int): end of the animation Returns: list """ # Get the padding length frame = match.group(1) padding = len(frame) # Get the parts of the filename surrounding the frame number, # so we can put our own frame numbers in. span = match.span(1) prefix = match.string[: span[0]] suffix = match.string[span[1]:] # Generate filenames for all frames result = [] for i in range(start_frame, end_frame + 1): # Format frame number by the padding amount str_frame = "{number:0{width}d}".format(number=i, width=padding) file_name = prefix + str_frame + suffix result.append(file_name) return result ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_inputs.py ================================================ import pyblish.api from openpype.pipeline import registered_host def collect_input_containers(nodes): """Collect containers that contain any of the node in `nodes`. This will return any loaded Avalon container that contains at least one of the nodes. As such, the Avalon container is an input for it. Or in short, there are member nodes of that container. Returns: list: Input avalon containers """ # Lookup by node ids lookup = frozenset(nodes) containers = [] host = registered_host() for container in host.ls(): node = container["node"] # Usually the loaded containers don't have any complex references # and the contained children should be all we need. So we disregard # checking for .references() on the nodes. members = set(node.allSubChildren()) members.add(node) # include the node itself # If there's an intersection if not lookup.isdisjoint(members): containers.append(container) return containers def iter_upstream(node): """Yields all upstream inputs for the current node. This includes all `node.inputAncestors()` but also traverses through all `node.references()` for the node itself and for any of the upstream nodes. This method has no max-depth and will collect all upstream inputs. Yields: hou.Node: The upstream nodes, including references. """ upstream = node.inputAncestors( include_ref_inputs=True, follow_subnets=True ) # Initialize process queue with the node's ancestors itself queue = list(upstream) collected = set(upstream) # Traverse upstream references for all nodes and yield them as we # process the queue. while queue: upstream_node = queue.pop() yield upstream_node # Find its references that are not collected yet. references = upstream_node.references() references = [n for n in references if n not in collected] queue.extend(references) collected.update(references) # Include the references' ancestors that have not been collected yet. for reference in references: ancestors = reference.inputAncestors( include_ref_inputs=True, follow_subnets=True ) ancestors = [n for n in ancestors if n not in collected] queue.extend(ancestors) collected.update(ancestors) class CollectUpstreamInputs(pyblish.api.InstancePlugin): """Collect source input containers used for this publish. This will include `inputs` data of which loaded publishes were used in the generation of this publish. This leaves an upstream trace to what was used as input. """ label = "Collect Inputs" order = pyblish.api.CollectorOrder + 0.4 hosts = ["houdini"] def process(self, instance): # We can't get the "inputAncestors" directly from the ROP # node, so we find the related output node (set in SOP/COP path) # and include that together with its ancestors output = instance.data.get("output_node") if output is None: # If no valid output node is set then ignore it as validation # will be checking those cases. self.log.debug( "No output node found, skipping collecting of inputs.." ) return # Collect all upstream parents nodes = list(iter_upstream(output)) nodes.append(output) # Collect containers for the given set of nodes containers = collect_input_containers(nodes) inputs = [c["representation"] for c in containers] instance.data["inputRepresentations"] = inputs self.log.debug("Collected inputs: %s" % inputs) ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_instances.py ================================================ import hou import pyblish.api from openpype.hosts.houdini.api import lib class CollectInstances(pyblish.api.ContextPlugin): """Gather instances by all node in out graph and pre-defined attributes This collector takes into account assets that are associated with an specific node and marked with a unique identifier; Identifier: id (str): "pyblish.avalon.instance Specific node: The specific node is important because it dictates in which way the subset is being exported. alembic: will export Alembic file which supports cascading attributes like 'cbId' and 'path' geometry: Can export a wide range of file types, default out """ order = pyblish.api.CollectorOrder - 0.01 label = "Collect Instances" hosts = ["houdini"] def process(self, context): nodes = hou.node("/out").children() nodes += hou.node("/obj").children() # Include instances in USD stage only when it exists so it # remains backwards compatible with version before houdini 18 stage = hou.node("/stage") if stage: nodes += stage.recursiveGlob("*", filter=hou.nodeTypeFilter.Rop) for node in nodes: if not node.parm("id"): continue if node.evalParm("id") != "pyblish.avalon.instance": continue # instance was created by new creator code, skip it as # it is already collected. if node.parm("creator_identifier"): continue has_family = node.evalParm("family") assert has_family, "'%s' is missing 'family'" % node.name() self.log.info( "Processing legacy instance node {}".format(node.path()) ) data = lib.read(node) # Check bypass state and reverse if hasattr(node, "isBypassed"): data.update({"active": not node.isBypassed()}) # temporarily translation of `active` to `publish` till issue has # been resolved. # https://github.com/pyblish/pyblish-base/issues/307 if "active" in data: data["publish"] = data["active"] # Create nice name if the instance has a frame range. label = data.get("name", node.name()) label += " (%s)" % data["asset"] # include asset in name instance = context.create_instance(label) # Include `families` using `family` data instance.data["families"] = [instance.data["family"]] instance[:] = [node] instance.data["instance_node"] = node.path() instance.data.update(data) def sort_by_family(instance): """Sort by family""" return instance.data.get("families", instance.data.get("family")) # Sort/grouped by family (preserving local index) context[:] = sorted(context, key=sort_by_family) return context ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py ================================================ import hou import pyblish.api from openpype.hosts.houdini.api import lib import openpype.hosts.houdini.api.usd as hou_usdlib import openpype.lib.usdlib as usdlib class CollectInstancesUsdLayered(pyblish.api.ContextPlugin): """Collect Instances from a ROP Network and its configured layer paths. The output nodes of the ROP node will only be published when *any* of the layers remain set to 'publish' by the user. This works differently from most of our Avalon instances in the pipeline. As opposed to storing `pyblish.avalon.instance` as id on the node we store `pyblish.avalon.usdlayered`. Additionally this instance has no need for storing family, asset, subset or name on the nodes. Instead all information is retrieved solely from the output filepath, which is an Avalon URI: avalon://{asset}/{subset}.{representation} Each final ROP node is considered a dependency for any of the Configured Save Path layers it sets along the way. As such, the instances shown in the Pyblish UI are solely the configured layers. The encapsulating usd files are generated whenever *any* of the dependencies is published. These dependency instances are stored in: instance.data["publishDependencies"] """ order = pyblish.api.CollectorOrder - 0.01 label = "Collect Instances (USD Configured Layers)" hosts = ["houdini"] def process(self, context): stage = hou.node("/stage") if not stage: # Likely Houdini version <18 return nodes = stage.recursiveGlob("*", filter=hou.nodeTypeFilter.Rop) for node in nodes: if not node.parm("id"): continue if node.evalParm("id") != "pyblish.avalon.usdlayered": continue has_family = node.evalParm("family") assert has_family, "'%s' is missing 'family'" % node.name() self.process_node(node, context) def sort_by_family(instance): """Sort by family""" return instance.data.get("families", instance.data.get("family")) # Sort/grouped by family (preserving local index) context[:] = sorted(context, key=sort_by_family) return context def process_node(self, node, context): # Allow a single ROP node or a full ROP network of USD ROP nodes # to be processed as a single entry that should "live together" on # a publish. if node.type().name() == "ropnet": # All rop nodes inside ROP Network ropnodes = node.recursiveGlob("*", filter=hou.nodeTypeFilter.Rop) else: # A single node ropnodes = [node] data = lib.read(node) # Don't use the explicit "colorbleed.usd.layered" family for publishing # instead use the "colorbleed.usd" family to integrate. data["publishFamilies"] = ["colorbleed.usd"] # For now group ALL of them into USD Layer subset group # Allow this subset to be grouped into a USD Layer on creation data["subsetGroup"] = "USD Layer" instances = list() dependencies = [] for ropnode in ropnodes: # Create a dependency instance per ROP Node. lopoutput = ropnode.evalParm("lopoutput") dependency_save_data = self.get_save_data(lopoutput) dependency = context.create_instance(dependency_save_data["name"]) dependency.append(ropnode) dependency.data.update(data) dependency.data.update(dependency_save_data) dependency.data["family"] = "colorbleed.usd.dependency" dependency.data["optional"] = False dependencies.append(dependency) # Hide the dependency instance from the context context.pop() # Get all configured layers for this USD ROP node # and create a Pyblish instance for each one layers = hou_usdlib.get_configured_save_layers(ropnode) for layer in layers: save_path = hou_usdlib.get_layer_save_path(layer) save_data = self.get_save_data(save_path) if not save_data: continue self.log.info(save_path) instance = context.create_instance(save_data["name"]) instance[:] = [node] # Set the instance data instance.data.update(data) instance.data.update(save_data) instance.data["usdLayer"] = layer instances.append(instance) # Store the collected ROP node dependencies self.log.debug("Collected dependencies: %s" % (dependencies,)) for instance in instances: instance.data["publishDependencies"] = dependencies def get_save_data(self, save_path): # Resolve Avalon URI uri_data = usdlib.parse_avalon_uri(save_path) if not uri_data: self.log.warning("Non Avalon URI Layer Path: %s" % save_path) return {} # Collect asset + subset from URI name = "{subset} ({asset})".format(**uri_data) fname = "{asset}_{subset}.{ext}".format(**uri_data) data = dict(uri_data) data["usdSavePath"] = save_path data["usdFilename"] = fname data["name"] = name return data ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_karma_rop.py ================================================ import re import os import hou import pyblish.api from openpype.hosts.houdini.api.lib import ( evalParmNoFrame, get_color_management_preferences ) from openpype.hosts.houdini.api import ( colorspace ) class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): """Collect Karma Render Products Collects the instance.data["files"] for the multipart render product. Provides: instance -> files """ label = "Karma ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["karma_rop"] def process(self, instance): rop = hou.node(instance.data.get("instance_node")) # Collect chunkSize chunk_size_parm = rop.parm("chunkSize") if chunk_size_parm: chunk_size = int(chunk_size_parm.eval()) instance.data["chunkSize"] = chunk_size self.log.debug("Chunk Size: %s" % chunk_size) default_prefix = evalParmNoFrame(rop, "picture") render_products = [] # Default beauty AOV beauty_product = self.get_render_product_name( prefix=default_prefix, suffix=None ) render_products.append(beauty_product) files_by_aov = { "beauty": self.generate_expected_files(instance, beauty_product) } filenames = list(render_products) instance.data["files"] = filenames instance.data["renderProducts"] = colorspace.ARenderProduct() for product in render_products: self.log.debug("Found render product: %s" % product) if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() instance.data["expectedFiles"].append(files_by_aov) # update the colorspace data colorspace_data = get_color_management_preferences() instance.data["colorspaceConfig"] = colorspace_data["config"] instance.data["colorspaceDisplay"] = colorspace_data["display"] instance.data["colorspaceView"] = colorspace_data["view"] def get_render_product_name(self, prefix, suffix): product_name = prefix if suffix: # Add ".{suffix}" before the extension prefix_base, ext = os.path.splitext(prefix) product_name = "{}.{}{}".format(prefix_base, suffix, ext) return product_name def generate_expected_files(self, instance, path): """Create expected files in instance data""" dir = os.path.dirname(path) file = os.path.basename(path) if "#" in file: def replace(match): return "%0{}d".format(len(match.group())) file = re.sub("#+", replace, file) if "%" not in file: return path expected_files = [] start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) return expected_files ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py ================================================ import re import os import hou import pyblish.api from openpype.hosts.houdini.api.lib import ( evalParmNoFrame, get_color_management_preferences ) from openpype.hosts.houdini.api import ( colorspace ) class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): """Collect Mantra Render Products Collects the instance.data["files"] for the render products. Provides: instance -> files """ label = "Mantra ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["mantra_rop"] def process(self, instance): rop = hou.node(instance.data.get("instance_node")) # Collect chunkSize chunk_size_parm = rop.parm("chunkSize") if chunk_size_parm: chunk_size = int(chunk_size_parm.eval()) instance.data["chunkSize"] = chunk_size self.log.debug("Chunk Size: %s" % chunk_size) default_prefix = evalParmNoFrame(rop, "vm_picture") render_products = [] # Store whether we are splitting the render job (export + render) split_render = bool(rop.parm("soho_outputmode").eval()) instance.data["splitRender"] = split_render export_prefix = None export_products = [] if split_render: export_prefix = evalParmNoFrame( rop, "soho_diskfile", pad_character="0" ) beauty_export_product = self.get_render_product_name( prefix=export_prefix, suffix=None) export_products.append(beauty_export_product) self.log.debug( "Found export product: {}".format(beauty_export_product) ) instance.data["ifdFile"] = beauty_export_product instance.data["exportFiles"] = list(export_products) # Default beauty AOV beauty_product = self.get_render_product_name( prefix=default_prefix, suffix=None ) render_products.append(beauty_product) files_by_aov = { "beauty": self.generate_expected_files(instance, beauty_product) } aov_numbers = rop.evalParm("vm_numaux") if aov_numbers > 0: # get the filenames of the AOVs for i in range(1, aov_numbers + 1): var = rop.evalParm("vm_variable_plane%d" % i) if var: aov_name = "vm_filename_plane%d" % i aov_boolean = "vm_usefile_plane%d" % i aov_enabled = rop.evalParm(aov_boolean) has_aov_path = rop.evalParm(aov_name) if has_aov_path and aov_enabled == 1: aov_prefix = evalParmNoFrame(rop, aov_name) aov_product = self.get_render_product_name( prefix=aov_prefix, suffix=None ) render_products.append(aov_product) files_by_aov[var] = self.generate_expected_files(instance, aov_product) # noqa for product in render_products: self.log.debug("Found render product: %s" % product) filenames = list(render_products) instance.data["files"] = filenames instance.data["renderProducts"] = colorspace.ARenderProduct() # For now by default do NOT try to publish the rendered output instance.data["publishJobState"] = "Suspended" instance.data["attachTo"] = [] # stub required data if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() instance.data["expectedFiles"].append(files_by_aov) # update the colorspace data colorspace_data = get_color_management_preferences() instance.data["colorspaceConfig"] = colorspace_data["config"] instance.data["colorspaceDisplay"] = colorspace_data["display"] instance.data["colorspaceView"] = colorspace_data["view"] def get_render_product_name(self, prefix, suffix): product_name = prefix if suffix: # Add ".{suffix}" before the extension prefix_base, ext = os.path.splitext(prefix) product_name = prefix_base + "." + suffix + ext return product_name def generate_expected_files(self, instance, path): """Create expected files in instance data""" dir = os.path.dirname(path) file = os.path.basename(path) if "#" in file: def replace(match): return "%0{}d".format(len(match.group())) file = re.sub("#+", replace, file) if "%" not in file: return path expected_files = [] start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) return expected_files ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_output_node.py ================================================ import pyblish.api from openpype.pipeline.publish import KnownPublishError class CollectOutputSOPPath(pyblish.api.InstancePlugin): """Collect the out node's SOP/COP Path value.""" order = pyblish.api.CollectorOrder families = [ "pointcache", "camera", "vdbcache", "imagesequence", "usd", "usdrender", "redshiftproxy", "staticMesh" ] hosts = ["houdini"] label = "Collect Output Node Path" def process(self, instance): import hou node = hou.node(instance.data["instance_node"]) # Get sop path node_type = node.type().name() if node_type == "geometry": out_node = node.parm("soppath").evalAsNode() elif node_type == "alembic": # Alembic can switch between using SOP Path or object if node.parm("use_sop_path").eval(): out_node = node.parm("sop_path").evalAsNode() else: root = node.parm("root").eval() objects = node.parm("objects").eval() path = root + "/" + objects out_node = hou.node(path) elif node_type == "comp": out_node = node.parm("coppath").evalAsNode() elif node_type == "usd" or node_type == "usdrender": out_node = node.parm("loppath").evalAsNode() elif node_type == "usd_rop" or node_type == "usdrender_rop": # Inside Solaris e.g. /stage (not in ROP context) # When incoming connection is present it takes it directly inputs = node.inputs() if inputs: out_node = inputs[0] else: out_node = node.parm("loppath").evalAsNode() elif node_type == "Redshift_Proxy_Output": out_node = node.parm("RS_archive_sopPath").evalAsNode() elif node_type == "filmboxfbx": out_node = node.parm("startnode").evalAsNode() else: raise KnownPublishError( "ROP node type '{}' is not supported.".format(node_type) ) if not out_node: self.log.warning("No output node collected.") return self.log.debug("Output node: %s" % out_node.path()) instance.data["output_node"] = out_node ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_pointcache_type.py ================================================ """Collector for pointcache types. This will add additional family to pointcache instance based on the creator_identifier parameter. """ import pyblish.api class CollectPointcacheType(pyblish.api.InstancePlugin): """Collect data type for pointcache instance.""" order = pyblish.api.CollectorOrder hosts = ["houdini"] families = ["pointcache"] label = "Collect type of pointcache" def process(self, instance): if instance.data["creator_identifier"] == "io.openpype.creators.houdini.bgeo": # noqa: E501 instance.data["families"] += ["bgeo"] elif instance.data["creator_identifier"] == "io.openpype.creators.houdini.pointcache": # noqa: E501 instance.data["families"] += ["abc"] ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py ================================================ import re import os import hou import pyblish.api from openpype.hosts.houdini.api.lib import ( evalParmNoFrame, get_color_management_preferences ) from openpype.hosts.houdini.api import ( colorspace ) class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): """Collect USD Render Products Collects the instance.data["files"] for the render products. Provides: instance -> files """ label = "Redshift ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["redshift_rop"] def process(self, instance): rop = hou.node(instance.data.get("instance_node")) # Collect chunkSize chunk_size_parm = rop.parm("chunkSize") if chunk_size_parm: chunk_size = int(chunk_size_parm.eval()) instance.data["chunkSize"] = chunk_size self.log.debug("Chunk Size: %s" % chunk_size) default_prefix = evalParmNoFrame(rop, "RS_outputFileNamePrefix") beauty_suffix = rop.evalParm("RS_outputBeautyAOVSuffix") # Store whether we are splitting the render job (export + render) split_render = bool(rop.parm("RS_archive_enable").eval()) instance.data["splitRender"] = split_render export_products = [] if split_render: export_prefix = evalParmNoFrame( rop, "RS_archive_file", pad_character="0" ) beauty_export_product = self.get_render_product_name( prefix=export_prefix, suffix=None) export_products.append(beauty_export_product) self.log.debug( "Found export product: {}".format(beauty_export_product) ) instance.data["ifdFile"] = beauty_export_product instance.data["exportFiles"] = list(export_products) # Default beauty AOV beauty_product = self.get_render_product_name( prefix=default_prefix, suffix=beauty_suffix ) render_products = [beauty_product] files_by_aov = { "_": self.generate_expected_files(instance, beauty_product)} num_aovs = rop.evalParm("RS_aov") for index in range(num_aovs): i = index + 1 # Skip disabled AOVs if not rop.evalParm(f"RS_aovEnable_{i}"): continue aov_suffix = rop.evalParm(f"RS_aovSuffix_{i}") aov_prefix = evalParmNoFrame(rop, f"RS_aovCustomPrefix_{i}") if not aov_prefix: aov_prefix = default_prefix aov_product = self.get_render_product_name(aov_prefix, aov_suffix) render_products.append(aov_product) files_by_aov[aov_suffix] = self.generate_expected_files(instance, aov_product) # noqa for product in render_products: self.log.debug("Found render product: %s" % product) filenames = list(render_products) instance.data["files"] = filenames instance.data["renderProducts"] = colorspace.ARenderProduct() # For now by default do NOT try to publish the rendered output instance.data["publishJobState"] = "Suspended" instance.data["attachTo"] = [] # stub required data if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = [] instance.data["expectedFiles"].append(files_by_aov) # update the colorspace data colorspace_data = get_color_management_preferences() instance.data["colorspaceConfig"] = colorspace_data["config"] instance.data["colorspaceDisplay"] = colorspace_data["display"] instance.data["colorspaceView"] = colorspace_data["view"] def get_render_product_name(self, prefix, suffix): """Return the output filename using the AOV prefix and suffix""" # When AOV is explicitly defined in prefix we just swap it out # directly with the AOV suffix to embed it. # Note: ${AOV} seems to be evaluated in the parameter as %AOV% has_aov_in_prefix = "%AOV%" in prefix if has_aov_in_prefix: # It seems that when some special separator characters are present # before the %AOV% token that Redshift will secretly remove it if # there is no suffix for the current product, for example: # foo_%AOV% -> foo.exr pattern = "%AOV%" if suffix else "[._-]?%AOV%" product_name = re.sub(pattern, suffix, prefix, flags=re.IGNORECASE) else: if suffix: # Add ".{suffix}" before the extension prefix_base, ext = os.path.splitext(prefix) product_name = prefix_base + "." + suffix + ext else: product_name = prefix return product_name def generate_expected_files(self, instance, path): """Create expected files in instance data""" dir = os.path.dirname(path) file = os.path.basename(path) if "#" in file: def replace(match): return "%0{}d".format(len(match.group())) file = re.sub("#+", replace, file) if "%" not in file: return path expected_files = [] start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) return expected_files ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_remote_publish.py ================================================ import pyblish.api import hou from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api import lib class CollectRemotePublishSettings(pyblish.api.ContextPlugin): """Collect custom settings of the Remote Publish node.""" order = pyblish.api.CollectorOrder families = ["*"] hosts = ["houdini"] targets = ["deadline"] label = "Remote Publish Submission Settings" actions = [RepairAction] def process(self, context): node = hou.node("/out/REMOTE_PUBLISH") if not node: return attributes = lib.read(node) # Debug the settings we have collected for key, value in sorted(attributes.items()): self.log.debug("Collected %s: %s" % (key, value)) context.data.update(attributes) ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_render_products.py ================================================ import re import os import hou import pxr.UsdRender import pyblish.api def get_var_changed(variable=None): """Return changed variables and operators that use it. Note: `varchange` hscript states that it forces a recook of the nodes that use Variables. That was tested in Houdini 18.0.391. Args: variable (str, Optional): A specific variable to query the operators for. When None is provided it will return all variables that have had recent changes and require a recook. Defaults to None. Returns: dict: Variable that changed with the operators that use it. """ cmd = "varchange -V" if variable: cmd += " {0}".format(variable) output, _ = hou.hscript(cmd) changed = {} for line in output.split("Variable: "): if not line.strip(): continue split = line.split() var = split[0] operators = split[1:] changed[var] = operators return changed class CollectRenderProducts(pyblish.api.InstancePlugin): """Collect USD Render Products.""" label = "Collect Render Products" order = pyblish.api.CollectorOrder + 0.4 hosts = ["houdini"] families = ["usdrender"] def process(self, instance): node = instance.data.get("output_node") if not node: rop_path = instance.data["instance_node"].path() raise RuntimeError( "No output node found. Make sure to connect an " "input to the USD ROP: %s" % rop_path ) # Workaround Houdini 18.0.391 bug where $HIPNAME doesn't automatically # update after scene save. if hou.applicationVersion() == (18, 0, 391): self.log.debug( "Checking for recook to workaround " "$HIPNAME refresh bug..." ) changed = get_var_changed("HIPNAME").get("HIPNAME") if changed: self.log.debug("Recooking for $HIPNAME refresh bug...") for operator in changed: hou.node(operator).cook(force=True) # Make sure to recook any 'cache' nodes in the history chain chain = [node] chain.extend(node.inputAncestors()) for input_node in chain: if input_node.type().name() == "cache": input_node.cook(force=True) stage = node.stage() filenames = [] for prim in stage.Traverse(): if not prim.IsA(pxr.UsdRender.Product): continue # Get Render Product Name product = pxr.UsdRender.Product(prim) # We force taking it from any random time sample as opposed to # "default" that the USD Api falls back to since that won't return # time sampled values if they were set per time sample. name = product.GetProductNameAttr().Get(time=0) dirname = os.path.dirname(name) basename = os.path.basename(name) dollarf_regex = r"(\$F([0-9]?))" frame_regex = r"^(.+\.)([0-9]+)(\.[a-zA-Z]+)$" if re.match(dollarf_regex, basename): # TODO: Confirm this actually is allowed USD stages and HUSK # Substitute $F def replace(match): """Replace $F4 with padded #.""" padding = int(match.group(2)) if match.group(2) else 1 return "#" * padding filename_base = re.sub(dollarf_regex, replace, basename) filename = os.path.join(dirname, filename_base) else: # Substitute basename.0001.ext def replace(match): prefix, frame, ext = match.groups() padding = "#" * len(frame) return prefix + padding + ext filename_base = re.sub(frame_regex, replace, basename) filename = os.path.join(dirname, filename_base) filename = filename.replace("\\", "/") assert "#" in filename, ( "Couldn't resolve render product name " "with frame number: %s" % name ) filenames.append(filename) prim_path = str(prim.GetPath()) self.log.info("Collected %s name: %s" % (prim_path, filename)) # Filenames for Deadline instance.data["files"] = filenames ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_review_data.py ================================================ import hou import pyblish.api class CollectHoudiniReviewData(pyblish.api.InstancePlugin): """Collect Review Data.""" label = "Collect Review Data" # This specific order value is used so that # this plugin runs after CollectRopFrameRange order = pyblish.api.CollectorOrder + 0.1 hosts = ["houdini"] families = ["review"] def process(self, instance): # This fixes the burnin having the incorrect start/end timestamps # because without this it would take it from the context instead # which isn't the actual frame range that this instance renders. instance.data["handleStart"] = 0 instance.data["handleEnd"] = 0 instance.data["fps"] = instance.context.data["fps"] # Enable ftrack functionality instance.data.setdefault("families", []).append('ftrack') # Get the camera from the rop node to collect the focal length ropnode_path = instance.data["instance_node"] ropnode = hou.node(ropnode_path) camera_path = ropnode.parm("camera").eval() camera_node = hou.node(camera_path) if not camera_node: self.log.warning("No valid camera node found on review node: " "{}".format(camera_path)) return # Collect focal length. focal_length_parm = camera_node.parm("focal") if not focal_length_parm: self.log.warning("No 'focal' (focal length) parameter found on " "camera: {}".format(camera_path)) return if focal_length_parm.isTimeDependent(): start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] + 1 focal_length = [ focal_length_parm.evalAsFloatAtFrame(t) for t in range(int(start), int(end)) ] else: focal_length = focal_length_parm.evalAsFloat() # Store focal length in `burninDataMembers` burnin_members = instance.data.setdefault("burninDataMembers", {}) burnin_members["focalLength"] = focal_length ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py ================================================ # -*- coding: utf-8 -*- """Collector plugin for frames data on ROP instances.""" import hou # noqa import pyblish.api from openpype.hosts.houdini.api import lib class CollectRopFrameRange(pyblish.api.InstancePlugin): """Collect all frames which would be saved from the ROP nodes""" hosts = ["houdini"] order = pyblish.api.CollectorOrder label = "Collect RopNode Frame Range" def process(self, instance): node_path = instance.data.get("instance_node") if node_path is None: # Instance without instance node like a workfile instance self.log.debug( "No instance node found for instance: {}".format(instance) ) return ropnode = hou.node(node_path) frame_data = lib.get_frame_data( ropnode, self.log ) if not frame_data: return # Log debug message about the collected frame range self.log.debug( "Collected frame_data: {}".format(frame_data) ) instance.data.update(frame_data) ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py ================================================ # -*- coding: utf-8 -*- """Collector for staticMesh types. """ import pyblish.api class CollectStaticMeshType(pyblish.api.InstancePlugin): """Collect data type for fbx instance.""" hosts = ["houdini"] families = ["staticMesh"] label = "Collect type of staticMesh" order = pyblish.api.CollectorOrder def process(self, instance): if instance.data["creator_identifier"] == "io.openpype.creators.houdini.staticmesh.fbx": # noqa: E501 # Marking this instance as FBX triggers the FBX extractor. instance.data["families"] += ["fbx"] ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_usd_bootstrap.py ================================================ import pyblish.api from openpype.client import ( get_subset_by_name, get_asset_by_name, get_asset_name_identifier, ) import openpype.lib.usdlib as usdlib class CollectUsdBootstrap(pyblish.api.InstancePlugin): """Collect special Asset/Shot bootstrap instances if those are needed. Some specific subsets are intended to be part of the default structure of an "Asset" or "Shot" in our USD pipeline. For example, for an Asset we layer a Model and Shade USD file over each other and expose that in a Asset USD file, ready to use. On the first publish of any of the components of a Asset or Shot the missing pieces are bootstrapped and generated in the pipeline too. This means that on the very first publish of your model the Asset USD file will exist too. """ order = pyblish.api.CollectorOrder + 0.35 label = "Collect USD Bootstrap" hosts = ["houdini"] families = ["usd", "usd.layered"] def process(self, instance): # Detect whether the current subset is a subset in a pipeline def get_bootstrap(instance): instance_subset = instance.data["subset"] for name, layers in usdlib.PIPELINE.items(): if instance_subset in set(layers): return name # e.g. "asset" break else: return bootstrap = get_bootstrap(instance) if bootstrap: self.add_bootstrap(instance, bootstrap) # Check if any of the dependencies requires a bootstrap for dependency in instance.data.get("publishDependencies", list()): bootstrap = get_bootstrap(dependency) if bootstrap: self.add_bootstrap(dependency, bootstrap) def add_bootstrap(self, instance, bootstrap): self.log.debug("Add bootstrap for: %s" % bootstrap) project_name = instance.context.data["projectName"] asset_name = instance.data["asset"] asset_doc = get_asset_by_name(project_name, asset_name) assert asset_doc, "Asset must exist: %s" % asset_name # Check which are not about to be created and don't exist yet required = {"shot": ["usdShot"], "asset": ["usdAsset"]}.get(bootstrap) require_all_layers = instance.data.get("requireAllLayers", False) if require_all_layers: # USD files load fine in usdview and Houdini even when layered or # referenced files do not exist. So by default we don't require # the layers to exist. layers = usdlib.PIPELINE.get(bootstrap) if layers: required += list(layers) self.log.debug("Checking required bootstrap: %s" % required) for subset_name in required: if self._subset_exists( project_name, instance, subset_name, asset_doc ): continue self.log.debug( "Creating {0} USD bootstrap: {1} {2}".format( bootstrap, asset_name, subset_name ) ) new = instance.context.create_instance(subset_name) new.data["subset"] = subset_name new.data["label"] = "{0} ({1})".format(subset_name, asset_name) new.data["family"] = "usd.bootstrap" new.data["comment"] = "Automated bootstrap USD file." new.data["publishFamilies"] = ["usd"] # Do not allow the user to toggle this instance new.data["optional"] = False # Copy some data from the instance for which we bootstrap for key in ["asset"]: new.data[key] = instance.data[key] def _subset_exists(self, project_name, instance, subset_name, asset_doc): """Return whether subset exists in current context or in database.""" # Allow it to be created during this publish session context = instance.context asset_doc_name = get_asset_name_identifier(asset_doc) for inst in context: if ( inst.data["subset"] == subset_name and inst.data["asset"] == asset_doc_name ): return True # Or, if they already exist in the database we can # skip them too. if get_subset_by_name( project_name, subset_name, asset_doc["_id"], fields=["_id"] ): return True return False ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_usd_layers.py ================================================ import os import pyblish.api import openpype.hosts.houdini.api.usd as usdlib import hou class CollectUsdLayers(pyblish.api.InstancePlugin): """Collect the USD Layers that have configured save paths.""" order = pyblish.api.CollectorOrder + 0.35 label = "Collect USD Layers" hosts = ["houdini"] families = ["usd"] def process(self, instance): output = instance.data.get("output_node") if not output: self.log.debug("No output node found..") return rop_node = hou.node(instance.data["instance_node"]) save_layers = [] for layer in usdlib.get_configured_save_layers(rop_node): info = layer.rootPrims.get("HoudiniLayerInfo") save_path = info.customData.get("HoudiniSavePath") creator = info.customData.get("HoudiniCreatorNode") self.log.debug("Found configured save path: " "%s -> %s" % (layer, save_path)) # Log node that configured this save path if creator: self.log.debug("Created by: %s" % creator) save_layers.append((layer, save_path)) # Store on the instance instance.data["usdConfiguredSavePaths"] = save_layers # Create configured layer instances so User can disable updating # specific configured layers for publishing. context = instance.context for layer, save_path in save_layers: name = os.path.basename(save_path) label = "{0} -> {1}".format(instance.data["name"], name) layer_inst = context.create_instance(name) family = "usdlayer" layer_inst.data["family"] = family layer_inst.data["families"] = [family] layer_inst.data["subset"] = "__stub__" layer_inst.data["label"] = label layer_inst.data["asset"] = instance.data["asset"] layer_inst.data["instance_node"] = instance.data["instance_node"] # include same USD ROP layer_inst.append(rop_node) # include layer data layer_inst.append((layer, save_path)) # Allow this subset to be grouped into a USD Layer on creation layer_inst.data["subsetGroup"] = "USD Layer" ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_vray_rop.py ================================================ import re import os import hou import pyblish.api from openpype.hosts.houdini.api.lib import ( evalParmNoFrame, get_color_management_preferences ) from openpype.hosts.houdini.api import ( colorspace ) class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): """Collect Vray Render Products Collects the instance.data["files"] for the render products. Provides: instance -> files """ label = "VRay ROP Render Products" # This specific order value is used so that # this plugin runs after CollectFrames order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["vray_rop"] def process(self, instance): rop = hou.node(instance.data.get("instance_node")) # Collect chunkSize chunk_size_parm = rop.parm("chunkSize") if chunk_size_parm: chunk_size = int(chunk_size_parm.eval()) instance.data["chunkSize"] = chunk_size self.log.debug("Chunk Size: %s" % chunk_size) default_prefix = evalParmNoFrame(rop, "SettingsOutput_img_file_path") render_products = [] # TODO: add render elements if render element # Store whether we are splitting the render job in an export + render split_render = rop.parm("render_export_mode").eval() == "2" instance.data["splitRender"] = split_render export_prefix = None export_products = [] if split_render: export_prefix = evalParmNoFrame( rop, "render_export_filepath", pad_character="0" ) beauty_export_product = self.get_render_product_name( prefix=export_prefix, suffix=None) export_products.append(beauty_export_product) self.log.debug( "Found export product: {}".format(beauty_export_product) ) instance.data["ifdFile"] = beauty_export_product instance.data["exportFiles"] = list(export_products) beauty_product = self.get_render_product_name(default_prefix) render_products.append(beauty_product) files_by_aov = { "": self.generate_expected_files(instance, beauty_product)} if instance.data.get("RenderElement", True): render_element = self.get_render_element_name(rop, default_prefix) if render_element: for aov, renderpass in render_element.items(): render_products.append(renderpass) files_by_aov[aov] = self.generate_expected_files( instance, renderpass) for product in render_products: self.log.debug("Found render product: %s" % product) filenames = list(render_products) instance.data["files"] = filenames instance.data["renderProducts"] = colorspace.ARenderProduct() # For now by default do NOT try to publish the rendered output instance.data["publishJobState"] = "Suspended" instance.data["attachTo"] = [] # stub required data if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() instance.data["expectedFiles"].append(files_by_aov) self.log.debug("expectedFiles:{}".format(files_by_aov)) # update the colorspace data colorspace_data = get_color_management_preferences() instance.data["colorspaceConfig"] = colorspace_data["config"] instance.data["colorspaceDisplay"] = colorspace_data["display"] instance.data["colorspaceView"] = colorspace_data["view"] def get_render_product_name(self, prefix, suffix=""): """Return the beauty output filename if render element enabled """ # Remove aov suffix from the product: `prefix.aov_suffix` -> `prefix` aov_parm = ".{}".format(suffix) return prefix.replace(aov_parm, "") def get_render_element_name(self, node, prefix, suffix=""): """Return the output filename using the AOV prefix and suffix """ render_element_dict = {} # need a rewrite re_path = node.evalParm("render_network_render_channels") if re_path: node_children = hou.node(re_path).children() for element in node_children: if element.shaderName() != "vray:SettingsRenderChannels": aov = str(element) render_product = prefix.replace(suffix, aov) render_element_dict[aov] = render_product return render_element_dict def generate_expected_files(self, instance, path): """Create expected files in instance data""" dir = os.path.dirname(path) file = os.path.basename(path) if "#" in file: def replace(match): return "%0{}d".format(len(match.group())) file = re.sub("#+", replace, file) if "%" not in file: return path expected_files = [] start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] for i in range(int(start), (int(end) + 1)): expected_files.append( os.path.join(dir, (file % i)).replace("\\", "/")) return expected_files ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_workfile.py ================================================ import os import pyblish.api class CollectWorkfile(pyblish.api.InstancePlugin): """Inject workfile representation into instance""" order = pyblish.api.CollectorOrder - 0.01 label = "Houdini Workfile Data" hosts = ["houdini"] families = ["workfile"] def process(self, instance): current_file = instance.context.data["currentFile"] folder, file = os.path.split(current_file) filename, ext = os.path.splitext(file) instance.data.update({ "setMembers": [current_file], "frameStart": instance.context.data['frameStart'], "frameEnd": instance.context.data['frameEnd'], "handleStart": instance.context.data['handleStart'], "handleEnd": instance.context.data['handleEnd'] }) instance.data['representations'] = [{ 'name': ext.lstrip("."), 'ext': ext.lstrip("."), 'files': file, "stagingDir": folder, }] self.log.debug('Collected workfile instance: {}'.format(file)) ================================================ FILE: openpype/hosts/houdini/plugins/publish/collect_workscene_fps.py ================================================ import pyblish.api import hou class CollectWorksceneFPS(pyblish.api.ContextPlugin): """Get the FPS of the work scene.""" label = "Workscene FPS" order = pyblish.api.CollectorOrder hosts = ["houdini"] def process(self, context): fps = hou.fps() self.log.info("Workscene FPS: %s" % fps) context.data.update({"fps": fps}) ================================================ FILE: openpype/hosts/houdini/plugins/publish/extract_alembic.py ================================================ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop import hou class ExtractAlembic(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract Alembic" hosts = ["houdini"] families = ["abc", "camera"] targets = ["local", "remote"] def process(self, instance): if instance.data.get("farm"): self.log.debug("Should be processed on farm, skipping.") return ropnode = hou.node(instance.data["instance_node"]) # Get the filename from the filename parameter output = ropnode.evalParm("filename") staging_dir = os.path.dirname(output) instance.data["stagingDir"] = staging_dir file_name = os.path.basename(output) # We run the render self.log.info("Writing alembic '%s' to '%s'" % (file_name, staging_dir)) render_rop(ropnode) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'abc', 'ext': 'abc', 'files': file_name, "stagingDir": staging_dir, } instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/houdini/plugins/publish/extract_ass.py ================================================ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop import hou class ExtractAss(publish.Extractor): order = pyblish.api.ExtractorOrder + 0.1 label = "Extract Ass" families = ["ass"] hosts = ["houdini"] targets = ["local", "remote"] def process(self, instance): if instance.data.get("farm"): self.log.debug("Should be processed on farm, skipping.") return ropnode = hou.node(instance.data["instance_node"]) # Get the filename from the filename parameter # `.evalParm(parameter)` will make sure all tokens are resolved output = ropnode.evalParm("ar_ass_file") staging_dir = os.path.dirname(output) instance.data["stagingDir"] = staging_dir file_name = os.path.basename(output) # We run the render self.log.info("Writing ASS '%s' to '%s'" % (file_name, staging_dir)) render_rop(ropnode) # Unfortunately user interrupting the extraction does not raise an # error and thus still continues to the integrator. To capture that # we make sure all files exist files = instance.data["frames"] missing = [] for file_name in files: full_path = os.path.normpath(os.path.join(staging_dir, file_name)) if not os.path.exists(full_path): missing.append(full_path) if missing: raise RuntimeError("Failed to complete Arnold ass extraction. " "Missing output files: {}".format(missing)) if "representations" not in instance.data: instance.data["representations"] = [] # Allow ass.gz extension as well ext = "ass.gz" if file_name.endswith(".ass.gz") else "ass" representation = { 'name': 'ass', 'ext': ext, "files": files, "stagingDir": staging_dir, "frameStart": instance.data["frameStartHandle"], "frameEnd": instance.data["frameEndHandle"], } instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/houdini/plugins/publish/extract_bgeo.py ================================================ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop from openpype.hosts.houdini.api import lib import hou class ExtractBGEO(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract BGEO" hosts = ["houdini"] families = ["bgeo"] def process(self, instance): if instance.data.get("farm"): self.log.debug("Should be processed on farm, skipping.") return ropnode = hou.node(instance.data["instance_node"]) # Get the filename from the filename parameter output = ropnode.evalParm("sopoutput") staging_dir, file_name = os.path.split(output) instance.data["stagingDir"] = staging_dir # We run the render self.log.info("Writing bgeo files '{}' to '{}'.".format( file_name, staging_dir)) # write files render_rop(ropnode) output = instance.data["frames"] _, ext = lib.splitext( output[0], allowed_multidot_extensions=[ ".ass.gz", ".bgeo.sc", ".bgeo.gz", ".bgeo.lzma", ".bgeo.bz2"]) if "representations" not in instance.data: instance.data["representations"] = [] representation = { "name": "bgeo", "ext": ext.lstrip("."), "files": output, "stagingDir": staging_dir, "frameStart": instance.data["frameStartHandle"], "frameEnd": instance.data["frameEndHandle"] } instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/houdini/plugins/publish/extract_composite.py ================================================ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop, splitext import hou class ExtractComposite(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract Composite (Image Sequence)" hosts = ["houdini"] families = ["imagesequence"] def process(self, instance): ropnode = hou.node(instance.data["instance_node"]) # Get the filename from the copoutput parameter # `.evalParm(parameter)` will make sure all tokens are resolved output = ropnode.evalParm("copoutput") staging_dir = os.path.dirname(output) instance.data["stagingDir"] = staging_dir file_name = os.path.basename(output) self.log.info("Writing comp '%s' to '%s'" % (file_name, staging_dir)) render_rop(ropnode) output = instance.data["frames"] _, ext = splitext(output[0], []) ext = ext.lstrip(".") if "representations" not in instance.data: instance.data["representations"] = [] representation = { "name": ext, "ext": ext, "files": output, "stagingDir": staging_dir, "frameStart": instance.data["frameStartHandle"], "frameEnd": instance.data["frameEndHandle"], } from pprint import pformat self.log.info(pformat(representation)) instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/houdini/plugins/publish/extract_fbx.py ================================================ # -*- coding: utf-8 -*- """Fbx Extractor for houdini. """ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop import hou class ExtractFBX(publish.Extractor): label = "Extract FBX" families = ["fbx"] hosts = ["houdini"] order = pyblish.api.ExtractorOrder + 0.1 def process(self, instance): # get rop node ropnode = hou.node(instance.data.get("instance_node")) output_file = ropnode.evalParm("sopoutput") # get staging_dir and file_name staging_dir = os.path.normpath(os.path.dirname(output_file)) file_name = os.path.basename(output_file) # render rop self.log.debug("Writing FBX '%s' to '%s'", file_name, staging_dir) render_rop(ropnode) # prepare representation representation = { "name": "fbx", "ext": "fbx", "files": file_name, "stagingDir": staging_dir } # A single frame may also be rendered without start/end frame. if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: # noqa representation["frameStart"] = instance.data["frameStartHandle"] representation["frameEnd"] = instance.data["frameEndHandle"] # set value type for 'representations' key to list if "representations" not in instance.data: instance.data["representations"] = [] # update instance data instance.data["stagingDir"] = staging_dir instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/houdini/plugins/publish/extract_hda.py ================================================ # -*- coding: utf-8 -*- import os from pprint import pformat import pyblish.api from openpype.pipeline import publish import hou class ExtractHDA(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract HDA" hosts = ["houdini"] families = ["hda"] def process(self, instance): self.log.info(pformat(instance.data)) hda_node = hou.node(instance.data.get("instance_node")) hda_def = hda_node.type().definition() hda_options = hda_def.options() hda_options.setSaveInitialParmsAndContents(True) next_version = instance.data["anatomyData"]["version"] self.log.info("setting version: {}".format(next_version)) hda_def.setVersion(str(next_version)) hda_def.setOptions(hda_options) hda_def.save(hda_def.libraryFilePath(), hda_node, hda_options) if "representations" not in instance.data: instance.data["representations"] = [] file = os.path.basename(hda_def.libraryFilePath()) staging_dir = os.path.dirname(hda_def.libraryFilePath()) self.log.info("Using HDA from {}".format(hda_def.libraryFilePath())) representation = { 'name': 'hda', 'ext': 'hda', 'files': file, "stagingDir": staging_dir, } instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/houdini/plugins/publish/extract_mantra_ifd.py ================================================ import os import pyblish.api from openpype.pipeline import publish import hou class ExtractMantraIFD(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract Mantra ifd" hosts = ["houdini"] families = ["mantraifd"] targets = ["local", "remote"] def process(self, instance): if instance.data.get("farm"): self.log.debug("Should be processed on farm, skipping.") return ropnode = hou.node(instance.data.get("instance_node")) output = ropnode.evalParm("soho_diskfile") staging_dir = os.path.dirname(output) instance.data["stagingDir"] = staging_dir files = instance.data["frames"] missing_frames = [ frame for frame in instance.data["frames"] if not os.path.exists( os.path.normpath(os.path.join(staging_dir, frame))) ] if missing_frames: raise RuntimeError("Failed to complete Mantra ifd extraction. " "Missing output files: {}".format( missing_frames)) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'ifd', 'ext': 'ifd', 'files': files, "stagingDir": staging_dir, "frameStart": instance.data["frameStart"], "frameEnd": instance.data["frameEnd"], } instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/houdini/plugins/publish/extract_opengl.py ================================================ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop import hou class ExtractOpenGL(publish.Extractor): order = pyblish.api.ExtractorOrder - 0.01 label = "Extract OpenGL" families = ["review"] hosts = ["houdini"] def process(self, instance): ropnode = hou.node(instance.data.get("instance_node")) output = ropnode.evalParm("picture") staging_dir = os.path.normpath(os.path.dirname(output)) instance.data["stagingDir"] = staging_dir file_name = os.path.basename(output) self.log.info("Extracting '%s' to '%s'" % (file_name, staging_dir)) render_rop(ropnode) output = instance.data["frames"] tags = ["review"] if not instance.data.get("keepImages"): tags.append("delete") representation = { "name": instance.data["imageFormat"], "ext": instance.data["imageFormat"], "files": output, "stagingDir": staging_dir, "frameStart": instance.data["frameStartHandle"], "frameEnd": instance.data["frameEndHandle"], "tags": tags, "preview": True, "camera_name": instance.data.get("review_camera") } if "representations" not in instance.data: instance.data["representations"] = [] instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py ================================================ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop import hou class ExtractRedshiftProxy(publish.Extractor): order = pyblish.api.ExtractorOrder + 0.1 label = "Extract Redshift Proxy" families = ["redshiftproxy"] hosts = ["houdini"] targets = ["local", "remote"] def process(self, instance): if instance.data.get("farm"): self.log.debug("Should be processed on farm, skipping.") return ropnode = hou.node(instance.data.get("instance_node")) # Get the filename from the filename parameter # `.evalParm(parameter)` will make sure all tokens are resolved output = ropnode.evalParm("RS_archive_file") staging_dir = os.path.normpath(os.path.dirname(output)) instance.data["stagingDir"] = staging_dir file_name = os.path.basename(output) self.log.info("Writing Redshift Proxy '%s' to '%s'" % (file_name, staging_dir)) render_rop(ropnode) output = instance.data["frames"] if "representations" not in instance.data: instance.data["representations"] = [] representation = { "name": "rs", "ext": "rs", "files": output, "stagingDir": staging_dir, } # A single frame may also be rendered without start/end frame. if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: # noqa representation["frameStart"] = instance.data["frameStartHandle"] representation["frameEnd"] = instance.data["frameEndHandle"] instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/houdini/plugins/publish/extract_usd.py ================================================ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop import hou class ExtractUSD(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract USD" hosts = ["houdini"] families = ["usd", "usdModel", "usdSetDress"] def process(self, instance): ropnode = hou.node(instance.data.get("instance_node")) # Get the filename from the filename parameter output = ropnode.evalParm("lopoutput") staging_dir = os.path.dirname(output) instance.data["stagingDir"] = staging_dir file_name = os.path.basename(output) self.log.info("Writing USD '%s' to '%s'" % (file_name, staging_dir)) render_rop(ropnode) assert os.path.exists(output), "Output does not exist: %s" % output if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'usd', 'ext': 'usd', 'files': file_name, "stagingDir": staging_dir, } instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/houdini/plugins/publish/extract_usd_layered.py ================================================ import os import contextlib import hou import sys from collections import deque import pyblish.api from openpype.client import ( get_asset_by_name, get_subset_by_name, get_last_version_by_subset_id, get_representation_by_name, ) from openpype.pipeline import ( get_representation_path, publish, ) import openpype.hosts.houdini.api.usd as hou_usdlib from openpype.hosts.houdini.api.lib import render_rop class ExitStack(object): """Context manager for dynamic management of a stack of exit callbacks. For example: with ExitStack() as stack: files = [stack.enter_context(open(fname)) for fname in filenames] # All opened files will automatically be closed at the end of # the with statement, even if attempts to open files later # in the list raise an exception """ def __init__(self): self._exit_callbacks = deque() def pop_all(self): """Preserve the context stack by transferring it to a new instance""" new_stack = type(self)() new_stack._exit_callbacks = self._exit_callbacks self._exit_callbacks = deque() return new_stack def _push_cm_exit(self, cm, cm_exit): """Helper to correctly register callbacks to __exit__ methods""" def _exit_wrapper(*exc_details): return cm_exit(cm, *exc_details) _exit_wrapper.__self__ = cm self.push(_exit_wrapper) def push(self, exit): """Registers a callback with the standard __exit__ method signature. Can suppress exceptions the same way __exit__ methods can. Also accepts any object with an __exit__ method (registering a call to the method instead of the object itself) """ # We use an unbound method rather than a bound method to follow # the standard lookup behaviour for special methods _cb_type = type(exit) try: exit_method = _cb_type.__exit__ except AttributeError: # Not a context manager, so assume its a callable self._exit_callbacks.append(exit) else: self._push_cm_exit(exit, exit_method) return exit # Allow use as a decorator def callback(self, callback, *args, **kwds): """Registers an arbitrary callback and arguments. Cannot suppress exceptions. """ def _exit_wrapper(exc_type, exc, tb): callback(*args, **kwds) # We changed the signature, so using @wraps is not appropriate, but # setting __wrapped__ may still help with introspection _exit_wrapper.__wrapped__ = callback self.push(_exit_wrapper) return callback # Allow use as a decorator def enter_context(self, cm): """Enters the supplied context manager If successful, also pushes its __exit__ method as a callback and returns the result of the __enter__ method. """ # We look up the special methods on the type to match the with # statement _cm_type = type(cm) _exit = _cm_type.__exit__ result = _cm_type.__enter__(cm) self._push_cm_exit(cm, _exit) return result def close(self): """Immediately unwind the context stack""" self.__exit__(None, None, None) def __enter__(self): return self def __exit__(self, *exc_details): # We manipulate the exception state so it behaves as though # we were actually nesting multiple with statements frame_exc = sys.exc_info()[1] def _fix_exception_context(new_exc, old_exc): while 1: exc_context = new_exc.__context__ if exc_context in (None, frame_exc): break new_exc = exc_context new_exc.__context__ = old_exc # Callbacks are invoked in LIFO order to match the behaviour of # nested context managers suppressed_exc = False while self._exit_callbacks: cb = self._exit_callbacks.pop() try: if cb(*exc_details): suppressed_exc = True exc_details = (None, None, None) except Exception: new_exc_details = sys.exc_info() # simulate the stack of exceptions by setting the context _fix_exception_context(new_exc_details[1], exc_details[1]) if not self._exit_callbacks: raise exc_details = new_exc_details return suppressed_exc @contextlib.contextmanager def parm_values(overrides): """Override Parameter values during the context.""" originals = [] try: for parm, value in overrides: originals.append((parm, parm.eval())) parm.set(value) yield finally: for parm, value in originals: # Parameter might not exist anymore so first # check whether it's still valid if hou.parm(parm.path()): parm.set(value) class ExtractUSDLayered(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract Layered USD" hosts = ["houdini"] families = ["usdLayered", "usdShade"] # Force Output Processors so it will always save any file # into our unique staging directory with processed Avalon paths output_processors = ["avalon_uri_processor", "stagingdir_processor"] def process(self, instance): self.log.info("Extracting: %s" % instance) staging_dir = self.staging_dir(instance) fname = instance.data.get("usdFilename") # The individual rop nodes are collected as "publishDependencies" dependencies = instance.data["publishDependencies"] ropnodes = [dependency[0] for dependency in dependencies] assert all( node.type().name() in {"usd", "usd_rop"} for node in ropnodes ) # Main ROP node, either a USD Rop or ROP network with # multiple USD ROPs node = hou.node(instance.data["instance_node"]) # Collect any output dependencies that have not been processed yet # during extraction of other instances outputs = [fname] active_dependencies = [ dep for dep in dependencies if dep.data.get("publish", True) and not dep.data.get("_isExtracted", False) ] for dependency in active_dependencies: outputs.append(dependency.data["usdFilename"]) pattern = r"*[/\]{0} {0}" save_pattern = " ".join(pattern.format(fname) for fname in outputs) # Run a stack of context managers before we start the render to # temporarily adjust USD ROP settings for our publish output. rop_overrides = { # This sets staging directory on the processor to force our # output files to end up in the Staging Directory. "stagingdiroutputprocessor_stagingDir": staging_dir, # Force the Avalon URI Output Processor to refactor paths for # references, payloads and layers to published paths. "avalonurioutputprocessor_use_publish_paths": True, # Only write out specific USD files based on our outputs "savepattern": save_pattern, } overrides = list() with ExitStack() as stack: for ropnode in ropnodes: manager = hou_usdlib.outputprocessors( ropnode, processors=self.output_processors, disable_all_others=True, ) stack.enter_context(manager) # Some of these must be added after we enter the output # processor context manager because those parameters only # exist when the Output Processor is added to the ROP node. for name, value in rop_overrides.items(): parm = ropnode.parm(name) assert parm, "Parm not found: %s.%s" % ( ropnode.path(), name, ) overrides.append((parm, value)) stack.enter_context(parm_values(overrides)) # Render the single ROP node or the full ROP network render_rop(node) # Assert all output files in the Staging Directory for output_fname in outputs: path = os.path.join(staging_dir, output_fname) assert os.path.exists(path), "Output file must exist: %s" % path # Set up the dependency for publish if they have new content # compared to previous publishes project_name = instance.context.data["projectName"] for dependency in active_dependencies: dependency_fname = dependency.data["usdFilename"] filepath = os.path.join(staging_dir, dependency_fname) similar = self._compare_with_latest_publish( project_name, dependency, filepath ) if similar: # Deactivate this dependency self.log.debug( "Dependency matches previous publish version," " deactivating %s for publish" % dependency ) dependency.data["publish"] = False else: self.log.debug("Extracted dependency: %s" % dependency) # This dependency should be published dependency.data["files"] = [dependency_fname] dependency.data["stagingDir"] = staging_dir dependency.data["_isExtracted"] = True # Store the created files on the instance if "files" not in instance.data: instance.data["files"] = [] instance.data["files"].append(fname) def _compare_with_latest_publish(self, project_name, dependency, new_file): import filecmp _, ext = os.path.splitext(new_file) # Compare this dependency with the latest published version # to detect whether we should make this into a new publish # version. If not, skip it. asset = get_asset_by_name( project_name, dependency.data["asset"], fields=["_id"] ) subset = get_subset_by_name( project_name, dependency.data["subset"], asset["_id"], fields=["_id"] ) if not subset: # Subset doesn't exist yet. Definitely new file self.log.debug("No existing subset..") return False version = get_last_version_by_subset_id( project_name, subset["_id"], fields=["_id"] ) if not version: self.log.debug("No existing version..") return False representation = get_representation_by_name( project_name, ext.lstrip("."), version["_id"] ) if not representation: self.log.debug("No existing representation..") return False old_file = get_representation_path(representation) if not os.path.exists(old_file): return False return filecmp.cmp(old_file, new_file) ================================================ FILE: openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py ================================================ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop import hou class ExtractVDBCache(publish.Extractor): order = pyblish.api.ExtractorOrder + 0.1 label = "Extract VDB Cache" families = ["vdbcache"] hosts = ["houdini"] def process(self, instance): if instance.data.get("farm"): self.log.debug("Should be processed on farm, skipping.") return ropnode = hou.node(instance.data["instance_node"]) # Get the filename from the filename parameter # `.evalParm(parameter)` will make sure all tokens are resolved sop_output = ropnode.evalParm("sopoutput") staging_dir = os.path.normpath(os.path.dirname(sop_output)) instance.data["stagingDir"] = staging_dir file_name = os.path.basename(sop_output) self.log.info("Writing VDB '%s' to '%s'" % (file_name, staging_dir)) render_rop(ropnode) output = instance.data["frames"] if "representations" not in instance.data: instance.data["representations"] = [] representation = { "name": "vdb", "ext": "vdb", "files": output, "stagingDir": staging_dir, "frameStart": instance.data["frameStartHandle"], "frameEnd": instance.data["frameEndHandle"], } instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/houdini/plugins/publish/help/validate_vdb_output_node.xml ================================================ Invalid VDB ## Invalid VDB output All primitives of the output geometry must be VDBs, no other primitive types are allowed. That means that regardless of the amount of VDBs in the geometry it will have an equal amount of VDBs, points, primitives and vertices since each VDB primitive is one point, one vertex and one VDB. This validation only checks the geometry on the first frame of the export frame range. ### Detailed Info ROP node `{rop_path}` is set to export SOP path `{sop_path}`. {message} ================================================ FILE: openpype/hosts/houdini/plugins/publish/increment_current_file.py ================================================ import pyblish.api from openpype.lib import version_up from openpype.pipeline import registered_host from openpype.pipeline.publish import get_errored_plugins_from_context from openpype.hosts.houdini.api import HoudiniHost from openpype.pipeline.publish import KnownPublishError class IncrementCurrentFile(pyblish.api.ContextPlugin): """Increment the current file. Saves the current scene with an increased version number. """ label = "Increment current file" order = pyblish.api.IntegratorOrder + 9.0 hosts = ["houdini"] families = ["workfile", "redshift_rop", "arnold_rop", "mantra_rop", "karma_rop", "usdrender", "publish.hou"] optional = True def process(self, context): errored_plugins = get_errored_plugins_from_context(context) if any( plugin.__name__ == "HoudiniSubmitPublishDeadline" for plugin in errored_plugins ): raise KnownPublishError( "Skipping incrementing current file because " "submission to deadline failed." ) # Filename must not have changed since collecting host = registered_host() # type: HoudiniHost current_file = host.current_file() if context.data["currentFile"] != current_file: raise KnownPublishError( "Collected filename mismatches from current scene name." ) new_filepath = version_up(current_file) host.save_workfile(new_filepath) ================================================ FILE: openpype/hosts/houdini/plugins/publish/save_scene.py ================================================ import pyblish.api from openpype.pipeline import registered_host class SaveCurrentScene(pyblish.api.ContextPlugin): """Save current scene""" label = "Save current file" order = pyblish.api.ExtractorOrder - 0.49 hosts = ["houdini"] def process(self, context): # Filename must not have changed since collecting host = registered_host() current_file = host.get_current_workfile() assert context.data['currentFile'] == current_file, ( "Collected filename from current scene name." ) if host.workfile_has_unsaved_changes(): self.log.info("Saving current file: {}".format(current_file)) host.save_workfile(current_file) else: self.log.debug("No unsaved changes, skipping file save..") ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from collections import defaultdict from openpype.pipeline import PublishValidationError class ValidateAbcPrimitiveToDetail(pyblish.api.InstancePlugin): """Validate Alembic ROP Primitive to Detail attribute is consistent. The Alembic ROP crashes Houdini whenever an attribute in the "Primitive to Detail" parameter exists on only a part of the primitives that belong to the same hierarchy path. Whenever it encounters inconsistent values, specifically where some are empty as opposed to others then Houdini crashes. (Tested in Houdini 17.5.229) """ order = pyblish.api.ValidatorOrder + 0.1 families = ["abc"] hosts = ["houdini"] label = "Validate Primitive to Detail (Abc)" def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( ("Primitives found with inconsistent primitive " "to detail attributes. See log."), title=self.label ) @classmethod def get_invalid(cls, instance): import hou # noqa output_node = instance.data.get("output_node") rop_node = hou.node(instance.data["instance_node"]) if output_node is None: cls.log.error( "SOP Output node in '%s' does not exist. " "Ensure a valid SOP output path is set." % rop_node.path() ) return [rop_node.path()] pattern = rop_node.parm("prim_to_detail_pattern").eval().strip() if not pattern: cls.log.debug( "Alembic ROP has no 'Primitive to Detail' pattern. " "Validation is ignored.." ) return build_from_path = rop_node.parm("build_from_path").eval() if not build_from_path: cls.log.debug( "Alembic ROP has 'Build from Path' disabled. " "Validation is ignored.." ) return path_attr = rop_node.parm("path_attrib").eval() if not path_attr: cls.log.error( "The Alembic ROP node has no Path Attribute" "value set, but 'Build Hierarchy from Attribute'" "is enabled." ) return [rop_node.path()] # Let's assume each attribute is explicitly named for now and has no # wildcards for Primitive to Detail. This simplifies the check. cls.log.debug("Checking Primitive to Detail pattern: %s" % pattern) cls.log.debug("Checking with path attribute: %s" % path_attr) if not hasattr(output_node, "geometry"): # In the case someone has explicitly set an Object # node instead of a SOP node in Geometry context # then for now we ignore - this allows us to also # export object transforms. cls.log.warning("No geometry output node found, skipping check..") return # Check if the primitive attribute exists frame = instance.data.get("frameStart", 0) geo = output_node.geometryAtFrame(frame) # If there are no primitives on the start frame then it might be # something that is emitted over time. As such we can't actually # validate whether the attributes exist, because they won't exist # yet. In that case, just warn the user and allow it. if len(geo.iterPrims()) == 0: cls.log.warning( "No primitives found on current frame. Validation" " for Primitive to Detail will be skipped." ) return attrib = geo.findPrimAttrib(path_attr) if not attrib: cls.log.info( "Geometry Primitives are missing " "path attribute: `%s`" % path_attr ) return [output_node.path()] # Ensure at least a single string value is present if not attrib.strings(): cls.log.info( "Primitive path attribute has no " "string values: %s" % path_attr ) return [output_node.path()] paths = None for attr in pattern.split(" "): if not attr.strip(): # Ignore empty values continue # Check if the primitive attribute exists attrib = geo.findPrimAttrib(attr) if not attrib: # It is allowed to not have the attribute at all continue # The issue can only happen if at least one string attribute is # present. So we ignore cases with no values whatsoever. if not attrib.strings(): continue check = defaultdict(set) values = geo.primStringAttribValues(attr) if paths is None: paths = geo.primStringAttribValues(path_attr) for path, value in zip(paths, values): check[path].add(value) for path, values in check.items(): # Whenever a single path has multiple values for the # Primitive to Detail attribute then we consider it # inconsistent and invalidate the ROP node's content. if len(values) > 1: cls.log.warning( "Path has multiple values: %s (path: %s)" % (list(values), path) ) return [output_node.path()] ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_alembic_face_sets.py ================================================ # -*- coding: utf-8 -*- import pyblish.api import hou class ValidateAlembicROPFaceSets(pyblish.api.InstancePlugin): """Validate Face Sets are disabled for extraction to pointcache. When groups are saved as Face Sets with the Alembic these show up as shadingEngine connections in Maya - however, with animated groups these connections in Maya won't work as expected, it won't update per frame. Additionally, it can break shader assignments in some cases where it requires to first break this connection to allow a shader to be assigned. It is allowed to include Face Sets, so only an issue is logged to identify that it could introduce issues down the pipeline. """ order = pyblish.api.ValidatorOrder + 0.1 families = ["abc"] hosts = ["houdini"] label = "Validate Alembic ROP Face Sets" def process(self, instance): rop = hou.node(instance.data["instance_node"]) facesets = rop.parm("facesets").eval() # 0 = No Face Sets # 1 = Save Non-Empty Groups as Face Sets # 2 = Save All Groups As Face Sets if facesets != 0: self.log.warning( "Alembic ROP saves 'Face Sets' for Geometry. " "Are you sure you want this?" ) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError import hou class ValidateAlembicInputNode(pyblish.api.InstancePlugin): """Validate that the node connected to the output is correct. The connected node cannot be of the following types for Alembic: - VDB - Volume """ order = pyblish.api.ValidatorOrder + 0.1 families = ["abc"] hosts = ["houdini"] label = "Validate Input Node (Abc)" def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( ("Primitive types found that are not supported " "for Alembic output."), title=self.label ) @classmethod def get_invalid(cls, instance): invalid_prim_types = ["VDB", "Volume"] output_node = instance.data.get("output_node") if output_node is None: node = hou.node(instance.data["instance_node"]) cls.log.error( "SOP Output node in '%s' does not exist. " "Ensure a valid SOP output path is set." % node.path() ) return [node.path()] if not hasattr(output_node, "geometry"): # In the case someone has explicitly set an Object # node instead of a SOP node in Geometry context # then for now we ignore - this allows us to also # export object transforms. cls.log.warning("No geometry output node found, skipping check..") return frame = instance.data.get("frameStart", 0) geo = output_node.geometryAtFrame(frame) invalid = False for prim_type in invalid_prim_types: if geo.countPrimType(prim_type) > 0: cls.log.error( "Found a primitive which is of type '%s' !" % prim_type ) invalid = True if invalid: return [instance] ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_animation_settings.py ================================================ import pyblish.api from openpype.pipeline.publish import PublishValidationError from openpype.hosts.houdini.api import lib import hou class ValidateAnimationSettings(pyblish.api.InstancePlugin): """Validate if the unexpanded string contains the frame ('$F') token This validator will only check the output parameter of the node if the Valid Frame Range is not set to 'Render Current Frame' Rules: If you render out a frame range it is mandatory to have the frame token - '$F4' or similar - to ensure that each frame gets written. If this is not the case you will override the same file every time a frame is written out. Examples: Good: 'my_vbd_cache.$F4.vdb' Bad: 'my_vbd_cache.vdb' """ order = pyblish.api.ValidatorOrder label = "Validate Frame Settings" families = ["vdbcache"] def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Output settings do no match for '%s'" % instance ) @classmethod def get_invalid(cls, instance): node = hou.node(instance.data["instance_node"]) # Check trange parm, 0 means Render Current Frame frame_range = node.evalParm("trange") if frame_range == 0: return [] output_parm = lib.get_output_parameter(node) unexpanded_str = output_parm.unexpandedString() if "$F" not in unexpanded_str: cls.log.error("No frame token found in '%s'" % node.path()) return [instance] ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_bypass.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError import hou class ValidateBypassed(pyblish.api.InstancePlugin): """Validate all primitives build hierarchy from attribute when enabled. The name of the attribute must exist on the prims and have the same name as Build Hierarchy from Attribute's `Path Attribute` value on the Alembic ROP node whenever Build Hierarchy from Attribute is enabled. """ order = pyblish.api.ValidatorOrder - 0.1 families = ["*"] hosts = ["houdini"] label = "Validate ROP Bypass" def process(self, instance): if len(instance) == 0: # Ignore instances without any nodes # e.g. in memory bootstrap instances return invalid = self.get_invalid(instance) if invalid: rop = invalid[0] raise PublishValidationError( ("ROP node {} is set to bypass, publishing cannot " "continue.".format(rop.path())), title=self.label ) @classmethod def get_invalid(cls, instance): rop = hou.node(instance.data["instance_node"]) if hasattr(rop, "isBypassed") and rop.isBypassed(): return [rop] ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_camera_rop.py ================================================ # -*- coding: utf-8 -*- """Validator plugin for Houdini Camera ROP settings.""" import pyblish.api from openpype.pipeline import PublishValidationError class ValidateCameraROP(pyblish.api.InstancePlugin): """Validate Camera ROP settings.""" order = pyblish.api.ValidatorOrder families = ["camera"] hosts = ["houdini"] label = "Camera ROP" def process(self, instance): import hou node = hou.node(instance.data.get("instance_node")) if node.parm("use_sop_path").eval(): raise PublishValidationError( ("Alembic ROP for Camera export should not be " "set to 'Use Sop Path'. Please disable."), title=self.label ) # Get the root and objects parameter of the Alembic ROP node root = node.parm("root").eval() objects = node.parm("objects").eval() errors = [] if not root: errors.append("Root parameter must be set on Alembic ROP") if not root.startswith("/"): errors.append("Root parameter must start with slash /") if not objects: errors.append("Objects parameter must be set on Alembic ROP") if len(objects.split(" ")) != 1: errors.append("Must have only a single object.") if errors: for error in errors: self.log.error(error) raise PublishValidationError( "Some checks failed, see validator log.", title=self.label) # Check if the object exists and is a camera path = root + "/" + objects camera = hou.node(path) if not camera: raise PublishValidationError( "Camera path does not exist: %s" % path, title=self.label) if camera.type().name() != "cam": raise PublishValidationError( ("Object set in Alembic ROP is not a camera: " "{} (type: {})").format(camera, camera.type().name()), title=self.label) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_cop_output_node.py ================================================ # -*- coding: utf-8 -*- import sys import pyblish.api import six from openpype.pipeline import PublishValidationError class ValidateCopOutputNode(pyblish.api.InstancePlugin): """Validate the instance COP Output Node. This will ensure: - The COP Path is set. - The COP Path refers to an existing object. - The COP Path node is a COP node. """ order = pyblish.api.ValidatorOrder families = ["imagesequence"] hosts = ["houdini"] label = "Validate COP Output Node" def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( ("Output node(s) `{}` are incorrect. " "See plug-in log for details.").format(invalid), title=self.label ) @classmethod def get_invalid(cls, instance): import hou try: output_node = instance.data["output_node"] except KeyError: six.reraise( PublishValidationError, PublishValidationError( "Can't determine COP output node.", title=cls.__name__), sys.exc_info()[2] ) if output_node is None: node = hou.node(instance.data.get("instance_node")) cls.log.error( "COP Output node in '%s' does not exist. " "Ensure a valid COP output path is set." % node.path() ) return [node.path()] # Output node must be a Sop node. if not isinstance(output_node, hou.CopNode): cls.log.error( "Output node %s is not a COP node. " "COP Path must point to a COP node, " "instead found category type: %s" % (output_node.path(), output_node.type().category().name()) ) return [output_node.path()] # For the sake of completeness also assert the category type # is Cop2 to avoid potential edge case scenarios even though # the isinstance check above should be stricter than this category if output_node.type().category().name() != "Cop2": raise PublishValidationError( ("Output node %s is not of category Cop2. " "This is a bug...").format(output_node.path()), title=cls.label) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError from openpype.hosts.houdini.api.action import ( SelectInvalidAction, SelectROPAction, ) from openpype.hosts.houdini.api.lib import get_obj_node_output import hou class ValidateFBXOutputNode(pyblish.api.InstancePlugin): """Validate the instance Output Node. This will ensure: - The Output Node Path is set. - The Output Node Path refers to an existing object. - The Output Node is a Sop or Obj node. - The Output Node has geometry data. - The Output Node doesn't include invalid primitive types. """ order = pyblish.api.ValidatorOrder families = ["fbx"] hosts = ["houdini"] label = "Validate FBX Output Node" actions = [SelectROPAction, SelectInvalidAction] def process(self, instance): invalid = self.get_invalid(instance) if invalid: nodes = [n.path() for n in invalid] raise PublishValidationError( "See log for details. " "Invalid nodes: {0}".format(nodes), title="Invalid output node(s)" ) @classmethod def get_invalid(cls, instance): output_node = instance.data.get("output_node") # Check if The Output Node Path is set and # refers to an existing object. if output_node is None: rop_node = hou.node(instance.data["instance_node"]) cls.log.error( "Output node in '%s' does not exist. " "Ensure a valid output path is set.", rop_node.path() ) return [rop_node] # Check if the Output Node is a Sop or an Obj node # also, list all sop output nodes inside as well as # invalid empty nodes. all_out_sops = [] invalid = [] # if output_node is an ObjSubnet or an ObjNetwork if output_node.childTypeCategory() == hou.objNodeTypeCategory(): for node in output_node.allSubChildren(): if node.type().name() == "geo": out = get_obj_node_output(node) if out: all_out_sops.append(out) else: invalid.append(node) # empty_objs cls.log.error( "Geo Obj Node '%s' is empty!", node.path() ) if not all_out_sops: invalid.append(output_node) # empty_objs cls.log.error( "Output Node '%s' is empty!", node.path() ) # elif output_node is an ObjNode elif output_node.type().name() == "geo": out = get_obj_node_output(output_node) if out: all_out_sops.append(out) else: invalid.append(node) # empty_objs cls.log.error( "Output Node '%s' is empty!", node.path() ) # elif output_node is a SopNode elif output_node.type().category().name() == "Sop": all_out_sops.append(output_node) # Then it's a wrong node type else: cls.log.error( "Output node %s is not a SOP or OBJ Geo or OBJ SubNet node. " "Instead found category type: %s %s", output_node.path(), output_node.type().category().name(), output_node.type().name() ) return [output_node] # Check if all output sop nodes have geometry # and don't contain invalid prims invalid_prim_types = ["VDB", "Volume"] for sop_node in all_out_sops: # Empty Geometry test if not hasattr(sop_node, "geometry"): invalid.append(sop_node) # empty_geometry cls.log.error( "Sop node '%s' doesn't include any prims.", sop_node.path() ) continue frame = instance.data.get("frameStart", 0) geo = sop_node.geometryAtFrame(frame) if len(geo.iterPrims()) == 0: invalid.append(sop_node) # empty_geometry cls.log.error( "Sop node '%s' doesn't include any prims.", sop_node.path() ) continue # Invalid Prims test for prim_type in invalid_prim_types: if geo.countPrimType(prim_type) > 0: invalid.append(sop_node) # invalid_prims cls.log.error( "Sop node '%s' includes invalid prims of type '%s'.", sop_node.path(), prim_type ) if invalid: return invalid ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_file_extension.py ================================================ # -*- coding: utf-8 -*- import os import pyblish.api from openpype.hosts.houdini.api import lib from openpype.pipeline import PublishValidationError import hou class ValidateFileExtension(pyblish.api.InstancePlugin): """Validate the output file extension fits the output family. File extensions: - Pointcache must be .abc - Camera must be .abc - VDB must be .vdb """ order = pyblish.api.ValidatorOrder families = ["camera", "vdbcache"] hosts = ["houdini"] label = "Output File Extension" family_extensions = { "camera": ".abc", "vdbcache": ".vdb", } def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "ROP node has incorrect file extension: {}".format(invalid), title=self.label ) @classmethod def get_invalid(cls, instance): # Get ROP node from instance node = hou.node(instance.data["instance_node"]) # Create lookup for current family in instance families = [] family = instance.data.get("family", None) if family: families.append(family) families = set(families) # Perform extension check output = lib.get_output_parameter(node).eval() _, output_extension = os.path.splitext(output) for family in families: extension = cls.family_extensions.get(family, None) if extension is None: raise PublishValidationError( "Unsupported family: {}".format(family), title=cls.label) if output_extension != extension: return [node.path()] ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_frame_range.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectInvalidAction import hou class DisableUseAssetHandlesAction(RepairAction): label = "Disable use asset handles" icon = "mdi.toggle-switch-off" class ValidateFrameRange(pyblish.api.InstancePlugin): """Validate Frame Range. Due to the usage of start and end handles, then Frame Range must be >= (start handle + end handle) which results that frameEnd be smaller than frameStart """ order = pyblish.api.ValidatorOrder - 0.1 hosts = ["houdini"] label = "Validate Frame Range" actions = [DisableUseAssetHandlesAction, SelectInvalidAction] def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( title="Invalid Frame Range", message=( "Invalid frame range because the instance " "start frame ({0[frameStart]}) is higher than " "the end frame ({0[frameEnd]})" .format(instance.data) ), description=( "## Invalid Frame Range\n" "The frame range for the instance is invalid because " "the start frame is higher than the end frame.\n\nThis " "is likely due to asset handles being applied to your " "instance or the ROP node's start frame " "is set higher than the end frame.\n\nIf your ROP frame " "range is correct and you do not want to apply asset " "handles make sure to disable Use asset handles on the " "publish instance." ) ) @classmethod def get_invalid(cls, instance): if not instance.data.get("instance_node"): return rop_node = hou.node(instance.data["instance_node"]) frame_start = instance.data.get("frameStart") frame_end = instance.data.get("frameEnd") if frame_start is None or frame_end is None: cls.log.debug( "Skipping frame range validation for " "instance without frame data: {}".format(rop_node.path()) ) return if frame_start > frame_end: cls.log.info( "The ROP node render range is set to " "{0[frameStartHandle]} - {0[frameEndHandle]} " "The asset handles applied to the instance are start handle " "{0[handleStart]} and end handle {0[handleEnd]}" .format(instance.data) ) return [rop_node] @classmethod def repair(cls, instance): if not cls.get_invalid(instance): # Already fixed return # Disable use asset handles context = instance.context create_context = context.data["create_context"] instance_id = instance.data.get("instance_id") if not instance_id: cls.log.debug("'{}' must have instance id" .format(instance)) return created_instance = create_context.get_instance_by_id(instance_id) if not instance_id: cls.log.debug("Unable to find instance '{}' by id" .format(instance)) return created_instance.publish_attributes["CollectAssetHandles"]["use_handles"] = False # noqa create_context.save_changes() cls.log.debug("use asset handles is turned off for '{}'" .format(instance)) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_frame_token.py ================================================ import pyblish.api from openpype.hosts.houdini.api import lib import hou class ValidateFrameToken(pyblish.api.InstancePlugin): """Validate if the unexpanded string contains the frame ('$F') token. This validator will *only* check the output parameter of the node if the Valid Frame Range is not set to 'Render Current Frame' Rules: If you render out a frame range it is mandatory to have the frame token - '$F4' or similar - to ensure that each frame gets written. If this is not the case you will override the same file every time a frame is written out. Examples: Good: 'my_vbd_cache.$F4.vdb' Bad: 'my_vbd_cache.vdb' """ order = pyblish.api.ValidatorOrder label = "Validate Frame Token" families = ["vdbcache"] def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise RuntimeError( "Output settings do no match for '%s'" % instance ) @classmethod def get_invalid(cls, instance): node = hou.node(instance.data["instance_node"]) # Check trange parm, 0 means Render Current Frame frame_range = node.evalParm("trange") if frame_range == 0: return [] output_parm = lib.get_output_parameter(node) unexpanded_str = output_parm.unexpandedString() if "$F" not in unexpanded_str: cls.log.error("No frame token found in '%s'" % node.path()) return [instance] ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_houdini_license_category.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError import hou class ValidateHoudiniNotApprenticeLicense(pyblish.api.InstancePlugin): """Validate the Houdini instance runs a non Apprentice license. USD ROPs: When extracting USD files from an apprentice Houdini license, the resulting files will get "scrambled" with a license protection and get a special .usdnc suffix. This currently breaks the Subset/representation pipeline so we disallow any publish with apprentice license. Alembic ROPs: Houdini Apprentice does not export Alembic. """ order = pyblish.api.ValidatorOrder families = ["usd", "abc", "fbx", "camera"] hosts = ["houdini"] label = "Houdini Apprentice License" def process(self, instance): if hou.isApprentice(): # Find which family was matched with the plug-in families = {instance.data["family"]} families.update(instance.data.get("families", [])) disallowed_families = families.intersection(self.families) families = " ".join(sorted(disallowed_families)).title() raise PublishValidationError( "{} publishing requires a non apprentice license." .format(families), title=self.label) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py ================================================ # -*- coding: utf-8 -*- """Validator for correct naming of Static Meshes.""" import pyblish.api from openpype.pipeline import ( PublishValidationError, OptionalPyblishPluginMixin ) from openpype.pipeline.publish import ValidateContentsOrder from openpype.hosts.houdini.api.action import SelectInvalidAction from openpype.hosts.houdini.api.lib import get_output_children class ValidateMeshIsStatic(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate mesh is static. It checks if output node is time dependent. """ families = ["staticMesh"] hosts = ["houdini"] label = "Validate Mesh is Static" order = ValidateContentsOrder + 0.1 actions = [SelectInvalidAction] def process(self, instance): invalid = self.get_invalid(instance) if invalid: nodes = [n.path() for n in invalid] raise PublishValidationError( "See log for details. " "Invalid nodes: {0}".format(nodes) ) @classmethod def get_invalid(cls, instance): invalid = [] output_node = instance.data.get("output_node") if output_node is None: cls.log.debug( "No Output Node, skipping check.." ) return all_outputs = get_output_children(output_node) for output in all_outputs: if output.isTimeDependent(): invalid.append(output) cls.log.error( "Output node '%s' is time dependent.", output.path() ) return invalid ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_mkpaths_toggled.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError class ValidateIntermediateDirectoriesChecked(pyblish.api.InstancePlugin): """Validate Create Intermediate Directories is enabled on ROP node.""" order = pyblish.api.ValidatorOrder families = ["pointcache", "camera", "vdbcache"] hosts = ["houdini"] label = "Create Intermediate Directories Checked" def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( ("Found ROP node with Create Intermediate " "Directories turned off: {}".format(invalid)), title=self.label) @classmethod def get_invalid(cls, instance): result = [] for node in instance[:]: if node.parm("mkpath").eval() != 1: cls.log.error("Invalid settings found on `%s`" % node.path()) result.append(node.path()) return result ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_no_errors.py ================================================ # -*- coding: utf-8 -*- import pyblish.api import hou from openpype.pipeline import PublishValidationError def cook_in_range(node, start, end): current = hou.intFrame() if start >= current >= end: # Allow cooking current frame since we're in frame range node.cook(force=False) else: node.cook(force=False, frame_range=(start, start)) def get_errors(node): """Get cooking errors. If node already has errors check whether it needs to recook If so, then recook first to see if that solves it. """ if node.errors() and node.needsToCook(): node.cook() return node.errors() class ValidateNoErrors(pyblish.api.InstancePlugin): """Validate the Instance has no current cooking errors.""" order = pyblish.api.ValidatorOrder hosts = ["houdini"] label = "Validate no errors" def process(self, instance): validate_nodes = [] if len(instance) > 0: validate_nodes.append(hou.node(instance.data.get("instance_node"))) output_node = instance.data.get("output_node") if output_node: validate_nodes.append(output_node) for node in validate_nodes: self.log.debug("Validating for errors: %s" % node.path()) errors = get_errors(node) if errors: # If there are current errors, then try an unforced cook # to see whether the error will disappear. self.log.debug( "Recooking to revalidate error " "is up to date for: %s" % node.path() ) current_frame = hou.intFrame() start = instance.data.get("frameStart", current_frame) end = instance.data.get("frameEnd", current_frame) cook_in_range(node, start=start, end=end) # Check for errors again after the forced recook errors = get_errors(node) if errors: self.log.error(errors) raise PublishValidationError( "Node has errors: {}".format(node.path()), title=self.label) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError from openpype.pipeline.publish import ( ValidateContentsOrder, RepairAction, ) import hou class AddDefaultPathAction(RepairAction): label = "Add a default path attribute" icon = "mdi.pencil-plus-outline" class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): """Validate all primitives build hierarchy from attribute when enabled. The name of the attribute must exist on the prims and have the same name as Build Hierarchy from Attribute's `Path Attribute` value on the Alembic ROP node whenever Build Hierarchy from Attribute is enabled. """ order = ValidateContentsOrder + 0.1 families = ["abc"] hosts = ["houdini"] label = "Validate Prims Hierarchy Path" actions = [AddDefaultPathAction] def process(self, instance): invalid = self.get_invalid(instance) if invalid: nodes = [n.path() for n in invalid] raise PublishValidationError( "See log for details. " "Invalid nodes: {0}".format(nodes), title=self.label ) @classmethod def get_invalid(cls, instance): output_node = instance.data.get("output_node") rop_node = hou.node(instance.data["instance_node"]) if output_node is None: cls.log.error( "SOP Output node in '%s' does not exist. " "Ensure a valid SOP output path is set.", rop_node.path() ) return [rop_node] build_from_path = rop_node.parm("build_from_path").eval() if not build_from_path: cls.log.debug( "Alembic ROP has 'Build from Path' disabled. " "Validation is ignored.." ) return path_attr = rop_node.parm("path_attrib").eval() if not path_attr: cls.log.error( "The Alembic ROP node has no Path Attribute" "value set, but 'Build Hierarchy from Attribute'" "is enabled." ) return [rop_node] cls.log.debug("Checking for attribute: %s", path_attr) if not hasattr(output_node, "geometry"): # In the case someone has explicitly set an Object # node instead of a SOP node in Geometry context # then for now we ignore - this allows us to also # export object transforms. cls.log.warning("No geometry output node found, skipping check..") return # Check if the primitive attribute exists frame = instance.data.get("frameStart", 0) geo = output_node.geometryAtFrame(frame) # If there are no primitives on the current frame then we can't # check whether the path names are correct. So we'll just issue a # warning that the check can't be done consistently and skip # validation. if len(geo.iterPrims()) == 0: cls.log.warning( "No primitives found on current frame. Validation" " for primitive hierarchy paths will be skipped," " thus can't be validated." ) return # Check if there are any values for the primitives attrib = geo.findPrimAttrib(path_attr) if not attrib: cls.log.info( "Geometry Primitives are missing " "path attribute: `%s`", path_attr ) return [output_node] # Ensure at least a single string value is present if not attrib.strings(): cls.log.info( "Primitive path attribute has no " "string values: %s", path_attr ) return [output_node] paths = geo.primStringAttribValues(path_attr) # Ensure all primitives are set to a valid path # Collect all invalid primitive numbers invalid_prims = [i for i, path in enumerate(paths) if not path] if invalid_prims: num_prims = len(geo.iterPrims()) # faster than len(geo.prims()) cls.log.info( "Prims have no value for attribute `%s` " "(%s of %s prims)", path_attr, len(invalid_prims), num_prims ) return [output_node] @classmethod def repair(cls, instance): """Add a default path attribute Action. It is a helper action more than a repair action, used to add a default single value for the path. """ rop_node = hou.node(instance.data["instance_node"]) output_node = rop_node.parm("sop_path").evalAsNode() if not output_node: cls.log.debug( "Action isn't performed, invalid SOP Path on %s", rop_node ) return # This check to prevent the action from running multiple times. # git_invalid only returns [output_node] when # path attribute is the problem if cls.get_invalid(instance) != [output_node]: return path_attr = rop_node.parm("path_attrib").eval() path_node = output_node.parent().createNode("name", "AUTO_PATH") path_node.parm("attribname").set(path_attr) path_node.parm("name1").set('`opname("..")`/`opname("..")`Shape') cls.log.debug( "'%s' was created. It adds '%s' with a default single value", path_node, path_attr ) path_node.setGenericFlag(hou.nodeFlag.DisplayComment, True) path_node.setComment( 'Auto path node was created automatically by ' '"Add a default path attribute"' '\nFeel free to modify or replace it.' ) if output_node.type().name() in ["null", "output"]: # Connect before path_node.setFirstInput(output_node.input(0)) path_node.moveToGoodPosition() output_node.setFirstInput(path_node) output_node.moveToGoodPosition() else: # Connect after path_node.setFirstInput(output_node) rop_node.parm("sop_path").set(path_node.path()) path_node.moveToGoodPosition() cls.log.debug( "SOP path on '%s' updated to new output node '%s'", rop_node, path_node ) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_remote_publish.py ================================================ # -*-coding: utf-8 -*- import pyblish.api from openpype.hosts.houdini.api import lib from openpype.pipeline.publish import RepairContextAction from openpype.pipeline import PublishValidationError import hou class ValidateRemotePublishOutNode(pyblish.api.ContextPlugin): """Validate the remote publish out node exists for Deadline to trigger.""" order = pyblish.api.ValidatorOrder - 0.4 families = ["*"] hosts = ["houdini"] targets = ["deadline"] label = "Remote Publish ROP node" actions = [RepairContextAction] def process(self, context): cmd = "import colorbleed.lib; colorbleed.lib.publish_remote()" node = hou.node("/out/REMOTE_PUBLISH") if not node: raise RuntimeError("Missing REMOTE_PUBLISH node.") # We ensure it's a shell node and that it has the pre-render script # set correctly. Plus the shell script it will trigger should be # completely empty (doing nothing) if node.type().name() != "shell": self.raise_error("Must be shell ROP node") if node.parm("command").eval() != "": self.raise_error("Must have no command") if node.parm("shellexec").eval(): self.raise_error("Must not execute in shell") if node.parm("prerender").eval() != cmd: self.raise_error("REMOTE_PUBLISH node does not have " "correct prerender script.") if node.parm("lprerender").eval() != "python": self.raise_error("REMOTE_PUBLISH node prerender script " "type not set to 'python'") @classmethod def repair(cls, context): """(Re)create the node if it fails to pass validation.""" lib.create_remote_publish_node(force=True) def raise_error(self, message): raise PublishValidationError(message) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_remote_publish_enabled.py ================================================ # -*- coding: utf-8 -*- import pyblish.api import hou from openpype.pipeline.publish import RepairContextAction from openpype.pipeline import PublishValidationError class ValidateRemotePublishEnabled(pyblish.api.ContextPlugin): """Validate the remote publish node is *not* bypassed.""" order = pyblish.api.ValidatorOrder - 0.39 families = ["*"] hosts = ["houdini"] targets = ["deadline"] label = "Remote Publish ROP enabled" actions = [RepairContextAction] def process(self, context): node = hou.node("/out/REMOTE_PUBLISH") if not node: raise PublishValidationError( "Missing REMOTE_PUBLISH node.", title=self.label) if node.isBypassed(): raise PublishValidationError( "REMOTE_PUBLISH must not be bypassed.", title=self.label) @classmethod def repair(cls, context): """(Re)create the node if it fails to pass validation.""" node = hou.node("/out/REMOTE_PUBLISH") if not node: raise PublishValidationError( "Missing REMOTE_PUBLISH node.", title=cls.label) cls.log.info("Disabling bypass on /out/REMOTE_PUBLISH") node.bypass(False) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import ( PublishValidationError, OptionalPyblishPluginMixin ) from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectROPAction import os import hou class SetDefaultViewSpaceAction(RepairAction): label = "Set default view colorspace" icon = "mdi.monitor" class ValidateReviewColorspace(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate Review Colorspace parameters. It checks if 'OCIO Colorspace' parameter was set to valid value. """ order = pyblish.api.ValidatorOrder + 0.1 families = ["review"] hosts = ["houdini"] label = "Validate Review Colorspace" actions = [SetDefaultViewSpaceAction, SelectROPAction] optional = True def process(self, instance): if not self.is_active(instance.data): return if os.getenv("OCIO") is None: self.log.debug( "Using Houdini's Default Color Management, " " skipping check.." ) return rop_node = hou.node(instance.data["instance_node"]) if rop_node.evalParm("colorcorrect") != 2: # any colorspace settings other than default requires # 'Color Correct' parm to be set to 'OpenColorIO' raise PublishValidationError( "'Color Correction' parm on '{}' ROP must be set to" " 'OpenColorIO'".format(rop_node.path()) ) if rop_node.evalParm("ociocolorspace") not in \ hou.Color.ocio_spaces(): raise PublishValidationError( "Invalid value: Colorspace name doesn't exist.\n" "Check 'OCIO Colorspace' parameter on '{}' ROP" .format(rop_node.path()) ) @classmethod def repair(cls, instance): """Set Default View Space Action. It is a helper action more than a repair action, used to set colorspace on opengl node to the default view. """ from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa rop_node = hou.node(instance.data["instance_node"]) if rop_node.evalParm("colorcorrect") != 2: rop_node.setParms({"colorcorrect": 2}) cls.log.debug( "'Color Correction' parm on '{}' has been set to" " 'OpenColorIO'".format(rop_node.path()) ) # Get default view colorspace name default_view_space = get_default_display_view_colorspace() rop_node.setParms({"ociocolorspace": default_view_space}) cls.log.info( "'OCIO Colorspace' parm on '{}' has been set to " "the default view color space '{}'" .format(rop_node, default_view_space) ) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_scene_review.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError import hou class ValidateSceneReview(pyblish.api.InstancePlugin): """Validator Some Scene Settings before publishing the review 1. Scene Path 2. Resolution """ order = pyblish.api.ValidatorOrder families = ["review"] hosts = ["houdini"] label = "Scene Setting for review" def process(self, instance): report = [] instance_node = hou.node(instance.data.get("instance_node")) invalid = self.get_invalid_scene_path(instance_node) if invalid: report.append(invalid) invalid = self.get_invalid_camera_path(instance_node) if invalid: report.append(invalid) invalid = self.get_invalid_resolution(instance_node) if invalid: report.extend(invalid) if report: raise PublishValidationError( "\n\n".join(report), title=self.label) def get_invalid_scene_path(self, rop_node): scene_path_parm = rop_node.parm("scenepath") scene_path_node = scene_path_parm.evalAsNode() if not scene_path_node: path = scene_path_parm.evalAsString() return "Scene path does not exist: '{}'".format(path) def get_invalid_camera_path(self, rop_node): camera_path_parm = rop_node.parm("camera") camera_node = camera_path_parm.evalAsNode() path = camera_path_parm.evalAsString() if not camera_node: return "Camera path does not exist: '{}'".format(path) type_name = camera_node.type().name() if type_name != "cam": return "Camera path is not a camera: '{}' (type: {})".format( path, type_name ) def get_invalid_resolution(self, rop_node): # The resolution setting is only used when Override Camera Resolution # is enabled. So we skip validation if it is disabled. override = rop_node.parm("tres").eval() if not override: return invalid = [] res_width = rop_node.parm("res1").eval() res_height = rop_node.parm("res2").eval() if res_width == 0: invalid.append("Override Resolution width is set to zero.") if res_height == 0: invalid.append("Override Resolution height is set to zero") return invalid ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError from openpype.hosts.houdini.api.action import ( SelectInvalidAction, SelectROPAction, ) import hou class ValidateSopOutputNode(pyblish.api.InstancePlugin): """Validate the instance SOP Output Node. This will ensure: - The SOP Path is set. - The SOP Path refers to an existing object. - The SOP Path node is a SOP node. - The SOP Path node has at least one input connection (has an input) - The SOP Path has geometry data. """ order = pyblish.api.ValidatorOrder families = ["pointcache", "vdbcache"] hosts = ["houdini"] label = "Validate Output Node (SOP)" actions = [SelectROPAction, SelectInvalidAction] def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Output node(s) are incorrect", title="Invalid output node(s)" ) @classmethod def get_invalid(cls, instance): output_node = instance.data.get("output_node") if output_node is None: node = hou.node(instance.data["instance_node"]) cls.log.error( "SOP Output node in '%s' does not exist. " "Ensure a valid SOP output path is set." % node.path() ) return [node] # Output node must be a Sop node. if not isinstance(output_node, hou.SopNode): cls.log.error( "Output node %s is not a SOP node. " "SOP Path must point to a SOP node, " "instead found category type: %s" % (output_node.path(), output_node.type().category().name()) ) return [output_node] # For the sake of completeness also assert the category type # is Sop to avoid potential edge case scenarios even though # the isinstance check above should be stricter than this category if output_node.type().category().name() != "Sop": raise PublishValidationError( ("Output node {} is not of category Sop. " "This is a bug.").format(output_node.path()), title=cls.label) # Ensure the node is cooked and succeeds to cook so we can correctly # check for its geometry data. if output_node.needsToCook(): cls.log.debug("Cooking node: %s" % output_node.path()) try: output_node.cook() except hou.Error as exc: cls.log.error("Cook failed: %s" % exc) cls.log.error(output_node.errors()[0]) return [output_node] # Ensure the output node has at least Geometry data if not output_node.geometry(): cls.log.error( "Output node `%s` has no geometry data." % output_node.path() ) return [output_node] ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_subset_name.py ================================================ # -*- coding: utf-8 -*- """Validator for correct naming of Static Meshes.""" import pyblish.api from openpype.pipeline import ( PublishValidationError, OptionalPyblishPluginMixin ) from openpype.pipeline.publish import ( ValidateContentsOrder, RepairAction, ) from openpype.hosts.houdini.api.action import SelectInvalidAction from openpype.pipeline.create import get_subset_name import hou class FixSubsetNameAction(RepairAction): label = "Fix Subset Name" class ValidateSubsetName(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate Subset name. """ families = ["staticMesh"] hosts = ["houdini"] label = "Validate Subset Name" order = ValidateContentsOrder + 0.1 actions = [FixSubsetNameAction, SelectInvalidAction] optional = True def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: nodes = [n.path() for n in invalid] raise PublishValidationError( "See log for details. " "Invalid nodes: {0}".format(nodes) ) @classmethod def get_invalid(cls, instance): invalid = [] rop_node = hou.node(instance.data["instance_node"]) # Check subset name asset_doc = instance.data["assetEntity"] subset_name = get_subset_name( family=instance.data["family"], variant=instance.data["variant"], task_name=instance.data["task"], asset_doc=asset_doc, dynamic_data={"asset": asset_doc["name"]} ) if instance.data.get("subset") != subset_name: invalid.append(rop_node) cls.log.error( "Invalid subset name on rop node '%s' should be '%s'.", rop_node.path(), subset_name ) return invalid @classmethod def repair(cls, instance): rop_node = hou.node(instance.data["instance_node"]) # Check subset name asset_doc = instance.data["assetEntity"] subset_name = get_subset_name( family=instance.data["family"], variant=instance.data["variant"], task_name=instance.data["task"], asset_doc=asset_doc, dynamic_data={"asset": asset_doc["name"]} ) instance.data["subset"] = subset_name rop_node.parm("subset").set(subset_name) cls.log.debug( "Subset name on rop node '%s' has been set to '%s'.", rop_node.path(), subset_name ) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py ================================================ # -*- coding: utf-8 -*- """Validator for correct naming of Static Meshes.""" import pyblish.api from openpype.pipeline import ( PublishValidationError, OptionalPyblishPluginMixin ) from openpype.pipeline.publish import ValidateContentsOrder from openpype.hosts.houdini.api.action import SelectInvalidAction from openpype.hosts.houdini.api.lib import get_output_children import hou class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate name of Unreal Static Mesh. This validator checks if output node name has a collision prefix: - UBX - UCP - USP - UCX This validator also checks if subset name is correct - {static mesh prefix}_{Asset-Name}{Variant}. """ families = ["staticMesh"] hosts = ["houdini"] label = "Unreal Static Mesh Name (FBX)" order = ValidateContentsOrder + 0.1 actions = [SelectInvalidAction] optional = True collision_prefixes = [] static_mesh_prefix = "" @classmethod def apply_settings(cls, project_settings, system_settings): settings = ( project_settings["houdini"]["create"]["CreateStaticMesh"] ) cls.collision_prefixes = settings["collision_prefixes"] cls.static_mesh_prefix = settings["static_mesh_prefix"] def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: nodes = [n.path() for n in invalid] raise PublishValidationError( "See log for details. " "Invalid nodes: {0}".format(nodes) ) @classmethod def get_invalid(cls, instance): invalid = [] rop_node = hou.node(instance.data["instance_node"]) output_node = instance.data.get("output_node") if output_node is None: cls.log.debug( "No Output Node, skipping check.." ) return if rop_node.evalParm("buildfrompath"): # This validator doesn't support naming check if # building hierarchy from path' is used cls.log.info( "Using 'Build Hierarchy from Path Attribute', skipping check.." ) return # Check nodes names all_outputs = get_output_children(output_node, include_sops=False) for output in all_outputs: for prefix in cls.collision_prefixes: if output.name().startswith(prefix): invalid.append(output) cls.log.error( "Invalid node name: Node '%s' " "includes a collision prefix '%s'", output.path(), prefix ) break return invalid ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_usd_layer_path_backslashes.py ================================================ # -*- coding: utf-8 -*- import pyblish.api import openpype.hosts.houdini.api.usd as hou_usdlib from openpype.pipeline import PublishValidationError import hou class ValidateUSDLayerPathBackslashes(pyblish.api.InstancePlugin): """Validate USD loaded paths have no backslashes. This is a crucial validation for HUSK USD rendering as Houdini's USD Render ROP will fail to write out a .usd file for rendering that correctly preserves the backslashes, e.g. it will incorrectly convert a '\t' to a TAB character disallowing HUSK to find those specific files. This validation is redundant for usdModel since that flattens the model before write. As such it will never have any used layers with a path. """ order = pyblish.api.ValidatorOrder families = ["usdSetDress", "usdShade", "usd", "usdrender"] hosts = ["houdini"] label = "USD Layer path backslashes" optional = True def process(self, instance): rop = hou.node(instance.data.get("instance_node")) lop_path = hou_usdlib.get_usd_rop_loppath(rop) stage = lop_path.stage(apply_viewport_overrides=False) invalid = [] for layer in stage.GetUsedLayers(): references = layer.externalReferences for ref in references: # Ignore anonymous layers if ref.startswith("anon:"): continue # If any backslashes in the path consider it invalid if "\\" in ref: self.log.error("Found invalid path: %s" % ref) invalid.append(layer) if invalid: raise PublishValidationError(( "Loaded layers have backslashes. " "This is invalid for HUSK USD rendering."), title=self.label) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_usd_model_and_shade.py ================================================ # -*- coding: utf-8 -*- import pyblish.api import openpype.hosts.houdini.api.usd as hou_usdlib from openpype.pipeline import PublishValidationError from pxr import UsdShade, UsdRender, UsdLux import hou def fullname(o): """Get fully qualified class name""" module = o.__module__ if module is None or module == str.__module__: return o.__name__ return module + "." + o.__name__ class ValidateUsdModel(pyblish.api.InstancePlugin): """Validate USD Model. Disallow Shaders, Render settings, products and vars and Lux lights. """ order = pyblish.api.ValidatorOrder families = ["usdModel"] hosts = ["houdini"] label = "Validate USD Model" optional = True disallowed = [ UsdShade.Shader, UsdRender.Settings, UsdRender.Product, UsdRender.Var, UsdLux.Light, ] def process(self, instance): rop = hou.node(instance.data.get("instance_node")) lop_path = hou_usdlib.get_usd_rop_loppath(rop) stage = lop_path.stage(apply_viewport_overrides=False) invalid = [] for prim in stage.Traverse(): for klass in self.disallowed: if klass(prim): # Get full class name without pxr. prefix name = fullname(klass).split("pxr.", 1)[-1] path = str(prim.GetPath()) self.log.warning("Disallowed %s: %s" % (name, path)) invalid.append(prim) if invalid: prim_paths = sorted([str(prim.GetPath()) for prim in invalid]) raise PublishValidationError( "Found invalid primitives: {}".format(prim_paths)) class ValidateUsdShade(ValidateUsdModel): """Validate usdShade. Disallow Render settings, products, vars and Lux lights. """ families = ["usdShade"] label = "Validate USD Shade" disallowed = [ UsdRender.Settings, UsdRender.Product, UsdRender.Var, UsdLux.Light, ] ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_usd_output_node.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError class ValidateUSDOutputNode(pyblish.api.InstancePlugin): """Validate the instance USD LOPs Output Node. This will ensure: - The LOP Path is set. - The LOP Path refers to an existing object. - The LOP Path node is a LOP node. """ order = pyblish.api.ValidatorOrder families = ["usd"] hosts = ["houdini"] label = "Validate Output Node (USD)" def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( ("Output node(s) `{}` are incorrect. " "See plug-in log for details.").format(invalid), title=self.label ) @classmethod def get_invalid(cls, instance): import hou output_node = instance.data["output_node"] if output_node is None: node = hou.node(instance.data.get("instance_node")) cls.log.error( "USD node '%s' LOP path does not exist. " "Ensure a valid LOP path is set." % node.path() ) return [node.path()] # Output node must be a Sop node. if not isinstance(output_node, hou.LopNode): cls.log.error( "Output node %s is not a LOP node. " "LOP Path must point to a LOP node, " "instead found category type: %s" % (output_node.path(), output_node.type().category().name()) ) return [output_node.path()] ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_usd_render_product_names.py ================================================ # -*- coding: utf-8 -*- import os import pyblish.api from openpype.pipeline import PublishValidationError class ValidateUSDRenderProductNames(pyblish.api.InstancePlugin): """Validate USD Render Product names are correctly set absolute paths.""" order = pyblish.api.ValidatorOrder families = ["usdrender"] hosts = ["houdini"] label = "Validate USD Render Product Names" optional = True def process(self, instance): invalid = [] for filepath in instance.data["files"]: if not filepath: invalid.append("Detected empty output filepath.") if not os.path.isabs(filepath): invalid.append( "Output file path is not absolute path: %s" % filepath ) if invalid: for message in invalid: self.log.error(message) raise PublishValidationError( "USD Render Paths are invalid.", title=self.label) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_usd_setdress.py ================================================ # -*- coding: utf-8 -*- import pyblish.api import openpype.hosts.houdini.api.usd as hou_usdlib from openpype.pipeline import PublishValidationError class ValidateUsdSetDress(pyblish.api.InstancePlugin): """Validate USD Set Dress. Must only have references or payloads. May not generate new mesh or flattened meshes. """ order = pyblish.api.ValidatorOrder families = ["usdSetDress"] hosts = ["houdini"] label = "Validate USD Set Dress" optional = True def process(self, instance): from pxr import UsdGeom import hou rop = hou.node(instance.data.get("instance_node")) lop_path = hou_usdlib.get_usd_rop_loppath(rop) stage = lop_path.stage(apply_viewport_overrides=False) invalid = [] for node in stage.Traverse(): if UsdGeom.Mesh(node): # This solely checks whether there is any USD involved # in this Prim's Stack and doesn't accurately tell us # whether it was generated locally or not. # TODO: More accurately track whether the Prim was created # in the local scene stack = node.GetPrimStack() for sdf in stack: path = sdf.layer.realPath if path: break else: prim_path = node.GetPath() self.log.error( "%s is not referenced geometry." % prim_path ) invalid.append(node) if invalid: raise PublishValidationError(( "SetDress contains local geometry. " "This is not allowed, it must be an assembly " "of referenced assets."), title=self.label ) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_usd_shade_model_exists.py ================================================ # -*- coding: utf-8 -*- import re import pyblish.api from openpype.client import get_subset_by_name from openpype.pipeline.publish import ValidateContentsOrder from openpype.pipeline import PublishValidationError class ValidateUSDShadeModelExists(pyblish.api.InstancePlugin): """Validate the Instance has no current cooking errors.""" order = ValidateContentsOrder hosts = ["houdini"] families = ["usdShade"] label = "USD Shade model exists" def process(self, instance): project_name = instance.context.data["projectName"] asset_name = instance.data["asset"] subset = instance.data["subset"] # Assume shading variation starts after a dot separator shade_subset = subset.split(".", 1)[0] model_subset = re.sub("^usdShade", "usdModel", shade_subset) asset_doc = instance.data.get("assetEntity") if not asset_doc: raise RuntimeError("Asset document is not filled on instance.") subset_doc = get_subset_by_name( project_name, model_subset, asset_doc["_id"], fields=["_id"] ) if not subset_doc: raise PublishValidationError( ("USD Model subset not found: " "{} ({})").format(model_subset, asset_name), title=self.label ) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_usd_shade_workspace.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError import hou class ValidateUsdShadeWorkspace(pyblish.api.InstancePlugin): """Validate USD Shading Workspace is correct version. There have been some issues with outdated/erroneous Shading Workspaces so this is to confirm everything is set as it should. """ order = pyblish.api.ValidatorOrder hosts = ["houdini"] families = ["usdShade"] label = "USD Shade Workspace" def process(self, instance): rop = hou.node(instance.data.get("instance_node")) workspace = rop.parent() definition = workspace.type().definition() name = definition.nodeType().name() library = definition.libraryFilePath() all_definitions = hou.hda.definitionsInFile(library) node_type, version = name.rsplit(":", 1) version = float(version) highest = version for other_definition in all_definitions: other_name = other_definition.nodeType().name() other_node_type, other_version = other_name.rsplit(":", 1) other_version = float(other_version) if node_type != other_node_type: continue # Get the highest version highest = max(highest, other_version) if version != highest: raise PublishValidationError( ("Shading Workspace is not the latest version." " Found {}. Latest is {}.").format(version, highest), title=self.label ) # There were some issues with the editable node not having the right # configured path. So for now let's assure that is correct to.from value = ( 'avalon://`chs("../asset_name")`/' 'usdShade`chs("../model_variantname1")`.usd' ) rop_value = rop.parm("lopoutput").rawValue() if rop_value != value: raise PublishValidationError( ("Shading Workspace has invalid 'lopoutput'" " parameter value. The Shading Workspace" " needs to be reset to its default values."), title=self.label ) ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py ================================================ # -*- coding: utf-8 -*- import contextlib import pyblish.api import hou from openpype.pipeline import PublishXmlValidationError from openpype.hosts.houdini.api.action import SelectInvalidAction def group_consecutive_numbers(nums): """ Args: nums (list): List of sorted integer numbers. Yields: str: Group ranges as {start}-{end} if more than one number in the range else it yields {end} """ start = None end = None def _result(a, b): if a == b: return "{}".format(a) else: return "{}-{}".format(a, b) for num in nums: if start is None: start = num end = num elif num == end + 1: end = num else: yield _result(start, end) start = num end = num if start is not None: yield _result(start, end) @contextlib.contextmanager def update_mode_context(mode): original = hou.updateModeSetting() try: hou.setUpdateMode(mode) yield finally: hou.setUpdateMode(original) def get_geometry_at_frame(sop_node, frame, force=True): """Return geometry at frame but force a cooked value.""" if not hasattr(sop_node, "geometry"): return with update_mode_context(hou.updateMode.AutoUpdate): sop_node.cook(force=force, frame_range=(frame, frame)) return sop_node.geometryAtFrame(frame) class ValidateVDBOutputNode(pyblish.api.InstancePlugin): """Validate that the node connected to the output node is of type VDB. All primitives of the output geometry must be VDBs, no other primitive types are allowed. That means that regardless of the amount of VDBs in the geometry it will have an equal amount of VDBs, points, primitives and vertices since each VDB primitive is one point, one vertex and one VDB. This validation only checks the geometry on the first frame of the export frame range for optimization purposes. A VDB is an inherited type of Prim, holds the following data: - Primitives: 1 - Points: 1 - Vertices: 1 - VDBs: 1 """ order = pyblish.api.ValidatorOrder + 0.1 families = ["vdbcache"] hosts = ["houdini"] label = "Validate Output Node (VDB)" actions = [SelectInvalidAction] def process(self, instance): invalid_nodes, message = self.get_invalid_with_message(instance) if invalid_nodes: # instance_node is str, but output_node is hou.Node so we convert output = instance.data.get("output_node") output_path = output.path() if output else None raise PublishXmlValidationError( self, "Invalid VDB content: {}".format(message), formatting_data={ "message": message, "rop_path": instance.data.get("instance_node"), "sop_path": output_path } ) @classmethod def get_invalid_with_message(cls, instance): node = instance.data.get("output_node") if node is None: instance_node = instance.data.get("instance_node") error = ( "SOP path is not correctly set on " "ROP node `{}`.".format(instance_node) ) return [hou.node(instance_node), error] frame = instance.data.get("frameStart", 0) geometry = get_geometry_at_frame(node, frame) if geometry is None: # No geometry data on this node, maybe the node hasn't cooked? error = ( "SOP node `{}` has no geometry data. " "Was it unable to cook?".format(node.path()) ) return [node, error] num_prims = geometry.intrinsicValue("primitivecount") num_points = geometry.intrinsicValue("pointcount") if num_prims == 0 and num_points == 0: # Since we are only checking the first frame it doesn't mean there # won't be VDB prims in a few frames. As such we'll assume for now # the user knows what he or she is doing cls.log.warning( "SOP node `{}` has no primitives on start frame {}. " "Validation is skipped and it is assumed elsewhere in the " "frame range VDB prims and only VDB prims will exist." "".format(node.path(), int(frame)) ) return [None, None] num_vdb_prims = geometry.countPrimType(hou.primType.VDB) cls.log.debug("Detected {} VDB primitives".format(num_vdb_prims)) if num_prims != num_vdb_prims: # There's at least one primitive that is not a VDB. # Search them and report them to the artist. prims = geometry.prims() invalid_prims = [prim for prim in prims if not isinstance(prim, hou.VDB)] if invalid_prims: # Log prim numbers as consecutive ranges so logging isn't very # slow for large number of primitives error = ( "Found non-VDB primitives for `{}`. " "Primitive indices {} are not VDB primitives.".format( node.path(), ", ".join(group_consecutive_numbers( prim.number() for prim in invalid_prims )) ) ) return [node, error] if num_points != num_vdb_prims: # We have points unrelated to the VDB primitives. error = ( "The number of primitives and points do not match in '{}'. " "This likely means you have unconnected points, which we do " "not allow in the VDB output.".format(node.path())) return [node, error] return [None, None] @classmethod def get_invalid(cls, instance): nodes, _ = cls.get_invalid_with_message(instance) return nodes ================================================ FILE: openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py ================================================ # -*- coding: utf-8 -*- import pyblish.api import hou from openpype.pipeline import ( PublishValidationError, OptionalPyblishPluginMixin ) from openpype.pipeline.publish import RepairAction class ValidateWorkfilePaths( pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate workfile paths so they are absolute.""" order = pyblish.api.ValidatorOrder families = ["workfile"] hosts = ["houdini"] label = "Validate Workfile Paths" actions = [RepairAction] optional = True node_types = ["file", "alembic"] prohibited_vars = ["$HIP", "$JOB"] def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid() self.log.debug( "Checking node types: {}".format(", ".join(self.node_types))) self.log.debug( "Searching prohibited vars: {}".format( ", ".join(self.prohibited_vars) ) ) if invalid: all_container_vars = set() for param in invalid: value = param.unexpandedString() contained_vars = [ var for var in self.prohibited_vars if var in value ] all_container_vars.update(contained_vars) self.log.error( "Parm {} contains prohibited vars {}: {}".format( param.path(), ", ".join(contained_vars), value) ) message = ( "Prohibited vars {} found in parameter values".format( ", ".join(all_container_vars) ) ) raise PublishValidationError(message, title=self.label) @classmethod def get_invalid(cls): invalid = [] for param, _ in hou.fileReferences(): # it might return None for some reason if not param: continue # skip nodes we are not interested in if param.node().type().name() not in cls.node_types: continue if any( v for v in cls.prohibited_vars if v in param.unexpandedString()): invalid.append(param) return invalid @classmethod def repair(cls, instance): invalid = cls.get_invalid() for param in invalid: cls.log.info("Processing: {}".format(param.path())) cls.log.info("Replacing {} for {}".format( param.unexpandedString(), hou.text.expandString(param.unexpandedString()))) param.set(hou.text.expandString(param.unexpandedString())) ================================================ FILE: openpype/hosts/houdini/startup/MainMenuCommon.xml ================================================ ================================================ FILE: openpype/hosts/houdini/startup/python2.7libs/pythonrc.py ================================================ # -*- coding: utf-8 -*- """OpenPype startup script.""" from openpype.pipeline import install_host from openpype.hosts.houdini.api import HoudiniHost from openpype import AYON_SERVER_ENABLED def main(): print("Installing {} ...".format( "AYON" if AYON_SERVER_ENABLED else "OpenPype")) install_host(HoudiniHost()) main() ================================================ FILE: openpype/hosts/houdini/startup/python3.10libs/pythonrc.py ================================================ # -*- coding: utf-8 -*- """OpenPype startup script.""" from openpype.pipeline import install_host from openpype.hosts.houdini.api import HoudiniHost from openpype import AYON_SERVER_ENABLED def main(): print("Installing {} ...".format( "AYON" if AYON_SERVER_ENABLED else "OpenPype")) install_host(HoudiniHost()) main() ================================================ FILE: openpype/hosts/houdini/startup/python3.7libs/pythonrc.py ================================================ # -*- coding: utf-8 -*- """OpenPype startup script.""" from openpype.pipeline import install_host from openpype.hosts.houdini.api import HoudiniHost from openpype import AYON_SERVER_ENABLED def main(): print("Installing {} ...".format( "AYON" if AYON_SERVER_ENABLED else "OpenPype")) install_host(HoudiniHost()) main() ================================================ FILE: openpype/hosts/houdini/startup/python3.9libs/pythonrc.py ================================================ # -*- coding: utf-8 -*- """OpenPype startup script.""" from openpype.pipeline import install_host from openpype.hosts.houdini.api import HoudiniHost from openpype import AYON_SERVER_ENABLED def main(): print("Installing {} ...".format( "AYON" if AYON_SERVER_ENABLED else "OpenPype")) install_host(HoudiniHost()) main() ================================================ FILE: openpype/hosts/max/__init__.py ================================================ from .addon import ( MaxAddon, MAX_HOST_DIR, ) __all__ = ( "MaxAddon", "MAX_HOST_DIR", ) ================================================ FILE: openpype/hosts/max/addon.py ================================================ # -*- coding: utf-8 -*- import os from openpype.modules import OpenPypeModule, IHostAddon MAX_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) class MaxAddon(OpenPypeModule, IHostAddon): name = "max" host_name = "max" def initialize(self, module_settings): self.enabled = True def add_implementation_envs(self, env, _app): # Remove auto screen scale factor for Qt # - let 3dsmax decide it's value env.pop("QT_AUTO_SCREEN_SCALE_FACTOR", None) def get_workfile_extensions(self): return [".max"] def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] return [ os.path.join(MAX_HOST_DIR, "hooks") ] ================================================ FILE: openpype/hosts/max/api/__init__.py ================================================ # -*- coding: utf-8 -*- """Public API for 3dsmax""" from .pipeline import ( MaxHost, ) from .lib import ( maintained_selection, lsattr, get_all_children ) __all__ = [ "MaxHost", "maintained_selection", "lsattr", "get_all_children" ] ================================================ FILE: openpype/hosts/max/api/action.py ================================================ from pymxs import runtime as rt import pyblish.api from openpype.pipeline.publish import get_errored_instances_from_context class SelectInvalidAction(pyblish.api.Action): """Select invalid objects in Blender when a publish plug-in failed.""" label = "Select Invalid" on = "failed" icon = "search" def process(self, context, plugin): errored_instances = get_errored_instances_from_context(context, plugin=plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes...") invalid = list() for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: if isinstance(invalid_nodes, (list, tuple)): invalid.extend(invalid_nodes) else: self.log.warning( "Failed plug-in doesn't have any selectable objects." ) if not invalid: self.log.info("No invalid nodes found.") return invalid_names = [obj.name for obj in invalid if isinstance(obj, str)] if not invalid_names: invalid_names = [obj.name for obj, _ in invalid] invalid = [obj for obj, _ in invalid] self.log.info( "Selecting invalid objects: %s", ", ".join(invalid_names) ) rt.Select(invalid) ================================================ FILE: openpype/hosts/max/api/colorspace.py ================================================ import attr from pymxs import runtime as rt @attr.s class LayerMetadata(object): """Data class for Render Layer metadata.""" frameStart = attr.ib() frameEnd = attr.ib() @attr.s class RenderProduct(object): """Getting Colorspace as Specific Render Product Parameter for submitting publish job. """ colorspace = attr.ib() # colorspace view = attr.ib() productName = attr.ib(default=None) class ARenderProduct(object): def __init__(self): """Constructor.""" # Initialize self.layer_data = self._get_layer_data() self.layer_data.products = self.get_colorspace_data() def _get_layer_data(self): return LayerMetadata( frameStart=int(rt.rendStart), frameEnd=int(rt.rendEnd), ) def get_colorspace_data(self): """To be implemented by renderer class. This should return a list of RenderProducts. Returns: list: List of RenderProduct """ colorspace_data = [ RenderProduct( colorspace="sRGB", view="ACES 1.0", productName="" ) ] return colorspace_data ================================================ FILE: openpype/hosts/max/api/lib.py ================================================ # -*- coding: utf-8 -*- """Library of functions useful for 3dsmax pipeline.""" import contextlib import logging import json from typing import Any, Dict, Union import six from openpype.pipeline import get_current_project_name, colorspace from openpype.settings import get_project_settings from openpype.pipeline.context_tools import ( get_current_project, get_current_project_asset) from openpype.style import load_stylesheet from pymxs import runtime as rt JSON_PREFIX = "JSON::" log = logging.getLogger("openpype.hosts.max") def get_main_window(): """Acquire Max's main window""" from qtpy import QtWidgets top_widgets = QtWidgets.QApplication.topLevelWidgets() name = "QmaxApplicationWindow" for widget in top_widgets: if ( widget.inherits("QMainWindow") and widget.metaObject().className() == name ): return widget raise RuntimeError('Count not find 3dsMax main window.') def imprint(node_name: str, data: dict) -> bool: node = rt.GetNodeByName(node_name) if not node: return False for k, v in data.items(): if isinstance(v, (dict, list)): rt.SetUserProp(node, k, f"{JSON_PREFIX}{json.dumps(v)}") else: rt.SetUserProp(node, k, v) return True def lsattr( attr: str, value: Union[str, None] = None, root: Union[str, None] = None) -> list: """List nodes having attribute with specified value. Args: attr (str): Attribute name to match. value (str, Optional): Value to match, of omitted, all nodes with specified attribute are returned no matter of value. root (str, Optional): Root node name. If omitted, scene root is used. Returns: list of nodes. """ root = rt.RootNode if root is None else rt.GetNodeByName(root) def output_node(node, nodes): nodes.append(node) for child in node.Children: output_node(child, nodes) nodes = [] output_node(root, nodes) return [ n for n in nodes if rt.GetUserProp(n, attr) == value ] if value else [ n for n in nodes if rt.GetUserProp(n, attr) ] def read(container) -> dict: data = {} props = rt.GetUserPropBuffer(container) # this shouldn't happen but let's guard against it anyway if not props: return data for line in props.split("\r\n"): try: key, value = line.split("=") except ValueError: # if the line cannot be split we can't really parse it continue value = value.strip() if isinstance(value.strip(), six.string_types) and \ value.startswith(JSON_PREFIX): with contextlib.suppress(json.JSONDecodeError): value = json.loads(value[len(JSON_PREFIX):]) # default value behavior # convert maxscript boolean values if value == "true": value = True elif value == "false": value = False data[key.strip()] = value data["instance_node"] = container.Name return data @contextlib.contextmanager def maintained_selection(): previous_selection = rt.GetCurrentSelection() try: yield finally: if previous_selection: rt.Select(previous_selection) else: rt.Select() def get_all_children(parent, node_type=None): """Handy function to get all the children of a given node Args: parent (3dsmax Node1): Node to get all children of. node_type (None, runtime.class): give class to check for e.g. rt.FFDBox/rt.GeometryClass etc. Returns: list: list of all children of the parent node """ def list_children(node): children = [] for c in node.Children: children.append(c) children = children + list_children(c) return children child_list = list_children(parent) return ([x for x in child_list if rt.SuperClassOf(x) == node_type] if node_type else child_list) def get_current_renderer(): """ Notes: Get current renderer for Max Returns: "{Current Renderer}:{Current Renderer}" e.g. "Redshift_Renderer:Redshift_Renderer" """ return rt.renderers.production def get_default_render_folder(project_setting=None): return (project_setting["max"] ["RenderSettings"] ["default_render_image_folder"]) def set_render_frame_range(start_frame, end_frame): """ Note: Frame range can be specified in different types. Possible values are: * `1` - Single frame. * `2` - Active time segment ( animationRange ). * `3` - User specified Range. * `4` - User specified Frame pickup string (for example `1,3,5-12`). Todo: Current type is hard-coded, there should be a custom setting for this. """ rt.rendTimeType = 3 if start_frame is not None and end_frame is not None: rt.rendStart = int(start_frame) rt.rendEnd = int(end_frame) def get_multipass_setting(project_setting=None): return (project_setting["max"] ["RenderSettings"] ["multipass"]) def set_scene_resolution(width: int, height: int): """Set the render resolution Args: width(int): value of the width height(int): value of the height Returns: None """ # make sure the render dialog is closed # for the update of resolution # Changing the Render Setup dialog settings should be done # with the actual Render Setup dialog in a closed state. if rt.renderSceneDialog.isOpen(): rt.renderSceneDialog.close() rt.renderWidth = width rt.renderHeight = height def reset_scene_resolution(): """Apply the scene resolution from the project definition scene resolution can be overwritten by an asset if the asset.data contains any information regarding scene resolution . Returns: None """ data = ["data.resolutionWidth", "data.resolutionHeight"] project_resolution = get_current_project(fields=data) project_resolution_data = project_resolution["data"] asset_resolution = get_current_project_asset(fields=data) asset_resolution_data = asset_resolution["data"] # Set project resolution project_width = int(project_resolution_data.get("resolutionWidth", 1920)) project_height = int(project_resolution_data.get("resolutionHeight", 1080)) width = int(asset_resolution_data.get("resolutionWidth", project_width)) height = int(asset_resolution_data.get("resolutionHeight", project_height)) set_scene_resolution(width, height) def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]: """Get the current assets frame range and handles. Args: asset_doc (dict): Asset Entity Data Returns: dict: with frame start, frame end, handle start, handle end. """ # Set frame start/end if asset_doc is None: asset_doc = get_current_project_asset() data = asset_doc["data"] frame_start = data.get("frameStart") frame_end = data.get("frameEnd") if frame_start is None or frame_end is None: return {} frame_start = int(frame_start) frame_end = int(frame_end) handle_start = int(data.get("handleStart", 0)) handle_end = int(data.get("handleEnd", 0)) frame_start_handle = frame_start - handle_start frame_end_handle = frame_end + handle_end return { "frameStart": frame_start, "frameEnd": frame_end, "handleStart": handle_start, "handleEnd": handle_end, "frameStartHandle": frame_start_handle, "frameEndHandle": frame_end_handle, } def reset_frame_range(fps: bool = True): """Set frame range to current asset. This is part of 3dsmax documentation: animationRange: A System Global variable which lets you get and set an Interval value that defines the start and end frames of the Active Time Segment. frameRate: A System Global variable which lets you get and set an Integer value that defines the current scene frame rate in frames-per-second. """ if fps: data_fps = get_current_project(fields=["data.fps"]) fps_number = float(data_fps["data"]["fps"]) rt.frameRate = fps_number frame_range = get_frame_range() set_timeline( frame_range["frameStartHandle"], frame_range["frameEndHandle"]) set_render_frame_range( frame_range["frameStartHandle"], frame_range["frameEndHandle"]) def reset_unit_scale(): """Apply the unit scale setting to 3dsMax """ project_name = get_current_project_name() settings = get_project_settings(project_name).get("max") scene_scale = settings.get("unit_scale_settings", {}).get("scene_unit_scale") if scene_scale: rt.units.DisplayType = rt.Name("Metric") rt.units.MetricType = rt.Name(scene_scale) else: rt.units.DisplayType = rt.Name("Generic") def convert_unit_scale(): """Convert system unit scale in 3dsMax for fbx export Returns: str: unit scale """ unit_scale_dict = { "millimeters": "mm", "centimeters": "cm", "meters": "m", "kilometers": "km" } current_unit_scale = rt.Execute("units.MetricType as string") return unit_scale_dict[current_unit_scale] def set_context_setting(): """Apply the project settings from the project definition Settings can be overwritten by an asset if the asset.data contains any information regarding those settings. Examples of settings: frame range resolution Returns: None """ reset_scene_resolution() reset_frame_range() reset_colorspace() reset_unit_scale() def get_max_version(): """ Args: get max version date for deadline Returns: #(25000, 62, 0, 25, 0, 0, 997, 2023, "") max_info[7] = max version date """ max_info = rt.MaxVersion() return max_info[7] def is_headless(): """Check if 3dsMax runs in batch mode. If it returns True, it runs in 3dsbatch.exe If it returns False, it runs in 3dsmax.exe """ return rt.maxops.isInNonInteractiveMode() def set_timeline(frameStart, frameEnd): """Set frame range for timeline editor in Max """ rt.animationRange = rt.interval(frameStart, frameEnd) return rt.animationRange def reset_colorspace(): """OCIO Configuration Supports in 3dsMax 2024+ """ if int(get_max_version()) < 2024: return project_name = get_current_project_name() colorspace_mgr = rt.ColorPipelineMgr project_settings = get_project_settings(project_name) max_config_data = colorspace.get_imageio_config( project_name, "max", project_settings) if max_config_data: ocio_config_path = max_config_data["path"] colorspace_mgr = rt.ColorPipelineMgr colorspace_mgr.Mode = rt.Name("OCIO_Custom") colorspace_mgr.OCIOConfigPath = ocio_config_path def check_colorspace(): parent = get_main_window() if parent is None: log.info("Skipping outdated pop-up " "because Max main window can't be found.") if int(get_max_version()) >= 2024: color_mgr = rt.ColorPipelineMgr project_name = get_current_project_name() project_settings = get_project_settings(project_name) max_config_data = colorspace.get_imageio_config( project_name, "max", project_settings) if max_config_data and color_mgr.Mode != rt.Name("OCIO_Custom"): if not is_headless(): from openpype.widgets import popup dialog = popup.Popup(parent=parent) dialog.setWindowTitle("Warning: Wrong OCIO Mode") dialog.setMessage("This scene has wrong OCIO " "Mode setting.") dialog.setButtonText("Fix") dialog.setStyleSheet(load_stylesheet()) dialog.on_clicked.connect(reset_colorspace) dialog.show() def unique_namespace(namespace, format="%02d", prefix="", suffix="", con_suffix="CON"): """Return unique namespace Arguments: namespace (str): Name of namespace to consider format (str, optional): Formatting of the given iteration number suffix (str, optional): Only consider namespaces with this suffix. con_suffix: max only, for finding the name of the master container >>> unique_namespace("bar") # bar01 >>> unique_namespace(":hello") # :hello01 >>> unique_namespace("bar:", suffix="_NS") # bar01_NS: """ def current_namespace(): current = namespace # When inside a namespace Max adds no trailing : if not current.endswith(":"): current += ":" return current # Always check against the absolute namespace root # There's no clash with :x if we're defining namespace :a:x ROOT = ":" if namespace.startswith(":") else current_namespace() # Strip trailing `:` tokens since we might want to add a suffix start = ":" if namespace.startswith(":") else "" end = ":" if namespace.endswith(":") else "" namespace = namespace.strip(":") if ":" in namespace: # Split off any nesting that we don't uniqify anyway. parents, namespace = namespace.rsplit(":", 1) start += parents + ":" ROOT += start iteration = 1 increment_version = True while increment_version: nr_namespace = namespace + format % iteration unique = prefix + nr_namespace + suffix container_name = f"{unique}:{namespace}{con_suffix}" if not rt.getNodeByName(container_name): name_space = start + unique + end increment_version = False return name_space else: increment_version = True iteration += 1 def get_namespace(container_name): """Get the namespace and name of the sub-container Args: container_name (str): the name of master container Raises: RuntimeError: when there is no master container found Returns: namespace (str): namespace of the sub-container name (str): name of the sub-container """ node = rt.getNodeByName(container_name) if not node: raise RuntimeError("Master Container Not Found..") name = rt.getUserProp(node, "name") namespace = rt.getUserProp(node, "namespace") return namespace, name def object_transform_set(container_children): """A function which allows to store the transform of previous loaded object(s) Args: container_children(list): A list of nodes Returns: transform_set (dict): A dict with all transform data of the previous loaded object(s) """ transform_set = {} for node in container_children: name = f"{node.name}.transform" transform_set[name] = node.pos name = f"{node.name}.scale" transform_set[name] = node.scale return transform_set def get_plugins() -> list: """Get all loaded plugins in 3dsMax Returns: plugin_info_list: a list of loaded plugins """ manager = rt.PluginManager count = manager.pluginDllCount plugin_info_list = [] for p in range(1, count + 1): plugin_info = manager.pluginDllName(p) plugin_info_list.append(plugin_info) return plugin_info_list @contextlib.contextmanager def render_resolution(width, height): """Set render resolution option during context Args: width (int): render width height (int): render height """ current_renderWidth = rt.renderWidth current_renderHeight = rt.renderHeight try: rt.renderWidth = width rt.renderHeight = height yield finally: rt.renderWidth = current_renderWidth rt.renderHeight = current_renderHeight @contextlib.contextmanager def suspended_refresh(): """Suspended refresh for scene and modify panel redraw. """ if is_headless(): yield return rt.disableSceneRedraw() rt.suspendEditing() try: yield finally: rt.enableSceneRedraw() rt.resumeEditing() ================================================ FILE: openpype/hosts/max/api/lib_renderproducts.py ================================================ # Render Element Example : For scanline render, VRay # https://help.autodesk.com/view/MAXDEV/2022/ENU/?guid=GUID-E8F75D47-B998-4800-A3A5-610E22913CFC # arnold # https://help.autodesk.com/view/ARNOL/ENU/?guid=arnold_for_3ds_max_ax_maxscript_commands_ax_renderview_commands_html import os from pymxs import runtime as rt from openpype.hosts.max.api.lib import get_current_renderer from openpype.pipeline import get_current_project_name from openpype.settings import get_project_settings class RenderProducts(object): def __init__(self, project_settings=None): self._project_settings = project_settings if not self._project_settings: self._project_settings = get_project_settings( get_current_project_name() ) def get_beauty(self, container): render_dir = os.path.dirname(rt.rendOutputFilename) output_file = os.path.join(render_dir, container) setting = self._project_settings img_fmt = setting["max"]["RenderSettings"]["image_format"] # noqa start_frame = int(rt.rendStart) end_frame = int(rt.rendEnd) + 1 return { "beauty": self.get_expected_beauty( output_file, start_frame, end_frame, img_fmt ) } def get_multiple_beauty(self, outputs, cameras): beauty_output_frames = dict() for output, camera in zip(outputs, cameras): filename, ext = os.path.splitext(output) filename = filename.replace(".", "") ext = ext.replace(".", "") start_frame = int(rt.rendStart) end_frame = int(rt.rendEnd) + 1 new_beauty = self.get_expected_beauty( filename, start_frame, end_frame, ext ) beauty_output = ({ f"{camera}_beauty": new_beauty }) beauty_output_frames.update(beauty_output) return beauty_output_frames def get_multiple_aovs(self, outputs, cameras): renderer_class = get_current_renderer() renderer = str(renderer_class).split(":")[0] aovs_frames = {} for output, camera in zip(outputs, cameras): filename, ext = os.path.splitext(output) filename = filename.replace(".", "") ext = ext.replace(".", "") start_frame = int(rt.rendStart) end_frame = int(rt.rendEnd) + 1 if renderer in [ "ART_Renderer", "V_Ray_6_Hotfix_3", "V_Ray_GPU_6_Hotfix_3", "Default_Scanline_Renderer", "Quicksilver_Hardware_Renderer", ]: render_name = self.get_render_elements_name() if render_name: for name in render_name: aovs_frames.update({ f"{camera}_{name}": self.get_expected_aovs( filename, name, start_frame, end_frame, ext) }) elif renderer == "Redshift_Renderer": render_name = self.get_render_elements_name() if render_name: rs_aov_files = rt.Execute("renderers.current.separateAovFiles") # noqa # this doesn't work, always returns False # rs_AovFiles = rt.RedShift_Renderer().separateAovFiles if ext == "exr" and not rs_aov_files: for name in render_name: if name == "RsCryptomatte": aovs_frames.update({ f"{camera}_{name}": self.get_expected_aovs( filename, name, start_frame, end_frame, ext) }) else: for name in render_name: aovs_frames.update({ f"{camera}_{name}": self.get_expected_aovs( filename, name, start_frame, end_frame, ext) }) elif renderer == "Arnold": render_name = self.get_arnold_product_name() if render_name: for name in render_name: aovs_frames.update({ f"{camera}_{name}": self.get_expected_arnold_product( # noqa filename, name, start_frame, end_frame, ext) }) elif renderer in [ "V_Ray_6_Hotfix_3", "V_Ray_GPU_6_Hotfix_3" ]: if ext != "exr": render_name = self.get_render_elements_name() if render_name: for name in render_name: aovs_frames.update({ f"{camera}_{name}": self.get_expected_aovs( filename, name, start_frame, end_frame, ext) }) return aovs_frames def get_aovs(self, container): render_dir = os.path.dirname(rt.rendOutputFilename) output_file = os.path.join(render_dir, container) setting = self._project_settings img_fmt = setting["max"]["RenderSettings"]["image_format"] # noqa start_frame = int(rt.rendStart) end_frame = int(rt.rendEnd) + 1 renderer_class = get_current_renderer() renderer = str(renderer_class).split(":")[0] render_dict = {} if renderer in [ "ART_Renderer", "V_Ray_6_Hotfix_3", "V_Ray_GPU_6_Hotfix_3", "Default_Scanline_Renderer", "Quicksilver_Hardware_Renderer", ]: render_name = self.get_render_elements_name() if render_name: for name in render_name: render_dict.update({ name: self.get_expected_aovs( output_file, name, start_frame, end_frame, img_fmt) }) elif renderer == "Redshift_Renderer": render_name = self.get_render_elements_name() if render_name: rs_aov_files = rt.Execute("renderers.current.separateAovFiles") # this doesn't work, always returns False # rs_AovFiles = rt.RedShift_Renderer().separateAovFiles if img_fmt == "exr" and not rs_aov_files: for name in render_name: if name == "RsCryptomatte": render_dict.update({ name: self.get_expected_aovs( output_file, name, start_frame, end_frame, img_fmt) }) else: for name in render_name: render_dict.update({ name: self.get_expected_aovs( output_file, name, start_frame, end_frame, img_fmt) }) elif renderer == "Arnold": render_name = self.get_arnold_product_name() if render_name: for name in render_name: render_dict.update({ name: self.get_expected_arnold_product( output_file, name, start_frame, end_frame, img_fmt) }) elif renderer in [ "V_Ray_6_Hotfix_3", "V_Ray_GPU_6_Hotfix_3" ]: if img_fmt != "exr": render_name = self.get_render_elements_name() if render_name: for name in render_name: render_dict.update({ name: self.get_expected_aovs( output_file, name, start_frame, end_frame, img_fmt) # noqa }) return render_dict def get_expected_beauty(self, folder, start_frame, end_frame, fmt): beauty_frame_range = [] for f in range(start_frame, end_frame): frame = "%04d" % f beauty_output = f"{folder}.{frame}.{fmt}" beauty_output = beauty_output.replace("\\", "/") beauty_frame_range.append(beauty_output) return beauty_frame_range def get_arnold_product_name(self): """Get all the Arnold AOVs name""" aov_name = [] amw = rt.MaxToAOps.AOVsManagerWindow() aov_mgr = rt.renderers.current.AOVManager # Check if there is any aov group set in AOV manager aov_group_num = len(aov_mgr.drivers) if aov_group_num < 1: return for i in range(aov_group_num): # get the specific AOV group aov_name.extend(aov.name for aov in aov_mgr.drivers[i].aov_list) # close the AOVs manager window amw.close() return aov_name def get_expected_arnold_product(self, folder, name, start_frame, end_frame, fmt): """Get all the expected Arnold AOVs""" aov_list = [] for f in range(start_frame, end_frame): frame = "%04d" % f render_element = f"{folder}_{name}.{frame}.{fmt}" render_element = render_element.replace("\\", "/") aov_list.append(render_element) return aov_list def get_render_elements_name(self): """Get all the render element names for general """ render_name = [] render_elem = rt.maxOps.GetCurRenderElementMgr() render_elem_num = render_elem.NumRenderElements() if render_elem_num < 1: return # get render elements from the renders for i in range(render_elem_num): renderlayer_name = render_elem.GetRenderElement(i) if renderlayer_name.enabled: target, renderpass = str(renderlayer_name).split(":") render_name.append(renderpass) return render_name def get_expected_aovs(self, folder, name, start_frame, end_frame, fmt): """Get all the expected render element output files. """ render_elements = [] for f in range(start_frame, end_frame): frame = "%04d" % f render_element = f"{folder}_{name}.{frame}.{fmt}" render_element = render_element.replace("\\", "/") render_elements.append(render_element) return render_elements def image_format(self): return self._project_settings["max"]["RenderSettings"]["image_format"] # noqa ================================================ FILE: openpype/hosts/max/api/lib_rendersettings.py ================================================ import os from pymxs import runtime as rt from openpype.lib import Logger from openpype.settings import get_project_settings from openpype.pipeline import get_current_project_name from openpype.pipeline.context_tools import get_current_project_asset from openpype.hosts.max.api.lib import ( set_render_frame_range, get_current_renderer, get_default_render_folder ) class RenderSettings(object): log = Logger.get_logger("RenderSettings") _aov_chars = { "dot": ".", "dash": "-", "underscore": "_" } def __init__(self, project_settings=None): """ Set up the naming convention for the render elements for the deadline submission """ self._project_settings = project_settings if not self._project_settings: self._project_settings = get_project_settings( get_current_project_name() ) def set_render_camera(self, selection): for sel in selection: # to avoid Attribute Error from pymxs wrapper if rt.classOf(sel) in rt.Camera.classes: rt.viewport.setCamera(sel) return raise RuntimeError("Active Camera not found") def render_output(self, container): folder = rt.maxFilePath # hard-coded, should be customized in the setting file = rt.maxFileName folder = folder.replace("\\", "/") # hard-coded, set the renderoutput path setting = self._project_settings render_folder = get_default_render_folder(setting) filename, ext = os.path.splitext(file) output_dir = os.path.join(folder, render_folder, filename) if not os.path.exists(output_dir): os.makedirs(output_dir) # hard-coded, should be customized in the setting context = get_current_project_asset() # get project resolution width = context["data"].get("resolutionWidth") height = context["data"].get("resolutionHeight") # Set Frame Range frame_start = context["data"].get("frame_start") frame_end = context["data"].get("frame_end") set_render_frame_range(frame_start, frame_end) # get the production render renderer_class = get_current_renderer() renderer = str(renderer_class).split(":")[0] img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa output = os.path.join(output_dir, container) try: aov_separator = self._aov_chars[( self._project_settings["max"] ["RenderSettings"] ["aov_separator"] )] except KeyError: aov_separator = "." output_filename = f"{output}..{img_fmt}" output_filename = output_filename.replace("{aov_separator}", aov_separator) rt.rendOutputFilename = output_filename if renderer == "VUE_File_Renderer": return # TODO: Finish the arnold render setup if renderer == "Arnold": self.arnold_setup() if renderer in [ "ART_Renderer", "Redshift_Renderer", "V_Ray_6_Hotfix_3", "V_Ray_GPU_6_Hotfix_3", "Default_Scanline_Renderer", "Quicksilver_Hardware_Renderer", ]: self.render_element_layer(output, width, height, img_fmt) rt.rendSaveFile = True if rt.renderSceneDialog.isOpen(): rt.renderSceneDialog.close() def arnold_setup(self): # get Arnold RenderView run in the background # for setting up renderable camera arv = rt.MAXToAOps.ArnoldRenderView() render_camera = rt.viewport.GetCamera() if render_camera: arv.setOption("Camera", str(render_camera)) # TODO: add AOVs and extension img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa setup_cmd = ( f""" amw = MaxtoAOps.AOVsManagerWindow() amw.close() aovmgr = renderers.current.AOVManager aovmgr.drivers = #() img_fmt = "{img_fmt}" if img_fmt == "png" then driver = ArnoldPNGDriver() if img_fmt == "jpg" then driver = ArnoldJPEGDriver() if img_fmt == "exr" then driver = ArnoldEXRDriver() if img_fmt == "tif" then driver = ArnoldTIFFDriver() if img_fmt == "tiff" then driver = ArnoldTIFFDriver() append aovmgr.drivers driver aovmgr.drivers[1].aov_list = #() """) rt.execute(setup_cmd) arv.close() def render_element_layer(self, dir, width, height, ext): """For Renderers with render elements""" rt.renderWidth = width rt.renderHeight = height render_elem = rt.maxOps.GetCurRenderElementMgr() render_elem_num = render_elem.NumRenderElements() if render_elem_num < 0: return for i in range(render_elem_num): renderlayer_name = render_elem.GetRenderElement(i) target, renderpass = str(renderlayer_name).split(":") aov_name = f"{dir}_{renderpass}..{ext}" render_elem.SetRenderElementFileName(i, aov_name) def get_render_output(self, container, output_dir): output = os.path.join(output_dir, container) img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa output_filename = f"{output}..{img_fmt}" return output_filename def get_render_element(self): orig_render_elem = [] render_elem = rt.maxOps.GetCurRenderElementMgr() render_elem_num = render_elem.NumRenderElements() if render_elem_num < 0: return for i in range(render_elem_num): render_element = render_elem.GetRenderElementFilename(i) orig_render_elem.append(render_element) return orig_render_elem def get_batch_render_elements(self, container, output_dir, camera): render_element_list = list() output = os.path.join(output_dir, container) render_elem = rt.maxOps.GetCurRenderElementMgr() render_elem_num = render_elem.NumRenderElements() if render_elem_num < 0: return img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa for i in range(render_elem_num): renderlayer_name = render_elem.GetRenderElement(i) target, renderpass = str(renderlayer_name).split(":") aov_name = f"{output}_{camera}_{renderpass}..{img_fmt}" render_element_list.append(aov_name) return render_element_list def get_batch_render_output(self, camera): target_layer_no = rt.batchRenderMgr.FindView(camera) target_layer = rt.batchRenderMgr.GetView(target_layer_no) return target_layer.outputFilename def batch_render_elements(self, camera): target_layer_no = rt.batchRenderMgr.FindView(camera) target_layer = rt.batchRenderMgr.GetView(target_layer_no) outputfilename = target_layer.outputFilename directory = os.path.dirname(outputfilename) render_elem = rt.maxOps.GetCurRenderElementMgr() render_elem_num = render_elem.NumRenderElements() if render_elem_num < 0: return ext = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa for i in range(render_elem_num): renderlayer_name = render_elem.GetRenderElement(i) target, renderpass = str(renderlayer_name).split(":") aov_name = f"{directory}_{camera}_{renderpass}..{ext}" render_elem.SetRenderElementFileName(i, aov_name) def batch_render_layer(self, container, output_dir, cameras): outputs = list() output = os.path.join(output_dir, container) img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa for cam in cameras: camera = rt.getNodeByName(cam) layer_no = rt.batchRenderMgr.FindView(cam) renderlayer = None if layer_no == 0: renderlayer = rt.batchRenderMgr.CreateView(camera) else: renderlayer = rt.batchRenderMgr.GetView(layer_no) # use camera name as renderlayer name renderlayer.name = cam renderlayer.outputFilename = f"{output}_{cam}..{img_fmt}" outputs.append(renderlayer.outputFilename) return outputs ================================================ FILE: openpype/hosts/max/api/menu.py ================================================ # -*- coding: utf-8 -*- """3dsmax menu definition of AYON.""" import os from qtpy import QtWidgets, QtCore from pymxs import runtime as rt from openpype.tools.utils import host_tools from openpype.hosts.max.api import lib class OpenPypeMenu(object): """Object representing OpenPype/AYON menu. This is using "hack" to inject itself before "Help" menu of 3dsmax. For some reason `postLoadingMenus` event doesn't fire, and main menu if probably re-initialized by menu templates, se we wait for at least 1 event Qt event loop before trying to insert. """ def __init__(self): super().__init__() self.main_widget = self.get_main_widget() self.menu = None timer = QtCore.QTimer() # set number of event loops to wait. timer.setInterval(1) timer.timeout.connect(self._on_timer) timer.start() self._timer = timer self._counter = 0 def _on_timer(self): if self._counter < 1: self._counter += 1 return self._counter = 0 self._timer.stop() self.build_openpype_menu() @staticmethod def get_main_widget(): """Get 3dsmax main window.""" return QtWidgets.QWidget.find(rt.windows.getMAXHWND()) def get_main_menubar(self) -> QtWidgets.QMenuBar: """Get main Menubar by 3dsmax main window.""" return list(self.main_widget.findChildren(QtWidgets.QMenuBar))[0] def get_or_create_openpype_menu( self, name: str = "&Openpype", before: str = "&Help") -> QtWidgets.QAction: """Create AYON menu. Args: name (str, Optional): AYON menu name. before (str, Optional): Name of the 3dsmax main menu item to add AYON menu before. Returns: QtWidgets.QAction: AYON menu action. """ if self.menu is not None: return self.menu menu_bar = self.get_main_menubar() menu_items = menu_bar.findChildren( QtWidgets.QMenu, options=QtCore.Qt.FindDirectChildrenOnly) help_action = None for item in menu_items: if name in item.title(): # we already have OpenPype menu return item if before in item.title(): help_action = item.menuAction() tab_menu_label = os.environ.get("AVALON_LABEL") or "AYON" op_menu = QtWidgets.QMenu("&{}".format(tab_menu_label)) menu_bar.insertMenu(help_action, op_menu) self.menu = op_menu return op_menu def build_openpype_menu(self) -> QtWidgets.QAction: """Build items in AYON menu.""" openpype_menu = self.get_or_create_openpype_menu() load_action = QtWidgets.QAction("Load...", openpype_menu) load_action.triggered.connect(self.load_callback) openpype_menu.addAction(load_action) publish_action = QtWidgets.QAction("Publish...", openpype_menu) publish_action.triggered.connect(self.publish_callback) openpype_menu.addAction(publish_action) manage_action = QtWidgets.QAction("Manage...", openpype_menu) manage_action.triggered.connect(self.manage_callback) openpype_menu.addAction(manage_action) library_action = QtWidgets.QAction("Library...", openpype_menu) library_action.triggered.connect(self.library_callback) openpype_menu.addAction(library_action) openpype_menu.addSeparator() workfiles_action = QtWidgets.QAction("Work Files...", openpype_menu) workfiles_action.triggered.connect(self.workfiles_callback) openpype_menu.addAction(workfiles_action) openpype_menu.addSeparator() res_action = QtWidgets.QAction("Set Resolution", openpype_menu) res_action.triggered.connect(self.resolution_callback) openpype_menu.addAction(res_action) frame_action = QtWidgets.QAction("Set Frame Range", openpype_menu) frame_action.triggered.connect(self.frame_range_callback) openpype_menu.addAction(frame_action) colorspace_action = QtWidgets.QAction("Set Colorspace", openpype_menu) colorspace_action.triggered.connect(self.colorspace_callback) openpype_menu.addAction(colorspace_action) unit_scale_action = QtWidgets.QAction("Set Unit Scale", openpype_menu) unit_scale_action.triggered.connect(self.unit_scale_callback) openpype_menu.addAction(unit_scale_action) return openpype_menu def load_callback(self): """Callback to show Loader tool.""" host_tools.show_loader(parent=self.main_widget) def publish_callback(self): """Callback to show Publisher tool.""" host_tools.show_publisher(parent=self.main_widget) def manage_callback(self): """Callback to show Scene Manager/Inventory tool.""" host_tools.show_scene_inventory(parent=self.main_widget) def library_callback(self): """Callback to show Library Loader tool.""" host_tools.show_library_loader(parent=self.main_widget) def workfiles_callback(self): """Callback to show Workfiles tool.""" host_tools.show_workfiles(parent=self.main_widget) def resolution_callback(self): """Callback to reset scene resolution""" return lib.reset_scene_resolution() def frame_range_callback(self): """Callback to reset frame range""" return lib.reset_frame_range() def colorspace_callback(self): """Callback to reset colorspace""" return lib.reset_colorspace() def unit_scale_callback(self): """Callback to reset unit scale""" return lib.reset_unit_scale() ================================================ FILE: openpype/hosts/max/api/pipeline.py ================================================ # -*- coding: utf-8 -*- """Pipeline tools for OpenPype Houdini integration.""" import os import logging from operator import attrgetter import json from openpype.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost import pyblish.api from openpype.pipeline import ( register_creator_plugin_path, register_loader_plugin_path, AVALON_CONTAINER_ID, ) from openpype.hosts.max.api.menu import OpenPypeMenu from openpype.hosts.max.api import lib from openpype.hosts.max.api.plugin import MS_CUSTOM_ATTRIB from openpype.hosts.max import MAX_HOST_DIR from pymxs import runtime as rt # noqa log = logging.getLogger("openpype.hosts.max") PLUGINS_DIR = os.path.join(MAX_HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") class MaxHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): name = "max" menu = None def __init__(self): super(MaxHost, self).__init__() self._op_events = {} self._has_been_setup = False def install(self): pyblish.api.register_host("max") pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) # self._register_callbacks() self.menu = OpenPypeMenu() self._has_been_setup = True def context_setting(): return lib.set_context_setting() rt.callbacks.addScript(rt.Name('systemPostNew'), context_setting) rt.callbacks.addScript(rt.Name('filePostOpen'), lib.check_colorspace) rt.callbacks.addScript(rt.Name('postWorkspaceChange'), self._deferred_menu_creation) def workfile_has_unsaved_changes(self): return rt.getSaveRequired() def get_workfile_extensions(self): return [".max"] def save_workfile(self, dst_path=None): rt.saveMaxFile(dst_path) return dst_path def open_workfile(self, filepath): rt.checkForSave() rt.loadMaxFile(filepath) return filepath def get_current_workfile(self): return os.path.join(rt.maxFilePath, rt.maxFileName) def get_containers(self): return ls() def _register_callbacks(self): rt.callbacks.removeScripts(id=rt.name("OpenPypeCallbacks")) rt.callbacks.addScript( rt.Name("postLoadingMenus"), self._deferred_menu_creation, id=rt.Name('OpenPypeCallbacks')) def _deferred_menu_creation(self): self.log.info("Building menu ...") self.menu = OpenPypeMenu() @staticmethod def create_context_node(): """Helper for creating context holding node.""" root_scene = rt.rootScene create_attr_script = (""" attributes "OpenPypeContext" ( parameters main rollout:params ( context type: #string ) rollout params "OpenPype Parameters" ( editText editTextContext "Context" type: #string ) ) """) attr = rt.execute(create_attr_script) rt.custAttributes.add(root_scene, attr) return root_scene.OpenPypeContext.context def update_context_data(self, data, changes): try: _ = rt.rootScene.OpenPypeContext.context except AttributeError: # context node doesn't exists self.create_context_node() rt.rootScene.OpenPypeContext.context = json.dumps(data) def get_context_data(self): try: context = rt.rootScene.OpenPypeContext.context except AttributeError: # context node doesn't exists context = self.create_context_node() if not context: context = "{}" return json.loads(context) def save_file(self, dst_path=None): # Force forwards slashes to avoid segfault dst_path = dst_path.replace("\\", "/") rt.saveMaxFile(dst_path) def ls() -> list: """Get all OpenPype instances.""" objs = rt.objects containers = [ obj for obj in objs if rt.getUserProp(obj, "id") == AVALON_CONTAINER_ID ] for container in sorted(containers, key=attrgetter("name")): yield lib.read(container) def containerise(name: str, nodes: list, context, namespace=None, loader=None, suffix="_CON"): data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace or "", "loader": loader, "representation": context["representation"]["_id"], } container_name = f"{namespace}:{name}{suffix}" container = rt.container(name=container_name) import_custom_attribute_data(container, nodes) if not lib.imprint(container_name, data): print(f"imprinting of {container_name} failed.") return container def load_custom_attribute_data(): """Re-loading the AYON custom parameter built by the creator Returns: attribute: re-loading the custom OP attributes set in Maxscript """ return rt.Execute(MS_CUSTOM_ATTRIB) def import_custom_attribute_data(container: str, selections: list): """Importing the Openpype/AYON custom parameter built by the creator Args: container (str): target container which adds custom attributes selections (list): nodes to be added into group in custom attributes """ attrs = load_custom_attribute_data() modifier = rt.EmptyModifier() rt.addModifier(container, modifier) container.modifiers[0].name = "OP Data" rt.custAttributes.add(container.modifiers[0], attrs) node_list = [] sel_list = [] for i in selections: node_ref = rt.NodeTransformMonitor(node=i) node_list.append(node_ref) sel_list.append(str(i)) # Setting the property rt.setProperty( container.modifiers[0].openPypeData, "all_handles", node_list) rt.setProperty( container.modifiers[0].openPypeData, "sel_list", sel_list) def update_custom_attribute_data(container: str, selections: list): """Updating the AYON custom parameter built by the creator Args: container (str): target container which adds custom attributes selections (list): nodes to be added into group in custom attributes """ if container.modifiers[0].name == "OP Data": rt.deleteModifier(container, container.modifiers[0]) import_custom_attribute_data(container, selections) def get_previous_loaded_object(container: str): """Get previous loaded_object through the OP data Args: container (str): the container which stores the OP data Returns: node_list(list): list of nodes which are previously loaded """ node_list = [] sel_list = rt.getProperty(container.modifiers[0].openPypeData, "sel_list") for obj in rt.Objects: if str(obj) in sel_list: node_list.append(obj) return node_list ================================================ FILE: openpype/hosts/max/api/plugin.py ================================================ # -*- coding: utf-8 -*- """3dsmax specific Avalon/Pyblish plugin definitions.""" from abc import ABCMeta import six from pymxs import runtime as rt from openpype.lib import BoolDef from openpype.pipeline import CreatedInstance, Creator, CreatorError from .lib import imprint, lsattr, read MS_CUSTOM_ATTRIB = """attributes "openPypeData" ( parameters main rollout:OPparams ( all_handles type:#maxObjectTab tabSize:0 tabSizeVariable:on sel_list type:#stringTab tabSize:0 tabSizeVariable:on ) rollout OPparams "OP Parameters" ( listbox list_node "Node References" items:#() button button_add "Add to Container" button button_del "Delete from Container" fn node_to_name the_node = ( handle = the_node.handle obj_name = the_node.name handle_name = obj_name + "<" + handle as string + ">" return handle_name ) fn nodes_to_add node = ( sceneObjs = #() if classOf node == Container do return false n = node as string for obj in Objects do ( tmp_obj = obj as string append sceneObjs tmp_obj ) if sel_list != undefined do ( for obj in sel_list do ( idx = findItem sceneObjs obj if idx do ( deleteItem sceneObjs idx ) ) ) idx = findItem sceneObjs n if idx then return true else false ) fn nodes_to_rmv node = ( n = node as string idx = findItem sel_list n if idx then return true else false ) on button_add pressed do ( current_sel = selectByName title:"Select Objects to add to the Container" buttontext:"Add" filter:nodes_to_add if current_sel == undefined then return False temp_arr = #() i_node_arr = #() for c in current_sel do ( handle_name = node_to_name c node_ref = NodeTransformMonitor node:c idx = finditem list_node.items handle_name if idx do ( continue ) name = c as string append temp_arr handle_name append i_node_arr node_ref append sel_list name ) all_handles = join i_node_arr all_handles list_node.items = join temp_arr list_node.items ) on button_del pressed do ( current_sel = selectByName title:"Select Objects to remove from the Container" buttontext:"Remove" filter: nodes_to_rmv if current_sel == undefined or current_sel.count == 0 then ( return False ) temp_arr = #() i_node_arr = #() new_i_node_arr = #() new_temp_arr = #() for c in current_sel do ( node_ref = NodeTransformMonitor node:c as string handle_name = node_to_name c n = c as string tmp_all_handles = #() for i in all_handles do ( tmp = i as string append tmp_all_handles tmp ) idx = finditem tmp_all_handles node_ref if idx do ( new_i_node_arr = DeleteItem all_handles idx ) idx = finditem list_node.items handle_name if idx do ( new_temp_arr = DeleteItem list_node.items idx ) idx = finditem sel_list n if idx do ( sel_list = DeleteItem sel_list idx ) ) all_handles = join i_node_arr new_i_node_arr list_node.items = join temp_arr new_temp_arr ) on OPparams open do ( if all_handles.count != 0 then ( temp_arr = #() for x in all_handles do ( if x.node == undefined do continue handle_name = node_to_name x.node append temp_arr handle_name ) list_node.items = temp_arr ) ) ) )""" class OpenPypeCreatorError(CreatorError): pass class MaxCreatorBase(object): @staticmethod def cache_subsets(shared_data): if shared_data.get("max_cached_subsets") is not None: return shared_data shared_data["max_cached_subsets"] = {} cached_instances = lsattr("id", "pyblish.avalon.instance") for i in cached_instances: creator_id = rt.GetUserProp(i, "creator_identifier") if creator_id not in shared_data["max_cached_subsets"]: shared_data["max_cached_subsets"][creator_id] = [i.name] else: shared_data[ "max_cached_subsets"][creator_id].append(i.name) return shared_data @staticmethod def create_instance_node(node): """Create instance node. If the supplied node is existing node, it will be used to hold the instance, otherwise new node of type Dummy will be created. Args: node (rt.MXSWrapperBase, str): Node or node name to use. Returns: instance """ if isinstance(node, str): node = rt.Container(name=node) attrs = rt.Execute(MS_CUSTOM_ATTRIB) modifier = rt.EmptyModifier() rt.addModifier(node, modifier) node.modifiers[0].name = "OP Data" rt.custAttributes.add(node.modifiers[0], attrs) return node @six.add_metaclass(ABCMeta) class MaxCreator(Creator, MaxCreatorBase): selected_nodes = [] def create(self, subset_name, instance_data, pre_create_data): if pre_create_data.get("use_selection"): self.selected_nodes = rt.GetCurrentSelection() if rt.getNodeByName(subset_name): raise CreatorError(f"'{subset_name}' is already created..") instance_node = self.create_instance_node(subset_name) instance_data["instance_node"] = instance_node.name instance = CreatedInstance( self.family, subset_name, instance_data, self ) if pre_create_data.get("use_selection"): node_list = [] sel_list = [] for i in self.selected_nodes: node_ref = rt.NodeTransformMonitor(node=i) node_list.append(node_ref) sel_list.append(str(i)) # Setting the property rt.setProperty( instance_node.modifiers[0].openPypeData, "all_handles", node_list) rt.setProperty( instance_node.modifiers[0].openPypeData, "sel_list", sel_list) self._add_instance_to_context(instance) imprint(instance_node.name, instance.data_to_store()) return instance def collect_instances(self): self.cache_subsets(self.collection_shared_data) for instance in self.collection_shared_data["max_cached_subsets"].get(self.identifier, []): # noqa created_instance = CreatedInstance.from_existing( read(rt.GetNodeByName(instance)), self ) self._add_instance_to_context(created_instance) def update_instances(self, update_list): for created_inst, changes in update_list: instance_node = created_inst.get("instance_node") new_values = { key: changes[key].new_value for key in changes.changed_keys } subset = new_values.get("subset", "") if subset and instance_node != subset: node = rt.getNodeByName(instance_node) new_subset_name = new_values["subset"] if rt.getNodeByName(new_subset_name): raise CreatorError( "The subset '{}' already exists.".format( new_subset_name)) instance_node = new_subset_name created_inst["instance_node"] = instance_node node.name = instance_node imprint( instance_node, created_inst.data_to_store(), ) def remove_instances(self, instances): """Remove specified instance from the scene. This is only removing `id` parameter so instance is no longer instance, because it might contain valuable data for artist. """ for instance in instances: instance_node = rt.GetNodeByName( instance.data.get("instance_node")) if instance_node: count = rt.custAttributes.count(instance_node.modifiers[0]) rt.custAttributes.delete(instance_node.modifiers[0], count) rt.Delete(instance_node) self._remove_instance_from_context(instance) def get_pre_create_attr_defs(self): return [ BoolDef("use_selection", label="Use selection") ] ================================================ FILE: openpype/hosts/max/api/preview_animation.py ================================================ import logging import contextlib from pymxs import runtime as rt from .lib import get_max_version, render_resolution log = logging.getLogger("openpype.hosts.max") @contextlib.contextmanager def play_preview_when_done(has_autoplay): """Set preview playback option during context Args: has_autoplay (bool): autoplay during creating preview animation """ current_playback = rt.preferences.playPreviewWhenDone try: rt.preferences.playPreviewWhenDone = has_autoplay yield finally: rt.preferences.playPreviewWhenDone = current_playback @contextlib.contextmanager def viewport_layout_and_camera(camera, layout="layout_1"): """Set viewport layout and camera during context ***For 3dsMax 2024+ Args: camera (str): viewport camera layout (str): layout to use in viewport, defaults to `layout_1` Use None to not change viewport layout during context. """ original_camera = rt.viewport.getCamera() original_layout = rt.viewport.getLayout() if not original_camera: # if there is no original camera # use the current camera as original original_camera = rt.getNodeByName(camera) review_camera = rt.getNodeByName(camera) try: if layout is not None: layout = rt.Name(layout) if rt.viewport.getLayout() != layout: rt.viewport.setLayout(layout) rt.viewport.setCamera(review_camera) yield finally: rt.viewport.setLayout(original_layout) rt.viewport.setCamera(original_camera) @contextlib.contextmanager def viewport_preference_setting(general_viewport, nitrous_manager, nitrous_viewport, vp_button_mgr): """Function to set viewport setting during context ***For Max Version < 2024 Args: camera (str): Viewport camera for review render general_viewport (dict): General viewport setting nitrous_manager (dict): Nitrous graphic manager nitrous_viewport (dict): Nitrous setting for preview animation vp_button_mgr (dict): Viewport button manager Setting preview_preferences (dict): Preview Preferences Setting """ orig_vp_grid = rt.viewport.getGridVisibility(1) orig_vp_bkg = rt.viewport.IsSolidBackgroundColorMode() nitrousGraphicMgr = rt.NitrousGraphicsManager viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() vp_button_mgr_original = { key: getattr(rt.ViewportButtonMgr, key) for key in vp_button_mgr } nitrous_manager_original = { key: getattr(nitrousGraphicMgr, key) for key in nitrous_manager } nitrous_viewport_original = { key: getattr(viewport_setting, key) for key in nitrous_viewport } try: rt.viewport.setGridVisibility(1, general_viewport["dspGrid"]) rt.viewport.EnableSolidBackgroundColorMode(general_viewport["dspBkg"]) for key, value in vp_button_mgr.items(): setattr(rt.ViewportButtonMgr, key, value) for key, value in nitrous_manager.items(): setattr(nitrousGraphicMgr, key, value) for key, value in nitrous_viewport.items(): if nitrous_viewport[key] != nitrous_viewport_original[key]: setattr(viewport_setting, key, value) yield finally: rt.viewport.setGridVisibility(1, orig_vp_grid) rt.viewport.EnableSolidBackgroundColorMode(orig_vp_bkg) for key, value in vp_button_mgr_original.items(): setattr(rt.ViewportButtonMgr, key, value) for key, value in nitrous_manager_original.items(): setattr(nitrousGraphicMgr, key, value) for key, value in nitrous_viewport_original.items(): setattr(viewport_setting, key, value) def _render_preview_animation_max_2024( filepath, start, end, percentSize, ext, viewport_options): """Render viewport preview with MaxScript using `CreateAnimation`. ****For 3dsMax 2024+ Args: filepath (str): filepath for render output without frame number and extension, for example: /path/to/file start (int): startFrame end (int): endFrame percentSize (float): render resolution multiplier by 100 e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x viewport_options (dict): viewport setting options, e.g. {"vpStyle": "defaultshading", "vpPreset": "highquality"} Returns: list: Created files """ # the percentSize argument must be integer percent = int(percentSize) filepath = filepath.replace("\\", "/") preview_output = f"{filepath}..{ext}" frame_template = f"{filepath}.{{:04d}}.{ext}" job_args = [] for key, value in viewport_options.items(): if isinstance(value, bool): if value: job_args.append(f"{key}:{value}") elif isinstance(value, str): if key == "vpStyle": if value == "Realistic": value = "defaultshading" elif value == "Shaded": log.warning( "'Shaded' Mode not supported in " "preview animation in Max 2024.\n" "Using 'defaultshading' instead.") value = "defaultshading" elif value == "ConsistentColors": value = "flatcolor" else: value = value.lower() elif key == "vpPreset": if value == "Quality": value = "highquality" elif value == "Customize": value = "userdefined" else: value = value.lower() job_args.append(f"{key}: #{value}") job_str = ( f'CreatePreview filename:"{preview_output}" outputAVI:false ' f"percentSize:{percent} start:{start} end:{end} " f"{' '.join(job_args)} " "autoPlay:false" ) rt.completeRedraw() rt.execute(job_str) # Return the created files return [frame_template.format(frame) for frame in range(start, end + 1)] def _render_preview_animation_max_pre_2024( filepath, startFrame, endFrame, width, height, percentSize, ext): """Render viewport animation by creating bitmaps ***For 3dsMax Version <2024 Args: filepath (str): filepath without frame numbers and extension startFrame (int): start frame endFrame (int): end frame width (int): render resolution width height (int): render resolution height percentSize (float): render resolution multiplier by 100 e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x ext (str): image extension Returns: list: Created filepaths """ # get the screenshot percent = percentSize / 100.0 res_width = width * percent res_height = height * percent frame_template = "{}.{{:04}}.{}".format(filepath, ext) frame_template.replace("\\", "/") files = [] user_cancelled = False for frame in range(startFrame, endFrame + 1): rt.sliderTime = frame filepath = frame_template.format(frame) preview_res = rt.bitmap( res_width, res_height, filename=filepath ) dib = rt.gw.getViewportDib() dib_width = float(dib.width) dib_height = float(dib.height) # aspect ratio viewportRatio = dib_width / dib_height renderRatio = float(res_width / res_height) if viewportRatio < renderRatio: heightCrop = (dib_width / renderRatio) topEdge = int((dib_height - heightCrop) / 2.0) tempImage_bmp = rt.bitmap(dib_width, heightCrop) src_box_value = rt.Box2(0, topEdge, dib_width, heightCrop) rt.pasteBitmap(dib, tempImage_bmp, src_box_value, rt.Point2(0, 0)) rt.copy(tempImage_bmp, preview_res) rt.close(tempImage_bmp) elif viewportRatio > renderRatio: widthCrop = dib_height * renderRatio leftEdge = int((dib_width - widthCrop) / 2.0) tempImage_bmp = rt.bitmap(widthCrop, dib_height) src_box_value = rt.Box2(leftEdge, 0, widthCrop, dib_height) rt.pasteBitmap(dib, tempImage_bmp, src_box_value, rt.Point2(0, 0)) rt.copy(tempImage_bmp, preview_res) rt.close(tempImage_bmp) else: rt.copy(dib, preview_res) rt.save(preview_res) rt.close(preview_res) rt.close(dib) files.append(filepath) if rt.keyboard.escPressed: user_cancelled = True break # clean up the cache rt.gc(delayed=True) if user_cancelled: raise RuntimeError("User cancelled rendering of viewport animation.") return files def render_preview_animation( filepath, ext, camera, start_frame=None, end_frame=None, percentSize=100.0, width=1920, height=1080, viewport_options=None): """Render camera review animation Args: filepath (str): filepath to render to, without frame number and extension ext (str): output file extension camera (str): viewport camera for preview render start_frame (int): start frame end_frame (int): end frame percentSize (float): render resolution multiplier by 100 e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x width (int): render resolution width height (int): render resolution height viewport_options (dict): viewport setting options Returns: list: Rendered output files """ if start_frame is None: start_frame = int(rt.animationRange.start) if end_frame is None: end_frame = int(rt.animationRange.end) if viewport_options is None: viewport_options = viewport_options_for_preview_animation() with play_preview_when_done(False): with viewport_layout_and_camera(camera): if int(get_max_version()) < 2024: with viewport_preference_setting( viewport_options["general_viewport"], viewport_options["nitrous_manager"], viewport_options["nitrous_viewport"], viewport_options["vp_btn_mgr"] ): return _render_preview_animation_max_pre_2024( filepath, start_frame, end_frame, width, height, percentSize, ext ) else: with render_resolution(width, height): return _render_preview_animation_max_2024( filepath, start_frame, end_frame, percentSize, ext, viewport_options ) def viewport_options_for_preview_animation(): """Get default viewport options for `render_preview_animation`. Returns: dict: viewport setting options """ # viewport_options should be the dictionary if int(get_max_version()) < 2024: return { "visualStyleMode": "defaultshading", "viewportPreset": "highquality", "vpTexture": False, "dspGeometry": True, "dspShapes": False, "dspLights": False, "dspCameras": False, "dspHelpers": False, "dspParticles": True, "dspBones": False, "dspBkg": True, "dspGrid": False, "dspSafeFrame": False, "dspFrameNums": False } else: viewport_options = {} viewport_options["general_viewport"] = { "dspBkg": True, "dspGrid": False } viewport_options["nitrous_manager"] = { "AntialiasingQuality": "None" } viewport_options["nitrous_viewport"] = { "VisualStyleMode": "defaultshading", "ViewportPreset": "highquality", "UseTextureEnabled": False } viewport_options["vp_btn_mgr"] = { "EnableButtons": False} return viewport_options ================================================ FILE: openpype/hosts/max/hooks/force_startup_script.py ================================================ # -*- coding: utf-8 -*- """Pre-launch to force 3ds max startup script.""" import os from openpype.hosts.max import MAX_HOST_DIR from openpype.lib.applications import PreLaunchHook, LaunchTypes class ForceStartupScript(PreLaunchHook): """Inject OpenPype environment to 3ds max. Note that this works in combination whit 3dsmax startup script that is translating it back to PYTHONPATH for cases when 3dsmax drops PYTHONPATH environment. Hook `GlobalHostDataHook` must be executed before this hook. """ app_groups = {"3dsmax", "adsk_3dsmax"} order = 11 launch_types = {LaunchTypes.local} def execute(self): startup_args = [ "-U", "MAXScript", os.path.join(MAX_HOST_DIR, "startup", "startup.ms"), ] self.launch_context.launch_args.append(startup_args) ================================================ FILE: openpype/hosts/max/hooks/inject_python.py ================================================ # -*- coding: utf-8 -*- """Pre-launch hook to inject python environment.""" import os from openpype.lib.applications import PreLaunchHook, LaunchTypes class InjectPythonPath(PreLaunchHook): """Inject OpenPype environment to 3dsmax. Note that this works in combination whit 3dsmax startup script that is translating it back to PYTHONPATH for cases when 3dsmax drops PYTHONPATH environment. Hook `GlobalHostDataHook` must be executed before this hook. """ app_groups = {"3dsmax", "adsk_3dsmax"} launch_types = {LaunchTypes.local} def execute(self): self.launch_context.env["MAX_PYTHONPATH"] = os.environ["PYTHONPATH"] ================================================ FILE: openpype/hosts/max/hooks/set_paths.py ================================================ from openpype.lib.applications import PreLaunchHook, LaunchTypes class SetPath(PreLaunchHook): """Set current dir to workdir. Hook `GlobalHostDataHook` must be executed before this hook. """ app_groups = {"max"} launch_types = {LaunchTypes.local} def execute(self): workdir = self.launch_context.env.get("AVALON_WORKDIR", "") if not workdir: self.log.warning("BUG: Workdir is not filled.") return self.launch_context.kwargs["cwd"] = workdir ================================================ FILE: openpype/hosts/max/plugins/__init__.py ================================================ ================================================ FILE: openpype/hosts/max/plugins/create/create_camera.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating camera.""" from openpype.hosts.max.api import plugin class CreateCamera(plugin.MaxCreator): """Creator plugin for Camera.""" identifier = "io.openpype.creators.max.camera" label = "Camera" family = "camera" icon = "gear" ================================================ FILE: openpype/hosts/max/plugins/create/create_maxScene.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating raw max scene.""" from openpype.hosts.max.api import plugin class CreateMaxScene(plugin.MaxCreator): """Creator plugin for 3ds max scenes.""" identifier = "io.openpype.creators.max.maxScene" label = "Max Scene" family = "maxScene" icon = "gear" ================================================ FILE: openpype/hosts/max/plugins/create/create_model.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for model.""" from openpype.hosts.max.api import plugin class CreateModel(plugin.MaxCreator): """Creator plugin for Model.""" identifier = "io.openpype.creators.max.model" label = "Model" family = "model" icon = "gear" ================================================ FILE: openpype/hosts/max/plugins/create/create_pointcache.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating pointcache alembics.""" from openpype.hosts.max.api import plugin class CreatePointCache(plugin.MaxCreator): """Creator plugin for Point caches.""" identifier = "io.openpype.creators.max.pointcache" label = "Point Cache" family = "pointcache" icon = "gear" ================================================ FILE: openpype/hosts/max/plugins/create/create_pointcloud.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating point cloud.""" from openpype.hosts.max.api import plugin class CreatePointCloud(plugin.MaxCreator): """Creator plugin for Point Clouds.""" identifier = "io.openpype.creators.max.pointcloud" label = "Point Cloud" family = "pointcloud" icon = "gear" ================================================ FILE: openpype/hosts/max/plugins/create/create_redshift_proxy.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating camera.""" from openpype.hosts.max.api import plugin from openpype.pipeline import CreatedInstance class CreateRedshiftProxy(plugin.MaxCreator): identifier = "io.openpype.creators.max.redshiftproxy" label = "Redshift Proxy" family = "redshiftproxy" icon = "gear" ================================================ FILE: openpype/hosts/max/plugins/create/create_render.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating camera.""" import os from openpype.hosts.max.api import plugin from openpype.lib import BoolDef from openpype.hosts.max.api.lib_rendersettings import RenderSettings class CreateRender(plugin.MaxCreator): """Creator plugin for Renders.""" identifier = "io.openpype.creators.max.render" label = "Render" family = "maxrender" icon = "gear" def create(self, subset_name, instance_data, pre_create_data): from pymxs import runtime as rt file = rt.maxFileName filename, _ = os.path.splitext(file) instance_data["AssetName"] = filename instance_data["multiCamera"] = pre_create_data.get("multi_cam") num_of_renderlayer = rt.batchRenderMgr.numViews if num_of_renderlayer > 0: rt.batchRenderMgr.DeleteView(num_of_renderlayer) instance = super(CreateRender, self).create( subset_name, instance_data, pre_create_data) container_name = instance.data.get("instance_node") # set output paths for rendering(mandatory for deadline) RenderSettings().render_output(container_name) # TODO: create multiple camera options if self.selected_nodes: selected_nodes_name = [] for sel in self.selected_nodes: name = sel.name selected_nodes_name.append(name) RenderSettings().batch_render_layer( container_name, filename, selected_nodes_name) def get_pre_create_attr_defs(self): attrs = super(CreateRender, self).get_pre_create_attr_defs() return attrs + [ BoolDef("multi_cam", label="Multiple Cameras Submission", default=False), ] ================================================ FILE: openpype/hosts/max/plugins/create/create_review.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating review in Max.""" from openpype.hosts.max.api import plugin from openpype.lib import BoolDef, EnumDef, NumberDef class CreateReview(plugin.MaxCreator): """Review in 3dsMax""" identifier = "io.openpype.creators.max.review" label = "Review" family = "review" icon = "video-camera" review_width = 1920 review_height = 1080 percentSize = 100 keep_images = False image_format = "png" visual_style = "Realistic" viewport_preset = "Quality" vp_texture = True anti_aliasing = "None" def apply_settings(self, project_settings): settings = project_settings["max"]["CreateReview"] # noqa # Take some defaults from settings self.review_width = settings.get("review_width", self.review_width) self.review_height = settings.get("review_height", self.review_height) self.percentSize = settings.get("percentSize", self.percentSize) self.keep_images = settings.get("keep_images", self.keep_images) self.image_format = settings.get("image_format", self.image_format) self.visual_style = settings.get("visual_style", self.visual_style) self.viewport_preset = settings.get( "viewport_preset", self.viewport_preset) self.anti_aliasing = settings.get( "anti_aliasing", self.anti_aliasing) self.vp_texture = settings.get("vp_texture", self.vp_texture) def create(self, subset_name, instance_data, pre_create_data): # Transfer settings from pre create to instance creator_attributes = instance_data.setdefault( "creator_attributes", dict()) for key in ["imageFormat", "keepImages", "review_width", "review_height", "percentSize", "visualStyleMode", "viewportPreset", "antialiasingQuality", "vpTexture"]: if key in pre_create_data: creator_attributes[key] = pre_create_data[key] super(CreateReview, self).create( subset_name, instance_data, pre_create_data) def get_instance_attr_defs(self): image_format_enum = ["exr", "jpg", "png", "tga"] visual_style_preset_enum = [ "Realistic", "Shaded", "Facets", "ConsistentColors", "HiddenLine", "Wireframe", "BoundingBox", "Ink", "ColorInk", "Acrylic", "Tech", "Graphite", "ColorPencil", "Pastel", "Clay", "ModelAssist" ] preview_preset_enum = [ "Quality", "Standard", "Performance", "DXMode", "Customize"] anti_aliasing_enum = ["None", "2X", "4X", "8X"] return [ NumberDef("review_width", label="Review width", decimals=0, minimum=0, default=self.review_width), NumberDef("review_height", label="Review height", decimals=0, minimum=0, default=self.review_height), NumberDef("percentSize", label="Percent of Output", default=self.percentSize, minimum=1, decimals=0), BoolDef("keepImages", label="Keep Image Sequences", default=self.keep_images), EnumDef("imageFormat", image_format_enum, default=self.image_format, label="Image Format Options"), EnumDef("visualStyleMode", visual_style_preset_enum, default=self.visual_style, label="Preference"), EnumDef("viewportPreset", preview_preset_enum, default=self.viewport_preset, label="Preview Preset"), EnumDef("antialiasingQuality", anti_aliasing_enum, default=self.anti_aliasing, label="Anti-aliasing Quality"), BoolDef("vpTexture", label="Viewport Texture", default=self.vp_texture) ] def get_pre_create_attr_defs(self): # Use same attributes as for instance attributes attrs = super().get_pre_create_attr_defs() return attrs + self.get_instance_attr_defs() ================================================ FILE: openpype/hosts/max/plugins/create/create_tycache.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating TyCache.""" from openpype.hosts.max.api import plugin class CreateTyCache(plugin.MaxCreator): """Creator plugin for TyCache.""" identifier = "io.openpype.creators.max.tycache" label = "TyCache" family = "tycache" icon = "gear" ================================================ FILE: openpype/hosts/max/plugins/create/create_workfile.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating workfiles.""" from openpype import AYON_SERVER_ENABLED from openpype.pipeline import CreatedInstance, AutoCreator from openpype.client import get_asset_by_name, get_asset_name_identifier from openpype.hosts.max.api import plugin from openpype.hosts.max.api.lib import read, imprint from pymxs import runtime as rt class CreateWorkfile(plugin.MaxCreatorBase, AutoCreator): """Workfile auto-creator.""" identifier = "io.openpype.creators.max.workfile" label = "Workfile" family = "workfile" icon = "fa5.file" default_variant = "Main" def create(self): variant = self.default_variant current_instance = next( ( instance for instance in self.create_context.instances if instance.creator_identifier == self.identifier ), None) project_name = self.project_name asset_name = self.create_context.get_current_asset_name() task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name if current_instance is None: current_instance_asset = None elif AYON_SERVER_ENABLED: current_instance_asset = current_instance["folderPath"] else: current_instance_asset = current_instance["asset"] if current_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) data = { "task": task_name, "variant": variant } if AYON_SERVER_ENABLED: data["folderPath"] = asset_name else: data["asset"] = asset_name data.update( self.get_dynamic_data( variant, task_name, asset_doc, project_name, host_name, current_instance) ) self.log.info("Auto-creating workfile instance...") instance_node = self.create_node(subset_name) data["instance_node"] = instance_node.name current_instance = CreatedInstance( self.family, subset_name, data, self ) self._add_instance_to_context(current_instance) imprint(instance_node.name, current_instance.data) elif ( current_instance_asset != asset_name or current_instance["task"] != task_name ): # Update instance context if is not the same asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) asset_name = get_asset_name_identifier(asset_doc) if AYON_SERVER_ENABLED: current_instance["folderPath"] = asset_name else: current_instance["asset"] = asset_name current_instance["task"] = task_name current_instance["subset"] = subset_name def collect_instances(self): self.cache_subsets(self.collection_shared_data) for instance in self.collection_shared_data["max_cached_subsets"].get(self.identifier, []): # noqa if not rt.getNodeByName(instance): continue created_instance = CreatedInstance.from_existing( read(rt.GetNodeByName(instance)), self ) self._add_instance_to_context(created_instance) def update_instances(self, update_list): for created_inst, _ in update_list: instance_node = created_inst.get("instance_node") imprint( instance_node, created_inst.data_to_store() ) def remove_instances(self, instances): """Remove specified instance from the scene. This is only removing `id` parameter so instance is no longer instance, because it might contain valuable data for artist. """ for instance in instances: instance_node = rt.GetNodeByName( instance.data.get("instance_node")) if instance_node: rt.Delete(instance_node) self._remove_instance_from_context(instance) def create_node(self, subset_name): if rt.getNodeByName(subset_name): node = rt.getNodeByName(subset_name) return node node = rt.Container(name=subset_name) node.isHidden = True return node ================================================ FILE: openpype/hosts/max/plugins/load/load_camera_fbx.py ================================================ import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import ( unique_namespace, get_namespace, object_transform_set ) from openpype.hosts.max.api.pipeline import ( containerise, get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load class FbxLoader(load.LoaderPlugin): """Fbx Loader.""" families = ["camera"] representations = ["fbx"] order = -9 icon = "code-fork" color = "white" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt filepath = self.filepath_from_context(context) filepath = os.path.normpath(filepath) rt.FBXImporterSetParam("Animation", True) rt.FBXImporterSetParam("Camera", True) rt.FBXImporterSetParam("AxisConversionMethod", True) rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( filepath, rt.name("noPrompt"), using=rt.FBXIMP) namespace = unique_namespace( name + "_", suffix="_", ) selections = rt.GetCurrentSelection() for selection in selections: selection.name = f"{namespace}:{selection.name}" return containerise( name, selections, context, namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) namespace, _ = get_namespace(node_name) node_list = get_previous_loaded_object(node) rt.Select(node_list) prev_fbx_objects = rt.GetCurrentSelection() transform_data = object_transform_set(prev_fbx_objects) for prev_fbx_obj in prev_fbx_objects: if rt.isValidNode(prev_fbx_obj): rt.Delete(prev_fbx_obj) rt.FBXImporterSetParam("Animation", True) rt.FBXImporterSetParam("Camera", True) rt.FBXImporterSetParam("Mode", rt.Name("merge")) rt.FBXImporterSetParam("AxisConversionMethod", True) rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() fbx_objects = [] for fbx_object in current_fbx_objects: fbx_object.name = f"{namespace}:{fbx_object.name}" fbx_objects.append(fbx_object) fbx_transform = f"{fbx_object.name}.transform" if fbx_transform in transform_data.keys(): fbx_object.pos = transform_data[fbx_transform] or 0 fbx_object.scale = transform_data[ f"{fbx_object.name}.scale"] or 0 update_custom_attribute_data(node, fbx_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) def switch(self, container, representation): self.update(container, representation) def remove(self, container): from pymxs import runtime as rt node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) ================================================ FILE: openpype/hosts/max/plugins/load/load_max_scene.py ================================================ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( unique_namespace, get_namespace, object_transform_set ) from openpype.hosts.max.api.pipeline import ( containerise, get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load class MaxSceneLoader(load.LoaderPlugin): """Max Scene Loader.""" families = ["camera", "maxScene", "model"] representations = ["max"] order = -8 icon = "code-fork" color = "green" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt path = self.filepath_from_context(context) path = os.path.normpath(path) # import the max scene by using "merge file" path = path.replace('\\', '/') rt.MergeMaxFile(path, quiet=True, includeFullGroup=True) max_objects = rt.getLastMergedNodes() max_object_names = [obj.name for obj in max_objects] # implement the OP/AYON custom attributes before load max_container = [] namespace = unique_namespace( name + "_", suffix="_", ) for max_obj, obj_name in zip(max_objects, max_object_names): max_obj.name = f"{namespace}:{obj_name}" max_container.append(rt.getNodeByName(max_obj.name)) return containerise( name, max_container, context, namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) namespace, _ = get_namespace(node_name) # delete the old container with attribute # delete old duplicate # use the modifier OP data to delete the data node_list = get_previous_loaded_object(node) rt.select(node_list) prev_max_objects = rt.GetCurrentSelection() transform_data = object_transform_set(prev_max_objects) for prev_max_obj in prev_max_objects: if rt.isValidNode(prev_max_obj): # noqa rt.Delete(prev_max_obj) rt.MergeMaxFile(path, quiet=True) current_max_objects = rt.getLastMergedNodes() current_max_object_names = [obj.name for obj in current_max_objects] max_objects = [] for max_obj, obj_name in zip(current_max_objects, current_max_object_names): max_obj.name = f"{namespace}:{obj_name}" max_objects.append(max_obj) max_transform = f"{max_obj.name}.transform" if max_transform in transform_data.keys(): max_obj.pos = transform_data[max_transform] or 0 max_obj.scale = transform_data[ f"{max_obj.name}.scale"] or 0 update_custom_attribute_data(node, max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) def switch(self, container, representation): self.update(container, representation) def remove(self, container): from pymxs import runtime as rt node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) ================================================ FILE: openpype/hosts/max/plugins/load/load_model.py ================================================ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import ( containerise, get_previous_loaded_object ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( maintained_selection, unique_namespace ) class ModelAbcLoader(load.LoaderPlugin): """Loading model with the Alembic loader.""" families = ["model"] label = "Load Model with Alembic" representations = ["abc"] order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt file_path = os.path.normpath(self.filepath_from_context(context)) abc_before = { c for c in rt.rootNode.Children if rt.classOf(c) == rt.AlembicContainer } rt.AlembicImport.ImportToRoot = False rt.AlembicImport.CustomAttributes = True rt.AlembicImport.UVs = True rt.AlembicImport.VertexColors = True rt.importFile(file_path, rt.name("noPrompt"), using=rt.AlembicImport) abc_after = { c for c in rt.rootNode.Children if rt.classOf(c) == rt.AlembicContainer } # This should yield new AlembicContainer node abc_containers = abc_after.difference(abc_before) if len(abc_containers) != 1: self.log.error("Something failed when loading.") abc_container = abc_containers.pop() namespace = unique_namespace( name + "_", suffix="_", ) abc_objects = [] for abc_object in abc_container.Children: abc_object.name = f"{namespace}:{abc_object.name}" abc_objects.append(abc_object) # rename the abc container with namespace abc_container_name = f"{namespace}:{name}" abc_container.name = abc_container_name abc_objects.append(abc_container) return containerise( name, abc_objects, context, namespace, loader=self.__class__.__name__ ) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) node_list = [n for n in get_previous_loaded_object(node) if rt.ClassOf(n) == rt.AlembicContainer] with maintained_selection(): rt.Select(node_list) for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) rt.Select(abc.Children) for abc_con in abc.Children: abc_con.source = path rt.Select(abc_con.Children) for abc_obj in abc_con.Children: abc_obj.source = path lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, ) def switch(self, container, representation): self.update(container, representation) def remove(self, container): from pymxs import runtime as rt node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) @staticmethod def get_container_children(parent, type_name): from pymxs import runtime as rt def list_children(node): children = [] for c in node.Children: children.append(c) children += list_children(c) return children filtered = [] for child in list_children(parent): class_type = str(rt.ClassOf(child.baseObject)) if class_type == type_name: filtered.append(child) return filtered ================================================ FILE: openpype/hosts/max/plugins/load/load_model_fbx.py ================================================ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import ( containerise, get_previous_loaded_object, update_custom_attribute_data ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( unique_namespace, get_namespace, object_transform_set ) from openpype.hosts.max.api.lib import maintained_selection class FbxModelLoader(load.LoaderPlugin): """Fbx Model Loader.""" families = ["model"] representations = ["fbx"] order = -9 icon = "code-fork" color = "white" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt filepath = self.filepath_from_context(context) filepath = os.path.normpath(filepath) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile( filepath, rt.name("noPrompt"), using=rt.FBXIMP) namespace = unique_namespace( name + "_", suffix="_", ) selections = rt.GetCurrentSelection() for selection in selections: selection.name = f"{namespace}:{selection.name}" return containerise( name, selections, context, namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) if not node: rt.Container(name=node_name) namespace, _ = get_namespace(node_name) node_list = get_previous_loaded_object(node) rt.Select(node_list) prev_fbx_objects = rt.GetCurrentSelection() transform_data = object_transform_set(prev_fbx_objects) for prev_fbx_obj in prev_fbx_objects: if rt.isValidNode(prev_fbx_obj): rt.Delete(prev_fbx_obj) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() fbx_objects = [] for fbx_object in current_fbx_objects: fbx_object.name = f"{namespace}:{fbx_object.name}" fbx_objects.append(fbx_object) fbx_transform = f"{fbx_object.name}.transform" if fbx_transform in transform_data.keys(): fbx_object.pos = transform_data[fbx_transform] or 0 fbx_object.scale = transform_data[ f"{fbx_object.name}.scale"] or 0 with maintained_selection(): rt.Select(node) update_custom_attribute_data(node, fbx_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) def switch(self, container, representation): self.update(container, representation) def remove(self, container): from pymxs import runtime as rt node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) ================================================ FILE: openpype/hosts/max/plugins/load/load_model_obj.py ================================================ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( unique_namespace, get_namespace, maintained_selection, object_transform_set ) from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load class ObjLoader(load.LoaderPlugin): """Obj Loader.""" families = ["model"] representations = ["obj"] order = -9 icon = "code-fork" color = "white" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt filepath = os.path.normpath(self.filepath_from_context(context)) self.log.debug("Executing command to import..") rt.Execute(f'importFile @"{filepath}" #noPrompt using:ObjImp') namespace = unique_namespace( name + "_", suffix="_", ) # create "missing" container for obj import selections = rt.GetCurrentSelection() # get current selection for selection in selections: selection.name = f"{namespace}:{selection.name}" return containerise( name, selections, context, namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) namespace, _ = get_namespace(node_name) node_list = get_previous_loaded_object(node) rt.Select(node_list) previous_objects = rt.GetCurrentSelection() transform_data = object_transform_set(previous_objects) for prev_obj in previous_objects: if rt.isValidNode(prev_obj): rt.Delete(prev_obj) rt.Execute(f'importFile @"{path}" #noPrompt using:ObjImp') # get current selection selections = rt.GetCurrentSelection() for selection in selections: selection.name = f"{namespace}:{selection.name}" selection_transform = f"{selection.name}.transform" if selection_transform in transform_data.keys(): selection.pos = transform_data[selection_transform] or 0 selection.scale = transform_data[ f"{selection.name}.scale"] or 0 update_custom_attribute_data(node, selections) with maintained_selection(): rt.Select(node) lib.imprint(node_name, { "representation": str(representation["_id"]) }) def switch(self, container, representation): self.update(container, representation) def remove(self, container): from pymxs import runtime as rt node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) ================================================ FILE: openpype/hosts/max/plugins/load/load_model_usd.py ================================================ import os from pymxs import runtime as rt from openpype.pipeline.load import LoadError from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( unique_namespace, get_namespace, object_transform_set, get_plugins ) from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load class ModelUSDLoader(load.LoaderPlugin): """Loading model with the USD loader.""" families = ["model"] label = "Load Model(USD)" representations = ["usda"] order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): # asset_filepath plugin_info = get_plugins() if "usdimport.dli" not in plugin_info: raise LoadError("No USDImporter loaded/installed in Max..") filepath = os.path.normpath(self.filepath_from_context(context)) import_options = rt.USDImporter.CreateOptions() base_filename = os.path.basename(filepath) _, ext = os.path.splitext(base_filename) log_filepath = filepath.replace(ext, "txt") rt.LogPath = log_filepath rt.LogLevel = rt.Name("info") rt.USDImporter.importFile(filepath, importOptions=import_options) namespace = unique_namespace( name + "_", suffix="_", ) asset = rt.GetNodeByName(name) usd_objects = [] for usd_asset in asset.Children: usd_asset.name = f"{namespace}:{usd_asset.name}" usd_objects.append(usd_asset) asset_name = f"{namespace}:{name}" asset.name = asset_name # need to get the correct container after renamed asset = rt.GetNodeByName(asset_name) usd_objects.append(asset) return containerise( name, usd_objects, context, namespace, loader=self.__class__.__name__) def update(self, container, representation): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.GetNodeByName(node_name) namespace, name = get_namespace(node_name) node_list = get_previous_loaded_object(node) rt.Select(node_list) prev_objects = [sel for sel in rt.GetCurrentSelection() if sel != rt.Container and sel.name != node_name] transform_data = object_transform_set(prev_objects) for n in prev_objects: rt.Delete(n) import_options = rt.USDImporter.CreateOptions() base_filename = os.path.basename(path) _, ext = os.path.splitext(base_filename) log_filepath = path.replace(ext, "txt") rt.LogPath = log_filepath rt.LogLevel = rt.Name("info") rt.USDImporter.importFile( path, importOptions=import_options) asset = rt.GetNodeByName(name) usd_objects = [] for children in asset.Children: children.name = f"{namespace}:{children.name}" usd_objects.append(children) children_transform = f"{children.name}.transform" if children_transform in transform_data.keys(): children.pos = transform_data[children_transform] or 0 children.scale = transform_data[ f"{children.name}.scale"] or 0 asset.name = f"{namespace}:{asset.name}" usd_objects.append(asset) update_custom_attribute_data(node, usd_objects) with maintained_selection(): rt.Select(node) lib.imprint(node_name, { "representation": str(representation["_id"]) }) def switch(self, container, representation): self.update(container, representation) def remove(self, container): node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) ================================================ FILE: openpype/hosts/max/plugins/load/load_pointcache.py ================================================ # -*- coding: utf-8 -*- """Simple alembic loader for 3dsmax. Because of limited api, alembics can be only loaded, but not easily updated. """ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.pipeline import ( containerise, get_previous_loaded_object ) class AbcLoader(load.LoaderPlugin): """Alembic loader.""" families = ["camera", "animation", "pointcache"] label = "Load Alembic" representations = ["abc"] order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt file_path = self.filepath_from_context(context) file_path = os.path.normpath(file_path) abc_before = { c for c in rt.rootNode.Children if rt.classOf(c) == rt.AlembicContainer } rt.AlembicImport.ImportToRoot = False rt.importFile(file_path, rt.name("noPrompt"), using=rt.AlembicImport) abc_after = { c for c in rt.rootNode.Children if rt.classOf(c) == rt.AlembicContainer } # This should yield new AlembicContainer node abc_containers = abc_after.difference(abc_before) if len(abc_containers) != 1: self.log.error("Something failed when loading.") abc_container = abc_containers.pop() selections = rt.GetCurrentSelection() for abc in selections: for cam_shape in abc.Children: cam_shape.playbackType = 0 namespace = unique_namespace( name + "_", suffix="_", ) abc_objects = [] for abc_object in abc_container.Children: abc_object.name = f"{namespace}:{abc_object.name}" abc_objects.append(abc_object) # rename the abc container with namespace abc_container_name = f"{namespace}:{name}" abc_container.name = abc_container_name abc_objects.append(abc_container) return containerise( name, abc_objects, context, namespace, loader=self.__class__.__name__ ) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) abc_container = [n for n in get_previous_loaded_object(node) if rt.ClassOf(n) == rt.AlembicContainer] with maintained_selection(): rt.Select(abc_container) for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) rt.Select(abc.Children) for abc_con in abc.Children: abc_con.source = path rt.Select(abc_con.Children) for abc_obj in abc_con.Children: abc_obj.source = path lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, ) def switch(self, container, representation): self.update(container, representation) def remove(self, container): from pymxs import runtime as rt node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) @staticmethod def get_container_children(parent, type_name): from pymxs import runtime as rt def list_children(node): children = [] for c in node.Children: children.append(c) children += list_children(c) return children filtered = [] for child in list_children(parent): class_type = str(rt.classOf(child.baseObject)) if class_type == type_name: filtered.append(child) return filtered ================================================ FILE: openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py ================================================ import os from openpype.pipeline import load, get_representation_path from openpype.pipeline.load import LoadError from openpype.hosts.max.api.pipeline import ( containerise, get_previous_loaded_object, update_custom_attribute_data ) from openpype.hosts.max.api.lib import ( unique_namespace, get_namespace, object_transform_set, get_plugins ) from openpype.hosts.max.api import lib from pymxs import runtime as rt class OxAbcLoader(load.LoaderPlugin): """Ornatrix Alembic loader.""" families = ["camera", "animation", "pointcache"] label = "Load Alembic with Ornatrix" representations = ["abc"] order = -10 icon = "code-fork" color = "orange" postfix = "param" def load(self, context, name=None, namespace=None, data=None): plugin_list = get_plugins() if "ephere.plugins.autodesk.max.ornatrix.dlo" not in plugin_list: raise LoadError("Ornatrix plugin not " "found/installed in Max yet..") file_path = os.path.normpath(self.filepath_from_context(context)) rt.AlembicImport.ImportToRoot = True rt.AlembicImport.CustomAttributes = True rt.importFile( file_path, rt.name("noPrompt"), using=rt.Ornatrix_Alembic_Importer) scene_object = [] for obj in rt.rootNode.Children: obj_type = rt.ClassOf(obj) if str(obj_type).startswith("Ox_"): scene_object.append(obj) namespace = unique_namespace( name + "_", suffix="_", ) abc_container = [] for abc in scene_object: abc.name = f"{namespace}:{abc.name}" abc_container.append(abc) return containerise( name, abc_container, context, namespace, loader=self.__class__.__name__ ) def update(self, container, representation): path = get_representation_path(representation) node_name = container["instance_node"] namespace, name = get_namespace(node_name) node = rt.getNodeByName(node_name) node_list = get_previous_loaded_object(node) rt.Select(node_list) selections = rt.getCurrentSelection() transform_data = object_transform_set(selections) for prev_obj in selections: if rt.isValidNode(prev_obj): rt.Delete(prev_obj) rt.AlembicImport.ImportToRoot = False rt.AlembicImport.CustomAttributes = True rt.importFile( path, rt.name("noPrompt"), using=rt.Ornatrix_Alembic_Importer) scene_object = [] for obj in rt.rootNode.Children: obj_type = rt.ClassOf(obj) if str(obj_type).startswith("Ox_"): scene_object.append(obj) ox_abc_objects = [] for abc in scene_object: abc.Parent = container abc.name = f"{namespace}:{abc.name}" ox_abc_objects.append(abc) ox_transform = f"{abc.name}.transform" if ox_transform in transform_data.keys(): abc.pos = transform_data[ox_transform] or 0 abc.scale = transform_data[f"{abc.name}.scale"] or 0 update_custom_attribute_data(node, ox_abc_objects) lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, ) def switch(self, container, representation): self.update(container, representation) def remove(self, container): node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) ================================================ FILE: openpype/hosts/max/plugins/load/load_pointcloud.py ================================================ import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import ( unique_namespace, ) from openpype.hosts.max.api.pipeline import ( containerise, get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load class PointCloudLoader(load.LoaderPlugin): """Point Cloud Loader.""" families = ["pointcloud"] representations = ["prt"] order = -8 icon = "code-fork" color = "green" postfix = "param" def load(self, context, name=None, namespace=None, data=None): """load point cloud by tyCache""" from pymxs import runtime as rt filepath = os.path.normpath(self.filepath_from_context(context)) obj = rt.tyCache() obj.filename = filepath namespace = unique_namespace( name + "_", suffix="_", ) obj.name = f"{namespace}:{obj.name}" return containerise( name, [obj], context, namespace, loader=self.__class__.__name__) def update(self, container, representation): """update the container""" from pymxs import runtime as rt path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) node_list = get_previous_loaded_object(node) update_custom_attribute_data( node, node_list) with maintained_selection(): rt.Select(node_list) for prt in rt.Selection: prt.filename = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) def switch(self, container, representation): self.update(container, representation) def remove(self, container): """remove the container""" from pymxs import runtime as rt node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) ================================================ FILE: openpype/hosts/max/plugins/load/load_redshift_proxy.py ================================================ import os import clique from openpype.pipeline import ( load, get_representation_path ) from openpype.pipeline.load import LoadError from openpype.hosts.max.api.pipeline import ( containerise, update_custom_attribute_data, get_previous_loaded_object ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( unique_namespace, get_plugins ) class RedshiftProxyLoader(load.LoaderPlugin): """Load rs files with Redshift Proxy""" label = "Load Redshift Proxy" families = ["redshiftproxy"] representations = ["rs"] order = -9 icon = "code-fork" color = "white" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt plugin_info = get_plugins() if "redshift4max.dlr" not in plugin_info: raise LoadError("Redshift not loaded/installed in Max..") filepath = self.filepath_from_context(context) rs_proxy = rt.RedshiftProxy() rs_proxy.file = filepath files_in_folder = os.listdir(os.path.dirname(filepath)) collections, remainder = clique.assemble(files_in_folder) if collections: rs_proxy.is_sequence = True namespace = unique_namespace( name + "_", suffix="_", ) rs_proxy.name = f"{namespace}:{rs_proxy.name}" return containerise( name, [rs_proxy], context, namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) node = rt.getNodeByName(container["instance_node"]) node_list = get_previous_loaded_object(node) rt.Select(node_list) update_custom_attribute_data( node, rt.Selection) for proxy in rt.Selection: proxy.file = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) def switch(self, container, representation): self.update(container, representation) def remove(self, container): from pymxs import runtime as rt node = rt.getNodeByName(container["instance_node"]) rt.delete(node) ================================================ FILE: openpype/hosts/max/plugins/load/load_tycache.py ================================================ import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import ( unique_namespace, ) from openpype.hosts.max.api.pipeline import ( containerise, get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load class TyCacheLoader(load.LoaderPlugin): """TyCache Loader.""" families = ["tycache"] representations = ["tyc"] order = -8 icon = "code-fork" color = "green" def load(self, context, name=None, namespace=None, data=None): """Load tyCache""" from pymxs import runtime as rt filepath = os.path.normpath(self.filepath_from_context(context)) obj = rt.tyCache() obj.filename = filepath namespace = unique_namespace( name + "_", suffix="_", ) obj.name = f"{namespace}:{obj.name}" return containerise( name, [obj], context, namespace, loader=self.__class__.__name__) def update(self, container, representation): """update the container""" from pymxs import runtime as rt path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) node_list = get_previous_loaded_object(node) update_custom_attribute_data(node, node_list) with maintained_selection(): for tyc in node_list: tyc.filename = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) def switch(self, container, representation): self.update(container, representation) def remove(self, container): """remove the container""" from pymxs import runtime as rt node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) ================================================ FILE: openpype/hosts/max/plugins/publish/collect_frame_range.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from pymxs import runtime as rt class CollectFrameRange(pyblish.api.InstancePlugin): """Collect Frame Range.""" order = pyblish.api.CollectorOrder + 0.01 label = "Collect Frame Range" hosts = ['max'] families = ["camera", "maxrender", "pointcache", "pointcloud", "review", "redshiftproxy"] def process(self, instance): if instance.data["family"] == "maxrender": instance.data["frameStartHandle"] = int(rt.rendStart) instance.data["frameEndHandle"] = int(rt.rendEnd) else: instance.data["frameStartHandle"] = int(rt.animationRange.start) instance.data["frameEndHandle"] = int(rt.animationRange.end) ================================================ FILE: openpype/hosts/max/plugins/publish/collect_members.py ================================================ # -*- coding: utf-8 -*- """Collect instance members.""" import pyblish.api from pymxs import runtime as rt class CollectMembers(pyblish.api.InstancePlugin): """Collect Set Members.""" order = pyblish.api.CollectorOrder + 0.01 label = "Collect Instance Members" hosts = ['max'] def process(self, instance): if instance.data["family"] == "workfile": self.log.debug("Skipping Actions for workfile family.") self.log.debug("{}".format(instance.data["subset"])) return if instance.data.get("instance_node"): container = rt.GetNodeByName(instance.data["instance_node"]) instance.data["members"] = [ member.node for member in container.modifiers[0].openPypeData.all_handles ] self.log.debug("{}".format(instance.data["members"])) ================================================ FILE: openpype/hosts/max/plugins/publish/collect_render.py ================================================ # -*- coding: utf-8 -*- """Collect Render""" import os import pyblish.api from pymxs import runtime as rt from openpype.pipeline.publish import KnownPublishError from openpype.hosts.max.api import colorspace from openpype.hosts.max.api.lib import get_max_version, get_current_renderer from openpype.hosts.max.api.lib_rendersettings import RenderSettings from openpype.hosts.max.api.lib_renderproducts import RenderProducts class CollectRender(pyblish.api.InstancePlugin): """Collect Render for Deadline""" order = pyblish.api.CollectorOrder + 0.02 label = "Collect 3dsmax Render Layers" hosts = ['max'] families = ["maxrender"] def process(self, instance): context = instance.context folder = rt.maxFilePath file = rt.maxFileName current_file = os.path.join(folder, file) filepath = current_file.replace("\\", "/") context.data['currentFile'] = current_file files_by_aov = RenderProducts().get_beauty(instance.name) aovs = RenderProducts().get_aovs(instance.name) files_by_aov.update(aovs) camera = rt.viewport.GetCamera() if instance.data.get("members"): camera_list = [member for member in instance.data["members"] if rt.ClassOf(member) == rt.Camera.Classes] if camera_list: camera = camera_list[-1] instance.data["cameras"] = [camera.name] if camera else None # noqa if instance.data.get("multiCamera"): cameras = instance.data.get("members") if not cameras: raise KnownPublishError("There should be at least" " one renderable camera in container") sel_cam = [ c.name for c in cameras if rt.classOf(c) in rt.Camera.classes] container_name = instance.data.get("instance_node") render_dir = os.path.dirname(rt.rendOutputFilename) outputs = RenderSettings().batch_render_layer( container_name, render_dir, sel_cam ) instance.data["cameras"] = sel_cam files_by_aov = RenderProducts().get_multiple_beauty( outputs, sel_cam) aovs = RenderProducts().get_multiple_aovs( outputs, sel_cam) files_by_aov.update(aovs) if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() instance.data["files"] = list() instance.data["expectedFiles"].append(files_by_aov) instance.data["files"].append(files_by_aov) img_format = RenderProducts().image_format() # OCIO config not support in # most of the 3dsmax renderers # so this is currently hard coded # TODO: add options for redshift/vray ocio config instance.data["colorspaceConfig"] = "" instance.data["colorspaceDisplay"] = "sRGB" instance.data["colorspaceView"] = "ACES 1.0 SDR-video" if int(get_max_version()) >= 2024: colorspace_mgr = rt.ColorPipelineMgr # noqa display = next( (display for display in colorspace_mgr.GetDisplayList())) view_transform = next( (view for view in colorspace_mgr.GetViewList(display))) instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath instance.data["colorspaceDisplay"] = display instance.data["colorspaceView"] = view_transform instance.data["renderProducts"] = colorspace.ARenderProduct() instance.data["publishJobState"] = "Suspended" instance.data["attachTo"] = [] renderer_class = get_current_renderer() renderer = str(renderer_class).split(":")[0] # also need to get the render dir for conversion data = { "asset": instance.data["asset"], "subset": str(instance.name), "publish": True, "maxversion": str(get_max_version()), "imageFormat": img_format, "family": 'maxrender', "families": ['maxrender'], "renderer": renderer, "source": filepath, "plugin": "3dsmax", "frameStart": instance.data["frameStartHandle"], "frameEnd": instance.data["frameEndHandle"], "farm": True } instance.data.update(data) # TODO: this should be unified with maya and its "multipart" flag # on instance. if renderer == "Redshift_Renderer": instance.data.update( {"separateAovFiles": rt.Execute( "renderers.current.separateAovFiles")}) self.log.info("data: {0}".format(data)) ================================================ FILE: openpype/hosts/max/plugins/publish/collect_review.py ================================================ # dont forget getting the focal length for burnin """Collect Review""" import pyblish.api from pymxs import runtime as rt from openpype.lib import BoolDef from openpype.hosts.max.api.lib import get_max_version from openpype.pipeline.publish import ( OpenPypePyblishPluginMixin, KnownPublishError ) class CollectReview(pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin): """Collect Review Data for Preview Animation""" order = pyblish.api.CollectorOrder + 0.02 label = "Collect Review Data" hosts = ['max'] families = ["review"] def process(self, instance): nodes = instance.data["members"] def is_camera(node): is_camera_class = rt.classOf(node) in rt.Camera.classes return is_camera_class and rt.isProperty(node, "fov") # Use first camera in instance cameras = [node for node in nodes if is_camera(node)] if cameras: if len(cameras) > 1: self.log.warning( "Found more than one camera in instance, using first " f"one found: {cameras[0]}" ) camera = cameras[0] camera_name = camera.name focal_length = camera.fov else: raise KnownPublishError( "Unable to find a valid camera in 'Review' container." " Only native max Camera supported. " f"Found objects: {nodes}" ) creator_attrs = instance.data["creator_attributes"] attr_values = self.get_attr_values_from_data(instance.data) general_preview_data = { "review_camera": camera_name, "frameStart": instance.data["frameStartHandle"], "frameEnd": instance.data["frameEndHandle"], "percentSize": creator_attrs["percentSize"], "imageFormat": creator_attrs["imageFormat"], "keepImages": creator_attrs["keepImages"], "fps": instance.context.data["fps"], "review_width": creator_attrs["review_width"], "review_height": creator_attrs["review_height"], } if int(get_max_version()) >= 2024: colorspace_mgr = rt.ColorPipelineMgr # noqa display = next( (display for display in colorspace_mgr.GetDisplayList())) view_transform = next( (view for view in colorspace_mgr.GetViewList(display))) instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath instance.data["colorspaceDisplay"] = display instance.data["colorspaceView"] = view_transform preview_data = { "vpStyle": creator_attrs["visualStyleMode"], "vpPreset": creator_attrs["viewportPreset"], "vpTextures": creator_attrs["vpTexture"], "dspGeometry": attr_values.get("dspGeometry"), "dspShapes": attr_values.get("dspShapes"), "dspLights": attr_values.get("dspLights"), "dspCameras": attr_values.get("dspCameras"), "dspHelpers": attr_values.get("dspHelpers"), "dspParticles": attr_values.get("dspParticles"), "dspBones": attr_values.get("dspBones"), "dspBkg": attr_values.get("dspBkg"), "dspGrid": attr_values.get("dspGrid"), "dspSafeFrame": attr_values.get("dspSafeFrame"), "dspFrameNums": attr_values.get("dspFrameNums") } else: general_viewport = { "dspBkg": attr_values.get("dspBkg"), "dspGrid": attr_values.get("dspGrid") } nitrous_manager = { "AntialiasingQuality": creator_attrs["antialiasingQuality"], } nitrous_viewport = { "VisualStyleMode": creator_attrs["visualStyleMode"], "ViewportPreset": creator_attrs["viewportPreset"], "UseTextureEnabled": creator_attrs["vpTexture"] } preview_data = { "general_viewport": general_viewport, "nitrous_manager": nitrous_manager, "nitrous_viewport": nitrous_viewport, "vp_btn_mgr": {"EnableButtons": False} } # Enable ftrack functionality instance.data.setdefault("families", []).append('ftrack') burnin_members = instance.data.setdefault("burninDataMembers", {}) burnin_members["focalLength"] = focal_length instance.data.update(general_preview_data) instance.data["viewport_options"] = preview_data @classmethod def get_attribute_defs(cls): return [ BoolDef("dspGeometry", label="Geometry", default=True), BoolDef("dspShapes", label="Shapes", default=False), BoolDef("dspLights", label="Lights", default=False), BoolDef("dspCameras", label="Cameras", default=False), BoolDef("dspHelpers", label="Helpers", default=False), BoolDef("dspParticles", label="Particle Systems", default=True), BoolDef("dspBones", label="Bone Objects", default=False), BoolDef("dspBkg", label="Background", default=True), BoolDef("dspGrid", label="Active Grid", default=False), BoolDef("dspSafeFrame", label="Safe Frames", default=False), BoolDef("dspFrameNums", label="Frame Numbers", default=False) ] ================================================ FILE: openpype/hosts/max/plugins/publish/collect_tycache_attributes.py ================================================ import pyblish.api from openpype.lib import EnumDef, TextDef from openpype.pipeline.publish import OpenPypePyblishPluginMixin class CollectTyCacheData(pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin): """Collect Channel Attributes for TyCache Export""" order = pyblish.api.CollectorOrder + 0.02 label = "Collect tyCache attribute Data" hosts = ['max'] families = ["tycache"] def process(self, instance): attr_values = self.get_attr_values_from_data(instance.data) attributes = {} for attr_key in attr_values.get("tycacheAttributes", []): attributes[attr_key] = True for key in ["tycacheLayer", "tycacheObjectName"]: attributes[key] = attr_values.get(key, "") # Collect the selected channel data before exporting instance.data["tyc_attrs"] = attributes self.log.debug( f"Found tycache attributes: {attributes}" ) @classmethod def get_attribute_defs(cls): # TODO: Support the attributes with maxObject array tyc_attr_enum = ["tycacheChanAge", "tycacheChanGroups", "tycacheChanPos", "tycacheChanRot", "tycacheChanScale", "tycacheChanVel", "tycacheChanSpin", "tycacheChanShape", "tycacheChanMatID", "tycacheChanMapping", "tycacheChanMaterials", "tycacheChanCustomFloat" "tycacheChanCustomVector", "tycacheChanCustomTM", "tycacheChanPhysX", "tycacheMeshBackup", "tycacheCreateObject", "tycacheCreateObjectIfNotCreated", "tycacheAdditionalCloth", "tycacheAdditionalSkin", "tycacheAdditionalSkinID", "tycacheAdditionalSkinIDValue", "tycacheAdditionalTerrain", "tycacheAdditionalVDB", "tycacheAdditionalSplinePaths", "tycacheAdditionalGeo", "tycacheAdditionalGeoActivateModifiers", "tycacheSplines", "tycacheSplinesAdditionalSplines" ] tyc_default_attrs = ["tycacheChanGroups", "tycacheChanPos", "tycacheChanRot", "tycacheChanScale", "tycacheChanVel", "tycacheChanShape", "tycacheChanMatID", "tycacheChanMapping", "tycacheChanMaterials", "tycacheCreateObjectIfNotCreated"] return [ EnumDef("tycacheAttributes", tyc_attr_enum, default=tyc_default_attrs, multiselection=True, label="TyCache Attributes"), TextDef("tycacheLayer", label="TyCache Layer", tooltip="Name of tycache layer", default="$(tyFlowLayer)"), TextDef("tycacheObjectName", label="TyCache Object Name", tooltip="TyCache Object Name", default="$(tyFlowName)_tyCache") ] ================================================ FILE: openpype/hosts/max/plugins/publish/collect_workfile.py ================================================ # -*- coding: utf-8 -*- """Collect current work file.""" import os import pyblish.api from pymxs import runtime as rt class CollectWorkfile(pyblish.api.InstancePlugin): """Inject the current working file into context""" order = pyblish.api.CollectorOrder - 0.01 label = "Collect 3dsmax Workfile" hosts = ['max'] def process(self, instance): """Inject the current working file.""" context = instance.context folder = rt.maxFilePath file = rt.maxFileName if not folder or not file: self.log.error("Scene is not saved.") current_file = os.path.join(folder, file) context.data['currentFile'] = current_file ext = os.path.splitext(file)[-1].lstrip(".") data = {} # create instance subset = instance.data["subset"] data.update({ "subset": subset, "asset": context.data["asset"], "label": subset, "publish": True, "family": 'workfile', "families": ['workfile'], "setMembers": [current_file], "frameStart": context.data['frameStart'], "frameEnd": context.data['frameEnd'], "handleStart": context.data['handleStart'], "handleEnd": context.data['handleEnd'] }) data['representations'] = [{ 'name': ext.lstrip("."), 'ext': ext.lstrip("."), 'files': file, "stagingDir": folder, }] instance.data.update(data) self.log.info('Collected data: {}'.format(data)) self.log.info('Collected instance: {}'.format(file)) self.log.info('Scene path: {}'.format(current_file)) self.log.info('staging Dir: {}'.format(folder)) self.log.info('subset: {}'.format(subset)) ================================================ FILE: openpype/hosts/max/plugins/publish/extract_alembic.py ================================================ # -*- coding: utf-8 -*- """ Export alembic file. Note: Parameters on AlembicExport (AlembicExport.Parameter): ParticleAsMesh (bool): Sets whether particle shapes are exported as meshes. AnimTimeRange (enum): How animation is saved: #CurrentFrame: saves current frame #TimeSlider: saves the active time segments on time slider (default) #StartEnd: saves a range specified by the Step StartFrame (int) EnFrame (int) ShapeSuffix (bool): When set to true, appends the string "Shape" to the name of each exported mesh. This property is set to false by default. SamplesPerFrame (int): Sets the number of animation samples per frame. Hidden (bool): When true, export hidden geometry. UVs (bool): When true, export the mesh UV map channel. Normals (bool): When true, export the mesh normals. VertexColors (bool): When true, export the mesh vertex color map 0 and the current vertex color display data when it differs ExtraChannels (bool): When true, export the mesh extra map channels (map channels greater than channel 1) Velocity (bool): When true, export the meh vertex and particle velocity data. MaterialIDs (bool): When true, export the mesh material ID as Alembic face sets. Visibility (bool): When true, export the node visibility data. LayerName (bool): When true, export the node layer name as an Alembic object property. MaterialName (bool): When true, export the geometry node material name as an Alembic object property ObjectID (bool): When true, export the geometry node g-buffer object ID as an Alembic object property. CustomAttributes (bool): When true, export the node and its modifiers custom attributes into an Alembic object compound property. """ import os import pyblish.api from openpype.pipeline import publish, OptionalPyblishPluginMixin from pymxs import runtime as rt from openpype.hosts.max.api import maintained_selection from openpype.hosts.max.api.lib import suspended_refresh from openpype.lib import BoolDef class ExtractAlembic(publish.Extractor, OptionalPyblishPluginMixin): order = pyblish.api.ExtractorOrder label = "Extract Pointcache" hosts = ["max"] families = ["pointcache"] optional = True def process(self, instance): if not self.is_active(instance.data): return parent_dir = self.staging_dir(instance) file_name = "{name}.abc".format(**instance.data) path = os.path.join(parent_dir, file_name) with suspended_refresh(): self._set_abc_attributes(instance) with maintained_selection(): # select and export node_list = instance.data["members"] rt.Select(node_list) rt.exportFile( path, rt.name("noPrompt"), selectedOnly=True, using=rt.AlembicExport, ) if "representations" not in instance.data: instance.data["representations"] = [] representation = { "name": "abc", "ext": "abc", "files": file_name, "stagingDir": parent_dir, } instance.data["representations"].append(representation) def _set_abc_attributes(self, instance): start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] attr_values = self.get_attr_values_from_data(instance.data) custom_attrs = attr_values.get("custom_attrs", False) if not custom_attrs: self.log.debug( "No Custom Attributes included in this abc export...") rt.AlembicExport.ArchiveType = rt.Name("ogawa") rt.AlembicExport.CoordinateSystem = rt.Name("maya") rt.AlembicExport.StartFrame = start rt.AlembicExport.EndFrame = end rt.AlembicExport.CustomAttributes = custom_attrs @classmethod def get_attribute_defs(cls): return [ BoolDef("custom_attrs", label="Custom Attributes", default=False), ] class ExtractCameraAlembic(ExtractAlembic): """Extract Camera with AlembicExport.""" label = "Extract Alembic Camera" families = ["camera"] class ExtractModel(ExtractAlembic): """Extract Geometry in Alembic Format""" label = "Extract Geometry (Alembic)" families = ["model"] def _set_abc_attributes(self, instance): attr_values = self.get_attr_values_from_data(instance.data) custom_attrs = attr_values.get("custom_attrs", False) if not custom_attrs: self.log.debug( "No Custom Attributes included in this abc export...") rt.AlembicExport.ArchiveType = rt.name("ogawa") rt.AlembicExport.CoordinateSystem = rt.name("maya") rt.AlembicExport.CustomAttributes = custom_attrs rt.AlembicExport.UVs = True rt.AlembicExport.VertexColors = True rt.AlembicExport.PreserveInstances = True ================================================ FILE: openpype/hosts/max/plugins/publish/extract_fbx.py ================================================ import os import pyblish.api from openpype.pipeline import publish, OptionalPyblishPluginMixin from pymxs import runtime as rt from openpype.hosts.max.api import maintained_selection from openpype.hosts.max.api.lib import convert_unit_scale class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin): """ Extract Geometry in FBX Format """ order = pyblish.api.ExtractorOrder - 0.05 label = "Extract FBX" hosts = ["max"] families = ["model"] optional = True def process(self, instance): if not self.is_active(instance.data): return stagingdir = self.staging_dir(instance) filename = "{name}.fbx".format(**instance.data) filepath = os.path.join(stagingdir, filename) self._set_fbx_attributes() with maintained_selection(): # select and export node_list = instance.data["members"] rt.Select(node_list) rt.exportFile( filepath, rt.name("noPrompt"), selectedOnly=True, using=rt.FBXEXP, ) if "representations" not in instance.data: instance.data["representations"] = [] representation = { "name": "fbx", "ext": "fbx", "files": filename, "stagingDir": stagingdir, } instance.data["representations"].append(representation) self.log.info( "Extracted instance '%s' to: %s" % (instance.name, filepath) ) def _set_fbx_attributes(self): unit_scale = convert_unit_scale() rt.FBXExporterSetParam("Animation", False) rt.FBXExporterSetParam("Cameras", False) rt.FBXExporterSetParam("Lights", False) rt.FBXExporterSetParam("PointCache", False) rt.FBXExporterSetParam("AxisConversionMethod", "Animation") rt.FBXExporterSetParam("UpAxis", "Y") rt.FBXExporterSetParam("Preserveinstances", True) if unit_scale: rt.FBXExporterSetParam("ConvertUnit", unit_scale) class ExtractCameraFbx(ExtractModelFbx): """Extract Camera with FbxExporter.""" order = pyblish.api.ExtractorOrder - 0.2 label = "Extract Fbx Camera" families = ["camera"] optional = True def _set_fbx_attributes(self): unit_scale = convert_unit_scale() rt.FBXExporterSetParam("Animation", True) rt.FBXExporterSetParam("Cameras", True) rt.FBXExporterSetParam("AxisConversionMethod", "Animation") rt.FBXExporterSetParam("UpAxis", "Y") rt.FBXExporterSetParam("Preserveinstances", True) if unit_scale: rt.FBXExporterSetParam("ConvertUnit", unit_scale) ================================================ FILE: openpype/hosts/max/plugins/publish/extract_max_scene_raw.py ================================================ import os import pyblish.api from openpype.pipeline import publish, OptionalPyblishPluginMixin from pymxs import runtime as rt class ExtractMaxSceneRaw(publish.Extractor, OptionalPyblishPluginMixin): """ Extract Raw Max Scene with SaveSelected """ order = pyblish.api.ExtractorOrder - 0.2 label = "Extract Max Scene (Raw)" hosts = ["max"] families = ["camera", "maxScene", "model"] optional = True def process(self, instance): if not self.is_active(instance.data): return # publish the raw scene for camera self.log.debug("Extracting Raw Max Scene ...") stagingdir = self.staging_dir(instance) filename = "{name}.max".format(**instance.data) max_path = os.path.join(stagingdir, filename) if "representations" not in instance.data: instance.data["representations"] = [] nodes = instance.data["members"] rt.saveNodes(nodes, max_path, quiet=True) self.log.info("Performing Extraction ...") representation = { "name": "max", "ext": "max", "files": filename, "stagingDir": stagingdir, } instance.data["representations"].append(representation) self.log.info( "Extracted instance '%s' to: %s" % (instance.name, max_path) ) ================================================ FILE: openpype/hosts/max/plugins/publish/extract_model_obj.py ================================================ import os import pyblish.api from openpype.pipeline import publish, OptionalPyblishPluginMixin from pymxs import runtime as rt from openpype.hosts.max.api import maintained_selection from openpype.hosts.max.api.lib import suspended_refresh from openpype.pipeline.publish import KnownPublishError class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin): """ Extract Geometry in OBJ Format """ order = pyblish.api.ExtractorOrder - 0.05 label = "Extract OBJ" hosts = ["max"] families = ["model"] optional = True def process(self, instance): if not self.is_active(instance.data): return stagingdir = self.staging_dir(instance) filename = "{name}.obj".format(**instance.data) filepath = os.path.join(stagingdir, filename) with suspended_refresh(): with maintained_selection(): # select and export node_list = instance.data["members"] rt.Select(node_list) rt.exportFile( filepath, rt.name("noPrompt"), selectedOnly=True, using=rt.ObjExp, ) if not os.path.exists(filepath): raise KnownPublishError( "File {} wasn't produced by 3ds max, please check the logs.") if "representations" not in instance.data: instance.data["representations"] = [] representation = { "name": "obj", "ext": "obj", "files": filename, "stagingDir": stagingdir, } instance.data["representations"].append(representation) self.log.info( "Extracted instance '%s' to: %s" % (instance.name, filepath) ) ================================================ FILE: openpype/hosts/max/plugins/publish/extract_model_usd.py ================================================ import os import pyblish.api from pymxs import runtime as rt from openpype.hosts.max.api import maintained_selection from openpype.pipeline import OptionalPyblishPluginMixin, publish class ExtractModelUSD(publish.Extractor, OptionalPyblishPluginMixin): """Extract Geometry in USDA Format.""" order = pyblish.api.ExtractorOrder - 0.05 label = "Extract Geometry (USD)" hosts = ["max"] families = ["model"] optional = True def process(self, instance): if not self.is_active(instance.data): return self.log.info("Extracting Geometry ...") stagingdir = self.staging_dir(instance) asset_filename = "{name}.usda".format(**instance.data) asset_filepath = os.path.join(stagingdir, asset_filename) self.log.info(f"Writing USD '{asset_filepath}' to '{stagingdir}'") log_filename = "{name}.txt".format(**instance.data) log_filepath = os.path.join(stagingdir, log_filename) self.log.info(f"Writing log '{log_filepath}' to '{stagingdir}'") # get the nodes which need to be exported export_options = self.get_export_options(log_filepath) with maintained_selection(): # select and export node_list = instance.data["members"] rt.Select(node_list) rt.USDExporter.ExportFile(asset_filepath, exportOptions=export_options, contentSource=rt.Name("selected"), nodeList=node_list) self.log.info("Performing Extraction ...") if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'usda', 'ext': 'usda', 'files': asset_filename, "stagingDir": stagingdir, } instance.data["representations"].append(representation) log_representation = { 'name': 'txt', 'ext': 'txt', 'files': log_filename, "stagingDir": stagingdir, } instance.data["representations"].append(log_representation) self.log.info( f"Extracted instance '{instance.name}' to: {asset_filepath}") @staticmethod def get_export_options(log_path): """Set Export Options for USD Exporter""" export_options = rt.USDExporter.createOptions() export_options.Meshes = True export_options.Shapes = False export_options.Lights = False export_options.Cameras = False export_options.Materials = False export_options.MeshFormat = rt.Name('fromScene') export_options.FileFormat = rt.Name('ascii') export_options.UpAxis = rt.Name('y') export_options.LogLevel = rt.Name('info') export_options.LogPath = log_path export_options.PreserveEdgeOrientation = True export_options.TimeMode = rt.Name('current') rt.USDexporter.UIOptions = export_options return export_options ================================================ FILE: openpype/hosts/max/plugins/publish/extract_pointcloud.py ================================================ import os import pyblish.api from pymxs import runtime as rt from openpype.hosts.max.api import maintained_selection from openpype.pipeline import publish class ExtractPointCloud(publish.Extractor): """ Extract PRT format with tyFlow operators. Notes: Currently only works for the default partition setting Args: self.export_particle(): sets up all job arguments for attributes to be exported in MAXscript self.get_operators(): get the export_particle operator self.get_custom_attr(): get all custom channel attributes from Openpype setting and sets it as job arguments before exporting self.get_files(): get the files with tyFlow naming convention before publishing self.partition_output_name(): get the naming with partition settings. self.get_partition(): get partition value """ order = pyblish.api.ExtractorOrder - 0.2 label = "Extract Point Cloud" hosts = ["max"] families = ["pointcloud"] settings = [] def process(self, instance): self.settings = self.get_setting(instance) start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] self.log.info("Extracting PRT...") stagingdir = self.staging_dir(instance) filename = "{name}.prt".format(**instance.data) path = os.path.join(stagingdir, filename) with maintained_selection(): job_args = self.export_particle(instance.data["members"], start, end, path) for job in job_args: rt.Execute(job) self.log.info("Performing Extraction ...") if "representations" not in instance.data: instance.data["representations"] = [] self.log.info("Writing PRT with TyFlow Plugin...") filenames = self.get_files( instance.data["members"], path, start, end) self.log.debug(f"filenames: {filenames}") partition = self.partition_output_name( instance.data["members"]) representation = { 'name': 'prt', 'ext': 'prt', 'files': filenames if len(filenames) > 1 else filenames[0], "stagingDir": stagingdir, "outputName": partition # partition value } instance.data["representations"].append(representation) self.log.info(f"Extracted instance '{instance.name}' to: {path}") def export_particle(self, members, start, end, filepath): """Sets up all job arguments for attributes. Those attributes are to be exported in MAX Script. Args: members (list): Member nodes of the instance. start (int): Start frame. end (int): End frame. filepath (str): Path to PRT file. Returns: list of arguments for MAX Script. """ job_args = [] opt_list = self.get_operators(members) for operator in opt_list: start_frame = f"{operator}.frameStart={start}" job_args.append(start_frame) end_frame = f"{operator}.frameEnd={end}" job_args.append(end_frame) filepath = filepath.replace("\\", "/") prt_filename = f'{operator}.PRTFilename="{filepath}"' job_args.append(prt_filename) # Partition mode = f"{operator}.PRTPartitionsMode=2" job_args.append(mode) additional_args = self.get_custom_attr(operator) job_args.extend(iter(additional_args)) prt_export = f"{operator}.exportPRT()" job_args.append(prt_export) return job_args @staticmethod def get_operators(members): """Get Export Particles Operator. Args: members (list): Instance members. Returns: list of particle operators """ opt_list = [] for member in members: obj = member.baseobject # TODO: to see if it can be used maxscript instead anim_names = rt.GetSubAnimNames(obj) for anim_name in anim_names: sub_anim = rt.GetSubAnim(obj, anim_name) boolean = rt.IsProperty(sub_anim, "Export_Particles") if boolean: event_name = sub_anim.Name opt = f"${member.Name}.{event_name}.export_particles" opt_list.append(opt) return opt_list @staticmethod def get_setting(instance): project_setting = instance.context.data["project_settings"] return project_setting["max"]["PointCloud"] def get_custom_attr(self, operator): """Get Custom Attributes""" custom_attr_list = [] attr_settings = self.settings["attribute"] for key, value in attr_settings.items(): custom_attr = "{0}.PRTChannels_{1}=True".format(operator, value) self.log.debug( "{0} will be added as custom attribute".format(key) ) custom_attr_list.append(custom_attr) return custom_attr_list def get_files(self, container, path, start_frame, end_frame): """Get file names for tyFlow. Set the filenames accordingly to the tyFlow file naming extension for the publishing purpose Actual File Output from tyFlow:: __partof..prt e.g. tyFlow_cloth_CCCS_blobbyFill_001__part1of1_00004.prt Args: container: Instance node. path (str): Output directory. start_frame (int): Start frame. end_frame (int): End frame. Returns: list of filenames """ filenames = [] filename = os.path.basename(path) orig_name, ext = os.path.splitext(filename) partition_count, partition_start = self.get_partition(container) for frame in range(int(start_frame), int(end_frame) + 1): actual_name = "{}__part{:03}of{}_{:05}".format(orig_name, partition_start, partition_count, frame) actual_filename = path.replace(orig_name, actual_name) filenames.append(os.path.basename(actual_filename)) return filenames def partition_output_name(self, container): """Get partition output name. Partition output name set for mapping the published file output. Todo: Customizes the setting for the output. Args: container: Instance node. Returns: str: Partition name. """ partition_count, partition_start = self.get_partition(container) return f"_part{partition_start:03}of{partition_count}" def get_partition(self, container): """Get Partition value. Args: container: Instance node. """ opt_list = self.get_operators(container) # TODO: This looks strange? Iterating over # the opt_list but returning from inside? for operator in opt_list: count = rt.Execute(f'{operator}.PRTPartitionsCount') start = rt.Execute(f'{operator}.PRTPartitionsFrom') return count, start ================================================ FILE: openpype/hosts/max/plugins/publish/extract_redshift_proxy.py ================================================ import os import pyblish.api from openpype.pipeline import publish from pymxs import runtime as rt from openpype.hosts.max.api import maintained_selection class ExtractRedshiftProxy(publish.Extractor): """ Extract Redshift Proxy with rsProxy """ order = pyblish.api.ExtractorOrder - 0.1 label = "Extract RedShift Proxy" hosts = ["max"] families = ["redshiftproxy"] def process(self, instance): start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] self.log.debug("Extracting Redshift Proxy...") stagingdir = self.staging_dir(instance) rs_filename = "{name}.rs".format(**instance.data) rs_filepath = os.path.join(stagingdir, rs_filename) rs_filepath = rs_filepath.replace("\\", "/") rs_filenames = self.get_rsfiles(instance, start, end) with maintained_selection(): # select and export node_list = instance.data["members"] rt.Select(node_list) # Redshift rsProxy command # rsProxy fp selected compress connectivity startFrame endFrame # camera warnExisting transformPivotToOrigin rt.rsProxy(rs_filepath, 1, 0, 0, start, end, 0, 1, 1) self.log.info("Performing Extraction ...") if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'rs', 'ext': 'rs', 'files': rs_filenames if len(rs_filenames) > 1 else rs_filenames[0], # noqa "stagingDir": stagingdir, } instance.data["representations"].append(representation) self.log.info("Extracted instance '%s' to: %s" % (instance.name, stagingdir)) def get_rsfiles(self, instance, startFrame, endFrame): rs_filenames = [] rs_name = instance.data["name"] for frame in range(startFrame, endFrame + 1): rs_filename = "%s.%04d.rs" % (rs_name, frame) rs_filenames.append(rs_filename) return rs_filenames ================================================ FILE: openpype/hosts/max/plugins/publish/extract_review_animation.py ================================================ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.max.api.preview_animation import ( render_preview_animation ) class ExtractReviewAnimation(publish.Extractor): """ Extract Review by Review Animation """ order = pyblish.api.ExtractorOrder + 0.001 label = "Extract Review Animation" hosts = ["max"] families = ["review"] def process(self, instance): staging_dir = self.staging_dir(instance) ext = instance.data.get("imageFormat") start = int(instance.data["frameStart"]) end = int(instance.data["frameEnd"]) filepath = os.path.join(staging_dir, instance.name) self.log.debug( "Writing Review Animation to '{}'".format(filepath)) review_camera = instance.data["review_camera"] viewport_options = instance.data.get("viewport_options", {}) files = render_preview_animation( filepath, ext, review_camera, start, end, percentSize=instance.data["percentSize"], width=instance.data["review_width"], height=instance.data["review_height"], viewport_options=viewport_options) filenames = [os.path.basename(path) for path in files] tags = ["review"] if not instance.data.get("keepImages"): tags.append("delete") self.log.debug("Performing Extraction ...") representation = { "name": instance.data["imageFormat"], "ext": instance.data["imageFormat"], "files": filenames, "stagingDir": staging_dir, "frameStart": instance.data["frameStartHandle"], "frameEnd": instance.data["frameEndHandle"], "tags": tags, "preview": True, "camera_name": review_camera } self.log.debug(f"{representation}") if "representations" not in instance.data: instance.data["representations"] = [] instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/max/plugins/publish/extract_thumbnail.py ================================================ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.max.api.preview_animation import render_preview_animation class ExtractThumbnail(publish.Extractor): """Extract Thumbnail for Review """ order = pyblish.api.ExtractorOrder label = "Extract Thumbnail" hosts = ["max"] families = ["review"] def process(self, instance): ext = instance.data.get("imageFormat") frame = int(instance.data["frameStart"]) staging_dir = self.staging_dir(instance) filepath = os.path.join( staging_dir, f"{instance.name}_thumbnail") self.log.debug("Writing Thumbnail to '{}'".format(filepath)) review_camera = instance.data["review_camera"] viewport_options = instance.data.get("viewport_options", {}) files = render_preview_animation( filepath, ext, review_camera, start_frame=frame, end_frame=frame, percentSize=instance.data["percentSize"], width=instance.data["review_width"], height=instance.data["review_height"], viewport_options=viewport_options) thumbnail = next(os.path.basename(path) for path in files) representation = { "name": "thumbnail", "ext": ext, "files": thumbnail, "stagingDir": staging_dir, "thumbnail": True } self.log.debug(f"{representation}") if "representations" not in instance.data: instance.data["representations"] = [] instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/max/plugins/publish/extract_tycache.py ================================================ import os import pyblish.api from pymxs import runtime as rt from openpype.hosts.max.api import maintained_selection from openpype.pipeline import publish class ExtractTyCache(publish.Extractor): """Extract tycache format with tyFlow operators. Notes: - TyCache only works for TyFlow Pro Plugin. Methods: self.get_export_particles_job_args(): sets up all job arguments for attributes to be exported in MAXscript self.get_operators(): get the export_particle operator self.get_files(): get the files with tyFlow naming convention before publishing """ order = pyblish.api.ExtractorOrder - 0.2 label = "Extract TyCache" hosts = ["max"] families = ["tycache"] def process(self, instance): # TODO: let user decide the param start = int(instance.context.data["frameStart"]) end = int(instance.context.data.get("frameEnd")) self.log.debug("Extracting Tycache...") stagingdir = self.staging_dir(instance) filename = "{name}.tyc".format(**instance.data) path = os.path.join(stagingdir, filename) filenames = self.get_files(instance, start, end) additional_attributes = instance.data.get("tyc_attrs", {}) with maintained_selection(): job_args = self.get_export_particles_job_args( instance.data["members"], start, end, path, additional_attributes) for job in job_args: rt.Execute(job) representations = instance.data.setdefault("representations", []) representation = { 'name': 'tyc', 'ext': 'tyc', 'files': filenames if len(filenames) > 1 else filenames[0], "stagingDir": stagingdir, } representations.append(representation) # Get the tyMesh filename for extraction mesh_filename = f"{instance.name}__tyMesh.tyc" mesh_repres = { 'name': 'tyMesh', 'ext': 'tyc', 'files': mesh_filename, "stagingDir": stagingdir, "outputName": '__tyMesh' } representations.append(mesh_repres) # Get the material filename of which assigned in # tyCache for extraction material_filename = f"{instance.name}__tyMtl.mat" full_material_name = os.path.join(stagingdir, material_filename) full_material_name = full_material_name.replace("\\", "/") if os.path.exists(full_material_name): mateiral_repres = { "name": 'tyMtl', "ext": 'mat', 'files': material_filename, 'stagingDir': stagingdir, "outputName": '__tyMtl' } representations.append(mateiral_repres) self.log.debug(f"Extracted instance '{instance.name}' to: {filenames}") def get_files(self, instance, start_frame, end_frame): """Get file names for tyFlow in tyCache format. Set the filenames accordingly to the tyCache file naming extension(.tyc) for the publishing purpose Actual File Output from tyFlow in tyCache format: __tyPart_.tyc e.g. tycacheMain__tyPart_00000.tyc Args: instance (pyblish.api.Instance): instance. start_frame (int): Start frame. end_frame (int): End frame. Returns: filenames(list): list of filenames """ filenames = [] for frame in range(int(start_frame), int(end_frame) + 1): filename = f"{instance.name}__tyPart_{frame:05}.tyc" filenames.append(filename) return filenames def get_export_particles_job_args(self, members, start, end, filepath, additional_attributes): """Sets up all job arguments for attributes. Those attributes are to be exported in MAX Script. Args: members (list): Member nodes of the instance. start (int): Start frame. end (int): End frame. filepath (str): Output path of the TyCache file. additional_attributes (dict): channel attributes data which needed to be exported Returns: list of arguments for MAX Script. """ settings = { "exportMode": 2, "frameStart": start, "frameEnd": end, "tyCacheFilename": filepath.replace("\\", "/") } settings.update(additional_attributes) job_args = [] for operator in self.get_operators(members): for key, value in settings.items(): if isinstance(value, str): # embed in quotes value = f'"{value}"' job_args.append(f"{operator}.{key}={value}") job_args.append(f"{operator}.exportTyCache()") return job_args @staticmethod def get_operators(members): """Get Export Particles Operator. Args: members (list): Instance members. Returns: list of particle operators """ opt_list = [] for member in members: obj = member.baseobject # TODO: see if it can use maxscript instead anim_names = rt.GetSubAnimNames(obj) for anim_name in anim_names: sub_anim = rt.GetSubAnim(obj, anim_name) boolean = rt.IsProperty(sub_anim, "Export_Particles") if boolean: event_name = sub_anim.Name opt = f"${member.Name}.{event_name}.export_particles" opt_list.append(opt) return opt_list ================================================ FILE: openpype/hosts/max/plugins/publish/increment_workfile_version.py ================================================ import pyblish.api from openpype.lib import version_up from pymxs import runtime as rt class IncrementWorkfileVersion(pyblish.api.ContextPlugin): """Increment current workfile version.""" order = pyblish.api.IntegratorOrder + 0.9 label = "Increment Workfile Version" hosts = ["max"] families = ["workfile"] def process(self, context): path = context.data["currentFile"] filepath = version_up(path) rt.saveMaxFile(filepath) self.log.info("Incrementing file version") ================================================ FILE: openpype/hosts/max/plugins/publish/save_scene.py ================================================ import pyblish.api from openpype.pipeline import registered_host class SaveCurrentScene(pyblish.api.ContextPlugin): """Save current scene""" label = "Save current file" order = pyblish.api.ExtractorOrder - 0.49 hosts = ["max"] families = ["maxrender", "workfile"] def process(self, context): host = registered_host() current_file = host.get_current_workfile() assert context.data["currentFile"] == current_file if host.workfile_has_unsaved_changes(): self.log.info(f"Saving current file: {current_file}") host.save_workfile(current_file) else: self.log.debug("No unsaved changes, skipping file save..") ================================================ FILE: openpype/hosts/max/plugins/publish/save_scenes_for_cameras.py ================================================ import pyblish.api import os import sys import tempfile from pymxs import runtime as rt from openpype.lib import run_subprocess from openpype.hosts.max.api.lib_rendersettings import RenderSettings from openpype.hosts.max.api.lib_renderproducts import RenderProducts class SaveScenesForCamera(pyblish.api.InstancePlugin): """Save scene files for multiple cameras without editing the original scene before deadline submission """ label = "Save Scene files for cameras" order = pyblish.api.ExtractorOrder - 0.48 hosts = ["max"] families = ["maxrender"] def process(self, instance): if not instance.data.get("multiCamera"): self.log.debug( "Multi Camera disabled. " "Skipping to save scene files for cameras") return current_folder = rt.maxFilePath current_filename = rt.maxFileName current_filepath = os.path.join(current_folder, current_filename) camera_scene_files = [] scripts = [] filename, ext = os.path.splitext(current_filename) fmt = RenderProducts().image_format() cameras = instance.data.get("cameras") if not cameras: return new_folder = f"{current_folder}_{filename}" os.makedirs(new_folder, exist_ok=True) for camera in cameras: new_output = RenderSettings().get_batch_render_output(camera) # noqa new_output = new_output.replace("\\", "/") new_filename = f"{filename}_{camera}{ext}" new_filepath = os.path.join(new_folder, new_filename) new_filepath = new_filepath.replace("\\", "/") camera_scene_files.append(new_filepath) RenderSettings().batch_render_elements(camera) rt.rendOutputFilename = new_output rt.saveMaxFile(current_filepath) script = (""" from pymxs import runtime as rt import os filename = "{filename}" new_filepath = "{new_filepath}" new_output = "{new_output}" camera = "{camera}" rt.rendOutputFilename = new_output directory = os.path.dirname(rt.rendOutputFilename) directory = os.path.join(directory, filename) render_elem = rt.maxOps.GetCurRenderElementMgr() render_elem_num = render_elem.NumRenderElements() if render_elem_num > 0: ext = "{ext}" for i in range(render_elem_num): renderlayer_name = render_elem.GetRenderElement(i) target, renderpass = str(renderlayer_name).split(":") aov_name = f"{{directory}}_{camera}_{{renderpass}}..{ext}" render_elem.SetRenderElementFileName(i, aov_name) rt.saveMaxFile(new_filepath) """).format(filename=instance.name, new_filepath=new_filepath, new_output=new_output, camera=camera, ext=fmt) scripts.append(script) maxbatch_exe = os.path.join( os.path.dirname(sys.executable), "3dsmaxbatch") maxbatch_exe = maxbatch_exe.replace("\\", "/") if sys.platform == "windows": maxbatch_exe += ".exe" maxbatch_exe = os.path.normpath(maxbatch_exe) with tempfile.TemporaryDirectory() as tmp_dir_name: tmp_script_path = os.path.join( tmp_dir_name, "extract_scene_files.py") self.log.info("Using script file: {}".format(tmp_script_path)) with open(tmp_script_path, "wt") as tmp: for script in scripts: tmp.write(script + "\n") try: current_filepath = current_filepath.replace("\\", "/") tmp_script_path = tmp_script_path.replace("\\", "/") run_subprocess([maxbatch_exe, tmp_script_path, "-sceneFile", current_filepath]) except RuntimeError: self.log.debug("Checking the scene files existing") for camera_scene in camera_scene_files: if not os.path.exists(camera_scene): self.log.error("Camera scene files not existed yet!") raise RuntimeError("MaxBatch.exe doesn't run as expected") self.log.debug(f"Found Camera scene:{camera_scene}") ================================================ FILE: openpype/hosts/max/plugins/publish/validate_attributes.py ================================================ # -*- coding: utf-8 -*- """Validator for Attributes.""" from pyblish.api import ContextPlugin, ValidatorOrder from pymxs import runtime as rt from openpype.pipeline.publish import ( OptionalPyblishPluginMixin, PublishValidationError, RepairContextAction ) def has_property(object_name, property_name): """Return whether an object has a property with given name""" return rt.Execute(f'isProperty {object_name} "{property_name}"') def is_matching_value(object_name, property_name, value): """Return whether an existing property matches value `value""" property_value = rt.Execute(f"{object_name}.{property_name}") # Wrap property value if value is a string valued attributes # starting with a `#` if ( isinstance(value, str) and value.startswith("#") and not value.endswith(")") ): # prefix value with `#` # not applicable for #() array value type # and only applicable for enum i.e. #bob, #sally property_value = f"#{property_value}" return property_value == value class ValidateAttributes(OptionalPyblishPluginMixin, ContextPlugin): """Validates attributes in the project setting are consistent with the nodes from MaxWrapper Class in 3ds max. E.g. "renderers.current.separateAovFiles", "renderers.production.PrimaryGIEngine" Admin(s) need to put the dict below and enable this validator for a check: { "renderers.current":{ "separateAovFiles" : True }, "renderers.production":{ "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE" } .... } """ order = ValidatorOrder hosts = ["max"] label = "Attributes" actions = [RepairContextAction] optional = True @classmethod def get_invalid(cls, context): attributes = ( context.data["project_settings"]["max"]["publish"] ["ValidateAttributes"]["attributes"] ) if not attributes: return invalid = [] for object_name, required_properties in attributes.items(): if not rt.Execute(f"isValidValue {object_name}"): # Skip checking if the node does not # exist in MaxWrapper Class cls.log.debug(f"Unable to find '{object_name}'." " Skipping validation of attributes.") continue for property_name, value in required_properties.items(): if not has_property(object_name, property_name): cls.log.error( "Non-existing property: " f"{object_name}.{property_name}") invalid.append((object_name, property_name)) if not is_matching_value(object_name, property_name, value): cls.log.error( f"Invalid value for: {object_name}.{property_name}" f" should be: {value}") invalid.append((object_name, property_name)) return invalid def process(self, context): if not self.is_active(context.data): self.log.debug("Skipping Validate Attributes...") return invalid_attributes = self.get_invalid(context) if invalid_attributes: bullet_point_invalid_statement = "\n".join( "- {}".format(invalid) for invalid in invalid_attributes ) report = ( "Required Attribute(s) have invalid value(s).\n\n" f"{bullet_point_invalid_statement}\n\n" "You can use repair action to fix them if they are not\n" "unknown property value(s)." ) raise PublishValidationError( report, title="Invalid Value(s) for Required Attribute(s)") @classmethod def repair(cls, context): attributes = ( context.data["project_settings"]["max"]["publish"] ["ValidateAttributes"]["attributes"] ) invalid_attributes = cls.get_invalid(context) for attrs in invalid_attributes: prop, attr = attrs value = attributes[prop][attr] if isinstance(value, str) and not value.startswith("#"): attribute_fix = '{}.{}="{}"'.format( prop, attr, value ) else: attribute_fix = "{}.{}={}".format( prop, attr, value ) rt.Execute(attribute_fix) ================================================ FILE: openpype/hosts/max/plugins/publish/validate_camera_attributes.py ================================================ import pyblish.api from pymxs import runtime as rt from openpype.pipeline.publish import ( RepairAction, OptionalPyblishPluginMixin, PublishValidationError ) from openpype.hosts.max.api.action import SelectInvalidAction class ValidateCameraAttributes(OptionalPyblishPluginMixin, pyblish.api.InstancePlugin): """Validates Camera has no invalid attribute properties or values.(For 3dsMax Cameras only) """ order = pyblish.api.ValidatorOrder families = ['camera'] hosts = ['max'] label = 'Validate Camera Attributes' actions = [SelectInvalidAction, RepairAction] optional = True DEFAULTS = ["fov", "nearrange", "farrange", "nearclip", "farclip"] CAM_TYPE = ["Freecamera", "Targetcamera", "Physical"] @classmethod def get_invalid(cls, instance): invalid = [] if rt.units.DisplayType != rt.Name("Generic"): cls.log.warning( "Generic Type is not used as a scene unit\n\n" "sure you tweak the settings with your own values\n\n" "before validation.") cameras = instance.data["members"] project_settings = instance.context.data["project_settings"].get("max") cam_attr_settings = ( project_settings["publish"]["ValidateCameraAttributes"] ) for camera in cameras: if str(rt.ClassOf(camera)) not in cls.CAM_TYPE: cls.log.debug( "Skipping camera created from external plugin..") continue for attr in cls.DEFAULTS: default_value = cam_attr_settings.get(attr) if default_value == float(0): cls.log.debug( f"the value of {attr} in setting set to" " zero. Skipping the check.") continue if round(rt.getProperty(camera, attr), 1) != default_value: cls.log.error( f"Invalid attribute value for {camera.name}:{attr} " f"(should be: {default_value}))") invalid.append(camera) return invalid def process(self, instance): if not self.is_active(instance.data): self.log.debug("Skipping Validate Camera Attributes.") return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Invalid camera attributes found. See log.") @classmethod def repair(cls, instance): invalid_cameras = cls.get_invalid(instance) project_settings = instance.context.data["project_settings"].get("max") cam_attr_settings = ( project_settings["publish"]["ValidateCameraAttributes"] ) for camera in invalid_cameras: for attr in cls.DEFAULTS: expected_value = cam_attr_settings.get(attr) if expected_value == float(0): cls.log.debug( f"the value of {attr} in setting set to zero.") continue rt.setProperty(camera, attr, expected_value) ================================================ FILE: openpype/hosts/max/plugins/publish/validate_camera_contents.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError from pymxs import runtime as rt class ValidateCameraContent(pyblish.api.InstancePlugin): """Validates Camera instance contents. A Camera instance may only hold a SINGLE camera's transform """ order = pyblish.api.ValidatorOrder families = ["camera", "review"] hosts = ["max"] label = "Camera Contents" camera_type = ["$Free_Camera", "$Target_Camera", "$Physical_Camera", "$Target"] def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError(("Camera instance must only include" "camera (and camera target). " f"Invalid content {invalid}")) def get_invalid(self, instance): """ Get invalid nodes if the instance is not camera """ invalid = [] container = instance.data["instance_node"] self.log.info(f"Validating camera content for {container}") selection_list = instance.data["members"] for sel in selection_list: # to avoid Attribute Error from pymxs wrapper sel_tmp = str(sel) found = any(sel_tmp.startswith(cam) for cam in self.camera_type) if not found: self.log.error("Camera not found") invalid.append(sel) return invalid ================================================ FILE: openpype/hosts/max/plugins/publish/validate_frame_range.py ================================================ import pyblish.api from pymxs import runtime as rt from openpype.pipeline import ( OptionalPyblishPluginMixin ) from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, KnownPublishError ) from openpype.hosts.max.api.lib import get_frame_range, set_timeline class ValidateFrameRange(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates the frame ranges. This is an optional validator checking if the frame range on instance matches the frame range specified for the asset. It also validates render frame ranges of render layers. Repair action will change everything to match the asset frame range. This can be turned off by the artist to allow custom ranges. """ label = "Validate Frame Range" order = ValidateContentsOrder families = ["camera", "maxrender", "pointcache", "pointcloud", "review", "redshiftproxy"] hosts = ["max"] optional = True actions = [RepairAction] def process(self, instance): if not self.is_active(instance.data): self.log.debug("Skipping Validate Frame Range...") return frame_range = get_frame_range( asset_doc=instance.data["assetEntity"]) inst_frame_start = instance.data.get("frameStartHandle") inst_frame_end = instance.data.get("frameEndHandle") if inst_frame_start is None or inst_frame_end is None: raise KnownPublishError( "Missing frame start and frame end on " "instance to to validate." ) frame_start_handle = frame_range["frameStartHandle"] frame_end_handle = frame_range["frameEndHandle"] errors = [] if frame_start_handle != inst_frame_start: errors.append( f"Start frame ({inst_frame_start}) on instance does not match " # noqa f"with the start frame ({frame_start_handle}) set on the asset data. ") # noqa if frame_end_handle != inst_frame_end: errors.append( f"End frame ({inst_frame_end}) on instance does not match " f"with the end frame ({frame_end_handle}) " "from the asset data. ") if errors: bullet_point_errors = "\n".join( "- {}".format(error) for error in errors ) report = ( "Frame range settings are incorrect.\n\n" f"{bullet_point_errors}\n\n" "You can use repair action to fix it." ) raise PublishValidationError(report, title="Frame Range incorrect") @classmethod def repair(cls, instance): frame_range = get_frame_range() frame_start_handle = frame_range["frameStartHandle"] frame_end_handle = frame_range["frameEndHandle"] if instance.data["family"] == "maxrender": rt.rendStart = frame_start_handle rt.rendEnd = frame_end_handle else: set_timeline(frame_start_handle, frame_end_handle) ================================================ FILE: openpype/hosts/max/plugins/publish/validate_instance_has_members.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError class ValidateInstanceHasMembers(pyblish.api.InstancePlugin): """Validates Instance has members. Check if MaxScene containers includes any contents underneath. """ order = pyblish.api.ValidatorOrder families = ["camera", "model", "maxScene", "review", "pointcache", "pointcloud", "redshiftproxy"] hosts = ["max"] label = "Container Contents" def process(self, instance): if not instance.data["members"]: raise PublishValidationError("No content found in the container") ================================================ FILE: openpype/hosts/max/plugins/publish/validate_instance_in_context.py ================================================ # -*- coding: utf-8 -*- """Validate if instance asset is the same as context asset.""" from __future__ import absolute_import import pyblish.api from openpype import AYON_SERVER_ENABLED from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) from openpype.hosts.max.api.action import SelectInvalidAction from pymxs import runtime as rt class ValidateInstanceInContext(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validator to check if instance asset match context asset. When working in per-shot style you always publish data in context of current asset (shot). This validator checks if this is so. It is optional so it can be disabled when needed. Action on this validator will select invalid instances. """ order = ValidateContentsOrder label = "Instance in same Context" optional = True hosts = ["max"] actions = [SelectInvalidAction, RepairAction] def process(self, instance): if not self.is_active(instance.data): return instance_node = rt.getNodeByName(instance.data.get( "instance_node", "")) if not instance_node: return asset_name_attr = "folderPath" if AYON_SERVER_ENABLED else "asset" asset = rt.getUserProp(instance_node, asset_name_attr) context_asset = self.get_context_asset(instance) task = rt.getUserProp(instance_node, "task") context_task = self.get_context_task(instance) if asset != context_asset: raise PublishValidationError( message=( "Instance '{}' publishes to different asset than current " "context: {}. Current context: {}".format( instance.name, asset, context_asset ) ), description=( "## Publishing to a different asset\n" "There are publish instances present which are publishing " "into a different asset than your current context.\n\n" "Usually this is not what you want but there can be cases " "where you might want to publish into another asset or " "shot. If that's the case you can disable the validation " "on the instance to ignore it." ) ) if task != context_task: raise PublishValidationError( message=( "Instance '{}' publishes to different task than current " "context: {}. Current context: {}".format( instance.name, task, context_task ) ), description=( "## Publishing to a different asset\n" "There are publish instances present which are publishing " "into a different asset than your current context.\n\n" "Usually this is not what you want but there can be cases " "where you might want to publish into another asset or " "shot. If that's the case you can disable the validation " "on the instance to ignore it." ) ) @classmethod def get_invalid(cls, instance): asset_name_attr = "folderPath" if AYON_SERVER_ENABLED else "asset" node = rt.getNodeByName(instance.data["instance_node"]) asset = rt.getUserProp(node, asset_name_attr) context_asset = cls.get_context_asset(instance) if asset != context_asset: return instance.data["instance_node"] @classmethod def repair(cls, instance): context_asset = cls.get_context_asset(instance) context_task = cls.get_context_task(instance) instance_node = rt.getNodeByName(instance.data.get( "instance_node", "")) if not instance_node: return asset_name_attr = "folderPath" if AYON_SERVER_ENABLED else "asset" rt.SetUserProp(instance_node, asset_name_attr, context_asset) rt.SetUserProp(instance_node, "task", context_task) @staticmethod def get_context_asset(instance): return instance.context.data["asset"] @staticmethod def get_context_task(instance): return instance.context.data["task"] ================================================ FILE: openpype/hosts/max/plugins/publish/validate_loaded_plugin.py ================================================ # -*- coding: utf-8 -*- """Validator for Loaded Plugin.""" import os import pyblish.api from pymxs import runtime as rt from openpype.pipeline.publish import ( RepairAction, OptionalPyblishPluginMixin, PublishValidationError ) from openpype.hosts.max.api.lib import get_plugins class ValidateLoadedPlugin(OptionalPyblishPluginMixin, pyblish.api.InstancePlugin): """Validates if the specific plugin is loaded in 3ds max. Studio Admin(s) can add the plugins they want to check in validation via studio defined project settings """ order = pyblish.api.ValidatorOrder hosts = ["max"] label = "Validate Loaded Plugins" optional = True actions = [RepairAction] family_plugins_mapping = {} @classmethod def get_invalid(cls, instance): """Plugin entry point.""" family_plugins_mapping = cls.family_plugins_mapping if not family_plugins_mapping: return invalid = [] # Find all plug-in requirements for current instance instance_families = {instance.data["family"]} instance_families.update(instance.data.get("families", [])) cls.log.debug("Checking plug-in validation " f"for instance families: {instance_families}") all_required_plugins = set() for mapping in family_plugins_mapping: # Check for matching families if not mapping: return match_families = {fam.strip() for fam in mapping["families"]} has_match = "*" in match_families or match_families.intersection( instance_families) if not has_match: continue cls.log.debug( f"Found plug-in family requirements: {match_families}") required_plugins = [ # match lowercase and format with os.environ to allow # plugin names defined by max version, e.g. {3DSMAX_VERSION} plugin.format(**os.environ).lower() for plugin in mapping["plugins"] # ignore empty fields in settings if plugin.strip() ] all_required_plugins.update(required_plugins) if not all_required_plugins: # Instance has no plug-in requirements return # get all DLL loaded plugins in Max and their plugin index available_plugins = { plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } # validate the required plug-ins for plugin in sorted(all_required_plugins): plugin_index = available_plugins.get(plugin) if plugin_index is None: debug_msg = ( f"Plugin {plugin} does not exist" " in 3dsMax Plugin List." ) invalid.append((plugin, debug_msg)) continue if not rt.pluginManager.isPluginDllLoaded(plugin_index): debug_msg = f"Plugin {plugin} not loaded." invalid.append((plugin, debug_msg)) return invalid def process(self, instance): if not self.is_active(instance.data): self.log.debug("Skipping Validate Loaded Plugin...") return invalid = self.get_invalid(instance) if invalid: bullet_point_invalid_statement = "\n".join( "- {}".format(message) for _, message in invalid ) report = ( "Required plugins are not loaded.\n\n" f"{bullet_point_invalid_statement}\n\n" "You can use repair action to load the plugin." ) raise PublishValidationError( report, title="Missing Required Plugins") @classmethod def repair(cls, instance): # get all DLL loaded plugins in Max and their plugin index invalid = cls.get_invalid(instance) if not invalid: return # get all DLL loaded plugins in Max and their plugin index available_plugins = { plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } for invalid_plugin, _ in invalid: plugin_index = available_plugins.get(invalid_plugin) if plugin_index is None: cls.log.warning( f"Can't enable missing plugin: {invalid_plugin}") continue if not rt.pluginManager.isPluginDllLoaded(plugin_index): rt.pluginManager.loadPluginDll(plugin_index) ================================================ FILE: openpype/hosts/max/plugins/publish/validate_model_contents.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from pymxs import runtime as rt from openpype.pipeline import PublishValidationError class ValidateModelContent(pyblish.api.InstancePlugin): """Validates Model instance contents. A model instance may only hold either geometry-related object(excluding Shapes) or editable meshes. """ order = pyblish.api.ValidatorOrder families = ["model"] hosts = ["max"] label = "Model Contents" def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError(("Model instance must only include" "Geometry and Editable Mesh. " f"Invalid types on: {invalid}")) def get_invalid(self, instance): """ Get invalid nodes if the instance is not camera """ invalid = [] container = instance.data["instance_node"] self.log.info(f"Validating model content for {container}") selection_list = instance.data["members"] for sel in selection_list: if rt.ClassOf(sel) in rt.Camera.classes: invalid.append(sel) if rt.ClassOf(sel) in rt.Light.classes: invalid.append(sel) if rt.ClassOf(sel) in rt.Shape.classes: invalid.append(sel) return invalid ================================================ FILE: openpype/hosts/max/plugins/publish/validate_pointcloud.py ================================================ import pyblish.api from openpype.pipeline import PublishValidationError from pymxs import runtime as rt class ValidatePointCloud(pyblish.api.InstancePlugin): """Validate that work file was saved.""" order = pyblish.api.ValidatorOrder families = ["pointcloud"] hosts = ["max"] label = "Validate Point Cloud" def process(self, instance): """ Notes: 1. Validate if the export mode of Export Particle is at PRT format 2. Validate the partition count and range set as default value Partition Count : 100 Partition Range : 1 to 1 3. Validate if the custom attribute(s) exist as parameter(s) of export_particle operator """ report = [] if self.validate_export_mode(instance): report.append("The export mode is not at PRT") if self.validate_partition_value(instance): report.append(("tyFlow Partition setting is " "not at the default value")) invalid_attribute = self.validate_custom_attribute(instance) if invalid_attribute: report.append(("Custom Attribute not found " f":{invalid_attribute}")) if report: raise PublishValidationError(f"{report}") def validate_custom_attribute(self, instance): invalid = [] container = instance.data["instance_node"] self.log.info( f"Validating tyFlow custom attributes for {container}") selection_list = instance.data["members"] project_settings = instance.context.data["project_settings"] attr_settings = project_settings["max"]["PointCloud"]["attribute"] for sel in selection_list: obj = sel.baseobject anim_names = rt.GetSubAnimNames(obj) for anim_name in anim_names: # get all the names of the related tyFlow nodes sub_anim = rt.GetSubAnim(obj, anim_name) if rt.IsProperty(sub_anim, "Export_Particles"): event_name = sub_anim.name opt = "${0}.{1}.export_particles".format(sel.name, event_name) for key, value in attr_settings.items(): custom_attr = "{0}.PRTChannels_{1}".format(opt, value) try: rt.Execute(custom_attr) except RuntimeError: invalid.append(key) return invalid def validate_partition_value(self, instance): invalid = [] container = instance.data["instance_node"] self.log.info( f"Validating tyFlow partition value for {container}") selection_list = instance.data["members"] for sel in selection_list: obj = sel.baseobject anim_names = rt.GetSubAnimNames(obj) for anim_name in anim_names: # get all the names of the related tyFlow nodes sub_anim = rt.GetSubAnim(obj, anim_name) if rt.IsProperty(sub_anim, "Export_Particles"): event_name = sub_anim.name opt = "${0}.{1}.export_particles".format(sel.name, event_name) count = rt.Execute(f'{opt}.PRTPartitionsCount') if count != 100: invalid.append(count) start = rt.Execute(f'{opt}.PRTPartitionsFrom') if start != 1: invalid.append(start) end = rt.Execute(f'{opt}.PRTPartitionsTo') if end != 1: invalid.append(end) return invalid def validate_export_mode(self, instance): invalid = [] container = instance.data["instance_node"] self.log.info( f"Validating tyFlow export mode for {container}") con = rt.GetNodeByName(container) selection_list = list(con.Children) for sel in selection_list: obj = sel.baseobject anim_names = rt.GetSubAnimNames(obj) for anim_name in anim_names: # get all the names of the related tyFlow nodes sub_anim = rt.GetSubAnim(obj, anim_name) # check if there is export particle operator boolean = rt.IsProperty(sub_anim, "Export_Particles") event_name = sub_anim.name if boolean: opt = f"${sel.name}.{event_name}.export_particles" export_mode = rt.Execute(f'{opt}.exportMode') if export_mode != 1: invalid.append(export_mode) return invalid ================================================ FILE: openpype/hosts/max/plugins/publish/validate_renderable_camera.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import ( PublishValidationError, OptionalPyblishPluginMixin) from openpype.pipeline.publish import RepairAction from openpype.hosts.max.api.lib import get_current_renderer from pymxs import runtime as rt class ValidateRenderableCamera(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates Renderable Camera Check if the renderable camera used for rendering """ order = pyblish.api.ValidatorOrder families = ["maxrender"] hosts = ["max"] label = "Renderable Camera" optional = True actions = [RepairAction] def process(self, instance): if not self.is_active(instance.data): return if not instance.data["cameras"]: raise PublishValidationError( "No renderable Camera found in scene." ) @classmethod def repair(cls, instance): rt.viewport.setType(rt.Name("view_camera")) camera = rt.viewport.GetCamera() cls.log.info(f"Camera {camera} set as renderable camera") renderer_class = get_current_renderer() renderer = str(renderer_class).split(":")[0] if renderer == "Arnold": arv = rt.MAXToAOps.ArnoldRenderView() arv.setOption("Camera", str(camera)) arv.close() instance.data["cameras"] = [camera.name] ================================================ FILE: openpype/hosts/max/plugins/publish/validate_renderer_redshift_proxy.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError from pymxs import runtime as rt from openpype.pipeline.publish import RepairAction from openpype.hosts.max.api.lib import get_current_renderer class ValidateRendererRedshiftProxy(pyblish.api.InstancePlugin): """ Validates Redshift as the current renderer for creating Redshift Proxy """ order = pyblish.api.ValidatorOrder families = ["redshiftproxy"] hosts = ["max"] label = "Redshift Renderer" actions = [RepairAction] def process(self, instance): invalid = self.get_redshift_renderer(instance) if invalid: raise PublishValidationError("Please install Redshift for 3dsMax" " before using the Redshift proxy instance") # noqa invalid = self.get_current_renderer(instance) if invalid: raise PublishValidationError("The Redshift proxy extraction" "discontinued since the current renderer is not Redshift") # noqa def get_redshift_renderer(self, instance): invalid = list() max_renderers_list = str(rt.RendererClass.classes) if "Redshift_Renderer" not in max_renderers_list: invalid.append(max_renderers_list) return invalid def get_current_renderer(self, instance): invalid = list() renderer_class = get_current_renderer() current_renderer = str(renderer_class).split(":")[0] if current_renderer != "Redshift_Renderer": invalid.append(current_renderer) return invalid @classmethod def repair(cls, instance): for Renderer in rt.RendererClass.classes: renderer = Renderer() if "Redshift_Renderer" in str(renderer): rt.renderers.production = renderer break ================================================ FILE: openpype/hosts/max/plugins/publish/validate_renderpasses.py ================================================ import os import pyblish.api from pymxs import runtime as rt from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) from openpype.hosts.max.api.lib_rendersettings import RenderSettings class ValidateRenderPasses(OptionalPyblishPluginMixin, pyblish.api.InstancePlugin): """Validates Render Passes before Deadline Submission """ order = ValidateContentsOrder families = ["maxrender"] hosts = ["max"] label = "Validate Render Passes" optional = True actions = [RepairAction] def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: bullet_point_invalid_statement = "\n".join( f"- {err_type}: {filepath}" for err_type, filepath in invalid ) report = ( "Invalid render passes found.\n\n" f"{bullet_point_invalid_statement}\n\n" "You can use repair action to fix the invalid filepath." ) raise PublishValidationError( report, title="Invalid Render Passes") @classmethod def get_invalid(cls, instance): """Function to get invalid beauty render outputs and render elements. 1. Check Render Output Folder matches the name of the current Max Scene, e.g. The name of the current Max scene: John_Doe.max The expected render output directory: {root[work]}/{project[name]}/{hierarchy}/{asset}/ work/{task[name]}/render/3dsmax/John_Doe/ 2. Check image extension(s) of the render output(s) matches the image format in OP/AYON setting, e.g. The current image format in settings: png The expected render outputs: John_Doe.png 3. Check filename of render element ends with the name of render element from the 3dsMax Render Element Manager. e.g. The name of render element: RsCryptomatte The expected filename: {InstanceName}_RsCryptomatte.png Args: instance (pyblish.api.Instance): instance filename (str): filename of the Max scene Returns: list: list of invalid filename which doesn't match with the project name """ invalid = [] file = rt.maxFileName filename, ext = os.path.splitext(file) if filename not in rt.rendOutputFilename: cls.log.error( "Render output folder must include " f" the max scene name {filename} " ) invalid_folder_name = os.path.dirname( rt.rendOutputFilename).replace( "\\", "/").split("/")[-1] invalid.append(("Invalid Render Output Folder", invalid_folder_name)) beauty_fname = os.path.basename(rt.rendOutputFilename) beauty_name, ext = os.path.splitext(beauty_fname) invalid_filenames = cls.get_invalid_filenames( instance, beauty_name) invalid.extend(invalid_filenames) invalid_image_format = cls.get_invalid_image_format( instance, ext.lstrip(".")) invalid.extend(invalid_image_format) renderer = instance.data["renderer"] if renderer in [ "ART_Renderer", "Redshift_Renderer", "V_Ray_6_Hotfix_3", "V_Ray_GPU_6_Hotfix_3", "Default_Scanline_Renderer", "Quicksilver_Hardware_Renderer", ]: render_elem = rt.maxOps.GetCurRenderElementMgr() render_elem_num = render_elem.NumRenderElements() for i in range(render_elem_num): renderlayer_name = render_elem.GetRenderElement(i) renderpass = str(renderlayer_name).split(":")[-1] rend_file = render_elem.GetRenderElementFilename(i) if not rend_file: cls.log.error( f"No filepath for render element {renderpass}") invalid.append((f"Invalid {renderpass}", "No filepath")) rend_fname, ext = os.path.splitext( os.path.basename(rend_file)) invalid_filenames = cls.get_invalid_filenames( instance, rend_fname, renderpass=renderpass) invalid.extend(invalid_filenames) invalid_image_format = cls.get_invalid_image_format( instance, ext) invalid.extend(invalid_image_format) elif renderer == "Arnold": cls.log.debug( "Renderpass validation does not support Arnold yet," " validation skipped...") return invalid @classmethod def get_invalid_filenames(cls, instance, file_name, renderpass=None): """Function to get invalid filenames from render outputs. Args: instance (pyblish.api.Instance): instance file_name (str): name of the file renderpass (str, optional): name of the renderpass. Defaults to None. Returns: list: invalid filenames """ invalid = [] if instance.name not in file_name: cls.log.error("The renderpass should have instance name inside.") invalid.append((f"Invalid instance name", file_name)) if renderpass is not None: if not file_name.rstrip(".").endswith(renderpass): cls.log.error( f"Filename for {renderpass} should " f"end with {renderpass}" ) invalid.append((f"Invalid {renderpass}", os.path.basename(file_name))) return invalid @classmethod def get_invalid_image_format(cls, instance, ext): """Function to check if the image format of the render outputs aligns with that in the setting. Args: instance (pyblish.api.Instance): instance ext (str): image extension Returns: list: list of files with invalid image format """ invalid = [] settings = instance.context.data["project_settings"].get("max") image_format = settings["RenderSettings"]["image_format"] ext = ext.lstrip(".") if ext != image_format: msg = ( f"Invalid image format {ext} for render outputs.\n" f"Should be: {image_format}") cls.log.error(msg) invalid.append((msg, ext)) return invalid @classmethod def repair(cls, instance): container = instance.data.get("instance_node") # TODO: need to rename the function of render_output RenderSettings().render_output(container) cls.log.debug("Finished repairing the render output " "folder and filenames.") ================================================ FILE: openpype/hosts/max/plugins/publish/validate_resolution_setting.py ================================================ import pyblish.api from pymxs import runtime as rt from openpype.pipeline import ( OptionalPyblishPluginMixin ) from openpype.pipeline.publish import ( RepairAction, PublishValidationError ) from openpype.hosts.max.api.lib import reset_scene_resolution class ValidateResolutionSetting(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate the resolution setting aligned with DB""" order = pyblish.api.ValidatorOrder - 0.01 families = ["maxrender"] hosts = ["max"] label = "Validate Resolution Setting" optional = True actions = [RepairAction] def process(self, instance): if not self.is_active(instance.data): return width, height = self.get_db_resolution(instance) current_width = rt.renderWidth current_height = rt.renderHeight if current_width != width and current_height != height: raise PublishValidationError("Resolution Setting " "not matching resolution " "set on asset or shot.") if current_width != width: raise PublishValidationError("Width in Resolution Setting " "not matching resolution set " "on asset or shot.") if current_height != height: raise PublishValidationError("Height in Resolution Setting " "not matching resolution set " "on asset or shot.") def get_db_resolution(self, instance): asset_doc = instance.data["assetEntity"] project_doc = instance.context.data["projectEntity"] for data in [asset_doc["data"], project_doc["data"]]: if "resolutionWidth" in data and "resolutionHeight" in data: width = data["resolutionWidth"] height = data["resolutionHeight"] return int(width), int(height) # Defaults if not found in asset document or project document return 1920, 1080 @classmethod def repair(cls, instance): reset_scene_resolution() ================================================ FILE: openpype/hosts/max/plugins/publish/validate_scene_saved.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError from pymxs import runtime as rt class ValidateSceneSaved(pyblish.api.InstancePlugin): """Validate that workfile was saved.""" order = pyblish.api.ValidatorOrder families = ["workfile"] hosts = ["max"] label = "Validate Workfile is saved" def process(self, instance): if not rt.maxFilePath or not rt.maxFileName: raise PublishValidationError( "Workfile is not saved", title=self.label) ================================================ FILE: openpype/hosts/max/plugins/publish/validate_tyflow_data.py ================================================ import pyblish.api from openpype.pipeline import PublishValidationError from pymxs import runtime as rt class ValidateTyFlowData(pyblish.api.InstancePlugin): """Validate TyFlow plugins or relevant operators are set correctly.""" order = pyblish.api.ValidatorOrder families = ["pointcloud", "tycache"] hosts = ["max"] label = "TyFlow Data" def process(self, instance): """ Notes: 1. Validate the container only include tyFlow objects 2. Validate if tyFlow operator Export Particle exists """ invalid_object = self.get_tyflow_object(instance) if invalid_object: self.log.error(f"Non tyFlow object found: {invalid_object}") invalid_operator = self.get_tyflow_operator(instance) if invalid_operator: self.log.error( "Operator 'Export Particles' not found in tyFlow editor.") if invalid_object or invalid_operator: raise PublishValidationError( "issues occurred", description="Container should only include tyFlow object " "and tyflow operator 'Export Particle' should be in " "the tyFlow editor.") def get_tyflow_object(self, instance): """Get the nodes which are not tyFlow object(s) and editable mesh(es) Args: instance (pyblish.api.Instance): instance Returns: list: invalid nodes which are not tyFlow object(s) and editable mesh(es). """ container = instance.data["instance_node"] self.log.debug(f"Validating tyFlow container for {container}") allowed_classes = [rt.tyFlow, rt.Editable_Mesh] return [ member for member in instance.data["members"] if rt.ClassOf(member) not in allowed_classes ] def get_tyflow_operator(self, instance): """Check if the Export Particle Operators in the node connections. Args: instance (str): instance node Returns: invalid(list): list of invalid nodes which do not consist of Export Particle Operators as parts of the node connections """ invalid = [] members = instance.data["members"] for member in members: obj = member.baseobject # There must be at least one animation with export # particles enabled has_export_particles = False anim_names = rt.GetSubAnimNames(obj) for anim_name in anim_names: # get name of the related tyFlow node sub_anim = rt.GetSubAnim(obj, anim_name) # check if there is export particle operator if rt.IsProperty(sub_anim, "Export_Particles"): has_export_particles = True break if not has_export_particles: invalid.append(member) return invalid ================================================ FILE: openpype/hosts/max/startup/startup.ms ================================================ -- OpenPype Init Script ( local sysPath = dotNetClass "System.IO.Path" local sysDir = dotNetClass "System.IO.Directory" local localScript = getThisScriptFilename() local startup = sysPath.Combine (sysPath.GetDirectoryName localScript) "startup.py" local pythonpath = systemTools.getEnvVariable "MAX_PYTHONPATH" systemTools.setEnvVariable "PYTHONPATH" pythonpath /*opens the create menu on startup to ensure users are presented with a useful default view.*/ max create mode python.ExecuteFile startup ) ================================================ FILE: openpype/hosts/max/startup/startup.py ================================================ # -*- coding: utf-8 -*- import os import sys # this might happen in some 3dsmax version where PYTHONPATH isn't added # to sys.path automatically for path in os.environ["PYTHONPATH"].split(os.pathsep): if path and path not in sys.path: sys.path.append(path) from openpype.hosts.max.api import MaxHost from openpype.pipeline import install_host host = MaxHost() install_host(host) ================================================ FILE: openpype/hosts/maya/__init__.py ================================================ from .addon import ( MayaAddon, MAYA_ROOT_DIR, ) __all__ = ( "MayaAddon", "MAYA_ROOT_DIR", ) ================================================ FILE: openpype/hosts/maya/addon.py ================================================ import os from openpype.modules import OpenPypeModule, IHostAddon MAYA_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) class MayaAddon(OpenPypeModule, IHostAddon): name = "maya" host_name = "maya" def initialize(self, module_settings): self.enabled = True def add_implementation_envs(self, env, _app): # Add requirements to PYTHONPATH new_python_paths = [ os.path.join(MAYA_ROOT_DIR, "startup") ] old_python_path = env.get("PYTHONPATH") or "" for path in old_python_path.split(os.pathsep): if not path: continue norm_path = os.path.normpath(path) if norm_path not in new_python_paths: new_python_paths.append(norm_path) env["PYTHONPATH"] = os.pathsep.join(new_python_paths) # Set default environments envs = { "OPENPYPE_LOG_NO_COLORS": "Yes", # For python module 'qtpy' "QT_API": "PySide2", # For python module 'Qt' "QT_PREFERRED_BINDING": "PySide2" } for key, value in envs.items(): env[key] = value def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] return [ os.path.join(MAYA_ROOT_DIR, "hooks") ] def get_workfile_extensions(self): return [".ma", ".mb"] ================================================ FILE: openpype/hosts/maya/api/__init__.py ================================================ """Public API Anything that isn't defined here is INTERNAL and unreliable for external use. """ from .pipeline import ( uninstall, ls, containerise, MayaHost, ) from .plugin import ( Creator, Loader ) from .workio import ( open_file, save_file, current_file, has_unsaved_changes, file_extensions, work_root ) from .lib import ( lsattr, lsattrs, read, apply_shaders, maintained_selection, suspended_refresh, unique_namespace, ) __all__ = [ "uninstall", "ls", "containerise", "MayaHost", "Creator", "Loader", # Workfiles API "open_file", "save_file", "current_file", "has_unsaved_changes", "file_extensions", "work_root", # Utility functions "lsattr", "lsattrs", "read", "unique_namespace", "apply_shaders", "maintained_selection", "suspended_refresh", ] # Backwards API compatibility open = open_file save = save_file ================================================ FILE: openpype/hosts/maya/api/action.py ================================================ # absolute_import is needed to counter the `module has no cmds error` in Maya from __future__ import absolute_import import pyblish.api from openpype.client import get_asset_by_name from openpype.pipeline.publish import get_errored_instances_from_context class GenerateUUIDsOnInvalidAction(pyblish.api.Action): """Generate UUIDs on the invalid nodes in the instance. Invalid nodes are those returned by the plugin's `get_invalid` method. As such it is the plug-in's responsibility to ensure the nodes that receive new UUIDs are actually invalid. Requires: - instance.data["asset"] """ label = "Regenerate UUIDs" on = "failed" # This action is only available on a failed plug-in icon = "wrench" # Icon from Awesome Icon def process(self, context, plugin): from maya import cmds self.log.info("Finding bad nodes..") errored_instances = get_errored_instances_from_context(context) # Apply pyblish logic to get the instances for the plug-in instances = pyblish.api.instances_by_plugin(errored_instances, plugin) # Get the nodes from the all instances that ran through this plug-in all_invalid = [] for instance in instances: invalid = plugin.get_invalid(instance) # Don't allow referenced nodes to get their ids regenerated to # avoid loaded content getting messed up with reference edits if invalid: referenced = {node for node in invalid if cmds.referenceQuery(node, isNodeReferenced=True)} if referenced: self.log.warning("Skipping UUID generation on referenced " "nodes: {}".format(list(referenced))) invalid = [node for node in invalid if node not in referenced] if invalid: self.log.info("Fixing instance {}".format(instance.name)) self._update_id_attribute(instance, invalid) all_invalid.extend(invalid) if not all_invalid: self.log.info("No invalid nodes found.") return all_invalid = list(set(all_invalid)) self.log.info("Generated ids on nodes: {0}".format(all_invalid)) def _update_id_attribute(self, instance, nodes): """Delete the id attribute Args: instance: The instance we're fixing for nodes (list): all nodes to regenerate ids on """ from . import lib # Expecting this is called on validators in which case 'assetEntity' # should be always available, but kept a way to query it by name. asset_doc = instance.data.get("assetEntity") if not asset_doc: asset_name = instance.data["asset"] project_name = instance.context.data["projectName"] self.log.info(( "Asset is not stored on instance." " Querying by name \"{}\" from project \"{}\"" ).format(asset_name, project_name)) asset_doc = get_asset_by_name( project_name, asset_name, fields=["_id"] ) for node, _id in lib.generate_ids(nodes, asset_id=asset_doc["_id"]): lib.set_id(node, _id, overwrite=True) class SelectInvalidAction(pyblish.api.Action): """Select invalid nodes in Maya when plug-in failed. To retrieve the invalid nodes this assumes a static `get_invalid()` method is available on the plugin. """ label = "Select invalid" on = "failed" # This action is only available on a failed plug-in icon = "search" # Icon from Awesome Icon def process(self, context, plugin): try: from maya import cmds except ImportError: raise ImportError("Current host is not Maya") errored_instances = get_errored_instances_from_context(context, plugin=plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes..") invalid = list() for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: if isinstance(invalid_nodes, (list, tuple)): invalid.extend(invalid_nodes) else: self.log.warning("Plug-in returned to be invalid, " "but has no selectable nodes.") # Ensure unique (process each node only once) invalid = list(set(invalid)) if invalid: self.log.info("Selecting invalid nodes: %s" % ", ".join(invalid)) cmds.select(invalid, replace=True, noExpand=True) else: self.log.info("No invalid nodes found.") cmds.select(deselect=True) ================================================ FILE: openpype/hosts/maya/api/alembic.py ================================================ import json import logging import os from maya import cmds # noqa from openpype.hosts.maya.api.lib import evaluation log = logging.getLogger(__name__) # The maya alembic export types ALEMBIC_ARGS = { "attr": (list, tuple), "attrPrefix": (list, tuple), "autoSubd": bool, "dataFormat": str, "dontSkipUnwrittenFrames": bool, "endFrame": float, "eulerFilter": bool, "frameRange": str, # "start end"; overrides startFrame & endFrame "frameRelativeSample": float, "melPerFrameCallback": str, "melPostJobCallback": str, "noNormals": bool, "preRoll": bool, "preRollStartFrame": int, "pythonPerFrameCallback": str, "pythonPostJobCallback": str, "renderableOnly": bool, "root": (list, tuple), "selection": bool, "startFrame": float, "step": float, "stripNamespaces": bool, "userAttr": (list, tuple), "userAttrPrefix": (list, tuple), "uvWrite": bool, "uvsOnly": bool, "verbose": bool, "wholeFrameGeo": bool, "worldSpace": bool, "writeColorSets": bool, "writeCreases": bool, # Maya 2015 Ext1+ "writeFaceSets": bool, "writeUVSets": bool, # Maya 2017+ "writeVisibility": bool, } def extract_alembic( file, attr=None, attrPrefix=None, dataFormat="ogawa", endFrame=None, eulerFilter=True, frameRange="", noNormals=False, preRoll=False, preRollStartFrame=0, renderableOnly=False, root=None, selection=True, startFrame=None, step=1.0, stripNamespaces=True, uvWrite=True, verbose=False, wholeFrameGeo=False, worldSpace=False, writeColorSets=False, writeCreases=False, writeNormals=False, writeFaceSets=False, writeUVSets=False, writeVisibility=False ): """Extract a single Alembic Cache. This extracts an Alembic cache using the `-selection` flag to minimize the extracted content to solely what was Collected into the instance. Arguments: startFrame (float): Start frame of output. Ignored if `frameRange` provided. endFrame (float): End frame of output. Ignored if `frameRange` provided. frameRange (tuple or str): Two-tuple with start and end frame or a string formatted as: "startFrame endFrame". This argument overrides `startFrame` and `endFrame` arguments. eulerFilter (bool): When on, X, Y, and Z rotation data is filtered with an Euler filter. Euler filtering helps resolve irregularities in rotations especially if X, Y, and Z rotations exceed 360 degrees. Defaults to True. noNormals (bool): When on, normal data from the original polygon objects is not included in the exported Alembic cache file. preRoll (bool): This frame range will not be sampled. Defaults to False. renderableOnly (bool): When on, any non-renderable nodes or hierarchy, such as hidden objects, are not included in the Alembic file. Defaults to False. selection (bool): Write out all all selected nodes from the active selection list that are descendents of the roots specified with -root. Defaults to False. uvWrite (bool): When on, UV data from polygon meshes and subdivision objects are written to the Alembic file. Only the current UV map is included. writeColorSets (bool): Write all color sets on MFnMeshes as color 3 or color 4 indexed geometry parameters with face varying scope. Defaults to False. writeFaceSets (bool): Write all Face sets on MFnMeshes. Defaults to False. wholeFrameGeo (bool): Data for geometry will only be written out on whole frames. Defaults to False. worldSpace (bool): When on, the top node in the node hierarchy is stored as world space. By default, these nodes are stored as local space. Defaults to False. writeVisibility (bool): Visibility state will be stored in the Alembic file. Otherwise everything written out is treated as visible. Defaults to False. writeUVSets (bool): Write all uv sets on MFnMeshes as vector 2 indexed geometry parameters with face varying scope. Defaults to False. writeCreases (bool): If the mesh has crease edges or crease vertices, the mesh (OPolyMesh) would now be written out as an OSubD and crease info will be stored in the Alembic file. Otherwise, creases info won't be preserved in Alembic file unless a custom Boolean attribute SubDivisionMesh has been added to mesh node and its value is true. Defaults to False. dataFormat (str): The data format to use for the cache, defaults to "ogawa" step (float): The time interval (expressed in frames) at which the frame range is sampled. Additional samples around each frame can be specified with -frs. Defaults to 1.0. attr (list of str, optional): A specific geometric attribute to write out. Defaults to []. attrPrefix (list of str, optional): Prefix filter for determining which geometric attributes to write out. Defaults to ["ABC_"]. root (list of str): Maya dag path which will be parented to the root of the Alembic file. Defaults to [], which means the entire scene will be written out. stripNamespaces (bool): When on, any namespaces associated with the exported objects are removed from the Alembic file. For example, an object with the namespace taco:foo:bar appears as bar in the Alembic file. verbose (bool): When on, outputs frame number information to the Script Editor or output window during extraction. preRollStartFrame (float): The frame to start scene evaluation at. This is used to set the starting frame for time dependent translations and can be used to evaluate run-up that isn't actually translated. Defaults to 0. """ # Ensure alembic exporter is loaded cmds.loadPlugin('AbcExport', quiet=True) # Alembic Exporter requires forward slashes file = file.replace('\\', '/') # Ensure list arguments are valid. attr = attr or [] attrPrefix = attrPrefix or [] root = root or [] # Pass the start and end frame on as `frameRange` so that it # never conflicts with that argument if not frameRange: # Fallback to maya timeline if no start or end frame provided. if startFrame is None: startFrame = cmds.playbackOptions(query=True, animationStartTime=True) if endFrame is None: endFrame = cmds.playbackOptions(query=True, animationEndTime=True) # Ensure valid types are converted to frame range assert isinstance(startFrame, ALEMBIC_ARGS["startFrame"]) assert isinstance(endFrame, ALEMBIC_ARGS["endFrame"]) frameRange = "{0} {1}".format(startFrame, endFrame) else: # Allow conversion from tuple for `frameRange` if isinstance(frameRange, (list, tuple)): assert len(frameRange) == 2 frameRange = "{0} {1}".format(frameRange[0], frameRange[1]) # Assemble options options = { "selection": selection, "frameRange": frameRange, "eulerFilter": eulerFilter, "noNormals": noNormals, "preRoll": preRoll, "renderableOnly": renderableOnly, "uvWrite": uvWrite, "writeColorSets": writeColorSets, "writeFaceSets": writeFaceSets, "wholeFrameGeo": wholeFrameGeo, "worldSpace": worldSpace, "writeVisibility": writeVisibility, "writeUVSets": writeUVSets, "writeCreases": writeCreases, "dataFormat": dataFormat, "step": step, "attr": attr, "attrPrefix": attrPrefix, "stripNamespaces": stripNamespaces, "verbose": verbose, "preRollStartFrame": preRollStartFrame } # Validate options for key, value in options.copy().items(): # Discard unknown options if key not in ALEMBIC_ARGS: log.warning("extract_alembic() does not support option '%s'. " "Flag will be ignored..", key) options.pop(key) continue # Validate value type valid_types = ALEMBIC_ARGS[key] if not isinstance(value, valid_types): raise TypeError("Alembic option unsupported type: " "{0} (expected {1})".format(value, valid_types)) # Ignore empty values, like an empty string, since they mess up how # job arguments are built if isinstance(value, (list, tuple)): value = [x for x in value if x.strip()] # Ignore option completely if no values remaining if not value: options.pop(key) continue options[key] = value # The `writeCreases` argument was changed to `autoSubd` in Maya 2018+ maya_version = int(cmds.about(version=True)) if maya_version >= 2018: options['autoSubd'] = options.pop('writeCreases', False) # Format the job string from options job_args = list() for key, value in options.items(): if isinstance(value, (list, tuple)): for entry in value: job_args.append("-{} {}".format(key, entry)) elif isinstance(value, bool): # Add only when state is set to True if value: job_args.append("-{0}".format(key)) else: job_args.append("-{0} {1}".format(key, value)) job_str = " ".join(job_args) job_str += ' -file "%s"' % file # Ensure output directory exists parent_dir = os.path.dirname(file) if not os.path.exists(parent_dir): os.makedirs(parent_dir) if verbose: log.debug("Preparing Alembic export with options: %s", json.dumps(options, indent=4)) log.debug("Extracting Alembic with job arguments: %s", job_str) # Perform extraction print("Alembic Job Arguments : {}".format(job_str)) # Disable the parallel evaluation temporarily to ensure no buggy # exports are made. (PLN-31) # TODO: Make sure this actually fixes the issues with evaluation("off"): cmds.AbcExport(j=job_str, verbose=verbose) if verbose: log.debug("Extracted Alembic to: %s", file) return file ================================================ FILE: openpype/hosts/maya/api/commands.py ================================================ # -*- coding: utf-8 -*- """OpenPype script commands to be used directly in Maya.""" from maya import cmds from openpype.client import get_asset_by_name, get_project from openpype.pipeline import get_current_project_name, get_current_asset_name class ToolWindows: _windows = {} @classmethod def get_window(cls, tool): """Get widget for specific tool. Args: tool (str): Name of the tool. Returns: Stored widget. """ try: return cls._windows[tool] except KeyError: return None @classmethod def set_window(cls, tool, window): """Set widget for the tool. Args: tool (str): Name of the tool. window (QtWidgets.QWidget): Widget """ cls._windows[tool] = window def edit_shader_definitions(): from qtpy import QtWidgets from openpype.hosts.maya.api.shader_definition_editor import ( ShaderDefinitionsEditor ) from openpype.tools.utils import qt_app_context top_level_widgets = QtWidgets.QApplication.topLevelWidgets() main_window = next(widget for widget in top_level_widgets if widget.objectName() == "MayaWindow") with qt_app_context(): window = ToolWindows.get_window("shader_definition_editor") if not window: window = ShaderDefinitionsEditor(parent=main_window) ToolWindows.set_window("shader_definition_editor", window) window.show() def _resolution_from_document(doc): if not doc or "data" not in doc: print("Entered document is not valid. \"{}\"".format(str(doc))) return None resolution_width = doc["data"].get("resolutionWidth") resolution_height = doc["data"].get("resolutionHeight") # Backwards compatibility if resolution_width is None or resolution_height is None: resolution_width = doc["data"].get("resolution_width") resolution_height = doc["data"].get("resolution_height") # Make sure both width and height are set if resolution_width is None or resolution_height is None: cmds.warning( "No resolution information found for \"{}\"".format(doc["name"]) ) return None return int(resolution_width), int(resolution_height) def reset_resolution(): # Default values resolution_width = 1920 resolution_height = 1080 # Get resolution from asset project_name = get_current_project_name() asset_name = get_current_asset_name() asset_doc = get_asset_by_name(project_name, asset_name) resolution = _resolution_from_document(asset_doc) # Try get resolution from project if resolution is None: # TODO go through visualParents print(( "Asset \"{}\" does not have set resolution." " Trying to get resolution from project" ).format(asset_name)) project_doc = get_project(project_name) resolution = _resolution_from_document(project_doc) if resolution is None: msg = "Using default resolution {}x{}" else: resolution_width, resolution_height = resolution msg = "Setting resolution to {}x{}" print(msg.format(resolution_width, resolution_height)) # set for different renderers # arnold, vray, redshift, renderman renderer = cmds.getAttr("defaultRenderGlobals.currentRenderer").lower() # handle various renderman names if renderer.startswith("renderman"): renderer = "renderman" # default attributes are usable for Arnold, Renderman and Redshift width_attr_name = "defaultResolution.width" height_attr_name = "defaultResolution.height" # Vray has its own way if renderer == "vray": width_attr_name = "vraySettings.width" height_attr_name = "vraySettings.height" cmds.setAttr(width_attr_name, resolution_width) cmds.setAttr(height_attr_name, resolution_height) ================================================ FILE: openpype/hosts/maya/api/customize.py ================================================ """A set of commands that install overrides to Maya's UI""" import os import logging from functools import partial import maya.cmds as cmds import maya.mel as mel from openpype import resources from openpype.tools.utils import host_tools from .lib import get_main_window from ..tools import show_look_assigner log = logging.getLogger(__name__) COMPONENT_MASK_ORIGINAL = {} def override_component_mask_commands(): """Override component mask ctrl+click behavior. This implements special behavior for Maya's component mask menu items where a ctrl+click will instantly make it an isolated behavior disabling all others. Tested in Maya 2016 and 2018 """ log.info("Installing override_component_mask_commands..") # Get all object mask buttons buttons = cmds.formLayout("objectMaskIcons", query=True, childArray=True) # Skip the triangle list item buttons = [btn for btn in buttons if btn != "objPickMenuLayout"] def on_changed_callback(raw_command, state): """New callback""" # If "control" is held force the toggled one to on and # toggle the others based on whether any of the buttons # was remaining active after the toggle, if not then # enable all if cmds.getModifiers() == 4: # = CTRL state = True active = [cmds.iconTextCheckBox(btn, query=True, value=True) for btn in buttons] if any(active): cmds.selectType(allObjects=False) else: cmds.selectType(allObjects=True) # Replace #1 with the current button state cmd = raw_command.replace(" #1", " {}".format(int(state))) mel.eval(cmd) for btn in buttons: # Store a reference to the original command so that if # we rerun this override command it doesn't recursively # try to implement the fix. (This also allows us to # "uninstall" the behavior later) if btn not in COMPONENT_MASK_ORIGINAL: original = cmds.iconTextCheckBox(btn, query=True, cc=True) COMPONENT_MASK_ORIGINAL[btn] = original # Assign the special callback original = COMPONENT_MASK_ORIGINAL[btn] new_fn = partial(on_changed_callback, original) cmds.iconTextCheckBox(btn, edit=True, cc=new_fn) def override_toolbox_ui(): """Add custom buttons in Toolbox as replacement for Maya web help icon.""" icons = resources.get_resource("icons") parent_widget = get_main_window() # Ensure the maya web icon on toolbox exists button_names = [ # Maya 2022.1+ with maya.cmds.iconTextStaticLabel "ToolBox|MainToolboxLayout|mayaHomeToolboxButton", # Older with maya.cmds.iconTextButton "ToolBox|MainToolboxLayout|mayaWebButton" ] for name in button_names: if cmds.control(name, query=True, exists=True): web_button = name break else: # Button does not exist log.warning("Can't find Maya Home/Web button to override toolbox ui..") return cmds.control(web_button, edit=True, visible=False) # real = 32, but 36 with padding - according to toolbox mel script icon_size = 36 parent = web_button.rsplit("|", 1)[0] # Ensure the parent is a formLayout if not cmds.objectTypeUI(parent) == "formLayout": return # Create our controls controls = [] controls.append( cmds.iconTextButton( "pype_toolbox_lookmanager", annotation="Look Manager", label="Look Manager", image=os.path.join(icons, "lookmanager.png"), command=show_look_assigner, width=icon_size, height=icon_size, parent=parent ) ) controls.append( cmds.iconTextButton( "pype_toolbox_workfiles", annotation="Work Files", label="Work Files", image=os.path.join(icons, "workfiles.png"), command=lambda: host_tools.show_workfiles( parent=parent_widget ), width=icon_size, height=icon_size, parent=parent ) ) controls.append( cmds.iconTextButton( "pype_toolbox_loader", annotation="Loader", label="Loader", image=os.path.join(icons, "loader.png"), command=lambda: host_tools.show_loader( parent=parent_widget, use_context=True ), width=icon_size, height=icon_size, parent=parent ) ) controls.append( cmds.iconTextButton( "pype_toolbox_manager", annotation="Inventory", label="Inventory", image=os.path.join(icons, "inventory.png"), command=lambda: host_tools.show_scene_inventory( parent=parent_widget ), width=icon_size, height=icon_size, parent=parent ) ) # Add the buttons on the bottom and stack # them above each other with side padding controls.reverse() for i, control in enumerate(controls): previous = controls[i - 1] if i > 0 else web_button cmds.formLayout(parent, edit=True, attachControl=[control, "bottom", 0, previous], attachForm=([control, "left", 1], [control, "right", 1])) ================================================ FILE: openpype/hosts/maya/api/exitstack.py ================================================ """Backwards compatible implementation of ExitStack for Python 2. ExitStack contextmanager was implemented with Python 3.3. As long as we supportPython 2 hosts we can use this backwards compatible implementation to support bothPython 2 and Python 3. Instead of using ExitStack from contextlib, use it from this module: >>> from openpype.hosts.maya.api.exitstack import ExitStack It will provide the appropriate ExitStack implementation for the current running Python version. """ # TODO: Remove the entire script once dropping Python 2 support. import contextlib if getattr(contextlib, "nested", None): from contextlib import ExitStack # noqa else: import sys from collections import deque class ExitStack(object): """Context manager for dynamic management of a stack of exit callbacks For example: with ExitStack() as stack: files = [stack.enter_context(open(fname)) for fname in filenames] # All opened files will automatically be closed at the end of # the with statement, even if attempts to open files later # in the list raise an exception """ def __init__(self): self._exit_callbacks = deque() def pop_all(self): """Preserve the context stack by transferring it to a new instance""" new_stack = type(self)() new_stack._exit_callbacks = self._exit_callbacks self._exit_callbacks = deque() return new_stack def _push_cm_exit(self, cm, cm_exit): """Helper to correctly register callbacks to __exit__ methods""" def _exit_wrapper(*exc_details): return cm_exit(cm, *exc_details) _exit_wrapper.__self__ = cm self.push(_exit_wrapper) def push(self, exit): """Registers a callback with the standard __exit__ method signature Can suppress exceptions the same way __exit__ methods can. Also accepts any object with an __exit__ method (registering a call to the method instead of the object itself) """ # We use an unbound method rather than a bound method to follow # the standard lookup behaviour for special methods _cb_type = type(exit) try: exit_method = _cb_type.__exit__ except AttributeError: # Not a context manager, so assume its a callable self._exit_callbacks.append(exit) else: self._push_cm_exit(exit, exit_method) return exit # Allow use as a decorator def callback(self, callback, *args, **kwds): """Registers an arbitrary callback and arguments. Cannot suppress exceptions. """ def _exit_wrapper(exc_type, exc, tb): callback(*args, **kwds) # We changed the signature, so using @wraps is not appropriate, but # setting __wrapped__ may still help with introspection _exit_wrapper.__wrapped__ = callback self.push(_exit_wrapper) return callback # Allow use as a decorator def enter_context(self, cm): """Enters the supplied context manager If successful, also pushes its __exit__ method as a callback and returns the result of the __enter__ method. """ # We look up the special methods on the type to # match the with statement _cm_type = type(cm) _exit = _cm_type.__exit__ result = _cm_type.__enter__(cm) self._push_cm_exit(cm, _exit) return result def close(self): """Immediately unwind the context stack""" self.__exit__(None, None, None) def __enter__(self): return self def __exit__(self, *exc_details): # We manipulate the exception state so it behaves as though # we were actually nesting multiple with statements frame_exc = sys.exc_info()[1] def _fix_exception_context(new_exc, old_exc): while 1: exc_context = new_exc.__context__ if exc_context in (None, frame_exc): break new_exc = exc_context new_exc.__context__ = old_exc # Callbacks are invoked in LIFO order to match the behaviour of # nested context managers suppressed_exc = False while self._exit_callbacks: cb = self._exit_callbacks.pop() try: if cb(*exc_details): suppressed_exc = True exc_details = (None, None, None) except Exception: new_exc_details = sys.exc_info() # simulate the stack of exceptions by setting the context _fix_exception_context(new_exc_details[1], exc_details[1]) if not self._exit_callbacks: raise exc_details = new_exc_details return suppressed_exc ================================================ FILE: openpype/hosts/maya/api/fbx.py ================================================ # -*- coding: utf-8 -*- """Tools to work with FBX.""" import logging from pyblish.api import Instance from maya import cmds # noqa import maya.mel as mel # noqa from openpype.hosts.maya.api.lib import maintained_selection class FBXExtractor: """Extract FBX from Maya. This extracts reproducible FBX exports ignoring any of the settings set on the local machine in the FBX export options window. All export settings are applied with the `FBXExport*` commands prior to the `FBXExport` call itself. The options can be overridden with their nice names as seen in the "options" property on this class. For more information on FBX exports see: - https://knowledge.autodesk.com/support/maya/learn-explore/caas /CloudHelp/cloudhelp/2016/ENU/Maya/files/GUID-6CCE943A-2ED4-4CEE-96D4 -9CB19C28F4E0-htm.html - http://forums.cgsociety.org/archive/index.php?t-1032853.html - https://groups.google.com/forum/#!msg/python_inside_maya/cLkaSo361oE /LKs9hakE28kJ """ @property def options(self): """Overridable options for FBX Export Given in the following format - {NAME: EXPECTED TYPE} If the overridden option's type does not match, the option is not included and a warning is logged. """ return { "cameras": bool, "smoothingGroups": bool, "hardEdges": bool, "tangents": bool, "smoothMesh": bool, "instances": bool, # "referencedContainersContent": bool, # deprecated in Maya 2016+ "bakeComplexAnimation": int, "bakeComplexStart": int, "bakeComplexEnd": int, "bakeComplexStep": int, "bakeResampleAnimation": bool, "useSceneName": bool, "quaternion": str, # "euler" "shapes": bool, "skins": bool, "constraints": bool, "lights": bool, "embeddedTextures": bool, "inputConnections": bool, "upAxis": str, # x, y or z, "triangulate": bool, "fileVersion": str, "skeletonDefinitions": bool, "referencedAssetsContent": bool } @property def default_options(self): """The default options for FBX extraction. This includes shapes, skins, constraints, lights and incoming connections and exports with the Y-axis as up-axis. By default this uses the time sliders start and end time. """ start_frame = int(cmds.playbackOptions(query=True, animationStartTime=True)) end_frame = int(cmds.playbackOptions(query=True, animationEndTime=True)) return { "cameras": False, "smoothingGroups": True, "hardEdges": False, "tangents": False, "smoothMesh": True, "instances": False, "bakeComplexAnimation": True, "bakeComplexStart": start_frame, "bakeComplexEnd": end_frame, "bakeComplexStep": 1, "bakeResampleAnimation": True, "useSceneName": False, "quaternion": "euler", "shapes": True, "skins": True, "constraints": False, "lights": True, "embeddedTextures": False, "inputConnections": True, "upAxis": "y", "triangulate": False, "fileVersion": "FBX202000", "skeletonDefinitions": False, "referencedAssetsContent": False } def __init__(self, log=None): # Ensure FBX plug-in is loaded self.log = log or logging.getLogger(self.__class__.__name__) cmds.loadPlugin("fbxmaya", quiet=True) def parse_overrides(self, instance, options): """Inspect data of instance to determine overridden options An instance may supply any of the overridable options as data, the option is then added to the extraction. """ for key in instance.data: if key not in self.options: continue # Ensure the data is of correct type value = instance.data[key] if not isinstance(value, self.options[key]): self.log.warning( "Overridden attribute {key} was of " "the wrong type: {invalid_type} " "- should have been {valid_type}".format( key=key, invalid_type=type(value).__name__, valid_type=self.options[key].__name__)) continue options[key] = value return options def set_options_from_instance(self, instance): # type: (Instance) -> None """Sets FBX export options from data in the instance. Args: instance (Instance): Instance data. """ # Parse export options options = self.default_options options = self.parse_overrides(instance, options) self.log.debug("Export options: {0}".format(options)) # Collect the start and end including handles start = instance.data.get("frameStartHandle") or \ instance.context.data.get("frameStartHandle") end = instance.data.get("frameEndHandle") or \ instance.context.data.get("frameEndHandle") options['bakeComplexStart'] = start options['bakeComplexEnd'] = end # First apply the default export settings to be fully consistent # each time for successive publishes mel.eval("FBXResetExport") # Apply the FBX overrides through MEL since the commands # only work correctly in MEL according to online # available discussions on the topic _iteritems = getattr(options, "iteritems", options.items) for option, value in _iteritems(): key = option[0].upper() + option[1:] # uppercase first letter # Boolean must be passed as lower-case strings # as to MEL standards if isinstance(value, bool): value = str(value).lower() template = "FBXExport{0} {1}" if key == "UpAxis" else \ "FBXExport{0} -v {1}" # noqa cmd = template.format(key, value) self.log.debug(cmd) mel.eval(cmd) # Never show the UI or generate a log mel.eval("FBXExportShowUI -v false") mel.eval("FBXExportGenerateLog -v false") @staticmethod def export(members, path): # type: (list, str) -> None """Export members as FBX with given path. Args: members (list): List of members to export. path (str): Path to use for export. """ # The export requires forward slashes because we need # to format it into a string in a mel expression path = path.replace("\\", "/") with maintained_selection(): cmds.select(members, r=True, noExpand=True) mel.eval('FBXExport -f "{}" -s'.format(path)) ================================================ FILE: openpype/hosts/maya/api/gltf.py ================================================ # -*- coding: utf-8 -*- """Tools to work with GLTF.""" import logging from maya import cmds, mel # noqa log = logging.getLogger(__name__) _gltf_options = { "of": str, # outputFolder "cpr": str, # copyright "sno": bool, # selectedNodeOnly "sn": str, # sceneName "glb": bool, # binary "nbu": bool, # niceBufferURIs "hbu": bool, # hashBufferURI "ext": bool, # externalTextures "ivt": int, # initialValuesTime "acn": str, # animationClipName "ast": int, # animationClipStartTime "aet": int, # animationClipEndTime "afr": float, # animationClipFrameRate "dsa": int, # detectStepAnimations "mpa": str, # meshPrimitiveAttributes "bpa": str, # blendPrimitiveAttributes "i32": bool, # force32bitIndices "ssm": bool, # skipStandardMaterials "eut": bool, # excludeUnusedTexcoord "dm": bool, # defaultMaterial "cm": bool, # colorizeMaterials "dmy": str, # dumpMaya "dgl": str, # dumpGLTF "imd": str, # ignoreMeshDeformers "ssc": bool, # skipSkinClusters "sbs": bool, # skipBlendShapes "rvp": bool, # redrawViewport "vno": bool # visibleNodesOnly } def extract_gltf(parent_dir, filename, **kwargs): """Sets GLTF export options from data in the instance. """ cmds.loadPlugin('maya2glTF', quiet=True) # load the UI to run mel command mel.eval("maya2glTF_UI()") parent_dir = parent_dir.replace('\\', '/') options = { "dsa": 1, "glb": True } options.update(kwargs) for key, value in options.copy().items(): if key not in _gltf_options: log.warning("extract_gltf() does not support option '%s'. " "Flag will be ignored..", key) options.pop(key) options.pop(value) continue job_args = list() default_opt = "maya2glTF -of \"{0}\" -sn \"{1}\"".format(parent_dir, filename) # noqa job_args.append(default_opt) for key, value in options.items(): if isinstance(value, str): job_args.append("-{0} \"{1}\"".format(key, value)) elif isinstance(value, bool): if value: job_args.append("-{0}".format(key)) else: job_args.append("-{0} {1}".format(key, value)) job_str = " ".join(job_args) log.info("{}".format(job_str)) mel.eval(job_str) # close the gltf export after finish the export gltf_UI = "maya2glTF_exporter_window" if cmds.window(gltf_UI, q=True, exists=True): cmds.deleteUI(gltf_UI) ================================================ FILE: openpype/hosts/maya/api/lib.py ================================================ """Standalone helper functions""" import os import copy from pprint import pformat import sys import uuid import re import json import logging import contextlib import capture from .exitstack import ExitStack from collections import OrderedDict, defaultdict from math import ceil from six import string_types from maya import cmds, mel from maya.api import OpenMaya from openpype.client import ( get_project, get_asset_by_name, get_subsets, get_last_versions, get_representation_by_name ) from openpype.settings import get_project_settings from openpype.pipeline import ( get_current_project_name, get_current_asset_name, get_current_task_name, discover_loader_plugins, loaders_from_representation, get_representation_path, load_container, registered_host ) from openpype.lib import NumberDef from openpype.pipeline.context_tools import get_current_project_asset from openpype.pipeline.create import CreateContext from openpype.lib.profiles_filtering import filter_profiles self = sys.modules[__name__] self._parent = None log = logging.getLogger(__name__) IS_HEADLESS = not hasattr(cmds, "about") or cmds.about(batch=True) ATTRIBUTE_DICT = {"int": {"attributeType": "long"}, "str": {"dataType": "string"}, "unicode": {"dataType": "string"}, "float": {"attributeType": "double"}, "bool": {"attributeType": "bool"}} SHAPE_ATTRS = {"castsShadows", "receiveShadows", "motionBlur", "primaryVisibility", "smoothShading", "visibleInReflections", "visibleInRefractions", "doubleSided", "opposite"} DEFAULT_MATRIX = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0] INT_FPS = {15, 24, 25, 30, 48, 50, 60, 44100, 48000} FLOAT_FPS = {23.98, 23.976, 29.97, 47.952, 59.94} DISPLAY_LIGHTS_ENUM = [ {"label": "Use Project Settings", "value": "project_settings"}, {"label": "Default Lighting", "value": "default"}, {"label": "All Lights", "value": "all"}, {"label": "Selected Lights", "value": "selected"}, {"label": "Flat Lighting", "value": "flat"}, {"label": "No Lights", "value": "none"} ] def get_main_window(): """Acquire Maya's main window""" from qtpy import QtWidgets if self._parent is None: self._parent = { widget.objectName(): widget for widget in QtWidgets.QApplication.topLevelWidgets() }["MayaWindow"] return self._parent @contextlib.contextmanager def suspended_refresh(suspend=True): """Suspend viewport refreshes cmds.ogs(pause=True) is a toggle so we cant pass False. """ if IS_HEADLESS: yield return original_state = cmds.ogs(query=True, pause=True) try: if suspend and not original_state: cmds.ogs(pause=True) yield finally: if suspend and not original_state: cmds.ogs(pause=True) @contextlib.contextmanager def maintained_selection(): """Maintain selection during context Example: >>> scene = cmds.file(new=True, force=True) >>> node = cmds.createNode("transform", name="Test") >>> cmds.select("persp") >>> with maintained_selection(): ... cmds.select("Test", replace=True) >>> "Test" in cmds.ls(selection=True) False """ previous_selection = cmds.ls(selection=True) try: yield finally: if previous_selection: cmds.select(previous_selection, replace=True, noExpand=True) else: cmds.select(clear=True) def reload_all_udim_tile_previews(): """Regenerate all UDIM tile preview in texture file""" for texture_file in cmds.ls(type="file"): if cmds.getAttr("{}.uvTilingMode".format(texture_file)) > 0: cmds.ogs(regenerateUVTilePreview=texture_file) @contextlib.contextmanager def panel_camera(panel, camera): """Set modelPanel's camera during the context. Arguments: panel (str): modelPanel name. camera (str): camera name. """ original_camera = cmds.modelPanel(panel, query=True, camera=True) try: cmds.modelPanel(panel, edit=True, camera=camera) yield finally: cmds.modelPanel(panel, edit=True, camera=original_camera) def render_capture_preset(preset): """Capture playblast with a preset. To generate the preset use `generate_capture_preset`. Args: preset (dict): preset options Returns: str: Output path of `capture.capture` """ # Force a refresh at the start of the timeline # TODO (Question): Why do we need to do this? What bug does it solve? # Is this for simulations? cmds.refresh(force=True) refresh_frame_int = int(cmds.playbackOptions(query=True, minTime=True)) cmds.currentTime(refresh_frame_int - 1, edit=True) cmds.currentTime(refresh_frame_int, edit=True) log.debug( "Using preset: {}".format( json.dumps(preset, indent=4, sort_keys=True) ) ) preset = copy.deepcopy(preset) # not supported by `capture` so we pop it off of the preset reload_textures = preset["viewport_options"].pop("loadTextures", False) panel = preset.pop("panel") with ExitStack() as stack: stack.enter_context(maintained_time()) stack.enter_context(panel_camera(panel, preset["camera"])) stack.enter_context(viewport_default_options(panel, preset)) if reload_textures: # Force immediate texture loading when to ensure # all textures have loaded before the playblast starts stack.enter_context(material_loading_mode(mode="immediate")) # Regenerate all UDIM tiles previews reload_all_udim_tile_previews() path = capture.capture(log=self.log, **preset) return path def generate_capture_preset(instance, camera, path, start=None, end=None, capture_preset=None): """Function for getting all the data of preset options for playblast capturing Args: instance (pyblish.api.Instance): instance camera (str): review camera path (str): filepath start (int): frameStart end (int): frameEnd capture_preset (dict): capture preset Returns: dict: Resulting preset """ preset = load_capture_preset(data=capture_preset) preset["camera"] = camera preset["start_frame"] = start preset["end_frame"] = end preset["filename"] = path preset["overwrite"] = True preset["panel"] = instance.data["panel"] # Disable viewer since we use the rendering logic for publishing # We don't want to open the generated playblast in a viewer directly. preset["viewer"] = False # "isolate_view" will already have been applied at creation, so we'll # ignore it here. preset.pop("isolate_view") # Set resolution variables from capture presets width_preset = capture_preset["Resolution"]["width"] height_preset = capture_preset["Resolution"]["height"] # Set resolution variables from asset values asset_data = instance.data["assetEntity"]["data"] asset_width = asset_data.get("resolutionWidth") asset_height = asset_data.get("resolutionHeight") review_instance_width = instance.data.get("review_width") review_instance_height = instance.data.get("review_height") # Use resolution from instance if review width/height is set # Otherwise use the resolution from preset if it has non-zero values # Otherwise fall back to asset width x height # Else define no width, then `capture.capture` will use render resolution if review_instance_width and review_instance_height: preset["width"] = review_instance_width preset["height"] = review_instance_height elif width_preset and height_preset: preset["width"] = width_preset preset["height"] = height_preset elif asset_width and asset_height: preset["width"] = asset_width preset["height"] = asset_height # Isolate view is requested by having objects in the set besides a # camera. If there is only 1 member it'll be the camera because we # validate to have 1 camera only. if instance.data["isolate"] and len(instance.data["setMembers"]) > 1: preset["isolate"] = instance.data["setMembers"] # Override camera options # Enforce persisting camera depth of field camera_options = preset.setdefault("camera_options", {}) camera_options["depthOfField"] = cmds.getAttr( "{0}.depthOfField".format(camera) ) # Use Pan/Zoom from instance data instead of from preset preset.pop("pan_zoom", None) camera_options["panZoomEnabled"] = instance.data["panZoom"] # Override viewport options by instance data viewport_options = preset.setdefault("viewport_options", {}) viewport_options["displayLights"] = instance.data["displayLights"] viewport_options["imagePlane"] = instance.data.get("imagePlane", True) # Override transparency if requested. transparency = instance.data.get("transparency", 0) if transparency != 0: preset["viewport2_options"]["transparencyAlgorithm"] = transparency # Update preset with current panel setting # if override_viewport_options is turned off if not capture_preset["Viewport Options"]["override_viewport_options"]: panel_preset = capture.parse_view(preset["panel"]) panel_preset.pop("camera") preset.update(panel_preset) return preset @contextlib.contextmanager def viewport_default_options(panel, preset): """Context manager used by `render_capture_preset`. We need to explicitly enable some viewport changes so the viewport is refreshed ahead of playblasting. """ # TODO: Clarify in the docstring WHY we need to set it ahead of # playblasting. What issues does it solve? viewport_defaults = {} try: keys = [ "useDefaultMaterial", "wireframeOnShaded", "xray", "jointXray", "backfaceCulling", "textures" ] for key in keys: viewport_defaults[key] = cmds.modelEditor( panel, query=True, **{key: True} ) if preset["viewport_options"].get(key): cmds.modelEditor( panel, edit=True, **{key: True} ) yield finally: # Restoring viewport options. if viewport_defaults: cmds.modelEditor( panel, edit=True, **viewport_defaults ) @contextlib.contextmanager def material_loading_mode(mode="immediate"): """Set material loading mode during context""" original = cmds.displayPref(query=True, materialLoadingMode=True) cmds.displayPref(materialLoadingMode=mode) try: yield finally: cmds.displayPref(materialLoadingMode=original) def get_namespace(node): """Return namespace of given node""" node_name = node.rsplit("|", 1)[-1] if ":" in node_name: return node_name.rsplit(":", 1)[0] else: return "" def strip_namespace(node, namespace): """Strip given namespace from node path. The namespace will only be stripped from names if it starts with that namespace. If the namespace occurs within another namespace it's not removed. Examples: >>> strip_namespace("namespace:node", namespace="namespace:") "node" >>> strip_namespace("hello:world:node", namespace="hello:world") "node" >>> strip_namespace("hello:world:node", namespace="hello") "world:node" >>> strip_namespace("hello:world:node", namespace="world") "hello:world:node" >>> strip_namespace("ns:group|ns:node", namespace="ns") "group|node" Returns: str: Node name without given starting namespace. """ # Ensure namespace ends with `:` if not namespace.endswith(":"): namespace = "{}:".format(namespace) # The long path for a node can also have the namespace # in its parents so we need to remove it from each return "|".join( name[len(namespace):] if name.startswith(namespace) else name for name in node.split("|") ) def get_custom_namespace(custom_namespace): """Return unique namespace. The input namespace can contain a single group of '#' number tokens to indicate where the namespace's unique index should go. The amount of tokens defines the zero padding of the number, e.g ### turns into 001. Warning: Note that a namespace will always be prefixed with a _ if it starts with a digit Example: >>> get_custom_namespace("myspace_##_") # myspace_01_ >>> get_custom_namespace("##_myspace") # _01_myspace >>> get_custom_namespace("myspace##") # myspace01 """ split = re.split("([#]+)", custom_namespace, 1) if len(split) == 3: base, padding, suffix = split padding = "%0{}d".format(len(padding)) else: base = split[0] padding = "%02d" # default padding suffix = "" return unique_namespace( base, format=padding, prefix="_" if not base or base[0].isdigit() else "", suffix=suffix ) def unique_namespace(namespace, format="%02d", prefix="", suffix=""): """Return unique namespace Arguments: namespace (str): Name of namespace to consider format (str, optional): Formatting of the given iteration number suffix (str, optional): Only consider namespaces with this suffix. >>> unique_namespace("bar") # bar01 >>> unique_namespace(":hello") # :hello01 >>> unique_namespace("bar:", suffix="_NS") # bar01_NS: """ def current_namespace(): current = cmds.namespaceInfo(currentNamespace=True, absoluteName=True) # When inside a namespace Maya adds no trailing : if not current.endswith(":"): current += ":" return current # Always check against the absolute namespace root # There's no clash with :x if we're defining namespace :a:x ROOT = ":" if namespace.startswith(":") else current_namespace() # Strip trailing `:` tokens since we might want to add a suffix start = ":" if namespace.startswith(":") else "" end = ":" if namespace.endswith(":") else "" namespace = namespace.strip(":") if ":" in namespace: # Split off any nesting that we don't uniqify anyway. parents, namespace = namespace.rsplit(":", 1) start += parents + ":" ROOT += start def exists(n): # Check for clash with nodes and namespaces fullpath = ROOT + n return cmds.objExists(fullpath) or cmds.namespace(exists=fullpath) iteration = 1 while True: nr_namespace = namespace + format % iteration unique = prefix + nr_namespace + suffix if not exists(unique): return start + unique + end iteration += 1 def read(node): """Return user-defined attributes from `node`""" data = dict() for attr in cmds.listAttr(node, userDefined=True) or list(): try: value = cmds.getAttr(node + "." + attr, asString=True) except RuntimeError: # For Message type attribute or others that have connections, # take source node name as value. source = cmds.listConnections(node + "." + attr, source=True, destination=False) source = cmds.ls(source, long=True) or [None] value = source[0] except ValueError: # Some attributes cannot be read directly, # such as mesh and color attributes. These # are considered non-essential to this # particular publishing pipeline. value = None data[attr] = value return data def matrix_equals(a, b, tolerance=1e-10): """ Compares two matrices with an imperfection tolerance Args: a (list, tuple): the matrix to check b (list, tuple): the matrix to check against tolerance (float): the precision of the differences Returns: bool : True or False """ if not all(abs(x - y) < tolerance for x, y in zip(a, b)): return False return True def float_round(num, places=0, direction=ceil): return direction(num * (10**places)) / float(10**places) def pairwise(iterable): """s -> (s0,s1), (s2,s3), (s4, s5), ...""" from six.moves import zip a = iter(iterable) return zip(a, a) def collect_animation_defs(fps=False): """Get the basic animation attribute defintions for the publisher. Returns: OrderedDict """ # get scene values as defaults frame_start = cmds.playbackOptions(query=True, minTime=True) frame_end = cmds.playbackOptions(query=True, maxTime=True) frame_start_handle = cmds.playbackOptions( query=True, animationStartTime=True ) frame_end_handle = cmds.playbackOptions(query=True, animationEndTime=True) handle_start = frame_start - frame_start_handle handle_end = frame_end_handle - frame_end # build attributes defs = [ NumberDef("frameStart", label="Frame Start", default=frame_start, decimals=0), NumberDef("frameEnd", label="Frame End", default=frame_end, decimals=0), NumberDef("handleStart", label="Handle Start", default=handle_start, decimals=0), NumberDef("handleEnd", label="Handle End", default=handle_end, decimals=0), NumberDef("step", label="Step size", tooltip="A smaller step size means more samples and larger " "output files.\n" "A 1.0 step size is a single sample every frame.\n" "A 0.5 step size is two samples per frame.\n" "A 0.2 step size is five samples per frame.", default=1.0, decimals=3), ] if fps: current_fps = mel.eval('currentTimeUnitToFPS()') fps_def = NumberDef( "fps", label="FPS", default=current_fps, decimals=5 ) defs.append(fps_def) return defs def imprint(node, data): """Write `data` to `node` as userDefined attributes Arguments: node (str): Long name of node data (dict): Dictionary of key/value pairs Example: >>> from maya import cmds >>> def compute(): ... return 6 ... >>> cube, generator = cmds.polyCube() >>> imprint(cube, { ... "regularString": "myFamily", ... "computedValue": lambda: compute() ... }) ... >>> cmds.getAttr(cube + ".computedValue") 6 """ for key, value in data.items(): if callable(value): # Support values evaluated at imprint value = value() if isinstance(value, bool): add_type = {"attributeType": "bool"} set_type = {"keyable": False, "channelBox": True} elif isinstance(value, string_types): add_type = {"dataType": "string"} set_type = {"type": "string"} elif isinstance(value, int): add_type = {"attributeType": "long"} set_type = {"keyable": False, "channelBox": True} elif isinstance(value, float): add_type = {"attributeType": "double"} set_type = {"keyable": False, "channelBox": True} elif isinstance(value, (list, tuple)): add_type = {"attributeType": "enum", "enumName": ":".join(value)} set_type = {"keyable": False, "channelBox": True} value = 0 # enum default else: raise TypeError("Unsupported type: %r" % type(value)) cmds.addAttr(node, longName=key, **add_type) cmds.setAttr(node + "." + key, value, **set_type) def lsattr(attr, value=None): """Return nodes matching `key` and `value` Arguments: attr (str): Name of Maya attribute value (object, optional): Value of attribute. If none is provided, return all nodes with this attribute. Example: >> lsattr("id", "myId") ["myNode"] >> lsattr("id") ["myNode", "myOtherNode"] """ if value is None: return cmds.ls("*.%s" % attr, recursive=True, objectsOnly=True, long=True) return lsattrs({attr: value}) def lsattrs(attrs): """Return nodes with the given attribute(s). Arguments: attrs (dict): Name and value pairs of expected matches Example: >>> # Return nodes with an `age` of five. >>> lsattrs({"age": "five"}) >>> # Return nodes with both `age` and `color` of five and blue. >>> lsattrs({"age": "five", "color": "blue"}) Return: list: matching nodes. """ dep_fn = OpenMaya.MFnDependencyNode() dag_fn = OpenMaya.MFnDagNode() selection_list = OpenMaya.MSelectionList() first_attr = next(iter(attrs)) try: selection_list.add("*.{0}".format(first_attr), searchChildNamespaces=True) except RuntimeError as exc: if str(exc).endswith("Object does not exist"): return [] matches = set() for i in range(selection_list.length()): node = selection_list.getDependNode(i) if node.hasFn(OpenMaya.MFn.kDagNode): fn_node = dag_fn.setObject(node) full_path_names = [path.fullPathName() for path in fn_node.getAllPaths()] else: fn_node = dep_fn.setObject(node) full_path_names = [fn_node.name()] for attr in attrs: try: plug = fn_node.findPlug(attr, True) if plug.asString() != attrs[attr]: break except RuntimeError: break else: matches.update(full_path_names) return list(matches) @contextlib.contextmanager def attribute_values(attr_values): """Remaps node attributes to values during context. Arguments: attr_values (dict): Dictionary with (attr, value) """ original = [(attr, cmds.getAttr(attr)) for attr in attr_values] try: for attr, value in attr_values.items(): if isinstance(value, string_types): cmds.setAttr(attr, value, type="string") else: cmds.setAttr(attr, value) yield finally: for attr, value in original: if isinstance(value, string_types): cmds.setAttr(attr, value, type="string") elif value is None and cmds.getAttr(attr, type=True) == "string": # In some cases the maya.cmds.getAttr command returns None # for string attributes but this value cannot assigned. # Note: After setting it once to "" it will then return "" # instead of None. So this would only happen once. cmds.setAttr(attr, "", type="string") else: cmds.setAttr(attr, value) @contextlib.contextmanager def keytangent_default(in_tangent_type='auto', out_tangent_type='auto'): """Set the default keyTangent for new keys during this context""" original_itt = cmds.keyTangent(query=True, g=True, itt=True)[0] original_ott = cmds.keyTangent(query=True, g=True, ott=True)[0] cmds.keyTangent(g=True, itt=in_tangent_type) cmds.keyTangent(g=True, ott=out_tangent_type) try: yield finally: cmds.keyTangent(g=True, itt=original_itt) cmds.keyTangent(g=True, ott=original_ott) @contextlib.contextmanager def undo_chunk(): """Open a undo chunk during context.""" try: cmds.undoInfo(openChunk=True) yield finally: cmds.undoInfo(closeChunk=True) @contextlib.contextmanager def evaluation(mode="off"): """Set the evaluation manager during context. Arguments: mode (str): The mode to apply during context. "off": The standard DG evaluation (stable) "serial": A serial DG evaluation "parallel": The Maya 2016+ parallel evaluation """ original = cmds.evaluationManager(query=True, mode=1)[0] try: cmds.evaluationManager(mode=mode) yield finally: cmds.evaluationManager(mode=original) @contextlib.contextmanager def empty_sets(sets, force=False): """Remove all members of the sets during the context""" assert isinstance(sets, (list, tuple)) original = dict() original_connections = [] # Store original state for obj_set in sets: members = cmds.sets(obj_set, query=True) original[obj_set] = members try: for obj_set in sets: cmds.sets(clear=obj_set) if force: # Break all connections if force is enabled, this way we # prevent Maya from exporting any reference nodes which are # connected with placeHolder[x] attributes plug = "%s.dagSetMembers" % obj_set connections = cmds.listConnections(plug, source=True, destination=False, plugs=True, connections=True) or [] original_connections.extend(connections) for dest, src in pairwise(connections): cmds.disconnectAttr(src, dest) yield finally: for dest, src in pairwise(original_connections): cmds.connectAttr(src, dest) # Restore original members _iteritems = getattr(original, "iteritems", original.items) for origin_set, members in _iteritems(): cmds.sets(members, forceElement=origin_set) @contextlib.contextmanager def renderlayer(layer): """Set the renderlayer during the context Arguments: layer (str): Name of layer to switch to. """ original = cmds.editRenderLayerGlobals(query=True, currentRenderLayer=True) try: cmds.editRenderLayerGlobals(currentRenderLayer=layer) yield finally: cmds.editRenderLayerGlobals(currentRenderLayer=original) class delete_after(object): """Context Manager that will delete collected nodes after exit. This allows to ensure the nodes added to the context are deleted afterwards. This is useful if you want to ensure nodes are deleted even if an error is raised. Examples: with delete_after() as delete_bin: cube = maya.cmds.polyCube() delete_bin.extend(cube) # cube exists # cube deleted """ def __init__(self, nodes=None): self._nodes = list() if nodes: self.extend(nodes) def append(self, node): self._nodes.append(node) def extend(self, nodes): self._nodes.extend(nodes) def __iter__(self): return iter(self._nodes) def __enter__(self): return self def __exit__(self, type, value, traceback): if self._nodes: cmds.delete(self._nodes) def get_current_renderlayer(): return cmds.editRenderLayerGlobals(query=True, currentRenderLayer=True) def get_renderer(layer): with renderlayer(layer): return cmds.getAttr("defaultRenderGlobals.currentRenderer") @contextlib.contextmanager def no_undo(flush=False): """Disable the undo queue during the context Arguments: flush (bool): When True the undo queue will be emptied when returning from the context losing all undo history. Defaults to False. """ original = cmds.undoInfo(query=True, state=True) keyword = 'state' if flush else 'stateWithoutFlush' try: cmds.undoInfo(**{keyword: False}) yield finally: cmds.undoInfo(**{keyword: original}) def get_shader_assignments_from_shapes(shapes, components=True): """Return the shape assignment per related shading engines. Returns a dictionary where the keys are shadingGroups and the values are lists of assigned shapes or shape-components. Since `maya.cmds.sets` returns shader members on the shapes as components on the transform we correct that in this method too. For the 'shapes' this will return a dictionary like: { "shadingEngineX": ["nodeX", "nodeY"], "shadingEngineY": ["nodeA", "nodeB"] } Args: shapes (list): The shapes to collect the assignments for. components (bool): Whether to include the component assignments. Returns: dict: The {shadingEngine: shapes} relationships """ shapes = cmds.ls(shapes, long=True, shapes=True, objectsOnly=True) if not shapes: return {} # Collect shading engines and their shapes assignments = defaultdict(list) for shape in shapes: # Get unique shading groups for the shape shading_groups = cmds.listConnections(shape, source=False, destination=True, plugs=False, connections=False, type="shadingEngine") or [] shading_groups = list(set(shading_groups)) for shading_group in shading_groups: assignments[shading_group].append(shape) if components: # Note: Components returned from maya.cmds.sets are "listed" as if # being assigned to the transform like: pCube1.f[0] as opposed # to pCubeShape1.f[0] so we correct that here too. # Build a mapping from parent to shapes to include in lookup. transforms = {shape.rsplit("|", 1)[0]: shape for shape in shapes} lookup = set(shapes) | set(transforms.keys()) component_assignments = defaultdict(list) for shading_group in assignments.keys(): members = cmds.ls(cmds.sets(shading_group, query=True), long=True) for member in members: node = member.split(".", 1)[0] if node not in lookup: continue # Component if "." in member: # Fix transform to shape as shaders are assigned to shapes if node in transforms: shape = transforms[node] component = member.split(".", 1)[1] member = "{0}.{1}".format(shape, component) component_assignments[shading_group].append(member) assignments = component_assignments return dict(assignments) @contextlib.contextmanager def shader(nodes, shadingEngine="initialShadingGroup"): """Assign a shader to nodes during the context""" shapes = cmds.ls(nodes, dag=1, objectsOnly=1, shapes=1, long=1) original = get_shader_assignments_from_shapes(shapes) try: # Assign override shader if shapes: cmds.sets(shapes, edit=True, forceElement=shadingEngine) yield finally: # Assign original shaders for sg, members in original.items(): if members: cmds.sets(members, edit=True, forceElement=sg) @contextlib.contextmanager def displaySmoothness(nodes, divisionsU=0, divisionsV=0, pointsWire=4, pointsShaded=1, polygonObject=1): """Set the displaySmoothness during the context""" # Ensure only non-intermediate shapes nodes = cmds.ls(nodes, dag=1, shapes=1, long=1, noIntermediate=True) def parse(node): """Parse the current state of a node""" state = {} for key in ["divisionsU", "divisionsV", "pointsWire", "pointsShaded", "polygonObject"]: value = cmds.displaySmoothness(node, query=1, **{key: True}) if value is not None: state[key] = value[0] return state originals = dict((node, parse(node)) for node in nodes) try: # Apply current state cmds.displaySmoothness(nodes, divisionsU=divisionsU, divisionsV=divisionsV, pointsWire=pointsWire, pointsShaded=pointsShaded, polygonObject=polygonObject) yield finally: # Revert state _iteritems = getattr(originals, "iteritems", originals.items) for node, state in _iteritems(): if state: cmds.displaySmoothness(node, **state) @contextlib.contextmanager def no_display_layers(nodes): """Ensure nodes are not in a displayLayer during context. Arguments: nodes (list): The nodes to remove from any display layer. """ # Ensure long names nodes = cmds.ls(nodes, long=True) # Get the original state lookup = set(nodes) original = {} for layer in cmds.ls(type='displayLayer'): # Skip default layer if layer == "defaultLayer": continue members = cmds.editDisplayLayerMembers(layer, query=True, fullNames=True) if not members: continue members = set(members) included = lookup.intersection(members) if included: original[layer] = list(included) try: # Add all nodes to default layer cmds.editDisplayLayerMembers("defaultLayer", nodes, noRecurse=True) yield finally: # Restore original members _iteritems = getattr(original, "iteritems", original.items) for layer, members in _iteritems(): cmds.editDisplayLayerMembers(layer, members, noRecurse=True) @contextlib.contextmanager def namespaced(namespace, new=True, relative_names=None): """Work inside namespace during context Args: new (bool): When enabled this will rename the namespace to a unique namespace if the input namespace already exists. Yields: str: The namespace that is used during the context """ original = cmds.namespaceInfo(cur=True, absoluteName=True) original_relative_names = cmds.namespace(query=True, relativeNames=True) if new: namespace = unique_namespace(namespace) cmds.namespace(add=namespace) if relative_names is not None: cmds.namespace(relativeNames=relative_names) try: cmds.namespace(set=namespace) yield namespace finally: cmds.namespace(set=original) if relative_names is not None: cmds.namespace(relativeNames=original_relative_names) @contextlib.contextmanager def maintained_selection_api(): """Maintain selection using the Maya Python API. Warning: This is *not* added to the undo stack. """ original = OpenMaya.MGlobal.getActiveSelectionList() try: yield finally: OpenMaya.MGlobal.setActiveSelectionList(original) @contextlib.contextmanager def tool(context): """Set a tool context during the context manager. """ original = cmds.currentCtx() try: cmds.setToolTo(context) yield finally: cmds.setToolTo(original) def polyConstraint(components, *args, **kwargs): """Return the list of *components* with the constraints applied. A wrapper around Maya's `polySelectConstraint` to retrieve its results as a list without altering selections. For a list of possible constraints see `maya.cmds.polySelectConstraint` documentation. Arguments: components (list): List of components of polygon meshes Returns: list: The list of components filtered by the given constraints. """ kwargs.pop('mode', None) with no_undo(flush=False): # Reverting selection to the original selection using # `maya.cmds.select` can be slow in rare cases where previously # `maya.cmds.polySelectConstraint` had set constrain to "All and Next" # and the "Random" setting was activated. To work around this we # revert to the original selection using the Maya API. This is safe # since we're not generating any undo change anyway. with tool("selectSuperContext"): # Selection can be very slow when in a manipulator mode. # So we force the selection context which is fast. with maintained_selection_api(): # Apply constraint using mode=2 (current and next) so # it applies to the selection made before it; because just # a `maya.cmds.select()` call will not trigger the constraint. with reset_polySelectConstraint(): cmds.select(components, r=1, noExpand=True) cmds.polySelectConstraint(*args, mode=2, **kwargs) result = cmds.ls(selection=True) cmds.select(clear=True) return result @contextlib.contextmanager def reset_polySelectConstraint(reset=True): """Context during which the given polyConstraint settings are disabled. The original settings are restored after the context. """ original = cmds.polySelectConstraint(query=True, stateString=True) try: if reset: # Ensure command is available in mel # This can happen when running standalone if not mel.eval("exists resetPolySelectConstraint"): mel.eval("source polygonConstraint") # Reset all parameters mel.eval("resetPolySelectConstraint;") cmds.polySelectConstraint(disable=True) yield finally: mel.eval(original) def is_visible(node, displayLayer=True, intermediateObject=True, parentHidden=True, visibility=True): """Is `node` visible? Returns whether a node is hidden by one of the following methods: - The node exists (always checked) - The node must be a dagNode (always checked) - The node's visibility is off. - The node is set as intermediate Object. - The node is in a disabled displayLayer. - Whether any of its parent nodes is hidden. Roughly based on: http://ewertb.soundlinker.com/mel/mel.098.php Returns: bool: Whether the node is visible in the scene """ # Only existing objects can be visible if not cmds.objExists(node): return False # Only dagNodes can be visible if not cmds.objectType(node, isAType='dagNode'): return False if visibility: if not cmds.getAttr('{0}.visibility'.format(node)): return False if intermediateObject and cmds.objectType(node, isAType='shape'): if cmds.getAttr('{0}.intermediateObject'.format(node)): return False if displayLayer: # Display layers set overrideEnabled and overrideVisibility on members if cmds.attributeQuery('overrideEnabled', node=node, exists=True): override_enabled = cmds.getAttr('{}.overrideEnabled'.format(node)) override_visibility = cmds.getAttr('{}.overrideVisibility'.format( node)) if override_enabled and not override_visibility: return False if parentHidden: parents = cmds.listRelatives(node, parent=True, fullPath=True) if parents: parent = parents[0] if not is_visible(parent, displayLayer=displayLayer, intermediateObject=False, parentHidden=parentHidden, visibility=visibility): return False return True # region ID def get_id_required_nodes(referenced_nodes=False, nodes=None): """Filter out any node which are locked (reference) or readOnly Args: referenced_nodes (bool): set True to filter out reference nodes nodes (list, Optional): nodes to consider Returns: nodes (set): list of filtered nodes """ lookup = None if nodes is None: # Consider all nodes nodes = cmds.ls() else: # Build a lookup for the only allowed nodes in output based # on `nodes` input of the function (+ ensure long names) lookup = set(cmds.ls(nodes, long=True)) def _node_type_exists(node_type): try: cmds.nodeType(node_type, isTypeName=True) return True except RuntimeError: return False # `readOnly` flag is obsolete as of Maya 2016 therefore we explicitly # remove default nodes and reference nodes camera_shapes = ["frontShape", "sideShape", "topShape", "perspShape"] ignore = set() if not referenced_nodes: ignore |= set(cmds.ls(long=True, referencedNodes=True)) # list all defaultNodes to filter out from the rest ignore |= set(cmds.ls(long=True, defaultNodes=True)) ignore |= set(cmds.ls(camera_shapes, long=True)) # Remove Turtle from the result of `cmds.ls` if Turtle is loaded # TODO: This should be a less specific check for a single plug-in. if _node_type_exists("ilrBakeLayer"): ignore |= set(cmds.ls(type="ilrBakeLayer", long=True)) # Establish set of nodes types to include types = ["objectSet", "file", "mesh", "nurbsCurve", "nurbsSurface"] # Check if plugin nodes are available for Maya by checking if the plugin # is loaded if cmds.pluginInfo("pgYetiMaya", query=True, loaded=True): types.append("pgYetiMaya") # We *always* ignore intermediate shapes, so we filter them out directly nodes = cmds.ls(nodes, type=types, long=True, noIntermediate=True) # The items which need to pass the id to their parent # Add the collected transform to the nodes dag = cmds.ls(nodes, type="dagNode", long=True) # query only dag nodes transforms = cmds.listRelatives(dag, parent=True, fullPath=True) or [] nodes = set(nodes) nodes |= set(transforms) nodes -= ignore # Remove the ignored nodes if not nodes: return nodes # Ensure only nodes from the input `nodes` are returned when a # filter was applied on function call because we also iterated # to parents and alike if lookup is not None: nodes &= lookup # Avoid locked nodes nodes_list = list(nodes) locked = cmds.lockNode(nodes_list, query=True, lock=True) for node, lock in zip(nodes_list, locked): if lock: log.warning("Skipping locked node: %s" % node) nodes.remove(node) return nodes def get_id(node): """Get the `cbId` attribute of the given node. Args: node (str): the name of the node to retrieve the attribute from Returns: str """ if node is None: return sel = OpenMaya.MSelectionList() sel.add(node) api_node = sel.getDependNode(0) fn = OpenMaya.MFnDependencyNode(api_node) if not fn.hasAttribute("cbId"): return try: return fn.findPlug("cbId", False).asString() except RuntimeError: log.warning("Failed to retrieve cbId on %s", node) return def generate_ids(nodes, asset_id=None): """Returns new unique ids for the given nodes. Note: This does not assign the new ids, it only generates the values. To assign new ids using this method: >>> nodes = ["a", "b", "c"] >>> for node, id in generate_ids(nodes): >>> set_id(node, id) To also override any existing values (and assign regenerated ids): >>> nodes = ["a", "b", "c"] >>> for node, id in generate_ids(nodes): >>> set_id(node, id, overwrite=True) Args: nodes (list): List of nodes. asset_id (str or bson.ObjectId): The database id for the *asset* to generate for. When None provided the current asset in the active session is used. Returns: list: A list of (node, id) tuples. """ if asset_id is None: # Get the asset ID from the database for the asset of current context project_name = get_current_project_name() asset_name = get_current_asset_name() asset_doc = get_asset_by_name(project_name, asset_name, fields=["_id"]) assert asset_doc, "No current asset found in Session" asset_id = asset_doc['_id'] node_ids = [] for node in nodes: _, uid = str(uuid.uuid4()).rsplit("-", 1) unique_id = "{}:{}".format(asset_id, uid) node_ids.append((node, unique_id)) return node_ids def set_id(node, unique_id, overwrite=False): """Add cbId to `node` unless one already exists. Args: node (str): the node to add the "cbId" on unique_id (str): The unique node id to assign. This should be generated by `generate_ids`. overwrite (bool, optional): When True overrides the current value even if `node` already has an id. Defaults to False. Returns: None """ exists = cmds.attributeQuery("cbId", node=node, exists=True) # Add the attribute if it does not exist yet if not exists: cmds.addAttr(node, longName="cbId", dataType="string") # Set the value if not exists or overwrite: attr = "{0}.cbId".format(node) cmds.setAttr(attr, unique_id, type="string") def get_attribute(plug, asString=False, expandEnvironmentVariables=False, **kwargs): """Maya getAttr with some fixes based on `pymel.core.general.getAttr()`. Like Pymel getAttr this applies some changes to `maya.cmds.getAttr` - maya pointlessly returned vector results as a tuple wrapped in a list (ex. '[(1,2,3)]'). This command unpacks the vector for you. - when getting a multi-attr, maya would raise an error, but this will return a list of values for the multi-attr - added support for getting message attributes by returning the connections instead Note that the asString + expandEnvironmentVariables argument naming convention matches the `maya.cmds.getAttr` arguments so that it can act as a direct replacement for it. Args: plug (str): Node's attribute plug as `node.attribute` asString (bool): Return string value for enum attributes instead of the index. Note that the return value can be dependent on the UI language Maya is running in. expandEnvironmentVariables (bool): Expand any environment variable and (tilde characters on UNIX) found in string attributes which are returned. Kwargs: Supports the keyword arguments of `maya.cmds.getAttr` Returns: object: The value of the maya attribute. """ attr_type = cmds.getAttr(plug, type=True) if asString: kwargs["asString"] = True if expandEnvironmentVariables: kwargs["expandEnvironmentVariables"] = True try: res = cmds.getAttr(plug, **kwargs) except RuntimeError: if attr_type == "message": return cmds.listConnections(plug) node, attr = plug.split(".", 1) children = cmds.attributeQuery(attr, node=node, listChildren=True) if children: return [ get_attribute("{}.{}".format(node, child)) for child in children ] raise # Convert vector result wrapped in tuple if isinstance(res, list) and len(res): if isinstance(res[0], tuple) and len(res): if attr_type in {'pointArray', 'vectorArray'}: return res return res[0] return res def set_attribute(attribute, value, node): """Adjust attributes based on the value from the attribute data If an attribute does not exists on the target it will be added with the dataType being controlled by the value type. Args: attribute (str): name of the attribute to change value: the value to change to attribute to node (str): name of the node Returns: None """ value_type = type(value).__name__ kwargs = ATTRIBUTE_DICT[value_type] if not cmds.attributeQuery(attribute, node=node, exists=True): log.debug("Creating attribute '{}' on " "'{}'".format(attribute, node)) cmds.addAttr(node, longName=attribute, **kwargs) node_attr = "{}.{}".format(node, attribute) enum_type = cmds.attributeQuery(attribute, node=node, enum=True) if enum_type and value_type == "str": enum_string_values = cmds.attributeQuery( attribute, node=node, listEnum=True )[0].split(":") cmds.setAttr( "{}.{}".format(node, attribute), enum_string_values.index(value) ) elif "dataType" in kwargs: attr_type = kwargs["dataType"] cmds.setAttr(node_attr, value, type=attr_type) else: cmds.setAttr(node_attr, value) def apply_attributes(attributes, nodes_by_id): """Alter the attributes to match the state when publishing Apply attribute settings from the publish to the node in the scene based on the UUID which is stored in the cbId attribute. Args: attributes (list): list of dictionaries nodes_by_id (dict): collection of nodes based on UUID {uuid: [node, node]} """ for attr_data in attributes: nodes = nodes_by_id[attr_data["uuid"]] attr_value = attr_data["attributes"] for node in nodes: for attr, value in attr_value.items(): set_attribute(attr, value, node) def get_container_members(container): """Returns the members of a container. This includes the nodes from any loaded references in the container. """ if isinstance(container, dict): # Assume it's a container dictionary container = container["objectName"] members = cmds.sets(container, query=True) or [] members = cmds.ls(members, long=True, objectsOnly=True) or [] all_members = set(members) # Include any referenced nodes from any reference in the container # This is required since we've removed adding ALL nodes of a reference # into the container set and only add the reference node now. for ref in cmds.ls(members, exactType="reference", objectsOnly=True): # Ignore any `:sharedReferenceNode` if ref.rsplit(":", 1)[-1].startswith("sharedReferenceNode"): continue # Ignore _UNKNOWN_REF_NODE_ (PLN-160) if ref.rsplit(":", 1)[-1].startswith("_UNKNOWN_REF_NODE_"): continue reference_members = cmds.referenceQuery(ref, nodes=True, dagPath=True) reference_members = cmds.ls(reference_members, long=True, objectsOnly=True) all_members.update(reference_members) return list(all_members) # region LOOKDEV def list_looks(project_name, asset_id): """Return all look subsets for the given asset This assumes all look subsets start with "look*" in their names. """ # # get all subsets with look leading in # the name associated with the asset # TODO this should probably look for family 'look' instead of checking # subset name that can not start with family subset_docs = get_subsets(project_name, asset_ids=[asset_id]) return [ subset_doc for subset_doc in subset_docs if subset_doc["name"].startswith("look") ] def assign_look_by_version(nodes, version_id): """Assign nodes a specific published look version by id. This assumes the nodes correspond with the asset. Args: nodes(list): nodes to assign look to version_id (bson.ObjectId): database id of the version Returns: None """ project_name = get_current_project_name() # Get representations of shader file and relationships look_representation = get_representation_by_name( project_name, "ma", version_id ) json_representation = get_representation_by_name( project_name, "json", version_id ) # See if representation is already loaded, if so reuse it. host = registered_host() representation_id = str(look_representation['_id']) for container in host.ls(): if (container['loader'] == "LookLoader" and container['representation'] == representation_id): log.info("Reusing loaded look ..") container_node = container['objectName'] break else: log.info("Using look for the first time ..") # Load file _loaders = discover_loader_plugins() loaders = loaders_from_representation(_loaders, representation_id) Loader = next((i for i in loaders if i.__name__ == "LookLoader"), None) if Loader is None: raise RuntimeError("Could not find LookLoader, this is a bug") # Reference the look file with maintained_selection(): container_node = load_container(Loader, look_representation) # Get container members shader_nodes = get_container_members(container_node) # Load relationships shader_relation = get_representation_path(json_representation) with open(shader_relation, "r") as f: relationships = json.load(f) # Assign relationships apply_shaders(relationships, shader_nodes, nodes) def assign_look(nodes, subset="lookDefault"): """Assigns a look to a node. Optimizes the nodes by grouping by asset id and finding related subset by name. Args: nodes (list): all nodes to assign the look to subset (str): name of the subset to find """ # Group all nodes per asset id grouped = defaultdict(list) for node in nodes: pype_id = get_id(node) if not pype_id: continue parts = pype_id.split(":", 1) grouped[parts[0]].append(node) project_name = get_current_project_name() subset_docs = get_subsets( project_name, subset_names=[subset], asset_ids=grouped.keys() ) subset_docs_by_asset_id = { str(subset_doc["parent"]): subset_doc for subset_doc in subset_docs } subset_ids = { subset_doc["_id"] for subset_doc in subset_docs_by_asset_id.values() } last_version_docs = get_last_versions( project_name, subset_ids=subset_ids, fields=["_id", "name", "data.families"] ) last_version_docs_by_subset_id = { last_version_doc["parent"]: last_version_doc for last_version_doc in last_version_docs } for asset_id, asset_nodes in grouped.items(): # create objectId for database subset_doc = subset_docs_by_asset_id.get(asset_id) if not subset_doc: log.warning("No subset '{}' found for {}".format(subset, asset_id)) continue last_version = last_version_docs_by_subset_id.get(subset_doc["_id"]) if not last_version: log.warning(( "Not found last version for subset '{}' on asset with id {}" ).format(subset, asset_id)) continue families = last_version.get("data", {}).get("families") or [] if "look" not in families: log.warning(( "Last version for subset '{}' on asset with id {}" " does not have look family" ).format(subset, asset_id)) continue log.debug("Assigning look '{}' ".format( subset, last_version["name"])) assign_look_by_version(asset_nodes, last_version["_id"]) def apply_shaders(relationships, shadernodes, nodes): """Link shadingEngine to the right nodes based on relationship data Relationship data is constructed of a collection of `sets` and `attributes` `sets` corresponds with the shaderEngines found in the lookdev. Each set has the keys `name`, `members` and `uuid`, the `members` hold a collection of node information `name` and `uuid`. Args: relationships (dict): relationship data shadernodes (list): list of nodes of the shading objectSets (includes VRayObjectProperties and shadingEngines) nodes (list): list of nodes to apply shader to Returns: None """ attributes = relationships.get("attributes", []) shader_data = relationships.get("relationships", {}) shading_engines = cmds.ls(shadernodes, type="objectSet", long=True) assert shading_engines, "Error in retrieving objectSets from reference" # region compute lookup nodes_by_id = defaultdict(list) for node in nodes: nodes_by_id[get_id(node)].append(node) shading_engines_by_id = defaultdict(list) for shad in shading_engines: shading_engines_by_id[get_id(shad)].append(shad) # endregion # region assign shading engines and other sets for data in shader_data.values(): # collect all unique IDs of the set members shader_uuid = data["uuid"] member_uuids = [member["uuid"] for member in data["members"]] filtered_nodes = list() for m_uuid in member_uuids: filtered_nodes.extend(nodes_by_id[m_uuid]) id_shading_engines = shading_engines_by_id[shader_uuid] if not id_shading_engines: log.error("No shader found with cbId " "'{}'".format(shader_uuid)) continue elif len(id_shading_engines) > 1: log.error("Skipping shader assignment. " "More than one shader found with cbId " "'{}'. (found: {})".format(shader_uuid, id_shading_engines)) continue if not filtered_nodes: log.warning("No nodes found for shading engine " "'{0}'".format(id_shading_engines[0])) continue try: cmds.sets(filtered_nodes, forceElement=id_shading_engines[0]) except RuntimeError as rte: log.error("Error during shader assignment: {}".format(rte)) # endregion apply_attributes(attributes, nodes_by_id) # endregion LOOKDEV def get_isolate_view_sets(): """Return isolate view sets of all modelPanels. Returns: list: all sets related to isolate view """ view_sets = set() for panel in cmds.getPanel(type="modelPanel") or []: view_set = cmds.modelEditor(panel, query=True, viewObjects=True) if view_set: view_sets.add(view_set) return view_sets def get_related_sets(node): """Return objectSets that are relationships for a look for `node`. Filters out based on: - id attribute is NOT `pyblish.avalon.container` - shapes and deformer shapes (alembic creates meshShapeDeformed) - set name ends with any from a predefined list - set in not in viewport set (isolate selected for example) Args: node (str): name of the current node to check Returns: list: The related sets """ # Ignore specific suffices ignore_suffices = ["out_SET", "controls_SET", "_INST", "_CON"] # Default nodes to ignore defaults = {"defaultLightSet", "defaultObjectSet"} # Ids to ignore ignored = {"pyblish.avalon.instance", "pyblish.avalon.container"} view_sets = get_isolate_view_sets() sets = cmds.listSets(object=node, extendToShape=False) if not sets: return [] # Fix 'no object matches name' errors on nodes returned by listSets. # In rare cases it can happen that a node is added to an internal maya # set inaccessible by maya commands, for example check some nodes # returned by `cmds.listSets(allSets=True)` sets = cmds.ls(sets) # Ignore `avalon.container` sets = [s for s in sets if not cmds.attributeQuery("id", node=s, exists=True) or not cmds.getAttr("%s.id" % s) in ignored] # Exclude deformer sets (`type=2` for `maya.cmds.listSets`) deformer_sets = cmds.listSets(object=node, extendToShape=False, type=2) or [] deformer_sets = set(deformer_sets) # optimize lookup sets = [s for s in sets if s not in deformer_sets] # Ignore when the set has a specific suffix sets = [s for s in sets if not any(s.endswith(x) for x in ignore_suffices)] # Ignore viewport filter view sets (from isolate select and # viewports) sets = [s for s in sets if s not in view_sets] sets = [s for s in sets if s not in defaults] return sets def get_container_transforms(container, members=None, root=False): """Retrieve the root node of the container content When a container is created through a Loader the content of the file will be grouped under a transform. The name of the root transform is stored in the container information Args: container (dict): the container members (list): optional and convenience argument root (bool): return highest node in hierarchy if True Returns: root (list / str): """ if not members: members = get_container_members(container) results = cmds.ls(members, type="transform", long=True) if root: root = get_highest_in_hierarchy(results) if root: results = root[0] return results def get_highest_in_hierarchy(nodes): """Return highest nodes in the hierarchy that are in the `nodes` list. The "highest in hierarchy" are the nodes closest to world: top-most level. Args: nodes (list): The nodes in which find the highest in hierarchies. Returns: list: The highest nodes from the input nodes. """ # Ensure we use long names nodes = cmds.ls(nodes, long=True) lookup = set(nodes) highest = [] for node in nodes: # If no parents are within the nodes input list # then this is a highest node if not any(n in lookup for n in iter_parents(node)): highest.append(node) return highest def iter_parents(node): """Iter parents of node from its long name. Note: The `node` *must* be the long node name. Args: node (str): Node long name. Yields: str: All parent node names (long names) """ while True: split = node.rsplit("|", 1) if len(split) == 1 or not split[0]: return node = split[0] yield node def remove_other_uv_sets(mesh): """Remove all other UV sets than the current UV set. Keep only current UV set and ensure it's the renamed to default 'map1'. """ uvSets = cmds.polyUVSet(mesh, query=True, allUVSets=True) current = cmds.polyUVSet(mesh, query=True, currentUVSet=True)[0] # Copy over to map1 if current != 'map1': cmds.polyUVSet(mesh, uvSet=current, newUVSet='map1', copy=True) cmds.polyUVSet(mesh, currentUVSet=True, uvSet='map1') current = 'map1' # Delete all non-current UV sets deleteUVSets = [uvSet for uvSet in uvSets if uvSet != current] uvSet = None # Maya Bug (tested in 2015/2016): # In some cases the API's MFnMesh will report less UV sets than # maya.cmds.polyUVSet. This seems to happen when the deletion of UV sets # has not triggered a cleanup of the UVSet array attribute on the mesh # node. It will still have extra entries in the attribute, though it will # not show up in API or UI. Nevertheless it does show up in # maya.cmds.polyUVSet. To ensure we clean up the array we'll force delete # the extra remaining 'indices' that we don't want. # TODO: Implement a better fix # The best way to fix would be to get the UVSet indices from api with # MFnMesh (to ensure we keep correct ones) and then only force delete the # other entries in the array attribute on the node. But for now we're # deleting all entries except first one. Note that the first entry could # never be removed (the default 'map1' always exists and is supposed to # be undeletable.) try: for uvSet in deleteUVSets: cmds.polyUVSet(mesh, delete=True, uvSet=uvSet) except RuntimeError as exc: log.warning('Error uvSet: %s - %s', uvSet, exc) indices = cmds.getAttr('{0}.uvSet'.format(mesh), multiIndices=True) if not indices: log.warning("No uv set found indices for: %s", mesh) return # Delete from end to avoid shifting indices # and remove the indices in the attribute indices = reversed(indices[1:]) for i in indices: attr = '{0}.uvSet[{1}]'.format(mesh, i) cmds.removeMultiInstance(attr, b=True) def get_node_parent(node): """Return full path name for parent of node""" parents = cmds.listRelatives(node, parent=True, fullPath=True) return parents[0] if parents else None def get_id_from_sibling(node, history_only=True): """Return first node id in the history chain that matches this node. The nodes in history must be of the exact same node type and must be parented under the same parent. Optionally, if no matching node is found from the history, all the siblings of the node that are of the same type are checked. Additionally to having the same parent, the sibling must be marked as 'intermediate object'. Args: node (str): node to retrieve the history from history_only (bool): if True and if nothing found in history, look for an 'intermediate object' in all the node's siblings of same type Returns: str or None: The id from the sibling node or None when no id found on any valid nodes in the history or siblings. """ node = cmds.ls(node, long=True)[0] # Find all similar nodes in history history = cmds.listHistory(node) node_type = cmds.nodeType(node) similar_nodes = cmds.ls(history, exactType=node_type, long=True) # Exclude itself similar_nodes = [x for x in similar_nodes if x != node] # The node *must be* under the same parent parent = get_node_parent(node) similar_nodes = [i for i in similar_nodes if get_node_parent(i) == parent] # Check all of the remaining similar nodes and take the first one # with an id and assume it's the original. for similar_node in similar_nodes: _id = get_id(similar_node) if _id: return _id if not history_only: # Get siblings of same type similar_nodes = cmds.listRelatives(parent, type=node_type, fullPath=True) similar_nodes = cmds.ls(similar_nodes, exactType=node_type, long=True) # Exclude itself similar_nodes = [x for x in similar_nodes if x != node] # Get all unique ids from siblings in order since # we consistently take the first one found sibling_ids = OrderedDict() for similar_node in similar_nodes: # Check if "intermediate object" if not cmds.getAttr(similar_node + ".intermediateObject"): continue _id = get_id(similar_node) if not _id: continue if _id in sibling_ids: sibling_ids[_id].append(similar_node) else: sibling_ids[_id] = [similar_node] if sibling_ids: first_id, found_nodes = next(iter(sibling_ids.items())) # Log a warning if we've found multiple unique ids if len(sibling_ids) > 1: log.warning(("Found more than 1 intermediate shape with" " unique id for '{}'. Using id of first" " found: '{}'".format(node, found_nodes[0]))) return first_id def set_scene_fps(fps, update=True): """Set FPS from project configuration Args: fps (int, float): desired FPS update(bool): toggle update animation, default is True Returns: None """ fps_mapping = { '15': 'game', '24': 'film', '25': 'pal', '30': 'ntsc', '48': 'show', '50': 'palf', '60': 'ntscf', '23.976023976023978': '23.976fps', '29.97002997002997': '29.97fps', '47.952047952047955': '47.952fps', '59.94005994005994': '59.94fps', '44100': '44100fps', '48000': '48000fps' } unit = fps_mapping.get(str(convert_to_maya_fps(fps)), None) if unit is None: raise ValueError("Unsupported FPS value: `%s`" % fps) # Get time slider current state start_frame = cmds.playbackOptions(query=True, minTime=True) end_frame = cmds.playbackOptions(query=True, maxTime=True) # Get animation data animation_start = cmds.playbackOptions(query=True, animationStartTime=True) animation_end = cmds.playbackOptions(query=True, animationEndTime=True) current_frame = cmds.currentTime(query=True) log.info("Setting scene FPS to: '{}'".format(unit)) cmds.currentUnit(time=unit, updateAnimation=update) # Set time slider data back to previous state cmds.playbackOptions(edit=True, minTime=start_frame) cmds.playbackOptions(edit=True, maxTime=end_frame) # Set animation data cmds.playbackOptions(edit=True, animationStartTime=animation_start) cmds.playbackOptions(edit=True, animationEndTime=animation_end) cmds.currentTime(current_frame, edit=True, update=True) # Force file stated to 'modified' cmds.file(modified=True) def set_scene_resolution(width, height, pixelAspect): """Set the render resolution Args: width(int): value of the width height(int): value of the height Returns: None """ control_node = "defaultResolution" current_renderer = cmds.getAttr("defaultRenderGlobals.currentRenderer") aspect_ratio_attr = "deviceAspectRatio" # Give VRay a helping hand as it is slightly different from the rest if current_renderer == "vray": aspect_ratio_attr = "aspectRatio" vray_node = "vraySettings" if cmds.objExists(vray_node): control_node = vray_node else: log.error("Can't set VRay resolution because there is no node " "named: `%s`" % vray_node) log.info("Setting scene resolution to: %s x %s" % (width, height)) cmds.setAttr("%s.width" % control_node, width) cmds.setAttr("%s.height" % control_node, height) deviceAspectRatio = ((float(width) / float(height)) * float(pixelAspect)) cmds.setAttr( "{}.{}".format(control_node, aspect_ratio_attr), deviceAspectRatio) cmds.setAttr("%s.pixelAspect" % control_node, pixelAspect) def get_fps_for_current_context(): """Get fps that should be set for current context. Todos: - Skip project value. - Merge logic with 'get_frame_range' and 'reset_scene_resolution' -> all the values in the functions can be collected at one place as they have same requirements. Returns: Union[int, float]: FPS value. """ project_name = get_current_project_name() asset_name = get_current_asset_name() asset_doc = get_asset_by_name( project_name, asset_name, fields=["data.fps"] ) or {} fps = asset_doc.get("data", {}).get("fps") if not fps: project_doc = get_project(project_name, fields=["data.fps"]) or {} fps = project_doc.get("data", {}).get("fps") if not fps: fps = 25 return convert_to_maya_fps(fps) def get_frame_range(include_animation_range=False): """Get the current assets frame range and handles. Args: include_animation_range (bool, optional): Whether to include `animationStart` and `animationEnd` keys to define the outer range of the timeline. It is excluded by default. Returns: dict: Asset's expected frame range values. """ # Set frame start/end project_name = get_current_project_name() asset_name = get_current_asset_name() asset = get_asset_by_name(project_name, asset_name) frame_start = asset["data"].get("frameStart") frame_end = asset["data"].get("frameEnd") if frame_start is None or frame_end is None: cmds.warning("No edit information found for %s" % asset_name) return handle_start = asset["data"].get("handleStart") or 0 handle_end = asset["data"].get("handleEnd") or 0 frame_range = { "frameStart": frame_start, "frameEnd": frame_end, "handleStart": handle_start, "handleEnd": handle_end } if include_animation_range: # The animation range values are only included to define whether # the Maya time slider should include the handles or not. # Some usages of this function use the full dictionary to define # instance attributes for which we want to exclude the animation # keys. That is why these are excluded by default. task_name = get_current_task_name() settings = get_project_settings(project_name) include_handles_settings = settings["maya"]["include_handles"] current_task = asset.get("data").get("tasks").get(task_name) animation_start = frame_start animation_end = frame_end include_handles = include_handles_settings["include_handles_default"] for item in include_handles_settings["per_task_type"]: if current_task["type"] in item["task_type"]: include_handles = item["include_handles"] break if include_handles: animation_start -= int(handle_start) animation_end += int(handle_end) frame_range["animationStart"] = animation_start frame_range["animationEnd"] = animation_end return frame_range def reset_frame_range(playback=True, render=True, fps=True): """Set frame range to current asset Args: playback (bool, Optional): Whether to set the maya timeline playback frame range. Defaults to True. render (bool, Optional): Whether to set the maya render frame range. Defaults to True. fps (bool, Optional): Whether to set scene FPS. Defaults to True. """ if fps: set_scene_fps(get_fps_for_current_context()) frame_range = get_frame_range(include_animation_range=True) if not frame_range: # No frame range data found for asset return frame_start = frame_range["frameStart"] frame_end = frame_range["frameEnd"] animation_start = frame_range["animationStart"] animation_end = frame_range["animationEnd"] if playback: cmds.playbackOptions( minTime=frame_start, maxTime=frame_end, animationStartTime=animation_start, animationEndTime=animation_end ) cmds.currentTime(frame_start) if render: cmds.setAttr("defaultRenderGlobals.startFrame", animation_start) cmds.setAttr("defaultRenderGlobals.endFrame", animation_end) def reset_scene_resolution(): """Apply the scene resolution from the project definition scene resolution can be overwritten by an asset if the asset.data contains any information regarding scene resolution . Returns: None """ project_name = get_current_project_name() project_doc = get_project(project_name) project_data = project_doc["data"] asset_data = get_current_project_asset()["data"] # Set project resolution width_key = "resolutionWidth" height_key = "resolutionHeight" pixelAspect_key = "pixelAspect" width = asset_data.get(width_key, project_data.get(width_key, 1920)) height = asset_data.get(height_key, project_data.get(height_key, 1080)) pixelAspect = asset_data.get(pixelAspect_key, project_data.get(pixelAspect_key, 1)) set_scene_resolution(width, height, pixelAspect) def set_context_settings(): """Apply the project settings from the project definition Settings can be overwritten by an asset if the asset.data contains any information regarding those settings. Examples of settings: fps resolution renderer Returns: None """ # Set project fps set_scene_fps(get_fps_for_current_context()) reset_scene_resolution() # Set frame range. reset_frame_range() # Set colorspace set_colorspace() # Valid FPS def validate_fps(): """Validate current scene FPS and show pop-up when it is incorrect Returns: bool """ expected_fps = get_fps_for_current_context() current_fps = mel.eval('currentTimeUnitToFPS()') fps_match = current_fps == expected_fps if not fps_match and not IS_HEADLESS: from openpype.widgets import popup parent = get_main_window() dialog = popup.PopupUpdateKeys(parent=parent) dialog.setModal(True) dialog.setWindowTitle("Maya scene does not match project FPS") dialog.setMessage( "Scene {} FPS does not match project {} FPS".format( current_fps, expected_fps ) ) dialog.setButtonText("Fix") # Set new text for button (add optional argument for the popup?) toggle = dialog.widgets["toggle"] update = toggle.isChecked() dialog.on_clicked_state.connect( lambda: set_scene_fps(expected_fps, update) ) dialog.show() return False return fps_match def bake(nodes, frame_range=None, step=1.0, simulation=True, preserve_outside_keys=False, disable_implicit_control=True, shape=True): """Bake the given nodes over the time range. This will bake all attributes of the node, including custom attributes. Args: nodes (list): Names of transform nodes, eg. camera, light. frame_range (list): frame range with start and end frame. or if None then takes timeSliderRange simulation (bool): Whether to perform a full simulation of the attributes over time. preserve_outside_keys (bool): Keep keys that are outside of the baked range. disable_implicit_control (bool): When True will disable any constraints to the object. shape (bool): When True also bake attributes on the children shapes. step (float): The step size to sample by. Returns: None """ # Parse inputs if not nodes: return assert isinstance(nodes, (list, tuple)), "Nodes must be a list or tuple" # If frame range is None fall back to time slider playback time range if frame_range is None: frame_range = [cmds.playbackOptions(query=True, minTime=True), cmds.playbackOptions(query=True, maxTime=True)] # If frame range is single frame bake one frame more, # otherwise maya.cmds.bakeResults gets confused if frame_range[1] == frame_range[0]: frame_range[1] += 1 # Bake it with keytangent_default(in_tangent_type='auto', out_tangent_type='auto'): cmds.bakeResults(nodes, simulation=simulation, preserveOutsideKeys=preserve_outside_keys, disableImplicitControl=disable_implicit_control, shape=shape, sampleBy=step, time=(frame_range[0], frame_range[1])) def bake_to_world_space(nodes, frame_range=None, simulation=True, preserve_outside_keys=False, disable_implicit_control=True, shape=True, step=1.0): """Bake the nodes to world space transformation (incl. other attributes) Bakes the transforms to world space (while maintaining all its animated attributes and settings) by duplicating the node. Then parents it to world and constrains to the original. Other attributes are also baked by connecting all attributes directly. Baking is then done using Maya's bakeResults command. See `bake` for the argument documentation. Returns: list: The newly created and baked node names. """ @contextlib.contextmanager def _unlock_attr(attr): """Unlock attribute during context if it is locked""" if not cmds.getAttr(attr, lock=True): # If not locked, do nothing yield return try: cmds.setAttr(attr, lock=False) yield finally: cmds.setAttr(attr, lock=True) def _get_attrs(node): """Workaround for buggy shape attribute listing with listAttr This will only return keyable settable attributes that have an incoming connections (those that have a reason to be baked). Technically this *may* fail to return attributes driven by complex expressions for which maya makes no connections, e.g. doing actual `setAttr` calls in expressions. Arguments: node (str): The node to list attributes for. Returns: list: Keyable attributes with incoming connections. The attribute may be locked. """ attrs = cmds.listAttr(node, write=True, scalar=True, settable=True, connectable=True, keyable=True, shortNames=True) or [] valid_attrs = [] for attr in attrs: node_attr = '{0}.{1}'.format(node, attr) # Sometimes Maya returns 'non-existent' attributes for shapes # so we filter those out if not cmds.attributeQuery(attr, node=node, exists=True): continue # We only need those that have a connection, just to be safe # that it's actually keyable/connectable anyway. if cmds.connectionInfo(node_attr, isDestination=True): valid_attrs.append(attr) return valid_attrs transform_attrs = {"t", "r", "s", "tx", "ty", "tz", "rx", "ry", "rz", "sx", "sy", "sz"} world_space_nodes = [] with ExitStack() as stack: delete_bin = stack.enter_context(delete_after()) # Create the duplicate nodes that are in world-space connected to # the originals for node in nodes: # Duplicate the node short_name = node.rsplit("|", 1)[-1] new_name = "{0}_baked".format(short_name) new_node = cmds.duplicate(node, name=new_name, renameChildren=True)[0] # noqa # Parent new node to world if cmds.listRelatives(new_node, parent=True): new_node = cmds.parent(new_node, world=True)[0] # Temporarily unlock and passthrough connect all attributes # so we can bake them over time # Skip transform attributes because we will constrain them later attrs = set(_get_attrs(node)) - transform_attrs for attr in attrs: orig_node_attr = "{}.{}".format(node, attr) new_node_attr = "{}.{}".format(new_node, attr) # unlock during context to avoid connection errors stack.enter_context(_unlock_attr(new_node_attr)) cmds.connectAttr(orig_node_attr, new_node_attr, force=True) # If shapes are also baked then also temporarily unlock and # passthrough connect all shape attributes for baking if shape: children_shapes = cmds.listRelatives(new_node, children=True, fullPath=True, shapes=True) if children_shapes: orig_children_shapes = cmds.listRelatives(node, children=True, fullPath=True, shapes=True) for orig_shape, new_shape in zip(orig_children_shapes, children_shapes): attrs = _get_attrs(orig_shape) for attr in attrs: orig_node_attr = "{}.{}".format(orig_shape, attr) new_node_attr = "{}.{}".format(new_shape, attr) # unlock during context to avoid connection errors stack.enter_context(_unlock_attr(new_node_attr)) cmds.connectAttr(orig_node_attr, new_node_attr, force=True) # Constraint transforms for attr in transform_attrs: transform_attr = "{}.{}".format(new_node, attr) stack.enter_context(_unlock_attr(transform_attr)) delete_bin.extend(cmds.parentConstraint(node, new_node, mo=False)) delete_bin.extend(cmds.scaleConstraint(node, new_node, mo=False)) world_space_nodes.append(new_node) bake(world_space_nodes, frame_range=frame_range, step=step, simulation=simulation, preserve_outside_keys=preserve_outside_keys, disable_implicit_control=disable_implicit_control, shape=shape) return world_space_nodes def load_capture_preset(data): """Convert OpenPype Extract Playblast settings to `capture` arguments Input data is the settings from: `project_settings/maya/publish/ExtractPlayblast/capture_preset` Args: data (dict): Capture preset settings from OpenPype settings Returns: dict: `capture.capture` compatible keyword arguments """ options = dict() viewport_options = dict() viewport2_options = dict() camera_options = dict() # Straight key-value match from settings to capture arguments options.update(data["Codec"]) options.update(data["Generic"]) options.update(data["Resolution"]) camera_options.update(data['Camera Options']) viewport_options.update(data["Renderer"]) # DISPLAY OPTIONS disp_options = {} for key, value in data['Display Options'].items(): if key.startswith('background'): # Convert background, backgroundTop, backgroundBottom colors if len(value) == 4: # Ignore alpha + convert RGB to float value = [ float(value[0]) / 255, float(value[1]) / 255, float(value[2]) / 255 ] disp_options[key] = value elif key == "displayGradient": disp_options[key] = value options['display_options'] = disp_options # Viewport Options has a mixture of Viewport2 Options and Viewport Options # to pass along to capture. So we'll need to differentiate between the two VIEWPORT2_OPTIONS = { "textureMaxResolution", "renderDepthOfField", "ssaoEnable", "ssaoSamples", "ssaoAmount", "ssaoRadius", "ssaoFilterRadius", "hwFogStart", "hwFogEnd", "hwFogAlpha", "hwFogFalloff", "hwFogColorR", "hwFogColorG", "hwFogColorB", "hwFogDensity", "motionBlurEnable", "motionBlurSampleCount", "motionBlurShutterOpenFraction", "lineAAEnable" } for key, value in data['Viewport Options'].items(): # There are some keys we want to ignore if key in {"override_viewport_options", "high_quality"}: continue # First handle special cases where we do value conversion to # separate option values if key == 'textureMaxResolution': viewport2_options['textureMaxResolution'] = value if value > 0: viewport2_options['enableTextureMaxRes'] = True viewport2_options['textureMaxResMode'] = 1 else: viewport2_options['enableTextureMaxRes'] = False viewport2_options['textureMaxResMode'] = 0 elif key == 'multiSample': viewport2_options['multiSampleEnable'] = value > 0 viewport2_options['multiSampleCount'] = value elif key == 'alphaCut': viewport2_options['transparencyAlgorithm'] = 5 viewport2_options['transparencyQuality'] = 1 elif key == 'hwFogFalloff': # Settings enum value string to integer viewport2_options['hwFogFalloff'] = int(value) # Then handle Viewport 2.0 Options elif key in VIEWPORT2_OPTIONS: viewport2_options[key] = value # Then assume remainder is Viewport Options else: viewport_options[key] = value options['viewport_options'] = viewport_options options['viewport2_options'] = viewport2_options options['camera_options'] = camera_options # use active sound track scene = capture.parse_active_scene() options['sound'] = scene['sound'] return options def get_attr_in_layer(attr, layer): """Return attribute value in specified renderlayer. Same as cmds.getAttr but this gets the attribute's value in a given render layer without having to switch to it. Warning for parent attribute overrides: Attributes that have render layer overrides to their parent attribute are not captured correctly since they do not have a direct connection. For example, an override to sphere.rotate when querying sphere.rotateX will not return correctly! Note: This is much faster for Maya's renderLayer system, yet the code does no optimized query for render setup. Args: attr (str): attribute name, ex. "node.attribute" layer (str): layer name Returns: The return value from `maya.cmds.getAttr` """ try: if cmds.mayaHasRenderSetup(): from . import lib_rendersetup return lib_rendersetup.get_attr_in_layer(attr, layer) except AttributeError: pass # Ignore complex query if we're in the layer anyway current_layer = cmds.editRenderLayerGlobals(query=True, currentRenderLayer=True) if layer == current_layer: return cmds.getAttr(attr) connections = cmds.listConnections(attr, plugs=True, source=False, destination=True, type="renderLayer") or [] connections = filter(lambda x: x.endswith(".plug"), connections) if not connections: return cmds.getAttr(attr) # Some value types perform a conversion when assigning # TODO: See if there's a maya method to allow this conversion # instead of computing it ourselves. attr_type = cmds.getAttr(attr, type=True) conversion = None if attr_type == "time": conversion = mel.eval('currentTimeUnitToFPS()') # returns float elif attr_type == "doubleAngle": # Radians to Degrees: 180 / pi # TODO: This will likely only be correct when Maya units are set # to degrees conversion = 57.2957795131 elif attr_type == "doubleLinear": raise NotImplementedError("doubleLinear conversion not implemented.") for connection in connections: if connection.startswith(layer + "."): attr_split = connection.split(".") if attr_split[0] == layer: attr = ".".join(attr_split[0:-1]) value = cmds.getAttr("%s.value" % attr) if conversion: value *= conversion return value else: # When connections are present, but none # to the specific renderlayer than the layer # should have the "defaultRenderLayer"'s value layer = "defaultRenderLayer" for connection in connections: if connection.startswith(layer): attr_split = connection.split(".") if attr_split[0] == "defaultRenderLayer": attr = ".".join(attr_split[0:-1]) value = cmds.getAttr("%s.value" % attr) if conversion: value *= conversion return value return cmds.getAttr(attr) def fix_incompatible_containers(): """Backwards compatibility: old containers to use new ReferenceLoader""" old_loaders = { "MayaAsciiLoader", "AbcLoader", "ModelLoader", "CameraLoader", "RigLoader", "FBXLoader" } host = registered_host() for container in host.ls(): loader = container['loader'] if loader in old_loaders: log.info( "Converting legacy container loader {} to " "ReferenceLoader: {}".format(loader, container["objectName"]) ) cmds.setAttr(container["objectName"] + ".loader", "ReferenceLoader", type="string") def update_content_on_context_change(): """ This will update scene content to match new asset on context change """ scene_sets = cmds.listSets(allSets=True) asset_doc = get_current_project_asset() new_asset = asset_doc["name"] new_data = asset_doc["data"] for s in scene_sets: try: if cmds.getAttr("{}.id".format(s)) == "pyblish.avalon.instance": attr = cmds.listAttr(s) print(s) if "asset" in attr: print(" - setting asset to: [ {} ]".format(new_asset)) cmds.setAttr("{}.asset".format(s), new_asset, type="string") if "frameStart" in attr: cmds.setAttr("{}.frameStart".format(s), new_data["frameStart"]) if "frameEnd" in attr: cmds.setAttr("{}.frameEnd".format(s), new_data["frameEnd"],) except ValueError: pass def show_message(title, msg): from qtpy import QtWidgets from openpype.widgets import message_window # Find maya main window top_level_widgets = {w.objectName(): w for w in QtWidgets.QApplication.topLevelWidgets()} parent = top_level_widgets.get("MayaWindow", None) if parent is None: pass else: message_window.message(title=title, message=msg, parent=parent) def iter_shader_edits(relationships, shader_nodes, nodes_by_id, label=None): """Yield edits as a set of actions.""" attributes = relationships.get("attributes", []) shader_data = relationships.get("relationships", {}) shading_engines = cmds.ls(shader_nodes, type="objectSet", long=True) assert shading_engines, "Error in retrieving objectSets from reference" # region compute lookup shading_engines_by_id = defaultdict(list) for shad in shading_engines: shading_engines_by_id[get_id(shad)].append(shad) # endregion # region assign shading engines and other sets for data in shader_data.values(): # collect all unique IDs of the set members shader_uuid = data["uuid"] member_uuids = [ (member["uuid"], member.get("components")) for member in data["members"]] filtered_nodes = list() for _uuid, components in member_uuids: nodes = nodes_by_id.get(_uuid, None) if nodes is None: continue if components: # Assign to the components nodes = [".".join([node, components]) for node in nodes] filtered_nodes.extend(nodes) id_shading_engines = shading_engines_by_id[shader_uuid] if not id_shading_engines: log.error("{} - No shader found with cbId " "'{}'".format(label, shader_uuid)) continue elif len(id_shading_engines) > 1: log.error("{} - Skipping shader assignment. " "More than one shader found with cbId " "'{}'. (found: {})".format(label, shader_uuid, id_shading_engines)) continue if not filtered_nodes: log.warning("{} - No nodes found for shading engine " "'{}'".format(label, id_shading_engines[0])) continue yield {"action": "assign", "uuid": data["uuid"], "nodes": filtered_nodes, "shader": id_shading_engines[0]} for data in attributes: nodes = nodes_by_id.get(data["uuid"], []) attr_value = data["attributes"] yield {"action": "setattr", "uuid": data["uuid"], "nodes": nodes, "attributes": attr_value} def set_colorspace(): """Set Colorspace from project configuration""" project_name = get_current_project_name() imageio = get_project_settings(project_name)["maya"]["imageio"] # ocio compatibility variables ocio_v2_maya_version = 2022 maya_version = int(cmds.about(version=True)) ocio_v2_support = use_ocio_v2 = maya_version >= ocio_v2_maya_version is_ocio_set = bool(os.environ.get("OCIO")) use_workfile_settings = imageio.get("workfile", {}).get("enabled") if use_workfile_settings: root_dict = imageio["workfile"] else: # TODO: deprecated code from 3.15.5 - remove # Maya 2022+ introduces new OCIO v2 color management settings that # can override the old color management preferences. OpenPype has # separate settings for both so we fall back when necessary. use_ocio_v2 = imageio["colorManagementPreference_v2"]["enabled"] if use_ocio_v2 and not ocio_v2_support: # Fallback to legacy behavior with a warning log.warning( "Color Management Preference v2 is enabled but not " "supported by current Maya version: {} (< {}). Falling " "back to legacy settings.".format( maya_version, ocio_v2_maya_version) ) if use_ocio_v2: root_dict = imageio["colorManagementPreference_v2"] else: root_dict = imageio["colorManagementPreference"] if not isinstance(root_dict, dict): msg = "set_colorspace(): argument should be dictionary" log.error(msg) return # backward compatibility # TODO: deprecated code from 3.15.5 - remove with deprecated code above view_name = root_dict.get("viewTransform") if view_name is None: view_name = root_dict.get("viewName") log.debug(">> root_dict: {}".format(pformat(root_dict))) if not root_dict: return # set color spaces for rendering space and view transforms def _colormanage(**kwargs): """Wrapper around `cmds.colorManagementPrefs`. This logs errors instead of raising an error so color management settings get applied as much as possible. """ assert len(kwargs) == 1, "Must receive one keyword argument" try: cmds.colorManagementPrefs(edit=True, **kwargs) log.debug("Setting Color Management Preference: {}".format(kwargs)) except RuntimeError as exc: log.error(exc) # enable color management cmds.colorManagementPrefs(edit=True, cmEnabled=True) cmds.colorManagementPrefs(edit=True, ocioRulesEnabled=True) if use_ocio_v2: log.info("Using Maya OCIO v2") if not is_ocio_set: # Set the Maya 2022+ default OCIO v2 config file path log.info("Setting default Maya OCIO v2 config") # Note: Setting "" as value also sets this default however # introduces a bug where launching a file on startup will prompt # to save the empty scene before it, so we set using the path. # This value has been the same for 2022, 2023 and 2024 path = "/OCIO-configs/Maya2022-default/config.ocio" cmds.colorManagementPrefs(edit=True, configFilePath=path) # set rendering space and view transform _colormanage(renderingSpaceName=root_dict["renderSpace"]) _colormanage(viewName=view_name) _colormanage(displayName=root_dict["displayName"]) else: log.info("Using Maya OCIO v1 (legacy)") if not is_ocio_set: # Set the Maya default config file path log.info("Setting default Maya OCIO v1 legacy config") cmds.colorManagementPrefs(edit=True, configFilePath="legacy") # set rendering space and view transform _colormanage(renderingSpaceName=root_dict["renderSpace"]) _colormanage(viewTransformName=view_name) @contextlib.contextmanager def parent_nodes(nodes, parent=None): # type: (list, str) -> list """Context manager to un-parent provided nodes and return them back.""" def _as_mdagpath(node): """Return MDagPath for node path.""" if not node: return sel = OpenMaya.MSelectionList() sel.add(node) return sel.getDagPath(0) # We can only parent dag nodes so we ensure input contains only dag nodes nodes = cmds.ls(nodes, type="dagNode", long=True) if not nodes: # opt-out early yield return parent_node_path = None delete_parent = False if parent: if not cmds.objExists(parent): parent_node = cmds.createNode("transform", name=parent, skipSelect=False) delete_parent = True else: parent_node = parent parent_node_path = cmds.ls(parent_node, long=True)[0] # Store original parents node_parents = [] for node in nodes: node_parent = get_node_parent(node) node_parents.append((_as_mdagpath(node), _as_mdagpath(node_parent))) try: for node, node_parent in node_parents: node_parent_path = node_parent.fullPathName() if node_parent else None # noqa if node_parent_path == parent_node_path: # Already a child continue if parent_node_path: cmds.parent(node.fullPathName(), parent_node_path) else: cmds.parent(node.fullPathName(), world=True) yield finally: # Reparent to original parents for node, original_parent in node_parents: node_path = node.fullPathName() if not node_path: # Node must have been deleted continue node_parent_path = get_node_parent(node_path) original_parent_path = None if original_parent: original_parent_path = original_parent.fullPathName() if not original_parent_path: # Original parent node must have been deleted continue if node_parent_path != original_parent_path: if not original_parent_path: cmds.parent(node_path, world=True) else: cmds.parent(node_path, original_parent_path) if delete_parent: cmds.delete(parent_node_path) @contextlib.contextmanager def maintained_time(): ct = cmds.currentTime(query=True) try: yield finally: cmds.currentTime(ct, edit=True) def iter_visible_nodes_in_range(nodes, start, end): """Yield nodes that are visible in start-end frame range. - Ignores intermediateObjects completely. - Considers animated visibility attributes + upstream visibilities. This is optimized for large scenes where some nodes in the parent hierarchy might have some input connections to the visibilities, e.g. key, driven keys, connections to other attributes, etc. This only does a single time step to `start` if current frame is not inside frame range since the assumption is made that changing a frame isn't so slow that it beats querying all visibility plugs through MDGContext on another frame. Args: nodes (list): List of node names to consider. start (int, float): Start frame. end (int, float): End frame. Returns: list: List of node names. These will be long full path names so might have a longer name than the input nodes. """ # States we consider per node VISIBLE = 1 # always visible INVISIBLE = 0 # always invisible ANIMATED = -1 # animated visibility # Ensure integers start = int(start) end = int(end) # Consider only non-intermediate dag nodes and use the "long" names. nodes = cmds.ls(nodes, long=True, noIntermediate=True, type="dagNode") if not nodes: return with maintained_time(): # Go to first frame of the range if the current time is outside # the queried range so can directly query all visible nodes on # that frame. current_time = cmds.currentTime(query=True) if not (start <= current_time <= end): cmds.currentTime(start) visible = cmds.ls(nodes, long=True, visible=True) for node in visible: yield node if len(visible) == len(nodes) or start == end: # All are visible on frame one, so they are at least visible once # inside the frame range. return # For the invisible ones check whether its visibility and/or # any of its parents visibility attributes are animated. If so, it might # get visible on other frames in the range. def memodict(f): """Memoization decorator for a function taking a single argument. See: http://code.activestate.com/recipes/ 578231-probably-the-fastest-memoization-decorator-in-the-/ """ class memodict(dict): def __missing__(self, key): ret = self[key] = f(key) return ret return memodict().__getitem__ @memodict def get_state(node): plug = node + ".visibility" connections = cmds.listConnections(plug, source=True, destination=False) if connections: return ANIMATED else: return VISIBLE if cmds.getAttr(plug) else INVISIBLE visible = set(visible) invisible = [node for node in nodes if node not in visible] always_invisible = set() # Iterate over the nodes by short to long names to iterate the highest # in hierarchy nodes first. So the collected data can be used from the # cache for parent queries in next iterations. node_dependencies = dict() for node in sorted(invisible, key=len): state = get_state(node) if state == INVISIBLE: always_invisible.add(node) continue # If not always invisible by itself we should go through and check # the parents to see if any of them are always invisible. For those # that are "ANIMATED" we consider that this node is dependent on # that attribute, we store them as dependency. dependencies = set() if state == ANIMATED: dependencies.add(node) traversed_parents = list() for parent in iter_parents(node): if parent in always_invisible or get_state(parent) == INVISIBLE: # When parent is always invisible then consider this parent, # this node we started from and any of the parents we # have traversed in-between to be *always invisible* always_invisible.add(parent) always_invisible.add(node) always_invisible.update(traversed_parents) break # If we have traversed the parent before and its visibility # was dependent on animated visibilities then we can just extend # its dependencies for to those for this node and break further # iteration upwards. parent_dependencies = node_dependencies.get(parent, None) if parent_dependencies is not None: dependencies.update(parent_dependencies) break state = get_state(parent) if state == ANIMATED: dependencies.add(parent) traversed_parents.append(parent) if node not in always_invisible and dependencies: node_dependencies[node] = dependencies if not node_dependencies: return # Now we only have to check the visibilities for nodes that have animated # visibility dependencies upstream. The fastest way to check these # visibility attributes across different frames is with Python api 2.0 # so we do that. @memodict def get_visibility_mplug(node): """Return api 2.0 MPlug with cached memoize decorator""" sel = OpenMaya.MSelectionList() sel.add(node) dag = sel.getDagPath(0) return OpenMaya.MFnDagNode(dag).findPlug("visibility", True) @contextlib.contextmanager def dgcontext(mtime): """MDGContext context manager""" context = OpenMaya.MDGContext(mtime) try: previous = context.makeCurrent() yield context finally: previous.makeCurrent() # We skip the first frame as we already used that frame to check for # overall visibilities. And end+1 to include the end frame. scene_units = OpenMaya.MTime.uiUnit() for frame in range(start + 1, end + 1): mtime = OpenMaya.MTime(frame, unit=scene_units) # Build little cache so we don't query the same MPlug's value # again if it was checked on this frame and also is a dependency # for another node frame_visibilities = {} with dgcontext(mtime) as context: for node, dependencies in list(node_dependencies.items()): for dependency in dependencies: dependency_visible = frame_visibilities.get(dependency, None) if dependency_visible is None: mplug = get_visibility_mplug(dependency) dependency_visible = mplug.asBool(context) frame_visibilities[dependency] = dependency_visible if not dependency_visible: # One dependency is not visible, thus the # node is not visible. break else: # All dependencies are visible. yield node # Remove node with dependencies for next frame iterations # because it was visible at least once. node_dependencies.pop(node) # If no more nodes to process break the frame iterations.. if not node_dependencies: break def get_attribute_input(attr): connections = cmds.listConnections(attr, plugs=True, destination=False) return connections[0] if connections else None def convert_to_maya_fps(fps): """Convert any fps to supported Maya framerates.""" float_framerates = [ 23.976023976023978, # WTF is 29.97 df vs fps? 29.97002997002997, 47.952047952047955, 59.94005994005994 ] # 44100 fps evaluates as 41000.0. Why? Omitting for now. int_framerates = [ 2, 3, 4, 5, 6, 8, 10, 12, 15, 16, 20, 24, 25, 30, 40, 48, 50, 60, 75, 80, 90, 100, 120, 125, 150, 200, 240, 250, 300, 375, 400, 500, 600, 750, 1200, 1500, 2000, 3000, 6000, 48000 ] # If input fps is a whole number we'll return. if float(fps).is_integer(): # Validate fps is part of Maya's fps selection. if int(fps) not in int_framerates: raise ValueError( "Framerate \"{}\" is not supported in Maya".format(fps) ) return int(fps) else: # Differences to supported float frame rates. differences = [] for i in float_framerates: differences.append(abs(i - fps)) # Validate difference does not stray too far from supported framerates. min_difference = min(differences) min_index = differences.index(min_difference) supported_framerate = float_framerates[min_index] if min_difference > 0.1: raise ValueError( "Framerate \"{}\" strays too far from any supported framerate" " in Maya. Closest supported framerate is \"{}\"".format( fps, supported_framerate ) ) return supported_framerate def write_xgen_file(data, filepath): """Overwrites data in .xgen files. Quite naive approach to mainly overwrite "xgDataPath" and "xgProjectPath". Args: data (dict): Dictionary of key, value. Key matches with xgen file. For example: {"xgDataPath": "some/path"} filepath (string): Absolute path of .xgen file. """ # Generate regex lookup for line to key basically # match any of the keys in `\t{key}\t\t` keys = "|".join(re.escape(key) for key in data.keys()) re_keys = re.compile("^\t({})\t\t".format(keys)) lines = [] with open(filepath, "r") as f: for line in f: match = re_keys.match(line) if match: key = match.group(1) value = data[key] line = "\t{}\t\t{}\n".format(key, value) lines.append(line) with open(filepath, "w") as f: f.writelines(lines) def get_color_management_preferences(): """Get and resolve OCIO preferences.""" data = { # Is color management enabled. "enabled": cmds.colorManagementPrefs( query=True, cmEnabled=True ), "rendering_space": cmds.colorManagementPrefs( query=True, renderingSpaceName=True ), "output_transform": cmds.colorManagementPrefs( query=True, outputTransformName=True ), "output_transform_enabled": cmds.colorManagementPrefs( query=True, outputTransformEnabled=True ), "view_transform": cmds.colorManagementPrefs( query=True, viewTransformName=True ) } # Split view and display from view_transform. view_transform comes in # format of "{view} ({display})". regex = re.compile(r"^(?P.+) \((?P.+)\)$") if int(cmds.about(version=True)) <= 2020: # view_transform comes in format of "{view} {display}" in 2020. regex = re.compile(r"^(?P.+) (?P.+)$") match = regex.match(data["view_transform"]) if not match: raise ValueError( "Unable to parse view and display from Maya view transform: '{}' " "using regex '{}'".format(data["view_transform"], regex.pattern) ) data.update({ "display": match.group("display"), "view": match.group("view") }) # Get config absolute path. path = cmds.colorManagementPrefs( query=True, configFilePath=True ) # The OCIO config supports a custom token. maya_resources_token = "" maya_resources_path = OpenMaya.MGlobal.getAbsolutePathToResources() path = path.replace(maya_resources_token, maya_resources_path) data["config"] = path return data def get_color_management_output_transform(): preferences = get_color_management_preferences() colorspace = preferences["rendering_space"] if preferences["output_transform_enabled"]: colorspace = preferences["output_transform"] return colorspace def image_info(file_path): # type: (str) -> dict """Based on tha texture path, get its bit depth and format information. Take reference from makeTx.py in Arnold: ImageInfo(filename): Get Image Information for colorspace AiTextureGetFormat(filename): Get Texture Format AiTextureGetBitDepth(filename): Get Texture bit depth Args: file_path (str): Path to the texture file. Returns: dict: Dictionary with the information about the texture file. """ from arnold import ( AiTextureGetBitDepth, AiTextureGetFormat ) # Get Texture Information img_info = {'filename': file_path} if os.path.isfile(file_path): img_info['bit_depth'] = AiTextureGetBitDepth(file_path) # noqa img_info['format'] = AiTextureGetFormat(file_path) # noqa else: img_info['bit_depth'] = 8 img_info['format'] = "unknown" return img_info def guess_colorspace(img_info): # type: (dict) -> str """Guess the colorspace of the input image filename. Note: Reference from makeTx.py Args: img_info (dict): Image info generated by :func:`image_info` Returns: str: color space name use in the `--colorconvert` option of maketx. """ from arnold import ( AiTextureInvalidate, # types AI_TYPE_BYTE, AI_TYPE_INT, AI_TYPE_UINT ) try: if img_info['bit_depth'] <= 16: if img_info['format'] in (AI_TYPE_BYTE, AI_TYPE_INT, AI_TYPE_UINT): # noqa return 'sRGB' else: return 'linear' # now discard the image file as AiTextureGetFormat has loaded it AiTextureInvalidate(img_info['filename']) # noqa except ValueError: print(("[maketx] Error: Could not guess" "colorspace for {}").format(img_info["filename"])) return "linear" def len_flattened(components): """Return the length of the list as if it was flattened. Maya will return consecutive components as a single entry when requesting with `maya.cmds.ls` without the `flatten` flag. Though enabling `flatten` on a large list (e.g. millions) will result in a slow result. This command will return the amount of entries in a non-flattened list by parsing the result with regex. Args: components (list): The non-flattened components. Returns: int: The amount of entries. """ assert isinstance(components, (list, tuple)) n = 0 pattern = re.compile(r"\[(\d+):(\d+)\]") for c in components: match = pattern.search(c) if match: start, end = match.groups() n += int(end) - int(start) + 1 else: n += 1 return n def get_all_children(nodes): """Return all children of `nodes` including each instanced child. Using maya.cmds.listRelatives(allDescendents=True) includes only the first instance. As such, this function acts as an optimal replacement with a focus on a fast query. """ sel = OpenMaya.MSelectionList() traversed = set() iterator = OpenMaya.MItDag(OpenMaya.MItDag.kDepthFirst) for node in nodes: if node in traversed: # Ignore if already processed as a child # before continue sel.clear() sel.add(node) dag = sel.getDagPath(0) iterator.reset(dag) # ignore self iterator.next() # noqa: B305 while not iterator.isDone(): path = iterator.fullPathName() if path in traversed: iterator.prune() iterator.next() # noqa: B305 continue traversed.add(path) iterator.next() # noqa: B305 return list(traversed) def get_capture_preset(task_name, task_type, subset, project_settings, log): """Get capture preset for playblasting. Logic for transitioning from old style capture preset to new capture preset profiles. Args: task_name (str): Task name. take_type (str): Task type. subset (str): Subset name. project_settings (dict): Project settings. log (object): Logging object. """ capture_preset = None filtering_criteria = { "hosts": "maya", "families": "review", "task_names": task_name, "task_types": task_type, "subset": subset } plugin_settings = project_settings["maya"]["publish"]["ExtractPlayblast"] if plugin_settings["profiles"]: profile = filter_profiles( plugin_settings["profiles"], filtering_criteria, logger=log ) capture_preset = profile.get("capture_preset") else: log.warning("No profiles present for Extract Playblast") # Backward compatibility for deprecated Extract Playblast settings # without profiles. if capture_preset is None: log.debug( "Falling back to deprecated Extract Playblast capture preset " "because no new style playblast profiles are defined." ) capture_preset = plugin_settings["capture_preset"] return capture_preset or {} def get_reference_node(members, log=None): """Get the reference node from the container members Args: members: list of node names Returns: str: Reference node name. """ # Collect the references without .placeHolderList[] attributes as # unique entries (objects only) and skipping the sharedReferenceNode. references = set() for ref in cmds.ls(members, exactType="reference", objectsOnly=True): # Ignore any `:sharedReferenceNode` if ref.rsplit(":", 1)[-1].startswith("sharedReferenceNode"): continue # Ignore _UNKNOWN_REF_NODE_ (PLN-160) if ref.rsplit(":", 1)[-1].startswith("_UNKNOWN_REF_NODE_"): continue references.add(ref) assert references, "No reference node found in container" # Get highest reference node (least parents) highest = min(references, key=lambda x: len(get_reference_node_parents(x))) # Warn the user when we're taking the highest reference node if len(references) > 1: if not log: log = logging.getLogger(__name__) log.warning("More than one reference node found in " "container, using highest reference node: " "%s (in: %s)", highest, list(references)) return highest def get_reference_node_parents(ref): """Return all parent reference nodes of reference node Args: ref (str): reference node. Returns: list: The upstream parent reference nodes. """ parent = cmds.referenceQuery(ref, referenceNode=True, parent=True) parents = [] while parent: parents.append(parent) parent = cmds.referenceQuery(parent, referenceNode=True, parent=True) return parents def create_rig_animation_instance( nodes, context, namespace, options=None, log=None ): """Create an animation publish instance for loaded rigs. See the RecreateRigAnimationInstance inventory action on how to use this for loaded rig containers. Arguments: nodes (list): Member nodes of the rig instance. context (dict): Representation context of the rig container namespace (str): Namespace of the rig container options (dict, optional): Additional loader data log (logging.Logger, optional): Logger to log to if provided Returns: None """ if options is None: options = {} name = context["representation"]["name"] output = next((node for node in nodes if node.endswith("out_SET")), None) controls = next((node for node in nodes if node.endswith("controls_SET")), None) if name != "fbx": assert output, "No out_SET in rig, this is a bug." assert controls, "No controls_SET in rig, this is a bug." anim_skeleton = next((node for node in nodes if node.endswith("skeletonAnim_SET")), None) skeleton_mesh = next((node for node in nodes if node.endswith("skeletonMesh_SET")), None) # Find the roots amongst the loaded nodes roots = ( cmds.ls(nodes, assemblies=True, long=True) or get_highest_in_hierarchy(nodes) ) assert roots, "No root nodes in rig, this is a bug." custom_subset = options.get("animationSubsetName") if custom_subset: formatting_data = { "asset": context["asset"], "subset": context['subset']['name'], "family": ( context['subset']['data'].get('family') or context['subset']['data']['families'][0] ) } namespace = get_custom_namespace( custom_subset.format( **formatting_data ) ) if log: log.info("Creating subset: {}".format(namespace)) # Fill creator identifier creator_identifier = "io.openpype.creators.maya.animation" host = registered_host() create_context = CreateContext(host) # Create the animation instance rig_sets = [output, controls, anim_skeleton, skeleton_mesh] # Remove sets that this particular rig does not have rig_sets = [s for s in rig_sets if s is not None] with maintained_selection(): cmds.select(rig_sets + roots, noExpand=True) create_context.create( creator_identifier=creator_identifier, variant=namespace, pre_create_data={"use_selection": True} ) ================================================ FILE: openpype/hosts/maya/api/lib_renderproducts.py ================================================ # -*- coding: utf-8 -*- """Module handling expected render output from Maya. This module is used in :mod:`collect_render` and :mod:`collect_vray_scene`. Note: To implement new renderer, just create new class inheriting from :class:`ARenderProducts` and add it to :func:`RenderProducts.get()`. Attributes: R_SINGLE_FRAME (:class:`re.Pattern`): Find single frame number. R_FRAME_RANGE (:class:`re.Pattern`): Find frame range. R_FRAME_NUMBER (:class:`re.Pattern`): Find frame number in string. R_LAYER_TOKEN (:class:`re.Pattern`): Find layer token in image prefixes. R_AOV_TOKEN (:class:`re.Pattern`): Find AOV token in image prefixes. R_SUBSTITUTE_AOV_TOKEN (:class:`re.Pattern`): Find and substitute AOV token in image prefixes. R_REMOVE_AOV_TOKEN (:class:`re.Pattern`): Find and remove AOV token in image prefixes. R_CLEAN_FRAME_TOKEN (:class:`re.Pattern`): Find and remove unfilled Renderman frame token in image prefix. R_CLEAN_EXT_TOKEN (:class:`re.Pattern`): Find and remove unfilled Renderman extension token in image prefix. R_SUBSTITUTE_LAYER_TOKEN (:class:`re.Pattern`): Find and substitute render layer token in image prefixes. R_SUBSTITUTE_SCENE_TOKEN (:class:`re.Pattern`): Find and substitute scene token in image prefixes. R_SUBSTITUTE_CAMERA_TOKEN (:class:`re.Pattern`): Find and substitute camera token in image prefixes. IMAGE_PREFIXES (dict): Mapping between renderers and their respective image prefix attribute names. Thanks: Roy Nieterau (BigRoy) / Colorbleed for overhaul of original *expected_files*. """ import logging import re import os from abc import ABCMeta, abstractmethod import six import attr from . import lib from . import lib_rendersetup from openpype.pipeline.colorspace import get_ocio_config_views from maya import cmds, mel log = logging.getLogger(__name__) R_SINGLE_FRAME = re.compile(r"^(-?)\d+$") R_FRAME_RANGE = re.compile(r"^(?P(-?)\d+)-(?P(-?)\d+)$") R_FRAME_NUMBER = re.compile(r".+\.(?P[0-9]+)\..+") R_LAYER_TOKEN = re.compile( r".*((?:%l)|(?:)|(?:)).*", re.IGNORECASE ) R_AOV_TOKEN = re.compile(r".*%a.*|.*.*|.*.*", re.IGNORECASE) R_SUBSTITUTE_AOV_TOKEN = re.compile(r"%a||", re.IGNORECASE) R_REMOVE_AOV_TOKEN = re.compile( r"_%a|\.%a|_|\.|_|\.", re.IGNORECASE) # to remove unused renderman tokens R_CLEAN_FRAME_TOKEN = re.compile(r"\.?\.?", re.IGNORECASE) R_CLEAN_EXT_TOKEN = re.compile(r"\.?\.?", re.IGNORECASE) R_SUBSTITUTE_LAYER_TOKEN = re.compile( r"%l||", re.IGNORECASE ) R_SUBSTITUTE_CAMERA_TOKEN = re.compile(r"%c|", re.IGNORECASE) R_SUBSTITUTE_SCENE_TOKEN = re.compile(r"%s|", re.IGNORECASE) # not sure about the renderman image prefix IMAGE_PREFIXES = { "vray": "vraySettings.fileNamePrefix", "arnold": "defaultRenderGlobals.imageFilePrefix", "renderman": "rmanGlobals.imageFileFormat", "redshift": "defaultRenderGlobals.imageFilePrefix", "mayahardware2": "defaultRenderGlobals.imageFilePrefix" } RENDERMAN_IMAGE_DIR = "/" def has_tokens(string, tokens): """Return whether any of tokens is in input string (case-insensitive)""" pattern = "({})".format("|".join(re.escape(token) for token in tokens)) match = re.search(pattern, string, re.IGNORECASE) return bool(match) @attr.s class LayerMetadata(object): """Data class for Render Layer metadata.""" frameStart = attr.ib() frameEnd = attr.ib() cameras = attr.ib() sceneName = attr.ib() layerName = attr.ib() renderer = attr.ib() defaultExt = attr.ib() filePrefix = attr.ib() frameStep = attr.ib(default=1) padding = attr.ib(default=4) # Render Products products = attr.ib(init=False, default=attr.Factory(list)) # The AOV separator token. Note that not all renderers define an explicit # render separator but allow to put the AOV/RenderPass token anywhere in # the file path prefix. For those renderers we'll fall back to whatever # is between the last occurrences of and tokens. aov_separator = attr.ib(default="_") @attr.s class RenderProduct(object): """Describes an image or other file-like artifact produced by a render. Warning: This currently does NOT return as a product PER render camera. A single Render Product will generate files per camera. E.g. with two cameras each render product generates two sequences on disk assuming the file path prefix correctly uses the tokens. """ productName = attr.ib() ext = attr.ib() # extension colorspace = attr.ib() # colorspace aov = attr.ib(default=None) # source aov driver = attr.ib(default=None) # source driver multipart = attr.ib(default=False) # multichannel file camera = attr.ib(default=None) # used only when rendering # from multiple cameras def get(layer, render_instance=None): # type: (str, object) -> ARenderProducts """Get render details and products for given renderer and render layer. Args: layer (str): Name of render layer render_instance (pyblish.api.Instance): Publish instance. If not provided an empty mock instance is used. Returns: ARenderProducts: The correct RenderProducts instance for that renderlayer. Raises: :exc:`UnsupportedRendererException`: If requested renderer is not supported. It needs to be implemented by extending :class:`ARenderProducts` and added to this methods ``if`` statement. """ if render_instance is None: # For now produce a mock instance class Instance(object): data = {} render_instance = Instance() renderer_name = lib.get_attr_in_layer( "defaultRenderGlobals.currentRenderer", layer=layer ) renderer = { "arnold": RenderProductsArnold, "vray": RenderProductsVray, "redshift": RenderProductsRedshift, "renderman": RenderProductsRenderman, "mayahardware2": RenderProductsMayaHardware }.get(renderer_name.lower(), None) if renderer is None: raise UnsupportedRendererException( "Unsupported renderer: {}".format(renderer_name) ) return renderer(layer, render_instance) @six.add_metaclass(ABCMeta) class ARenderProducts: """Abstract class with common code for all renderers. Attributes: renderer (str): name of renderer. """ renderer = None def __init__(self, layer, render_instance): """Constructor.""" self.layer = layer self.render_instance = render_instance self.multipart = self.get_multipart() # Initialize self.layer_data = self._get_layer_data() self.layer_data.products = self.get_render_products() def get_multipart(self): raise NotImplementedError( "The render product implementation does not have a " "\"get_multipart\" method." ) def has_camera_token(self): # type: () -> bool """Check if camera token is in image prefix. Returns: bool: True/False if camera token is present. """ return "" in self.layer_data.filePrefix.lower() @abstractmethod def get_render_products(self): """To be implemented by renderer class. This should return a list of RenderProducts. Returns: list: List of RenderProduct """ @staticmethod def sanitize_camera_name(camera): # type: (str) -> str """Sanitize camera name. Remove Maya illegal characters from camera name. Args: camera (str): Maya camera name. Returns: (str): sanitized camera name Example: >>> ARenderProducts.sanizite_camera_name('test:camera_01') test_camera_01 """ return re.sub('[^0-9a-zA-Z_]+', '_', camera) def get_renderer_prefix(self): # type: () -> str """Return prefix for specific renderer. This is for most renderers the same and can be overridden if needed. Returns: str: String with image prefix containing tokens Raises: :exc:`UnsupportedRendererException`: If we requested image prefix for renderer we know nothing about. See :data:`IMAGE_PREFIXES` for mapping of renderers and image prefixes. """ try: prefix_attr = IMAGE_PREFIXES[self.renderer] except KeyError: raise UnsupportedRendererException( "Unsupported renderer {}".format(self.renderer) ) # Note: When this attribute is never set (e.g. on maya launch) then # this can return None even though it is a string attribute prefix = self._get_attr(prefix_attr) if not prefix: # Fall back to scene name by default log.warning("Image prefix not set, using ") prefix = "" return prefix def get_render_attribute(self, attribute): """Get attribute from render options. Args: attribute (str): name of attribute to be looked up. Returns: Attribute value """ return self._get_attr("defaultRenderGlobals", attribute) def _get_attr(self, node_attr, attribute=None): """Return the value of the attribute in the renderlayer For readability this allows passing in the attribute in two ways. As a single argument: _get_attr("node.attr") Or as two arguments: _get_attr("node", "attr") Returns: Value of the attribute inside the layer this instance is set to. """ if attribute is None: plug = node_attr else: plug = "{}.{}".format(node_attr, attribute) return lib.get_attr_in_layer(plug, layer=self.layer) @staticmethod def extract_separator(file_prefix): """Extract AOV separator character from the prefix. Default behavior extracts the part between last occurrences of and Todo: This code also triggers for V-Ray which overrides it explicitly so this code will invalidly debug log it couldn't extract the AOV separator even though it does set it in RenderProductsVray. Args: file_prefix (str): File prefix with tokens. Returns: str or None: prefix character if it can be extracted. """ layer_tokens = ["", ""] aov_tokens = ["", ""] def match_last(tokens, text): """regex match the last occurrence from a list of tokens""" pattern = "(?:.*)({})".format("|".join(tokens)) return re.search(pattern, text, re.IGNORECASE) layer_match = match_last(layer_tokens, file_prefix) aov_match = match_last(aov_tokens, file_prefix) separator = None if layer_match and aov_match: matches = sorted((layer_match, aov_match), key=lambda match: match.end(1)) separator = file_prefix[matches[0].end(1):matches[1].start(1)] return separator def _get_layer_data(self): # type: () -> LayerMetadata # ______________________________________________ # ____________________/ ____________________________________________/ # 1 - get scene name /__________________/ # ____________________/ _, scene_basename = os.path.split(cmds.file(q=True, loc=True)) scene_name, _ = os.path.splitext(scene_basename) kwargs = {} file_prefix = self.get_renderer_prefix() # If the Render Layer belongs to a Render Setup layer then the # output name is based on the Render Setup Layer name without # the `rs_` prefix. layer_name = self.layer rs_layer = lib_rendersetup.get_rendersetup_layer(layer_name) if rs_layer: layer_name = rs_layer if self.layer == "defaultRenderLayer": # defaultRenderLayer renders as masterLayer layer_name = "masterLayer" separator = self.extract_separator(file_prefix) if separator: kwargs["aov_separator"] = separator else: log.debug("Couldn't extract aov separator from " "file prefix: {}".format(file_prefix)) # todo: Support Custom Frames sequences 0,5-10,100-120 # Deadline allows submitting renders with a custom frame list # to support those cases we might want to allow 'custom frames' # to be overridden to `ExpectFiles` class? return LayerMetadata( frameStart=int(self.get_render_attribute("startFrame")), frameEnd=int(self.get_render_attribute("endFrame")), frameStep=int(self.get_render_attribute("byFrameStep")), padding=int(self.get_render_attribute("extensionPadding")), # if we have token in prefix path we'll expect output for # every renderable camera in layer. cameras=self.get_renderable_cameras(), sceneName=scene_name, layerName=layer_name, renderer=self.renderer, defaultExt=self._get_attr("defaultRenderGlobals.imfPluginKey"), filePrefix=file_prefix, **kwargs ) def _generate_file_sequence( self, layer_data, force_aov_name=None, force_ext=None, force_cameras=None): # type: (LayerMetadata, str, str, list) -> list expected_files = [] cameras = force_cameras or layer_data.cameras ext = force_ext or layer_data.defaultExt for cam in cameras: file_prefix = layer_data.filePrefix mappings = ( (R_SUBSTITUTE_SCENE_TOKEN, layer_data.sceneName), (R_SUBSTITUTE_LAYER_TOKEN, layer_data.layerName), (R_SUBSTITUTE_CAMERA_TOKEN, self.sanitize_camera_name(cam)), # this is required to remove unfilled aov token, for example # in Redshift (R_REMOVE_AOV_TOKEN, "") if not force_aov_name \ else (R_SUBSTITUTE_AOV_TOKEN, force_aov_name), (R_CLEAN_FRAME_TOKEN, ""), (R_CLEAN_EXT_TOKEN, ""), ) for regex, value in mappings: file_prefix = re.sub(regex, value, file_prefix) for frame in range( int(layer_data.frameStart), int(layer_data.frameEnd) + 1, int(layer_data.frameStep), ): frame_str = str(frame).rjust(layer_data.padding, "0") expected_files.append( "{}.{}.{}".format(file_prefix, frame_str, ext) ) return expected_files def get_files(self, product): # type: (RenderProduct) -> list """Return list of expected files. It will translate render token strings ('', etc.) to their values. This task is tricky as every renderer deals with this differently. That's why we expose `get_files` as a method on the Renderer class so it can be overridden for complex cases. Args: product (RenderProduct): Render product to be used for file generation. Returns: List of files """ return self._generate_file_sequence( self.layer_data, force_aov_name=product.productName, force_ext=product.ext, force_cameras=[product.camera] ) def get_renderable_cameras(self): # type: () -> list """Get all renderable camera transforms. Returns: list: list of renderable cameras. """ renderable_cameras = [ cam for cam in cmds.ls(cameras=True) if self._get_attr(cam, "renderable") ] # The output produces a sanitized name for using its # shortest unique path of the transform so we'll return # at least that unique path. This could include a parent # name too when two cameras have the same name but are # in a different hierarchy, e.g. "group1|cam" and "group2|cam" def get_name(camera): return cmds.ls(cmds.listRelatives(camera, parent=True, fullPath=True))[0] return [get_name(cam) for cam in renderable_cameras] class RenderProductsArnold(ARenderProducts): """Render products for Arnold renderer. References: mtoa.utils.getFileName() mtoa.utils.ui.common.updateArnoldTargetFilePreview() Notes: - Output Denoising AOVs are not currently included. - Only Frame/Animation ext: name.#.ext is supported. - Use Custom extension is not supported. - and tokens not tested - With Merge AOVs but in File Name Prefix Arnold will still NOT merge the aovs. This class correctly resolves it - but user should be aware. - File Path Prefix overrides per AOV driver are not implemented Attributes: aiDriverExtension (dict): Arnold AOV driver extension mapping. Is there a better way? renderer (str): name of renderer. """ renderer = "arnold" aiDriverExtension = { "jpeg": "jpg", "exr": "exr", "deepexr": "exr", "png": "png", "tiff": "tif", "mtoa_shaders": "ass", # TODO: research what those last two should be "maya": "", } def get_renderer_prefix(self): prefix = super(RenderProductsArnold, self).get_renderer_prefix() merge_aovs = self._get_attr("defaultArnoldDriver.mergeAOVs") if not merge_aovs and "" not in prefix.lower(): # When Merge AOVs is disabled and token not present # then Arnold prepends / to the output path. # todo: It's untested what happens if AOV driver has an # an explicit override path prefix. prefix = "/" + prefix return prefix def get_multipart(self): multipart = False multilayer = bool(self._get_attr("defaultArnoldDriver.multipart")) merge_AOVs = bool(self._get_attr("defaultArnoldDriver.mergeAOVs")) if multilayer or merge_AOVs: multipart = True return multipart def _get_aov_render_products(self, aov, cameras=None): """Return all render products for the AOV""" products = [] aov_name = self._get_attr(aov, "name") ai_drivers = cmds.listConnections("{}.outputs".format(aov), source=True, destination=False, type="aiAOVDriver") or [] if not cameras: cameras = [ self.sanitize_camera_name( self.get_renderable_cameras()[0] ) ] for ai_driver in ai_drivers: colorspace = self._get_colorspace( ai_driver + ".colorManagement" ) # todo: check aiAOVDriver.prefix as it could have # a custom path prefix set for this driver # Skip Drivers set only for GUI # 0: GUI, 1: Batch, 2: GUI and Batch output_mode = self._get_attr(ai_driver, "outputMode") if output_mode == 0: # GUI only log.warning("%s has Output Mode set to GUI, " "skipping...", ai_driver) continue ai_translator = self._get_attr(ai_driver, "aiTranslator") try: ext = self.aiDriverExtension[ai_translator] except KeyError: raise AOVError( "Unrecognized arnold driver format " "for AOV - {}".format(aov_name) ) # If aov RGBA is selected, arnold will translate it to `beauty` name = aov_name if name == "RGBA": name = "beauty" # Support Arnold light groups for AOVs # Global AOV: When disabled the main layer is # not written: `{pass}` # All Light Groups: When enabled, a `{pass}_lgroups` file is # written and is always merged into a # single file # Light Groups List: When set, a product per light # group is written # e.g. {pass}_front, {pass}_rim global_aov = self._get_attr(aov, "globalAov") if global_aov: for camera in cameras: product = RenderProduct( productName=name, ext=ext, aov=aov_name, driver=ai_driver, multipart=self.multipart, camera=camera, colorspace=colorspace ) products.append(product) all_light_groups = self._get_attr(aov, "lightGroups") if all_light_groups: # All light groups is enabled. A single multipart # Render Product for camera in cameras: product = RenderProduct( productName=name + "_lgroups", ext=ext, aov=aov_name, driver=ai_driver, # Always multichannel output multipart=True, camera=camera, colorspace=colorspace ) products.append(product) else: value = self._get_attr(aov, "lightGroupsList") if not value: continue selected_light_groups = value.strip().split() for light_group in selected_light_groups: # Render Product per selected light group aov_light_group_name = "{}_{}".format(name, light_group) for camera in cameras: product = RenderProduct( productName=aov_light_group_name, aov=aov_name, driver=ai_driver, ext=ext, camera=camera, colorspace=colorspace ) products.append(product) return products def _get_colorspace(self, attribute): """Resolve colorspace from Arnold settings.""" def _view_transform(): preferences = lib.get_color_management_preferences() views_data = get_ocio_config_views(preferences["config"]) view_data = views_data[ "{}/{}".format(preferences["display"], preferences["view"]) ] return view_data["colorspace"] def _raw(): preferences = lib.get_color_management_preferences() return preferences["rendering_space"] resolved_values = { "Raw": _raw, "Use View Transform": _view_transform, # Default. Same as Maya Preferences. "Use Output Transform": lib.get_color_management_output_transform } return resolved_values[self._get_attr(attribute)]() def get_render_products(self): """Get all AOVs. See Also: :func:`ARenderProducts.get_render_products()` Raises: :class:`AOVError`: If AOV cannot be determined. """ if not cmds.ls("defaultArnoldRenderOptions", type="aiOptions"): # this occurs when Render Setting windows was not opened yet. In # such case there are no Arnold options created so query for AOVs # will fail. We terminate here as there are no AOVs specified then. # This state will most probably fail later on some Validator # anyway. return [] # check if camera token is in prefix. If so, and we have list of # renderable cameras, generate render product for each and every # of them. cameras = [ self.sanitize_camera_name(c) for c in self.get_renderable_cameras() ] default_ext = self._get_attr("defaultRenderGlobals.imfPluginKey") colorspace = self._get_colorspace( "defaultArnoldDriver.colorManagement" ) beauty_products = [ RenderProduct( productName="beauty", ext=default_ext, driver="defaultArnoldDriver", camera=camera, colorspace=colorspace ) for camera in cameras ] # AOVs > Legacy > Maya Render View > Mode aovs_enabled = bool( self._get_attr("defaultArnoldRenderOptions.aovMode") ) if not aovs_enabled: return beauty_products # Common > File Output > Merge AOVs or # We don't need to check for Merge AOVs due to overridden # `get_renderer_prefix()` behavior which forces has_renderpass_token = ( "" in self.layer_data.filePrefix.lower() ) if not has_renderpass_token: for product in beauty_products: product.multipart = True return beauty_products # AOVs are set to be rendered separately. We should expect # token in path. # handle aovs from references use_ref_aovs = self.render_instance.data.get( "useReferencedAovs", False) or False aovs = cmds.ls(type="aiAOV") if not use_ref_aovs: ref_aovs = cmds.ls(type="aiAOV", referencedNodes=True) aovs = list(set(aovs) - set(ref_aovs)) products = [] # Append the AOV products for aov in aovs: enabled = self._get_attr(aov, "enabled") if not enabled: continue # For now stick to the legacy output format. aov_products = self._get_aov_render_products(aov, cameras) products.extend(aov_products) if all(product.aov != "RGBA" for product in products): # Append default 'beauty' as this is arnolds default. # However, it is excluded whenever a RGBA pass is enabled. # For legibility add the beauty layer as first entry products += beauty_products # TODO: Output Denoising AOVs? return products class RenderProductsVray(ARenderProducts): """Expected files for V-Ray renderer. Notes: - "Disabled" animation incorrectly returns frames in filename - "Renumber Frames" is not supported Reference: vrayAddRenderElementImpl() in vrayCreateRenderElementsTab.mel """ # todo: detect whether rendering with V-Ray GPU + whether AOV is supported renderer = "vray" def get_multipart(self): multipart = False image_format = self._get_attr("vraySettings.imageFormatStr") if image_format == "exr (multichannel)": multipart = True return multipart def get_renderer_prefix(self): # type: () -> str """Get image prefix for V-Ray. This overrides :func:`ARenderProducts.get_renderer_prefix()` as we must add `` token manually. This is done only for non-multipart outputs, where `` token doesn't make sense. See also: :func:`ARenderProducts.get_renderer_prefix()` """ prefix = super(RenderProductsVray, self).get_renderer_prefix() if self.multipart: return prefix aov_separator = self._get_aov_separator() prefix = "{}{}".format(prefix, aov_separator) return prefix def _get_aov_separator(self): # type: () -> str """Return the V-Ray AOV/Render Elements separator""" return self._get_attr( "vraySettings.fileNameRenderElementSeparator" ) def _get_layer_data(self): # type: () -> LayerMetadata """Override to get vray specific extension.""" layer_data = super(RenderProductsVray, self)._get_layer_data() default_ext = self._get_attr("vraySettings.imageFormatStr") if default_ext in ["exr (multichannel)", "exr (deep)"]: default_ext = "exr" layer_data.defaultExt = default_ext layer_data.padding = self._get_attr("vraySettings.fileNamePadding") layer_data.aov_separator = self._get_aov_separator() return layer_data def get_render_products(self): """Get all AOVs. See Also: :func:`ARenderProducts.get_render_products()` """ if not cmds.ls("vraySettings", type="VRaySettingsNode"): # this occurs when Render Setting windows was not opened yet. In # such case there are no VRay options created so query for AOVs # will fail. We terminate here as there are no AOVs specified then. # This state will most probably fail later on some Validator # anyway. return [] cameras = [ self.sanitize_camera_name(c) for c in self.get_renderable_cameras() ] image_format_str = self._get_attr("vraySettings.imageFormatStr") default_ext = image_format_str if default_ext in {"exr (multichannel)", "exr (deep)"}: default_ext = "exr" colorspace = lib.get_color_management_output_transform() products = [] # add beauty as default when not disabled dont_save_rgb = self._get_attr("vraySettings.dontSaveRgbChannel") if not dont_save_rgb: for camera in cameras: products.append( RenderProduct( productName="", ext=default_ext, camera=camera, colorspace=colorspace, multipart=self.multipart ) ) # separate alpha file separate_alpha = self._get_attr("vraySettings.separateAlpha") if separate_alpha: for camera in cameras: products.append( RenderProduct( productName="Alpha", ext=default_ext, camera=camera, colorspace=colorspace, multipart=self.multipart ) ) if self.multipart: # AOVs are merged in m-channel file, only main layer is rendered return products # handle aovs from references use_ref_aovs = self.render_instance.data.get( "useReferencedAovs", False) or False # this will have list of all aovs no matter if they are coming from # reference or not. aov_types = ["VRayRenderElement", "VRayRenderElementSet"] aovs = cmds.ls(type=aov_types) if not use_ref_aovs: ref_aovs = cmds.ls(type=aov_types, referencedNodes=True) or [] aovs = list(set(aovs) - set(ref_aovs)) for aov in aovs: enabled = self._get_attr(aov, "enabled") if not enabled: continue class_type = self._get_attr(aov + ".vrayClassType") if class_type == "LightMixElement": # Special case which doesn't define a name by itself but # instead seems to output multiple Render Products, # specifically "Self_Illumination" and "Environment" product_names = ["Self_Illumination", "Environment"] for camera in cameras: for name in product_names: product = RenderProduct(productName=name, ext=default_ext, aov=aov, camera=camera, colorspace=colorspace) products.append(product) # Continue as we've processed this special case AOV continue aov_name = self._get_vray_aov_name(aov) for camera in cameras: product = RenderProduct( productName=aov_name, ext=default_ext, aov=aov, camera=camera, colorspace=colorspace ) products.append(product) return products def _get_vray_aov_attr(self, node, prefix): """Get value for attribute that starts with key in name V-Ray AOVs have attribute names that include the type of AOV in the attribute name, for example: - vray_filename_rawdiffuse - vray_filename_velocity - vray_name_gi - vray_explicit_name_extratex To simplify querying the "vray_filename" or "vray_name" attributes we just find the first attribute that has that particular "{prefix}_" in the attribute name. Args: node (str): AOV node name prefix (str): Prefix of the attribute name. Returns: Value of the attribute if it exists, else None """ attrs = cmds.listAttr(node, string="{}_*".format(prefix)) if not attrs: return None assert len(attrs) == 1, "Found more than one attribute: %s" % attrs attr = attrs[0] return self._get_attr(node, attr) def _get_vray_aov_name(self, node): """Get AOVs name from Vray. Args: node (str): aov node name. Returns: str: aov name. """ vray_explicit_name = self._get_vray_aov_attr(node, "vray_explicit_name") vray_filename = self._get_vray_aov_attr(node, "vray_filename") vray_name = self._get_vray_aov_attr(node, "vray_name") final_name = vray_explicit_name or vray_filename or vray_name or None class_type = self._get_attr(node, "vrayClassType") if not vray_explicit_name: # Explicit name takes precedence and overrides completely # otherwise add the connected node names to the special cases # Any namespace colon ':' gets replaced to underscore '_' # so we sanitize using `sanitize_camera_name` def _get_source_name(node, attr): """Return sanitized name of input connection to attribute""" plug = "{}.{}".format(node, attr) connections = cmds.listConnections(plug, source=True, destination=False) if connections: return self.sanitize_camera_name(connections[0]) if class_type == "MaterialSelectElement": # Name suffix is based on the connected material or set attrs = [ "vray_mtllist_mtlselect", "vray_mtl_mtlselect" ] for attribute in attrs: name = _get_source_name(node, attribute) if name: final_name += '_{}'.format(name) break else: log.warning("Material Select Element has no " "selected materials: %s", node) elif class_type == "ExtraTexElement": # Name suffix is based on the connected textures extratex_type = self._get_attr(node, "vray_type_extratex") attr = { 0: "vray_texture_extratex", 1: "vray_float_texture_extratex", 2: "vray_int_texture_extratex", }.get(extratex_type) name = _get_source_name(node, attr) if name: final_name += '_{}'.format(name) else: log.warning("Extratex Element has no incoming texture") assert final_name, "Output filename not defined for AOV: %s" % node return final_name class RenderProductsRedshift(ARenderProducts): """Expected files for Redshift renderer. Notes: - `get_files()` only supports rendering with frames, like "animation" Attributes: unmerged_aovs (list): Name of aovs that are not merged into resulting exr and we need them specified in Render Products output. """ renderer = "redshift" unmerged_aovs = {"Cryptomatte"} def get_files(self, product): # When outputting AOVs we need to replace Redshift specific AOV tokens # with Maya render tokens for generating file sequences. We validate to # a specific AOV fileprefix so we only need to account for one # replacement. if not product.multipart and product.driver: file_prefix = self._get_attr(product.driver + ".filePrefix") self.layer_data.filePrefix = file_prefix.replace( "/", "//" ) return super(RenderProductsRedshift, self).get_files(product) def get_multipart(self): # For Redshift we don't directly return upon forcing multilayer # due to some AOVs still being written into separate files, # like Cryptomatte. # AOVs are merged in multi-channel file multipart = False force_layer = bool( self._get_attr("redshiftOptions.exrForceMultilayer") ) if force_layer: multipart = True return multipart def get_renderer_prefix(self): """Get image prefix for Redshift. This overrides :func:`ARenderProducts.get_renderer_prefix()` as we must add `` token manually. This is done only for non-multipart outputs, where `` token doesn't make sense. See also: :func:`ARenderProducts.get_renderer_prefix()` """ prefix = super(RenderProductsRedshift, self).get_renderer_prefix() if self.multipart: return prefix separator = self.extract_separator(prefix) prefix = "{}{}".format(prefix, separator or "_") return prefix def get_render_products(self): """Get all AOVs. See Also: :func:`ARenderProducts.get_render_products()` """ if not cmds.ls("redshiftOptions", type="RedshiftOptions"): # this occurs when Render Setting windows was not opened yet. In # such case there are no Redshift options created so query for AOVs # will fail. We terminate here as there are no AOVs specified then. # This state will most probably fail later on some Validator # anyway. return [] cameras = [ self.sanitize_camera_name(c) for c in self.get_renderable_cameras() ] # Get Redshift Extension from image format image_format = self._get_attr("redshiftOptions.imageFormat") # integer ext = mel.eval("redshiftGetImageExtension(%i)" % image_format) use_ref_aovs = self.render_instance.data.get( "useReferencedAovs", False) or False aovs = cmds.ls(type="RedshiftAOV") if not use_ref_aovs: ref_aovs = cmds.ls(type="RedshiftAOV", referencedNodes=True) aovs = list(set(aovs) - set(ref_aovs)) products = [] light_groups_enabled = False has_beauty_aov = False colorspace = lib.get_color_management_output_transform() for aov in aovs: enabled = self._get_attr(aov, "enabled") if not enabled: continue aov_type = self._get_attr(aov, "aovType") if self.multipart and aov_type not in self.unmerged_aovs: continue # Any AOVs that still get processed, like Cryptomatte # by themselves are not multipart files. # Redshift skips rendering of masterlayer without AOV suffix # when a Beauty AOV is rendered. It overrides the main layer. if aov_type == "Beauty": has_beauty_aov = True aov_name = self._get_attr(aov, "name") # Support light Groups light_groups = [] if self._get_attr(aov, "supportsLightGroups"): all_light_groups = self._get_attr(aov, "allLightGroups") if all_light_groups: # All light groups is enabled light_groups = self._get_redshift_light_groups() else: value = self._get_attr(aov, "lightGroupList") # note: string value can return None when never set if value: selected_light_groups = value.strip().split() light_groups = selected_light_groups for light_group in light_groups: aov_light_group_name = "{}_{}".format(aov_name, light_group) for camera in cameras: product = RenderProduct( productName=aov_light_group_name, aov=aov_name, ext=ext, multipart=False, camera=camera, driver=aov, colorspace=colorspace) products.append(product) if light_groups: light_groups_enabled = True # Redshift AOV Light Select always renders the global AOV # even when light groups are present so we don't need to # exclude it when light groups are active for camera in cameras: product = RenderProduct(productName=aov_name, aov=aov_name, ext=ext, multipart=False, camera=camera, driver=aov, colorspace=colorspace) products.append(product) # When a Beauty AOV is added manually, it will be rendered as # 'Beauty_other' in file name and "standard" beauty will have # 'Beauty' in its name. When disabled, standard output will be # without `Beauty`. Except when using light groups. if light_groups_enabled: return products beauty_name = "BeautyAux" if has_beauty_aov else "" for camera in cameras: products.insert(0, RenderProduct(productName=beauty_name, ext=ext, multipart=self.multipart, camera=camera, colorspace=colorspace)) return products @staticmethod def _get_redshift_light_groups(): return sorted(mel.eval("redshiftAllAovLightGroups")) class RenderProductsRenderman(ARenderProducts): """Expected files for Renderman renderer. Warning: This is very rudimentary and needs more love and testing. """ renderer = "renderman" unmerged_aovs = {"PxrCryptomatte"} def get_multipart(self): # Implemented as display specific in "get_render_products". return False def get_render_products(self): """Get all AOVs. See Also: :func:`ARenderProducts.get_render_products()` """ from rfm2.api.displays import get_displays # noqa colorspace = lib.get_color_management_output_transform() cameras = [ self.sanitize_camera_name(c) for c in self.get_renderable_cameras() ] if not cameras: cameras = [ self.sanitize_camera_name( self.get_renderable_cameras()[0]) ] products = [] # NOTE: This is guessing extensions from renderman display types. # Some of them are just framebuffers, d_texture format can be # set in display setting. We set those now to None, but it # should be handled more gracefully. display_types = { "d_deepexr": "exr", "d_it": None, "d_null": None, "d_openexr": "exr", "d_png": "png", "d_pointcloud": "ptc", "d_targa": "tga", "d_texture": None, "d_tiff": "tif" } displays = get_displays(override_dst="render")["displays"] for name, display in displays.items(): enabled = display["params"]["enable"]["value"] if not enabled: continue # Skip display types not producing any file output. # Is there a better way to do it? if not display_types.get(display["driverNode"]["type"]): continue has_cryptomatte = cmds.ls(type=self.unmerged_aovs) matte_enabled = False if has_cryptomatte: for cryptomatte in has_cryptomatte: cryptomatte_aov = cryptomatte matte_name = "cryptomatte" rman_globals = cmds.listConnections(cryptomatte + ".message") if rman_globals: matte_enabled = True aov_name = name if aov_name == "rmanDefaultDisplay": aov_name = "beauty" extensions = display_types.get( display["driverNode"]["type"], "exr") for camera in cameras: # Create render product and set it as multipart only on # display types supporting it. In all other cases, Renderman # will create separate output per channel. if display["driverNode"]["type"] in ["d_openexr", "d_deepexr", "d_tiff"]: # noqa product = RenderProduct( productName=aov_name, ext=extensions, camera=camera, multipart=True, colorspace=colorspace ) if has_cryptomatte and matte_enabled: cryptomatte = RenderProduct( productName=matte_name, aov=cryptomatte_aov, ext=extensions, camera=camera, multipart=True, colorspace=colorspace ) else: # this code should handle the case where no multipart # capable format is selected. But since it involves # shady logic to determine what channel become what # lets not do that as all productions will use exr anyway. """ for channel in display['params']['displayChannels']['value']: # noqa product = RenderProduct( productName="{}_{}".format(aov_name, channel), ext=extensions, camera=camera, multipart=False ) """ raise UnsupportedImageFormatException( "Only exr, deep exr and tiff formats are supported.") products.append(product) if has_cryptomatte and matte_enabled: products.append(cryptomatte) return products def get_files(self, product): """Get expected files. """ files = super(RenderProductsRenderman, self).get_files(product) layer_data = self.layer_data new_files = [] resolved_image_dir = re.sub("", layer_data.sceneName, RENDERMAN_IMAGE_DIR, flags=re.IGNORECASE) # noqa: E501 resolved_image_dir = re.sub("", layer_data.layerName, resolved_image_dir, flags=re.IGNORECASE) # noqa: E501 for file in files: new_file = "{}/{}".format(resolved_image_dir, file) new_files.append(new_file) return new_files class RenderProductsMayaHardware(ARenderProducts): """Expected files for MayaHardware renderer.""" renderer = "mayahardware2" extensions = [ {"label": "JPEG", "index": 8, "extension": "jpg"}, {"label": "PNG", "index": 32, "extension": "png"}, {"label": "EXR(exr)", "index": 40, "extension": "exr"} ] def get_multipart(self): # MayaHardware does not support multipart EXRs. return False def _get_extension(self, value): result = None if isinstance(value, int): extensions = { extension["index"]: extension["extension"] for extension in self.extensions } try: result = extensions[value] except KeyError: raise NotImplementedError( "Could not find extension for {}".format(value) ) if isinstance(value, six.string_types): extensions = { extension["label"]: extension["extension"] for extension in self.extensions } try: result = extensions[value] except KeyError: raise NotImplementedError( "Could not find extension for {}".format(value) ) if not result: raise NotImplementedError( "Could not find extension for {}".format(value) ) return result def get_render_products(self): """Get all AOVs. See Also: :func:`ARenderProducts.get_render_products()` """ ext = self._get_extension( self._get_attr("defaultRenderGlobals.imageFormat") ) products = [] for cam in self.get_renderable_cameras(): product = RenderProduct( productName="beauty", ext=ext, camera=cam, colorspace=lib.get_color_management_output_transform() ) products.append(product) return products class AOVError(Exception): """Custom exception for determining AOVs.""" class UnsupportedRendererException(Exception): """Custom exception. Raised when requesting data from unsupported renderer. """ class UnsupportedImageFormatException(Exception): """Custom exception to report unsupported output image format.""" ================================================ FILE: openpype/hosts/maya/api/lib_rendersettings.py ================================================ # -*- coding: utf-8 -*- """Class for handling Render Settings.""" import six import sys from openpype.lib import Logger from openpype.settings import get_project_settings from openpype.pipeline import CreatorError, get_current_project_name from openpype.pipeline.context_tools import get_current_project_asset from openpype.hosts.maya.api.lib import reset_frame_range class RenderSettings(object): _image_prefix_nodes = { 'vray': 'vraySettings.fileNamePrefix', 'arnold': 'defaultRenderGlobals.imageFilePrefix', 'renderman': 'rmanGlobals.imageFileFormat', 'redshift': 'defaultRenderGlobals.imageFilePrefix', 'mayahardware2': 'defaultRenderGlobals.imageFilePrefix' } _aov_chars = { "dot": ".", "dash": "-", "underscore": "_" } log = Logger.get_logger("RenderSettings") @classmethod def get_image_prefix_attr(cls, renderer): return cls._image_prefix_nodes[renderer] @staticmethod def get_padding_attr(renderer): """Return attribute for renderer that defines frame padding amount""" if renderer == "vray": return "vraySettings.fileNamePadding" else: return "defaultRenderGlobals.extensionPadding" def __init__(self, project_settings=None): if not project_settings: project_settings = get_project_settings( get_current_project_name() ) render_settings = project_settings["maya"]["RenderSettings"] image_prefixes = { "vray": render_settings["vray_renderer"]["image_prefix"], "arnold": render_settings["arnold_renderer"]["image_prefix"], "renderman": render_settings["renderman_renderer"]["image_prefix"], "redshift": render_settings["redshift_renderer"]["image_prefix"] } # TODO probably should be stored to more explicit attribute # Renderman only renderman_settings = render_settings["renderman_renderer"] _image_dir = { "renderman": renderman_settings["image_dir"], "cryptomatte": renderman_settings["cryptomatte_dir"], "imageDisplay": renderman_settings["imageDisplay_dir"], "watermark": renderman_settings["watermark_dir"] } self._image_prefixes = image_prefixes self._image_dir = _image_dir self._project_settings = project_settings def set_default_renderer_settings(self, renderer=None): """Set basic settings based on renderer.""" # Not all hosts can import this module. from maya import cmds # noqa: F401 import maya.mel as mel # noqa: F401 if not renderer: renderer = cmds.getAttr( 'defaultRenderGlobals.currentRenderer').lower() asset_doc = get_current_project_asset() # project_settings/maya/create/CreateRender/aov_separator try: aov_separator = self._aov_chars[( self._project_settings["maya"] ["RenderSettings"] ["aov_separator"] )] except KeyError: aov_separator = "_" reset_frame = self._project_settings["maya"]["RenderSettings"]["reset_current_frame"] # noqa if reset_frame: start_frame = cmds.getAttr("defaultRenderGlobals.startFrame") cmds.currentTime(start_frame, edit=True) if renderer in self._image_prefix_nodes: prefix = self._image_prefixes[renderer] prefix = prefix.replace("{aov_separator}", aov_separator) cmds.setAttr(self._image_prefix_nodes[renderer], prefix, type="string") # noqa else: print("{0} isn't a supported renderer to autoset settings.".format(renderer)) # noqa # TODO: handle not having res values in the doc width = asset_doc["data"].get("resolutionWidth") height = asset_doc["data"].get("resolutionHeight") if renderer == "arnold": # set renderer settings for Arnold from project settings self._set_arnold_settings(width, height) if renderer == "vray": self._set_vray_settings(aov_separator, width, height) if renderer == "redshift": self._set_redshift_settings(width, height) mel.eval("redshiftUpdateActiveAovList") if renderer == "renderman": image_dir = self._image_dir["renderman"] cmds.setAttr("rmanGlobals.imageOutputDir", image_dir, type="string") self._set_renderman_settings(width, height, aov_separator) def _set_arnold_settings(self, width, height): """Sets settings for Arnold.""" from mtoa.core import createOptions # noqa from mtoa.aovs import AOVInterface # noqa # Not all hosts can import this module. from maya import cmds # noqa: F401 import maya.mel as mel # noqa: F401 createOptions() render_settings = self._project_settings["maya"]["RenderSettings"] arnold_render_presets = render_settings["arnold_renderer"] # noqa # Force resetting settings and AOV list to avoid having to deal with # AOV checking logic, for now. # This is a work around because the standard # function to revert render settings does not reset AOVs list in MtoA # Fetch current aovs in case there's any. current_aovs = AOVInterface().getAOVs() remove_aovs = render_settings["remove_aovs"] if remove_aovs: # Remove fetched AOVs AOVInterface().removeAOVs(current_aovs) mel.eval("unifiedRenderGlobalsRevertToDefault") img_ext = arnold_render_presets["image_format"] img_prefix = arnold_render_presets["image_prefix"] aovs = arnold_render_presets["aov_list"] img_tiled = arnold_render_presets["tiled"] multi_exr = arnold_render_presets["multilayer_exr"] additional_options = arnold_render_presets["additional_options"] for aov in aovs: if aov in current_aovs and not remove_aovs: continue AOVInterface('defaultArnoldRenderOptions').addAOV(aov) cmds.setAttr("defaultResolution.width", width) cmds.setAttr("defaultResolution.height", height) self._set_global_output_settings() cmds.setAttr( "defaultRenderGlobals.imageFilePrefix", img_prefix, type="string") cmds.setAttr( "defaultArnoldDriver.ai_translator", img_ext, type="string") cmds.setAttr( "defaultArnoldDriver.exrTiled", img_tiled) cmds.setAttr( "defaultArnoldDriver.mergeAOVs", multi_exr) self._additional_attribs_setter(additional_options) reset_frame_range(playback=False, fps=False, render=True) def _set_redshift_settings(self, width, height): """Sets settings for Redshift.""" # Not all hosts can import this module. from maya import cmds # noqa: F401 import maya.mel as mel # noqa: F401 render_settings = self._project_settings["maya"]["RenderSettings"] redshift_render_presets = render_settings["redshift_renderer"] remove_aovs = render_settings["remove_aovs"] all_rs_aovs = cmds.ls(type='RedshiftAOV') if remove_aovs: for aov in all_rs_aovs: enabled = cmds.getAttr("{}.enabled".format(aov)) if enabled: cmds.delete(aov) redshift_aovs = redshift_render_presets["aov_list"] # list all the aovs all_rs_aovs = cmds.ls(type='RedshiftAOV') for rs_aov in redshift_aovs: rs_layername = "rsAov_{}".format(rs_aov.replace(" ", "")) if rs_layername in all_rs_aovs: continue cmds.rsCreateAov(type=rs_aov) # update the AOV list mel.eval("redshiftUpdateActiveAovList") rs_p_engine = redshift_render_presets["primary_gi_engine"] rs_s_engine = redshift_render_presets["secondary_gi_engine"] if int(rs_p_engine) or int(rs_s_engine) != 0: cmds.setAttr("redshiftOptions.GIEnabled", 1) if int(rs_p_engine) == 0: # reset the primary GI Engine as default cmds.setAttr("redshiftOptions.primaryGIEngine", 4) if int(rs_s_engine) == 0: # reset the secondary GI Engine as default cmds.setAttr("redshiftOptions.secondaryGIEngine", 2) else: cmds.setAttr("redshiftOptions.GIEnabled", 0) cmds.setAttr("redshiftOptions.primaryGIEngine", int(rs_p_engine)) cmds.setAttr("redshiftOptions.secondaryGIEngine", int(rs_s_engine)) additional_options = redshift_render_presets["additional_options"] ext = redshift_render_presets["image_format"] img_exts = ["iff", "exr", "tif", "png", "tga", "jpg"] img_ext = img_exts.index(ext) self._set_global_output_settings() cmds.setAttr("redshiftOptions.imageFormat", img_ext) cmds.setAttr("defaultResolution.width", width) cmds.setAttr("defaultResolution.height", height) self._additional_attribs_setter(additional_options) def _set_renderman_settings(self, width, height, aov_separator): """Sets settings for Renderman""" # Not all hosts can import this module. from maya import cmds # noqa: F401 import maya.mel as mel # noqa: F401 rman_render_presets = ( self._project_settings ["maya"] ["RenderSettings"] ["renderman_renderer"] ) display_filters = rman_render_presets["display_filters"] d_filters_number = len(display_filters) for i in range(d_filters_number): d_node = cmds.ls(typ=display_filters[i]) if len(d_node) > 0: filter_nodes = d_node[0] else: filter_nodes = cmds.createNode(display_filters[i]) cmds.connectAttr(filter_nodes + ".message", "rmanGlobals.displayFilters[%i]" % i, force=True) if filter_nodes.startswith("PxrImageDisplayFilter"): imageDisplay_dir = self._image_dir["imageDisplay"] imageDisplay_dir = imageDisplay_dir.replace("{aov_separator}", aov_separator) cmds.setAttr(filter_nodes + ".filename", imageDisplay_dir, type="string") sample_filters = rman_render_presets["sample_filters"] s_filters_number = len(sample_filters) for n in range(s_filters_number): s_node = cmds.ls(typ=sample_filters[n]) if len(s_node) > 0: filter_nodes = s_node[0] else: filter_nodes = cmds.createNode(sample_filters[n]) cmds.connectAttr(filter_nodes + ".message", "rmanGlobals.sampleFilters[%i]" % n, force=True) if filter_nodes.startswith("PxrCryptomatte"): matte_dir = self._image_dir["cryptomatte"] matte_dir = matte_dir.replace("{aov_separator}", aov_separator) cmds.setAttr(filter_nodes + ".filename", matte_dir, type="string") elif filter_nodes.startswith("PxrWatermarkFilter"): watermark_dir = self._image_dir["watermark"] watermark_dir = watermark_dir.replace("{aov_separator}", aov_separator) cmds.setAttr(filter_nodes + ".filename", watermark_dir, type="string") additional_options = rman_render_presets["additional_options"] self._set_global_output_settings() cmds.setAttr("defaultResolution.width", width) cmds.setAttr("defaultResolution.height", height) self._additional_attribs_setter(additional_options) def _set_vray_settings(self, aov_separator, width, height): # type: (str, int, int) -> None """Sets important settings for Vray.""" # Not all hosts can import this module. from maya import cmds # noqa: F401 import maya.mel as mel # noqa: F401 settings = cmds.ls(type="VRaySettingsNode") node = settings[0] if settings else cmds.createNode("VRaySettingsNode") render_settings = self._project_settings["maya"]["RenderSettings"] vray_render_presets = render_settings["vray_renderer"] # vrayRenderElement remove_aovs = render_settings["remove_aovs"] all_vray_aovs = cmds.ls(type='VRayRenderElement') lightSelect_aovs = cmds.ls(type='VRayRenderElementSet') if remove_aovs: for aov in all_vray_aovs: # remove all aovs except LightSelect enabled = cmds.getAttr("{}.enabled".format(aov)) if enabled: cmds.delete(aov) # remove LightSelect for light_aovs in lightSelect_aovs: light_enabled = cmds.getAttr("{}.enabled".format(light_aovs)) if light_enabled: cmds.delete(lightSelect_aovs) vray_aovs = vray_render_presets["aov_list"] for renderlayer in vray_aovs: renderElement = "vrayAddRenderElement {}".format(renderlayer) RE_name = mel.eval(renderElement) # if there is more than one same render element if RE_name.endswith("1"): cmds.delete(RE_name) # Set aov separator # First we need to explicitly set the UI items in Render Settings # because that is also what V-Ray updates to when that Render Settings # UI did initialize before and refreshes again. MENU = "vrayRenderElementSeparator" if cmds.optionMenuGrp(MENU, query=True, exists=True): items = cmds.optionMenuGrp(MENU, query=True, ill=True) separators = [cmds.menuItem(i, query=True, label=True) for i in items] # noqa: E501 try: sep_idx = separators.index(aov_separator) except ValueError: six.reraise( CreatorError, CreatorError( "AOV character {} not in {}".format( aov_separator, separators)), sys.exc_info()[2]) cmds.optionMenuGrp(MENU, edit=True, select=sep_idx + 1) # Set the render element attribute as string. This is also what V-Ray # sets whenever the `vrayRenderElementSeparator` menu items switch cmds.setAttr( "{}.fileNameRenderElementSeparator".format(node), aov_separator, type="string" ) # Set render file format to exr ext = vray_render_presets["image_format"] cmds.setAttr("{}.imageFormatStr".format(node), ext, type="string") # animType cmds.setAttr("{}.animType".format(node), 1) # resolution cmds.setAttr("{}.width".format(node), width) cmds.setAttr("{}.height".format(node), height) additional_options = vray_render_presets["additional_options"] self._additional_attribs_setter(additional_options) @staticmethod def _set_global_output_settings(): # Not all hosts can import this module. from maya import cmds # noqa: F401 import maya.mel as mel # noqa: F401 # enable animation cmds.setAttr("defaultRenderGlobals.outFormatControl", 0) cmds.setAttr("defaultRenderGlobals.animation", 1) cmds.setAttr("defaultRenderGlobals.putFrameBeforeExt", 1) cmds.setAttr("defaultRenderGlobals.extensionPadding", 4) def _additional_attribs_setter(self, additional_attribs): # Not all hosts can import this module. from maya import cmds # noqa: F401 import maya.mel as mel # noqa: F401 for item in additional_attribs: attribute, value = item attribute = str(attribute) # ensure str conversion from settings attribute_type = cmds.getAttr(attribute, type=True) if attribute_type in {"long", "bool"}: cmds.setAttr(attribute, int(value)) elif attribute_type == "string": cmds.setAttr(attribute, str(value), type="string") elif attribute_type in {"double", "doubleAngle", "doubleLinear"}: cmds.setAttr(attribute, float(value)) else: self.log.error( "Attribute {attribute} can not be set due to unsupported " "type: {attribute_type}".format( attribute=attribute, attribute_type=attribute_type) ) ================================================ FILE: openpype/hosts/maya/api/lib_rendersetup.py ================================================ # -*- coding: utf-8 -*- """Code to get attributes from render layer without switching to it. https://github.com/Colorbleed/colorbleed-config/blob/acre/colorbleed/maya/lib_rendersetup.py Credits: Roy Nieterau (BigRoy) / Colorbleed Modified for use in OpenPype """ from maya import cmds import maya.api.OpenMaya as om import logging import maya.app.renderSetup.model.utils as utils from maya.app.renderSetup.model import renderSetup from maya.app.renderSetup.model.override import ( AbsOverride, RelOverride, UniqueOverride ) from openpype.hosts.maya.api.lib import get_attribute EXACT_MATCH = 0 PARENT_MATCH = 1 CLIENT_MATCH = 2 DEFAULT_RENDER_LAYER = "defaultRenderLayer" log = logging.getLogger(__name__) def get_rendersetup_layer(layer): """Return render setup layer name. This also converts names from legacy renderLayer node name to render setup name. Note: `defaultRenderLayer` is not a renderSetupLayer node but it is however the valid layer name for Render Setup - so we return that as is. Example: >>> for legacy_layer in cmds.ls(type="renderLayer"): >>> layer = get_rendersetup_layer(legacy_layer) Returns: str or None: Returns renderSetupLayer node name if `layer` is a valid layer name in legacy renderlayers or render setup layers. Returns None if the layer can't be found or Render Setup is currently disabled. """ if layer == DEFAULT_RENDER_LAYER: # defaultRenderLayer doesn't have a `renderSetupLayer` return layer if not cmds.mayaHasRenderSetup(): return None if not cmds.objExists(layer): return None if cmds.nodeType(layer) == "renderSetupLayer": return layer # By default Render Setup renames the legacy renderlayer # to `rs_` but lets not rely on that as the # layer node can be renamed manually connections = cmds.listConnections(layer + ".message", type="renderSetupLayer", exactType=True, source=False, destination=True, plugs=True) or [] return next((conn.split(".", 1)[0] for conn in connections if conn.endswith(".legacyRenderLayer")), None) def get_attr_in_layer(node_attr, layer): """Return attribute value in Render Setup layer. This will only work for attributes which can be retrieved with `maya.cmds.getAttr` and for which Relative and Absolute overrides are applicable. Examples: >>> get_attr_in_layer("defaultResolution.width", layer="layer1") >>> get_attr_in_layer("defaultRenderGlobals.startFrame", layer="layer") >>> get_attr_in_layer("transform.translate", layer="layer3") Args: attr (str): attribute name as 'node.attribute' layer (str): layer name Returns: object: attribute value in layer """ def _layer_needs_update(layer): """Return whether layer needs updating.""" # Use `getattr` as e.g. DEFAULT_RENDER_LAYER does not have # the attribute return getattr(layer, "needsMembershipUpdate", False) or \ getattr(layer, "needsApplyUpdate", False) def get_default_layer_value(node_attr_): """Return attribute value in `DEFAULT_RENDER_LAYER`.""" inputs = cmds.listConnections(node_attr_, source=True, destination=False, # We want to skip conversion nodes since # an override to `endFrame` could have # a `unitToTimeConversion` node # in-between skipConversionNodes=True, type="applyOverride") or [] if inputs: override = inputs[0] history_overrides = cmds.ls(cmds.listHistory(override, pruneDagObjects=True), type="applyOverride") node = history_overrides[-1] if history_overrides else override node_attr_ = node + ".original" return get_attribute(node_attr_, asString=True) layer = get_rendersetup_layer(layer) rs = renderSetup.instance() current_layer = rs.getVisibleRenderLayer() if current_layer.name() == layer: # Ensure layer is up-to-date if _layer_needs_update(current_layer): try: rs.switchToLayer(current_layer) except RuntimeError: # Some cases can cause errors on switching # the first time with Render Setup layers # e.g. different overrides to compounds # and its children plugs. So we just force # it another time. If it then still fails # we will let it error out. rs.switchToLayer(current_layer) return get_attribute(node_attr, asString=True) overrides = get_attr_overrides(node_attr, layer) default_layer_value = get_default_layer_value(node_attr) if not overrides: return default_layer_value value = default_layer_value for match, layer_override, index in overrides: if isinstance(layer_override, AbsOverride): # Absolute override value = get_attribute(layer_override.name() + ".attrValue") if match == EXACT_MATCH: # value = value pass elif match == PARENT_MATCH: value = value[index] elif match == CLIENT_MATCH: value[index] = value elif isinstance(layer_override, RelOverride): # Relative override # Value = Original * Multiply + Offset multiply = get_attribute(layer_override.name() + ".multiply") offset = get_attribute(layer_override.name() + ".offset") if match == EXACT_MATCH: value = value * multiply + offset elif match == PARENT_MATCH: value = value * multiply[index] + offset[index] elif match == CLIENT_MATCH: value[index] = value[index] * multiply + offset else: raise TypeError("Unsupported override: %s" % layer_override) return value def get_attr_overrides(node_attr, layer, skip_disabled=True, skip_local_render=True, stop_at_absolute_override=True): """Return all Overrides applicable to the attribute. Overrides are returned as a 3-tuple: (Match, Override, Index) Match: This is any of EXACT_MATCH, PARENT_MATCH, CLIENT_MATCH and defines whether the override is exactly on the plug, on the parent or on a child plug. Override: This is the RenderSetup Override instance. Index: This is the Plug index under the parent or for the child that matches. The EXACT_MATCH index will always be None. For PARENT_MATCH the index is which index the plug is under the parent plug. For CLIENT_MATCH the index is which child index matches the plug. Args: node_attr (str): attribute name as 'node.attribute' layer (str): layer name skip_disabled (bool): exclude disabled overrides skip_local_render (bool): exclude overrides marked as local render. stop_at_absolute_override: exclude overrides prior to the last absolute override as they have no influence on the resulting value. Returns: list: Ordered Overrides in order of strength """ def get_mplug_children(plug): """Return children MPlugs of compound `MPlug`.""" children = [] if plug.isCompound: for i in range(plug.numChildren()): children.append(plug.child(i)) return children def get_mplug_names(mplug): """Return long and short name of `MPlug`.""" long_name = mplug.partialName(useLongNames=True) short_name = mplug.partialName(useLongNames=False) return {long_name, short_name} def iter_override_targets(override): try: for target in override._targets(): yield target except AssertionError: # Workaround: There is a bug where the private `_targets()` method # fails on some attribute plugs. For example overrides # to the defaultRenderGlobals.endFrame # (Tested in Maya 2020.2) log.debug("Workaround for %s" % override) from maya.app.renderSetup.common.utils import findPlug attr = override.attributeName() if isinstance(override, UniqueOverride): node = override.targetNodeName() yield findPlug(node, attr) else: nodes = override.parent().selector().nodes() for node in nodes: if cmds.attributeQuery(attr, node=node, exists=True): yield findPlug(node, attr) # Get the MPlug for the node.attr sel = om.MSelectionList() sel.add(node_attr) plug = sel.getPlug(0) layer = get_rendersetup_layer(layer) if layer == DEFAULT_RENDER_LAYER: # DEFAULT_RENDER_LAYER will never have overrides # since it's the default layer return [] rs_layer = renderSetup.instance().getRenderLayer(layer) if rs_layer is None: # Renderlayer does not exist return # Get any parent or children plugs as we also # want to include them in the attribute match # for overrides parent = plug.parent() if plug.isChild else None parent_index = None if parent: parent_index = get_mplug_children(parent).index(plug) children = get_mplug_children(plug) # Create lookup for the attribute by both long # and short names attr_names = get_mplug_names(plug) for child in children: attr_names.update(get_mplug_names(child)) if parent: attr_names.update(get_mplug_names(parent)) # Get all overrides of the layer # And find those that are relevant to the attribute plug_overrides = [] # Iterate over the overrides in reverse so we get the last # overrides first and can "break" whenever an absolute # override is reached layer_overrides = list(utils.getOverridesRecursive(rs_layer)) for layer_override in reversed(layer_overrides): if skip_disabled and not layer_override.isEnabled(): # Ignore disabled overrides continue if skip_local_render and layer_override.isLocalRender(): continue # The targets list can be very large so we'll do # a quick filter by attribute name to detect whether # it matches the attribute name, or its parent or child if layer_override.attributeName() not in attr_names: continue override_match = None for override_plug in iter_override_targets(layer_override): override_match = None if plug == override_plug: override_match = (EXACT_MATCH, layer_override, None) elif parent and override_plug == parent: override_match = (PARENT_MATCH, layer_override, parent_index) elif children and override_plug in children: child_index = children.index(override_plug) override_match = (CLIENT_MATCH, layer_override, child_index) if override_match: plug_overrides.append(override_match) break if ( override_match and stop_at_absolute_override and isinstance(layer_override, AbsOverride) and # When the override is only on a child plug then it doesn't # override the entire value so we not stop at this override not override_match[0] == CLIENT_MATCH ): # If override is absolute override, then BREAK out # of parent loop we don't need to look any further as # this is the absolute override break return reversed(plug_overrides) def get_shader_in_layer(node, layer): """Return the assigned shader in a renderlayer without switching layers. This has been developed and tested for Legacy Renderlayers and *not* for Render Setup. Note: This will also return the shader for any face assignments, however it will *not* return the components they are assigned to. This could be implemented, but since Maya's renderlayers are famous for breaking with face assignments there has been no need for this function to support that. Returns: list: The list of assigned shaders in the given layer. """ def _get_connected_shader(plug): """Return current shader""" return cmds.listConnections(plug, source=False, destination=True, plugs=False, connections=False, type="shadingEngine") or [] # We check the instObjGroups (shader connection) for layer overrides. plug = node + ".instObjGroups" # Ignore complex query if we're in the layer anyway (optimization) current_layer = cmds.editRenderLayerGlobals(query=True, currentRenderLayer=True) if layer == current_layer: return _get_connected_shader(plug) connections = cmds.listConnections(plug, plugs=True, source=False, destination=True, type="renderLayer") or [] connections = filter(lambda x: x.endswith(".outPlug"), connections) if not connections: # If no overrides anywhere on the shader, just get the current shader return _get_connected_shader(plug) def _get_override(connections, layer): """Return the overridden connection for that layer in connections""" # If there's an override on that layer, return that. for connection in connections: if (connection.startswith(layer + ".outAdjustments") and connection.endswith(".outPlug")): # This is a shader override on that layer so get the shader # connected to .outValue of the .outAdjustment[i] out_adjustment = connection.rsplit(".", 1)[0] connection_attr = out_adjustment + ".outValue" override = cmds.listConnections(connection_attr) or [] return override override_shader = _get_override(connections, layer) if override_shader is not None: return override_shader else: # Get the override for "defaultRenderLayer" (=masterLayer) return _get_override(connections, layer="defaultRenderLayer") ================================================ FILE: openpype/hosts/maya/api/menu.py ================================================ import os import logging from functools import partial from qtpy import QtWidgets, QtGui import maya.utils import maya.cmds as cmds from openpype.pipeline import ( get_current_asset_name, get_current_task_name ) from openpype.pipeline.workfile import BuildWorkfile from openpype.tools.utils import host_tools from openpype.hosts.maya.api import lib, lib_rendersettings from .lib import get_main_window, IS_HEADLESS from ..tools import show_look_assigner from .workfile_template_builder import ( create_placeholder, update_placeholder, build_workfile_template, update_workfile_template, ) log = logging.getLogger(__name__) MENU_NAME = "op_maya_menu" def _get_menu(menu_name=None): """Return the menu instance if it currently exists in Maya""" if menu_name is None: menu_name = MENU_NAME widgets = {w.objectName(): w for w in QtWidgets.QApplication.allWidgets()} return widgets.get(menu_name) def get_context_label(): return "{}, {}".format( get_current_asset_name(), get_current_task_name() ) def install(project_settings): if cmds.about(batch=True): log.info("Skipping openpype.menu initialization in batch mode..") return def add_menu(): pyblish_icon = host_tools.get_pyblish_icon() parent_widget = get_main_window() cmds.menu( MENU_NAME, label=os.environ.get("AVALON_LABEL") or "OpenPype", tearOff=True, parent="MayaWindow" ) # Create context menu cmds.menuItem( "currentContext", label=get_context_label(), parent=MENU_NAME, enable=False ) cmds.setParent("..", menu=True) cmds.menuItem(divider=True) cmds.menuItem( "Create...", command=lambda *args: host_tools.show_publisher( parent=parent_widget, tab="create" ) ) cmds.menuItem( "Load...", command=lambda *args: host_tools.show_loader( parent=parent_widget, use_context=True ) ) cmds.menuItem( "Publish...", command=lambda *args: host_tools.show_publisher( parent=parent_widget, tab="publish" ), image=pyblish_icon ) cmds.menuItem( "Manage...", command=lambda *args: host_tools.show_scene_inventory( parent=parent_widget ) ) cmds.menuItem( "Library...", command=lambda *args: host_tools.show_library_loader( parent=parent_widget ) ) cmds.menuItem(divider=True) cmds.menuItem( "Work Files...", command=lambda *args: host_tools.show_workfiles( parent=parent_widget ), ) cmds.menuItem( "Set Frame Range", command=lambda *args: lib.reset_frame_range() ) cmds.menuItem( "Set Resolution", command=lambda *args: lib.reset_scene_resolution() ) cmds.menuItem( "Set Colorspace", command=lambda *args: lib.set_colorspace(), ) cmds.menuItem( "Set Render Settings", command=lambda *args: lib_rendersettings.RenderSettings().set_default_renderer_settings() # noqa ) cmds.menuItem(divider=True, parent=MENU_NAME) cmds.menuItem( "Build First Workfile", parent=MENU_NAME, command=lambda *args: BuildWorkfile().process() ) cmds.menuItem( "Look assigner...", command=lambda *args: show_look_assigner( parent_widget ) ) cmds.menuItem( "Experimental tools...", command=lambda *args: host_tools.show_experimental_tools_dialog( parent_widget ) ) builder_menu = cmds.menuItem( "Template Builder", subMenu=True, tearOff=True, parent=MENU_NAME ) cmds.menuItem( "Create Placeholder", parent=builder_menu, command=create_placeholder ) cmds.menuItem( "Update Placeholder", parent=builder_menu, command=update_placeholder ) cmds.menuItem( "Build Workfile from template", parent=builder_menu, command=build_workfile_template ) cmds.menuItem( "Update Workfile from template", parent=builder_menu, command=update_workfile_template ) cmds.setParent(MENU_NAME, menu=True) def add_scripts_menu(project_settings): try: import scriptsmenu.launchformaya as launchformaya except ImportError: log.warning( "Skipping studio.menu install, because " "'scriptsmenu' module seems unavailable." ) return config = project_settings["maya"]["scriptsmenu"]["definition"] _menu = project_settings["maya"]["scriptsmenu"]["name"] if not config: log.warning("Skipping studio menu, no definition found.") return # run the launcher for Maya menu studio_menu = launchformaya.main( title=_menu.title(), objectName=_menu.title().lower().replace(" ", "_") ) # apply configuration studio_menu.build_from_configuration(studio_menu, config) # Allow time for uninstallation to finish. # We use Maya's executeDeferred instead of QTimer.singleShot # so that it only gets called after Maya UI has initialized too. # This is crucial with Maya 2020+ which initializes without UI # first as a QCoreApplication maya.utils.executeDeferred(add_menu) cmds.evalDeferred(partial(add_scripts_menu, project_settings), lowestPriority=True) def uninstall(): menu = _get_menu() if menu: log.info("Attempting to uninstall ...") try: menu.deleteLater() del menu except Exception as e: log.error(e) def popup(): """Pop-up the existing menu near the mouse cursor.""" menu = _get_menu() cursor = QtGui.QCursor() point = cursor.pos() menu.exec_(point) def update_menu_task_label(): """Update the task label in Avalon menu to current session""" if IS_HEADLESS: return object_name = "{}|currentContext".format(MENU_NAME) if not cmds.menuItem(object_name, query=True, exists=True): log.warning("Can't find menuItem: {}".format(object_name)) return label = get_context_label() cmds.menuItem(object_name, edit=True, label=label) ================================================ FILE: openpype/hosts/maya/api/pipeline.py ================================================ import json import base64 import os import errno import logging import contextlib import shutil from maya import utils, cmds, OpenMaya import maya.api.OpenMaya as om import pyblish.api from openpype.settings import get_project_settings from openpype.host import ( HostBase, IWorkfileHost, ILoadHost, IPublishHost, HostDirmap, ) from openpype.tools.utils import host_tools from openpype.tools.workfiles.lock_dialog import WorkfileLockDialog from openpype.lib import ( register_event_callback, emit_event ) from openpype.pipeline import ( legacy_io, get_current_project_name, register_loader_plugin_path, register_inventory_action_path, register_creator_plugin_path, deregister_loader_plugin_path, deregister_inventory_action_path, deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) from openpype.pipeline.load import any_outdated_containers from openpype.pipeline.workfile.lock_workfile import ( create_workfile_lock, remove_workfile_lock, is_workfile_locked, is_workfile_lock_enabled ) from openpype.hosts.maya import MAYA_ROOT_DIR from openpype.hosts.maya.lib import create_workspace_mel from . import menu, lib from .workfile_template_builder import MayaPlaceholderLoadPlugin from .workio import ( open_file, save_file, file_extensions, has_unsaved_changes, work_root, current_file ) log = logging.getLogger("openpype.hosts.maya") PLUGINS_DIR = os.path.join(MAYA_ROOT_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") AVALON_CONTAINERS = ":AVALON_CONTAINERS" class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): name = "maya" def __init__(self): super(MayaHost, self).__init__() self._op_events = {} def install(self): project_name = get_current_project_name() project_settings = get_project_settings(project_name) # process path mapping dirmap_processor = MayaDirmap("maya", project_name, project_settings) dirmap_processor.process_dirmap() pyblish.api.register_plugin_path(PUBLISH_PATH) pyblish.api.register_host("mayabatch") pyblish.api.register_host("mayapy") pyblish.api.register_host("maya") register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) register_inventory_action_path(INVENTORY_PATH) self.log.info(PUBLISH_PATH) self.log.info("Installing callbacks ... ") register_event_callback("init", on_init) _set_project() if lib.IS_HEADLESS: self.log.info(( "Running in headless mode, skipping Maya save/open/new" " callback installation.." )) return self._register_callbacks() menu.install(project_settings) register_event_callback("save", on_save) register_event_callback("open", on_open) register_event_callback("new", on_new) register_event_callback("before.save", on_before_save) register_event_callback("after.save", on_after_save) register_event_callback("before.close", on_before_close) register_event_callback("before.file.open", before_file_open) register_event_callback("taskChanged", on_task_changed) register_event_callback("workfile.open.before", before_workfile_open) register_event_callback("workfile.save.before", before_workfile_save) register_event_callback( "workfile.save.before", workfile_save_before_xgen ) register_event_callback("workfile.save.after", after_workfile_save) def open_workfile(self, filepath): return open_file(filepath) def save_workfile(self, filepath=None): return save_file(filepath) def work_root(self, session): return work_root(session) def get_current_workfile(self): return current_file() def workfile_has_unsaved_changes(self): return has_unsaved_changes() def get_workfile_extensions(self): return file_extensions() def get_containers(self): return ls() def get_workfile_build_placeholder_plugins(self): return [ MayaPlaceholderLoadPlugin ] @contextlib.contextmanager def maintained_selection(self): with lib.maintained_selection(): yield def get_context_data(self): data = cmds.fileInfo("OpenPypeContext", query=True) if not data: return {} data = data[0] # Maya seems to return a list decoded = base64.b64decode(data).decode("utf-8") return json.loads(decoded) def update_context_data(self, data, changes): json_str = json.dumps(data) encoded = base64.b64encode(json_str.encode("utf-8")) return cmds.fileInfo("OpenPypeContext", encoded) def _register_callbacks(self): for handler, event in self._op_events.copy().items(): if event is None: continue try: OpenMaya.MMessage.removeCallback(event) self._op_events[handler] = None except RuntimeError as exc: self.log.info(exc) self._op_events[_on_scene_save] = OpenMaya.MSceneMessage.addCallback( OpenMaya.MSceneMessage.kBeforeSave, _on_scene_save ) self._op_events[_after_scene_save] = ( OpenMaya.MSceneMessage.addCallback( OpenMaya.MSceneMessage.kAfterSave, _after_scene_save ) ) self._op_events[_before_scene_save] = ( OpenMaya.MSceneMessage.addCheckCallback( OpenMaya.MSceneMessage.kBeforeSaveCheck, _before_scene_save ) ) self._op_events[_on_scene_new] = OpenMaya.MSceneMessage.addCallback( OpenMaya.MSceneMessage.kAfterNew, _on_scene_new ) self._op_events[_on_maya_initialized] = ( OpenMaya.MSceneMessage.addCallback( OpenMaya.MSceneMessage.kMayaInitialized, _on_maya_initialized ) ) self._op_events[_on_scene_open] = ( OpenMaya.MSceneMessage.addCallback( OpenMaya.MSceneMessage.kAfterOpen, _on_scene_open ) ) self._op_events[_before_scene_open] = ( OpenMaya.MSceneMessage.addCallback( OpenMaya.MSceneMessage.kBeforeOpen, _before_scene_open ) ) self._op_events[_before_close_maya] = ( OpenMaya.MSceneMessage.addCallback( OpenMaya.MSceneMessage.kMayaExiting, _before_close_maya ) ) self.log.info("Installed event handler _on_scene_save..") self.log.info("Installed event handler _before_scene_save..") self.log.info("Installed event handler _on_after_save..") self.log.info("Installed event handler _on_scene_new..") self.log.info("Installed event handler _on_maya_initialized..") self.log.info("Installed event handler _on_scene_open..") self.log.info("Installed event handler _check_lock_file..") self.log.info("Installed event handler _before_close_maya..") def _set_project(): """Sets the maya project to the current Session's work directory. Returns: None """ workdir = legacy_io.Session["AVALON_WORKDIR"] try: os.makedirs(workdir) except OSError as e: # An already existing working directory is fine. if e.errno == errno.EEXIST: pass else: raise cmds.workspace(workdir, openWorkspace=True) def _on_maya_initialized(*args): emit_event("init") if cmds.about(batch=True): log.warning("Running batch mode ...") return # Keep reference to the main Window, once a main window exists. lib.get_main_window() def _on_scene_new(*args): emit_event("new") def _after_scene_save(*arg): emit_event("after.save") def _on_scene_save(*args): emit_event("save") def _on_scene_open(*args): emit_event("open") def _before_close_maya(*args): emit_event("before.close") def _before_scene_open(*args): emit_event("before.file.open") def _before_scene_save(return_code, client_data): # Default to allowing the action. Registered # callbacks can optionally set this to False # in order to block the operation. OpenMaya.MScriptUtil.setBool(return_code, True) emit_event( "before.save", {"return_code": return_code} ) def _remove_workfile_lock(): """Remove workfile lock on current file""" if not handle_workfile_locks(): return filepath = current_file() log.info("Removing lock on current file {}...".format(filepath)) if filepath: remove_workfile_lock(filepath) def handle_workfile_locks(): if lib.IS_HEADLESS: return False project_name = get_current_project_name() return is_workfile_lock_enabled(MayaHost.name, project_name) def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) pyblish.api.deregister_host("mayabatch") pyblish.api.deregister_host("mayapy") pyblish.api.deregister_host("maya") deregister_loader_plugin_path(LOAD_PATH) deregister_creator_plugin_path(CREATE_PATH) deregister_inventory_action_path(INVENTORY_PATH) menu.uninstall() def parse_container(container): """Return the container node's full container data. Args: container (str): A container node name. Returns: dict: The container schema data for this container node. """ data = lib.read(container) # Backwards compatibility pre-schemas for containers data["schema"] = data.get("schema", "openpype:container-1.0") # Append transient data data["objectName"] = container return data def _ls(): """Yields Avalon container node names. Used by `ls()` to retrieve the nodes and then query the full container's data. Yields: str: Avalon container node name (objectSet) """ def _maya_iterate(iterator): """Helper to iterate a maya iterator""" while not iterator.isDone(): yield iterator.thisNode() iterator.next() ids = {AVALON_CONTAINER_ID, # Backwards compatibility "pyblish.mindbender.container"} # Iterate over all 'set' nodes in the scene to detect whether # they have the avalon container ".id" attribute. fn_dep = om.MFnDependencyNode() iterator = om.MItDependencyNodes(om.MFn.kSet) for mobject in _maya_iterate(iterator): if mobject.apiTypeStr != "kSet": # Only match by exact type continue fn_dep.setObject(mobject) if not fn_dep.hasAttribute("id"): continue plug = fn_dep.findPlug("id", True) value = plug.asString() if value in ids: yield fn_dep.name() def ls(): """Yields containers from active Maya scene This is the host-equivalent of api.ls(), but instead of listing assets on disk, it lists assets already loaded in Maya; once loaded they are called 'containers' Yields: dict: container """ container_names = _ls() for container in sorted(container_names): yield parse_container(container) def containerise(name, namespace, nodes, context, loader=None, suffix="CON"): """Bundle `nodes` into an assembly and imprint it with metadata Containerisation enables a tracking of version, author and origin for loaded assets. Arguments: name (str): Name of resulting assembly namespace (str): Namespace under which to host container nodes (list): Long names of nodes to containerise context (dict): Asset information loader (str, optional): Name of loader used to produce this container. suffix (str, optional): Suffix of container, defaults to `_CON`. Returns: container (str): Name of container assembly """ container = cmds.sets(nodes, name="%s_%s_%s" % (namespace, name, suffix)) data = [ ("schema", "openpype:container-2.0"), ("id", AVALON_CONTAINER_ID), ("name", name), ("namespace", namespace), ("loader", loader), ("representation", context["representation"]["_id"]), ] for key, value in data: cmds.addAttr(container, longName=key, dataType="string") cmds.setAttr(container + "." + key, str(value), type="string") main_container = cmds.ls(AVALON_CONTAINERS, type="objectSet") if not main_container: main_container = cmds.sets(empty=True, name=AVALON_CONTAINERS) # Implement #399: Maya 2019+ hide AVALON_CONTAINERS on creation.. if cmds.attributeQuery("hiddenInOutliner", node=main_container, exists=True): cmds.setAttr(main_container + ".hiddenInOutliner", True) else: main_container = main_container[0] cmds.sets(container, addElement=main_container) # Implement #399: Maya 2019+ hide containers in outliner if cmds.attributeQuery("hiddenInOutliner", node=container, exists=True): cmds.setAttr(container + ".hiddenInOutliner", True) return container def on_init(): log.info("Running callback on init..") def safe_deferred(fn): """Execute deferred the function in a try-except""" def _fn(): """safely call in deferred callback""" try: fn() except Exception as exc: print(exc) try: utils.executeDeferred(_fn) except Exception as exc: print(exc) # Force load Alembic so referenced alembics # work correctly on scene open cmds.loadPlugin("AbcImport", quiet=True) cmds.loadPlugin("AbcExport", quiet=True) # Force load objExport plug-in (requested by artists) cmds.loadPlugin("objExport", quiet=True) if not lib.IS_HEADLESS: launch_workfiles = os.environ.get("WORKFILES_STARTUP") if launch_workfiles: safe_deferred(host_tools.show_workfiles) from .customize import ( override_component_mask_commands, override_toolbox_ui ) safe_deferred(override_component_mask_commands) safe_deferred(override_toolbox_ui) def on_before_save(): """Run validation for scene's FPS prior to saving""" return lib.validate_fps() def on_after_save(): """Check if there is a lockfile after save""" check_lock_on_current_file() def check_lock_on_current_file(): """Check if there is a user opening the file""" if not handle_workfile_locks(): return log.info("Running callback on checking the lock file...") # add the lock file when opening the file filepath = current_file() # Skip if current file is 'untitled' if not filepath: return if is_workfile_locked(filepath): # add lockfile dialog workfile_dialog = WorkfileLockDialog(filepath) if not workfile_dialog.exec_(): cmds.file(new=True) return create_workfile_lock(filepath) def on_before_close(): """Delete the lock file after user quitting the Maya Scene""" log.info("Closing Maya...") # delete the lock file filepath = current_file() if handle_workfile_locks(): remove_workfile_lock(filepath) def before_file_open(): """check lock file when the file changed""" # delete the lock file _remove_workfile_lock() def on_save(): """Automatically add IDs to new nodes Any transform of a mesh, without an existing ID, is given one automatically on file save. """ log.info("Running callback on save..") # remove lockfile if users jumps over from one scene to another _remove_workfile_lock() # Generate ids of the current context on nodes in the scene nodes = lib.get_id_required_nodes(referenced_nodes=False) for node, new_id in lib.generate_ids(nodes): lib.set_id(node, new_id, overwrite=False) def on_open(): """On scene open let's assume the containers have changed.""" from openpype.widgets import popup # Validate FPS after update_task_from_path to # ensure it is using correct FPS for the asset lib.validate_fps() lib.fix_incompatible_containers() if any_outdated_containers(): log.warning("Scene has outdated content.") # Find maya main window parent = lib.get_main_window() if parent is None: log.info("Skipping outdated content pop-up " "because Maya window can't be found.") else: # Show outdated pop-up def _on_show_inventory(): host_tools.show_scene_inventory(parent=parent) dialog = popup.Popup(parent=parent) dialog.setWindowTitle("Maya scene has outdated content") dialog.setMessage("There are outdated containers in " "your Maya scene.") dialog.on_clicked.connect(_on_show_inventory) dialog.show() # create lock file for the maya scene check_lock_on_current_file() def on_new(): """Set project resolution and fps when create a new file""" log.info("Running callback on new..") with lib.suspended_refresh(): lib.set_context_settings() _remove_workfile_lock() def on_task_changed(): """Wrapped function of app initialize and maya's on task changed""" # Run menu.update_menu_task_label() workdir = legacy_io.Session["AVALON_WORKDIR"] if os.path.exists(workdir): log.info("Updating Maya workspace for task change to %s", workdir) _set_project() # Set Maya fileDialog's start-dir to /scenes frule_scene = cmds.workspace(fileRuleEntry="scene") cmds.optionVar(stringValue=("browserLocationmayaBinaryscene", workdir + "/" + frule_scene)) else: log.warning(( "Can't set project for new context because path does not exist: {}" ).format(workdir)) with lib.suspended_refresh(): lib.set_context_settings() lib.update_content_on_context_change() def before_workfile_open(): if handle_workfile_locks(): _remove_workfile_lock() def before_workfile_save(event): project_name = get_current_project_name() if handle_workfile_locks(): _remove_workfile_lock() workdir_path = event["workdir_path"] if workdir_path: create_workspace_mel(workdir_path, project_name) def workfile_save_before_xgen(event): """Manage Xgen external files when switching context. Xgen has various external files that needs to be unique and relative to the workfile, so we need to copy and potentially overwrite these files when switching context. Args: event (Event) - openpype/lib/events.py """ if not cmds.pluginInfo("xgenToolkit", query=True, loaded=True): return import xgenm current_work_dir = legacy_io.Session["AVALON_WORKDIR"].replace("\\", "/") expected_work_dir = event.data["workdir_path"].replace("\\", "/") if current_work_dir == expected_work_dir: return palettes = cmds.ls(type="xgmPalette", long=True) if not palettes: return transfers = [] overwrites = [] attribute_changes = {} attrs = ["xgFileName", "xgBaseFile"] for palette in palettes: sanitized_palette = palette.replace("|", "") project_path = xgenm.getAttr("xgProjectPath", sanitized_palette) _, maya_extension = os.path.splitext(event.data["filename"]) for attr in attrs: node_attr = "{}.{}".format(palette, attr) attr_value = cmds.getAttr(node_attr) if not attr_value: continue source = os.path.join(project_path, attr_value) attr_value = event.data["filename"].replace( maya_extension, "__{}{}".format( sanitized_palette.replace(":", "__"), os.path.splitext(attr_value)[1] ) ) target = os.path.join(expected_work_dir, attr_value) transfers.append((source, target)) attribute_changes[node_attr] = attr_value relative_path = xgenm.getAttr( "xgDataPath", sanitized_palette ).split(os.pathsep)[0] absolute_path = relative_path.replace("${PROJECT}", project_path) for root, _, files in os.walk(absolute_path): for f in files: source = os.path.join(root, f).replace("\\", "/") target = source.replace(project_path, expected_work_dir + "/") transfers.append((source, target)) if os.path.exists(target): overwrites.append(target) # Ask user about overwriting files. if overwrites: log.warning( "WARNING! Potential loss of data.\n\n" "Found duplicate Xgen files in new context.\n{}".format( "\n".join(overwrites) ) ) return for source, destination in transfers: if not os.path.exists(os.path.dirname(destination)): os.makedirs(os.path.dirname(destination)) shutil.copy(source, destination) for attribute, value in attribute_changes.items(): cmds.setAttr(attribute, value, type="string") def after_workfile_save(event): workfile_name = event["filename"] if ( handle_workfile_locks() and workfile_name and not is_workfile_locked(workfile_name) ): create_workfile_lock(workfile_name) class MayaDirmap(HostDirmap): def on_enable_dirmap(self): cmds.dirmap(en=True) def dirmap_routine(self, source_path, destination_path): cmds.dirmap(m=(source_path, destination_path)) cmds.dirmap(m=(destination_path, source_path)) ================================================ FILE: openpype/hosts/maya/api/plugin.py ================================================ import json import os from abc import ABCMeta import qargparse import six from maya import cmds from maya.app.renderSetup.model import renderSetup from openpype import AYON_SERVER_ENABLED from openpype.lib import BoolDef, Logger from openpype.settings import get_project_settings from openpype.pipeline import ( AVALON_CONTAINER_ID, Anatomy, CreatedInstance, Creator as NewCreator, AutoCreator, HiddenCreator, CreatorError, LegacyCreator, LoaderPlugin, get_representation_path, ) from openpype.pipeline.load import LoadError from openpype.client import get_asset_by_name from openpype.pipeline.create import get_subset_name from . import lib from .lib import imprint, read from .pipeline import containerise log = Logger.get_logger() def _get_attr(node, attr, default=None): """Helper to get attribute which allows attribute to not exist.""" if not cmds.attributeQuery(attr, node=node, exists=True): return default return cmds.getAttr("{}.{}".format(node, attr)) # Backwards compatibility: these functions has been moved to lib. def get_reference_node(*args, **kwargs): """Get the reference node from the container members Deprecated: This function was moved and will be removed in 3.16.x. """ msg = "Function 'get_reference_node' has been moved." log.warning(msg) cmds.warning(msg) return lib.get_reference_node(*args, **kwargs) def get_reference_node_parents(*args, **kwargs): """ Deprecated: This function was moved and will be removed in 3.16.x. """ msg = "Function 'get_reference_node_parents' has been moved." log.warning(msg) cmds.warning(msg) return lib.get_reference_node_parents(*args, **kwargs) class Creator(LegacyCreator): defaults = ['Main'] def process(self): nodes = list() with lib.undo_chunk(): if (self.options or {}).get("useSelection"): nodes = cmds.ls(selection=True) instance = cmds.sets(nodes, name=self.name) lib.imprint(instance, self.data) return instance @six.add_metaclass(ABCMeta) class MayaCreatorBase(object): @staticmethod def cache_subsets(shared_data): """Cache instances for Creators to shared data. Create `maya_cached_subsets` key when needed in shared data and fill it with all collected instances from the scene under its respective creator identifiers. If legacy instances are detected in the scene, create `maya_cached_legacy_subsets` there and fill it with all legacy subsets under family as a key. Args: Dict[str, Any]: Shared data. Return: Dict[str, Any]: Shared data dictionary. """ if shared_data.get("maya_cached_subsets") is None: cache = dict() cache_legacy = dict() for node in cmds.ls(type="objectSet"): if _get_attr(node, attr="id") != "pyblish.avalon.instance": continue creator_id = _get_attr(node, attr="creator_identifier") if creator_id is not None: # creator instance cache.setdefault(creator_id, []).append(node) else: # legacy instance family = _get_attr(node, attr="family") if family is None: # must be a broken instance continue cache_legacy.setdefault(family, []).append(node) shared_data["maya_cached_subsets"] = cache shared_data["maya_cached_legacy_subsets"] = cache_legacy return shared_data def get_publish_families(self): """Return families for the instances of this creator. Allow a Creator to define multiple families so that a creator can e.g. specify `usd` and `usdMaya` and another USD creator can also specify `usd` but apply different extractors like `usdMultiverse`. There is no need to override this method if you only have the primary family defined by the `family` property as that will always be set. Returns: list: families for instances of this creator """ return [] def imprint_instance_node(self, node, data): # We never store the instance_node as value on the node since # it's the node name itself data.pop("instance_node", None) data.pop("instance_id", None) # Don't store `families` since it's up to the creator itself # to define the initial publish families - not a stored attribute of # `families` data.pop("families", None) # We store creator attributes at the root level and assume they # will not clash in names with `subset`, `task`, etc. and other # default names. This is just so these attributes in many cases # are still editable in the maya UI by artists. # note: pop to move to end of dict to sort attributes last on the node creator_attributes = data.pop("creator_attributes", {}) # We only flatten value types which `imprint` function supports json_creator_attributes = {} for key, value in dict(creator_attributes).items(): if isinstance(value, (list, tuple, dict)): creator_attributes.pop(key) json_creator_attributes[key] = value # Flatten remaining creator attributes to the node itself data.update(creator_attributes) # We know the "publish_attributes" will be complex data of # settings per plugins, we'll store this as a flattened json structure # pop to move to end of dict to sort attributes last on the node data["publish_attributes"] = json.dumps( data.pop("publish_attributes", {}) ) # Persist the non-flattened creator attributes (special value types, # like multiselection EnumDef) data["creator_attributes"] = json.dumps(json_creator_attributes) # Since we flattened the data structure for creator attributes we want # to correctly detect which flattened attributes should end back in the # creator attributes when reading the data from the node, so we store # the relevant keys as a string data["__creator_attributes_keys"] = ",".join(creator_attributes.keys()) # Kill any existing attributes just so we can imprint cleanly again for attr in data.keys(): if cmds.attributeQuery(attr, node=node, exists=True): cmds.deleteAttr("{}.{}".format(node, attr)) return imprint(node, data) def read_instance_node(self, node): node_data = read(node) # Never care about a cbId attribute on the object set # being read as 'data' node_data.pop("cbId", None) # Make sure we convert any creator attributes from the json string creator_attributes = node_data.get("creator_attributes") if creator_attributes: node_data["creator_attributes"] = json.loads(creator_attributes) else: node_data["creator_attributes"] = {} # Move the relevant attributes into "creator_attributes" that # we flattened originally creator_attribute_keys = node_data.pop("__creator_attributes_keys", "").split(",") for key in creator_attribute_keys: if key in node_data: node_data["creator_attributes"][key] = node_data.pop(key) # Make sure we convert any publish attributes from the json string publish_attributes = node_data.get("publish_attributes") if publish_attributes: node_data["publish_attributes"] = json.loads(publish_attributes) # Explicitly re-parse the node name node_data["instance_node"] = node node_data["instance_id"] = node # If the creator plug-in specifies families = self.get_publish_families() if families: node_data["families"] = families return node_data def _default_collect_instances(self): self.cache_subsets(self.collection_shared_data) cached_subsets = self.collection_shared_data["maya_cached_subsets"] for node in cached_subsets.get(self.identifier, []): node_data = self.read_instance_node(node) created_instance = CreatedInstance.from_existing(node_data, self) self._add_instance_to_context(created_instance) def _default_update_instances(self, update_list): for created_inst, _changes in update_list: data = created_inst.data_to_store() node = data.get("instance_node") self.imprint_instance_node(node, data) def _default_remove_instances(self, instances): """Remove specified instance from the scene. This is only removing `id` parameter so instance is no longer instance, because it might contain valuable data for artist. """ for instance in instances: node = instance.data.get("instance_node") if node: cmds.delete(node) self._remove_instance_from_context(instance) @six.add_metaclass(ABCMeta) class MayaCreator(NewCreator, MayaCreatorBase): settings_category = "maya" def create(self, subset_name, instance_data, pre_create_data): members = list() if pre_create_data.get("use_selection"): members = cmds.ls(selection=True) # Allow a Creator to define multiple families publish_families = self.get_publish_families() if publish_families: families = instance_data.setdefault("families", []) for family in self.get_publish_families(): if family not in families: families.append(family) with lib.undo_chunk(): instance_node = cmds.sets(members, name=subset_name) instance_data["instance_node"] = instance_node instance = CreatedInstance( self.family, subset_name, instance_data, self) self._add_instance_to_context(instance) self.imprint_instance_node(instance_node, data=instance.data_to_store()) return instance def collect_instances(self): return self._default_collect_instances() def update_instances(self, update_list): return self._default_update_instances(update_list) def remove_instances(self, instances): return self._default_remove_instances(instances) def get_pre_create_attr_defs(self): return [ BoolDef("use_selection", label="Use selection", default=True) ] class MayaAutoCreator(AutoCreator, MayaCreatorBase): """Automatically triggered creator for Maya. The plugin is not visible in UI, and 'create' method does not expect any arguments. """ settings_category = "maya" def collect_instances(self): return self._default_collect_instances() def update_instances(self, update_list): return self._default_update_instances(update_list) def remove_instances(self, instances): return self._default_remove_instances(instances) class MayaHiddenCreator(HiddenCreator, MayaCreatorBase): """Hidden creator for Maya. The plugin is not visible in UI, and it does not have strictly defined arguments for 'create' method. """ settings_category = "maya" def create(self, *args, **kwargs): return MayaCreator.create(self, *args, **kwargs) def collect_instances(self): return self._default_collect_instances() def update_instances(self, update_list): return self._default_update_instances(update_list) def remove_instances(self, instances): return self._default_remove_instances(instances) def ensure_namespace(namespace): """Make sure the namespace exists. Args: namespace (str): The preferred namespace name. Returns: str: The generated or existing namespace """ exists = cmds.namespace(exists=namespace) if exists: return namespace else: return cmds.namespace(add=namespace) class RenderlayerCreator(NewCreator, MayaCreatorBase): """Creator which creates an instance per renderlayer in the workfile. Create and manages renderlayer subset per renderLayer in workfile. This generates a singleton node in the scene which, if it exists, tells the Creator to collect Maya rendersetup renderlayers as individual instances. As such, triggering create doesn't actually create the instance node per layer but only the node which tells the Creator it may now collect an instance per renderlayer. """ # These are required to be overridden in subclass singleton_node_name = "" # These are optional to be overridden in subclass layer_instance_prefix = None def _get_singleton_node(self, return_all=False): nodes = lib.lsattr("pre_creator_identifier", self.identifier) if nodes: return nodes if return_all else nodes[0] def create(self, subset_name, instance_data, pre_create_data): # A Renderlayer is never explicitly created using the create method. # Instead, renderlayers from the scene are collected. Thus "create" # would only ever be called to say, 'hey, please refresh collect' self.create_singleton_node() # if no render layers are present, create default one with # asterisk selector rs = renderSetup.instance() if not rs.getRenderLayers(): render_layer = rs.createRenderLayer("Main") collection = render_layer.createCollection("defaultCollection") collection.getSelector().setPattern('*') # By RenderLayerCreator.create we make it so that the renderlayer # instances directly appear even though it just collects scene # renderlayers. This doesn't actually 'create' any scene contents. self.collect_instances() def create_singleton_node(self): if self._get_singleton_node(): raise CreatorError("A Render instance already exists - only " "one can be configured.") with lib.undo_chunk(): node = cmds.sets(empty=True, name=self.singleton_node_name) lib.imprint(node, data={ "pre_creator_identifier": self.identifier }) return node def collect_instances(self): # We only collect if the global render instance exists if not self._get_singleton_node(): return rs = renderSetup.instance() layers = rs.getRenderLayers() for layer in layers: layer_instance_node = self.find_layer_instance_node(layer) if layer_instance_node: data = self.read_instance_node(layer_instance_node) instance = CreatedInstance.from_existing(data, creator=self) else: # No existing scene instance node for this layer. Note that # this instance will not have the `instance_node` data yet # until it's been saved/persisted at least once. project_name = self.create_context.get_current_project_name() asset_name = self.create_context.get_current_asset_name() instance_data = { "task": self.create_context.get_current_task_name(), "variant": layer.name(), } if AYON_SERVER_ENABLED: instance_data["folderPath"] = asset_name else: instance_data["asset"] = asset_name asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( layer.name(), instance_data["task"], asset_doc, project_name) instance = CreatedInstance( family=self.family, subset_name=subset_name, data=instance_data, creator=self ) instance.transient_data["layer"] = layer self._add_instance_to_context(instance) def find_layer_instance_node(self, layer): connected_sets = cmds.listConnections( "{}.message".format(layer.name()), source=False, destination=True, type="objectSet" ) or [] for node in connected_sets: if not cmds.attributeQuery("creator_identifier", node=node, exists=True): continue creator_identifier = cmds.getAttr(node + ".creator_identifier") if creator_identifier == self.identifier: self.log.info("Found node: {}".format(node)) return node def _create_layer_instance_node(self, layer): # We only collect if a CreateRender instance exists create_render_set = self._get_singleton_node() if not create_render_set: raise CreatorError("Creating a renderlayer instance node is not " "allowed if no 'CreateRender' instance exists") namespace = "_{}".format(self.singleton_node_name) namespace = ensure_namespace(namespace) name = "{}:{}".format(namespace, layer.name()) render_set = cmds.sets(name=name, empty=True) # Keep an active link with the renderlayer so we can retrieve it # later by a physical maya connection instead of relying on the layer # name cmds.addAttr(render_set, longName="renderlayer", at="message") cmds.connectAttr("{}.message".format(layer.name()), "{}.renderlayer".format(render_set), force=True) # Add the set to the 'CreateRender' set. cmds.sets(render_set, forceElement=create_render_set) return render_set def update_instances(self, update_list): # We only generate the persisting layer data into the scene once # we save with the UI on e.g. validate or publish for instance, _changes in update_list: instance_node = instance.data.get("instance_node") # Ensure a node exists to persist the data to if not instance_node: layer = instance.transient_data["layer"] instance_node = self._create_layer_instance_node(layer) instance.data["instance_node"] = instance_node self.imprint_instance_node(instance_node, data=instance.data_to_store()) def imprint_instance_node(self, node, data): # Do not ever try to update the `renderlayer` since it'll try # to remove the attribute and recreate it but fail to keep it a # message attribute link. We only ever imprint that on the initial # node creation. # TODO: Improve how this is handled data.pop("renderlayer", None) data.get("creator_attributes", {}).pop("renderlayer", None) return super(RenderlayerCreator, self).imprint_instance_node(node, data=data) def remove_instances(self, instances): """Remove specified instances from the scene. This is only removing `id` parameter so instance is no longer instance, because it might contain valuable data for artist. """ # Instead of removing the single instance or renderlayers we instead # remove the CreateRender node this creator relies on to decide whether # it should collect anything at all. nodes = self._get_singleton_node(return_all=True) if nodes: cmds.delete(nodes) # Remove ALL the instances even if only one gets deleted for instance in list(self.create_context.instances): if instance.get("creator_identifier") == self.identifier: self._remove_instance_from_context(instance) # Remove the stored settings per renderlayer too node = instance.data.get("instance_node") if node and cmds.objExists(node): cmds.delete(node) def get_subset_name( self, variant, task_name, asset_doc, project_name, host_name=None, instance=None ): # creator.family != 'render' as expected return get_subset_name(self.layer_instance_prefix, variant, task_name, asset_doc, project_name) class Loader(LoaderPlugin): hosts = ["maya"] load_settings = {} # defined in settings @classmethod def apply_settings(cls, project_settings, system_settings): super(Loader, cls).apply_settings(project_settings, system_settings) cls.load_settings = project_settings['maya']['load'] def get_custom_namespace_and_group(self, context, options, loader_key): """Queries Settings to get custom template for namespace and group. Group template might be empty >> this forces to not wrap imported items into separate group. Args: context (dict) options (dict): artist modifiable options from dialog loader_key (str): key to get separate configuration from Settings ('reference_loader'|'import_loader') """ options["attach_to_root"] = True try: custom_naming = self.load_settings[loader_key] except KeyError: self.log.warning( "No settings found for {} in settings, falling back to " "ReferenceLoader defaults.".format(loader_key)) custom_naming = self.load_settings["reference_loader"] if not custom_naming['namespace']: raise LoadError("No namespace specified in " "Maya ReferenceLoader settings") elif not custom_naming['group_name']: self.log.debug("No custom group_name, no group will be created.") options["attach_to_root"] = False asset = context['asset'] subset = context['subset'] formatting_data = { "asset_name": asset['name'], "asset_type": asset['type'], "folder": { "name": asset["name"], }, "subset": subset['name'], "family": ( subset['data'].get('family') or subset['data']['families'][0] ) } custom_namespace = custom_naming['namespace'].format( **formatting_data ) custom_group_name = custom_naming['group_name'].format( **formatting_data ) return custom_group_name, custom_namespace, options class ReferenceLoader(Loader): """A basic ReferenceLoader for Maya This will implement the basic behavior for a loader to inherit from that will containerize the reference and will implement the `remove` and `update` logic. """ options = [ qargparse.Integer( "count", label="Count", default=1, min=1, help="How many times to load?" ), qargparse.Double3( "offset", label="Position Offset", help="Offset loaded models for easier selection." ), qargparse.Boolean( "attach_to_root", label="Group imported asset", default=True, help="Should a group be created to encapsulate" " imported representation ?" ) ] def load( self, context, name=None, namespace=None, options=None ): path = self.filepath_from_context(context) assert os.path.exists(path), "%s does not exist." % path custom_group_name, custom_namespace, options = \ self.get_custom_namespace_and_group(context, options, "reference_loader") count = options.get("count") or 1 loaded_containers = [] for c in range(0, count): namespace = lib.get_custom_namespace(custom_namespace) group_name = "{}:{}".format( namespace, custom_group_name ) options['group_name'] = group_name # Offset loaded subset if "offset" in options: offset = [i * c for i in options["offset"]] options["translate"] = offset self.log.info(options) self.process_reference( context=context, name=name, namespace=namespace, options=options ) # Only containerize if any nodes were loaded by the Loader nodes = self[:] if not nodes: return ref_node = lib.get_reference_node(nodes, self.log) container = containerise( name=name, namespace=namespace, nodes=[ref_node], context=context, loader=self.__class__.__name__ ) loaded_containers.append(container) self._organize_containers(nodes, container) c += 1 return loaded_containers def process_reference(self, context, name, namespace, options): """To be implemented by subclass""" raise NotImplementedError("Must be implemented by subclass") def update(self, container, representation): from maya import cmds from openpype.hosts.maya.api.lib import get_container_members node = container["objectName"] path = get_representation_path(representation) # Get reference node from container members members = get_container_members(node) reference_node = lib.get_reference_node(members, self.log) namespace = cmds.referenceQuery(reference_node, namespace=True) file_type = { "ma": "mayaAscii", "mb": "mayaBinary", "abc": "Alembic", "fbx": "FBX", "usd": "USD Import" }.get(representation["name"]) assert file_type, "Unsupported representation: %s" % representation assert os.path.exists(path), "%s does not exist." % path # Need to save alembic settings and reapply, cause referencing resets # them to incoming data. alembic_attrs = ["speed", "offset", "cycleType", "time"] alembic_data = {} if representation["name"] == "abc": alembic_nodes = cmds.ls( "{}:*".format(namespace), type="AlembicNode" ) if alembic_nodes: for attr in alembic_attrs: node_attr = "{}.{}".format(alembic_nodes[0], attr) data = { "input": lib.get_attribute_input(node_attr), "value": cmds.getAttr(node_attr) } alembic_data[attr] = data else: self.log.debug("No alembic nodes found in {}".format(members)) try: path = self.prepare_root_value(path, representation["context"] ["project"] ["name"]) content = cmds.file(path, loadReference=reference_node, type=file_type, returnNewNodes=True) except RuntimeError as exc: # When changing a reference to a file that has load errors the # command will raise an error even if the file is still loaded # correctly (e.g. when raising errors on Arnold attributes) # When the file is loaded and has content, we consider it's fine. if not cmds.referenceQuery(reference_node, isLoaded=True): raise content = cmds.referenceQuery(reference_node, nodes=True, dagPath=True) if not content: raise self.log.warning("Ignoring file read error:\n%s", exc) self._organize_containers(content, container["objectName"]) # Reapply alembic settings. if representation["name"] == "abc" and alembic_data: alembic_nodes = cmds.ls( "{}:*".format(namespace), type="AlembicNode" ) if alembic_nodes: alembic_node = alembic_nodes[0] # assume single AlembicNode for attr, data in alembic_data.items(): node_attr = "{}.{}".format(alembic_node, attr) input = lib.get_attribute_input(node_attr) if data["input"]: if data["input"] != input: cmds.connectAttr( data["input"], node_attr, force=True ) else: if input: cmds.disconnectAttr(input, node_attr) cmds.setAttr(node_attr, data["value"]) # Fix PLN-40 for older containers created with Avalon that had the # `.verticesOnlySet` set to True. if cmds.getAttr("{}.verticesOnlySet".format(node)): self.log.info("Setting %s.verticesOnlySet to False", node) cmds.setAttr("{}.verticesOnlySet".format(node), False) # Remove any placeHolderList attribute entries from the set that # are remaining from nodes being removed from the referenced file. members = cmds.sets(node, query=True) invalid = [x for x in members if ".placeHolderList" in x] if invalid: cmds.sets(invalid, remove=node) # Update metadata cmds.setAttr("{}.representation".format(node), str(representation["_id"]), type="string") # When an animation or pointcache gets connected to an Xgen container, # the compound attribute "xgenContainers" gets created. When animation # containers gets updated we also need to update the cacheFileName on # the Xgen collection. compound_name = "xgenContainers" if cmds.objExists("{}.{}".format(node, compound_name)): import xgenm container_amount = cmds.getAttr( "{}.{}".format(node, compound_name), size=True ) # loop through all compound children for i in range(container_amount): attr = "{}.{}[{}].container".format(node, compound_name, i) objectset = cmds.listConnections(attr)[0] reference_node = cmds.sets(objectset, query=True)[0] palettes = cmds.ls( cmds.referenceQuery(reference_node, nodes=True), type="xgmPalette" ) for palette in palettes: for description in xgenm.descriptions(palette): xgenm.setAttr( "cacheFileName", path.replace("\\", "/"), palette, description, "SplinePrimitive" ) # Refresh UI and viewport. de = xgenm.xgGlobal.DescriptionEditor de.refresh("Full") def remove(self, container): """Remove an existing `container` from Maya scene Deprecated; this functionality is replaced by `api.remove()` Arguments: container (openpype:container-1.0): Which container to remove from scene. """ from maya import cmds node = container["objectName"] # Assume asset has been referenced members = cmds.sets(node, query=True) reference_node = lib.get_reference_node(members, self.log) assert reference_node, ("Imported container not supported; " "container must be referenced.") self.log.info("Removing '%s' from Maya.." % container["name"]) namespace = cmds.referenceQuery(reference_node, namespace=True) fname = cmds.referenceQuery(reference_node, filename=True) cmds.file(fname, removeReference=True) try: cmds.delete(node) except ValueError: # Already implicitly deleted by Maya upon removing reference pass try: # If container is not automatically cleaned up by May (issue #118) cmds.namespace(removeNamespace=namespace, deleteNamespaceContent=True) except RuntimeError: pass def prepare_root_value(self, file_url, project_name): """Replace root value with env var placeholder. Use ${OPENPYPE_ROOT_WORK} (or any other root) instead of proper root value when storing referenced url into a workfile. Useful for remote workflows with SiteSync. Args: file_url (str) project_name (dict) Returns: (str) """ settings = get_project_settings(project_name) use_env_var_as_root = (settings["maya"] ["maya-dirmap"] ["use_env_var_as_root"]) if use_env_var_as_root: anatomy = Anatomy(project_name) file_url = anatomy.replace_root_with_env_key(file_url, '${{{}}}') return file_url @staticmethod def _organize_containers(nodes, container): # type: (list, str) -> None """Put containers in loaded data to correct hierarchy.""" for node in nodes: id_attr = "{}.id".format(node) if not cmds.attributeQuery("id", node=node, exists=True): continue if cmds.getAttr(id_attr) == AVALON_CONTAINER_ID: cmds.sets(node, forceElement=container) ================================================ FILE: openpype/hosts/maya/api/render_setup_tools.py ================================================ # -*- coding: utf-8 -*- """Export stuff in render setup layer context. Export Maya nodes from Render Setup layer as if flattened in that layer instead of exporting the defaultRenderLayer as Maya forces by default Credits: Roy Nieterau (BigRoy) / Colorbleed Modified for use in OpenPype """ import os import contextlib from maya import cmds from maya.app.renderSetup.model import renderSetup from .lib import pairwise @contextlib.contextmanager def _allow_export_from_render_setup_layer(): """Context manager to override Maya settings to allow RS layer export""" try: rs = renderSetup.instance() # Exclude Render Setup nodes from the export rs._setAllRSNodesDoNotWrite(True) # Disable Render Setup forcing the switch to master layer os.environ["MAYA_BATCH_RENDER_EXPORT"] = "1" yield finally: # Reset original state rs._setAllRSNodesDoNotWrite(False) os.environ.pop("MAYA_BATCH_RENDER_EXPORT", None) def export_in_rs_layer(path, nodes, export=None): """Export nodes from Render Setup layer. When exporting from Render Setup layer Maya by default forces a switch to the defaultRenderLayer as such making it impossible to export the contents of a Render Setup layer. Maya presents this warning message: # Warning: Exporting Render Setup master layer content # This function however avoids the renderlayer switch and exports from the Render Setup layer as if the edits were 'flattened' in the master layer. It does so by: - Allowing export from Render Setup Layer - Enforce Render Setup nodes to NOT be written on export - Disconnect connections from any `applyOverride` nodes to flatten the values (so they are written correctly)* *Connection overrides like Shader Override and Material Overrides export correctly out of the box since they don't create an intermediate connection to an 'applyOverride' node. However, any scalar override (absolute or relative override) will get input connections in the layer so we'll break those to 'store' the values on the attribute itself and write value out instead. Args: path (str): File path to export to. nodes (list): Maya nodes to export. export (callable, optional): Callback to be used for exporting. If not specified, default export to `.ma` will be called. Returns: None Raises: AssertionError: When not in a Render Setup layer an AssertionError is raised. This command assumes you are currently in a Render Setup layer. """ rs = renderSetup.instance() assert rs.getVisibleRenderLayer().name() != "defaultRenderLayer", \ ("Export in Render Setup layer is only supported when in " "Render Setup layer") # Break connection to any value overrides history = cmds.listHistory(nodes) or [] nodes_all = list( set(cmds.ls(nodes + history, long=True, objectsOnly=True))) overrides = cmds.listConnections(nodes_all, source=True, destination=False, type="applyOverride", plugs=True, connections=True) or [] for dest, src in pairwise(overrides): # Even after disconnecting the values # should be preserved as they were # Note: animated overrides would be lost for export cmds.disconnectAttr(src, dest) # Export Selected with _allow_export_from_render_setup_layer(): cmds.select(nodes, noExpand=True) if export: export() else: cmds.file(path, force=True, typ="mayaAscii", exportSelected=True, preserveReferences=False, channels=True, constraints=True, expressions=True, constructionHistory=True) if overrides: # If we have broken override connections then Maya # is unaware that the Render Setup layer is in an # invalid state. So let's 'hard reset' the state # by going to default render layer and switching back layer = rs.getVisibleRenderLayer() rs.switchToLayer(None) rs.switchToLayer(layer) ================================================ FILE: openpype/hosts/maya/api/setdress.py ================================================ import logging import json import os import contextlib import copy import six from maya import cmds from openpype.client import ( get_version_by_name, get_last_version_by_subset_id, get_representation_by_id, get_representation_by_name, get_representation_parents, ) from openpype.pipeline import ( schema, discover_loader_plugins, loaders_from_representation, load_container, update_container, remove_container, get_representation_path, get_current_project_name, ) from openpype.hosts.maya.api.lib import ( matrix_equals, unique_namespace, get_container_transforms, DEFAULT_MATRIX ) log = logging.getLogger("PackageLoader") def to_namespace(node, namespace): """Return node name as if it's inside the namespace. Args: node (str): Node name namespace (str): Namespace Returns: str: The node in the namespace. """ namespace_prefix = "|{}:".format(namespace) node = namespace_prefix.join(node.split("|")) return node @contextlib.contextmanager def namespaced(namespace, new=True): """Work inside namespace during context Args: new (bool): When enabled this will rename the namespace to a unique namespace if the input namespace already exists. Yields: str: The namespace that is used during the context """ original = cmds.namespaceInfo(cur=True) if new: namespace = unique_namespace(namespace) cmds.namespace(add=namespace) try: cmds.namespace(set=namespace) yield namespace finally: cmds.namespace(set=original) @contextlib.contextmanager def unlocked(nodes): # Get node state by Maya's uuid nodes = cmds.ls(nodes, long=True) uuids = cmds.ls(nodes, uuid=True) states = cmds.lockNode(nodes, query=True, lock=True) states = {uuid: state for uuid, state in zip(uuids, states)} originals = {uuid: node for uuid, node in zip(uuids, nodes)} try: cmds.lockNode(nodes, lock=False) yield finally: # Reapply original states _iteritems = getattr(states, "iteritems", states.items) for uuid, state in _iteritems(): nodes_from_id = cmds.ls(uuid, long=True) if nodes_from_id: node = nodes_from_id[0] else: log.debug("Falling back to node name: %s", node) node = originals[uuid] if not cmds.objExists(node): log.warning("Unable to find: %s", node) continue cmds.lockNode(node, lock=state) def load_package(filepath, name, namespace=None): """Load a package that was gathered elsewhere. A package is a group of published instances, possibly with additional data in a hierarchy. """ if namespace is None: # Define a unique namespace for the package namespace = os.path.basename(filepath).split(".")[0] unique_namespace(namespace) assert isinstance(namespace, six.string_types) # Load the setdress package data with open(filepath, "r") as fp: data = json.load(fp) # Load the setdress alembic hierarchy # We import this into the namespace in which we'll load the package's # instances into afterwards. alembic = filepath.replace(".json", ".abc") hierarchy = cmds.file(alembic, reference=True, namespace=namespace, returnNewNodes=True, groupReference=True, groupName="{}:{}".format(namespace, name), typ="Alembic") # Get the top root node (the reference group) root = "{}:{}".format(namespace, name) containers = [] all_loaders = discover_loader_plugins() for representation_id, instances in data.items(): # Find the compatible loaders loaders = loaders_from_representation( all_loaders, representation_id ) for instance in instances: container = _add(instance=instance, representation_id=representation_id, loaders=loaders, namespace=namespace, root=root) containers.append(container) # TODO: Do we want to cripple? Or do we want to add a 'parent' parameter? # Cripple the original avalon containers so they don't show up in the # manager # for container in containers: # cmds.setAttr("%s.id" % container, # "setdress.container", # type="string") # TODO: Lock all loaded nodes # This is to ensure the hierarchy remains unaltered by the artists # for node in nodes: # cmds.lockNode(node, lock=True) return containers + hierarchy def _add(instance, representation_id, loaders, namespace, root="|"): """Add an item from the package Args: instance (dict): representation_id (str): loaders (list): namespace (str): Returns: str: The created Avalon container. """ # Process within the namespace with namespaced(namespace, new=False) as namespace: # Get the used loader Loader = next((x for x in loaders if x.__name__ == instance['loader']), None) if Loader is None: log.warning("Loader is missing: %s. Skipping %s", instance['loader'], instance) raise RuntimeError("Loader is missing.") container = load_container( Loader, representation_id, namespace=instance['namespace'] ) # Get the root from the loaded container loaded_root = get_container_transforms({"objectName": container}, root=True) # Apply matrix to root node (if any matrix edits) matrix = instance.get("matrix", None) if matrix: cmds.xform(loaded_root, objectSpace=True, matrix=matrix) # Parent into the setdress hierarchy # Namespace is missing from parent node(s), add namespace # manually parent = root + to_namespace(instance["parent"], namespace) cmds.parent(loaded_root, parent, relative=True) return container # Store root nodes based on representation and namespace def _instances_by_namespace(data): """Rebuild instance data so we can look it up by namespace. Note that the `representation` is added into the instance's data with a `representation` key. Args: data (dict): scene build data Returns: dict """ result = {} # Add new assets for representation_id, instances in data.items(): # Ensure we leave the source data unaltered instances = copy.deepcopy(instances) for instance in instances: instance['representation'] = representation_id result[instance['namespace']] = instance return result def get_contained_containers(container): """Get the Avalon containers in this container Args: container (dict): The container dict. Returns: list: A list of member container dictionaries. """ from .pipeline import parse_container # Get avalon containers in this package setdress container containers = [] members = cmds.sets(container['objectName'], query=True) for node in cmds.ls(members, type="objectSet"): try: member_container = parse_container(node) containers.append(member_container) except schema.ValidationError: pass return containers def update_package_version(container, version): """ Update package by version number Args: container (dict): container data of the container node version (int): the new version number of the package Returns: None """ # Versioning (from `core.maya.pipeline`) project_name = get_current_project_name() current_representation = get_representation_by_id( project_name, container["representation"] ) assert current_representation is not None, "This is a bug" version_doc, subset_doc, asset_doc, project_doc = ( get_representation_parents(project_name, current_representation) ) if version == -1: new_version = get_last_version_by_subset_id( project_name, subset_doc["_id"] ) else: new_version = get_version_by_name( project_name, version, subset_doc["_id"] ) assert new_version is not None, "This is a bug" # Get the new representation (new file) new_representation = get_representation_by_name( project_name, current_representation["name"], new_version["_id"] ) update_package(container, new_representation) def update_package(set_container, representation): """Update any matrix changes in the scene based on the new data Args: set_container (dict): container data from `ls()` representation (dict): the representation document from the database Returns: None """ # Load the original package data project_name = get_current_project_name() current_representation = get_representation_by_id( project_name, set_container["representation"] ) current_file = get_representation_path(current_representation) assert current_file.endswith(".json") with open(current_file, "r") as fp: current_data = json.load(fp) # Load the new package data new_file = get_representation_path(representation) assert new_file.endswith(".json") with open(new_file, "r") as fp: new_data = json.load(fp) # Update scene content containers = get_contained_containers(set_container) update_scene(set_container, containers, current_data, new_data, new_file) # TODO: This should be handled by the pipeline itself cmds.setAttr(set_container['objectName'] + ".representation", str(representation['_id']), type="string") def update_scene(set_container, containers, current_data, new_data, new_file): """Updates the hierarchy, assets and their matrix Updates the following within the scene: * Setdress hierarchy alembic * Matrix * Parenting * Representations It removes any assets which are not present in the new build data Args: set_container (dict): the setdress container of the scene containers (list): the list of containers under the setdress container current_data (dict): the current build data of the setdress new_data (dict): the new build data of the setdres Returns: processed_containers (list): all new and updated containers """ set_namespace = set_container['namespace'] project_name = get_current_project_name() # Update the setdress hierarchy alembic set_root = get_container_transforms(set_container, root=True) set_hierarchy_root = cmds.listRelatives(set_root, fullPath=True)[0] set_hierarchy_reference = cmds.referenceQuery(set_hierarchy_root, referenceNode=True) new_alembic = new_file.replace(".json", ".abc") assert os.path.exists(new_alembic), "%s does not exist." % new_alembic with unlocked(cmds.listRelatives(set_root, ad=True, fullPath=True)): cmds.file(new_alembic, loadReference=set_hierarchy_reference, type="Alembic") identity = DEFAULT_MATRIX[:] processed_namespaces = set() processed_containers = list() new_lookup = _instances_by_namespace(new_data) old_lookup = _instances_by_namespace(current_data) for container in containers: container_ns = container['namespace'] # Consider it processed here, even it it fails we want to store that # the namespace was already available. processed_namespaces.add(container_ns) processed_containers.append(container['objectName']) if container_ns in new_lookup: root = get_container_transforms(container, root=True) if not root: log.error("Can't find root for %s", container['objectName']) continue old_instance = old_lookup.get(container_ns, {}) new_instance = new_lookup[container_ns] # Update the matrix # check matrix against old_data matrix to find local overrides current_matrix = cmds.xform(root, query=True, matrix=True, objectSpace=True) original_matrix = old_instance.get("matrix", identity) has_matrix_override = not matrix_equals(current_matrix, original_matrix) if has_matrix_override: log.warning("Matrix override preserved on %s", container_ns) else: new_matrix = new_instance.get("matrix", identity) cmds.xform(root, matrix=new_matrix, objectSpace=True) # Update the parenting if old_instance.get("parent", None) != new_instance["parent"]: parent = to_namespace(new_instance['parent'], set_namespace) if not cmds.objExists(parent): log.error("Can't find parent %s", parent) continue # Set the new parent cmds.lockNode(root, lock=False) root = cmds.parent(root, parent, relative=True) cmds.lockNode(root, lock=True) # Update the representation representation_current = container['representation'] representation_old = old_instance['representation'] representation_new = new_instance['representation'] has_representation_override = (representation_current != representation_old) if representation_new != representation_current: if has_representation_override: log.warning("Your scene had local representation " "overrides within the set. New " "representations not loaded for %s.", container_ns) continue # We check it against the current 'loader' in the scene instead # of the original data of the package that was loaded because # an Artist might have made scene local overrides if new_instance['loader'] != container['loader']: log.warning("Loader is switched - local edits will be " "lost. Removing: %s", container_ns) # Remove this from the "has been processed" list so it's # considered as new element and added afterwards. processed_containers.pop() processed_namespaces.remove(container_ns) remove_container(container) continue # Check whether the conversion can be done by the Loader. # They *must* use the same asset, subset and Loader for # `update_container` to make sense. old = get_representation_by_id( project_name, representation_current ) new = get_representation_by_id( project_name, representation_new ) is_valid = compare_representations(old=old, new=new) if not is_valid: log.error("Skipping: %s. See log for details.", container_ns) continue new_version = new["context"]["version"] update_container(container, version=new_version) else: # Remove this container because it's not in the new data log.warning("Removing content: %s", container_ns) remove_container(container) # Add new assets all_loaders = discover_loader_plugins() for representation_id, instances in new_data.items(): # Find the compatible loaders loaders = loaders_from_representation( all_loaders, representation_id ) for instance in instances: # Already processed in update functionality if instance['namespace'] in processed_namespaces: continue container = _add(instance=instance, representation_id=representation_id, loaders=loaders, namespace=set_container['namespace'], root=set_root) # Add to the setdress container cmds.sets(container, addElement=set_container['objectName']) processed_containers.append(container) return processed_containers def compare_representations(old, new): """Check if the old representation given can be updated Due to limitations of the `update_container` function we cannot allow differences in the following data: * Representation name (extension) * Asset name * Subset name (variation) If any of those data values differs, the function will raise an RuntimeError Args: old(dict): representation data from the database new(dict): representation data from the database Returns: bool: False if the representation is not invalid else True """ if new["name"] != old["name"]: log.error("Cannot switch extensions") return False new_context = new["context"] old_context = old["context"] if new_context["asset"] != old_context["asset"]: log.error("Changing assets between updates is " "not supported.") return False if new_context["subset"] != old_context["subset"]: log.error("Changing subsets between updates is " "not supported.") return False return True ================================================ FILE: openpype/hosts/maya/api/shader_definition_editor.py ================================================ # -*- coding: utf-8 -*- """Editor for shader definitions. Shader names are stored as simple text file over GridFS in mongodb. """ import os from qtpy import QtWidgets, QtCore, QtGui from openpype.client.mongo import OpenPypeMongoConnection from openpype import resources import gridfs DEFINITION_FILENAME = "{}/maya/shader_definition.txt".format( os.getenv("AVALON_PROJECT")) class ShaderDefinitionsEditor(QtWidgets.QWidget): """Widget serving as simple editor for shader name definitions.""" # name of the file used to store definitions def __init__(self, parent=None): super(ShaderDefinitionsEditor, self).__init__(parent) self._mongo = OpenPypeMongoConnection.get_mongo_client() self._gridfs = gridfs.GridFS( self._mongo[os.getenv("OPENPYPE_DATABASE_NAME")]) self._editor = None self._original_content = self._read_definition_file() self.setObjectName("shaderDefinitionEditor") self.setWindowTitle("OpenPype shader name definition editor") icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags(QtCore.Qt.Window) self.setParent(parent) self.setAttribute(QtCore.Qt.WA_DeleteOnClose) self.resize(750, 500) self._setup_ui() self._reload() def _setup_ui(self): """Setup UI of Widget.""" layout = QtWidgets.QVBoxLayout(self) label = QtWidgets.QLabel() label.setText("Put shader names here - one name per line:") layout.addWidget(label) self._editor = QtWidgets.QPlainTextEdit() self._editor.setStyleSheet("border: none;") layout.addWidget(self._editor) btn_layout = QtWidgets.QHBoxLayout() save_btn = QtWidgets.QPushButton("Save") save_btn.clicked.connect(self._save) reload_btn = QtWidgets.QPushButton("Reload") reload_btn.clicked.connect(self._reload) exit_btn = QtWidgets.QPushButton("Exit") exit_btn.clicked.connect(self._close) btn_layout.addWidget(reload_btn) btn_layout.addWidget(save_btn) btn_layout.addWidget(exit_btn) layout.addLayout(btn_layout) def _read_definition_file(self, file=None): """Read definition file from database. Args: file (gridfs.grid_file.GridOut, Optional): File to read. If not set, new query will be issued to find it. Returns: str: Content of the file or empty string if file doesn't exist. """ content = "" if not file: file = self._gridfs.find_one( {"filename": DEFINITION_FILENAME}) if not file: print(">>> [SNDE]: nothing in database yet") return content content = file.read() file.close() return content def _write_definition_file(self, content, force=False): """Write content as definition to file in database. Before file is written, check is made if its content has not changed. If is changed, warning is issued to user if he wants it to overwrite. Note: GridFs doesn't allow changing file content. You need to delete existing file and create new one. Args: content (str): Content to write. Raises: ContentException: If file is changed in database while editor is running. """ file = self._gridfs.find_one( {"filename": DEFINITION_FILENAME}) if file: content_check = self._read_definition_file(file) if content == content_check: print(">>> [SNDE]: content not changed") return if self._original_content != content_check: if not force: raise ContentException("Content changed") print(">>> [SNDE]: overwriting data") file.close() self._gridfs.delete(file._id) file = self._gridfs.new_file( filename=DEFINITION_FILENAME, content_type='text/plain', encoding='utf-8') file.write(content) file.close() QtCore.QTimer.singleShot(200, self._reset_style) self._editor.setStyleSheet("border: 1px solid #33AF65;") self._original_content = content def _reset_style(self): """Reset editor style back. Used to visually indicate save. """ self._editor.setStyleSheet("border: none;") def _close(self): self.hide() def closeEvent(self, event): event.ignore() self.hide() def _reload(self): print(">>> [SNDE]: reloading") self._set_content(self._read_definition_file()) def _save(self): try: self._write_definition_file(content=self._editor.toPlainText()) except ContentException: # content has changed meanwhile print(">>> [SNDE]: content has changed") self._show_overwrite_warning() def _set_content(self, content): self._editor.setPlainText(content) def _show_overwrite_warning(self): reply = QtWidgets.QMessageBox.question( self, "Warning", ("Content you are editing was changed meanwhile in database.\n" "Please, reload and solve the conflict."), QtWidgets.QMessageBox.OK) if reply == QtWidgets.QMessageBox.OK: # do nothing pass class ContentException(Exception): """This is risen during save if file is changed in database.""" pass ================================================ FILE: openpype/hosts/maya/api/workfile_template_builder.py ================================================ import json from maya import cmds from openpype.pipeline import registered_host, get_current_asset_name from openpype.pipeline.workfile.workfile_template_builder import ( TemplateAlreadyImported, AbstractTemplateBuilder, PlaceholderPlugin, LoadPlaceholderItem, PlaceholderLoadMixin, ) from openpype.tools.workfile_template_build import ( WorkfileBuildPlaceholderDialog, ) from .lib import read, imprint, get_reference_node, get_main_window PLACEHOLDER_SET = "PLACEHOLDERS_SET" class MayaTemplateBuilder(AbstractTemplateBuilder): """Concrete implementation of AbstractTemplateBuilder for maya""" use_legacy_creators = True def import_template(self, path): """Import template into current scene. Block if a template is already loaded. Args: path (str): A path to current template (usually given by get_template_preset implementation) Returns: bool: Whether the template was successfully imported or not """ if cmds.objExists(PLACEHOLDER_SET): raise TemplateAlreadyImported(( "Build template already loaded\n" "Clean scene if needed (File > New Scene)" )) cmds.sets(name=PLACEHOLDER_SET, empty=True) new_nodes = cmds.file( path, i=True, returnNewNodes=True, preserveReferences=True, loadReferenceDepth="all", ) # make default cameras non-renderable default_cameras = [cam for cam in cmds.ls(cameras=True) if cmds.camera(cam, query=True, startupCamera=True)] for cam in default_cameras: if not cmds.attributeQuery("renderable", node=cam, exists=True): self.log.debug( "Camera {} has no attribute 'renderable'".format(cam) ) continue cmds.setAttr("{}.renderable".format(cam), 0) cmds.setAttr(PLACEHOLDER_SET + ".hiddenInOutliner", True) imported_sets = cmds.ls(new_nodes, set=True) if not imported_sets: return True # update imported sets information asset_name = get_current_asset_name() for node in imported_sets: if not cmds.attributeQuery("id", node=node, exists=True): continue if cmds.getAttr("{}.id".format(node)) != "pyblish.avalon.instance": continue if not cmds.attributeQuery("asset", node=node, exists=True): continue cmds.setAttr( "{}.asset".format(node), asset_name, type="string") return True class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin): identifier = "maya.load" label = "Maya load" def _collect_scene_placeholders(self): # Cache placeholder data to shared data placeholder_nodes = self.builder.get_shared_populate_data( "placeholder_nodes" ) if placeholder_nodes is None: attributes = cmds.ls("*.plugin_identifier", long=True) placeholder_nodes = {} for attribute in attributes: node_name = attribute.rpartition(".")[0] placeholder_nodes[node_name] = ( self._parse_placeholder_node_data(node_name) ) self.builder.set_shared_populate_data( "placeholder_nodes", placeholder_nodes ) return placeholder_nodes def _parse_placeholder_node_data(self, node_name): placeholder_data = read(node_name) parent_name = ( cmds.getAttr(node_name + ".parent", asString=True) or node_name.rpartition("|")[0] or "" ) if parent_name: siblings = cmds.listRelatives(parent_name, children=True) else: siblings = cmds.ls(assemblies=True) node_shortname = node_name.rpartition("|")[2] current_index = cmds.getAttr(node_name + ".index", asString=True) if current_index < 0: current_index = siblings.index(node_shortname) placeholder_data.update({ "parent": parent_name, "index": current_index }) return placeholder_data def _create_placeholder_name(self, placeholder_data): placeholder_name_parts = placeholder_data["builder_type"].split("_") pos = 1 # add family in any placeholder_family = placeholder_data["family"] if placeholder_family: placeholder_name_parts.insert(pos, placeholder_family) pos += 1 # add loader arguments if any loader_args = placeholder_data["loader_args"] if loader_args: loader_args = json.loads(loader_args.replace('\'', '\"')) values = [v for v in loader_args.values()] for value in values: placeholder_name_parts.insert(pos, value) pos += 1 placeholder_name = "_".join(placeholder_name_parts) return placeholder_name.capitalize() def _get_loaded_repre_ids(self): loaded_representation_ids = self.builder.get_shared_populate_data( "loaded_representation_ids" ) if loaded_representation_ids is None: try: containers = cmds.sets("AVALON_CONTAINERS", q=True) except ValueError: containers = [] loaded_representation_ids = { cmds.getAttr(container + ".representation") for container in containers } self.builder.set_shared_populate_data( "loaded_representation_ids", loaded_representation_ids ) return loaded_representation_ids def create_placeholder(self, placeholder_data): selection = cmds.ls(selection=True) if len(selection) > 1: raise ValueError("More then one item are selected") parent = selection[0] if selection else None placeholder_data["plugin_identifier"] = self.identifier placeholder_name = self._create_placeholder_name(placeholder_data) placeholder = cmds.spaceLocator(name=placeholder_name)[0] if parent: placeholder = cmds.parent(placeholder, selection[0])[0] imprint(placeholder, placeholder_data) # Add helper attributes to keep placeholder info cmds.addAttr( placeholder, longName="parent", hidden=True, dataType="string" ) cmds.addAttr( placeholder, longName="index", hidden=True, attributeType="short", defaultValue=-1 ) cmds.setAttr(placeholder + ".parent", "", type="string") def update_placeholder(self, placeholder_item, placeholder_data): node_name = placeholder_item.scene_identifier new_values = {} for key, value in placeholder_data.items(): placeholder_value = placeholder_item.data.get(key) if value != placeholder_value: new_values[key] = value placeholder_item.data[key] = value for key in new_values.keys(): cmds.deleteAttr(node_name + "." + key) imprint(node_name, new_values) def collect_placeholders(self): output = [] scene_placeholders = self._collect_scene_placeholders() for node_name, placeholder_data in scene_placeholders.items(): if placeholder_data.get("plugin_identifier") != self.identifier: continue # TODO do data validations and maybe upgrades if they are invalid output.append( LoadPlaceholderItem(node_name, placeholder_data, self) ) return output def populate_placeholder(self, placeholder): self.populate_load_placeholder(placeholder) def repopulate_placeholder(self, placeholder): repre_ids = self._get_loaded_repre_ids() self.populate_load_placeholder(placeholder, repre_ids) def get_placeholder_options(self, options=None): return self.get_load_plugin_options(options) def post_placeholder_process(self, placeholder, failed): """Cleanup placeholder after load of its corresponding representations. Args: placeholder (PlaceholderItem): Item which was just used to load representation. failed (bool): Loading of representation failed. """ # Hide placeholder and add them to placeholder set node = placeholder.scene_identifier cmds.sets(node, addElement=PLACEHOLDER_SET) cmds.hide(node) cmds.setAttr(node + ".hiddenInOutliner", True) def delete_placeholder(self, placeholder): """Remove placeholder if building was successful""" cmds.delete(placeholder.scene_identifier) def load_succeed(self, placeholder, container): self._parent_in_hierarchy(placeholder, container) def _parent_in_hierarchy(self, placeholder, container): """Parent loaded container to placeholder's parent. ie : Set loaded content as placeholder's sibling Args: container (str): Placeholder loaded containers """ if not container: return roots = cmds.sets(container, q=True) ref_node = None try: ref_node = get_reference_node(roots) except AssertionError as e: self.log.info(e.args[0]) nodes_to_parent = [] for root in roots: if ref_node: ref_root = cmds.referenceQuery(root, nodes=True)[0] ref_root = ( cmds.listRelatives(ref_root, parent=True, path=True) or [ref_root] ) nodes_to_parent.extend(ref_root) continue if root.endswith("_RN"): # Backwards compatibility for hardcoded reference names. refRoot = cmds.referenceQuery(root, n=True)[0] refRoot = cmds.listRelatives(refRoot, parent=True) or [refRoot] nodes_to_parent.extend(refRoot) elif root not in cmds.listSets(allSets=True): nodes_to_parent.append(root) elif not cmds.sets(root, q=True): return # Move loaded nodes to correct index in outliner hierarchy placeholder_form = cmds.xform( placeholder.scene_identifier, q=True, matrix=True, worldSpace=True ) scene_parent = cmds.listRelatives( placeholder.scene_identifier, parent=True, fullPath=True ) for node in set(nodes_to_parent): cmds.reorder(node, front=True) cmds.reorder(node, relative=placeholder.data["index"]) cmds.xform(node, matrix=placeholder_form, ws=True) if scene_parent: cmds.parent(node, scene_parent) else: cmds.parent(node, world=True) holding_sets = cmds.listSets(object=placeholder.scene_identifier) if not holding_sets: return for holding_set in holding_sets: cmds.sets(roots, forceElement=holding_set) def build_workfile_template(*args): builder = MayaTemplateBuilder(registered_host()) builder.build_template() def update_workfile_template(*args): builder = MayaTemplateBuilder(registered_host()) builder.rebuild_template() def create_placeholder(*args): host = registered_host() builder = MayaTemplateBuilder(host) window = WorkfileBuildPlaceholderDialog(host, builder, parent=get_main_window()) window.show() def update_placeholder(*args): host = registered_host() builder = MayaTemplateBuilder(host) placeholder_items_by_id = { placeholder_item.scene_identifier: placeholder_item for placeholder_item in builder.get_placeholders() } placeholder_items = [] for node_name in cmds.ls(selection=True, long=True): if node_name in placeholder_items_by_id: placeholder_items.append(placeholder_items_by_id[node_name]) # TODO show UI at least if len(placeholder_items) == 0: raise ValueError("No node selected") if len(placeholder_items) > 1: raise ValueError("Too many selected nodes") placeholder_item = placeholder_items[0] window = WorkfileBuildPlaceholderDialog(host, builder, parent=get_main_window()) window.set_update_mode(placeholder_item) window.exec_() ================================================ FILE: openpype/hosts/maya/api/workio.py ================================================ """Host API required Work Files tool""" import os from maya import cmds def file_extensions(): return [".ma", ".mb"] def has_unsaved_changes(): return cmds.file(query=True, modified=True) def save_file(filepath): cmds.file(rename=filepath) ext = os.path.splitext(filepath)[1] if ext == ".mb": file_type = "mayaBinary" else: file_type = "mayaAscii" cmds.file(save=True, type=file_type) def open_file(filepath): return cmds.file(filepath, open=True, force=True) def current_file(): current_filepath = cmds.file(query=True, sceneName=True) if not current_filepath: return None return current_filepath def work_root(session): work_dir = session["AVALON_WORKDIR"] scene_dir = None # Query scene file rule from workspace.mel if it exists in WORKDIR # We are parsing the workspace.mel manually as opposed to temporarily # setting the Workspace in Maya in a context manager since Maya had a # tendency to crash on frequently changing the workspace when this # function was called many times as one scrolled through Work Files assets. workspace_mel = os.path.join(work_dir, "workspace.mel") if os.path.exists(workspace_mel): scene_rule = 'workspace -fr "scene" ' # We need to use builtins as `open` is overridden by the workio API open_file = __builtins__["open"] with open_file(workspace_mel, "r") as f: for line in f: if line.strip().startswith(scene_rule): # remainder == "rule"; remainder = line[len(scene_rule):] # scene_dir == rule scene_dir = remainder.split('"')[1] else: # We can't query a workspace that does not exist # so we return similar to what we do in other hosts. scene_dir = session.get("AVALON_SCENEDIR") if scene_dir: return os.path.join(work_dir, scene_dir) else: return work_dir ================================================ FILE: openpype/hosts/maya/hooks/pre_auto_load_plugins.py ================================================ from openpype.lib.applications import PreLaunchHook, LaunchTypes class MayaPreAutoLoadPlugins(PreLaunchHook): """Define -noAutoloadPlugins command flag.""" # Before AddLastWorkfileToLaunchArgs order = 9 app_groups = {"maya"} launch_types = {LaunchTypes.local} def execute(self): # Ignore if there's no last workfile to start. if not self.data.get("start_last_workfile"): return maya_settings = self.data["project_settings"]["maya"] enabled = maya_settings["explicit_plugins_loading"]["enabled"] if enabled: # Force disable the `AddLastWorkfileToLaunchArgs`. self.data.pop("start_last_workfile") # Force post initialization so our dedicated plug-in load can run # prior to Maya opening a scene file. key = "OPENPYPE_OPEN_WORKFILE_POST_INITIALIZATION" self.launch_context.env[key] = "1" self.log.debug("Explicit plugins loading.") self.launch_context.launch_args.append("-noAutoloadPlugins") ================================================ FILE: openpype/hosts/maya/hooks/pre_copy_mel.py ================================================ from openpype.lib.applications import PreLaunchHook, LaunchTypes from openpype.hosts.maya.lib import create_workspace_mel class PreCopyMel(PreLaunchHook): """Copy workspace.mel to workdir. Hook `GlobalHostDataHook` must be executed before this hook. """ app_groups = {"maya", "mayapy"} launch_types = {LaunchTypes.local} def execute(self): project_doc = self.data["project_doc"] workdir = self.launch_context.env.get("AVALON_WORKDIR") if not workdir: self.log.warning("BUG: Workdir is not filled.") return project_settings = self.data["project_settings"] create_workspace_mel(workdir, project_doc["name"], project_settings) ================================================ FILE: openpype/hosts/maya/hooks/pre_open_workfile_post_initialization.py ================================================ from openpype.lib.applications import PreLaunchHook, LaunchTypes class MayaPreOpenWorkfilePostInitialization(PreLaunchHook): """Define whether open last workfile should run post initialize.""" # Before AddLastWorkfileToLaunchArgs. order = 9 app_groups = {"maya"} launch_types = {LaunchTypes.local} def execute(self): # Ignore if there's no last workfile to start. if not self.data.get("start_last_workfile"): return maya_settings = self.data["project_settings"]["maya"] enabled = maya_settings["open_workfile_post_initialization"] if enabled: # Force disable the `AddLastWorkfileToLaunchArgs`. self.data.pop("start_last_workfile") self.log.debug("Opening workfile post initialization.") key = "OPENPYPE_OPEN_WORKFILE_POST_INITIALIZATION" self.launch_context.env[key] = "1" ================================================ FILE: openpype/hosts/maya/lib.py ================================================ import os from openpype.settings import get_project_settings from openpype.lib import Logger def create_workspace_mel(workdir, project_name, project_settings=None): dst_filepath = os.path.join(workdir, "workspace.mel") if os.path.exists(dst_filepath): return if not os.path.exists(workdir): os.makedirs(workdir) if not project_settings: project_settings = get_project_settings(project_name) mel_script = project_settings["maya"].get("mel_workspace") # Skip if mel script in settings is empty if not mel_script: log = Logger.get_logger("create_workspace_mel") log.debug("File 'workspace.mel' not created. Settings value is empty.") return with open(dst_filepath, "w") as mel_file: mel_file.write(mel_script) ================================================ FILE: openpype/hosts/maya/plugins/__init__.py ================================================ ================================================ FILE: openpype/hosts/maya/plugins/create/convert_legacy.py ================================================ from openpype.pipeline.create.creator_plugins import SubsetConvertorPlugin from openpype.hosts.maya.api import plugin from openpype.hosts.maya.api.lib import read from openpype.client import get_asset_by_name from maya import cmds from maya.app.renderSetup.model import renderSetup class MayaLegacyConvertor(SubsetConvertorPlugin, plugin.MayaCreatorBase): """Find and convert any legacy subsets in the scene. This Convertor will find all legacy subsets in the scene and will transform them to the current system. Since the old subsets doesn't retain any information about their original creators, the only mapping we can do is based on their families. Its limitation is that you can have multiple creators creating subset of the same family and there is no way to handle it. This code should nevertheless cover all creators that came with OpenPype. """ identifier = "io.openpype.creators.maya.legacy" # Cases where the identifier or new family doesn't correspond to the # original family on the legacy instances special_family_conversions = { "rendering": "io.openpype.creators.maya.renderlayer", } def find_instances(self): self.cache_subsets(self.collection_shared_data) legacy = self.collection_shared_data.get("maya_cached_legacy_subsets") if not legacy: return self.add_convertor_item("Convert legacy instances") def convert(self): self.remove_convertor_item() # We can't use the collected shared data cache here # we re-query it here directly to convert all found. cache = {} self.cache_subsets(cache) legacy = cache.get("maya_cached_legacy_subsets") if not legacy: return # From all current new style manual creators find the mapping # from family to identifier family_to_id = {} for identifier, creator in self.create_context.creators.items(): family = getattr(creator, "family", None) if not family: continue if family in family_to_id: # We have a clash of family -> identifier. Multiple # new style creators use the same family self.log.warning("Clash on family->identifier: " "{}".format(identifier)) family_to_id[family] = identifier family_to_id.update(self.special_family_conversions) # We also embed the current 'task' into the instance since legacy # instances didn't store that data on the instances. The old style # logic was thus to be live to the current task to begin with. data = dict() data["task"] = self.create_context.get_current_task_name() for family, instance_nodes in legacy.items(): if family not in family_to_id: self.log.warning( "Unable to convert legacy instance with family '{}'" " because there is no matching new creator's family" "".format(family) ) continue creator_id = family_to_id[family] creator = self.create_context.creators[creator_id] data["creator_identifier"] = creator_id if isinstance(creator, plugin.RenderlayerCreator): self._convert_per_renderlayer(instance_nodes, data, creator) else: self._convert_regular(instance_nodes, data) def _convert_regular(self, instance_nodes, data): # We only imprint the creator identifier for it to identify # as the new style creator for instance_node in instance_nodes: self.imprint_instance_node(instance_node, data=data.copy()) def _convert_per_renderlayer(self, instance_nodes, data, creator): # Split the instance into an instance per layer rs = renderSetup.instance() layers = rs.getRenderLayers() if not layers: self.log.error( "Can't convert legacy renderlayer instance because no existing" " renderSetup layers exist in the scene." ) return creator_attribute_names = { attr_def.key for attr_def in creator.get_instance_attr_defs() } for instance_node in instance_nodes: # Ensure we have the new style singleton node generated # TODO: Make function public singleton_node = creator._get_singleton_node() if singleton_node: self.log.error( "Can't convert legacy renderlayer instance '{}' because" " new style instance '{}' already exists".format( instance_node, singleton_node ) ) continue creator.create_singleton_node() # We are creating new nodes to replace the original instance # Copy the attributes of the original instance to the new node original_data = read(instance_node) # The family gets converted to the new family (this is due to # "rendering" family being converted to "renderlayer" family) original_data["family"] = creator.family # recreate subset name as without it would be # `renderingMain` vs correct `renderMain` project_name = self.create_context.get_current_project_name() asset_doc = get_asset_by_name(project_name, original_data["asset"]) subset_name = creator.get_subset_name( original_data["variant"], data["task"], asset_doc, project_name) original_data["subset"] = subset_name # Convert to creator attributes when relevant creator_attributes = {} for key in list(original_data.keys()): # Iterate in order of the original attributes to preserve order # in the output creator attributes if key in creator_attribute_names: creator_attributes[key] = original_data.pop(key) original_data["creator_attributes"] = creator_attributes # For layer in maya layers for layer in layers: layer_instance_node = creator.find_layer_instance_node(layer) if not layer_instance_node: # TODO: Make function public layer_instance_node = creator._create_layer_instance_node( layer ) # Transfer the main attributes of the original instance layer_data = original_data.copy() layer_data.update(data) self.imprint_instance_node(layer_instance_node, data=layer_data) # Delete the legacy instance node cmds.delete(instance_node) ================================================ FILE: openpype/hosts/maya/plugins/create/create_animation_pointcache.py ================================================ from maya import cmds from openpype.hosts.maya.api import lib, plugin from openpype.lib import ( BoolDef, NumberDef, ) from openpype.pipeline import CreatedInstance def _get_animation_attr_defs(cls): """Get Animation generic definitions.""" defs = lib.collect_animation_defs() defs.extend( [ BoolDef("farm", label="Submit to Farm"), NumberDef("priority", label="Farm job Priority", default=50), BoolDef("refresh", label="Refresh viewport during export"), BoolDef( "includeParentHierarchy", label="Include Parent Hierarchy" ), BoolDef( "includeUserDefinedAttributes", label="Include User Defined Attributes" ), ] ) return defs def extract_alembic_attributes(node_data, class_name): """This is a legacy transfer of creator attributes to publish attributes for ExtractAlembic/ExtractAnimation plugin. """ publish_attributes = node_data["publish_attributes"] if class_name in publish_attributes: return node_data extract_alembic_flags = [ "writeColorSets", "writeFaceSets", "writeNormals", "renderableOnly", "visibleOnly", "worldSpace", "renderableOnly" ] extract_alembic_attributes = [ "attr", "attrPrefix", "visibleOnly" ] attributes = extract_alembic_flags + extract_alembic_attributes plugin_attributes = {"flags": []} for attr in attributes: if attr not in node_data["creator_attributes"].keys(): continue value = node_data["creator_attributes"].pop(attr) if value and attr in extract_alembic_flags: plugin_attributes["flags"].append(attr) if attr in extract_alembic_attributes: plugin_attributes[attr] = value publish_attributes[class_name] = plugin_attributes return node_data class CreateAnimation(plugin.MayaHiddenCreator): """Animation output for character rigs We hide the animation creator from the UI since the creation of it is automated upon loading a rig. There's an inventory action to recreate it for loaded rigs if by chance someone deleted the animation instance. """ identifier = "io.openpype.creators.maya.animation" name = "animationDefault" label = "Animation" family = "animation" icon = "male" write_color_sets = False write_face_sets = False include_parent_hierarchy = False include_user_defined_attributes = False def collect_instances(self): try: cached_subsets = self.collection_shared_data["maya_cached_subsets"] except KeyError: self.cache_subsets(self.collection_shared_data) cached_subsets = self.collection_shared_data["maya_cached_subsets"] for node in cached_subsets.get(self.identifier, []): node_data = self.read_instance_node(node) node_data = extract_alembic_attributes( node_data, "ExtractAnimation" ) created_instance = CreatedInstance.from_existing(node_data, self) self._add_instance_to_context(created_instance) def get_instance_attr_defs(self): super(CreateAnimation, self).get_instance_attr_defs() defs = _get_animation_attr_defs(self) return defs class CreatePointCache(plugin.MayaCreator): """Alembic pointcache for animated data""" identifier = "io.openpype.creators.maya.pointcache" label = "Pointcache" family = "pointcache" icon = "gears" write_color_sets = False write_face_sets = False include_user_defined_attributes = False def collect_instances(self): try: cached_subsets = self.collection_shared_data["maya_cached_subsets"] except KeyError: self.cache_subsets(self.collection_shared_data) cached_subsets = self.collection_shared_data["maya_cached_subsets"] for node in cached_subsets.get(self.identifier, []): node_data = self.read_instance_node(node) node_data = extract_alembic_attributes(node_data, "ExtractAlembic") created_instance = CreatedInstance.from_existing(node_data, self) self._add_instance_to_context(created_instance) def get_instance_attr_defs(self): super(CreatePointCache, self).get_instance_attr_defs() defs = _get_animation_attr_defs(self) return defs def create(self, subset_name, instance_data, pre_create_data): instance = super(CreatePointCache, self).create( subset_name, instance_data, pre_create_data ) instance_node = instance.get("instance_node") # For Arnold standin proxy proxy_set = cmds.sets(name=instance_node + "_proxy_SET", empty=True) cmds.sets(proxy_set, forceElement=instance_node) ================================================ FILE: openpype/hosts/maya/plugins/create/create_arnold_scene_source.py ================================================ from maya import cmds from openpype.hosts.maya.api import ( lib, plugin ) from openpype.lib import ( NumberDef, BoolDef ) class CreateArnoldSceneSource(plugin.MayaCreator): """Arnold Scene Source""" identifier = "io.openpype.creators.maya.ass" label = "Arnold Scene Source" family = "ass" icon = "cube" settings_name = "CreateAss" expandProcedurals = False motionBlur = True motionBlurKeys = 2 motionBlurLength = 0.5 maskOptions = False maskCamera = False maskLight = False maskShape = False maskShader = False maskOverride = False maskDriver = False maskFilter = False maskColor_manager = False maskOperator = False def get_instance_attr_defs(self): defs = lib.collect_animation_defs() defs.extend([ BoolDef("expandProcedural", label="Expand Procedural", default=self.expandProcedurals), BoolDef("motionBlur", label="Motion Blur", default=self.motionBlur), NumberDef("motionBlurKeys", label="Motion Blur Keys", decimals=0, default=self.motionBlurKeys), NumberDef("motionBlurLength", label="Motion Blur Length", decimals=3, default=self.motionBlurLength), # Masks BoolDef("maskOptions", label="Export Options", default=self.maskOptions), BoolDef("maskCamera", label="Export Cameras", default=self.maskCamera), BoolDef("maskLight", label="Export Lights", default=self.maskLight), BoolDef("maskShape", label="Export Shapes", default=self.maskShape), BoolDef("maskShader", label="Export Shaders", default=self.maskShader), BoolDef("maskOverride", label="Export Override Nodes", default=self.maskOverride), BoolDef("maskDriver", label="Export Drivers", default=self.maskDriver), BoolDef("maskFilter", label="Export Filters", default=self.maskFilter), BoolDef("maskOperator", label="Export Operators", default=self.maskOperator), BoolDef("maskColor_manager", label="Export Color Managers", default=self.maskColor_manager), ]) return defs class CreateArnoldSceneSourceProxy(CreateArnoldSceneSource): """Arnold Scene Source Proxy This product type facilitates working with proxy geometry in the viewport. """ identifier = "io.openpype.creators.maya.assproxy" label = "Arnold Scene Source Proxy" family = "assProxy" icon = "cube" def create(self, subset_name, instance_data, pre_create_data): instance = super(CreateArnoldSceneSource, self).create( subset_name, instance_data, pre_create_data ) instance_node = instance.get("instance_node") proxy = cmds.sets(name=instance_node + "_proxy_SET", empty=True) cmds.sets([proxy], forceElement=instance_node) ================================================ FILE: openpype/hosts/maya/plugins/create/create_assembly.py ================================================ from openpype.hosts.maya.api import plugin class CreateAssembly(plugin.MayaCreator): """A grouped package of loaded content""" identifier = "io.openpype.creators.maya.assembly" label = "Assembly" family = "assembly" icon = "cubes" ================================================ FILE: openpype/hosts/maya/plugins/create/create_camera.py ================================================ from openpype.hosts.maya.api import ( lib, plugin ) from openpype.lib import BoolDef class CreateCamera(plugin.MayaCreator): """Single baked camera""" identifier = "io.openpype.creators.maya.camera" label = "Camera" family = "camera" icon = "video-camera" def get_instance_attr_defs(self): defs = lib.collect_animation_defs() defs.extend([ BoolDef("bakeToWorldSpace", label="Bake to World-Space", tooltip="Bake to World-Space", default=True), ]) return defs class CreateCameraRig(plugin.MayaCreator): """Complex hierarchy with camera.""" identifier = "io.openpype.creators.maya.camerarig" label = "Camera Rig" family = "camerarig" icon = "video-camera" ================================================ FILE: openpype/hosts/maya/plugins/create/create_layout.py ================================================ from openpype.hosts.maya.api import plugin from openpype.lib import BoolDef class CreateLayout(plugin.MayaCreator): """A grouped package of loaded content""" identifier = "io.openpype.creators.maya.layout" label = "Layout" family = "layout" icon = "cubes" def get_instance_attr_defs(self): return [ BoolDef("groupLoadedAssets", label="Group Loaded Assets", tooltip="Enable this when you want to publish group of " "loaded asset", default=False) ] ================================================ FILE: openpype/hosts/maya/plugins/create/create_look.py ================================================ from openpype.hosts.maya.api import ( plugin, lib ) from openpype.lib import ( BoolDef, TextDef ) class CreateLook(plugin.MayaCreator): """Shader connections defining shape look""" identifier = "io.openpype.creators.maya.look" label = "Look" family = "look" icon = "paint-brush" make_tx = True rs_tex = False def get_instance_attr_defs(self): return [ # TODO: This value should actually get set on create! TextDef("renderLayer", # TODO: Bug: Hidden attribute's label is still shown in UI? hidden=True, default=lib.get_current_renderlayer(), label="Renderlayer", tooltip="Renderlayer to extract the look from"), BoolDef("maketx", label="MakeTX", tooltip="Whether to generate .tx files for your textures", default=self.make_tx), BoolDef("rstex", label="Convert textures to .rstex", tooltip="Whether to generate Redshift .rstex files for " "your textures", default=self.rs_tex) ] def get_pre_create_attr_defs(self): # Show same attributes on create but include use selection defs = super(CreateLook, self).get_pre_create_attr_defs() defs.extend(self.get_instance_attr_defs()) return defs ================================================ FILE: openpype/hosts/maya/plugins/create/create_matchmove.py ================================================ from openpype.hosts.maya.api import ( lib, plugin ) from openpype.lib import BoolDef class CreateMatchmove(plugin.MayaCreator): """Instance for more complex setup of cameras. Might contain multiple cameras, geometries etc. It is expected to be extracted into .abc or .ma """ identifier = "io.openpype.creators.maya.matchmove" label = "Matchmove" family = "matchmove" icon = "video-camera" def get_instance_attr_defs(self): defs = lib.collect_animation_defs() defs.extend([ BoolDef("bakeToWorldSpace", label="Bake Cameras to World-Space", tooltip="Bake Cameras to World-Space", default=True), ]) return defs ================================================ FILE: openpype/hosts/maya/plugins/create/create_maya_usd.py ================================================ from openpype.hosts.maya.api import plugin, lib from openpype.lib import ( BoolDef, EnumDef, TextDef ) from maya import cmds class CreateMayaUsd(plugin.MayaCreator): """Create Maya USD Export""" identifier = "io.openpype.creators.maya.mayausd" label = "Maya USD" family = "usd" icon = "cubes" description = "Create Maya USD Export" cache = {} def get_publish_families(self): return ["usd", "mayaUsd"] def get_instance_attr_defs(self): if "jobContextItems" not in self.cache: # Query once instead of per instance job_context_items = {} try: cmds.loadPlugin("mayaUsdPlugin", quiet=True) job_context_items = { cmds.mayaUSDListJobContexts(jobContext=name): name for name in cmds.mayaUSDListJobContexts(export=True) or [] } except RuntimeError: # Likely `mayaUsdPlugin` plug-in not available self.log.warning("Unable to retrieve available job " "contexts for `mayaUsdPlugin` exports") if not job_context_items: # enumdef multiselection may not be empty job_context_items = [""] self.cache["jobContextItems"] = job_context_items defs = lib.collect_animation_defs() defs.extend([ EnumDef("defaultUSDFormat", label="File format", items={ "usdc": "Binary", "usda": "ASCII" }, default="usdc"), BoolDef("stripNamespaces", label="Strip Namespaces", tooltip=( "Remove namespaces during export. By default, " "namespaces are exported to the USD file in the " "following format: nameSpaceExample_pPlatonic1" ), default=True), BoolDef("mergeTransformAndShape", label="Merge Transform and Shape", tooltip=( "Combine Maya transform and shape into a single USD" "prim that has transform and geometry, for all" " \"geometric primitives\" (gprims).\n" "This results in smaller and faster scenes. Gprims " "will be \"unpacked\" back into transform and shape " "nodes when imported into Maya from USD." ), default=True), BoolDef("includeUserDefinedAttributes", label="Include User Defined Attributes", tooltip=( "Whether to include all custom maya attributes found " "on nodes as metadata (userProperties) in USD." ), default=False), TextDef("attr", label="Custom Attributes", default="", placeholder="attr1, attr2"), TextDef("attrPrefix", label="Custom Attributes Prefix", default="", placeholder="prefix1, prefix2"), EnumDef("jobContext", label="Job Context", items=self.cache["jobContextItems"], tooltip=( "Specifies an additional export context to handle.\n" "These usually contain extra schemas, primitives,\n" "and materials that are to be exported for a " "specific\ntask, a target renderer for example." ), multiselection=True), ]) return defs ================================================ FILE: openpype/hosts/maya/plugins/create/create_mayascene.py ================================================ from openpype.hosts.maya.api import plugin class CreateMayaScene(plugin.MayaCreator): """Raw Maya Scene file export""" identifier = "io.openpype.creators.maya.mayascene" name = "mayaScene" label = "Maya Scene" family = "mayaScene" icon = "file-archive-o" ================================================ FILE: openpype/hosts/maya/plugins/create/create_model.py ================================================ from openpype.hosts.maya.api import plugin from openpype.lib import ( BoolDef, TextDef ) class CreateModel(plugin.MayaCreator): """Polygonal static geometry""" identifier = "io.openpype.creators.maya.model" label = "Model" family = "model" icon = "cube" default_variants = ["Main", "Proxy", "_MD", "_HD", "_LD"] write_color_sets = False write_face_sets = False def get_instance_attr_defs(self): return [ BoolDef("writeColorSets", label="Write vertex colors", tooltip="Write vertex colors with the geometry", default=self.write_color_sets), BoolDef("writeFaceSets", label="Write face sets", tooltip="Write face sets with the geometry", default=self.write_face_sets), BoolDef("includeParentHierarchy", label="Include Parent Hierarchy", tooltip="Whether to include parent hierarchy of nodes in " "the publish instance", default=False), TextDef("attr", label="Custom Attributes", default="", placeholder="attr1, attr2"), TextDef("attrPrefix", label="Custom Attributes Prefix", placeholder="prefix1, prefix2") ] ================================================ FILE: openpype/hosts/maya/plugins/create/create_multishot_layout.py ================================================ from ayon_api import ( get_folder_by_name, get_folder_by_path, get_folders, ) from maya import cmds # noqa: F401 from openpype import AYON_SERVER_ENABLED from openpype.client import get_assets from openpype.hosts.maya.api import plugin from openpype.lib import BoolDef, EnumDef, TextDef from openpype.pipeline import ( Creator, get_current_asset_name, get_current_project_name, ) from openpype.pipeline.create import CreatorError class CreateMultishotLayout(plugin.MayaCreator): """Create a multi-shot layout in the Maya scene. This creator will create a Camera Sequencer in the Maya scene based on the shots found under the specified folder. The shots will be added to the sequencer in the order of their clipIn and clipOut values. For each shot a Layout will be created. """ identifier = "io.openpype.creators.maya.multishotlayout" label = "Multi-shot Layout" family = "layout" icon = "project-diagram" def get_pre_create_attr_defs(self): # Present artist with a list of parents of the current context # to choose from. This will be used to get the shots under the # selected folder to create the Camera Sequencer. """ Todo: `get_folder_by_name` should be switched to `get_folder_by_path` once the fork to pure AYON is done. Warning: this will not work for projects where the asset name is not unique across the project until the switch mentioned above is done. """ project_name = get_current_project_name() folder_path = get_current_asset_name() if "/" in folder_path: current_folder = get_folder_by_path(project_name, folder_path) else: current_folder = get_folder_by_name( project_name, folder_name=folder_path ) current_path_parts = current_folder["path"].split("/") # populate the list with parents of the current folder # this will create menu items like: # [ # { # "value": "", # "label": "project (shots directly under the project)" # }, { # "value": "shots/shot_01", "label": "shot_01 (current)" # }, { # "value": "shots", "label": "shots" # } # ] # add the project as the first item items_with_label = [ { "label": f"{self.project_name} " "(shots directly under the project)", "value": "" } ] # go through the current folder path and add each part to the list, # but mark the current folder. for part_idx, part in enumerate(current_path_parts): label = part if label == current_folder["name"]: label = f"{label} (current)" value = "/".join(current_path_parts[:part_idx + 1]) items_with_label.append({"label": label, "value": value}) return [ EnumDef("shotParent", default=current_folder["name"], label="Shot Parent Folder", items=items_with_label, ), BoolDef("groupLoadedAssets", label="Group Loaded Assets", tooltip="Enable this when you want to publish group of " "loaded asset", default=False), TextDef("taskName", label="Associated Task Name", tooltip=("Task name to be associated " "with the created Layout"), default="layout"), ] def create(self, subset_name, instance_data, pre_create_data): shots = list( self.get_related_shots(folder_path=pre_create_data["shotParent"]) ) if not shots: # There are no shot folders under the specified folder. # We are raising an error here but in the future we might # want to create a new shot folders by publishing the layouts # and shot defined in the sequencer. Sort of editorial publish # in side of Maya. raise CreatorError(( "No shots found under the specified " f"folder: {pre_create_data['shotParent']}.")) # Get layout creator layout_creator_id = "io.openpype.creators.maya.layout" layout_creator: Creator = self.create_context.creators.get( layout_creator_id) if not layout_creator: raise CreatorError( f"Creator {layout_creator_id} not found.") # Get OpenPype style asset documents for the shots op_asset_docs = get_assets( self.project_name, [s["id"] for s in shots]) asset_docs_by_id = {doc["_id"]: doc for doc in op_asset_docs} for shot in shots: # we are setting shot name to be displayed in the sequencer to # `shot name (shot label)` if the label is set, otherwise just # `shot name`. So far, labels are used only when the name is set # with characters that are not allowed in the shot name. if not shot["active"]: continue # get task for shot asset_doc = asset_docs_by_id[shot["id"]] tasks = asset_doc.get("data").get("tasks").keys() layout_task = None if pre_create_data["taskName"] in tasks: layout_task = pre_create_data["taskName"] shot_name = f"{shot['name']}%s" % ( f" ({shot['label']})" if shot["label"] else "") cmds.shot(sequenceStartTime=shot["attrib"]["clipIn"], sequenceEndTime=shot["attrib"]["clipOut"], shotName=shot_name) # Create layout instance by the layout creator instance_data = { "folderPath": shot["path"], "variant": layout_creator.get_default_variant() } if layout_task: instance_data["task"] = layout_task layout_creator.create( subset_name=layout_creator.get_subset_name( layout_creator.get_default_variant(), self.create_context.get_current_task_name(), asset_doc, self.project_name), instance_data=instance_data, pre_create_data={ "groupLoadedAssets": pre_create_data["groupLoadedAssets"] } ) def get_related_shots(self, folder_path: str): """Get all shots related to the current asset. Get all folders of type Shot under specified folder. Args: folder_path (str): Path of the folder. Returns: list: List of dicts with folder data. """ # if folder_path is None, project is selected as a root # and its name is used as a parent id parent_id = self.project_name if folder_path: current_folder = get_folder_by_path( project_name=self.project_name, folder_path=folder_path, ) parent_id = current_folder["id"] # get all child folders of the current one return get_folders( project_name=self.project_name, parent_ids=[parent_id], fields=[ "attrib.clipIn", "attrib.clipOut", "attrib.frameStart", "attrib.frameEnd", "name", "label", "path", "folderType", "id" ] ) # blast this creator if Ayon server is not enabled if not AYON_SERVER_ENABLED: del CreateMultishotLayout ================================================ FILE: openpype/hosts/maya/plugins/create/create_multiverse_look.py ================================================ from openpype.hosts.maya.api import plugin from openpype.lib import ( BoolDef, EnumDef ) class CreateMultiverseLook(plugin.MayaCreator): """Create Multiverse Look""" identifier = "io.openpype.creators.maya.mvlook" label = "Multiverse Look" family = "mvLook" icon = "cubes" def get_instance_attr_defs(self): return [ EnumDef("fileFormat", label="File Format", tooltip="USD export file format", items=["usda", "usd"], default="usda"), BoolDef("publishMipMap", label="Publish MipMap", default=True), ] ================================================ FILE: openpype/hosts/maya/plugins/create/create_multiverse_usd.py ================================================ from openpype.hosts.maya.api import plugin, lib from openpype.lib import ( BoolDef, NumberDef, TextDef, EnumDef ) class CreateMultiverseUsd(plugin.MayaCreator): """Create Multiverse USD Asset""" identifier = "io.openpype.creators.maya.mvusdasset" label = "Multiverse USD Asset" family = "usd" icon = "cubes" description = "Create Multiverse USD Asset" def get_publish_families(self): return ["usd", "mvUsd"] def get_instance_attr_defs(self): defs = lib.collect_animation_defs(fps=True) defs.extend([ EnumDef("fileFormat", label="File format", items=["usd", "usda", "usdz"], default="usd"), BoolDef("stripNamespaces", label="Strip Namespaces", default=True), BoolDef("mergeTransformAndShape", label="Merge Transform and Shape", default=False), BoolDef("writeAncestors", label="Write Ancestors", default=True), BoolDef("flattenParentXforms", label="Flatten Parent Xforms", default=False), BoolDef("writeSparseOverrides", label="Write Sparse Overrides", default=False), BoolDef("useMetaPrimPath", label="Use Meta Prim Path", default=False), TextDef("customRootPath", label="Custom Root Path", default=''), TextDef("customAttributes", label="Custom Attributes", tooltip="Comma-separated list of attribute names", default=''), TextDef("nodeTypesToIgnore", label="Node Types to Ignore", tooltip="Comma-separated list of node types to be ignored", default=''), BoolDef("writeMeshes", label="Write Meshes", default=True), BoolDef("writeCurves", label="Write Curves", default=True), BoolDef("writeParticles", label="Write Particles", default=True), BoolDef("writeCameras", label="Write Cameras", default=False), BoolDef("writeLights", label="Write Lights", default=False), BoolDef("writeJoints", label="Write Joints", default=False), BoolDef("writeCollections", label="Write Collections", default=False), BoolDef("writePositions", label="Write Positions", default=True), BoolDef("writeNormals", label="Write Normals", default=True), BoolDef("writeUVs", label="Write UVs", default=True), BoolDef("writeColorSets", label="Write Color Sets", default=False), BoolDef("writeTangents", label="Write Tangents", default=False), BoolDef("writeRefPositions", label="Write Ref Positions", default=True), BoolDef("writeBlendShapes", label="Write BlendShapes", default=False), BoolDef("writeDisplayColor", label="Write Display Color", default=True), BoolDef("writeSkinWeights", label="Write Skin Weights", default=False), BoolDef("writeMaterialAssignment", label="Write Material Assignment", default=False), BoolDef("writeHardwareShader", label="Write Hardware Shader", default=False), BoolDef("writeShadingNetworks", label="Write Shading Networks", default=False), BoolDef("writeTransformMatrix", label="Write Transform Matrix", default=True), BoolDef("writeUsdAttributes", label="Write USD Attributes", default=True), BoolDef("writeInstancesAsReferences", label="Write Instances as References", default=False), BoolDef("timeVaryingTopology", label="Time Varying Topology", default=False), TextDef("customMaterialNamespace", label="Custom Material Namespace", default=''), NumberDef("numTimeSamples", label="Num Time Samples", default=1), NumberDef("timeSamplesSpan", label="Time Samples Span", default=0.0), ]) return defs ================================================ FILE: openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py ================================================ from openpype.hosts.maya.api import plugin, lib from openpype.lib import ( BoolDef, NumberDef, EnumDef ) class CreateMultiverseUsdComp(plugin.MayaCreator): """Create Multiverse USD Composition""" identifier = "io.openpype.creators.maya.mvusdcomposition" label = "Multiverse USD Composition" family = "mvUsdComposition" icon = "cubes" def get_instance_attr_defs(self): defs = lib.collect_animation_defs(fps=True) defs.extend([ EnumDef("fileFormat", label="File format", items=["usd", "usda"], default="usd"), BoolDef("stripNamespaces", label="Strip Namespaces", default=False), BoolDef("mergeTransformAndShape", label="Merge Transform and Shape", default=False), BoolDef("flattenContent", label="Flatten Content", default=False), BoolDef("writeAsCompoundLayers", label="Write As Compound Layers", default=False), BoolDef("writePendingOverrides", label="Write Pending Overrides", default=False), NumberDef("numTimeSamples", label="Num Time Samples", default=1), NumberDef("timeSamplesSpan", label="Time Samples Span", default=0.0), ]) return defs ================================================ FILE: openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py ================================================ from openpype.hosts.maya.api import plugin, lib from openpype.lib import ( BoolDef, NumberDef, EnumDef ) class CreateMultiverseUsdOver(plugin.MayaCreator): """Create Multiverse USD Override""" identifier = "io.openpype.creators.maya.mvusdoverride" label = "Multiverse USD Override" family = "mvUsdOverride" icon = "cubes" def get_instance_attr_defs(self): defs = lib.collect_animation_defs(fps=True) defs.extend([ EnumDef("fileFormat", label="File format", items=["usd", "usda"], default="usd"), BoolDef("writeAll", label="Write All", default=False), BoolDef("writeTransforms", label="Write Transforms", default=True), BoolDef("writeVisibility", label="Write Visibility", default=True), BoolDef("writeAttributes", label="Write Attributes", default=True), BoolDef("writeMaterials", label="Write Materials", default=True), BoolDef("writeVariants", label="Write Variants", default=True), BoolDef("writeVariantsDefinition", label="Write Variants Definition", default=True), BoolDef("writeActiveState", label="Write Active State", default=True), BoolDef("writeNamespaces", label="Write Namespaces", default=False), NumberDef("numTimeSamples", label="Num Time Samples", default=1), NumberDef("timeSamplesSpan", label="Time Samples Span", default=0.0), ]) return defs ================================================ FILE: openpype/hosts/maya/plugins/create/create_proxy_abc.py ================================================ from openpype.hosts.maya.api import ( lib, plugin ) from openpype.lib import ( BoolDef, TextDef ) class CreateProxyAlembic(plugin.MayaCreator): """Proxy Alembic for animated data""" identifier = "io.openpype.creators.maya.proxyabc" label = "Proxy Alembic" family = "proxyAbc" icon = "gears" write_color_sets = False write_face_sets = False def get_instance_attr_defs(self): defs = lib.collect_animation_defs() defs.extend([ BoolDef("writeColorSets", label="Write vertex colors", tooltip="Write vertex colors with the geometry", default=self.write_color_sets), BoolDef("writeFaceSets", label="Write face sets", tooltip="Write face sets with the geometry", default=self.write_face_sets), BoolDef("worldSpace", label="World-Space Export", default=True), TextDef("nameSuffix", label="Name Suffix for Bounding Box", default="_BBox", placeholder="_BBox"), TextDef("attr", label="Custom Attributes", default="", placeholder="attr1, attr2"), TextDef("attrPrefix", label="Custom Attributes Prefix", placeholder="prefix1, prefix2") ]) return defs ================================================ FILE: openpype/hosts/maya/plugins/create/create_redshift_proxy.py ================================================ # -*- coding: utf-8 -*- """Creator of Redshift proxy subset types.""" from openpype.hosts.maya.api import plugin, lib from openpype.lib import BoolDef class CreateRedshiftProxy(plugin.MayaCreator): """Create instance of Redshift Proxy subset.""" identifier = "io.openpype.creators.maya.redshiftproxy" label = "Redshift Proxy" family = "redshiftproxy" icon = "gears" def get_instance_attr_defs(self): defs = [ BoolDef("animation", label="Export animation", default=False) ] defs.extend(lib.collect_animation_defs()) return defs ================================================ FILE: openpype/hosts/maya/plugins/create/create_render.py ================================================ # -*- coding: utf-8 -*- """Create ``Render`` instance in Maya.""" from openpype.hosts.maya.api import ( lib_rendersettings, plugin ) from openpype.pipeline import CreatorError from openpype.lib import ( BoolDef, NumberDef, ) class CreateRenderlayer(plugin.RenderlayerCreator): """Create and manages renderlayer subset per renderLayer in workfile. This generates a single node in the scene which tells the Creator to if it exists collect Maya rendersetup renderlayers as individual instances. As such, triggering create doesn't actually create the instance node per layer but only the node which tells the Creator it may now collect the renderlayers. """ identifier = "io.openpype.creators.maya.renderlayer" family = "renderlayer" label = "Render" icon = "eye" layer_instance_prefix = "render" singleton_node_name = "renderingMain" render_settings = {} @classmethod def apply_settings(cls, project_settings): cls.render_settings = project_settings["maya"]["RenderSettings"] def create(self, subset_name, instance_data, pre_create_data): # Only allow a single render instance to exist if self._get_singleton_node(): raise CreatorError("A Render instance already exists - only " "one can be configured.") # Apply default project render settings on create if self.render_settings.get("apply_render_settings"): lib_rendersettings.RenderSettings().set_default_renderer_settings() super(CreateRenderlayer, self).create(subset_name, instance_data, pre_create_data) def get_instance_attr_defs(self): """Create instance settings.""" return [ BoolDef("review", label="Review", tooltip="Mark as reviewable", default=True), BoolDef("extendFrames", label="Extend Frames", tooltip="Extends the frames on top of the previous " "publish.\nIf the previous was 1001-1050 and you " "would now submit 1020-1070 only the new frames " "1051-1070 would be rendered and published " "together with the previously rendered frames.\n" "If 'overrideExistingFrame' is enabled it *will* " "render any existing frames.", default=False), BoolDef("overrideExistingFrame", label="Override Existing Frame", tooltip="Override existing rendered frames " "(if they exist).", default=True), # TODO: Should these move to submit_maya_deadline plugin? # Tile rendering BoolDef("tileRendering", label="Enable tiled rendering", default=False), NumberDef("tilesX", label="Tiles X", default=2, minimum=1, decimals=0), NumberDef("tilesY", label="Tiles Y", default=2, minimum=1, decimals=0), # Additional settings BoolDef("convertToScanline", label="Convert to Scanline", tooltip="Convert the output images to scanline images", default=False), BoolDef("useReferencedAovs", label="Use Referenced AOVs", tooltip="Consider the AOVs from referenced scenes as well", default=False), BoolDef("renderSetupIncludeLights", label="Render Setup Include Lights", default=self.render_settings.get("enable_all_lights", False)) ] ================================================ FILE: openpype/hosts/maya/plugins/create/create_rendersetup.py ================================================ from openpype.hosts.maya.api import plugin from openpype.pipeline import CreatorError class CreateRenderSetup(plugin.MayaCreator): """Create rendersetup template json data""" identifier = "io.openpype.creators.maya.rendersetup" label = "Render Setup Preset" family = "rendersetup" icon = "tablet" def get_pre_create_attr_defs(self): # Do not show the "use_selection" setting from parent class return [] def create(self, subset_name, instance_data, pre_create_data): existing_instance = None for instance in self.create_context.instances: if instance.family == self.family: existing_instance = instance break if existing_instance: raise CreatorError("A RenderSetup instance already exists - only " "one can be configured.") super(CreateRenderSetup, self).create(subset_name, instance_data, pre_create_data) ================================================ FILE: openpype/hosts/maya/plugins/create/create_review.py ================================================ import json from maya import cmds from openpype import AYON_SERVER_ENABLED from openpype.hosts.maya.api import ( lib, plugin ) from openpype.lib import ( BoolDef, NumberDef, EnumDef ) from openpype.pipeline import CreatedInstance from openpype.client import get_asset_by_name TRANSPARENCIES = [ "preset", "simple", "object sorting", "weighted average", "depth peeling", "alpha cut" ] class CreateReview(plugin.MayaCreator): """Playblast reviewable""" identifier = "io.openpype.creators.maya.review" label = "Review" family = "review" icon = "video-camera" useMayaTimeline = True panZoom = False # Overriding "create" method to prefill values from settings. def create(self, subset_name, instance_data, pre_create_data): members = list() if pre_create_data.get("use_selection"): members = cmds.ls(selection=True) project_name = self.project_name if AYON_SERVER_ENABLED: asset_name = instance_data["folderPath"] else: asset_name = instance_data["asset"] asset_doc = get_asset_by_name(project_name, asset_name) task_name = instance_data["task"] preset = lib.get_capture_preset( task_name, asset_doc["data"]["tasks"][task_name]["type"], subset_name, self.project_settings, self.log ) self.log.debug( "Using preset: {}".format( json.dumps(preset, indent=4, sort_keys=True) ) ) with lib.undo_chunk(): instance_node = cmds.sets(members, name=subset_name) instance_data["instance_node"] = instance_node instance = CreatedInstance( self.family, subset_name, instance_data, self) creator_attribute_defs_by_key = { x.key: x for x in instance.creator_attribute_defs } mapping = { "review_width": preset["Resolution"]["width"], "review_height": preset["Resolution"]["height"], "isolate": preset["Generic"]["isolate_view"], "imagePlane": preset["Viewport Options"]["imagePlane"], "panZoom": preset["Generic"]["pan_zoom"] } for key, value in mapping.items(): creator_attribute_defs_by_key[key].default = value self._add_instance_to_context(instance) self.imprint_instance_node(instance_node, data=instance.data_to_store()) return instance def get_instance_attr_defs(self): defs = lib.collect_animation_defs() # Option for using Maya or asset frame range in settings. if not self.useMayaTimeline: # Update the defaults to be the asset frame range frame_range = lib.get_frame_range() defs_by_key = {attr_def.key: attr_def for attr_def in defs} for key, value in frame_range.items(): if key not in defs_by_key: raise RuntimeError("Attribute definition not found to be " "updated for key: {}".format(key)) attr_def = defs_by_key[key] attr_def.default = value defs.extend([ NumberDef("review_width", label="Review width", tooltip="A value of zero will use the asset resolution.", decimals=0, minimum=0, default=0), NumberDef("review_height", label="Review height", tooltip="A value of zero will use the asset resolution.", decimals=0, minimum=0, default=0), BoolDef("keepImages", label="Keep Images", tooltip="Whether to also publish along the image sequence " "next to the video reviewable.", default=False), BoolDef("isolate", label="Isolate render members of instance", tooltip="When enabled only the members of the instance " "will be included in the playblast review.", default=False), BoolDef("imagePlane", label="Show Image Plane", default=True), EnumDef("transparency", label="Transparency", items=TRANSPARENCIES), BoolDef("panZoom", label="Enable camera pan/zoom", default=True), EnumDef("displayLights", label="Display Lights", items=lib.DISPLAY_LIGHTS_ENUM), ]) return defs ================================================ FILE: openpype/hosts/maya/plugins/create/create_rig.py ================================================ from maya import cmds from openpype.hosts.maya.api import plugin class CreateRig(plugin.MayaCreator): """Artist-friendly rig with controls to direct motion""" identifier = "io.openpype.creators.maya.rig" label = "Rig" family = "rig" icon = "wheelchair" def create(self, subset_name, instance_data, pre_create_data): instance = super(CreateRig, self).create(subset_name, instance_data, pre_create_data) instance_node = instance.get("instance_node") self.log.info("Creating Rig instance set up ...") # TODO:change name (_controls_SET -> _rigs_SET) controls = cmds.sets(name=subset_name + "_controls_SET", empty=True) # TODO:change name (_out_SET -> _geo_SET) pointcache = cmds.sets(name=subset_name + "_out_SET", empty=True) skeleton = cmds.sets( name=subset_name + "_skeletonAnim_SET", empty=True) skeleton_mesh = cmds.sets( name=subset_name + "_skeletonMesh_SET", empty=True) cmds.sets([controls, pointcache, skeleton, skeleton_mesh], forceElement=instance_node) ================================================ FILE: openpype/hosts/maya/plugins/create/create_setdress.py ================================================ from openpype.hosts.maya.api import plugin from openpype.lib import BoolDef class CreateSetDress(plugin.MayaCreator): """A grouped package of loaded content""" identifier = "io.openpype.creators.maya.setdress" label = "Set Dress" family = "setdress" icon = "cubes" default_variants = ["Main", "Anim"] def get_instance_attr_defs(self): return [ BoolDef("exactSetMembersOnly", label="Exact Set Members Only", default=True) ] ================================================ FILE: openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py ================================================ # -*- coding: utf-8 -*- """Creator for Unreal Skeletal Meshes.""" from openpype.hosts.maya.api import plugin, lib from openpype.lib import ( BoolDef, TextDef ) from maya import cmds # noqa class CreateUnrealSkeletalMesh(plugin.MayaCreator): """Unreal Static Meshes with collisions.""" identifier = "io.openpype.creators.maya.unrealskeletalmesh" label = "Unreal - Skeletal Mesh" family = "skeletalMesh" icon = "thumbs-up" dynamic_subset_keys = ["asset"] # Defined in settings joint_hints = set() def apply_settings(self, project_settings): """Apply project settings to creator""" settings = ( project_settings["maya"]["create"]["CreateUnrealSkeletalMesh"] ) self.joint_hints = set(settings.get("joint_hints", [])) def get_dynamic_data( self, variant, task_name, asset_doc, project_name, host_name, instance ): """ The default subset name templates for Unreal include {asset} and thus we should pass that along as dynamic data. """ dynamic_data = super(CreateUnrealSkeletalMesh, self).get_dynamic_data( variant, task_name, asset_doc, project_name, host_name, instance ) dynamic_data["asset"] = asset_doc["name"] return dynamic_data def create(self, subset_name, instance_data, pre_create_data): with lib.undo_chunk(): instance = super(CreateUnrealSkeletalMesh, self).create( subset_name, instance_data, pre_create_data) instance_node = instance.get("instance_node") # We reorganize the geometry that was originally added into the # set into either 'joints_SET' or 'geometry_SET' based on the # joint_hints from project settings members = cmds.sets(instance_node, query=True) or [] cmds.sets(clear=instance_node) geometry_set = cmds.sets(name="geometry_SET", empty=True) joints_set = cmds.sets(name="joints_SET", empty=True) cmds.sets([geometry_set, joints_set], forceElement=instance_node) for node in members: if node in self.joint_hints: cmds.sets(node, forceElement=joints_set) else: cmds.sets(node, forceElement=geometry_set) def get_instance_attr_defs(self): defs = lib.collect_animation_defs() defs.extend([ BoolDef("renderableOnly", label="Renderable Only", tooltip="Only export renderable visible shapes", default=False), BoolDef("visibleOnly", label="Visible Only", tooltip="Only export dag objects visible during " "frame range", default=False), BoolDef("includeParentHierarchy", label="Include Parent Hierarchy", tooltip="Whether to include parent hierarchy of nodes in " "the publish instance", default=False), BoolDef("worldSpace", label="World-Space Export", default=True), BoolDef("refresh", label="Refresh viewport during export", default=False), TextDef("attr", label="Custom Attributes", default="", placeholder="attr1, attr2"), TextDef("attrPrefix", label="Custom Attributes Prefix", placeholder="prefix1, prefix2") ]) return defs ================================================ FILE: openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py ================================================ # -*- coding: utf-8 -*- """Creator for Unreal Static Meshes.""" from openpype.hosts.maya.api import plugin, lib from maya import cmds # noqa class CreateUnrealStaticMesh(plugin.MayaCreator): """Unreal Static Meshes with collisions.""" identifier = "io.openpype.creators.maya.unrealstaticmesh" label = "Unreal - Static Mesh" family = "staticMesh" icon = "cube" dynamic_subset_keys = ["asset"] # Defined in settings collision_prefixes = [] def apply_settings(self, project_settings): """Apply project settings to creator""" settings = project_settings["maya"]["create"]["CreateUnrealStaticMesh"] self.collision_prefixes = settings["collision_prefixes"] def get_dynamic_data( self, variant, task_name, asset_doc, project_name, host_name, instance ): """ The default subset name templates for Unreal include {asset} and thus we should pass that along as dynamic data. """ dynamic_data = super(CreateUnrealStaticMesh, self).get_dynamic_data( variant, task_name, asset_doc, project_name, host_name, instance ) dynamic_data["asset"] = asset_doc["name"] return dynamic_data def create(self, subset_name, instance_data, pre_create_data): with lib.undo_chunk(): instance = super(CreateUnrealStaticMesh, self).create( subset_name, instance_data, pre_create_data) instance_node = instance.get("instance_node") # We reorganize the geometry that was originally added into the # set into either 'collision_SET' or 'geometry_SET' based on the # collision_prefixes from project settings members = cmds.sets(instance_node, query=True) cmds.sets(clear=instance_node) geometry_set = cmds.sets(name="geometry_SET", empty=True) collisions_set = cmds.sets(name="collisions_SET", empty=True) cmds.sets([geometry_set, collisions_set], forceElement=instance_node) members = cmds.ls(members, long=True) or [] children = cmds.listRelatives(members, allDescendents=True, fullPath=True) or [] transforms = cmds.ls(members + children, type="transform") for transform in transforms: if not cmds.listRelatives(transform, type="shape", noIntermediate=True): # Exclude all transforms that have no direct shapes continue if self.has_collision_prefix(transform): cmds.sets(transform, forceElement=collisions_set) else: cmds.sets(transform, forceElement=geometry_set) def has_collision_prefix(self, node_path): """Return whether node name of path matches collision prefix. If the node name matches the collision prefix we add it to the `collisions_SET` instead of the `geometry_SET`. Args: node_path (str): Maya node path. Returns: bool: Whether the node should be considered a collision mesh. """ node_name = node_path.rsplit("|", 1)[-1] for prefix in self.collision_prefixes: if node_name.startswith(prefix): return True return False ================================================ FILE: openpype/hosts/maya/plugins/create/create_unreal_yeticache.py ================================================ from openpype.hosts.maya.api import ( lib, plugin ) from openpype.lib import NumberDef class CreateYetiCache(plugin.MayaCreator): """Output for procedural plugin nodes of Yeti """ identifier = "io.openpype.creators.maya.unrealyeticache" label = "Unreal - Yeti Cache" family = "yeticacheUE" icon = "pagelines" def get_instance_attr_defs(self): defs = [ NumberDef("preroll", label="Preroll", minimum=0, default=0, decimals=0) ] # Add animation data without step and handles defs.extend(lib.collect_animation_defs()) remove = {"step", "handleStart", "handleEnd"} defs = [attr_def for attr_def in defs if attr_def.key not in remove] # Add samples after frame range defs.append( NumberDef("samples", label="Samples", default=3, decimals=0) ) return defs ================================================ FILE: openpype/hosts/maya/plugins/create/create_vrayproxy.py ================================================ from openpype.hosts.maya.api import ( plugin, lib ) from openpype.lib import BoolDef class CreateVrayProxy(plugin.MayaCreator): """Alembic pointcache for animated data""" identifier = "io.openpype.creators.maya.vrayproxy" label = "VRay Proxy" family = "vrayproxy" icon = "gears" vrmesh = True alembic = True def get_instance_attr_defs(self): defs = [ BoolDef("animation", label="Export Animation", default=False) ] # Add time range attributes but remove some attributes # which this instance actually doesn't use defs.extend(lib.collect_animation_defs()) remove = {"handleStart", "handleEnd", "step"} defs = [attr_def for attr_def in defs if attr_def.key not in remove] defs.extend([ BoolDef("vertexColors", label="Write vertex colors", tooltip="Write vertex colors with the geometry", default=False), BoolDef("vrmesh", label="Export VRayMesh", tooltip="Publish a .vrmesh (VRayMesh) file for " "this VRayProxy", default=self.vrmesh), BoolDef("alembic", label="Export Alembic", tooltip="Publish a .abc (Alembic) file for " "this VRayProxy", default=self.alembic), ]) return defs ================================================ FILE: openpype/hosts/maya/plugins/create/create_vrayscene.py ================================================ # -*- coding: utf-8 -*- """Create instance of vrayscene.""" from openpype.hosts.maya.api import ( lib_rendersettings, plugin ) from openpype.pipeline import CreatorError from openpype.lib import BoolDef class CreateVRayScene(plugin.RenderlayerCreator): """Create Vray Scene.""" identifier = "io.openpype.creators.maya.vrayscene" family = "vrayscene" label = "VRay Scene" icon = "cubes" render_settings = {} singleton_node_name = "vraysceneMain" @classmethod def apply_settings(cls, project_settings): cls.render_settings = project_settings["maya"]["RenderSettings"] def create(self, subset_name, instance_data, pre_create_data): # Only allow a single render instance to exist if self._get_singleton_node(): raise CreatorError("A Render instance already exists - only " "one can be configured.") super(CreateVRayScene, self).create(subset_name, instance_data, pre_create_data) # Apply default project render settings on create if self.render_settings.get("apply_render_settings"): lib_rendersettings.RenderSettings().set_default_renderer_settings() def get_instance_attr_defs(self): """Create instance settings.""" return [ BoolDef("vraySceneMultipleFiles", label="V-Ray Scene Multiple Files", default=False), BoolDef("exportOnFarm", label="Export on farm", default=False) ] ================================================ FILE: openpype/hosts/maya/plugins/create/create_workfile.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating workfiles.""" from openpype import AYON_SERVER_ENABLED from openpype.pipeline import CreatedInstance, AutoCreator from openpype.client import get_asset_by_name, get_asset_name_identifier from openpype.hosts.maya.api import plugin from maya import cmds class CreateWorkfile(plugin.MayaCreatorBase, AutoCreator): """Workfile auto-creator.""" identifier = "io.openpype.creators.maya.workfile" label = "Workfile" family = "workfile" icon = "fa5.file" default_variant = "Main" def create(self): variant = self.default_variant current_instance = next( ( instance for instance in self.create_context.instances if instance.creator_identifier == self.identifier ), None) project_name = self.project_name asset_name = self.create_context.get_current_asset_name() task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name if current_instance is None: current_instance_asset = None elif AYON_SERVER_ENABLED: current_instance_asset = current_instance["folderPath"] else: current_instance_asset = current_instance["asset"] if current_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) data = { "task": task_name, "variant": variant } if AYON_SERVER_ENABLED: data["folderPath"] = asset_name else: data["asset"] = asset_name data.update( self.get_dynamic_data( variant, task_name, asset_doc, project_name, host_name, current_instance) ) self.log.info("Auto-creating workfile instance...") current_instance = CreatedInstance( self.family, subset_name, data, self ) self._add_instance_to_context(current_instance) elif ( current_instance_asset != asset_name or current_instance["task"] != task_name ): # Update instance context if is not the same asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) asset_name = get_asset_name_identifier(asset_doc) if AYON_SERVER_ENABLED: current_instance["folderPath"] = asset_name else: current_instance["asset"] = asset_name current_instance["task"] = task_name current_instance["subset"] = subset_name def collect_instances(self): self.cache_subsets(self.collection_shared_data) cached_subsets = self.collection_shared_data["maya_cached_subsets"] for node in cached_subsets.get(self.identifier, []): node_data = self.read_instance_node(node) created_instance = CreatedInstance.from_existing(node_data, self) self._add_instance_to_context(created_instance) def update_instances(self, update_list): for created_inst, _changes in update_list: data = created_inst.data_to_store() node = data.get("instance_node") if not node: node = self.create_node() created_inst["instance_node"] = node data = created_inst.data_to_store() self.imprint_instance_node(node, data) def create_node(self): node = cmds.sets(empty=True, name="workfileMain") cmds.setAttr(node + ".hiddenInOutliner", True) return node ================================================ FILE: openpype/hosts/maya/plugins/create/create_xgen.py ================================================ from openpype.hosts.maya.api import plugin class CreateXgen(plugin.MayaCreator): """Xgen""" identifier = "io.openpype.creators.maya.xgen" label = "Xgen" family = "xgen" icon = "pagelines" ================================================ FILE: openpype/hosts/maya/plugins/create/create_yeti_cache.py ================================================ from openpype.hosts.maya.api import ( lib, plugin ) from openpype.lib import NumberDef class CreateYetiCache(plugin.MayaCreator): """Output for procedural plugin nodes of Yeti """ identifier = "io.openpype.creators.maya.yeticache" label = "Yeti Cache" family = "yeticache" icon = "pagelines" def get_instance_attr_defs(self): defs = [ NumberDef("preroll", label="Preroll", minimum=0, default=0, decimals=0) ] # Add animation data without step and handles defs.extend(lib.collect_animation_defs()) remove = {"step", "handleStart", "handleEnd"} defs = [attr_def for attr_def in defs if attr_def.key not in remove] # Add samples after frame range defs.append( NumberDef("samples", label="Samples", default=3, decimals=0) ) return defs ================================================ FILE: openpype/hosts/maya/plugins/create/create_yeti_rig.py ================================================ from maya import cmds from openpype.hosts.maya.api import ( lib, plugin ) class CreateYetiRig(plugin.MayaCreator): """Output for procedural plugin nodes ( Yeti / XGen / etc)""" identifier = "io.openpype.creators.maya.yetirig" label = "Yeti Rig" family = "yetiRig" icon = "usb" def create(self, subset_name, instance_data, pre_create_data): with lib.undo_chunk(): instance = super(CreateYetiRig, self).create(subset_name, instance_data, pre_create_data) instance_node = instance.get("instance_node") self.log.info("Creating Rig instance set up ...") input_meshes = cmds.sets(name="input_SET", empty=True) cmds.sets(input_meshes, forceElement=instance_node) ================================================ FILE: openpype/hosts/maya/plugins/inventory/connect_geometry.py ================================================ from maya import cmds from openpype.pipeline import InventoryAction, get_representation_context from openpype.hosts.maya.api.lib import get_id class ConnectGeometry(InventoryAction): """Connect geometries within containers. Source container will connect to the target containers, by searching for matching geometry IDs (cbid). Source containers are of family; "animation" and "pointcache". The connection with be done with a live world space blendshape. """ label = "Connect Geometry" icon = "link" color = "white" def process(self, containers): # Validate selection is more than 1. message = ( "Only 1 container selected. 2+ containers needed for this action." ) if len(containers) == 1: self.display_warning(message) return # Categorize containers by family. containers_by_family = {} for container in containers: family = get_representation_context( container["representation"] )["subset"]["data"]["family"] try: containers_by_family[family].append(container) except KeyError: containers_by_family[family] = [container] # Validate to only 1 source container. source_containers = containers_by_family.get("animation", []) source_containers += containers_by_family.get("pointcache", []) source_container_namespaces = [ x["namespace"] for x in source_containers ] message = ( "{} animation containers selected:\n\n{}\n\nOnly select 1 of type " "\"animation\" or \"pointcache\".".format( len(source_containers), source_container_namespaces ) ) if len(source_containers) != 1: self.display_warning(message) return source_object = source_containers[0]["objectName"] # Collect matching geometry transforms based cbId attribute. target_containers = [] for family, containers in containers_by_family.items(): if family in ["animation", "pointcache"]: continue target_containers.extend(containers) source_data = self.get_container_data(source_object) matches = [] node_types = set() for target_container in target_containers: target_data = self.get_container_data( target_container["objectName"] ) node_types.update(target_data["node_types"]) for id, transform in target_data["ids"].items(): source_match = source_data["ids"].get(id) if source_match: matches.append([source_match, transform]) # Message user about what is about to happen. if not matches: self.display_warning("No matching geometries found.") return message = "Connecting geometries:\n\n" for match in matches: message += "{} > {}\n".format(match[0], match[1]) choice = self.display_warning(message, show_cancel=True) if choice is False: return # Setup live worldspace blendshape connection. for source, target in matches: blendshape = cmds.blendShape(source, target)[0] cmds.setAttr(blendshape + ".origin", 0) cmds.setAttr(blendshape + "." + target.split(":")[-1], 1) # Update Xgen if in any of the containers. if "xgmPalette" in node_types: cmds.xgmPreview() def get_container_data(self, container): """Collects data about the container nodes. Args: container (dict): Container instance. Returns: data (dict): "node_types": All node types in container nodes. "ids": If the node is a mesh, we collect its parent transform id. """ data = {"node_types": set(), "ids": {}} ref_node = cmds.sets(container, query=True, nodesOnly=True)[0] for node in cmds.referenceQuery(ref_node, nodes=True): node_type = cmds.nodeType(node) data["node_types"].add(node_type) # Only interested in mesh transforms for connecting geometry with # blendshape. if node_type != "mesh": continue transform = cmds.listRelatives(node, parent=True)[0] data["ids"][get_id(transform)] = transform return data def display_warning(self, message, show_cancel=False): """Show feedback to user. Returns: bool """ from qtpy import QtWidgets accept = QtWidgets.QMessageBox.Ok if show_cancel: buttons = accept | QtWidgets.QMessageBox.Cancel else: buttons = accept state = QtWidgets.QMessageBox.warning( None, "", message, buttons=buttons, defaultButton=accept ) return state == accept ================================================ FILE: openpype/hosts/maya/plugins/inventory/connect_xgen.py ================================================ from maya import cmds import xgenm from openpype.pipeline import ( InventoryAction, get_representation_context, get_representation_path ) class ConnectXgen(InventoryAction): """Connect Xgen with an animation or pointcache. """ label = "Connect Xgen" icon = "link" color = "white" def process(self, containers): # Validate selection is more than 1. message = ( "Only 1 container selected. 2+ containers needed for this action." ) if len(containers) == 1: self.display_warning(message) return # Categorize containers by family. containers_by_family = {} for container in containers: family = get_representation_context( container["representation"] )["subset"]["data"]["family"] try: containers_by_family[family].append(container) except KeyError: containers_by_family[family] = [container] # Validate to only 1 source container. source_containers = containers_by_family.get("animation", []) source_containers += containers_by_family.get("pointcache", []) source_container_namespaces = [ x["namespace"] for x in source_containers ] message = ( "{} animation containers selected:\n\n{}\n\nOnly select 1 of type " "\"animation\" or \"pointcache\".".format( len(source_containers), source_container_namespaces ) ) if len(source_containers) != 1: self.display_warning(message) return source_container = source_containers[0] source_object = source_container["objectName"] # Validate source representation is an alembic. source_path = get_representation_path( get_representation_context( source_container["representation"] )["representation"] ).replace("\\", "/") message = "Animation container \"{}\" is not an alembic:\n{}".format( source_container["namespace"], source_path ) if not source_path.endswith(".abc"): self.display_warning(message) return # Target containers. target_containers = [] for family, containers in containers_by_family.items(): if family in ["animation", "pointcache"]: continue target_containers.extend(containers) # Inform user of connections from source representation to target # descriptions. descriptions_data = [] connections_msg = "" for target_container in target_containers: reference_node = cmds.sets( target_container["objectName"], query=True )[0] palettes = cmds.ls( cmds.referenceQuery(reference_node, nodes=True), type="xgmPalette" ) for palette in palettes: for description in xgenm.descriptions(palette): descriptions_data.append([palette, description]) connections_msg += "\n{}/{}".format(palette, description) message = "Connecting \"{}\" to:\n".format( source_container["namespace"] ) message += connections_msg choice = self.display_warning(message, show_cancel=True) if choice is False: return # Recreate "xgenContainers" attribute to reset. compound_name = "xgenContainers" attr = "{}.{}".format(source_object, compound_name) if cmds.objExists(attr): cmds.deleteAttr(attr) cmds.addAttr( source_object, longName=compound_name, attributeType="compound", numberOfChildren=1, multi=True ) # Connect target containers. for target_container in target_containers: cmds.addAttr( source_object, longName="container", attributeType="message", parent=compound_name ) index = target_containers.index(target_container) cmds.connectAttr( target_container["objectName"] + ".message", source_object + ".{}[{}].container".format( compound_name, index ) ) # Setup cache on Xgen object = "SplinePrimitive" for palette, description in descriptions_data: xgenm.setAttr("useCache", "true", palette, description, object) xgenm.setAttr("liveMode", "false", palette, description, object) xgenm.setAttr( "cacheFileName", source_path, palette, description, object ) # Refresh UI and viewport. de = xgenm.xgGlobal.DescriptionEditor de.refresh("Full") def display_warning(self, message, show_cancel=False): """Show feedback to user. Returns: bool """ from qtpy import QtWidgets accept = QtWidgets.QMessageBox.Ok if show_cancel: buttons = accept | QtWidgets.QMessageBox.Cancel else: buttons = accept state = QtWidgets.QMessageBox.warning( None, "", message, buttons=buttons, defaultButton=accept ) return state == accept ================================================ FILE: openpype/hosts/maya/plugins/inventory/connect_yeti_rig.py ================================================ import os import json from collections import defaultdict from maya import cmds from openpype.pipeline import ( InventoryAction, get_representation_context, get_representation_path ) from openpype.hosts.maya.api.lib import get_container_members, get_id class ConnectYetiRig(InventoryAction): """Connect Yeti Rig with an animation or pointcache.""" label = "Connect Yeti Rig" icon = "link" color = "white" def process(self, containers): # Validate selection is more than 1. message = ( "Only 1 container selected. 2+ containers needed for this action." ) if len(containers) == 1: self.display_warning(message) return # Categorize containers by family. containers_by_family = defaultdict(list) for container in containers: family = get_representation_context( container["representation"] )["subset"]["data"]["family"] containers_by_family[family].append(container) # Validate to only 1 source container. source_containers = containers_by_family.get("animation", []) source_containers += containers_by_family.get("pointcache", []) source_container_namespaces = [ x["namespace"] for x in source_containers ] message = ( "{} animation containers selected:\n\n{}\n\nOnly select 1 of type " "\"animation\" or \"pointcache\".".format( len(source_containers), source_container_namespaces ) ) if len(source_containers) != 1: self.display_warning(message) return source_container = source_containers[0] source_ids = self.nodes_by_id(source_container) # Target containers. target_ids = {} inputs = [] yeti_rig_containers = containers_by_family.get("yetiRig") if not yeti_rig_containers: self.display_warning( "Select at least one yetiRig container" ) return for container in yeti_rig_containers: target_ids.update(self.nodes_by_id(container)) maya_file = get_representation_path( get_representation_context( container["representation"] )["representation"] ) _, ext = os.path.splitext(maya_file) settings_file = maya_file.replace(ext, ".rigsettings") if not os.path.exists(settings_file): continue with open(settings_file) as f: inputs.extend(json.load(f)["inputs"]) # Compare loaded connections to scene. for input in inputs: source_node = source_ids.get(input["sourceID"]) target_node = target_ids.get(input["destinationID"]) if not source_node or not target_node: self.log.debug( "Could not find nodes for input:\n" + json.dumps(input, indent=4, sort_keys=True) ) continue source_attr, target_attr = input["connections"] if not cmds.attributeQuery( source_attr, node=source_node, exists=True ): self.log.debug( "Could not find attribute {} on node {} for " "input:\n{}".format( source_attr, source_node, json.dumps(input, indent=4, sort_keys=True) ) ) continue if not cmds.attributeQuery( target_attr, node=target_node, exists=True ): self.log.debug( "Could not find attribute {} on node {} for " "input:\n{}".format( target_attr, target_node, json.dumps(input, indent=4, sort_keys=True) ) ) continue source_plug = "{}.{}".format( source_node, source_attr ) target_plug = "{}.{}".format( target_node, target_attr ) if cmds.isConnected( source_plug, target_plug, ignoreUnitConversion=True ): self.log.debug( "Connection already exists: {} -> {}".format( source_plug, target_plug ) ) continue cmds.connectAttr(source_plug, target_plug, force=True) self.log.debug( "Connected attributes: {} -> {}".format( source_plug, target_plug ) ) def nodes_by_id(self, container): ids = {} for member in get_container_members(container): id = get_id(member) if not id: continue ids[id] = member return ids def display_warning(self, message, show_cancel=False): """Show feedback to user. Returns: bool """ from qtpy import QtWidgets accept = QtWidgets.QMessageBox.Ok if show_cancel: buttons = accept | QtWidgets.QMessageBox.Cancel else: buttons = accept state = QtWidgets.QMessageBox.warning( None, "", message, buttons=buttons, defaultButton=accept ) return state == accept ================================================ FILE: openpype/hosts/maya/plugins/inventory/import_modelrender.py ================================================ import re import json from openpype.client import ( get_representation_by_id, get_representations ) from openpype.pipeline import ( InventoryAction, get_representation_context, get_current_project_name, ) from openpype.hosts.maya.api.lib import ( maintained_selection, apply_shaders ) class ImportModelRender(InventoryAction): label = "Import Model Render Sets" icon = "industry" color = "#55DDAA" scene_type_regex = "meta.render.m[ab]" look_data_type = "meta.render.json" @staticmethod def is_compatible(container): return ( container.get("loader") == "ReferenceLoader" and container.get("name", "").startswith("model") ) def process(self, containers): from maya import cmds # noqa: F401 project_name = get_current_project_name() for container in containers: con_name = container["objectName"] nodes = [] for n in cmds.sets(con_name, query=True, nodesOnly=True) or []: if cmds.nodeType(n) == "reference": nodes += cmds.referenceQuery(n, nodes=True) else: nodes.append(n) repr_doc = get_representation_by_id( project_name, container["representation"], fields=["parent"] ) version_id = repr_doc["parent"] print("Importing render sets for model %r" % con_name) self.assign_model_render_by_version(nodes, version_id) def assign_model_render_by_version(self, nodes, version_id): """Assign nodes a specific published model render data version by id. This assumes the nodes correspond with the asset. Args: nodes(list): nodes to assign render data to version_id (bson.ObjectId): database id of the version of model Returns: None """ from maya import cmds # noqa: F401 project_name = get_current_project_name() repre_docs = get_representations( project_name, version_ids=[version_id], fields=["_id", "name"] ) # Get representations of shader file and relationships json_repre = None look_repres = [] scene_type_regex = re.compile(self.scene_type_regex) for repre_doc in repre_docs: repre_name = repre_doc["name"] if repre_name == self.look_data_type: json_repre = repre_doc continue if scene_type_regex.fullmatch(repre_name): look_repres.append(repre_doc) look_repre = look_repres[0] if look_repres else None # QUESTION shouldn't be json representation validated too? if not look_repre: print("No model render sets for this model version..") return context = get_representation_context(look_repre["_id"]) maya_file = self.filepath_from_context(context) context = get_representation_context(json_repre["_id"]) json_file = self.filepath_from_context(context) # Import the look file with maintained_selection(): shader_nodes = cmds.file(maya_file, i=True, # import returnNewNodes=True) # imprint context data # Load relationships shader_relation = json_file with open(shader_relation, "r") as f: relationships = json.load(f) # Assign relationships apply_shaders(relationships, shader_nodes, nodes) ================================================ FILE: openpype/hosts/maya/plugins/inventory/import_reference.py ================================================ from maya import cmds from openpype.pipeline import InventoryAction from openpype.hosts.maya.api.lib import get_reference_node class ImportReference(InventoryAction): """Imports selected reference to inside of the file.""" label = "Import Reference" icon = "download" color = "#d8d8d8" def process(self, containers): for container in containers: if container["loader"] != "ReferenceLoader": print("Not a reference, skipping") continue node = container["objectName"] members = cmds.sets(node, query=True, nodesOnly=True) ref_node = get_reference_node(members) ref_file = cmds.referenceQuery(ref_node, f=True) cmds.file(ref_file, importReference=True) return True # return anything to trigger model refresh ================================================ FILE: openpype/hosts/maya/plugins/inventory/rig_recreate_animation_instance.py ================================================ from openpype.pipeline import ( InventoryAction, get_representation_context ) from openpype.hosts.maya.api.lib import ( create_rig_animation_instance, get_container_members, ) class RecreateRigAnimationInstance(InventoryAction): """Recreate animation publish instance for loaded rigs""" label = "Recreate rig animation instance" icon = "wrench" color = "#888888" @staticmethod def is_compatible(container): return ( container.get("loader") == "ReferenceLoader" and container.get("name", "").startswith("rig") ) def process(self, containers): for container in containers: # todo: delete an existing entry if it exist or skip creation namespace = container["namespace"] representation_id = container["representation"] context = get_representation_context(representation_id) nodes = get_container_members(container) create_rig_animation_instance(nodes, context, namespace) ================================================ FILE: openpype/hosts/maya/plugins/inventory/select_containers.py ================================================ from maya import cmds from openpype.pipeline import InventoryAction, registered_host from openpype.hosts.maya.api.lib import get_container_members class SelectInScene(InventoryAction): """Select nodes in the scene from selected containers in scene inventory""" label = "Select in scene" icon = "search" color = "#888888" order = 99 def process(self, containers): all_members = [] for container in containers: members = get_container_members(container) all_members.extend(members) cmds.select(all_members, replace=True, noExpand=True) class HighlightBySceneSelection(InventoryAction): """Select containers in scene inventory from the current scene selection""" label = "Highlight by scene selection" icon = "search" color = "#888888" order = 100 def process(self, containers): selection = set(cmds.ls(selection=True, long=True, objectsOnly=True)) host = registered_host() to_select = [] for container in host.get_containers(): members = get_container_members(container) if any(member in selection for member in members): to_select.append(container["objectName"]) return { "objectNames": to_select, "options": {"clear": True} } ================================================ FILE: openpype/hosts/maya/plugins/load/_load_animation.py ================================================ import openpype.hosts.maya.api.plugin import maya.cmds as cmds def _process_reference(file_url, name, namespace, options): """Load files by referencing scene in Maya. Args: file_url (str): fileapth of the objects to be loaded name (str): subset name namespace (str): namespace options (dict): dict of storing the param Returns: list: list of object nodes """ from openpype.hosts.maya.api.lib import unique_namespace # Get name from asset being loaded # Assuming name is subset name from the animation, we split the number # suffix from the name to ensure the namespace is unique name = name.split("_")[0] ext = file_url.split(".")[-1] namespace = unique_namespace( "{}_".format(name), format="%03d", suffix="_{}".format(ext) ) attach_to_root = options.get("attach_to_root", True) group_name = options["group_name"] # no group shall be created if not attach_to_root: group_name = namespace nodes = cmds.file(file_url, namespace=namespace, sharedReferenceFile=False, groupReference=attach_to_root, groupName=group_name, reference=True, returnNewNodes=True) return nodes class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): """Loader to reference an Alembic file""" families = ["animation", "camera", "pointcache"] representations = ["abc"] label = "Reference animation" order = -10 icon = "code-fork" color = "orange" def process_reference(self, context, name, namespace, options): cmds.loadPlugin("AbcImport.mll", quiet=True) # hero_001 (abc) # asset_counter{optional} path = self.filepath_from_context(context) file_url = self.prepare_root_value(path, context["project"]["name"]) nodes = _process_reference(file_url, name, namespace, options) # load colorbleed ID attribute self[:] = nodes return nodes class FbxLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): """Loader to reference an Fbx files""" families = ["animation", "camera"] representations = ["fbx"] label = "Reference animation" order = -10 icon = "code-fork" color = "orange" def process_reference(self, context, name, namespace, options): cmds.loadPlugin("fbx4maya.mll", quiet=True) path = self.filepath_from_context(context) file_url = self.prepare_root_value(path, context["project"]["name"]) nodes = _process_reference(file_url, name, namespace, options) self[:] = nodes return nodes ================================================ FILE: openpype/hosts/maya/plugins/load/actions.py ================================================ """A module containing generic loader actions that will display in the Loader. """ import qargparse from openpype.pipeline import load from openpype.hosts.maya.api.lib import ( maintained_selection, get_custom_namespace ) import openpype.hosts.maya.api.plugin class SetFrameRangeLoader(load.LoaderPlugin): """Set frame range excluding pre- and post-handles""" families = ["animation", "camera", "proxyAbc", "pointcache"] representations = ["abc"] label = "Set frame range" order = 11 icon = "clock-o" color = "white" def load(self, context, name, namespace, data): import maya.cmds as cmds version = context['version'] version_data = version.get("data", {}) start = version_data.get("frameStart", None) end = version_data.get("frameEnd", None) if start is None or end is None: print("Skipping setting frame range because start or " "end frame data is missing..") return cmds.playbackOptions(minTime=start, maxTime=end, animationStartTime=start, animationEndTime=end) class SetFrameRangeWithHandlesLoader(load.LoaderPlugin): """Set frame range including pre- and post-handles""" families = ["animation", "camera", "proxyAbc", "pointcache"] representations = ["abc"] label = "Set frame range (with handles)" order = 12 icon = "clock-o" color = "white" def load(self, context, name, namespace, data): import maya.cmds as cmds version = context['version'] version_data = version.get("data", {}) start = version_data.get("frameStart", None) end = version_data.get("frameEnd", None) if start is None or end is None: print("Skipping setting frame range because start or " "end frame data is missing..") return # Include handles start -= version_data.get("handleStart", 0) end += version_data.get("handleEnd", 0) cmds.playbackOptions(minTime=start, maxTime=end, animationStartTime=start, animationEndTime=end) class ImportMayaLoader(openpype.hosts.maya.api.plugin.Loader): """Import action for Maya (unmanaged) Warning: The loaded content will be unmanaged and is *not* visible in the scene inventory. It's purely intended to merge content into your scene so you could also use it as a new base. """ representations = ["ma", "mb", "obj"] families = [ "model", "pointcache", "proxyAbc", "animation", "mayaAscii", "mayaScene", "setdress", "layout", "camera", "rig", "camerarig", "staticMesh", "workfile" ] label = "Import" order = 10 icon = "arrow-circle-down" color = "#775555" options = [ qargparse.Boolean( "clean_import", label="Clean import", default=False, help="Should all occurrences of cbId be purged?" ) ] def load(self, context, name=None, namespace=None, data=None): import maya.cmds as cmds choice = self.display_warning() if choice is False: return custom_group_name, custom_namespace, options = \ self.get_custom_namespace_and_group(context, data, "import_loader") namespace = get_custom_namespace(custom_namespace) if not options.get("attach_to_root", True): custom_group_name = namespace path = self.filepath_from_context(context) with maintained_selection(): nodes = cmds.file(path, i=True, preserveReferences=True, namespace=namespace, returnNewNodes=True, groupReference=options.get("attach_to_root", True), groupName=custom_group_name) if data.get("clean_import", False): remove_attributes = ["cbId"] for node in nodes: for attr in remove_attributes: if cmds.attributeQuery(attr, node=node, exists=True): full_attr = "{}.{}".format(node, attr) print("Removing {}".format(full_attr)) cmds.deleteAttr(full_attr) # We do not containerize imported content, it remains unmanaged return def display_warning(self): """Show warning to ensure the user can't import models by accident Returns: bool """ from qtpy import QtWidgets accept = QtWidgets.QMessageBox.Ok buttons = accept | QtWidgets.QMessageBox.Cancel message = "Are you sure you want import this" state = QtWidgets.QMessageBox.warning(None, "Are you sure?", message, buttons=buttons, defaultButton=accept) return state == accept ================================================ FILE: openpype/hosts/maya/plugins/load/load_arnold_standin.py ================================================ import os import clique import maya.cmds as cmds from openpype.settings import get_project_settings from openpype.pipeline import ( load, legacy_io, get_representation_path ) from openpype.hosts.maya.api.lib import ( unique_namespace, get_attribute_input, maintained_selection, convert_to_maya_fps ) from openpype.hosts.maya.api.pipeline import containerise def is_sequence(files): sequence = False collections, remainder = clique.assemble(files, minimum_items=1) if collections: sequence = True return sequence def get_current_session_fps(): session_fps = float(legacy_io.Session.get('AVALON_FPS', 25)) return convert_to_maya_fps(session_fps) class ArnoldStandinLoader(load.LoaderPlugin): """Load as Arnold standin""" families = [ "ass", "assProxy", "animation", "model", "proxyAbc", "pointcache", "usd" ] representations = ["ass", "abc", "usda", "usdc", "usd"] label = "Load as Arnold standin" order = -5 icon = "code-fork" color = "orange" def load(self, context, name, namespace, options): if not cmds.pluginInfo("mtoa", query=True, loaded=True): cmds.loadPlugin("mtoa") # Create defaultArnoldRenderOptions before creating aiStandin # which tries to connect it. Since we load the plugin and directly # create aiStandin without the defaultArnoldRenderOptions, # we need to create the render options for aiStandin creation. from mtoa.core import createOptions createOptions() import mtoa.ui.arnoldmenu version = context['version'] version_data = version.get("data", {}) self.log.info("version_data: {}\n".format(version_data)) asset = context['asset']['name'] namespace = namespace or unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", ) # Root group label = "{}:{}".format(namespace, name) root = cmds.group(name=label, empty=True) # Set color. settings = get_project_settings(context["project"]["name"]) color = settings['maya']['load']['colors'].get('ass') if color is not None: cmds.setAttr(root + ".useOutlinerColor", True) cmds.setAttr( root + ".outlinerColor", color[0], color[1], color[2] ) with maintained_selection(): # Create transform with shape transform_name = label + "_standin" standin_shape = mtoa.ui.arnoldmenu.createStandIn() standin = cmds.listRelatives(standin_shape, parent=True)[0] standin = cmds.rename(standin, transform_name) standin_shape = cmds.listRelatives(standin, shapes=True)[0] cmds.parent(standin, root) # Set the standin filepath repre_path = self.filepath_from_context(context) path, operator = self._setup_proxy( standin_shape, repre_path, namespace ) cmds.setAttr(standin_shape + ".dso", path, type="string") sequence = is_sequence(os.listdir(os.path.dirname(repre_path))) cmds.setAttr(standin_shape + ".useFrameExtension", sequence) fps = version["data"].get("fps") or get_current_session_fps() cmds.setAttr(standin_shape + ".abcFPS", float(fps)) nodes = [root, standin, standin_shape] if operator is not None: nodes.append(operator) self[:] = nodes return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) def get_next_free_multi_index(self, attr_name): """Find the next unconnected multi index at the input attribute.""" for index in range(10000000): connection_info = cmds.connectionInfo( "{}[{}]".format(attr_name, index), sourceFromDestination=True ) if len(connection_info or []) == 0: return index def _get_proxy_path(self, path): basename_split = os.path.basename(path).split(".") proxy_basename = ( basename_split[0] + "_proxy." + ".".join(basename_split[1:]) ) proxy_path = "/".join([os.path.dirname(path), proxy_basename]) return proxy_basename, proxy_path def _update_operators(self, string_replace_operator, proxy_basename, path): cmds.setAttr( string_replace_operator + ".match", proxy_basename.split(".")[0], type="string" ) cmds.setAttr( string_replace_operator + ".replace", os.path.basename(path).split(".")[0], type="string" ) def _setup_proxy(self, shape, path, namespace): proxy_basename, proxy_path = self._get_proxy_path(path) options_node = "defaultArnoldRenderOptions" merge_operator = get_attribute_input(options_node + ".operator") if merge_operator is None: merge_operator = cmds.createNode("aiMerge") cmds.connectAttr( merge_operator + ".message", options_node + ".operator" ) merge_operator = merge_operator.split(".")[0] string_replace_operator = cmds.createNode( "aiStringReplace", name=namespace + ":string_replace_operator" ) node_type = "alembic" if path.endswith(".abc") else "procedural" cmds.setAttr( string_replace_operator + ".selection", "*.(@node=='{}')".format(node_type), type="string" ) self._update_operators(string_replace_operator, proxy_basename, path) cmds.connectAttr( string_replace_operator + ".out", "{}.inputs[{}]".format( merge_operator, self.get_next_free_multi_index(merge_operator + ".inputs") ) ) # We setup the string operator no matter whether there is a proxy or # not. This makes it easier to update since the string operator will # always be created. Return original path to use for standin. if not os.path.exists(proxy_path): return path, string_replace_operator return proxy_path, string_replace_operator def update(self, container, representation): # Update the standin members = cmds.sets(container['objectName'], query=True) for member in members: if cmds.nodeType(member) == "aiStringReplace": string_replace_operator = member shapes = cmds.listRelatives(member, shapes=True) if not shapes: continue if cmds.nodeType(shapes[0]) == "aiStandIn": standin = shapes[0] path = get_representation_path(representation) proxy_basename, proxy_path = self._get_proxy_path(path) # Whether there is proxy or not, we still update the string operator. # If no proxy exists, the string operator won't replace anything. self._update_operators(string_replace_operator, proxy_basename, path) dso_path = path if os.path.exists(proxy_path): dso_path = proxy_path cmds.setAttr(standin + ".dso", dso_path, type="string") sequence = is_sequence(os.listdir(os.path.dirname(path))) cmds.setAttr(standin + ".useFrameExtension", sequence) cmds.setAttr( container["objectName"] + ".representation", str(representation["_id"]), type="string" ) def switch(self, container, representation): self.update(container, representation) def remove(self, container): members = cmds.sets(container['objectName'], query=True) cmds.lockNode(members, lock=False) cmds.delete([container['objectName']] + members) # Clean up the namespace try: cmds.namespace(removeNamespace=container['namespace'], deleteNamespaceContent=True) except RuntimeError: pass ================================================ FILE: openpype/hosts/maya/plugins/load/load_assembly.py ================================================ import maya.cmds as cmds from openpype.pipeline import ( load, remove_container ) from openpype.hosts.maya.api.pipeline import containerise from openpype.hosts.maya.api.lib import unique_namespace from openpype.hosts.maya.api import setdress class AssemblyLoader(load.LoaderPlugin): families = ["assembly"] representations = ["json"] label = "Load Set Dress" order = -9 icon = "code-fork" color = "orange" def load(self, context, name, namespace, data): asset = context['asset']['name'] namespace = namespace or unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", ) containers = setdress.load_package( filepath=self.filepath_from_context(context), name=name, namespace=namespace ) self[:] = containers # Only containerize if any nodes were loaded by the Loader nodes = self[:] if not nodes: return return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) def update(self, container, representation): return setdress.update_package(container, representation) def remove(self, container): """Remove all sub containers""" # Remove all members member_containers = setdress.get_contained_containers(container) for member_container in member_containers: self.log.info("Removing container %s", member_container['objectName']) remove_container(member_container) # Remove alembic hierarchy reference # TODO: Check whether removing all contained references is safe enough members = cmds.sets(container['objectName'], query=True) or [] references = cmds.ls(members, type="reference") for reference in references: self.log.info("Removing %s", reference) fname = cmds.referenceQuery(reference, filename=True) cmds.file(fname, removeReference=True) # Delete container and its contents if cmds.objExists(container['objectName']): members = cmds.sets(container['objectName'], query=True) or [] cmds.delete([container['objectName']] + members) # TODO: Ensure namespace is gone ================================================ FILE: openpype/hosts/maya/plugins/load/load_audio.py ================================================ from maya import cmds, mel from openpype.pipeline import ( load, get_representation_path, ) from openpype.hosts.maya.api.pipeline import containerise from openpype.hosts.maya.api.lib import unique_namespace, get_container_members class AudioLoader(load.LoaderPlugin): """Specific loader of audio.""" families = ["audio"] label = "Load audio" representations = ["wav"] icon = "volume-up" color = "orange" def load(self, context, name, namespace, data): start_frame = cmds.playbackOptions(query=True, min=True) sound_node = cmds.sound( file=self.filepath_from_context(context), offset=start_frame ) cmds.timeControl( mel.eval("$gPlayBackSlider=$gPlayBackSlider"), edit=True, sound=sound_node, displaySound=True ) asset = context["asset"]["name"] namespace = namespace or unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", ) return containerise( name=name, namespace=namespace, nodes=[sound_node], context=context, loader=self.__class__.__name__ ) def update(self, container, representation): members = get_container_members(container) audio_nodes = cmds.ls(members, type="audio") assert audio_nodes is not None, "Audio node not found." audio_node = audio_nodes[0] current_sound = cmds.timeControl( mel.eval("$gPlayBackSlider=$gPlayBackSlider"), query=True, sound=True ) activate_sound = current_sound == audio_node path = get_representation_path(representation) cmds.sound( audio_node, edit=True, file=path ) # The source start + end does not automatically update itself to the # length of thew new audio file, even though maya does do that when # creating a new audio node. So to update we compute it manually. # This would however override any source start and source end a user # might have done on the original audio node after load. audio_frame_count = cmds.getAttr("{}.frameCount".format(audio_node)) audio_sample_rate = cmds.getAttr("{}.sampleRate".format(audio_node)) duration_in_seconds = audio_frame_count / audio_sample_rate fps = mel.eval('currentTimeUnitToFPS()') # workfile FPS source_start = 0 source_end = (duration_in_seconds * fps) cmds.setAttr("{}.sourceStart".format(audio_node), source_start) cmds.setAttr("{}.sourceEnd".format(audio_node), source_end) if activate_sound: # maya by default deactivates it from timeline on file change cmds.timeControl( mel.eval("$gPlayBackSlider=$gPlayBackSlider"), edit=True, sound=audio_node, displaySound=True ) cmds.setAttr( container["objectName"] + ".representation", str(representation["_id"]), type="string" ) def switch(self, container, representation): self.update(container, representation) def remove(self, container): members = cmds.sets(container['objectName'], query=True) cmds.lockNode(members, lock=False) cmds.delete([container['objectName']] + members) # Clean up the namespace try: cmds.namespace(removeNamespace=container['namespace'], deleteNamespaceContent=True) except RuntimeError: pass ================================================ FILE: openpype/hosts/maya/plugins/load/load_gpucache.py ================================================ import os import maya.cmds as cmds from openpype.hosts.maya.api.pipeline import containerise from openpype.hosts.maya.api.lib import unique_namespace from openpype.pipeline import ( load, get_representation_path ) from openpype.settings import get_project_settings class GpuCacheLoader(load.LoaderPlugin): """Load Alembic as gpuCache""" families = ["model", "animation", "proxyAbc", "pointcache"] representations = ["abc", "gpu_cache"] label = "Load Gpu Cache" order = -5 icon = "code-fork" color = "orange" def load(self, context, name, namespace, data): asset = context['asset']['name'] namespace = namespace or unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", ) cmds.loadPlugin("gpuCache", quiet=True) # Root group label = "{}:{}".format(namespace, name) root = cmds.group(name=label, empty=True) project_name = context["project"]["name"] settings = get_project_settings(project_name) colors = settings['maya']['load']['colors'] c = colors.get('model') if c is not None: cmds.setAttr(root + ".useOutlinerColor", 1) cmds.setAttr( root + ".outlinerColor", (float(c[0]) / 255), (float(c[1]) / 255), (float(c[2]) / 255) ) # Create transform with shape transform_name = label + "_GPU" transform = cmds.createNode("transform", name=transform_name, parent=root) cache = cmds.createNode("gpuCache", parent=transform, name="{0}Shape".format(transform_name)) # Set the cache filepath path = self.filepath_from_context(context) cmds.setAttr(cache + '.cacheFileName', path, type="string") cmds.setAttr(cache + '.cacheGeomPath', "|", type="string") # root # Lock parenting of the transform and cache cmds.lockNode([transform, cache], lock=True) nodes = [root, transform, cache] self[:] = nodes return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) def update(self, container, representation): path = get_representation_path(representation) # Update the cache members = cmds.sets(container['objectName'], query=True) caches = cmds.ls(members, type="gpuCache", long=True) assert len(caches) == 1, "This is a bug" for cache in caches: cmds.setAttr(cache + ".cacheFileName", path, type="string") cmds.setAttr(container["objectName"] + ".representation", str(representation["_id"]), type="string") def switch(self, container, representation): self.update(container, representation) def remove(self, container): members = cmds.sets(container['objectName'], query=True) cmds.lockNode(members, lock=False) cmds.delete([container['objectName']] + members) # Clean up the namespace try: cmds.namespace(removeNamespace=container['namespace'], deleteNamespaceContent=True) except RuntimeError: pass ================================================ FILE: openpype/hosts/maya/plugins/load/load_image.py ================================================ import os import copy from openpype.lib import EnumDef from openpype.pipeline import ( load, get_representation_context, get_current_host_name, ) from openpype.pipeline.load.utils import get_representation_path_from_context from openpype.pipeline.colorspace import ( get_imageio_file_rules_colorspace_from_filepath, get_imageio_config, get_imageio_file_rules ) from openpype.settings import get_project_settings from openpype.hosts.maya.api.pipeline import containerise from openpype.hosts.maya.api.lib import ( unique_namespace, namespaced ) from maya import cmds def create_texture(): """Create place2dTexture with file node with uv connections Mimics Maya "file [Texture]" creation. """ place = cmds.shadingNode("place2dTexture", asUtility=True, name="place2d") file = cmds.shadingNode("file", asTexture=True, name="file") connections = ["coverage", "translateFrame", "rotateFrame", "rotateUV", "mirrorU", "mirrorV", "stagger", "wrapV", "wrapU", "repeatUV", "offset", "noiseUV", "vertexUvThree", "vertexUvTwo", "vertexUvOne", "vertexCameraOne"] for attr in connections: src = "{}.{}".format(place, attr) dest = "{}.{}".format(file, attr) cmds.connectAttr(src, dest) cmds.connectAttr(place + '.outUV', file + '.uvCoord') cmds.connectAttr(place + '.outUvFilterSize', file + '.uvFilterSize') return file, place def create_projection(): """Create texture with place3dTexture and projection Mimics Maya "file [Projection]" creation. """ file, place = create_texture() projection = cmds.shadingNode("projection", asTexture=True, name="projection") place3d = cmds.shadingNode("place3dTexture", asUtility=True, name="place3d") cmds.connectAttr(place3d + '.worldInverseMatrix[0]', projection + ".placementMatrix") cmds.connectAttr(file + '.outColor', projection + ".image") return file, place, projection, place3d def create_stencil(): """Create texture with extra place2dTexture offset and stencil Mimics Maya "file [Stencil]" creation. """ file, place = create_texture() place_stencil = cmds.shadingNode("place2dTexture", asUtility=True, name="place2d_stencil") stencil = cmds.shadingNode("stencil", asTexture=True, name="stencil") for src_attr, dest_attr in [ ("outUV", "uvCoord"), ("outUvFilterSize", "uvFilterSize") ]: src_plug = "{}.{}".format(place_stencil, src_attr) cmds.connectAttr(src_plug, "{}.{}".format(place, dest_attr)) cmds.connectAttr(src_plug, "{}.{}".format(stencil, dest_attr)) return file, place, stencil, place_stencil class FileNodeLoader(load.LoaderPlugin): """File node loader.""" families = ["image", "plate", "render"] label = "Load file node" representations = ["exr", "tif", "png", "jpg"] icon = "image" color = "orange" order = 2 options = [ EnumDef( "mode", items={ "texture": "Texture", "projection": "Projection", "stencil": "Stencil" }, default="texture", label="Texture Mode" ) ] def load(self, context, name, namespace, data): asset = context['asset']['name'] namespace = namespace or unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", ) with namespaced(namespace, new=True) as namespace: # Create the nodes within the namespace nodes = { "texture": create_texture, "projection": create_projection, "stencil": create_stencil }[data.get("mode", "texture")]() file_node = cmds.ls(nodes, type="file")[0] self._apply_representation_context(context, file_node) # For ease of access for the user select all the nodes and select # the file node last so that UI shows its attributes by default cmds.select(list(nodes) + [file_node], replace=True) return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__ ) def update(self, container, representation): members = cmds.sets(container['objectName'], query=True) file_node = cmds.ls(members, type="file")[0] context = get_representation_context(representation) self._apply_representation_context(context, file_node) # Update representation cmds.setAttr( container["objectName"] + ".representation", str(representation["_id"]), type="string" ) def switch(self, container, representation): self.update(container, representation) def remove(self, container): members = cmds.sets(container['objectName'], query=True) cmds.lockNode(members, lock=False) cmds.delete([container['objectName']] + members) # Clean up the namespace try: cmds.namespace(removeNamespace=container['namespace'], deleteNamespaceContent=True) except RuntimeError: pass def _apply_representation_context(self, context, file_node): """Update the file node to match the context. This sets the file node's attributes for: - file path - udim tiling mode (if it is an udim tile) - use frame extension (if it is a sequence) - colorspace """ repre_context = context["representation"]["context"] has_frames = repre_context.get("frame") is not None has_udim = repre_context.get("udim") is not None # Set UV tiling mode if UDIM tiles if has_udim: cmds.setAttr(file_node + ".uvTilingMode", 3) # UDIM-tiles else: cmds.setAttr(file_node + ".uvTilingMode", 0) # off # Enable sequence if publish has `startFrame` and `endFrame` and # `startFrame != endFrame` if has_frames and self._is_sequence(context): # When enabling useFrameExtension maya automatically # connects an expression to .frameExtension to set # the current frame. However, this expression is generated # with some delay and thus it'll show a warning if frame 0 # doesn't exist because we're explicitly setting the # token. cmds.setAttr(file_node + ".useFrameExtension", True) else: cmds.setAttr(file_node + ".useFrameExtension", False) # Set the file node path attribute path = self._format_path(context) cmds.setAttr(file_node + ".fileTextureName", path, type="string") # Set colorspace colorspace = self._get_colorspace(context) if colorspace: cmds.setAttr(file_node + ".colorSpace", colorspace, type="string") else: self.log.debug("Unknown colorspace - setting colorspace skipped.") def _is_sequence(self, context): """Check whether frameStart and frameEnd are not the same.""" version = context.get("version", {}) representation = context.get("representation", {}) for doc in [representation, version]: # Frame range can be set on version or representation. # When set on representation it overrides version data. data = doc.get("data", {}) start = data.get("frameStartHandle", data.get("frameStart", None)) end = data.get("frameEndHandle", data.get("frameEnd", None)) if start is None or end is None: continue if start != end: return True else: return False return False def _get_colorspace(self, context): """Return colorspace of the file to load. Retrieves the explicit colorspace from the publish. If no colorspace data is stored with published content then project imageio settings are used to make an assumption of the colorspace based on the file rules. If no file rules match then None is returned. Returns: str or None: The colorspace of the file or None if not detected. """ # We can't apply color spaces if management is not enabled if not cmds.colorManagementPrefs(query=True, cmEnabled=True): return representation = context["representation"] colorspace_data = representation.get("data", {}).get("colorspaceData") if colorspace_data: return colorspace_data["colorspace"] # Assume colorspace from filepath based on project settings project_name = context["project"]["name"] host_name = get_current_host_name() project_settings = get_project_settings(project_name) config_data = get_imageio_config( project_name, host_name, project_settings=project_settings ) # ignore if host imageio is not enabled if not config_data: return file_rules = get_imageio_file_rules( project_name, host_name, project_settings=project_settings ) path = get_representation_path_from_context(context) colorspace = get_imageio_file_rules_colorspace_from_filepath( path, host_name, project_name, config_data=config_data, file_rules=file_rules, project_settings=project_settings ) return colorspace def _format_path(self, context): """Format the path with correct tokens for frames and udim tiles.""" context = copy.deepcopy(context) representation = context["representation"] template = representation.get("data", {}).get("template") if not template: # No template to find token locations for return get_representation_path_from_context(context) def _placeholder(key): # Substitute with a long placeholder value so that potential # custom formatting with padding doesn't find its way into # our formatting, so that wouldn't be padded as 0 return "___{}___".format(key) # We format UDIM and Frame numbers with their specific tokens. To do so # we in-place change the representation context data to format the path # with our own data tokens = { "frame": "", "udim": "" } has_tokens = False repre_context = representation["context"] for key, _token in tokens.items(): if key in repre_context: repre_context[key] = _placeholder(key) has_tokens = True # Replace with our custom template that has the tokens set representation["data"]["template"] = template path = get_representation_path_from_context(context) if has_tokens: for key, token in tokens.items(): if key in repre_context: path = path.replace(_placeholder(key), token) return path ================================================ FILE: openpype/hosts/maya/plugins/load/load_image_plane.py ================================================ from qtpy import QtWidgets, QtCore from openpype.client import ( get_asset_by_id, get_subset_by_id, get_version_by_id, ) from openpype.pipeline import ( load, get_representation_path, get_current_project_name, ) from openpype.hosts.maya.api.pipeline import containerise from openpype.hosts.maya.api.lib import ( unique_namespace, namespaced, pairwise, get_container_members ) from maya import cmds def disconnect_inputs(plug): overrides = cmds.listConnections(plug, source=True, destination=False, plugs=True, connections=True) or [] for dest, src in pairwise(overrides): cmds.disconnectAttr(src, dest) class CameraWindow(QtWidgets.QDialog): def __init__(self, cameras): super(CameraWindow, self).__init__() self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) self.camera = None self.widgets = { "label": QtWidgets.QLabel("Select camera for image plane."), "list": QtWidgets.QListWidget(), "staticImagePlane": QtWidgets.QCheckBox(), "showInAllViews": QtWidgets.QCheckBox(), "warning": QtWidgets.QLabel("No cameras selected!"), "buttons": QtWidgets.QWidget(), "okButton": QtWidgets.QPushButton("Ok"), "cancelButton": QtWidgets.QPushButton("Cancel") } # Build warning. self.widgets["warning"].setVisible(False) self.widgets["warning"].setStyleSheet("color: red") # Build list. for camera in cameras: self.widgets["list"].addItem(camera) # Build buttons. layout = QtWidgets.QHBoxLayout(self.widgets["buttons"]) layout.addWidget(self.widgets["okButton"]) layout.addWidget(self.widgets["cancelButton"]) # Build layout. layout = QtWidgets.QVBoxLayout(self) layout.addWidget(self.widgets["label"]) layout.addWidget(self.widgets["list"]) layout.addWidget(self.widgets["buttons"]) layout.addWidget(self.widgets["warning"]) self.widgets["okButton"].pressed.connect(self.on_ok_pressed) self.widgets["cancelButton"].pressed.connect(self.on_cancel_pressed) self.widgets["list"].itemPressed.connect(self.on_list_itemPressed) def on_list_itemPressed(self, item): self.camera = item.text() def on_ok_pressed(self): if self.camera is None: self.widgets["warning"].setVisible(True) return self.close() def on_cancel_pressed(self): self.camera = None self.close() class ImagePlaneLoader(load.LoaderPlugin): """Specific loader of plate for image planes on selected camera.""" families = ["image", "plate", "render"] label = "Load imagePlane" representations = ["mov", "exr", "preview", "png", "jpg"] icon = "image" color = "orange" def load(self, context, name, namespace, data, options=None): image_plane_depth = 1000 asset = context['asset']['name'] namespace = namespace or unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", ) # Get camera from user selection. # is_static_image_plane = None # is_in_all_views = None camera = data.get("camera") if data else None if not camera: cameras = cmds.ls(type="camera") # Cameras by names camera_names = {} for camera in cameras: parent = cmds.listRelatives(camera, parent=True, path=True)[0] camera_names[parent] = camera camera_names["Create new camera."] = "create-camera" window = CameraWindow(camera_names.keys()) window.exec_() # Skip if no camera was selected (Dialog was closed) if window.camera not in camera_names: return camera = camera_names[window.camera] if camera == "create-camera": camera = cmds.createNode("camera") if camera is None: return try: cmds.setAttr("{}.displayResolution".format(camera), True) cmds.setAttr("{}.farClipPlane".format(camera), image_plane_depth * 10) except RuntimeError: pass # Create image plane with namespaced(namespace): # Create inside the namespace image_plane_transform, image_plane_shape = cmds.imagePlane( fileName=context["representation"]["data"]["path"], camera=camera ) start_frame = cmds.playbackOptions(query=True, min=True) end_frame = cmds.playbackOptions(query=True, max=True) for attr, value in { "depth": image_plane_depth, "frameOffset": 0, "frameIn": start_frame, "frameOut": end_frame, "frameCache": end_frame, "useFrameExtension": True }.items(): plug = "{}.{}".format(image_plane_shape, attr) cmds.setAttr(plug, value) movie_representations = ["mov", "preview"] if context["representation"]["name"] in movie_representations: cmds.setAttr(image_plane_shape + ".type", 2) # Ask user whether to use sequence or still image. if context["representation"]["name"] == "exr": # Ensure OpenEXRLoader plugin is loaded. cmds.loadPlugin("OpenEXRLoader", quiet=True) message = ( "Hold image sequence on first frame?" "\n{} files available.".format( len(context["representation"]["files"]) ) ) reply = QtWidgets.QMessageBox.information( None, "Frame Hold.", message, QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No ) if reply == QtWidgets.QMessageBox.Yes: frame_extension_plug = "{}.frameExtension".format(image_plane_shape) # noqa # Remove current frame expression disconnect_inputs(frame_extension_plug) cmds.setAttr(frame_extension_plug, start_frame) new_nodes = [image_plane_transform, image_plane_shape] return containerise( name=name, namespace=namespace, nodes=new_nodes, context=context, loader=self.__class__.__name__ ) def update(self, container, representation): members = get_container_members(container) image_planes = cmds.ls(members, type="imagePlane") assert image_planes, "Image plane not found." image_plane_shape = image_planes[0] path = get_representation_path(representation) cmds.setAttr("{}.imageName".format(image_plane_shape), path, type="string") cmds.setAttr("{}.representation".format(container["objectName"]), str(representation["_id"]), type="string") # Set frame range. project_name = get_current_project_name() version = get_version_by_id( project_name, representation["parent"], fields=["parent"] ) subset = get_subset_by_id( project_name, version["parent"], fields=["parent"] ) asset = get_asset_by_id( project_name, subset["parent"], fields=["parent"] ) start_frame = asset["data"]["frameStart"] end_frame = asset["data"]["frameEnd"] for attr, value in { "frameOffset": 0, "frameIn": start_frame, "frameOut": end_frame, "frameCache": end_frame }: plug = "{}.{}".format(image_plane_shape, attr) cmds.setAttr(plug, value) def switch(self, container, representation): self.update(container, representation) def remove(self, container): members = cmds.sets(container['objectName'], query=True) cmds.lockNode(members, lock=False) cmds.delete([container['objectName']] + members) # Clean up the namespace try: cmds.namespace(removeNamespace=container['namespace'], deleteNamespaceContent=True) except RuntimeError: pass ================================================ FILE: openpype/hosts/maya/plugins/load/load_look.py ================================================ # -*- coding: utf-8 -*- """Look loader.""" import json from collections import defaultdict from qtpy import QtWidgets from openpype.client import get_representation_by_name from openpype.pipeline import ( get_current_project_name, get_representation_path, ) import openpype.hosts.maya.api.plugin from openpype.hosts.maya.api import lib from openpype.widgets.message_window import ScrollMessageBox from openpype.hosts.maya.api.lib import get_reference_node class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): """Specific loader for lookdev""" families = ["look"] representations = ["ma"] label = "Reference look" order = -10 icon = "code-fork" color = "orange" def process_reference(self, context, name, namespace, options): from maya import cmds with lib.maintained_selection(): file_url = self.prepare_root_value( file_url=self.filepath_from_context(context), project_name=context["project"]["name"] ) nodes = cmds.file(file_url, namespace=namespace, reference=True, returnNewNodes=True) self[:] = nodes def switch(self, container, representation): self.update(container, representation) def update(self, container, representation): """ Called by Scene Inventory when look should be updated to current version. If any reference edits cannot be applied, eg. shader renamed and material not present, reference is unloaded and cleaned. All failed edits are highlighted to the user via message box. Args: container: object that has look to be updated representation: (dict): relationship data to get proper representation from DB and persisted data in .json Returns: None """ from maya import cmds # Get reference node from container members members = lib.get_container_members(container) reference_node = get_reference_node(members, log=self.log) shader_nodes = cmds.ls(members, type='shadingEngine') orig_nodes = set(self._get_nodes_with_shader(shader_nodes)) # Trigger the regular reference update on the ReferenceLoader super(LookLoader, self).update(container, representation) # get new applied shaders and nodes from new version shader_nodes = cmds.ls(members, type='shadingEngine') nodes = set(self._get_nodes_with_shader(shader_nodes)) project_name = get_current_project_name() json_representation = get_representation_by_name( project_name, "json", representation["parent"] ) # Load relationships shader_relation = get_representation_path(json_representation) with open(shader_relation, "r") as f: json_data = json.load(f) # update of reference could result in failed edits - material is not # present because of renaming etc. If so highlight failed edits to user failed_edits = cmds.referenceQuery(reference_node, editStrings=True, failedEdits=True, successfulEdits=False) if failed_edits: # clean references - removes failed reference edits cmds.file(cr=reference_node) # cleanReference # reapply shading groups from json representation on orig nodes lib.apply_shaders(json_data, shader_nodes, orig_nodes) msg = ["During reference update some edits failed.", "All successful edits were kept intact.\n", "Failed and removed edits:"] msg.extend(failed_edits) msg = ScrollMessageBox(QtWidgets.QMessageBox.Warning, "Some reference edit failed", msg) msg.exec_() attributes = json_data.get("attributes", []) # region compute lookup nodes_by_id = defaultdict(list) for node in nodes: nodes_by_id[lib.get_id(node)].append(node) lib.apply_attributes(attributes, nodes_by_id) def _get_nodes_with_shader(self, shader_nodes): """ Returns list of nodes belonging to specific shaders Args: shader_nodes: of Shader groups Returns node names """ from maya import cmds for shader in shader_nodes: future = cmds.listHistory(shader, future=True) connections = cmds.listConnections(future, type='mesh') if connections: # Ensure unique entries only to optimize query and results connections = list(set(connections)) return cmds.listRelatives(connections, shapes=True, fullPath=True) or [] return [] ================================================ FILE: openpype/hosts/maya/plugins/load/load_matchmove.py ================================================ # -*- coding: utf-8 -*- from maya import cmds, mel # noqa: F401 from openpype.hosts.maya.api.pipeline import containerise from openpype.hosts.maya.api import lib, Loader from openpype.pipeline.load import get_representation_path, LoadError class MatchmoveLoader(Loader): """Run matchmove script to create track in scene. Supported script types are .py and .mel TODO: there might be error in the scripts exported from 3DEqualizer that it is trying to set frame attribute on camera image plane and then add expression for image sequence. Maya will throw RuntimeError at that point that will stop processing rest of the script and the container will not be created. We should somehow handle this - maybe even by patching the mel script on-the-fly. """ families = ["matchmove"] representations = ["py", "mel"] defaults = ["Camera", "Object", "Mocap"] label = "Run matchmove script" icon = "empire" color = "orange" def load(self, context, name, namespace, options): path = self.filepath_from_context(context) custom_group_name, custom_namespace, options = \ self.get_custom_namespace_and_group( context, options, "matchmove_loader") namespace = lib.get_custom_namespace(custom_namespace) nodes = self._load_nodes_from_script(path) return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__ ) def update(self, container, representation): # type: (dict, dict) -> None """Update container with specified representation.""" self.remove(container) path = get_representation_path(representation) namespace = container["namespace"] print(f">>> loading from {path}") try: nodes = self._load_nodes_from_script(path) except RuntimeError as e: raise LoadError("Failed to load matchmove script.") from e return containerise( name=container["name"], namespace=namespace, nodes=nodes, context=representation["context"], loader=self.__class__.__name__ ) def switch(self, container, representation): self.update(container, representation) def remove(self, container): """Delete container and its contents.""" if cmds.objExists(container['objectName']): members = cmds.sets(container['objectName'], query=True) or [] cmds.delete([container['objectName']] + members) def _load_nodes_from_script(self, path): # type: (str) -> list """Load nodes from script. This will execute py or mel script and resulting nodes will be returned. Args: path (str): path to script Returns: list: list of created nodes """ previous_nodes = set(cmds.ls(long=True)) if path.lower().endswith(".py"): exec(open(path).read()) elif path.lower().endswith(".mel"): mel.eval(open(path).read()) else: self.log.error("Unsupported script type") current_nodes = set(cmds.ls(long=True)) return list(current_nodes - previous_nodes) ================================================ FILE: openpype/hosts/maya/plugins/load/load_maya_usd.py ================================================ # -*- coding: utf-8 -*- import maya.cmds as cmds from openpype.pipeline import ( load, get_representation_path, ) from openpype.pipeline.load import get_representation_path_from_context from openpype.hosts.maya.api.lib import ( namespaced, unique_namespace ) from openpype.hosts.maya.api.pipeline import containerise class MayaUsdLoader(load.LoaderPlugin): """Read USD data in a Maya USD Proxy""" families = ["model", "usd", "pointcache", "animation"] representations = ["usd", "usda", "usdc", "usdz", "abc"] label = "Load USD to Maya Proxy" order = -1 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, options=None): asset = context['asset']['name'] namespace = namespace or unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", ) # Make sure we can load the plugin cmds.loadPlugin("mayaUsdPlugin", quiet=True) path = get_representation_path_from_context(context) # Create the shape cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): transform = cmds.createNode("transform", name=name, skipSelect=True) proxy = cmds.createNode('mayaUsdProxyShape', name="{}Shape".format(name), parent=transform, skipSelect=True) cmds.connectAttr("time1.outTime", "{}.time".format(proxy)) cmds.setAttr("{}.filePath".format(proxy), path, type="string") # By default, we force the proxy to not use a shared stage because # when doing so Maya will quite easily allow to save into the # loaded usd file. Since we are loading published files we want to # avoid altering them. Unshared stages also save their edits into # the workfile as an artist might expect it to do. cmds.setAttr("{}.shareStage".format(proxy), False) # cmds.setAttr("{}.shareStage".format(proxy), lock=True) nodes = [transform, proxy] self[:] = nodes return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) def update(self, container, representation): # type: (dict, dict) -> None """Update container with specified representation.""" node = container['objectName'] assert cmds.objExists(node), "Missing container" members = cmds.sets(node, query=True) or [] shapes = cmds.ls(members, type="mayaUsdProxyShape") path = get_representation_path(representation) for shape in shapes: cmds.setAttr("{}.filePath".format(shape), path, type="string") cmds.setAttr("{}.representation".format(node), str(representation["_id"]), type="string") def switch(self, container, representation): self.update(container, representation) def remove(self, container): # type: (dict) -> None """Remove loaded container.""" # Delete container and its contents if cmds.objExists(container['objectName']): members = cmds.sets(container['objectName'], query=True) or [] cmds.delete([container['objectName']] + members) # Remove the namespace, if empty namespace = container['namespace'] if cmds.namespace(exists=namespace): members = cmds.namespaceInfo(namespace, listNamespace=True) if not members: cmds.namespace(removeNamespace=namespace) else: self.log.warning("Namespace not deleted because it " "still has members: %s", namespace) ================================================ FILE: openpype/hosts/maya/plugins/load/load_multiverse_usd.py ================================================ # -*- coding: utf-8 -*- import maya.cmds as cmds from maya import mel import os from openpype.pipeline import ( load, get_representation_path ) from openpype.hosts.maya.api.lib import ( maintained_selection, namespaced, unique_namespace ) from openpype.hosts.maya.api.pipeline import containerise from openpype.client import get_representation_by_id class MultiverseUsdLoader(load.LoaderPlugin): """Read USD data in a Multiverse Compound""" families = ["model", "usd", "mvUsdComposition", "mvUsdOverride", "pointcache", "animation"] representations = ["usd", "usda", "usdc", "usdz", "abc"] label = "Load USD to Multiverse" order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, options=None): asset = context['asset']['name'] namespace = namespace or unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", ) path = self.filepath_from_context(context) # Make sure we can load the plugin cmds.loadPlugin("MultiverseForMaya", quiet=True) import multiverse # Create the shape with maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): shape = multiverse.CreateUsdCompound(path) transform = cmds.listRelatives( shape, parent=True, fullPath=True)[0] nodes = [transform, shape] self[:] = nodes return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) def update(self, container, representation): # type: (dict, dict) -> None """Update container with specified representation.""" node = container['objectName'] assert cmds.objExists(node), "Missing container" members = cmds.sets(node, query=True) or [] shapes = cmds.ls(members, type="mvUsdCompoundShape") assert shapes, "Cannot find mvUsdCompoundShape in container" project_name = representation["context"]["project"]["name"] prev_representation_id = cmds.getAttr("{}.representation".format(node)) prev_representation = get_representation_by_id(project_name, prev_representation_id) prev_path = os.path.normpath(prev_representation["data"]["path"]) # Make sure we can load the plugin cmds.loadPlugin("MultiverseForMaya", quiet=True) import multiverse for shape in shapes: asset_paths = multiverse.GetUsdCompoundAssetPaths(shape) asset_paths = [os.path.normpath(p) for p in asset_paths] assert asset_paths.count(prev_path) == 1, \ "Couldn't find matching path (or too many)" prev_path_idx = asset_paths.index(prev_path) path = get_representation_path(representation) asset_paths[prev_path_idx] = path multiverse.SetUsdCompoundAssetPaths(shape, asset_paths) cmds.setAttr("{}.representation".format(node), str(representation["_id"]), type="string") mel.eval('refreshEditorTemplates;') def switch(self, container, representation): self.update(container, representation) def remove(self, container): # type: (dict) -> None """Remove loaded container.""" # Delete container and its contents if cmds.objExists(container['objectName']): members = cmds.sets(container['objectName'], query=True) or [] cmds.delete([container['objectName']] + members) # Remove the namespace, if empty namespace = container['namespace'] if cmds.namespace(exists=namespace): members = cmds.namespaceInfo(namespace, listNamespace=True) if not members: cmds.namespace(removeNamespace=namespace) else: self.log.warning("Namespace not deleted because it " "still has members: %s", namespace) ================================================ FILE: openpype/hosts/maya/plugins/load/load_multiverse_usd_over.py ================================================ # -*- coding: utf-8 -*- import maya.cmds as cmds from maya import mel import os import qargparse from openpype.pipeline import ( load, get_representation_path ) from openpype.hosts.maya.api.lib import ( maintained_selection ) from openpype.hosts.maya.api.pipeline import containerise from openpype.client import get_representation_by_id class MultiverseUsdOverLoader(load.LoaderPlugin): """Reference file""" families = ["mvUsdOverride"] representations = ["usda", "usd", "udsz"] label = "Load Usd Override into Compound" order = -10 icon = "code-fork" color = "orange" options = [ qargparse.String( "Which Compound", label="Compound", help="Select which compound to add this as a layer to." ) ] def load(self, context, name=None, namespace=None, options=None): current_usd = cmds.ls(selection=True, type="mvUsdCompoundShape", dag=True, long=True) if len(current_usd) != 1: self.log.error("Current selection invalid: '{}', " "must contain exactly 1 mvUsdCompoundShape." "".format(current_usd)) return # Make sure we can load the plugin cmds.loadPlugin("MultiverseForMaya", quiet=True) import multiverse path = self.filepath_from_context(context) nodes = current_usd with maintained_selection(): multiverse.AddUsdCompoundAssetPath(current_usd[0], path) namespace = current_usd[0].split("|")[1].split(":")[0] container = containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) cmds.addAttr(container, longName="mvUsdCompoundShape", niceName="mvUsdCompoundShape", dataType="string") cmds.setAttr(container + ".mvUsdCompoundShape", current_usd[0], type="string") return container def update(self, container, representation): # type: (dict, dict) -> None """Update container with specified representation.""" cmds.loadPlugin("MultiverseForMaya", quiet=True) import multiverse node = container['objectName'] assert cmds.objExists(node), "Missing container" members = cmds.sets(node, query=True) or [] shapes = cmds.ls(members, type="mvUsdCompoundShape") assert shapes, "Cannot find mvUsdCompoundShape in container" mvShape = container['mvUsdCompoundShape'] assert mvShape, "Missing mv source" project_name = representation["context"]["project"]["name"] prev_representation_id = cmds.getAttr("{}.representation".format(node)) prev_representation = get_representation_by_id(project_name, prev_representation_id) prev_path = os.path.normpath(prev_representation["data"]["path"]) path = get_representation_path(representation) for shape in shapes: asset_paths = multiverse.GetUsdCompoundAssetPaths(shape) asset_paths = [os.path.normpath(p) for p in asset_paths] assert asset_paths.count(prev_path) == 1, \ "Couldn't find matching path (or too many)" prev_path_idx = asset_paths.index(prev_path) asset_paths[prev_path_idx] = path multiverse.SetUsdCompoundAssetPaths(shape, asset_paths) cmds.setAttr("{}.representation".format(node), str(representation["_id"]), type="string") mel.eval('refreshEditorTemplates;') def switch(self, container, representation): self.update(container, representation) def remove(self, container): # type: (dict) -> None """Remove loaded container.""" # Delete container and its contents if cmds.objExists(container['objectName']): members = cmds.sets(container['objectName'], query=True) or [] cmds.delete([container['objectName']] + members) # Remove the namespace, if empty namespace = container['namespace'] if cmds.namespace(exists=namespace): members = cmds.namespaceInfo(namespace, listNamespace=True) if not members: cmds.namespace(removeNamespace=namespace) else: self.log.warning("Namespace not deleted because it " "still has members: %s", namespace) ================================================ FILE: openpype/hosts/maya/plugins/load/load_redshift_proxy.py ================================================ # -*- coding: utf-8 -*- """Loader for Redshift proxy.""" import os import clique import maya.cmds as cmds from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_representation_path ) from openpype.hosts.maya.api.lib import ( namespaced, maintained_selection, unique_namespace ) from openpype.hosts.maya.api.pipeline import containerise class RedshiftProxyLoader(load.LoaderPlugin): """Load Redshift proxy""" families = ["redshiftproxy"] representations = ["rs"] label = "Import Redshift Proxy" order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, options=None): """Plugin entry point.""" try: family = context["representation"]["context"]["family"] except ValueError: family = "redshiftproxy" asset_name = context['asset']["name"] namespace = namespace or unique_namespace( asset_name + "_", prefix="_" if asset_name[0].isdigit() else "", suffix="_", ) # Ensure Redshift for Maya is loaded. cmds.loadPlugin("redshift4maya", quiet=True) path = self.filepath_from_context(context) with maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): nodes, group_node = self.create_rs_proxy(name, path) self[:] = nodes if not nodes: return # colour the group node project_name = context["project"]["name"] settings = get_project_settings(project_name) colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: cmds.setAttr("{0}.useOutlinerColor".format(group_node), 1) cmds.setAttr("{0}.outlinerColor".format(group_node), c[0], c[1], c[2]) return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) def update(self, container, representation): node = container['objectName'] assert cmds.objExists(node), "Missing container" members = cmds.sets(node, query=True) or [] rs_meshes = cmds.ls(members, type="RedshiftProxyMesh") assert rs_meshes, "Cannot find RedshiftProxyMesh in container" filename = get_representation_path(representation) for rs_mesh in rs_meshes: cmds.setAttr("{}.fileName".format(rs_mesh), filename, type="string") # Update metadata cmds.setAttr("{}.representation".format(node), str(representation["_id"]), type="string") def remove(self, container): # Delete container and its contents if cmds.objExists(container['objectName']): members = cmds.sets(container['objectName'], query=True) or [] cmds.delete([container['objectName']] + members) # Remove the namespace, if empty namespace = container['namespace'] if cmds.namespace(exists=namespace): members = cmds.namespaceInfo(namespace, listNamespace=True) if not members: cmds.namespace(removeNamespace=namespace) else: self.log.warning("Namespace not deleted because it " "still has members: %s", namespace) def switch(self, container, representation): self.update(container, representation) def create_rs_proxy(self, name, path): """Creates Redshift Proxies showing a proxy object. Args: name (str): Proxy name. path (str): Path to proxy file. Returns: (str, str): Name of mesh with Redshift proxy and its parent transform. """ rs_mesh = cmds.createNode( 'RedshiftProxyMesh', name="{}_RS".format(name)) mesh_shape = cmds.createNode("mesh", name="{}_GEOShape".format(name)) cmds.setAttr("{}.fileName".format(rs_mesh), path, type="string") cmds.connectAttr("{}.outMesh".format(rs_mesh), "{}.inMesh".format(mesh_shape)) # TODO: use the assigned shading group as shaders if existed # assign default shader to redshift proxy if cmds.ls("initialShadingGroup", type="shadingEngine"): cmds.sets(mesh_shape, forceElement="initialShadingGroup") group_node = cmds.group(empty=True, name="{}_GRP".format(name)) mesh_transform = cmds.listRelatives(mesh_shape, parent=True, fullPath=True) cmds.parent(mesh_transform, group_node) nodes = [rs_mesh, mesh_shape, group_node] # determine if we need to enable animation support files_in_folder = os.listdir(os.path.dirname(path)) collections, remainder = clique.assemble(files_in_folder) if collections: cmds.setAttr("{}.useFrameExtension".format(rs_mesh), 1) return nodes, group_node ================================================ FILE: openpype/hosts/maya/plugins/load/load_reference.py ================================================ import os import difflib import contextlib from maya import cmds import qargparse from openpype.settings import get_project_settings import openpype.hosts.maya.api.plugin from openpype.hosts.maya.api.lib import ( maintained_selection, get_container_members, parent_nodes, create_rig_animation_instance ) @contextlib.contextmanager def preserve_modelpanel_cameras(container, log=None): """Preserve camera members of container in the modelPanels. This is used to ensure a camera remains in the modelPanels after updating to a new version. """ # Get the modelPanels that used the old camera members = get_container_members(container) old_cameras = set(cmds.ls(members, type="camera", long=True)) if not old_cameras: # No need to manage anything yield return panel_cameras = {} for panel in cmds.getPanel(type="modelPanel"): cam = cmds.ls(cmds.modelPanel(panel, query=True, camera=True), long=True)[0] # Often but not always maya returns the transform from the # modelPanel as opposed to the camera shape, so we convert it # to explicitly be the camera shape if cmds.nodeType(cam) != "camera": cam = cmds.listRelatives(cam, children=True, fullPath=True, type="camera")[0] if cam in old_cameras: panel_cameras[panel] = cam if not panel_cameras: # No need to manage anything yield return try: yield finally: new_members = get_container_members(container) new_cameras = set(cmds.ls(new_members, type="camera", long=True)) if not new_cameras: return for panel, cam_name in panel_cameras.items(): new_camera = None if cam_name in new_cameras: new_camera = cam_name elif len(new_cameras) == 1: new_camera = next(iter(new_cameras)) else: # Multiple cameras in the updated container but not an exact # match detected by name. Find the closest match matches = difflib.get_close_matches(word=cam_name, possibilities=new_cameras, n=1) if matches: new_camera = matches[0] # best match if log: log.info("Camera in '{}' restored with " "closest match camera: {} (before: {})" .format(panel, new_camera, cam_name)) if not new_camera: # Unable to find the camera to re-apply in the modelpanel continue cmds.modelPanel(panel, edit=True, camera=new_camera) class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): """Reference file""" families = ["model", "pointcache", "proxyAbc", "animation", "mayaAscii", "mayaScene", "setdress", "layout", "camera", "rig", "camerarig", "staticMesh", "skeletalMesh", "mvLook", "matchmove"] representations = ["ma", "abc", "fbx", "mb"] label = "Reference" order = -10 icon = "code-fork" color = "orange" def process_reference(self, context, name, namespace, options): import maya.cmds as cmds try: family = context["representation"]["context"]["family"] except ValueError: family = "model" project_name = context["project"]["name"] # True by default to keep legacy behaviours attach_to_root = options.get("attach_to_root", True) group_name = options["group_name"] # no group shall be created if not attach_to_root: group_name = namespace kwargs = {} if "file_options" in options: kwargs["options"] = options["file_options"] if "file_type" in options: kwargs["type"] = options["file_type"] path = self.filepath_from_context(context) with maintained_selection(): cmds.loadPlugin("AbcImport.mll", quiet=True) file_url = self.prepare_root_value(path, project_name) nodes = cmds.file(file_url, namespace=namespace, sharedReferenceFile=False, reference=True, returnNewNodes=True, groupReference=attach_to_root, groupName=group_name, **kwargs) shapes = cmds.ls(nodes, shapes=True, long=True) new_nodes = (list(set(nodes) - set(shapes))) # if there are cameras, try to lock their transforms self._lock_camera_transforms(new_nodes) current_namespace = cmds.namespaceInfo(currentNamespace=True) if current_namespace != ":": group_name = current_namespace + ":" + group_name self[:] = new_nodes if attach_to_root: group_name = "|" + group_name roots = cmds.listRelatives(group_name, children=True, fullPath=True) or [] if family not in {"layout", "setdress", "mayaAscii", "mayaScene"}: # QUESTION Why do we need to exclude these families? with parent_nodes(roots, parent=None): cmds.xform(group_name, zeroTransformPivots=True) settings = get_project_settings(project_name) display_handle = settings['maya']['load'].get( 'reference_loader', {} ).get('display_handle', True) cmds.setAttr( "{}.displayHandle".format(group_name), display_handle ) colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: cmds.setAttr("{}.useOutlinerColor".format(group_name), 1) cmds.setAttr("{}.outlinerColor".format(group_name), (float(c[0]) / 255), (float(c[1]) / 255), (float(c[2]) / 255)) cmds.setAttr( "{}.displayHandle".format(group_name), display_handle ) # get bounding box bbox = cmds.exactWorldBoundingBox(group_name) # get pivot position on world space pivot = cmds.xform(group_name, q=True, sp=True, ws=True) # center of bounding box cx = (bbox[0] + bbox[3]) / 2 cy = (bbox[1] + bbox[4]) / 2 cz = (bbox[2] + bbox[5]) / 2 # add pivot position to calculate offset cx = cx + pivot[0] cy = cy + pivot[1] cz = cz + pivot[2] # set selection handle offset to center of bounding box cmds.setAttr("{}.selectHandleX".format(group_name), cx) cmds.setAttr("{}.selectHandleY".format(group_name), cy) cmds.setAttr("{}.selectHandleZ".format(group_name), cz) if family == "rig": self._post_process_rig(namespace, context, options) else: if "translate" in options: if not attach_to_root and new_nodes: root_nodes = cmds.ls(new_nodes, assemblies=True, long=True) # we assume only a single root is ever loaded group_name = root_nodes[0] cmds.setAttr("{}.translate".format(group_name), *options["translate"]) return new_nodes def switch(self, container, representation): self.update(container, representation) def update(self, container, representation): with preserve_modelpanel_cameras(container, log=self.log): super(ReferenceLoader, self).update(container, representation) # We also want to lock camera transforms on any new cameras in the # reference or for a camera which might have changed names. members = get_container_members(container) self._lock_camera_transforms(members) def _post_process_rig(self, namespace, context, options): nodes = self[:] create_rig_animation_instance( nodes, context, namespace, options=options, log=self.log ) def _lock_camera_transforms(self, nodes): cameras = cmds.ls(nodes, type="camera") if not cameras: return # Check the Maya version, lockTransform has been introduced since # Maya 2016.5 Ext 2 version = int(cmds.about(version=True)) if version >= 2016: for camera in cameras: cmds.camera(camera, edit=True, lockTransform=True) else: self.log.warning("This version of Maya does not support locking of" " transforms of cameras.") class MayaUSDReferenceLoader(ReferenceLoader): """Reference USD file to native Maya nodes using MayaUSDImport reference""" label = "Reference Maya USD" families = ["usd"] representations = ["usd"] extensions = {"usd", "usda", "usdc"} options = ReferenceLoader.options + [ qargparse.Boolean( "readAnimData", label="Load anim data", default=True, help="Load animation data from USD file" ), qargparse.Boolean( "useAsAnimationCache", label="Use as animation cache", default=True, help=( "Imports geometry prims with time-sampled point data using a " "point-based deformer that references the imported " "USD file.\n" "This provides better import and playback performance when " "importing time-sampled geometry from USD, and should " "reduce the weight of the resulting Maya scene." ) ), qargparse.Boolean( "importInstances", label="Import instances", default=True, help=( "Import USD instanced geometries as Maya instanced shapes. " "Will flatten the scene otherwise." ) ), qargparse.String( "primPath", label="Prim Path", default="/", help=( "Name of the USD scope where traversing will begin.\n" "The prim at the specified primPath (including the prim) will " "be imported.\n" "Specifying the pseudo-root (/) means you want " "to import everything in the file.\n" "If the passed prim path is empty, it will first try to " "import the defaultPrim for the rootLayer if it exists.\n" "Otherwise, it will behave as if the pseudo-root was passed " "in." ) ) ] file_type = "USD Import" def process_reference(self, context, name, namespace, options): cmds.loadPlugin("mayaUsdPlugin", quiet=True) def bool_option(key, default): # Shorthand for getting optional boolean file option from options value = int(bool(options.get(key, default))) return "{}={}".format(key, value) def string_option(key, default): # Shorthand for getting optional string file option from options value = str(options.get(key, default)) return "{}={}".format(key, value) options["file_options"] = ";".join([ string_option("primPath", default="/"), bool_option("importInstances", default=True), bool_option("useAsAnimationCache", default=True), bool_option("readAnimData", default=True), # TODO: Expose more parameters # "preferredMaterial=none", # "importRelativeTextures=Automatic", # "useCustomFrameRange=0", # "startTime=0", # "endTime=0", # "importUSDZTextures=0" ]) options["file_type"] = self.file_type return super(MayaUSDReferenceLoader, self).process_reference( context, name, namespace, options ) ================================================ FILE: openpype/hosts/maya/plugins/load/load_rendersetup.py ================================================ # -*- coding: utf-8 -*- """Load and update RenderSetup settings. Working with RenderSetup setting is Maya is done utilizing json files. When this json is loaded, it will overwrite all settings on RenderSetup instance. """ import json import sys import six from openpype.pipeline import ( load, get_representation_path ) from openpype.hosts.maya.api import lib from openpype.hosts.maya.api.pipeline import containerise from maya import cmds import maya.app.renderSetup.model.renderSetup as renderSetup class RenderSetupLoader(load.LoaderPlugin): """Load json preset for RenderSetup overwriting current one.""" families = ["rendersetup"] representations = ["json"] defaults = ['Main'] label = "Load RenderSetup template" icon = "tablet" color = "orange" def load(self, context, name, namespace, data): """Load RenderSetup settings.""" # from openpype.hosts.maya.api.lib import namespaced asset = context['asset']['name'] namespace = namespace or lib.unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", ) path = self.filepath_from_context(context) self.log.info(">>> loading json [ {} ]".format(path)) with open(path, "r") as file: renderSetup.instance().decode( json.load(file), renderSetup.DECODE_AND_OVERWRITE, None) nodes = [] null = cmds.sets(name="null_SET", empty=True) nodes.append(null) self[:] = nodes if not nodes: return self.log.info(">>> containerising [ {} ]".format(name)) return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) def remove(self, container): """Remove RenderSetup settings instance.""" from maya import cmds container_name = container["objectName"] self.log.info("Removing '%s' from Maya.." % container["name"]) container_content = cmds.sets(container_name, query=True) nodes = cmds.ls(container_content, long=True) nodes.append(container_name) try: cmds.delete(nodes) except ValueError: # Already implicitly deleted by Maya upon removing reference pass def update(self, container, representation): """Update RenderSetup setting by overwriting existing settings.""" lib.show_message( "Render setup update", "Render setup setting will be overwritten by new version. All " "setting specified by user not included in loaded version " "will be lost.") path = get_representation_path(representation) with open(path, "r") as file: try: renderSetup.instance().decode( json.load(file), renderSetup.DECODE_AND_OVERWRITE, None) except Exception: self.log.error("There were errors during loading") six.reraise(*sys.exc_info()) # Update metadata node = container["objectName"] cmds.setAttr("{}.representation".format(node), str(representation["_id"]), type="string") self.log.info("... updated") def switch(self, container, representation): """Switch representations.""" self.update(container, representation) ================================================ FILE: openpype/hosts/maya/plugins/load/load_vdb_to_arnold.py ================================================ import os from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_representation_path ) # TODO aiVolume doesn't automatically set velocity fps correctly, set manual? class LoadVDBtoArnold(load.LoaderPlugin): """Load OpenVDB for Arnold in aiVolume""" families = ["vdbcache"] representations = ["vdb"] label = "Load VDB to Arnold" icon = "cloud" color = "orange" def load(self, context, name, namespace, data): from maya import cmds from openpype.hosts.maya.api.pipeline import containerise from openpype.hosts.maya.api.lib import unique_namespace try: family = context["representation"]["context"]["family"] except ValueError: family = "vdbcache" # Check if the plugin for arnold is available on the pc try: cmds.loadPlugin("mtoa", quiet=True) except Exception as exc: self.log.error("Encountered exception:\n%s" % exc) return asset = context['asset'] asset_name = asset["name"] namespace = namespace or unique_namespace( asset_name + "_", prefix="_" if asset_name[0].isdigit() else "", suffix="_", ) # Root group label = "{}:{}".format(namespace, name) root = cmds.group(name=label, empty=True) project_name = context["project"]["name"] settings = get_project_settings(project_name) colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: cmds.setAttr(root + ".useOutlinerColor", 1) cmds.setAttr(root + ".outlinerColor", (float(c[0]) / 255), (float(c[1]) / 255), (float(c[2]) / 255) ) # Create VRayVolumeGrid grid_node = cmds.createNode("aiVolume", name="{}Shape".format(root), parent=root) path = self.filepath_from_context(context) self._set_path(grid_node, path=path, representation=context["representation"]) # Lock the shape node so the user can't delete the transform/shape # as if it was referenced cmds.lockNode(grid_node, lock=True) nodes = [root, grid_node] self[:] = nodes return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) def update(self, container, representation): from maya import cmds path = get_representation_path(representation) # Find VRayVolumeGrid members = cmds.sets(container['objectName'], query=True) grid_nodes = cmds.ls(members, type="aiVolume", long=True) assert len(grid_nodes) == 1, "This is a bug" # Update the VRayVolumeGrid self._set_path(grid_nodes[0], path=path, representation=representation) # Update container representation cmds.setAttr(container["objectName"] + ".representation", str(representation["_id"]), type="string") def switch(self, container, representation): self.update(container, representation) def remove(self, container): from maya import cmds # Get all members of the avalon container, ensure they are unlocked # and delete everything members = cmds.sets(container['objectName'], query=True) cmds.lockNode(members, lock=False) cmds.delete([container['objectName']] + members) # Clean up the namespace try: cmds.namespace(removeNamespace=container['namespace'], deleteNamespaceContent=True) except RuntimeError: pass @staticmethod def _set_path(grid_node, path, representation): """Apply the settings for the VDB path to the aiVolume node""" from maya import cmds if not os.path.exists(path): raise RuntimeError("Path does not exist: %s" % path) is_sequence = bool(representation["context"].get("frame")) cmds.setAttr(grid_node + ".useFrameExtension", is_sequence) # Set file path cmds.setAttr(grid_node + ".filename", path, type="string") ================================================ FILE: openpype/hosts/maya/plugins/load/load_vdb_to_redshift.py ================================================ import os from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_representation_path ) class LoadVDBtoRedShift(load.LoaderPlugin): """Load OpenVDB in a Redshift Volume Shape Note that the RedshiftVolumeShape is created without a RedshiftVolume shader assigned. To get the Redshift volume to render correctly assign a RedshiftVolume shader (in the Hypershade) and set the density, scatter and emission channels to the channel names of the volumes in the VDB file. """ families = ["vdbcache"] representations = ["vdb"] label = "Load VDB to RedShift" icon = "cloud" color = "orange" def load(self, context, name=None, namespace=None, data=None): from maya import cmds from openpype.hosts.maya.api.pipeline import containerise from openpype.hosts.maya.api.lib import unique_namespace try: family = context["representation"]["context"]["family"] except ValueError: family = "vdbcache" # Check if the plugin for redshift is available on the pc try: cmds.loadPlugin("redshift4maya", quiet=True) except Exception as exc: self.log.error("Encountered exception:\n%s" % exc) return # Check if viewport drawing engine is Open GL Core (compat) render_engine = None compatible = "OpenGL" if cmds.optionVar(exists="vp2RenderingEngine"): render_engine = cmds.optionVar(query="vp2RenderingEngine") if not render_engine or not render_engine.startswith(compatible): raise RuntimeError("Current scene's settings are incompatible." "See Preferences > Display > Viewport 2.0 to " "set the render engine to '%s'" % compatible) asset = context['asset'] asset_name = asset["name"] namespace = namespace or unique_namespace( asset_name + "_", prefix="_" if asset_name[0].isdigit() else "", suffix="_", ) # Root group label = "{}:{}".format(namespace, name) root = cmds.createNode("transform", name=label) project_name = context["project"]["name"] settings = get_project_settings(project_name) colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: cmds.setAttr(root + ".useOutlinerColor", 1) cmds.setAttr(root + ".outlinerColor", (float(c[0])/255), (float(c[1])/255), (float(c[2])/255) ) # Create VR volume_node = cmds.createNode("RedshiftVolumeShape", name="{}RVSShape".format(label), parent=root) self._set_path(volume_node, path=self.filepath_from_context(context), representation=context["representation"]) nodes = [root, volume_node] self[:] = nodes return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) def update(self, container, representation): from maya import cmds path = get_representation_path(representation) # Find VRayVolumeGrid members = cmds.sets(container['objectName'], query=True) grid_nodes = cmds.ls(members, type="RedshiftVolumeShape", long=True) assert len(grid_nodes) == 1, "This is a bug" # Update the VRayVolumeGrid self._set_path(grid_nodes[0], path=path, representation=representation) # Update container representation cmds.setAttr(container["objectName"] + ".representation", str(representation["_id"]), type="string") def remove(self, container): from maya import cmds # Get all members of the avalon container, ensure they are unlocked # and delete everything members = cmds.sets(container['objectName'], query=True) cmds.lockNode(members, lock=False) cmds.delete([container['objectName']] + members) # Clean up the namespace try: cmds.namespace(removeNamespace=container['namespace'], deleteNamespaceContent=True) except RuntimeError: pass def switch(self, container, representation): self.update(container, representation) @staticmethod def _set_path(grid_node, path, representation): """Apply the settings for the VDB path to the RedshiftVolumeShape""" from maya import cmds if not os.path.exists(path): raise RuntimeError("Path does not exist: %s" % path) is_sequence = bool(representation["context"].get("frame")) cmds.setAttr(grid_node + ".useFrameExtension", is_sequence) # Set file path cmds.setAttr(grid_node + ".fileName", path, type="string") ================================================ FILE: openpype/hosts/maya/plugins/load/load_vdb_to_vray.py ================================================ import os from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_representation_path ) from maya import cmds # List of 3rd Party Channels Mapping names for VRayVolumeGrid # See: https://docs.chaosgroup.com/display/VRAY4MAYA/Input # #Input-3rdPartyChannelsMapping THIRD_PARTY_CHANNELS = { 2: "Smoke", 1: "Temperature", 10: "Fuel", 4: "Velocity.x", 5: "Velocity.y", 6: "Velocity.z", 7: "Red", 8: "Green", 9: "Blue", 14: "Wavelet Energy", 19: "Wavelet.u", 20: "Wavelet.v", 21: "Wavelet.w", # These are not in UI or documentation but V-Ray does seem to set these. 15: "AdvectionOrigin.x", 16: "AdvectionOrigin.y", 17: "AdvectionOrigin.z", } def _fix_duplicate_vvg_callbacks(): """Workaround to kill duplicate VRayVolumeGrids attribute callbacks. This fixes a huge lag in Maya on switching 3rd Party Channels Mappings or to different .vdb file paths because it spams an attribute changed callback: `vvgUserChannelMappingsUpdateUI`. ChaosGroup bug ticket: 154-008-9890 Found with: - Maya 2019.2 on Windows 10 - V-Ray: V-Ray Next for Maya, update 1 version 4.12.01.00001 Bug still present in: - Maya 2022.1 on Windows 10 - V-Ray 5 for Maya, Update 2.1 (v5.20.01 from Dec 16 2021) """ # todo(roy): Remove when new V-Ray release fixes duplicate calls jobs = cmds.scriptJob(listJobs=True) matched = set() for entry in jobs: # Remove the number index, callback = entry.split(":", 1) callback = callback.strip() # Detect whether it is a `vvgUserChannelMappingsUpdateUI` # attribute change callback if callback.startswith('"-runOnce" 1 "-attributeChange" "'): if '"vvgUserChannelMappingsUpdateUI(' in callback: if callback in matched: # If we've seen this callback before then # delete the duplicate callback cmds.scriptJob(kill=int(index)) else: matched.add(callback) class LoadVDBtoVRay(load.LoaderPlugin): """Load OpenVDB in a V-Ray Volume Grid""" families = ["vdbcache"] representations = ["vdb"] label = "Load VDB to VRay" icon = "cloud" color = "orange" def load(self, context, name, namespace, data): from openpype.hosts.maya.api.lib import unique_namespace from openpype.hosts.maya.api.pipeline import containerise path = self.filepath_from_context(context) assert os.path.exists(path), ( "Path does not exist: %s" % path ) try: family = context["representation"]["context"]["family"] except ValueError: family = "vdbcache" # Ensure V-ray is loaded with the vrayvolumegrid if not cmds.pluginInfo("vrayformaya", query=True, loaded=True): cmds.loadPlugin("vrayformaya") if not cmds.pluginInfo("vrayvolumegrid", query=True, loaded=True): cmds.loadPlugin("vrayvolumegrid") # Check if viewport drawing engine is Open GL Core (compat) render_engine = None compatible = "OpenGLCoreProfileCompat" if cmds.optionVar(exists="vp2RenderingEngine"): render_engine = cmds.optionVar(query="vp2RenderingEngine") if not render_engine or render_engine != compatible: self.log.warning("Current scene's settings are incompatible." "See Preferences > Display > Viewport 2.0 to " "set the render engine to '%s'" % compatible) asset = context['asset'] asset_name = asset["name"] namespace = namespace or unique_namespace( asset_name + "_", prefix="_" if asset_name[0].isdigit() else "", suffix="_", ) # Root group label = "{}:{}_VDB".format(namespace, name) root = cmds.group(name=label, empty=True) project_name = context["project"]["name"] settings = get_project_settings(project_name) colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: cmds.setAttr(root + ".useOutlinerColor", 1) cmds.setAttr(root + ".outlinerColor", float(c[0]) / 255, float(c[1]) / 255, float(c[2]) / 255) # Create VRayVolumeGrid grid_node = cmds.createNode("VRayVolumeGrid", name="{}Shape".format(label), parent=root) # Ensure .currentTime is connected to time1.outTime cmds.connectAttr("time1.outTime", grid_node + ".currentTime") # Set path self._set_path(grid_node, path, show_preset_popup=True) # Lock the shape node so the user can't delete the transform/shape # as if it was referenced cmds.lockNode(grid_node, lock=True) nodes = [root, grid_node] self[:] = nodes return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) def _set_path(self, grid_node, path, show_preset_popup=True): from openpype.hosts.maya.api.lib import attribute_values from maya import cmds def _get_filename_from_folder(path): # Using the sequence of .vdb files we check the frame range, etc. # to set the filename with #### padding. files = sorted(x for x in os.listdir(path) if x.endswith(".vdb")) if not files: raise RuntimeError("Couldn't find .vdb files in: %s" % path) if len(files) == 1: # Ensure check for single file is also done in folder fname = files[0] else: # Sequence import clique # todo: check support for negative frames as input collections, remainder = clique.assemble(files) assert len(collections) == 1, ( "Must find a single image sequence, " "found: %s" % (collections,) ) collection = collections[0] fname = collection.format('{head}{{padding}}{tail}') padding = collection.padding if padding == 0: # Clique doesn't provide padding if the frame number never # starts with a zero and thus has never any visual padding. # So we fall back to the smallest frame number as padding. padding = min(len(str(i)) for i in collection.indexes) # Supply frame/padding with # signs padding_str = "#" * padding fname = fname.format(padding=padding_str) return os.path.join(path, fname) # The path is either a single file or sequence in a folder so # we do a quick lookup for our files if os.path.isfile(path): path = os.path.dirname(path) path = _get_filename_from_folder(path) # Even when not applying a preset V-Ray will reset the 3rd Party # Channels Mapping of the VRayVolumeGrid when setting the .inPath # value. As such we try and preserve the values ourselves. # Reported as ChaosGroup bug ticket: 154-011-2909  # todo(roy): Remove when new V-Ray release preserves values original_user_mapping = cmds.getAttr(grid_node + ".usrchmap") or "" # Workaround for V-Ray bug: fix lag on path change, see function _fix_duplicate_vvg_callbacks() # Suppress preset pop-up if we want. popup_attr = "{0}.inDontOfferPresets".format(grid_node) popup = {popup_attr: not show_preset_popup} with attribute_values(popup): cmds.setAttr(grid_node + ".inPath", path, type="string") # Reapply the 3rd Party channels user mapping when no preset popup # was shown to the user if not show_preset_popup: channels = cmds.getAttr(grid_node + ".usrchmapallch").split(";") channels = set(channels) # optimize lookup restored_mapping = "" for entry in original_user_mapping.split(";"): if not entry: # Ignore empty entries continue # If 3rd Party Channels selection channel still exists then # add it again. index, channel = entry.split(",") attr = THIRD_PARTY_CHANNELS.get(int(index), # Fallback for when a mapping # was set that is not in the # documentation "???") if channel in channels: restored_mapping += entry + ";" else: self.log.warning("Can't preserve '%s' mapping due to " "missing channel '%s' on node: " "%s" % (attr, channel, grid_node)) if restored_mapping: cmds.setAttr(grid_node + ".usrchmap", restored_mapping, type="string") def update(self, container, representation): path = get_representation_path(representation) # Find VRayVolumeGrid members = cmds.sets(container['objectName'], query=True) grid_nodes = cmds.ls(members, type="VRayVolumeGrid", long=True) assert len(grid_nodes) > 0, "This is a bug" # Update the VRayVolumeGrid for grid_node in grid_nodes: self._set_path(grid_node, path=path, show_preset_popup=False) # Update container representation cmds.setAttr(container["objectName"] + ".representation", str(representation["_id"]), type="string") def switch(self, container, representation): self.update(container, representation) def remove(self, container): # Get all members of the avalon container, ensure they are unlocked # and delete everything members = cmds.sets(container['objectName'], query=True) cmds.lockNode(members, lock=False) cmds.delete([container['objectName']] + members) # Clean up the namespace try: cmds.namespace(removeNamespace=container['namespace'], deleteNamespaceContent=True) except RuntimeError: pass ================================================ FILE: openpype/hosts/maya/plugins/load/load_vrayproxy.py ================================================ # -*- coding: utf-8 -*- """Loader for Vray Proxy files. If there are Alembics published along vray proxy (in the same version), loader will use them instead of native vray vrmesh format. """ import os import maya.cmds as cmds from openpype.client import get_representation_by_name from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_current_project_name, get_representation_path, ) from openpype.hosts.maya.api.lib import ( maintained_selection, namespaced, unique_namespace ) from openpype.hosts.maya.api.pipeline import containerise class VRayProxyLoader(load.LoaderPlugin): """Load VRay Proxy with Alembic or VrayMesh.""" families = ["vrayproxy", "model", "pointcache", "animation"] representations = ["vrmesh", "abc"] label = "Import VRay Proxy" order = -10 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, options=None): # type: (dict, str, str, dict) -> None """Loader entry point. Args: context (dict): Loaded representation context. name (str): Name of container. namespace (str): Optional namespace name. options (dict): Optional loader options. """ try: family = context["representation"]["context"]["family"] except ValueError: family = "vrayproxy" # get all representations for this version filename = self._get_abc(context["version"]["_id"]) if not filename: filename = self.filepath_from_context(context) asset_name = context['asset']["name"] namespace = namespace or unique_namespace( asset_name + "_", prefix="_" if asset_name[0].isdigit() else "", suffix="_", ) # Ensure V-Ray for Maya is loaded. cmds.loadPlugin("vrayformaya", quiet=True) with maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): nodes, group_node = self.create_vray_proxy( name, filename=filename) self[:] = nodes if not nodes: return # colour the group node project_name = context["project"]["name"] settings = get_project_settings(project_name) colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: cmds.setAttr("{0}.useOutlinerColor".format(group_node), 1) cmds.setAttr( "{0}.outlinerColor".format(group_node), (float(c[0]) / 255), (float(c[1]) / 255), (float(c[2]) / 255) ) return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) def update(self, container, representation): # type: (dict, dict) -> None """Update container with specified representation.""" node = container['objectName'] assert cmds.objExists(node), "Missing container" members = cmds.sets(node, query=True) or [] vraymeshes = cmds.ls(members, type="VRayProxy") assert vraymeshes, "Cannot find VRayMesh in container" # get all representations for this version filename = ( self._get_abc(representation["parent"]) or get_representation_path(representation) ) for vray_mesh in vraymeshes: cmds.setAttr("{}.fileName".format(vray_mesh), filename, type="string") # Update metadata cmds.setAttr("{}.representation".format(node), str(representation["_id"]), type="string") def remove(self, container): # type: (dict) -> None """Remove loaded container.""" # Delete container and its contents if cmds.objExists(container['objectName']): members = cmds.sets(container['objectName'], query=True) or [] cmds.delete([container['objectName']] + members) # Remove the namespace, if empty namespace = container['namespace'] if cmds.namespace(exists=namespace): members = cmds.namespaceInfo(namespace, listNamespace=True) if not members: cmds.namespace(removeNamespace=namespace) else: self.log.warning("Namespace not deleted because it " "still has members: %s", namespace) def switch(self, container, representation): # type: (dict, dict) -> None """Switch loaded representation.""" self.update(container, representation) def create_vray_proxy(self, name, filename): # type: (str, str) -> (list, str) """Re-create the structure created by VRay to support vrmeshes Args: name (str): Name of the asset. filename (str): File name of vrmesh. Returns: nodes(list) """ if name is None: name = os.path.splitext(os.path.basename(filename))[0] parent = cmds.createNode("transform", name=name) proxy = cmds.createNode( "VRayProxy", name="{}Shape".format(name), parent=parent) cmds.setAttr(proxy + ".fileName", filename, type="string") cmds.connectAttr("time1.outTime", proxy + ".currentFrame") return [parent, proxy], parent def _get_abc(self, version_id): # type: (str) -> str """Get abc representation file path if present. If here is published Alembic (abc) representation published along vray proxy, get is file path. Args: version_id (str): Version hash id. Returns: str: Path to file. None: If abc not found. """ self.log.debug( "Looking for abc in published representations of this version.") project_name = get_current_project_name() abc_rep = get_representation_by_name(project_name, "abc", version_id) if abc_rep: self.log.debug("Found, we'll link alembic to vray proxy.") file_name = get_representation_path(abc_rep) self.log.debug("File: {}".format(file_name)) return file_name return "" ================================================ FILE: openpype/hosts/maya/plugins/load/load_vrayscene.py ================================================ # -*- coding: utf-8 -*- import os import maya.cmds as cmds # noqa from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_representation_path ) from openpype.hosts.maya.api.lib import ( maintained_selection, namespaced, unique_namespace ) from openpype.hosts.maya.api.pipeline import containerise class VRaySceneLoader(load.LoaderPlugin): """Load Vray scene""" families = ["vrayscene_layer"] representations = ["vrscene"] label = "Import VRay Scene" order = -10 icon = "code-fork" color = "orange" def load(self, context, name, namespace, data): try: family = context["representation"]["context"]["family"] except ValueError: family = "vrayscene_layer" asset_name = context['asset']["name"] namespace = namespace or unique_namespace( asset_name + "_", prefix="_" if asset_name[0].isdigit() else "", suffix="_", ) # Ensure V-Ray for Maya is loaded. cmds.loadPlugin("vrayformaya", quiet=True) with maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): nodes, root_node = self.create_vray_scene( name, filename=self.filepath_from_context(context) ) self[:] = nodes if not nodes: return # colour the group node project_name = context["project"]["name"] settings = get_project_settings(project_name) colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: cmds.setAttr("{0}.useOutlinerColor".format(root_node), 1) cmds.setAttr("{0}.outlinerColor".format(root_node), (float(c[0])/255), (float(c[1])/255), (float(c[2])/255) ) return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) def update(self, container, representation): node = container['objectName'] assert cmds.objExists(node), "Missing container" members = cmds.sets(node, query=True) or [] vraymeshes = cmds.ls(members, type="VRayScene") assert vraymeshes, "Cannot find VRayScene in container" filename = get_representation_path(representation) for vray_mesh in vraymeshes: cmds.setAttr("{}.FilePath".format(vray_mesh), filename, type="string") # Update metadata cmds.setAttr("{}.representation".format(node), str(representation["_id"]), type="string") def remove(self, container): # Delete container and its contents if cmds.objExists(container['objectName']): members = cmds.sets(container['objectName'], query=True) or [] cmds.delete([container['objectName']] + members) # Remove the namespace, if empty namespace = container['namespace'] if cmds.namespace(exists=namespace): members = cmds.namespaceInfo(namespace, listNamespace=True) if not members: cmds.namespace(removeNamespace=namespace) else: self.log.warning("Namespace not deleted because it " "still has members: %s", namespace) def switch(self, container, representation): self.update(container, representation) def create_vray_scene(self, name, filename): """Re-create the structure created by VRay to support vrscenes Args: name(str): name of the asset Returns: nodes(list) """ # Create nodes mesh_node_name = "VRayScene_{}".format(name) trans = cmds.createNode( "transform", name=mesh_node_name) vray_scene = cmds.createNode( "VRayScene", name="{}_VRSCN".format(mesh_node_name), parent=trans) mesh = cmds.createNode( "mesh", name="{}_Shape".format(mesh_node_name), parent=trans) cmds.connectAttr( "{}.outMesh".format(vray_scene), "{}.inMesh".format(mesh)) cmds.setAttr("{}.FilePath".format(vray_scene), filename, type="string") # Lock the shape nodes so the user cannot delete these cmds.lockNode(mesh, lock=True) cmds.lockNode(vray_scene, lock=True) # Create important connections cmds.connectAttr("time1.outTime", "{0}.inputTime".format(trans)) # Connect mesh to initialShadingGroup cmds.sets([mesh], forceElement="initialShadingGroup") nodes = [trans, vray_scene, mesh] # Fix: Force refresh so the mesh shows correctly after creation cmds.refresh() return nodes, trans ================================================ FILE: openpype/hosts/maya/plugins/load/load_xgen.py ================================================ import os import shutil import maya.cmds as cmds import xgenm from qtpy import QtWidgets import openpype.hosts.maya.api.plugin from openpype.hosts.maya.api.lib import ( maintained_selection, get_container_members, attribute_values, write_xgen_file ) from openpype.hosts.maya.api import current_file from openpype.pipeline import get_representation_path class XgenLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): """Load Xgen as reference""" families = ["xgen"] representations = ["ma", "mb"] label = "Reference Xgen" icon = "code-fork" color = "orange" def get_xgen_xgd_paths(self, palette): _, maya_extension = os.path.splitext(current_file()) xgen_file = current_file().replace( maya_extension, "__{}.xgen".format(palette.replace("|", "").replace(":", "__")) ) xgd_file = xgen_file.replace(".xgen", ".xgd") return xgen_file, xgd_file def process_reference(self, context, name, namespace, options): # Validate workfile has a path. if current_file() is None: QtWidgets.QMessageBox.warning( None, "", "Current workfile has not been saved. Please save the workfile" " before loading an Xgen." ) return maya_filepath = self.prepare_root_value( file_url=self.filepath_from_context(context), project_name=context["project"]["name"] ) # Reference xgen. Xgen does not like being referenced in under a group. with maintained_selection(): nodes = cmds.file( maya_filepath, namespace=namespace, sharedReferenceFile=False, reference=True, returnNewNodes=True ) xgen_palette = cmds.ls( nodes, type="xgmPalette", long=True )[0].replace("|", "") xgen_file, xgd_file = self.get_xgen_xgd_paths(xgen_palette) self.set_palette_attributes(xgen_palette, xgen_file, xgd_file) # Change the cache and disk values of xgDataPath and xgProjectPath # to ensure paths are setup correctly. project_path = os.path.dirname(current_file()).replace("\\", "/") xgenm.setAttr("xgProjectPath", project_path, xgen_palette) data_path = "${{PROJECT}}xgen/collections/{};{}".format( xgen_palette.replace(":", "__ns__"), xgenm.getAttr("xgDataPath", xgen_palette) ) xgenm.setAttr("xgDataPath", data_path, xgen_palette) data = {"xgProjectPath": project_path, "xgDataPath": data_path} write_xgen_file(data, xgen_file) # This create an expression attribute of float. If we did not add # any changes to collection, then Xgen does not create an xgd file # on save. This gives errors when launching the workfile again due # to trying to find the xgd file. name = "custom_float_ignore" if name not in xgenm.customAttrs(xgen_palette): xgenm.addCustomAttr( "custom_float_ignore", xgen_palette ) shapes = cmds.ls(nodes, shapes=True, long=True) new_nodes = (list(set(nodes) - set(shapes))) self[:] = new_nodes return new_nodes def set_palette_attributes(self, xgen_palette, xgen_file, xgd_file): cmds.setAttr( "{}.xgBaseFile".format(xgen_palette), os.path.basename(xgen_file), type="string" ) cmds.setAttr( "{}.xgFileName".format(xgen_palette), os.path.basename(xgd_file), type="string" ) cmds.setAttr("{}.xgExportAsDelta".format(xgen_palette), True) def update(self, container, representation): """Workflow for updating Xgen. - Export changes to delta file. - Copy and overwrite the workspace .xgen file. - Set collection attributes to not include delta files. - Update xgen maya file reference. - Apply the delta file changes. - Reset collection attributes to include delta files. We have to do this workflow because when using referencing of the xgen collection, Maya implicitly imports the Xgen data from the xgen file so we dont have any control over when adding the delta file changes. There is an implicit increment of the xgen and delta files, due to using the workfile basename. """ # Storing current description to try and maintain later. current_description = ( xgenm.xgGlobal.DescriptionEditor.currentDescription() ) container_node = container["objectName"] members = get_container_members(container_node) xgen_palette = cmds.ls( members, type="xgmPalette", long=True )[0].replace("|", "") xgen_file, xgd_file = self.get_xgen_xgd_paths(xgen_palette) # Export current changes to apply later. xgenm.createDelta(xgen_palette.replace("|", ""), xgd_file) self.set_palette_attributes(xgen_palette, xgen_file, xgd_file) maya_file = get_representation_path(representation) _, extension = os.path.splitext(maya_file) new_xgen_file = maya_file.replace(extension, ".xgen") data_path = "" with open(new_xgen_file, "r") as f: for line in f: if line.startswith("\txgDataPath"): line = line.rstrip() data_path = line.split("\t")[-1] break project_path = os.path.dirname(current_file()).replace("\\", "/") data_path = "${{PROJECT}}xgen/collections/{};{}".format( xgen_palette.replace(":", "__ns__"), data_path ) data = {"xgProjectPath": project_path, "xgDataPath": data_path} shutil.copy(new_xgen_file, xgen_file) write_xgen_file(data, xgen_file) attribute_data = { "{}.xgFileName".format(xgen_palette): os.path.basename(xgen_file), "{}.xgBaseFile".format(xgen_palette): "", "{}.xgExportAsDelta".format(xgen_palette): False } with attribute_values(attribute_data): super().update(container, representation) xgenm.applyDelta(xgen_palette.replace("|", ""), xgd_file) # Restore current selected description if it exists. if cmds.objExists(current_description): xgenm.xgGlobal.DescriptionEditor.setCurrentDescription( current_description ) # Full UI refresh. xgenm.xgGlobal.DescriptionEditor.refresh("Full") ================================================ FILE: openpype/hosts/maya/plugins/load/load_yeti_cache.py ================================================ import os import json import re from collections import defaultdict import clique from maya import cmds from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_representation_path ) from openpype.hosts.maya.api import lib from openpype.hosts.maya.api.pipeline import containerise # Do not reset these values on update but only apply on first load # to preserve any potential local overrides SKIP_UPDATE_ATTRS = { "displayOutput", "viewportDensity", "viewportWidth", "viewportLength", } def set_attribute(node, attr, value): """Wrapper of set attribute which ignores None values""" if value is None: return lib.set_attribute(node, attr, value) class YetiCacheLoader(load.LoaderPlugin): """Load Yeti Cache with one or more Yeti nodes""" families = ["yeticache", "yetiRig"] representations = ["fur"] label = "Load Yeti Cache" order = -9 icon = "code-fork" color = "orange" def load(self, context, name=None, namespace=None, data=None): """Loads a .fursettings file defining how to load .fur sequences A single yeticache or yetiRig can have more than a single pgYetiMaya nodes and thus load more than a single yeti.fur sequence. The .fursettings file defines what the node names should be and also what "cbId" attribute they should receive to match the original source and allow published looks to also work for Yeti rigs and its caches. """ try: family = context["representation"]["context"]["family"] except ValueError: family = "yeticache" # Build namespace asset = context["asset"] if namespace is None: namespace = self.create_namespace(asset["name"]) # Ensure Yeti is loaded if not cmds.pluginInfo("pgYetiMaya", query=True, loaded=True): cmds.loadPlugin("pgYetiMaya", quiet=True) # Create Yeti cache nodes according to settings path = self.filepath_from_context(context) settings = self.read_settings(path) nodes = [] for node in settings["nodes"]: nodes.extend(self.create_node(namespace, node)) group_name = "{}:{}".format(namespace, name) group_node = cmds.group(nodes, name=group_name) project_name = context["project"]["name"] settings = get_project_settings(project_name) colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: cmds.setAttr(group_node + ".useOutlinerColor", 1) cmds.setAttr(group_node + ".outlinerColor", (float(c[0])/255), (float(c[1])/255), (float(c[2])/255) ) nodes.append(group_node) self[:] = nodes return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__ ) def remove(self, container): from maya import cmds namespace = container["namespace"] container_name = container["objectName"] self.log.info("Removing '%s' from Maya.." % container["name"]) container_content = cmds.sets(container_name, query=True) nodes = cmds.ls(container_content, long=True) nodes.append(container_name) try: cmds.delete(nodes) except ValueError: # Already implicitly deleted by Maya upon removing reference pass cmds.namespace(removeNamespace=namespace, deleteNamespaceContent=True) def update(self, container, representation): namespace = container["namespace"] container_node = container["objectName"] path = get_representation_path(representation) settings = self.read_settings(path) # Collect scene information of asset set_members = lib.get_container_members(container) container_root = lib.get_container_transforms(container, members=set_members, root=True) scene_nodes = cmds.ls(set_members, type="pgYetiMaya", long=True) # Build lookup with cbId as keys scene_lookup = defaultdict(list) for node in scene_nodes: cb_id = lib.get_id(node) scene_lookup[cb_id].append(node) # Re-assemble metadata with cbId as keys meta_data_lookup = {n["cbId"]: n for n in settings["nodes"]} # Delete nodes by "cbId" that are not in the updated version to_delete_lookup = {cb_id for cb_id in scene_lookup.keys() if cb_id not in meta_data_lookup} if to_delete_lookup: # Get nodes and remove entry from lookup to_remove = [] for _id in to_delete_lookup: # Get all related nodes shapes = scene_lookup[_id] # Get the parents of all shapes under the ID transforms = cmds.listRelatives(shapes, parent=True, fullPath=True) or [] to_remove.extend(shapes + transforms) # Remove id from lookup scene_lookup.pop(_id, None) cmds.delete(to_remove) for cb_id, node_settings in meta_data_lookup.items(): if cb_id not in scene_lookup: # Create new nodes self.log.info("Creating new nodes ..") new_nodes = self.create_node(namespace, node_settings) cmds.sets(new_nodes, addElement=container_node) cmds.parent(new_nodes, container_root) else: # Update the matching nodes scene_nodes = scene_lookup[cb_id] lookup_result = meta_data_lookup[cb_id]["name"] # Remove namespace if any (e.g.: "character_01_:head_YNShape") node_name = lookup_result.rsplit(":", 1)[-1] for scene_node in scene_nodes: # Get transform node, this makes renaming easier transforms = cmds.listRelatives(scene_node, parent=True, fullPath=True) or [] assert len(transforms) == 1, "This is a bug!" # Get scene node's namespace and rename the transform node lead = scene_node.rsplit(":", 1)[0] namespace = ":{}".format(lead.rsplit("|")[-1]) new_shape_name = "{}:{}".format(namespace, node_name) new_trans_name = new_shape_name.rsplit("Shape", 1)[0] transform_node = transforms[0] cmds.rename(transform_node, new_trans_name, ignoreShape=False) # Get the newly named shape node yeti_nodes = cmds.listRelatives(new_trans_name, children=True) yeti_node = yeti_nodes[0] for attr, value in node_settings["attrs"].items(): if attr in SKIP_UPDATE_ATTRS: continue set_attribute(attr, value, yeti_node) cmds.setAttr("{}.representation".format(container_node), str(representation["_id"]), typ="string") def switch(self, container, representation): self.update(container, representation) # helper functions def create_namespace(self, asset): """Create a unique namespace Args: asset (dict): asset information """ asset_name = "{}_".format(asset) prefix = "_" if asset_name[0].isdigit()else "" namespace = lib.unique_namespace( asset_name, prefix=prefix, suffix="_" ) return namespace def get_cache_node_filepath(self, root, node_name): """Get the cache file path for one of the yeti nodes. All caches with more than 1 frame need cache file name set with `%04d` If the cache has only one frame we return the file name as we assume it is a snapshot. This expects the files to be named after the "node name" through exports with in Yeti. Args: root(str): Folder containing cache files to search in. node_name(str): Node name to search cache files for Returns: str: Cache file path value needed for cacheFileName attribute """ name = node_name.replace(":", "_") pattern = r"^({name})(\.[0-9]+)?(\.fur)$".format(name=re.escape(name)) files = [fname for fname in os.listdir(root) if re.match(pattern, fname)] if not files: self.log.error("Could not find cache files for '{}' " "with pattern {}".format(node_name, pattern)) return if len(files) == 1: # Single file return os.path.join(root, files[0]) # Get filename for the sequence with padding collections, remainder = clique.assemble(files) assert not remainder, "This is a bug" assert len(collections) == 1, "This is a bug" collection = collections[0] # Formats name as {head}%d{tail} like cache.%04d.fur fname = collection.format("{head}{padding}{tail}") return os.path.join(root, fname) def create_node(self, namespace, node_settings): """Create nodes with the correct namespace and settings Args: namespace(str): namespace node_settings(dict): Single "nodes" entry from .fursettings file. Returns: list: Created nodes """ nodes = [] # Get original names and ids orig_transform_name = node_settings["transform"]["name"] orig_shape_name = node_settings["name"] # Add namespace transform_name = "{}:{}".format(namespace, orig_transform_name) shape_name = "{}:{}".format(namespace, orig_shape_name) # Create pgYetiMaya node transform_node = cmds.createNode("transform", name=transform_name) yeti_node = cmds.createNode("pgYetiMaya", name=shape_name, parent=transform_node) lib.set_id(transform_node, node_settings["transform"]["cbId"]) lib.set_id(yeti_node, node_settings["cbId"]) nodes.extend([transform_node, yeti_node]) # Update attributes with defaults attributes = node_settings["attrs"] attributes.update({ "verbosity": 2, "fileMode": 1, # Fix render stats, like Yeti's own # ../scripts/pgYetiNode.mel script "visibleInReflections": True, "visibleInRefractions": True }) if "viewportDensity" not in attributes: attributes["viewportDensity"] = 0.1 # Apply attributes to pgYetiMaya node for attr, value in attributes.items(): set_attribute(attr, value, yeti_node) # Connect to the time node cmds.connectAttr("time1.outTime", "%s.currentTime" % yeti_node) return nodes def read_settings(self, path): """Read .fursettings file and compute some additional attributes""" with open(path, "r") as fp: fur_settings = json.load(fp) if "nodes" not in fur_settings: raise RuntimeError("Encountered invalid data, " "expected 'nodes' in fursettings.") # Compute the cache file name values we want to set for the nodes root = os.path.dirname(path) for node in fur_settings["nodes"]: cache_filename = self.get_cache_node_filepath( root=root, node_name=node["name"]) attrs = node.get("attrs", {}) # allow 'attrs' to not exist attrs["cacheFileName"] = cache_filename node["attrs"] = attrs return fur_settings ================================================ FILE: openpype/hosts/maya/plugins/load/load_yeti_rig.py ================================================ import maya.cmds as cmds from openpype.settings import get_current_project_settings import openpype.hosts.maya.api.plugin from openpype.hosts.maya.api import lib class YetiRigLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): """This loader will load Yeti rig.""" families = ["yetiRig"] representations = ["ma"] label = "Load Yeti Rig" order = -9 icon = "code-fork" color = "orange" def process_reference( self, context, name=None, namespace=None, options=None ): path = self.filepath_from_context(context) attach_to_root = options.get("attach_to_root", True) group_name = options["group_name"] # no group shall be created if not attach_to_root: group_name = namespace with lib.maintained_selection(): file_url = self.prepare_root_value( path, context["project"]["name"] ) nodes = cmds.file( file_url, namespace=namespace, reference=True, returnNewNodes=True, groupReference=attach_to_root, groupName=group_name ) settings = get_current_project_settings() colors = settings["maya"]["load"]["colors"] c = colors.get("yetiRig") if c is not None: cmds.setAttr(group_name + ".useOutlinerColor", 1) cmds.setAttr( group_name + ".outlinerColor", (float(c[0]) / 255), (float(c[1]) / 255), (float(c[2]) / 255) ) self[:] = nodes return nodes ================================================ FILE: openpype/hosts/maya/plugins/publish/__init__.py ================================================ ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_animation.py ================================================ import pyblish.api import maya.cmds as cmds class CollectAnimationOutputGeometry(pyblish.api.InstancePlugin): """Collect out hierarchy data for instance. Collect all hierarchy nodes which reside in the out_SET of the animation instance or point cache instance. This is to unify the logic of retrieving that specific data. This eliminates the need to write two separate pieces of logic to fetch all hierarchy nodes. Results in a list of nodes from the content of the instances """ order = pyblish.api.CollectorOrder + 0.4 families = ["animation"] label = "Collect Animation" hosts = ["maya"] ignore_type = ["constraints"] def process(self, instance): """Collect the hierarchy nodes""" family = instance.data["family"] out_set = next((i for i in instance.data["setMembers"] if i.endswith("out_SET")), None) if out_set is None: warning = "Expecting out_SET for instance of family '%s'" % family self.log.warning(warning) return members = cmds.ls(cmds.sets(out_set, query=True), long=True) # Get all the relatives of the members descendants = cmds.listRelatives(members, allDescendents=True, fullPath=True) or [] descendants = cmds.ls(descendants, noIntermediate=True, long=True) # Add members and descendants together for a complete overview hierarchy = members + descendants # Ignore certain node types (e.g. constraints) ignore = cmds.ls(hierarchy, type=self.ignore_type, long=True) if ignore: ignore = set(ignore) hierarchy = [node for node in hierarchy if node not in ignore] # Store data in the instance for the validator instance.data["out_hierarchy"] = hierarchy if instance.data.get("farm"): instance.data["families"].append("publish.farm") # User defined attributes. instance.data["includeUserDefinedAttributes"] = ( instance.data["creator_attributes"]["includeUserDefinedAttributes"] ) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py ================================================ from maya import cmds import pyblish.api from openpype.hosts.maya.api.lib import get_all_children class CollectArnoldSceneSource(pyblish.api.InstancePlugin): """Collect Arnold Scene Source data.""" # Offset to be after renderable camera collection. order = pyblish.api.CollectorOrder + 0.2 label = "Collect Arnold Scene Source" families = ["ass", "assProxy"] def process(self, instance): instance.data["members"] = [] for set_member in instance.data["setMembers"]: if cmds.nodeType(set_member) != "objectSet": instance.data["members"].extend(self.get_hierarchy(set_member)) continue members = cmds.sets(set_member, query=True) members = cmds.ls(members, long=True) if members is None: self.log.warning( "Skipped empty instance: \"%s\" " % set_member ) continue if set_member.endswith("proxy_SET"): instance.data["proxy"] = self.get_hierarchy(members) # Use camera in object set if present else default to render globals # camera. cameras = cmds.ls(type="camera", long=True) renderable = [c for c in cameras if cmds.getAttr("%s.renderable" % c)] if renderable: camera = renderable[0] for node in instance.data["members"]: camera_shapes = cmds.listRelatives( node, shapes=True, type="camera" ) if camera_shapes: camera = node instance.data["camera"] = camera else: self.log.debug("No renderable cameras found.") self.log.debug("data: {}".format(instance.data)) def get_hierarchy(self, nodes): """Return nodes with all their children""" nodes = cmds.ls(nodes, long=True) if not nodes: return [] children = get_all_children(nodes) # Make sure nodes merged with children only # contains unique entries return list(set(nodes + children)) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_assembly.py ================================================ from collections import defaultdict import pyblish.api from maya import cmds, mel from openpype.hosts.maya import api from openpype.hosts.maya.api import lib # TODO : Publish of assembly: -unique namespace for all assets, VALIDATOR! class CollectAssembly(pyblish.api.InstancePlugin): """Collect all relevant assembly items Collected data: * File name * Compatible loader * Matrix per instance * Namespace Note: GPU caches are currently not supported in the pipeline. There is no logic yet which supports the swapping of GPU cache to renderable objects. """ order = pyblish.api.CollectorOrder + 0.49 label = "Assembly" families = ["assembly"] def process(self, instance): # Find containers containers = api.ls() # Get all content from the instance instance_lookup = set(cmds.ls(instance, type="transform", long=True)) data = defaultdict(list) hierarchy_nodes = [] for container in containers: root = lib.get_container_transforms(container, root=True) if not root or root not in instance_lookup: continue # Retrieve the hierarchy parent = cmds.listRelatives(root, parent=True, fullPath=True)[0] hierarchy_nodes.append(parent) # Temporary warning for GPU cache which are not supported yet loader = container["loader"] if loader == "GpuCacheLoader": self.log.warning("GPU Cache Loader is currently not supported" "in the pipeline, we will export it tho") # Gather info for new data entry representation_id = container["representation"] instance_data = {"loader": loader, "parent": parent, "namespace": container["namespace"]} # Check if matrix differs from default and store changes matrix_data = self.get_matrix_data(root) if matrix_data: instance_data["matrix"] = matrix_data data[representation_id].append(instance_data) instance.data["scenedata"] = dict(data) instance.data["nodesHierarchy"] = list(set(hierarchy_nodes)) def get_file_rule(self, rule): return mel.eval('workspace -query -fileRuleEntry "{}"'.format(rule)) def get_matrix_data(self, node): """Get the matrix of all members when they are not default Each matrix which differs from the default will be stored in a dictionary Args: members (list): list of transform nmodes Returns: dict """ matrix = cmds.xform(node, query=True, matrix=True) if matrix == lib.DEFAULT_MATRIX: return return matrix ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_current_file.py ================================================ import pyblish.api from maya import cmds class CollectCurrentFile(pyblish.api.ContextPlugin): """Inject the current working file.""" order = pyblish.api.CollectorOrder - 0.4 label = "Maya Current File" hosts = ['maya'] def process(self, context): """Inject the current working file""" context.data['currentFile'] = cmds.file(query=True, sceneName=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_fbx_animation.py ================================================ # -*- coding: utf-8 -*- from maya import cmds # noqa import pyblish.api from openpype.pipeline import OptionalPyblishPluginMixin class CollectFbxAnimation(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Collect Animated Rig Data for FBX Extractor.""" order = pyblish.api.CollectorOrder + 0.2 label = "Collect Fbx Animation" hosts = ["maya"] families = ["animation"] optional = True def process(self, instance): if not self.is_active(instance.data): return skeleton_sets = [ i for i in instance if i.endswith("skeletonAnim_SET") ] if not skeleton_sets: return instance.data["families"].append("animation.fbx") instance.data["animated_skeleton"] = [] for skeleton_set in skeleton_sets: skeleton_content = cmds.sets(skeleton_set, query=True) self.log.debug( "Collected animated skeleton data: {}".format( skeleton_content )) if skeleton_content: instance.data["animated_skeleton"] = skeleton_content ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_fbx_camera.py ================================================ # -*- coding: utf-8 -*- from maya import cmds # noqa import pyblish.api class CollectFbxCamera(pyblish.api.InstancePlugin): """Collect Camera for FBX export.""" order = pyblish.api.CollectorOrder + 0.2 label = "Collect Camera for FBX export" families = ["camera"] def process(self, instance): if not instance.data.get("families"): instance.data["families"] = [] if "fbx" not in instance.data["families"]: instance.data["families"].append("fbx") instance.data["cameras"] = True ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_file_dependencies.py ================================================ import json from maya import cmds import pyblish.api class CollectFileDependencies(pyblish.api.ContextPlugin): """Gather all files referenced in this scene.""" label = "Collect File Dependencies" order = pyblish.api.CollectorOrder - 0.49 hosts = ["maya"] def process(self, context): dependencies = [] for node in cmds.ls(type="file"): path = cmds.getAttr("{}.{}".format(node, "fileTextureName")) if path not in dependencies: dependencies.append(path) for node in cmds.ls(type="AlembicNode"): path = cmds.getAttr("{}.{}".format(node, "abc_File")) if path not in dependencies: dependencies.append(path) context.data["fileDependencies"] = dependencies self.log.debug(json.dumps(dependencies, indent=4)) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_gltf.py ================================================ # -*- coding: utf-8 -*- import pyblish.api class CollectGLTF(pyblish.api.InstancePlugin): """Collect Assets for GLTF/GLB export.""" order = pyblish.api.CollectorOrder + 0.2 label = "Collect Asset for GLTF/GLB export" families = ["model", "animation", "pointcache"] def process(self, instance): if not instance.data.get("families"): instance.data["families"] = [] if "gltf" not in instance.data["families"]: instance.data["families"].append("gltf") ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_history.py ================================================ from maya import cmds import pyblish.api class CollectMayaHistory(pyblish.api.InstancePlugin): """Collect history for instances from the Maya scene Note: This removes render layers collected in the history This is separate from Collect Instances so we can target it towards only specific family types. """ order = pyblish.api.CollectorOrder + 0.1 hosts = ["maya"] label = "Maya History" families = ["rig"] def process(self, instance): kwargs = {} if int(cmds.about(version=True)) >= 2020: # New flag since Maya 2020 which makes cmds.listHistory faster kwargs = {"fastIteration": True} else: self.log.debug("Ignoring `fastIteration` flag before Maya 2020..") # Collect the history with long names history = set(cmds.listHistory(instance, leaf=False, **kwargs) or []) history = cmds.ls(list(history), long=True) # Exclude invalid nodes (like renderlayers) exclude = cmds.ls(type="renderLayer", long=True) if exclude: exclude = set(exclude) # optimize lookup history = [x for x in history if x not in exclude] # Combine members with history members = instance[:] + history members = list(set(members)) # ensure unique # Update the instance instance[:] = members ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_inputs.py ================================================ import copy from maya import cmds import maya.api.OpenMaya as om import pyblish.api from openpype.pipeline import registered_host from openpype.hosts.maya.api.lib import get_container_members from openpype.hosts.maya.api.lib_rendersetup import get_shader_in_layer def iter_history(nodes, filter=om.MFn.kInvalid, direction=om.MItDependencyGraph.kUpstream): """Iterate unique upstream history for list of nodes. This acts as a replacement to maya.cmds.listHistory. It's faster by about 2x-3x. It returns less than maya.cmds.listHistory as it excludes the input nodes from the output (unless an input node was history for another input node). It also excludes duplicates. Args: nodes (list): Maya node names to start search from. filter (om.MFn.Type): Filter to only specific types. e.g. to dag nodes using om.MFn.kDagNode direction (om.MItDependencyGraph.Direction): Direction to traverse in. Defaults to upstream. Yields: str: Node names in upstream history. """ if not nodes: return sel = om.MSelectionList() for node in nodes: sel.add(node) it = om.MItDependencyGraph(sel.getDependNode(0)) # init iterator handle = om.MObjectHandle traversed = set() fn_dep = om.MFnDependencyNode() fn_dag = om.MFnDagNode() for i in range(sel.length()): start_node = sel.getDependNode(i) start_node_hash = handle(start_node).hashCode() if start_node_hash in traversed: continue it.resetTo(start_node, filter=filter, direction=direction) while not it.isDone(): node = it.currentNode() node_hash = handle(node).hashCode() if node_hash in traversed: it.prune() it.next() # noqa: B305 continue traversed.add(node_hash) if node.hasFn(om.MFn.kDagNode): fn_dag.setObject(node) yield fn_dag.fullPathName() else: fn_dep.setObject(node) yield fn_dep.name() it.next() # noqa: B305 def collect_input_containers(containers, nodes): """Collect containers that contain any of the node in `nodes`. This will return any loaded Avalon container that contains at least one of the nodes. As such, the Avalon container is an input for it. Or in short, there are member nodes of that container. Returns: list: Input avalon containers """ # Assume the containers have collected their cached '_members' data # in the collector. return [container for container in containers if any(node in container["_members"] for node in nodes)] class CollectUpstreamInputs(pyblish.api.InstancePlugin): """Collect input source inputs for this publish. This will include `inputs` data of which loaded publishes were used in the generation of this publish. This leaves an upstream trace to what was used as input. """ label = "Collect Inputs" order = pyblish.api.CollectorOrder + 0.34 hosts = ["maya"] def process(self, instance): # For large scenes the querying of "host.ls()" can be relatively slow # e.g. up to a second. Many instances calling it easily slows this # down. As such, we cache it so we trigger it only once. # todo: Instead of hidden cache make "CollectContainers" plug-in cache_key = "__cache_containers" scene_containers = instance.context.data.get(cache_key, None) if scene_containers is None: # Query the scenes' containers if there's no cache yet host = registered_host() scene_containers = list(host.ls()) for container in scene_containers: # Embed the members into the container dictionary container_members = set(get_container_members(container)) container["_members"] = container_members instance.context.data["__cache_containers"] = scene_containers # Collect the relevant input containers for this instance if "renderlayer" in set(instance.data.get("families", [])): # Special behavior for renderlayers self.log.debug("Collecting renderlayer inputs....") containers = self._collect_renderlayer_inputs(scene_containers, instance) else: # Basic behavior nodes = instance[:] # Include any input connections of history with long names # For optimization purposes only trace upstream from shape nodes # looking for used dag nodes. This way having just a constraint # on a transform is also ignored which tended to give irrelevant # inputs for the majority of our use cases. We tend to care more # about geometry inputs. shapes = cmds.ls(nodes, type=("mesh", "nurbsSurface", "nurbsCurve"), noIntermediate=True) if shapes: history = list(iter_history(shapes, filter=om.MFn.kShape)) history = cmds.ls(history, long=True) # Include the transforms in the collected history as shapes # are excluded from containers transforms = cmds.listRelatives(cmds.ls(history, shapes=True), parent=True, fullPath=True, type="transform") if transforms: history.extend(transforms) if history: nodes = list(set(nodes + history)) # Collect containers for the given set of nodes containers = collect_input_containers(scene_containers, nodes) inputs = [c["representation"] for c in containers] instance.data["inputRepresentations"] = inputs self.log.debug("Collected inputs: %s" % inputs) def _collect_renderlayer_inputs(self, scene_containers, instance): """Collects inputs from nodes in renderlayer, incl. shaders + camera""" # Get the renderlayer renderlayer = instance.data.get("renderlayer") if renderlayer == "defaultRenderLayer": # Assume all loaded containers in the scene are inputs # for the masterlayer return copy.deepcopy(scene_containers) else: # Get the members of the layer members = cmds.editRenderLayerMembers(renderlayer, query=True, fullNames=True) or [] # In some cases invalid objects are returned from # `editRenderLayerMembers` so we filter them out members = cmds.ls(members, long=True) # Include all children children = cmds.listRelatives(members, allDescendents=True, fullPath=True) or [] members.extend(children) # Include assigned shaders in renderlayer shapes = cmds.ls(members, shapes=True, long=True) shaders = set() for shape in shapes: shape_shaders = get_shader_in_layer(shape, layer=renderlayer) if not shape_shaders: continue shaders.update(shape_shaders) members.extend(shaders) # Explicitly include the camera being rendered in renderlayer cameras = instance.data.get("cameras") members.extend(cameras) containers = collect_input_containers(scene_containers, members) return containers ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_instances.py ================================================ from maya import cmds import pyblish.api from openpype.hosts.maya.api.lib import get_all_children class CollectNewInstances(pyblish.api.InstancePlugin): """Gather members for instances and pre-defined attribute This collector takes into account assets that are associated with an objectSet and marked with a unique identifier; Identifier: id (str): "pyblish.avalon.instance" Limitations: - Does not take into account nodes connected to those within an objectSet. Extractors are assumed to export with history preserved, but this limits what they will be able to achieve and the amount of data available to validators. An additional collector could also append this input data into the instance, as we do for `pype.rig` with collect_history. """ label = "Collect New Instance Data" order = pyblish.api.CollectorOrder hosts = ["maya"] valid_empty_families = {"workfile", "renderlayer"} def process(self, instance): objset = instance.data.get("instance_node") if not objset: self.log.debug("Instance has no `instance_node` data") # TODO: We might not want to do this in the future # Merge creator attributes into instance.data just backwards compatible # code still runs as expected creator_attributes = instance.data.get("creator_attributes", {}) if creator_attributes: instance.data.update(creator_attributes) members = cmds.sets(objset, query=True) or [] if members: # Collect members members = cmds.ls(members, long=True) or [] dag_members = cmds.ls(members, type="dagNode", long=True) children = get_all_children(dag_members) children = cmds.ls(children, noIntermediate=True, long=True) parents = ( self.get_all_parents(members) if creator_attributes.get("includeParentHierarchy", True) else [] ) members_hierarchy = list(set(members + children + parents)) instance[:] = members_hierarchy elif instance.data["family"] not in self.valid_empty_families: self.log.warning("Empty instance: \"%s\" " % objset) # Store the exact members of the object set instance.data["setMembers"] = members # TODO: This might make more sense as a separate collector # Convert frame values to integers for attr_name in ( "handleStart", "handleEnd", "frameStart", "frameEnd", ): value = instance.data.get(attr_name) if value is not None: instance.data[attr_name] = int(value) # Append start frame and end frame to label if present if "frameStart" in instance.data and "frameEnd" in instance.data: # Take handles from context if not set locally on the instance for key in ["handleStart", "handleEnd"]: if key not in instance.data: value = instance.context.data[key] if value is not None: value = int(value) instance.data[key] = value instance.data["frameStartHandle"] = int( instance.data["frameStart"] - instance.data["handleStart"] ) instance.data["frameEndHandle"] = int( instance.data["frameEnd"] + instance.data["handleEnd"] ) def get_all_parents(self, nodes): """Get all parents by using string operations (optimization) Args: nodes (list): the nodes which are found in the objectSet Returns: list """ parents = [] for node in nodes: splitted = node.split("|") items = ["|".join(splitted[0:i]) for i in range(2, len(splitted))] parents.extend(items) return list(set(parents)) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_look.py ================================================ # -*- coding: utf-8 -*- """Maya look collector.""" import re import os import glob from maya import cmds # noqa import pyblish.api from openpype.hosts.maya.api import lib SHAPE_ATTRS = ["castsShadows", "receiveShadows", "motionBlur", "primaryVisibility", "smoothShading", "visibleInReflections", "visibleInRefractions", "doubleSided", "opposite"] SHAPE_ATTRS = set(SHAPE_ATTRS) def get_pxr_multitexture_file_attrs(node): attrs = [] for i in range(9): if cmds.attributeQuery("filename{}".format(i), node=node, ex=True): file = cmds.getAttr("{}.filename{}".format(node, i)) if file: attrs.append("filename{}".format(i)) return attrs FILE_NODES = { # maya "file": "fileTextureName", # arnold (mtoa) "aiImage": "filename", # redshift "RedshiftNormalMap": "tex0", # renderman "PxrBump": "filename", "PxrNormalMap": "filename", "PxrMultiTexture": get_pxr_multitexture_file_attrs, "PxrPtexture": "filename", "PxrTexture": "filename" } RENDER_SET_TYPES = [ "VRayDisplacement", "VRayLightMesh", "VRayObjectProperties", "RedshiftObjectId", "RedshiftMeshParameters", ] # Keep only node types that actually exist all_node_types = set(cmds.allNodeTypes()) for node_type in list(FILE_NODES.keys()): if node_type not in all_node_types: FILE_NODES.pop(node_type) for node_type in RENDER_SET_TYPES: if node_type not in all_node_types: RENDER_SET_TYPES.remove(node_type) del all_node_types # Cache pixar dependency node types so we can perform a type lookup against it PXR_NODES = set() if cmds.pluginInfo("RenderMan_for_Maya", query=True, loaded=True): PXR_NODES = set( cmds.pluginInfo("RenderMan_for_Maya", query=True, dependNode=True) ) def get_attributes(dictionary, attr, node=None): # type: (dict, str, str) -> list if callable(dictionary[attr]): val = dictionary[attr](node) else: val = dictionary.get(attr, []) return val if isinstance(val, list) else [val] def get_look_attrs(node): """Returns attributes of a node that are important for the look. These are the "changed" attributes (those that have edits applied in the current scene). Returns: list: Attribute names to extract """ # When referenced get only attributes that are "changed since file open" # which includes any reference edits, otherwise take *all* user defined # attributes is_referenced = cmds.referenceQuery(node, isNodeReferenced=True) result = cmds.listAttr(node, userDefined=True, changedSinceFileOpen=is_referenced) or [] # `cbId` is added when a scene is saved, ignore by default if "cbId" in result: result.remove("cbId") # For shapes allow render stat changes if cmds.objectType(node, isAType="shape"): attrs = cmds.listAttr(node, changedSinceFileOpen=True) or [] for attr in attrs: if attr in SHAPE_ATTRS or \ attr not in SHAPE_ATTRS and attr.startswith('ai'): result.append(attr) return result def node_uses_image_sequence(node, node_path): # type: (str, str) -> bool """Return whether file node uses an image sequence or single image. Determine if a node uses an image sequence or just a single image, not always obvious from its file path alone. Args: node (str): Name of the Maya node node_path (str): The file path of the node Returns: bool: True if node uses an image sequence """ # useFrameExtension indicates an explicit image sequence try: use_frame_extension = cmds.getAttr('%s.useFrameExtension' % node) except ValueError: use_frame_extension = False if use_frame_extension: return True # The following tokens imply a sequence patterns = ["", "", "", "u_v", ""] node_path_lowered = node_path.lower() return any(pattern in node_path_lowered for pattern in patterns) def seq_to_glob(path): """Takes an image sequence path and returns it in glob format, with the frame number replaced by a '*'. Image sequences may be numerical sequences, e.g. /path/to/file.1001.exr will return as /path/to/file.*.exr. Image sequences may also use tokens to denote sequences, e.g. /path/to/texture..tif will return as /path/to/texture.*.tif. Args: path (str): the image sequence path Returns: str: Return glob string that matches the filename pattern. """ if path is None: return path # If any of the patterns, convert the pattern patterns = { "": "", "": "", "": "", "#": "#", "u_v": "|", "", "": "" } lower = path.lower() has_pattern = False for pattern, regex_pattern in patterns.items(): if pattern in lower: path = re.sub(regex_pattern, "*", path, flags=re.IGNORECASE) has_pattern = True if has_pattern: return path base = os.path.basename(path) matches = list(re.finditer(r'\d+', base)) if matches: match = matches[-1] new_base = '{0}*{1}'.format(base[:match.start()], base[match.end():]) head = os.path.dirname(path) return os.path.join(head, new_base) else: return path def get_file_node_paths(node): # type: (str) -> list """Get the file path used by a Maya file node. Args: node (str): Name of the Maya file node Returns: list: the file paths in use """ # if the path appears to be sequence, use computedFileTextureNamePattern, # this preserves the <> tag if cmds.attributeQuery('computedFileTextureNamePattern', node=node, exists=True): plug = '{0}.computedFileTextureNamePattern'.format(node) texture_pattern = cmds.getAttr(plug) patterns = ["", "", "u_v", "", ""] lower = texture_pattern.lower() if any(pattern in lower for pattern in patterns): return [texture_pattern] try: file_attributes = get_attributes( FILE_NODES, cmds.nodeType(node), node) except AttributeError: file_attributes = "fileTextureName" files = [] for file_attr in file_attributes: if cmds.attributeQuery(file_attr, node=node, exists=True): files.append(cmds.getAttr("{}.{}".format(node, file_attr))) return files def get_file_node_files(node): """Return the file paths related to the file node Note: Will only return existing files. Returns an empty list if not valid existing files are linked. Returns: list: List of full file paths. """ paths = get_file_node_paths(node) # For sequences get all files and filter to only existing files result = [] for path in paths: if node_uses_image_sequence(node, path): glob_pattern = seq_to_glob(path) result.extend(glob.glob(glob_pattern)) elif os.path.exists(path): result.append(path) return result class CollectLook(pyblish.api.InstancePlugin): """Collect look data for instance. For the shapes/transforms of the referenced object to collect look for retrieve the user-defined attributes (like V-ray attributes) and their values as they were created in the current scene. For the members of the instance collect the sets (shadingEngines and other sets, e.g. VRayDisplacement) they are in along with the exact membership relations. Collects: lookAttributes (list): Nodes in instance with their altered attributes lookSetRelations (list): Sets and their memberships lookSets (list): List of set names included in the look """ order = pyblish.api.CollectorOrder + 0.2 families = ["look"] label = "Collect Look" hosts = ["maya"] maketx = True def process(self, instance): """Collect the Look in the instance with the correct layer settings""" renderlayer = instance.data.get("renderlayer", "defaultRenderLayer") with lib.renderlayer(renderlayer): self.collect(instance) def collect(self, instance): """Collect looks. Args: instance: Instance to collect. """ self.log.debug("Looking for look associations " "for %s" % instance.data['name']) # Lookup set (optimization) instance_lookup = set(cmds.ls(instance, long=True)) # Discover related object sets self.log.debug("Gathering sets ...") sets = self.collect_sets(instance) # Lookup set (optimization) instance_lookup = set(cmds.ls(instance, long=True)) self.log.debug("Gathering set relations ...") # Ensure iteration happen in a list to allow removing keys from the # dict within the loop for obj_set in list(sets): self.log.debug("From {}".format(obj_set)) # Get all nodes of the current objectSet (shadingEngine) for member in cmds.ls(cmds.sets(obj_set, query=True), long=True): member_data = self.collect_member_data(member, instance_lookup) if member_data: # Add information of the node to the members list sets[obj_set]["members"].append(member_data) # Remove sets that didn't have any members assigned in the end # Thus the data will be limited to only what we need. if not sets[obj_set]["members"]: self.log.debug( "Removing redundant set information: {}".format(obj_set) ) sets.pop(obj_set, None) self.log.debug("Gathering attribute changes to instance members..") attributes = self.collect_attributes_changed(instance) # Store data on the instance instance.data["lookData"] = { "attributes": attributes, "relationships": sets } # Collect file nodes used by shading engines (if we have any) files = [] look_sets = list(sets.keys()) shader_attrs = [ "surfaceShader", "volumeShader", "displacementShader", "aiSurfaceShader", "aiVolumeShader", "rman__surface", "rman__displacement" ] if look_sets: self.log.debug("Found look sets: {}".format(look_sets)) # Get all material attrs for all look sets to retrieve their inputs existing_attrs = [] for look in look_sets: for attr in shader_attrs: if cmds.attributeQuery(attr, node=look, exists=True): existing_attrs.append("{}.{}".format(look, attr)) materials = cmds.listConnections(existing_attrs, source=True, destination=False) or [] self.log.debug("Found materials:\n{}".format(materials)) self.log.debug("Found the following sets:\n{}".format(look_sets)) # Get the entire node chain of the look sets # history = cmds.listHistory(look_sets, allConnections=True) # if materials list is empty, listHistory() will crash with # RuntimeError history = set() if materials: history = set( cmds.listHistory(materials, allConnections=True)) # Since we retrieved history only of the connected materials # connected to the look sets above we now add direct history # for some of the look sets directly # handling render attribute sets # Maya (at least 2024) crashes with Warning when render set type # isn't available. cmds.ls() will return empty list if RENDER_SET_TYPES: render_sets = cmds.ls(look_sets, type=RENDER_SET_TYPES) if render_sets: history.update( cmds.listHistory(render_sets, future=False, pruneDagObjects=True) or [] ) # Ensure unique entries only history = list(history) files = cmds.ls(history, # It's important only node types are passed that # exist (e.g. for loaded plugins) because otherwise # the result will turn back empty type=list(FILE_NODES.keys()), long=True) # Sort for log readability files.sort() self.log.debug("Collected file nodes:\n{}".format(files)) # Collect textures if any file nodes are found resources = [] for node in files: # sort for log readability resources.extend(self.collect_resources(node)) instance.data["resources"] = resources self.log.debug("Collected resources: {}".format(resources)) # Log warning when no relevant sets were retrieved for the look. if ( not instance.data["lookData"]["relationships"] and "model" not in self.families ): self.log.warning("No sets found for the nodes in the " "instance: %s" % instance[:]) # Ensure unique shader sets # Add shader sets to the instance for unify ID validation instance.extend(shader for shader in look_sets if shader not in instance_lookup) self.log.debug("Collected look for %s" % instance) def collect_sets(self, instance): """Collect all objectSets which are of importance for publishing It checks if all nodes in the instance are related to any objectSet which need to be Args: instance (list): all nodes to be published Returns: dict """ sets = {} for node in instance: related_sets = lib.get_related_sets(node) if not related_sets: continue for objset in related_sets: if objset in sets: continue sets[objset] = {"uuid": lib.get_id(objset), "members": list()} return sets def collect_member_data(self, member, instance_members): """Get all information of the node Args: member (str): the name of the node to check instance_members (set): the collected instance members Returns: dict """ node, components = (member.rsplit(".", 1) + [None])[:2] # Only include valid members of the instance if node not in instance_members: return node_id = lib.get_id(node) if not node_id: self.log.error("Member '{}' has no attribute 'cbId'".format(node)) return member_data = {"name": node, "uuid": node_id} if components: member_data["components"] = components return member_data def collect_attributes_changed(self, instance): """Collect all userDefined attributes which have changed Each node gets checked for user defined attributes which have been altered during development. Each changes gets logged in a dictionary [{name: node, uuid: uuid, attributes: {attribute: value}}] Args: instance (list): all nodes which will be published Returns: list """ attributes = [] for node in instance: # Collect changes to "custom" attributes node_attrs = get_look_attrs(node) # Only include if there are any properties we care about if not node_attrs: continue self.log.debug( "Node \"{0}\" attributes: {1}".format(node, node_attrs) ) node_attributes = {} for attr in node_attrs: if not cmds.attributeQuery(attr, node=node, exists=True): continue attribute = "{}.{}".format(node, attr) # We don't support mixed-type attributes yet. if cmds.attributeQuery(attr, node=node, multi=True): self.log.warning("Attribute '{}' is mixed-type and is " "not supported yet.".format(attribute)) continue if cmds.getAttr(attribute, type=True) == "message": continue node_attributes[attr] = cmds.getAttr(attribute, asString=True) # Only include if there are any properties we care about if not node_attributes: continue attributes.append({"name": node, "uuid": lib.get_id(node), "attributes": node_attributes}) return attributes def collect_resources(self, node): """Collect the link to the file(s) used (resource) Args: node (str): name of the node Returns: dict """ if cmds.nodeType(node) not in FILE_NODES: self.log.error( "Unsupported file node: {}".format(cmds.nodeType(node))) raise AssertionError("Unsupported file node") self.log.debug( "Collecting resource: {} ({})".format(node, cmds.nodeType(node)) ) attributes = get_attributes(FILE_NODES, cmds.nodeType(node), node) for attribute in attributes: source = cmds.getAttr("{}.{}".format( node, attribute )) self.log.debug(" - file source: {}".format(source)) color_space_attr = "{}.colorSpace".format(node) try: color_space = cmds.getAttr(color_space_attr) except ValueError: # node doesn't have colorspace attribute color_space = "Raw" # Compare with the computed file path, e.g. the one with # the pattern in it, to generate some logging information # about this difference # Only for file nodes with `fileTextureName` attribute if attribute == "fileTextureName": computed_source = cmds.getAttr( "{}.computedFileTextureNamePattern".format(node) ) if source != computed_source: self.log.debug("Detected computed file pattern difference " "from original pattern: {0} " "({1} -> {2})".format(node, source, computed_source)) # renderman allows nodes to have filename attribute empty while # you can have another incoming connection from different node. if not source and cmds.nodeType(node) in PXR_NODES: self.log.debug("Renderman: source is empty, skipping...") continue # We replace backslashes with forward slashes because V-Ray # can't handle the UDIM files with the backslashes in the # paths as the computed patterns source = source.replace("\\", "/") files = get_file_node_files(node) if len(files) == 0: self.log.debug("No valid files found from node `%s`" % node) self.log.debug("collection of resource done:") self.log.debug(" - node: {}".format(node)) self.log.debug(" - attribute: {}".format(attribute)) self.log.debug(" - source: {}".format(source)) self.log.debug(" - file: {}".format(files)) self.log.debug(" - color space: {}".format(color_space)) # Define the resource yield { "node": node, # here we are passing not only attribute, but with node again # this should be simplified and changed extractor. "attribute": "{}.{}".format(node, attribute), "source": source, # required for resources "files": files, "color_space": color_space } # required for resources class CollectModelRenderSets(CollectLook): """Collect render attribute sets for model instance. Collects additional render attribute sets so they can be published with model. """ order = pyblish.api.CollectorOrder + 0.21 families = ["model"] label = "Collect Model Render Sets" hosts = ["maya"] maketx = True def collect_sets(self, instance): """Collect all related objectSets except shadingEngines Args: instance (list): all nodes to be published Returns: dict """ sets = {} for node in instance: related_sets = lib.get_related_sets(node) if not related_sets: continue for objset in related_sets: if objset in sets: continue if "shadingEngine" in cmds.nodeType(objset, inherited=True): continue sets[objset] = {"uuid": lib.get_id(objset), "members": list()} return sets ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_maya_scene_time.py ================================================ from maya import cmds import pyblish.api class CollectMayaSceneTime(pyblish.api.InstancePlugin): """Collect Maya Scene playback range This allows to reproduce the playback range for the content to be loaded. It does *not* limit the extracted data to only data inside that time range. """ order = pyblish.api.CollectorOrder + 0.2 label = 'Collect Maya Scene Time' families = ["mayaScene"] def process(self, instance): instance.data.update({ "frameStart": int( cmds.playbackOptions(query=True, minTime=True)), "frameEnd": int( cmds.playbackOptions(query=True, maxTime=True)), "frameStartHandle": int( cmds.playbackOptions(query=True, animationStartTime=True)), "frameEndHandle": int( cmds.playbackOptions(query=True, animationEndTime=True)) }) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_maya_units.py ================================================ import maya.cmds as cmds import maya.mel as mel import pyblish.api class CollectMayaUnits(pyblish.api.ContextPlugin): """Collect Maya's scene units.""" label = "Maya Units" order = pyblish.api.CollectorOrder hosts = ["maya"] def process(self, context): # Get the current linear units units = cmds.currentUnit(query=True, linear=True) # Get the current angular units ('deg' or 'rad') units_angle = cmds.currentUnit(query=True, angle=True) # Get the current time units # Using the mel command is simpler than using # `cmds.currentUnit(q=1, time=1)`. Otherwise we # have to parse the returned string value to FPS fps = mel.eval('currentTimeUnitToFPS()') context.data['linearUnits'] = units context.data['angularUnits'] = units_angle context.data['fps'] = fps ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_maya_workspace.py ================================================ import os import pyblish.api from maya import cmds class CollectMayaWorkspace(pyblish.api.ContextPlugin): """Inject the current workspace into context""" order = pyblish.api.CollectorOrder - 0.5 label = "Maya Workspace" hosts = ['maya'] def process(self, context): workspace = cmds.workspace(rootDirectory=True, query=True) if not workspace: # Project has not been set. Files will # instead end up next to the working file. workspace = cmds.workspace(dir=True, query=True) # Maya returns forward-slashes by default normalised = os.path.normpath(workspace) context.set_data('workspaceDir', value=normalised) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_model.py ================================================ from maya import cmds import pyblish.api class CollectModelData(pyblish.api.InstancePlugin): """Collect model data Ensures always only a single frame is extracted (current frame). Note: This is a workaround so that the `pype.model` family can use the same pointcache extractor implementation as animation and pointcaches. This always enforces the "current" frame to be published. """ order = pyblish.api.CollectorOrder + 0.2 label = 'Collect Model Data' families = ["model"] def process(self, instance): # Extract only current frame (override) frame = cmds.currentTime(query=True) instance.data["frameStart"] = frame instance.data["frameEnd"] = frame ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_multiverse_look.py ================================================ import glob import os import re from maya import cmds import pyblish.api from openpype.hosts.maya.api import lib SHAPE_ATTRS = ["castsShadows", "receiveShadows", "motionBlur", "primaryVisibility", "smoothShading", "visibleInReflections", "visibleInRefractions", "doubleSided", "opposite"] SHAPE_ATTRS = set(SHAPE_ATTRS) COLOUR_SPACES = ['sRGB', 'linear', 'auto'] MIPMAP_EXTENSIONS = ['tdl'] class _NodeTypeAttrib(object): """docstring for _NodeType""" def __init__(self, name, fname, computed_fname=None, colour_space=None): self.name = name self.fname = fname self.computed_fname = computed_fname or fname self.colour_space = colour_space or "colorSpace" def get_fname(self, node): return "{}.{}".format(node, self.fname) def get_computed_fname(self, node): return "{}.{}".format(node, self.computed_fname) def get_colour_space(self, node): return "{}.{}".format(node, self.colour_space) def __str__(self): return "_NodeTypeAttrib(name={}, fname={}, " "computed_fname={}, colour_space={})".format( self.name, self.fname, self.computed_fname, self.colour_space) NODETYPES = { "file": [_NodeTypeAttrib("file", "fileTextureName", "computedFileTextureNamePattern")], "aiImage": [_NodeTypeAttrib("aiImage", "filename")], "RedshiftNormalMap": [_NodeTypeAttrib("RedshiftNormalMap", "tex0")], "dlTexture": [_NodeTypeAttrib("dlTexture", "textureFile", None, "textureFile_meta_colorspace")], "dlTriplanar": [_NodeTypeAttrib("dlTriplanar", "colorTexture", None, "colorTexture_meta_colorspace"), _NodeTypeAttrib("dlTriplanar", "floatTexture", None, "floatTexture_meta_colorspace"), _NodeTypeAttrib("dlTriplanar", "heightTexture", None, "heightTexture_meta_colorspace")] } def get_file_paths_for_node(node): """Gets all the file paths in this node. Returns all filepaths that this node references. Some node types only reference one, but others, like dlTriplanar, can reference 3. Args: node (str): Name of the Maya node Returns list(str): A list with all evaluated maya attributes for filepaths. """ node_type = cmds.nodeType(node) if node_type not in NODETYPES: return [] paths = [] for node_type_attr in NODETYPES[node_type]: fname = cmds.getAttr("{}.{}".format(node, node_type_attr.fname)) paths.append(fname) return paths def node_uses_image_sequence(node): """Return whether file node uses an image sequence or single image. Determine if a node uses an image sequence or just a single image, not always obvious from its file path alone. Args: node (str): Name of the Maya node Returns: bool: True if node uses an image sequence """ # useFrameExtension indicates an explicit image sequence paths = get_file_node_paths(node) paths = [path.lower() for path in paths] # The following tokens imply a sequence patterns = ["", "", "", "u_v", ".tif will return as /path/to/texture.*.tif. Args: path (str): the image sequence path Returns: str: Return glob string that matches the filename pattern. """ if path is None: return path # If any of the patterns, convert the pattern patterns = { "": "", "": "", "": "", "#": "#", "u_v": "|", "", # noqa - copied from collect_look.py "": "" } lower = path.lower() has_pattern = False for pattern, regex_pattern in patterns.items(): if pattern in lower: path = re.sub(regex_pattern, "*", path, flags=re.IGNORECASE) has_pattern = True if has_pattern: return path base = os.path.basename(path) matches = list(re.finditer(r'\d+', base)) if matches: match = matches[-1] new_base = '{0}*{1}'.format(base[:match.start()], base[match.end():]) head = os.path.dirname(path) return os.path.join(head, new_base) else: return path def get_file_node_paths(node): """Get the file path used by a Maya file node. Args: node (str): Name of the Maya file node Returns: str: the file path in use """ # if the path appears to be sequence, use computedFileTextureNamePattern, # this preserves the <> tag if cmds.attributeQuery('computedFileTextureNamePattern', node=node, exists=True): plug = '{0}.computedFileTextureNamePattern'.format(node) texture_pattern = cmds.getAttr(plug) patterns = ["", "", "u_v", "", ""] lower = texture_pattern.lower() if any(pattern in lower for pattern in patterns): return [texture_pattern] return get_file_paths_for_node(node) def get_file_node_files(node): """Return the file paths related to the file node Note: Will only return existing files. Returns an empty list if not valid existing files are linked. Returns: list: List of full file paths. """ paths = get_file_node_paths(node) paths = [cmds.workspace(expandName=path) for path in paths] if node_uses_image_sequence(node): globs = [] for path in paths: globs += glob.glob(seq_to_glob(path)) return globs else: return list(filter(lambda x: os.path.exists(x), paths)) def get_mipmap(fname): for colour_space in COLOUR_SPACES: for mipmap_ext in MIPMAP_EXTENSIONS: mipmap_fname = '.'.join([fname, colour_space, mipmap_ext]) if os.path.exists(mipmap_fname): return mipmap_fname return None def is_mipmap(fname): ext = os.path.splitext(fname)[1][1:] if ext in MIPMAP_EXTENSIONS: return True return False class CollectMultiverseLookData(pyblish.api.InstancePlugin): """Collect Multiverse Look Searches through the overrides finding all material overrides. From there it extracts the shading group and then finds all texture files in the shading group network. It also checks for mipmap versions of texture files and adds them to the resources to get published. """ order = pyblish.api.CollectorOrder + 0.2 label = 'Collect Multiverse Look' families = ["mvLook"] def process(self, instance): # Load plugin first cmds.loadPlugin("MultiverseForMaya", quiet=True) import multiverse self.log.debug("Processing mvLook for '{}'".format(instance)) nodes = set() for node in instance: # We want only mvUsdCompoundShape nodes. nodes_of_interest = cmds.ls(node, dag=True, shapes=False, type="mvUsdCompoundShape", noIntermediate=True, long=True) nodes.update(nodes_of_interest) sets = {} instance.data["resources"] = [] publishMipMap = instance.data["publishMipMap"] for node in nodes: self.log.debug("Getting resources for '{}'".format(node)) # We know what nodes need to be collected, now we need to # extract the materials overrides. overrides = multiverse.ListMaterialOverridePrims(node) for override in overrides: matOver = multiverse.GetMaterialOverride(node, override) if isinstance(matOver, multiverse.MaterialSourceShadingGroup): # We now need to grab the shadingGroup so add it to the # sets we pass down the pipe. shadingGroup = matOver.shadingGroupName self.log.debug("ShadingGroup = '{}'".format(shadingGroup)) sets[shadingGroup] = {"uuid": lib.get_id( shadingGroup), "members": list()} # The SG may reference files, add those too! history = cmds.listHistory( shadingGroup, allConnections=True) # We need to iterate over node_types since `cmds.ls` may # error out if we don't have the appropriate plugin loaded. files = [] for node_type in NODETYPES.keys(): files += cmds.ls(history, type=node_type, long=True) for f in files: resources = self.collect_resource(f, publishMipMap) instance.data["resources"] += resources elif isinstance(matOver, multiverse.MaterialSourceUsdPath): # TODO: Handle this later. pass # Store data on the instance for validators, extractos, etc. instance.data["lookData"] = { "attributes": [], "relationships": sets } def collect_resource(self, node, publishMipMap): """Collect the link to the file(s) used (resource) Args: node (str): name of the node Returns: dict """ node_type = cmds.nodeType(node) self.log.debug("processing: {}/{}".format(node, node_type)) if node_type not in NODETYPES: self.log.error("Unsupported file node: {}".format(node_type)) raise AssertionError("Unsupported file node") resources = [] for node_type_attr in NODETYPES[node_type]: fname_attrib = node_type_attr.get_fname(node) computed_fname_attrib = node_type_attr.get_computed_fname(node) colour_space_attrib = node_type_attr.get_colour_space(node) source = cmds.getAttr(fname_attrib) color_space = "Raw" try: color_space = cmds.getAttr(colour_space_attrib) except ValueError: # node doesn't have colorspace attribute, use "Raw" from before pass # Compare with the computed file path, e.g. the one with the # pattern in it, to generate some logging information about this # difference # computed_attribute = "{}.computedFileTextureNamePattern".format(node) # noqa computed_source = cmds.getAttr(computed_fname_attrib) if source != computed_source: self.log.debug("Detected computed file pattern difference " "from original pattern: {0} " "({1} -> {2})".format(node, source, computed_source)) # We replace backslashes with forward slashes because V-Ray # can't handle the UDIM files with the backslashes in the # paths as the computed patterns source = source.replace("\\", "/") files = get_file_node_files(node) files = self.handle_files(files, publishMipMap) if len(files) == 0: self.log.error("No valid files found from node `%s`" % node) self.log.debug("collection of resource done:") self.log.debug(" - node: {}".format(node)) self.log.debug(" - attribute: {}".format(fname_attrib)) self.log.debug(" - source: {}".format(source)) self.log.debug(" - file: {}".format(files)) self.log.debug(" - color space: {}".format(color_space)) # Define the resource resource = {"node": node, "attribute": fname_attrib, "source": source, # required for resources "files": files, "color_space": color_space} # required for resources resources.append(resource) return resources def handle_files(self, files, publishMipMap): """This will go through all the files and make sure that they are either already mipmapped or have a corresponding mipmap sidecar and add that to the list.""" if not publishMipMap: return files extra_files = [] self.log.debug("Expecting MipMaps, going to look for them.") for fname in files: self.log.debug("Checking '{}' for mipmaps".format(fname)) if is_mipmap(fname): self.log.debug(" - file is already MipMap, skipping.") continue mipmap = get_mipmap(fname) if mipmap: self.log.debug(" mipmap found for '{}'".format(fname)) extra_files.append(mipmap) else: self.log.warning(" no mipmap found for '{}'".format(fname)) return files + extra_files ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_pointcache.py ================================================ from maya import cmds import pyblish.api class CollectPointcache(pyblish.api.InstancePlugin): """Collect pointcache data for instance.""" order = pyblish.api.CollectorOrder + 0.4 families = ["pointcache"] label = "Collect Pointcache" hosts = ["maya"] def process(self, instance): if instance.data.get("farm"): instance.data["families"].append("publish.farm") proxy_set = None for node in cmds.ls(instance.data["setMembers"], exactType="objectSet"): # Find proxy_SET objectSet in the instance for proxy meshes if node.endswith("proxy_SET"): members = cmds.sets(node, query=True) if members is None: self.log.debug("Skipped empty proxy_SET: \"%s\" " % node) continue self.log.debug("Found proxy set: {}".format(node)) proxy_set = node instance.data["proxy"] = [] instance.data["proxyRoots"] = [] for member in members: instance.data["proxy"].extend(cmds.ls(member, long=True)) instance.data["proxyRoots"].extend( cmds.ls(member, long=True) ) instance.data["proxy"].extend( cmds.listRelatives(member, shapes=True, fullPath=True) ) self.log.debug( "Found proxy members: {}".format(instance.data["proxy"]) ) break if proxy_set: instance.remove(proxy_set) instance.data["setMembers"].remove(proxy_set) # User defined attributes. instance.data["includeUserDefinedAttributes"] = ( instance.data["creator_attributes"]["includeUserDefinedAttributes"] ) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_remove_marked.py ================================================ import pyblish.api class CollectRemoveMarked(pyblish.api.ContextPlugin): """Remove marked data Remove instances that have 'remove' in their instance.data """ order = pyblish.api.CollectorOrder + 0.499 label = 'Remove Marked Instances' def process(self, context): self.log.debug(context) # make ftrack publishable instances_to_remove = [] for instance in context: if instance.data.get('remove'): instances_to_remove.append(instance) for instance in instances_to_remove: context.remove(instance) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_render.py ================================================ # -*- coding: utf-8 -*- """Collect render data. This collector will go through render layers in maya and prepare all data needed to create instances and their representations for submission and publishing on farm. Requires: instance -> families instance -> setMembers context -> currentFile context -> workspaceDir context -> user session -> AVALON_ASSET Optional: Provides: instance -> label instance -> subset instance -> attachTo instance -> setMembers instance -> publish instance -> frameStart instance -> frameEnd instance -> byFrameStep instance -> renderer instance -> family instance -> families instance -> asset instance -> time instance -> author instance -> source instance -> expectedFiles instance -> resolutionWidth instance -> resolutionHeight instance -> pixelAspect """ import os import platform import json from maya import cmds import pyblish.api from openpype.pipeline import KnownPublishError from openpype.lib import get_formatted_current_time from openpype.hosts.maya.api.lib_renderproducts import ( get as get_layer_render_products, UnsupportedRendererException ) from openpype.hosts.maya.api import lib class CollectMayaRender(pyblish.api.InstancePlugin): """Gather all publishable render layers from renderSetup.""" order = pyblish.api.CollectorOrder + 0.01 hosts = ["maya"] families = ["renderlayer"] label = "Collect Render Layers" sync_workfile_version = False _aov_chars = { "dot": ".", "dash": "-", "underscore": "_" } def process(self, instance): # TODO: Re-add force enable of workfile instance? # TODO: Re-add legacy layer support with LAYER_ prefix but in Creator # TODO: Set and collect active state of RenderLayer in Creator using # renderlayer.isRenderable() context = instance.context layer = instance.data["transientData"]["layer"] objset = instance.data.get("instance_node") filepath = context.data["currentFile"].replace("\\", "/") workspace = context.data["workspaceDir"] # check if layer is renderable if not layer.isRenderable(): msg = "Render layer [ {} ] is not " "renderable".format( layer.name() ) self.log.warning(msg) # detect if there are sets (subsets) to attach render to sets = cmds.sets(objset, query=True) or [] attach_to = [] for s in sets: if not cmds.attributeQuery("family", node=s, exists=True): continue attach_to.append( { "version": None, # we need integrator for that "subset": s, "family": cmds.getAttr("{}.family".format(s)), } ) self.log.debug(" -> attach render to: {}".format(s)) layer_name = layer.name() # collect all frames we are expecting to be rendered # return all expected files for all cameras and aovs in given # frame range try: layer_render_products = get_layer_render_products(layer.name()) except UnsupportedRendererException as exc: raise KnownPublishError(exc) render_products = layer_render_products.layer_data.products assert render_products, "no render products generated" expected_files = [] multipart = False for product in render_products: if product.multipart: multipart = True product_name = product.productName if product.camera and layer_render_products.has_camera_token(): product_name = "{}{}".format( product.camera, "_{}".format(product_name) if product_name else "") expected_files.append( { product_name: layer_render_products.get_files( product) }) has_cameras = any(product.camera for product in render_products) assert has_cameras, "No render cameras found." self.log.debug("multipart: {}".format( multipart)) assert expected_files, "no file names were generated, this is a bug" self.log.debug( "expected files: {}".format( json.dumps(expected_files, indent=4, sort_keys=True) ) ) # if we want to attach render to subset, check if we have AOV's # in expectedFiles. If so, raise error as we cannot attach AOV # (considered to be subset on its own) to another subset if attach_to: assert isinstance(expected_files, list), ( "attaching multiple AOVs or renderable cameras to " "subset is not supported" ) # append full path aov_dict = {} image_directory = os.path.join( cmds.workspace(query=True, rootDirectory=True), cmds.workspace(fileRuleEntry="images") ) # replace relative paths with absolute. Render products are # returned as list of dictionaries. publish_meta_path = None for aov in expected_files: full_paths = [] aov_first_key = list(aov.keys())[0] for file in aov[aov_first_key]: full_path = os.path.join(image_directory, file) full_path = full_path.replace("\\", "/") full_paths.append(full_path) publish_meta_path = os.path.dirname(full_path) aov_dict[aov_first_key] = full_paths full_exp_files = [aov_dict] self.log.debug(full_exp_files) if publish_meta_path is None: raise KnownPublishError("Unable to detect any expected output " "images for: {}. Make sure you have a " "renderable camera and a valid frame " "range set for your renderlayer." "".format(instance.name)) frame_start_render = int(self.get_render_attribute( "startFrame", layer=layer_name)) frame_end_render = int(self.get_render_attribute( "endFrame", layer=layer_name)) if (int(context.data["frameStartHandle"]) == frame_start_render and int(context.data["frameEndHandle"]) == frame_end_render): # noqa: W503, E501 handle_start = context.data["handleStart"] handle_end = context.data["handleEnd"] frame_start = context.data["frameStart"] frame_end = context.data["frameEnd"] frame_start_handle = context.data["frameStartHandle"] frame_end_handle = context.data["frameEndHandle"] else: handle_start = 0 handle_end = 0 frame_start = frame_start_render frame_end = frame_end_render frame_start_handle = frame_start_render frame_end_handle = frame_end_render # find common path to store metadata # so if image prefix is branching to many directories # metadata file will be located in top-most common # directory. # TODO: use `os.path.commonpath()` after switch to Python 3 publish_meta_path = os.path.normpath(publish_meta_path) common_publish_meta_path = os.path.splitdrive( publish_meta_path)[0] if common_publish_meta_path: common_publish_meta_path += os.path.sep for part in publish_meta_path.replace( common_publish_meta_path, "").split(os.path.sep): common_publish_meta_path = os.path.join( common_publish_meta_path, part) if part == layer_name: break # TODO: replace this terrible linux hotfix with real solution :) if platform.system().lower() in ["linux", "darwin"]: common_publish_meta_path = "/" + common_publish_meta_path self.log.debug( "Publish meta path: {}".format(common_publish_meta_path)) # Get layer specific settings, might be overrides colorspace_data = lib.get_color_management_preferences() data = { "farm": True, "attachTo": attach_to, "multipartExr": multipart, "review": instance.data.get("review") or False, # Frame range "handleStart": handle_start, "handleEnd": handle_end, "frameStart": frame_start, "frameEnd": frame_end, "frameStartHandle": frame_start_handle, "frameEndHandle": frame_end_handle, "byFrameStep": int( self.get_render_attribute("byFrameStep", layer=layer_name)), # Renderlayer "renderer": self.get_render_attribute( "currentRenderer", layer=layer_name).lower(), "setMembers": layer._getLegacyNodeName(), # legacy renderlayer "renderlayer": layer_name, # todo: is `time` and `author` still needed? "time": get_formatted_current_time(), "author": context.data["user"], # Add source to allow tracing back to the scene from # which was submitted originally "source": filepath, "expectedFiles": full_exp_files, "publishRenderMetadataFolder": common_publish_meta_path, "renderProducts": layer_render_products, "resolutionWidth": lib.get_attr_in_layer( "defaultResolution.width", layer=layer_name ), "resolutionHeight": lib.get_attr_in_layer( "defaultResolution.height", layer=layer_name ), "pixelAspect": lib.get_attr_in_layer( "defaultResolution.pixelAspect", layer=layer_name ), # todo: Following are likely not needed due to collecting from the # instance itself if they are attribute definitions "tileRendering": instance.data.get("tileRendering") or False, # noqa: E501 "tilesX": instance.data.get("tilesX") or 2, "tilesY": instance.data.get("tilesY") or 2, "convertToScanline": instance.data.get( "convertToScanline") or False, "useReferencedAovs": instance.data.get( "useReferencedAovs") or instance.data.get( "vrayUseReferencedAovs") or False, "aovSeparator": layer_render_products.layer_data.aov_separator, # noqa: E501 "renderSetupIncludeLights": instance.data.get( "renderSetupIncludeLights" ), "colorspaceConfig": colorspace_data["config"], "colorspaceDisplay": colorspace_data["display"], "colorspaceView": colorspace_data["view"], } rr_settings = ( context.data["system_settings"]["modules"]["royalrender"] ) if rr_settings["enabled"]: data["rrPathName"] = instance.data.get("rrPathName") self.log.debug(data["rrPathName"]) if self.sync_workfile_version: data["version"] = context.data["version"] for _instance in context: if _instance.data['family'] == "workfile": _instance.data["version"] = context.data["version"] # Define nice label label = "{0} ({1})".format(layer_name, instance.data["asset"]) label += " [{0}-{1}]".format( int(data["frameStartHandle"]), int(data["frameEndHandle"]) ) data["label"] = label # Override frames should be False if extendFrames is False. This is # to ensure it doesn't go off doing crazy unpredictable things extend_frames = instance.data.get("extendFrames", False) if not extend_frames: instance.data["overrideExistingFrame"] = False # Update the instace instance.data.update(data) @staticmethod def get_render_attribute(attr, layer): """Get attribute from render options. Args: attr (str): name of attribute to be looked up layer (str): name of render layer Returns: Attribute value """ return lib.get_attr_in_layer( "defaultRenderGlobals.{}".format(attr), layer=layer ) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_render_layer_aovs.py ================================================ from maya import cmds import pyblish.api from openpype.hosts.maya.api import lib class CollectRenderLayerAOVS(pyblish.api.InstancePlugin): """Collect all render layer's AOVs / Render Elements that will render. This collector is important to be able to Extend Frames. Technical information: Each renderer uses different logic to work with render passes. VRay - RenderElement Simple node connection to the actual renderLayer node Arnold - AOV: Uses its own render settings node and connects an aiOAV to it Redshift - AOV: Uses its own render settings node and RedshiftAOV node. It is not connected but all AOVs are enabled for all render layers by default. """ order = pyblish.api.CollectorOrder + 0.02 label = "Render Elements / AOVs" hosts = ["maya"] families = ["renderlayer"] def process(self, instance): # Check if Extend Frames is toggled if not instance.data("extendFrames", False): return # Get renderer renderer = instance.data["renderer"] self.log.debug("Renderer found: {}".format(renderer)) rp_node_types = {"vray": ["VRayRenderElement", "VRayRenderElementSet"], "arnold": ["aiAOV"], "redshift": ["RedshiftAOV"]} if renderer not in rp_node_types.keys(): self.log.error("Unsupported renderer found: '{}'".format(renderer)) return result = [] # Collect all AOVs / Render Elements layer = instance.data["renderlayer"] node_type = rp_node_types[renderer] render_elements = cmds.ls(type=node_type) # Check if AOVs / Render Elements are enabled for element in render_elements: enabled = lib.get_attr_in_layer("{}.enabled".format(element), layer=layer) if not enabled: continue pass_name = self.get_pass_name(renderer, element) render_pass = "%s.%s" % (instance.data["subset"], pass_name) result.append(render_pass) self.log.debug("Found {} render elements / AOVs for " "'{}'".format(len(result), instance.data["subset"])) instance.data["renderPasses"] = result def get_pass_name(self, renderer, node): if renderer == "vray": # Get render element pass type vray_node_attr = next(attr for attr in cmds.listAttr(node) if attr.startswith("vray_name")) pass_type = vray_node_attr.rsplit("_", 1)[-1] # Support V-Ray extratex explicit name (if set by user) if pass_type == "extratex": explicit_attr = "{}.vray_explicit_name_extratex".format(node) explicit_name = cmds.getAttr(explicit_attr) if explicit_name: return explicit_name # Node type is in the attribute name but we need to check if value # of the attribute as it can be changed return cmds.getAttr("{}.{}".format(node, vray_node_attr)) elif renderer in ["arnold", "redshift"]: return cmds.getAttr("{}.name".format(node)) else: raise RuntimeError("Unsupported renderer: '{}'".format(renderer)) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_renderable_camera.py ================================================ import pyblish.api from maya import cmds from openpype.hosts.maya.api.lib_rendersetup import get_attr_in_layer class CollectRenderableCamera(pyblish.api.InstancePlugin): """Collect the renderable camera(s) for the render layer""" # Offset to be after renderlayer collection. order = pyblish.api.CollectorOrder + 0.02 label = "Collect Renderable Camera(s)" hosts = ["maya"] families = ["vrayscene_layer", "renderlayer"] def process(self, instance): if "vrayscene_layer" in instance.data.get("families", []): layer = instance.data.get("layer") else: layer = instance.data["renderlayer"] cameras = cmds.ls(type="camera", long=True) renderable = [cam for cam in cameras if get_attr_in_layer("{}.renderable".format(cam), layer)] self.log.debug( "Found renderable cameras %s: %s", len(renderable), renderable ) instance.data["cameras"] = renderable ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_review.py ================================================ from maya import cmds, mel import pyblish.api from openpype.client import get_subset_by_name from openpype.pipeline import KnownPublishError from openpype.hosts.maya.api import lib class CollectReview(pyblish.api.InstancePlugin): """Collect Review data """ order = pyblish.api.CollectorOrder + 0.3 label = 'Collect Review Data' families = ["review"] def process(self, instance): # Get panel. instance.data["panel"] = cmds.playblast( activeEditor=True ).rsplit("|", 1)[-1] # get cameras members = instance.data['setMembers'] self.log.debug('members: {}'.format(members)) cameras = cmds.ls(members, long=True, dag=True, cameras=True) camera = cameras[0] if cameras else None context = instance.context objectset = { i.data.get("instance_node") for i in context } # Collect display lights. display_lights = instance.data.get("displayLights", "default") if display_lights == "project_settings": settings = instance.context.data["project_settings"] settings = settings["maya"]["publish"]["ExtractPlayblast"] settings = settings["capture_preset"]["Viewport Options"] display_lights = settings["displayLights"] # Collect camera focal length. burninDataMembers = instance.data.get("burninDataMembers", {}) if camera is not None: attr = camera + ".focalLength" if lib.get_attribute_input(attr): start = instance.data["frameStart"] end = instance.data["frameEnd"] + 1 time_range = range(int(start), int(end)) focal_length = [cmds.getAttr(attr, time=t) for t in time_range] else: focal_length = cmds.getAttr(attr) burninDataMembers["focalLength"] = focal_length # Account for nested instances like model. reviewable_subsets = list(set(members) & objectset) if reviewable_subsets: if len(reviewable_subsets) > 1: raise KnownPublishError( "Multiple attached subsets for review are not supported. " "Attached: {}".format(", ".join(reviewable_subsets)) ) reviewable_subset = reviewable_subsets[0] self.log.debug( "Subset attached to review: {}".format(reviewable_subset) ) # Find the relevant publishing instance in the current context reviewable_inst = next(inst for inst in context if inst.name == reviewable_subset) data = reviewable_inst.data self.log.debug( 'Adding review family to {}'.format(reviewable_subset) ) if data.get('families'): data['families'].append('review') else: data['families'] = ['review'] data["cameras"] = cameras data['review_camera'] = camera data['frameStartFtrack'] = instance.data["frameStartHandle"] data['frameEndFtrack'] = instance.data["frameEndHandle"] data['frameStartHandle'] = instance.data["frameStartHandle"] data['frameEndHandle'] = instance.data["frameEndHandle"] data['handleStart'] = instance.data["handleStart"] data['handleEnd'] = instance.data["handleEnd"] data["frameStart"] = instance.data["frameStart"] data["frameEnd"] = instance.data["frameEnd"] data['step'] = instance.data['step'] # this (with other time related data) should be set on # representations. Once plugins like Extract Review start # using representations, this should be removed from here # as Extract Playblast is already adding fps to representation. data['fps'] = context.data['fps'] data['review_width'] = instance.data['review_width'] data['review_height'] = instance.data['review_height'] data["isolate"] = instance.data["isolate"] data["panZoom"] = instance.data.get("panZoom", False) data["panel"] = instance.data["panel"] data["displayLights"] = display_lights data["burninDataMembers"] = burninDataMembers for key, value in instance.data["publish_attributes"].items(): data["publish_attributes"][key] = value # The review instance must be active cmds.setAttr(str(instance) + '.active', 1) instance.data['remove'] = True else: project_name = instance.context.data["projectName"] asset_doc = instance.context.data['assetEntity'] task = instance.context.data["task"] legacy_subset_name = task + 'Review' subset_doc = get_subset_by_name( project_name, legacy_subset_name, asset_doc["_id"], fields=["_id"] ) if subset_doc: self.log.debug("Existing subsets found, keep legacy name.") instance.data['subset'] = legacy_subset_name instance.data["cameras"] = cameras instance.data['review_camera'] = camera instance.data['frameStartFtrack'] = \ instance.data["frameStartHandle"] instance.data['frameEndFtrack'] = \ instance.data["frameEndHandle"] instance.data["displayLights"] = display_lights instance.data["burninDataMembers"] = burninDataMembers # this (with other time related data) should be set on # representations. Once plugins like Extract Review start # using representations, this should be removed from here # as Extract Playblast is already adding fps to representation. instance.data["fps"] = instance.context.data["fps"] # make ftrack publishable instance.data.setdefault("families", []).append('ftrack') cmds.setAttr(str(instance) + '.active', 1) # Collect audio playback_slider = mel.eval('$tmpVar=$gPlayBackSlider') audio_name = cmds.timeControl(playback_slider, query=True, sound=True) display_sounds = cmds.timeControl( playback_slider, query=True, displaySound=True ) def get_audio_node_data(node): return { "offset": cmds.getAttr("{}.offset".format(node)), "filename": cmds.getAttr("{}.filename".format(node)) } audio_data = [] if audio_name: audio_data.append(get_audio_node_data(audio_name)) elif display_sounds: start_frame = int(cmds.playbackOptions(query=True, min=True)) end_frame = int(cmds.playbackOptions(query=True, max=True)) for node in cmds.ls(type="audio"): # Check if frame range and audio range intersections, # for whether to include this audio node or not. duration = cmds.getAttr("{}.duration".format(node)) start_audio = cmds.getAttr("{}.offset".format(node)) end_audio = start_audio + duration if start_audio <= end_frame and end_audio > start_frame: audio_data.append(get_audio_node_data(node)) instance.data["audio"] = audio_data ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_rig_sets.py ================================================ import pyblish.api from maya import cmds class CollectRigSets(pyblish.api.InstancePlugin): """Ensure rig contains pipeline-critical content Every rig must contain at least two object sets: "controls_SET" - Set of all animatable controls "out_SET" - Set of all cacheable meshes """ order = pyblish.api.CollectorOrder + 0.05 label = "Collect Rig Sets" hosts = ["maya"] families = ["rig"] accepted_output = ["mesh", "transform"] accepted_controllers = ["transform"] def process(self, instance): # Find required sets by suffix searching = {"controls_SET", "out_SET", "skeletonAnim_SET", "skeletonMesh_SET"} found = {} for node in cmds.ls(instance, exactType="objectSet"): for suffix in searching: if node.endswith(suffix): found[suffix] = node searching.remove(suffix) break if not searching: break self.log.debug("Found sets: {}".format(found)) rig_sets = instance.data.setdefault("rig_sets", {}) for name, objset in found.items(): rig_sets[name] = objset ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py ================================================ # -*- coding: utf-8 -*- from maya import cmds # noqa import pyblish.api class CollectSkeletonMesh(pyblish.api.InstancePlugin): """Collect Static Rig Data for FBX Extractor.""" order = pyblish.api.CollectorOrder + 0.2 label = "Collect Skeleton Mesh" hosts = ["maya"] families = ["rig"] def process(self, instance): skeleton_mesh_set = instance.data["rig_sets"].get( "skeletonMesh_SET") if not skeleton_mesh_set: self.log.debug( "No skeletonMesh_SET found. " "Skipping collecting of skeleton mesh..." ) return # Store current frame to ensure single frame export frame = cmds.currentTime(query=True) instance.data["frameStart"] = frame instance.data["frameEnd"] = frame instance.data["skeleton_mesh"] = [] skeleton_mesh_content = cmds.sets( skeleton_mesh_set, query=True) or [] if not skeleton_mesh_content: self.log.debug( "No object nodes in skeletonMesh_SET. " "Skipping collecting of skeleton mesh..." ) return instance.data["families"] += ["rig.fbx"] instance.data["skeleton_mesh"] = skeleton_mesh_content self.log.debug( "Collected skeletonMesh_SET members: {}".format( skeleton_mesh_content )) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py ================================================ # -*- coding: utf-8 -*- from maya import cmds # noqa import pyblish.api class CollectUnrealSkeletalMesh(pyblish.api.InstancePlugin): """Collect Unreal Skeletal Mesh.""" order = pyblish.api.CollectorOrder + 0.2 label = "Collect Unreal Skeletal Meshes" families = ["skeletalMesh"] def process(self, instance): frame = cmds.currentTime(query=True) instance.data["frameStart"] = frame instance.data["frameEnd"] = frame geo_sets = [ i for i in instance[:] if i.lower().startswith("geometry_set") ] joint_sets = [ i for i in instance[:] if i.lower().startswith("joints_set") ] instance.data["geometry"] = [] instance.data["joints"] = [] for geo_set in geo_sets: geo_content = cmds.ls(cmds.sets(geo_set, query=True), long=True) if geo_content: instance.data["geometry"] += geo_content for join_set in joint_sets: join_content = cmds.ls(cmds.sets(join_set, query=True), long=True) if join_content: instance.data["joints"] += join_content ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py ================================================ # -*- coding: utf-8 -*- from maya import cmds # noqa import pyblish.api from pprint import pformat class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): """Collect Unreal Static Mesh.""" order = pyblish.api.CollectorOrder + 0.2 label = "Collect Unreal Static Meshes" families = ["staticMesh"] def process(self, instance): geometry_set = [ i for i in instance if i.startswith("geometry_SET") ] instance.data["geometryMembers"] = cmds.sets( geometry_set, query=True) self.log.debug("geometry: {}".format( pformat(instance.data.get("geometryMembers")))) collision_set = [ i for i in instance if i.startswith("collisions_SET") ] instance.data["collisionMembers"] = cmds.sets( collision_set, query=True) self.log.debug("collisions: {}".format( pformat(instance.data.get("collisionMembers")))) frame = cmds.currentTime(query=True) instance.data["frameStart"] = frame instance.data["frameEnd"] = frame ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py ================================================ from maya import cmds import pyblish.api class CollectUserDefinedAttributes(pyblish.api.InstancePlugin): """Collect user defined attributes for nodes in instance.""" order = pyblish.api.CollectorOrder + 0.45 families = ["pointcache", "animation", "usd"] label = "Collect User Defined Attributes" hosts = ["maya"] def process(self, instance): # Collect user defined attributes. if not instance.data.get("includeUserDefinedAttributes", False): return if "out_hierarchy" in instance.data: # animation family nodes = instance.data["out_hierarchy"] else: nodes = instance[:] if not nodes: return shapes = cmds.listRelatives(nodes, shapes=True, fullPath=True) or [] nodes = set(nodes).union(shapes) attrs = cmds.listAttr(list(nodes), userDefined=True) or [] user_defined_attributes = list(sorted(set(attrs))) instance.data["userDefinedAttributes"] = user_defined_attributes self.log.debug( "Collected user defined attributes: {}".format( ", ".join(user_defined_attributes) ) ) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_vrayproxy.py ================================================ # -*- coding: utf-8 -*- """Collect Vray Proxy.""" import pyblish.api class CollectVrayProxy(pyblish.api.InstancePlugin): """Collect Vray Proxy instance. Add `pointcache` family for it. """ order = pyblish.api.CollectorOrder + 0.01 label = "Collect Vray Proxy" families = ["vrayproxy"] def process(self, instance): """Collector entry point.""" if not instance.data.get('families'): instance.data["families"] = [] if instance.data.get("vrmesh"): instance.data["families"].append("vrayproxy.vrmesh") if instance.data.get("alembic"): instance.data["families"].append("vrayproxy.alembic") ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_vrayscene.py ================================================ # -*- coding: utf-8 -*- """Collect Vray Scene and prepare it for extraction and publishing.""" import re import maya.app.renderSetup.model.renderSetup as renderSetup from maya import cmds import pyblish.api from openpype.pipeline import legacy_io from openpype.lib import get_formatted_current_time from openpype.hosts.maya.api import lib class CollectVrayScene(pyblish.api.InstancePlugin): """Collect Vray Scene. If export on farm is checked, job is created to export it. """ order = pyblish.api.CollectorOrder + 0.01 label = "Collect Vray Scene" families = ["vrayscene"] def process(self, instance): """Collector entry point.""" context = instance.context layer = instance.data["transientData"]["layer"] layer_name = layer.name() renderer = self.get_render_attribute("currentRenderer", layer=layer_name) if renderer != "vray": self.log.warning("Layer '{}' renderer is not set to V-Ray".format( layer_name )) # collect all frames we are expecting to be rendered frame_start_render = int(self.get_render_attribute( "startFrame", layer=layer_name)) frame_end_render = int(self.get_render_attribute( "endFrame", layer=layer_name)) if (int(context.data['frameStartHandle']) == frame_start_render and int(context.data['frameEndHandle']) == frame_end_render): # noqa: W503, E501 handle_start = context.data['handleStart'] handle_end = context.data['handleEnd'] frame_start = context.data['frameStart'] frame_end = context.data['frameEnd'] frame_start_handle = context.data['frameStartHandle'] frame_end_handle = context.data['frameEndHandle'] else: handle_start = 0 handle_end = 0 frame_start = frame_start_render frame_end = frame_end_render frame_start_handle = frame_start_render frame_end_handle = frame_end_render # Get layer specific settings, might be overrides data = { "subset": layer_name, "layer": layer_name, # TODO: This likely needs fixing now # Before refactor: cmds.sets(layer, q=True) or ["*"] "setMembers": ["*"], "review": False, "publish": True, "handleStart": handle_start, "handleEnd": handle_end, "frameStart": frame_start, "frameEnd": frame_end, "frameStartHandle": frame_start_handle, "frameEndHandle": frame_end_handle, "byFrameStep": int( self.get_render_attribute("byFrameStep", layer=layer_name)), "renderer": renderer, # instance subset "family": "vrayscene_layer", "families": ["vrayscene_layer"], "time": get_formatted_current_time(), "author": context.data["user"], # Add source to allow tracing back to the scene from # which was submitted originally "source": context.data["currentFile"].replace("\\", "/"), "resolutionWidth": lib.get_attr_in_layer( "defaultResolution.height", layer=layer_name ), "resolutionHeight": lib.get_attr_in_layer( "defaultResolution.width", layer=layer_name ), "pixelAspect": lib.get_attr_in_layer( "defaultResolution.pixelAspect", layer=layer_name ), "priority": instance.data.get("priority"), "useMultipleSceneFiles": instance.data.get( "vraySceneMultipleFiles") } instance.data.update(data) # Define nice label label = "{0} ({1})".format(layer_name, instance.data["asset"]) label += " [{0}-{1}]".format( int(data["frameStartHandle"]), int(data["frameEndHandle"]) ) instance.data["label"] = label def get_render_attribute(self, attr, layer): """Get attribute from render options. Args: attr (str): name of attribute to be looked up. Returns: Attribute value """ return lib.get_attr_in_layer( "defaultRenderGlobals.{}".format(attr), layer=layer ) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_workfile.py ================================================ import os import pyblish.api class CollectWorkfileData(pyblish.api.InstancePlugin): """Inject data into Workfile instance""" order = pyblish.api.CollectorOrder - 0.01 label = "Maya Workfile" hosts = ['maya'] families = ["workfile"] def process(self, instance): """Inject the current working file""" context = instance.context current_file = instance.context.data['currentFile'] folder, file = os.path.split(current_file) filename, ext = os.path.splitext(file) data = { # noqa "setMembers": [current_file], "frameStart": context.data['frameStart'], "frameEnd": context.data['frameEnd'], "handleStart": context.data['handleStart'], "handleEnd": context.data['handleEnd'] } data['representations'] = [{ 'name': ext.lstrip("."), 'ext': ext.lstrip("."), 'files': file, "stagingDir": folder, }] instance.data.update(data) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_workscene_fps.py ================================================ import pyblish.api from maya import mel class CollectWorksceneFPS(pyblish.api.ContextPlugin): """Get the FPS of the work scene""" label = "Workscene FPS" order = pyblish.api.CollectorOrder hosts = ["maya"] def process(self, context): fps = mel.eval('currentTimeUnitToFPS()') self.log.info("Workscene FPS: %s" % fps) context.data.update({"fps": fps}) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_xgen.py ================================================ import os from maya import cmds import pyblish.api from openpype.hosts.maya.api.lib import get_attribute_input class CollectXgen(pyblish.api.InstancePlugin): """Collect Xgen""" order = pyblish.api.CollectorOrder + 0.499999 label = "Collect Xgen" families = ["xgen"] def process(self, instance): data = { "xgmPalettes": cmds.ls(instance, type="xgmPalette", long=True), "xgmDescriptions": cmds.ls( instance, type="xgmDescription", long=True ), "xgmSubdPatches": cmds.ls(instance, type="xgmSubdPatch", long=True) } data["xgenNodes"] = ( data["xgmPalettes"] + data["xgmDescriptions"] + data["xgmSubdPatches"] ) if data["xgmPalettes"]: data["xgmPalette"] = data["xgmPalettes"][0] data["xgenConnections"] = set() for node in data["xgmSubdPatches"]: connected_transform = get_attribute_input( node + ".transform" ).split(".")[0] data["xgenConnections"].add(connected_transform) # Collect all files under palette root as resources. import xgenm data_path = xgenm.getAttr( "xgDataPath", data["xgmPalette"].replace("|", "") ).split(os.pathsep)[0] data_path = data_path.replace( "${PROJECT}", xgenm.getAttr("xgProjectPath", data["xgmPalette"].replace("|", "")) ) transfers = [] # Since we are duplicating this palette when extracting we predict that # the name will be the basename without namespaces. predicted_palette_name = data["xgmPalette"].split(":")[-1] predicted_palette_name = predicted_palette_name.replace("|", "") for root, _, files in os.walk(data_path): for file in files: source = os.path.join(root, file).replace("\\", "/") destination = os.path.join( instance.data["resourcesDir"], "collections", predicted_palette_name, source.replace(data_path, "")[1:] ) transfers.append((source, destination.replace("\\", "/"))) data["transfers"] = transfers self.log.debug(data) instance.data.update(data) ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_yeti_cache.py ================================================ from maya import cmds import pyblish.api from openpype.hosts.maya.api import lib SETTINGS = { # Preview "displayOutput", "colorR", "colorG", "colorB", "viewportDensity", "viewportWidth", "viewportLength", # Render attributes "renderDensity", "renderWidth", "renderLength", "increaseRenderBounds", "imageSearchPath", # Pipeline specific "cbId" } class CollectYetiCache(pyblish.api.InstancePlugin): """Collect all information of the Yeti caches The information contains the following attributes per Yeti node - "renderDensity" - "renderWidth" - "renderLength" - "increaseRenderBounds" - "imageSearchPath" Other information is the name of the transform and it's Colorbleed ID """ order = pyblish.api.CollectorOrder + 0.45 label = "Collect Yeti Cache" families = ["yetiRig", "yeticache", "yeticacheUE"] hosts = ["maya"] def process(self, instance): # Collect fur settings settings = {"nodes": []} # Get yeti nodes and their transforms yeti_shapes = cmds.ls(instance, type="pgYetiMaya") for shape in yeti_shapes: # Get specific node attributes attr_data = {} for attr in SETTINGS: current = cmds.getAttr("%s.%s" % (shape, attr)) # change None to empty string as Maya doesn't support # NoneType in attributes if current is None: current = "" attr_data[attr] = current # Get transform data parent = cmds.listRelatives(shape, parent=True)[0] transform_data = {"name": parent, "cbId": lib.get_id(parent)} shape_data = { "transform": transform_data, "name": shape, "cbId": lib.get_id(shape), "attrs": attr_data, } settings["nodes"].append(shape_data) instance.data["fursettings"] = settings ================================================ FILE: openpype/hosts/maya/plugins/publish/collect_yeti_rig.py ================================================ import os import re from maya import cmds import pyblish.api from openpype.hosts.maya.api import lib from openpype.pipeline.publish import KnownPublishError SETTINGS = {"renderDensity", "renderWidth", "renderLength", "increaseRenderBounds", "imageSearchPath", "cbId"} class CollectYetiRig(pyblish.api.InstancePlugin): """Collect all information of the Yeti Rig""" order = pyblish.api.CollectorOrder + 0.4 label = "Collect Yeti Rig" families = ["yetiRig"] hosts = ["maya"] def process(self, instance): assert "input_SET" in instance.data["setMembers"], ( "Yeti Rig must have an input_SET") input_connections = self.collect_input_connections(instance) # Collect any textures if used yeti_resources = [] yeti_nodes = cmds.ls(instance[:], type="pgYetiMaya", long=True) for node in yeti_nodes: # Get Yeti resources (textures) resources = self.get_yeti_resources(node) yeti_resources.extend(resources) instance.data["rigsettings"] = {"inputs": input_connections} instance.data["resources"] = yeti_resources # Force frame range for yeti cache export for the rig start = cmds.playbackOptions(query=True, animationStartTime=True) for key in ["frameStart", "frameEnd", "frameStartHandle", "frameEndHandle"]: instance.data[key] = start instance.data["preroll"] = 0 def collect_input_connections(self, instance): """Collect the inputs for all nodes in the input_SET""" # Get the input meshes information input_content = cmds.ls(cmds.sets("input_SET", query=True), long=True) # Include children input_content += cmds.listRelatives(input_content, allDescendents=True, fullPath=True) or [] # Ignore intermediate objects input_content = cmds.ls(input_content, long=True, noIntermediate=True) if not input_content: return [] # Store all connections connections = cmds.listConnections(input_content, source=True, destination=False, connections=True, # Only allow inputs from dagNodes # (avoid display layers, etc.) type="dagNode", plugs=True) or [] connections = cmds.ls(connections, long=True) # Ensure long names inputs = [] for dest, src in lib.pairwise(connections): source_node, source_attr = src.split(".", 1) dest_node, dest_attr = dest.split(".", 1) # Ensure the source of the connection is not included in the # current instance's hierarchy. If so, we ignore that connection # as we will want to preserve it even over a publish. if source_node in instance: self.log.debug("Ignoring input connection between nodes " "inside the instance: %s -> %s" % (src, dest)) continue inputs.append({"connections": [source_attr, dest_attr], "sourceID": lib.get_id(source_node), "destinationID": lib.get_id(dest_node)}) return inputs def get_yeti_resources(self, node): """Get all resource file paths If a texture is a sequence it gathers all sibling files to ensure the texture sequence is complete. References can be used in the Yeti graph, this means that it is possible to load previously caches files. The information will need to be stored and, if the file not publish, copied to the resource folder. Args: node (str): node name of the pgYetiMaya node Returns: list """ resources = [] image_search_paths = cmds.getAttr("{}.imageSearchPath".format(node)) if image_search_paths: # TODO: Somehow this uses OS environment path separator, `:` vs `;` # Later on check whether this is pipeline OS cross-compatible. image_search_paths = [p for p in image_search_paths.split(os.path.pathsep) if p] # find all ${TOKEN} tokens and replace them with $TOKEN env. variable image_search_paths = self._replace_tokens(image_search_paths) # List all related textures texture_nodes = cmds.pgYetiGraph( node, listNodes=True, type="texture") texture_filenames = [ cmds.pgYetiGraph( node, node=texture_node, param="file_name", getParamValue=True) for texture_node in texture_nodes ] self.log.debug("Found %i texture(s)" % len(texture_filenames)) # Get all reference nodes reference_nodes = cmds.pgYetiGraph(node, listNodes=True, type="reference") self.log.debug("Found %i reference node(s)" % len(reference_nodes)) # Collect all texture files # find all ${TOKEN} tokens and replace them with $TOKEN env. variable texture_filenames = self._replace_tokens(texture_filenames) for texture in texture_filenames: files = [] if os.path.isabs(texture): self.log.debug("Texture is absolute path, ignoring " "image search paths for: %s" % texture) files = self.search_textures(texture) else: for root in image_search_paths: filepath = os.path.join(root, texture) files = self.search_textures(filepath) if files: # Break out on first match in search paths.. break if not files: raise KnownPublishError( "No texture found for: %s " "(searched: %s)" % (texture, image_search_paths)) item = { "files": files, "source": texture, "node": node } resources.append(item) # For now validate that every texture has at least a single file # resolved. Since a 'resource' does not have the requirement of having # a `files` explicitly mapped it's not explicitly validated. # TODO: Validate this as a validator invalid_resources = [] for resource in resources: if not resource['files']: invalid_resources.append(resource) if invalid_resources: raise RuntimeError("Invalid resources") # Collect all referenced files for reference_node in reference_nodes: ref_file = cmds.pgYetiGraph(node, node=reference_node, param="reference_file", getParamValue=True) # Create resource dict item = { "source": ref_file, "node": node, "graphnode": reference_node, "param": "reference_file", "files": [] } ref_file_name = os.path.basename(ref_file) if "%04d" in ref_file_name: item["files"] = self.get_sequence(ref_file) else: if os.path.exists(ref_file) and os.path.isfile(ref_file): item["files"] = [ref_file] if not item["files"]: self.log.warning("Reference node '%s' has no valid file " "path set: %s" % (reference_node, ref_file)) # TODO: This should allow to pass and fail in Validator instead raise RuntimeError("Reference node must be a full file path!") resources.append(item) return resources def search_textures(self, filepath): """Search all texture files on disk. This also parses to full sequences for those with dynamic patterns like and %04d in the filename. Args: filepath (str): The full path to the file, including any dynamic patterns like or %04d Returns: list: The files found on disk """ filename = os.path.basename(filepath) # Collect full sequence if it matches a sequence pattern if len(filename.split(".")) > 2: # For UDIM based textures (tiles) if "" in filename: sequences = self.get_sequence(filepath, pattern="") if sequences: return sequences # Frame/time - Based textures (animated masks f.e) elif "%04d" in filename: sequences = self.get_sequence(filepath, pattern="%04d") if sequences: return sequences # Assuming it is a fixed name (single file) if os.path.exists(filepath): return [filepath] return [] def get_sequence(self, filepath, pattern="%04d"): """Get sequence from filename. This will only return files if they exist on disk as it tries to collect the sequence using the filename pattern and searching for them on disk. Supports negative frame ranges like -001, 0000, 0001 and -0001, 0000, 0001. Arguments: filepath (str): The full path to filename containing the given pattern. pattern (str): The pattern to swap with the variable frame number. Returns: list: file sequence. """ import clique escaped = re.escape(filepath) re_pattern = escaped.replace(pattern, "-?[0-9]+") source_dir = os.path.dirname(filepath) files = [f for f in os.listdir(source_dir) if re.match(re_pattern, f)] pattern = [clique.PATTERNS["frames"]] collection, remainder = clique.assemble(files, patterns=pattern) return collection def _replace_tokens(self, strings): env_re = re.compile(r"\$\{(\w+)\}") replaced = [] for s in strings: matches = re.finditer(env_re, s) for m in matches: try: s = s.replace(m.group(), os.environ[m.group(1)]) except KeyError: msg = "Cannot find requested {} in environment".format( m.group(1)) self.log.error(msg) raise RuntimeError(msg) replaced.append(s) return replaced ================================================ FILE: openpype/hosts/maya/plugins/publish/determine_future_version.py ================================================ import pyblish class DetermineFutureVersion(pyblish.api.InstancePlugin): """ This will determine version of subset if we want render to be attached to. """ label = "Determine Subset Version" order = pyblish.api.IntegratorOrder hosts = ["maya"] families = ["renderlayer"] def process(self, instance): context = instance.context attach_to_subsets = [s["subset"] for s in instance.data['attachTo']] if not attach_to_subsets: return for i in context: if i.data["subset"] in attach_to_subsets: # # this will get corresponding subset in attachTo list # # so we can set version there sub = next(item for item in instance.data['attachTo'] if item["subset"] == i.data["subset"]) # noqa: E501 sub["version"] = i.data.get("version", 1) self.log.info("render will be attached to {} v{}".format( sub["subset"], sub["version"] )) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_active_view_thumbnail.py ================================================ import maya.api.OpenMaya as om import maya.api.OpenMayaUI as omui import pyblish.api import tempfile from openpype.hosts.maya.api.lib import IS_HEADLESS class ExtractActiveViewThumbnail(pyblish.api.InstancePlugin): """Set instance thumbnail to a screengrab of current active viewport. This makes it so that if an instance does not have a thumbnail set yet that it will get a thumbnail of the currently active view at the time of publishing as a fallback. """ order = pyblish.api.ExtractorOrder + 0.49 label = "Active View Thumbnail" families = ["workfile"] hosts = ["maya"] def process(self, instance): if IS_HEADLESS: self.log.debug( "Skip extraction of active view thumbnail, due to being in" "headless mode." ) return thumbnail = instance.data.get("thumbnailPath") if not thumbnail: view_thumbnail = self.get_view_thumbnail(instance) if not view_thumbnail: return self.log.debug("Setting instance thumbnail path to: {}".format( view_thumbnail )) instance.data["thumbnailPath"] = view_thumbnail def get_view_thumbnail(self, instance): cache_key = "__maya_view_thumbnail" context = instance.context if cache_key not in context.data: # Generate only a single thumbnail, even for multiple instances with tempfile.NamedTemporaryFile(suffix="_thumbnail.jpg", delete=False) as f: path = f.name view = omui.M3dView.active3dView() image = om.MImage() view.readColorBuffer(image, True) image.writeToFile(path, "jpg") self.log.debug("Generated thumbnail: {}".format(path)) context.data["cleanupFullPaths"].append(path) context.data[cache_key] = path return context.data[cache_key] ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py ================================================ import os from collections import defaultdict import json from maya import cmds import arnold from openpype.pipeline import publish from openpype.hosts.maya.api import lib class ExtractArnoldSceneSource(publish.Extractor): """Extract the content of the instance to an Arnold Scene Source file.""" label = "Extract Arnold Scene Source" hosts = ["maya"] families = ["ass"] asciiAss = True def _pre_process(self, instance, staging_dir): file_path = os.path.join(staging_dir, "{}.ass".format(instance.name)) # Mask mask = arnold.AI_NODE_ALL node_types = { "options": arnold.AI_NODE_OPTIONS, "camera": arnold.AI_NODE_CAMERA, "light": arnold.AI_NODE_LIGHT, "shape": arnold.AI_NODE_SHAPE, "shader": arnold.AI_NODE_SHADER, "override": arnold.AI_NODE_OVERRIDE, "driver": arnold.AI_NODE_DRIVER, "filter": arnold.AI_NODE_FILTER, "color_manager": arnold.AI_NODE_COLOR_MANAGER, "operator": arnold.AI_NODE_OPERATOR } for key in node_types.keys(): if instance.data.get("mask" + key.title()): mask = mask ^ node_types[key] # Motion blur attribute_data = { "defaultArnoldRenderOptions.motion_blur_enable": instance.data.get( "motionBlur", True ), "defaultArnoldRenderOptions.motion_steps": instance.data.get( "motionBlurKeys", 2 ), "defaultArnoldRenderOptions.motion_frames": instance.data.get( "motionBlurLength", 0.5 ) } # Write out .ass file kwargs = { "filename": file_path, "startFrame": instance.data.get("frameStartHandle", 1), "endFrame": instance.data.get("frameEndHandle", 1), "frameStep": instance.data.get("step", 1), "selected": True, "asciiAss": self.asciiAss, "shadowLinks": True, "lightLinks": True, "boundingBox": True, "expandProcedurals": instance.data.get("expandProcedurals", False), "camera": instance.data["camera"], "mask": mask } if "representations" not in instance.data: instance.data["representations"] = [] return attribute_data, kwargs def process(self, instance): staging_dir = self.staging_dir(instance) attribute_data, kwargs = self._pre_process(instance, staging_dir) filenames = self._extract( instance.data["members"], attribute_data, kwargs ) self._post_process( instance, filenames, staging_dir, kwargs["startFrame"] ) def _post_process(self, instance, filenames, staging_dir, frame_start): nodes_by_id = self._nodes_by_id(instance[:]) representation = { "name": "ass", "ext": "ass", "files": filenames if len(filenames) > 1 else filenames[0], "stagingDir": staging_dir, "frameStart": frame_start } instance.data["representations"].append(representation) json_path = os.path.join( staging_dir, "{}.json".format(instance.name) ) with open(json_path, "w") as f: json.dump(nodes_by_id, f) representation = { "name": "json", "ext": "json", "files": os.path.basename(json_path), "stagingDir": staging_dir } instance.data["representations"].append(representation) self.log.debug( "Extracted instance {} to: {}".format(instance.name, staging_dir) ) def _nodes_by_id(self, nodes): nodes_by_id = defaultdict(list) for node in nodes: id = lib.get_id(node) if id is None: continue # Converting Maya hierarchy separator "|" to Arnold separator "/". nodes_by_id[id].append(node.replace("|", "/")) return nodes_by_id def _extract(self, nodes, attribute_data, kwargs): filenames = [] with lib.attribute_values(attribute_data): with lib.maintained_selection(): self.log.debug( "Writing: {}".format(nodes) ) cmds.select(nodes, noExpand=True) self.log.debug( "Extracting ass sequence with: {}".format(kwargs) ) exported_files = cmds.arnoldExportAss(**kwargs) for file in exported_files: filenames.append(os.path.split(file)[1]) self.log.debug("Exported: {}".format(filenames)) return filenames class ExtractArnoldSceneSourceProxy(ExtractArnoldSceneSource): """Extract the content of the instance to an Arnold Scene Source file.""" label = "Extract Arnold Scene Source Proxy" hosts = ["maya"] families = ["assProxy"] asciiAss = True def process(self, instance): staging_dir = self.staging_dir(instance) attribute_data, kwargs = self._pre_process(instance, staging_dir) filenames, _ = self._duplicate_extract( instance.data["members"], attribute_data, kwargs ) self._post_process( instance, filenames, staging_dir, kwargs["startFrame"] ) kwargs["filename"] = os.path.join( staging_dir, "{}_proxy.ass".format(instance.name) ) filenames, _ = self._duplicate_extract( instance.data["proxy"], attribute_data, kwargs ) representation = { "name": "proxy", "ext": "ass", "files": filenames if len(filenames) > 1 else filenames[0], "stagingDir": staging_dir, "frameStart": kwargs["startFrame"], "outputName": "proxy" } instance.data["representations"].append(representation) def _duplicate_extract(self, nodes, attribute_data, kwargs): self.log.debug( "Writing {} with:\n{}".format(kwargs["filename"], kwargs) ) filenames = [] # Duplicating nodes so they are direct children of the world. This # makes the hierarchy of any exported ass file the same. with lib.delete_after() as delete_bin: duplicate_nodes = [] for node in nodes: # Only interested in transforms: if cmds.nodeType(node) != "transform": continue # Only interested in transforms with shapes. shapes = cmds.listRelatives( node, shapes=True, noIntermediate=True ) if not shapes: continue basename = cmds.duplicate(node)[0] parents = cmds.ls(node, long=True)[0].split("|")[:-1] duplicate_transform = "|".join(parents + [basename]) if cmds.listRelatives(duplicate_transform, parent=True): duplicate_transform = cmds.parent( duplicate_transform, world=True )[0] basename = node.rsplit("|", 1)[-1].rsplit(":", 1)[-1] duplicate_transform = cmds.rename( duplicate_transform, basename ) # Discard children nodes that are not shapes shapes = cmds.listRelatives( duplicate_transform, shapes=True, fullPath=True ) children = cmds.listRelatives( duplicate_transform, children=True, fullPath=True ) cmds.delete(set(children) - set(shapes)) duplicate_nodes.append(duplicate_transform) duplicate_nodes.extend(shapes) delete_bin.append(duplicate_transform) nodes_by_id = self._nodes_by_id(duplicate_nodes) filenames = self._extract(duplicate_nodes, attribute_data, kwargs) return filenames, nodes_by_id ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_assembly.py ================================================ import os import json from openpype.pipeline import publish from openpype.hosts.maya.api.lib import extract_alembic from maya import cmds class ExtractAssembly(publish.Extractor): """Produce an alembic of just point positions and normals. Positions and normals are preserved, but nothing more, for plain and predictable point caches. """ label = "Extract Assembly" hosts = ["maya"] families = ["assembly"] def process(self, instance): staging_dir = self.staging_dir(instance) hierarchy_filename = "{}.abc".format(instance.name) hierarchy_path = os.path.join(staging_dir, hierarchy_filename) json_filename = "{}.json".format(instance.name) json_path = os.path.join(staging_dir, json_filename) self.log.debug("Dumping scene data for debugging ..") with open(json_path, "w") as filepath: json.dump(instance.data["scenedata"], filepath, ensure_ascii=False) self.log.debug("Extracting pointcache ..") cmds.select(instance.data["nodesHierarchy"]) # Run basic alembic exporter extract_alembic(file=hierarchy_path, startFrame=1.0, endFrame=1.0, **{"step": 1.0, "attr": ["cbId"], "writeVisibility": True, "writeCreases": True, "uvWrite": True, "selection": True}) if "representations" not in instance.data: instance.data["representations"] = [] representation_abc = { 'name': 'abc', 'ext': 'abc', 'files': hierarchy_filename, "stagingDir": staging_dir } instance.data["representations"].append(representation_abc) representation_json = { 'name': 'json', 'ext': 'json', 'files': json_filename, "stagingDir": staging_dir } instance.data["representations"].append(representation_json) # Remove data instance.data.pop("scenedata", None) cmds.select(clear=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_camera_alembic.py ================================================ import os from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api import lib class ExtractCameraAlembic(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract a Camera as Alembic. The camera gets baked to world space by default. Only when the instance's `bakeToWorldSpace` is set to False it will include its full hierarchy. 'camera' family expects only single camera, if multiple cameras are needed, 'matchmove' is better choice. """ label = "Extract Camera (Alembic)" hosts = ["maya"] families = ["camera", "matchmove"] bake_attributes = [] def process(self, instance): # Collect the start and end including handles start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] step = instance.data.get("step", 1.0) bake_to_worldspace = instance.data("bakeToWorldSpace", True) # get cameras members = instance.data['setMembers'] cameras = cmds.ls(members, leaf=True, long=True, dag=True, type="camera") # validate required settings assert isinstance(step, float), "Step must be a float value" # Define extract output file path dir_path = self.staging_dir(instance) if not os.path.exists(dir_path): os.makedirs(dir_path) filename = "{0}.abc".format(instance.name) path = os.path.join(dir_path, filename) # Perform alembic extraction member_shapes = cmds.ls( members, leaf=True, shapes=True, long=True, dag=True) with lib.maintained_selection(): cmds.select( member_shapes, replace=True, noExpand=True) # Enforce forward slashes for AbcExport because we're # embedding it into a job string path = path.replace("\\", "/") job_str = ' -selection -dataFormat "ogawa" ' job_str += ' -attrPrefix cb' job_str += ' -frameRange {0} {1} '.format(start, end) job_str += ' -step {0} '.format(step) if bake_to_worldspace: job_str += ' -worldSpace' # if baked, drop the camera hierarchy to maintain # clean output and backwards compatibility camera_roots = cmds.listRelatives( cameras, parent=True, fullPath=True) for camera_root in camera_roots: job_str += ' -root {0}'.format(camera_root) for member in members: descendants = cmds.listRelatives(member, allDescendents=True, fullPath=True) or [] shapes = cmds.ls(descendants, shapes=True, noIntermediate=True, long=True) cameras = cmds.ls(shapes, type="camera", long=True) if cameras: if not set(shapes) - set(cameras): continue self.log.warning(( "Camera hierarchy contains additional geometry. " "Extraction will fail.") ) transform = cmds.listRelatives( member, parent=True, fullPath=True) transform = transform[0] if transform else member job_str += ' -root {0}'.format(transform) job_str += ' -file "{0}"'.format(path) # bake specified attributes in preset assert isinstance(self.bake_attributes, (list, tuple)), ( "Attributes to bake must be specified as a list" ) for attr in self.bake_attributes: self.log.debug("Adding {} attribute".format(attr)) job_str += " -attr {0}".format(attr) with lib.evaluation("off"): with lib.suspended_refresh(): cmds.AbcExport(j=job_str, verbose=False) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'abc', 'ext': 'abc', 'files': filename, "stagingDir": dir_path, } instance.data["representations"].append(representation) self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py ================================================ # -*- coding: utf-8 -*- """Extract camera as Maya Scene.""" import os import itertools import contextlib from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api import lib from openpype.lib import ( BoolDef ) def massage_ma_file(path): """Clean up .ma file for backwards compatibility. Massage the .ma of baked camera to stay backwards compatible with older versions of Fusion (6.4) """ # Get open file's lines f = open(path, "r+") lines = f.readlines() f.seek(0) # reset to start of file # Rewrite the file for line in lines: # Skip all 'rename -uid' lines stripped = line.strip() if stripped.startswith("rename -uid "): continue f.write(line) f.truncate() # remove remainder f.close() def grouper(iterable, n, fillvalue=None): """Collect data into fixed-length chunks or blocks. Examples: grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx """ args = [iter(iterable)] * n from six.moves import zip_longest return zip_longest(fillvalue=fillvalue, *args) def unlock(plug): """Unlocks attribute and disconnects inputs for a plug. This will also recursively unlock the attribute upwards to any parent attributes for compound attributes, to ensure it's fully unlocked and free to change the value. """ node, attr = plug.rsplit(".", 1) # Unlock attribute cmds.setAttr(plug, lock=False) # Also unlock any parent attribute (if compound) parents = cmds.attributeQuery(attr, node=node, listParent=True) if parents: for parent in parents: unlock("{0}.{1}".format(node, parent)) # Break incoming connections connections = cmds.listConnections(plug, source=True, destination=False, plugs=True, connections=True) if connections: for destination, source in grouper(connections, 2): cmds.disconnectAttr(source, destination) class ExtractCameraMayaScene(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract a Camera as Maya Scene. This will create a duplicate of the camera that will be baked *with* substeps and handles for the required frames. This temporary duplicate will be published. The cameras gets baked to world space by default. Only when the instance's `bakeToWorldSpace` is set to False it will include its full hierarchy. 'camera' family expects only single camera, if multiple cameras are needed, 'matchmove' is better choice. Note: The extracted Maya ascii file gets "massaged" removing the uuid values so they are valid for older versions of Fusion (e.g. 6.4) """ label = "Extract Camera (Maya Scene)" hosts = ["maya"] families = ["camera", "matchmove"] scene_type = "ma" keep_image_planes = True def process(self, instance): """Plugin entry point.""" # get settings ext_mapping = ( instance.context.data["project_settings"]["maya"]["ext_mapping"] ) if ext_mapping: self.log.debug("Looking in settings for scene type ...") # use extension mapping for first family found for family in self.families: try: self.scene_type = ext_mapping[family] self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: # no preset found pass # Collect the start and end including handles start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] step = instance.data.get("step", 1.0) bake_to_worldspace = instance.data("bakeToWorldSpace", True) if not bake_to_worldspace: self.log.warning("Camera (Maya Scene) export only supports world" "space baked camera extractions. The disabled " "bake to world space is ignored...") # get cameras members = set(cmds.ls(instance.data['setMembers'], leaf=True, shapes=True, long=True, dag=True)) cameras = set(cmds.ls(members, leaf=True, shapes=True, long=True, dag=True, type="camera")) # validate required settings assert isinstance(step, float), "Step must be a float value" transforms = cmds.listRelatives(list(cameras), parent=True, fullPath=True) # Define extract output file path dir_path = self.staging_dir(instance) filename = "{0}.{1}".format(instance.name, self.scene_type) path = os.path.join(dir_path, filename) # Perform extraction with lib.maintained_selection(): with lib.evaluation("off"): with lib.suspended_refresh(): if bake_to_worldspace: baked = lib.bake_to_world_space( transforms, frame_range=[start, end], step=step ) baked_camera_shapes = set(cmds.ls(baked, type="camera", dag=True, shapes=True, long=True)) members.update(baked_camera_shapes) members.difference_update(cameras) else: baked_camera_shapes = cmds.ls(list(cameras), type="camera", dag=True, shapes=True, long=True) attrs = {"backgroundColorR": 0.0, "backgroundColorG": 0.0, "backgroundColorB": 0.0, "overscan": 1.0} # Fix PLN-178: Don't allow background color to be non-black for cam, (attr, value) in itertools.product(cmds.ls( baked_camera_shapes, type="camera", dag=True, long=True), attrs.items()): plug = "{0}.{1}".format(cam, attr) unlock(plug) cmds.setAttr(plug, value) attr_values = self.get_attr_values_from_data( instance.data) keep_image_planes = attr_values.get("keep_image_planes") with transfer_image_planes(sorted(cameras), sorted(baked_camera_shapes), keep_image_planes): self.log.info("Performing extraction..") cmds.select(cmds.ls(list(members), dag=True, shapes=True, long=True), noExpand=True) cmds.file(path, force=True, typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 exportSelected=True, preserveReferences=False, constructionHistory=False, channels=True, # allow animation constraints=False, shader=False, expressions=False) # Delete the baked hierarchy if bake_to_worldspace: cmds.delete(baked) if self.scene_type == "ma": massage_ma_file(path) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': self.scene_type, 'ext': self.scene_type, 'files': filename, "stagingDir": dir_path, } instance.data["representations"].append(representation) self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, path)) @classmethod def get_attribute_defs(cls): defs = super(ExtractCameraMayaScene, cls).get_attribute_defs() defs.extend([ BoolDef("keep_image_planes", label="Keep Image Planes", tooltip="Preserving connected image planes on camera", default=cls.keep_image_planes), ]) return defs @contextlib.contextmanager def transfer_image_planes(source_cameras, target_cameras, keep_input_connections): """Reattaches image planes to baked or original cameras. Baked cameras are duplicates of original ones. This attaches it to duplicated camera properly and after export it reattaches it back to original to keep image plane in workfile. """ originals = {} try: for source_camera, target_camera in zip(source_cameras, target_cameras): image_plane_plug = "{}.imagePlane".format(source_camera) image_planes = cmds.listConnections(image_plane_plug, source=True, destination=False, type="imagePlane") or [] # Split of the parent path they are attached - we want # the image plane node name if attached to a camera. # TODO: Does this still mean the image plane name is unique? image_planes = [x.split("->", 1)[-1] for x in image_planes] if not image_planes: continue originals[source_camera] = [] for image_plane in image_planes: if keep_input_connections: if source_camera == target_camera: continue _attach_image_plane(target_camera, image_plane) else: # explicitly detach image planes cmds.imagePlane(image_plane, edit=True, detach=True) originals[source_camera].append(image_plane) yield finally: for camera, image_planes in originals.items(): for image_plane in image_planes: _attach_image_plane(camera, image_plane) def _attach_image_plane(camera, image_plane): cmds.imagePlane(image_plane, edit=True, detach=True) cmds.imagePlane(image_plane, edit=True, camera=camera) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_fbx.py ================================================ # -*- coding: utf-8 -*- import os from maya import cmds # noqa import maya.mel as mel # noqa import pyblish.api from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection from openpype.hosts.maya.api import fbx class ExtractFBX(publish.Extractor): """Extract FBX from Maya. This extracts reproducible FBX exports ignoring any of the settings set on the local machine in the FBX export options window. """ order = pyblish.api.ExtractorOrder label = "Extract FBX" families = ["fbx"] def process(self, instance): fbx_exporter = fbx.FBXExtractor(log=self.log) # Define output path staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) path = os.path.join(staging_dir, filename) # The export requires forward slashes because we need # to format it into a string in a mel expression path = path.replace('\\', '/') self.log.debug("Extracting FBX to: {0}".format(path)) members = instance.data["setMembers"] self.log.debug("Members: {0}".format(members)) self.log.debug("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) # Export with maintained_selection(): fbx_exporter.export(members, path) cmds.select(members, r=1, noExpand=True) mel.eval('FBXExport -f "{}" -s'.format(path)) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'fbx', 'ext': 'fbx', 'files': filename, "stagingDir": staging_dir, } instance.data["representations"].append(representation) self.log.debug("Extract FBX successful to: {0}".format(path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_fbx_animation.py ================================================ # -*- coding: utf-8 -*- import os from maya import cmds # noqa import pyblish.api from openpype.pipeline import publish from openpype.hosts.maya.api import fbx from openpype.hosts.maya.api.lib import ( namespaced, get_namespace, strip_namespace ) class ExtractFBXAnimation(publish.Extractor): """Extract Rig in FBX format from Maya. This extracts the rig in fbx with the constraints and referenced asset content included. This also optionally extract animated rig in fbx with geometries included. """ order = pyblish.api.ExtractorOrder label = "Extract Animation (FBX)" hosts = ["maya"] families = ["animation.fbx"] def process(self, instance): # Define output path staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) path = os.path.join(staging_dir, filename) path = path.replace("\\", "/") fbx_exporter = fbx.FBXExtractor(log=self.log) out_members = instance.data.get("animated_skeleton", []) # Export instance.data["constraints"] = True instance.data["skeletonDefinitions"] = True instance.data["referencedAssetsContent"] = True fbx_exporter.set_options_from_instance(instance) # Export from the rig's namespace so that the exported # FBX does not include the namespace but preserves the node # names as existing in the rig workfile if not out_members: skeleton_set = [ i for i in instance if i.endswith("skeletonAnim_SET") ] self.log.debug( "Top group of animated skeleton not found in " "{}.\nSkipping fbx animation extraction.".format(skeleton_set)) return namespace = get_namespace(out_members[0]) relative_out_members = [ strip_namespace(node, namespace) for node in out_members ] with namespaced( ":" + namespace, new=False, relative_names=True ) as namespace: fbx_exporter.export(relative_out_members, path) representations = instance.data.setdefault("representations", []) representations.append({ 'name': 'fbx', 'ext': 'fbx', 'files': filename, "stagingDir": staging_dir }) self.log.debug( "Extracted FBX animation to: {0}".format(path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_gltf.py ================================================ import os from maya import cmds, mel import pyblish.api from openpype.pipeline import publish from openpype.hosts.maya.api import lib from openpype.hosts.maya.api.gltf import extract_gltf class ExtractGLB(publish.Extractor): order = pyblish.api.ExtractorOrder hosts = ["maya"] label = "Extract GLB" families = ["gltf"] def process(self, instance): staging_dir = self.staging_dir(instance) filename = "{0}.glb".format(instance.name) path = os.path.join(staging_dir, filename) cmds.loadPlugin("maya2glTF", quiet=True) nodes = instance[:] start_frame = instance.data('frameStart') or \ int(cmds.playbackOptions(query=True, animationStartTime=True))# noqa end_frame = instance.data('frameEnd') or \ int(cmds.playbackOptions(query=True, animationEndTime=True)) # noqa fps = mel.eval('currentTimeUnitToFPS()') options = { "sno": True, # selectedNodeOnly "nbu": True, # .bin instead of .bin0 "ast": start_frame, "aet": end_frame, "afr": fps, "dsa": 1, "acn": instance.name, "glb": True, "vno": True # visibleNodeOnly } self.log.debug("Extracting GLB to: {}".format(path)) with lib.maintained_selection(): cmds.select(nodes, hi=True, noExpand=True) extract_gltf(staging_dir, instance.name, **options) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'glb', 'ext': 'glb', 'files': filename, "stagingDir": staging_dir, } instance.data["representations"].append(representation) self.log.debug("Extract GLB successful to: {0}".format(path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_gpu_cache.py ================================================ import json from maya import cmds from openpype.pipeline import publish class ExtractGPUCache(publish.Extractor): """Extract the content of the instance to a GPU cache file.""" label = "GPU Cache" hosts = ["maya"] families = ["model", "animation", "pointcache"] step = 1.0 stepSave = 1 optimize = True optimizationThreshold = 40000 optimizeAnimationsForMotionBlur = True writeMaterials = True useBaseTessellation = True def process(self, instance): cmds.loadPlugin("gpuCache", quiet=True) staging_dir = self.staging_dir(instance) filename = "{}_gpu_cache".format(instance.name) # Write out GPU cache file. kwargs = { "directory": staging_dir, "fileName": filename, "saveMultipleFiles": False, "simulationRate": self.step, "sampleMultiplier": self.stepSave, "optimize": self.optimize, "optimizationThreshold": self.optimizationThreshold, "optimizeAnimationsForMotionBlur": ( self.optimizeAnimationsForMotionBlur ), "writeMaterials": self.writeMaterials, "useBaseTessellation": self.useBaseTessellation } self.log.debug( "Extract {} with:\n{}".format( instance[:], json.dumps(kwargs, indent=4, sort_keys=True) ) ) cmds.gpuCache(instance[:], **kwargs) if "representations" not in instance.data: instance.data["representations"] = [] representation = { "name": "gpu_cache", "ext": "abc", "files": filename + ".abc", "stagingDir": staging_dir, "outputName": "gpu_cache" } instance.data["representations"].append(representation) self.log.debug( "Extracted instance {} to: {}".format(instance.name, staging_dir) ) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_import_reference.py ================================================ import os import sys from maya import cmds import pyblish.api import tempfile from openpype.lib import run_subprocess from openpype.pipeline import publish from openpype.pipeline.publish import OptionalPyblishPluginMixin from openpype.hosts.maya.api import lib class ExtractImportReference(publish.Extractor, OptionalPyblishPluginMixin): """ Extract the scene with imported reference. The temp scene with imported reference is published for rendering if this extractor is activated """ label = "Extract Import Reference" order = pyblish.api.ExtractorOrder - 0.48 hosts = ["maya"] families = ["renderlayer", "workfile"] optional = True tmp_format = "_tmp" @classmethod def apply_settings(cls, project_settings): cls.active = project_settings["deadline"]["publish"]["MayaSubmitDeadline"]["import_reference"] # noqa def process(self, instance): if not self.is_active(instance.data): return ext_mapping = ( instance.context.data["project_settings"]["maya"]["ext_mapping"] ) if ext_mapping: self.log.debug("Looking in settings for scene type ...") # use extension mapping for first family found for family in self.families: try: self.scene_type = ext_mapping[family] self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: # set scene type to ma self.scene_type = "ma" _scene_type = ("mayaAscii" if self.scene_type == "ma" else "mayaBinary") dir_path = self.staging_dir(instance) # named the file with imported reference if instance.name == "Main": return tmp_name = instance.name + self.tmp_format current_name = cmds.file(query=True, sceneName=True) ref_scene_name = "{0}.{1}".format(tmp_name, self.scene_type) reference_path = os.path.join(dir_path, ref_scene_name) tmp_path = os.path.dirname(current_name) + "/" + ref_scene_name self.log.debug("Performing extraction..") # This generates script for mayapy to take care of reference # importing outside current session. It is passing current scene # name and destination scene name. script = (""" # -*- coding: utf-8 -*- '''Script to import references to given scene.''' import maya.standalone maya.standalone.initialize() # scene names filled by caller current_name = "{current_name}" ref_scene_name = "{ref_scene_name}" print(">>> Opening {{}} ...".format(current_name)) cmds.file(current_name, open=True, force=True) print(">>> Processing references") all_reference = cmds.file(q=True, reference=True) or [] for ref in all_reference: if cmds.referenceQuery(ref, il=True): cmds.file(ref, importReference=True) nested_ref = cmds.file(q=True, reference=True) if nested_ref: for new_ref in nested_ref: if new_ref not in all_reference: all_reference.append(new_ref) print(">>> Finish importing references") print(">>> Saving scene as {{}}".format(ref_scene_name)) cmds.file(rename=ref_scene_name) cmds.file(save=True, force=True) print("*** Done") """).format(current_name=current_name, ref_scene_name=tmp_path) mayapy_exe = os.path.join(os.getenv("MAYA_LOCATION"), "bin", "mayapy") if sys.platform == "windows": mayapy_exe += ".exe" mayapy_exe = os.path.normpath(mayapy_exe) # can't use TemporaryNamedFile as that can't be opened in another # process until handles are closed by context manager. with tempfile.TemporaryDirectory() as tmp_dir_name: tmp_script_path = os.path.join(tmp_dir_name, "import_ref.py") self.log.debug("Using script file: {}".format(tmp_script_path)) with open(tmp_script_path, "wt") as tmp: tmp.write(script) try: run_subprocess([mayapy_exe, tmp_script_path]) except Exception: self.log.error("Import reference failed", exc_info=True) raise with lib.maintained_selection(): cmds.select(all=True, noExpand=True) cmds.file(reference_path, force=True, typ=_scene_type, exportSelected=True, channels=True, constraints=True, shader=True, expressions=True, constructionHistory=True) instance.context.data["currentFile"] = tmp_path if "files" not in instance.data: instance.data["files"] = [] instance.data["files"].append(ref_scene_name) if instance.data.get("representations") is None: instance.data["representations"] = [] ref_representation = { "name": self.scene_type, "ext": self.scene_type, "files": ref_scene_name, "stagingDir": os.path.dirname(current_name), "outputName": "imported" } self.log.debug(ref_representation) instance.data["representations"].append(ref_representation) self.log.debug("Extracted instance '%s' to : '%s'" % (ref_scene_name, reference_path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_layout.py ================================================ import math import os import json from maya import cmds from maya.api import OpenMaya as om from openpype.client import get_representation_by_id from openpype.pipeline import publish class ExtractLayout(publish.Extractor): """Extract a layout.""" label = "Extract Layout" hosts = ["maya"] families = ["layout"] project_container = "AVALON_CONTAINERS" optional = True def process(self, instance): # Define extract output file path stagingdir = self.staging_dir(instance) # Perform extraction self.log.debug("Performing extraction..") if "representations" not in instance.data: instance.data["representations"] = [] json_data = [] # TODO representation queries can be refactored to be faster project_name = instance.context.data["projectName"] for asset in cmds.sets(str(instance), query=True): # Find the container project_container = self.project_container container_list = cmds.ls(project_container) if len(container_list) == 0: self.log.warning("Project container is not found!") self.log.warning("The asset(s) may not be properly loaded after published") # noqa continue grp_loaded_ass = instance.data.get("groupLoadedAssets", False) if grp_loaded_ass: asset_list = cmds.listRelatives(asset, children=True) for asset in asset_list: grp_name = asset.split(':')[0] else: grp_name = asset.split(':')[0] containers = cmds.ls("{}*_CON".format(grp_name)) if len(containers) == 0: self.log.warning("{} isn't from the loader".format(asset)) self.log.warning("It may not be properly loaded after published") # noqa continue container = containers[0] representation_id = cmds.getAttr( "{}.representation".format(container)) representation = get_representation_by_id( project_name, representation_id, fields=["parent", "context.family"] ) self.log.debug(representation) version_id = representation.get("parent") family = representation.get("context").get("family") json_element = { "family": family, "instance_name": cmds.getAttr( "{}.namespace".format(container)), "representation": str(representation_id), "version": str(version_id) } loc = cmds.xform(asset, query=True, translation=True) rot = cmds.xform(asset, query=True, rotation=True, euler=True) scl = cmds.xform(asset, query=True, relative=True, scale=True) json_element["transform"] = { "translation": { "x": loc[0], "y": loc[1], "z": loc[2] }, "rotation": { "x": math.radians(rot[0]), "y": math.radians(rot[1]), "z": math.radians(rot[2]) }, "scale": { "x": scl[0], "y": scl[1], "z": scl[2] } } row_length = 4 t_matrix_list = cmds.xform(asset, query=True, matrix=True) transform_mm = om.MMatrix(t_matrix_list) transform = om.MTransformationMatrix(transform_mm) t = transform.translation(om.MSpace.kWorld) t = om.MVector(t.x, t.z, -t.y) transform.setTranslation(t, om.MSpace.kWorld) transform.rotateBy( om.MEulerRotation(math.radians(-90), 0, 0), om.MSpace.kWorld) transform.scaleBy([1.0, 1.0, -1.0], om.MSpace.kObject) t_matrix_list = list(transform.asMatrix()) t_matrix = [] for i in range(0, len(t_matrix_list), row_length): t_matrix.append(t_matrix_list[i:i + row_length]) json_element["transform_matrix"] = [ list(row) for row in t_matrix ] basis_list = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1 ] basis_mm = om.MMatrix(basis_list) basis = om.MTransformationMatrix(basis_mm) b_matrix_list = list(basis.asMatrix()) b_matrix = [] for i in range(0, len(b_matrix_list), row_length): b_matrix.append(b_matrix_list[i:i + row_length]) json_element["basis"] = [] for row in b_matrix: json_element["basis"].append(list(row)) json_data.append(json_element) json_filename = "{}.json".format(instance.name) json_path = os.path.join(stagingdir, json_filename) with open(json_path, "w+") as file: json.dump(json_data, fp=file, indent=2) json_representation = { 'name': 'json', 'ext': 'json', 'files': json_filename, "stagingDir": stagingdir, } instance.data["representations"].append(json_representation) self.log.debug("Extracted instance '%s' to: %s", instance.name, json_representation) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_look.py ================================================ # -*- coding: utf-8 -*- """Maya look extractor.""" import sys from abc import ABCMeta, abstractmethod from collections import OrderedDict import contextlib import json import logging import os import tempfile import six import attr import pyblish.api from maya import cmds # noqa from openpype.lib import ( find_executable, source_hash, run_subprocess, get_oiio_tool_args, ToolNotFoundError, ) from openpype.pipeline import legacy_io, publish, KnownPublishError from openpype.hosts.maya.api import lib from openpype import AYON_SERVER_ENABLED # Modes for transfer COPY = 1 HARDLINK = 2 @attr.s class TextureResult(object): """The resulting texture of a processed file for a resource""" # Path to the file path = attr.ib() # Colorspace of the resulting texture. This might not be the input # colorspace of the texture if a TextureProcessor has processed the file. colorspace = attr.ib() # Hash generated for the texture using openpype.lib.source_hash file_hash = attr.ib() # The transfer mode, e.g. COPY or HARDLINK transfer_mode = attr.ib() def find_paths_by_hash(texture_hash): """Find the texture hash key in the dictionary. All paths that originate from it. Args: texture_hash (str): Hash of the texture. Return: str: path to texture if found. """ if AYON_SERVER_ENABLED: raise KnownPublishError( "This is a bug. \"find_paths_by_hash\" is not compatible with " "AYON." ) key = "data.sourceHashes.{0}".format(texture_hash) return legacy_io.distinct(key, {"type": "version"}) @contextlib.contextmanager def no_workspace_dir(): """Force maya to a fake temporary workspace directory. Note: This is not maya.cmds.workspace 'rootDirectory' but the 'directory' This helps to avoid Maya automatically remapping image paths to files relative to the currently set directory. """ # Store current workspace original = cmds.workspace(query=True, directory=True) # Set a fake workspace fake_workspace_dir = tempfile.mkdtemp() cmds.workspace(directory=fake_workspace_dir) try: yield finally: try: cmds.workspace(directory=original) except RuntimeError: # If the original workspace directory didn't exist either # ignore the fact that it fails to reset it to the old path pass # Remove the temporary directory os.rmdir(fake_workspace_dir) @six.add_metaclass(ABCMeta) class TextureProcessor: extension = None def __init__(self, log=None): if log is None: log = logging.getLogger(self.__class__.__name__) self.log = log def apply_settings(self, system_settings, project_settings): """Apply OpenPype system/project settings to the TextureProcessor Args: system_settings (dict): OpenPype system settings project_settings (dict): OpenPype project settings Returns: None """ pass @abstractmethod def process(self, source, colorspace, color_management, staging_dir): """Process the `source` texture. Must be implemented on inherited class. This must always return a TextureResult even when it does not generate a texture. If it doesn't generate a texture then it should return a TextureResult using the input path and colorspace. Args: source (str): Path to source file. colorspace (str): Colorspace of the source file. color_management (dict): Maya Color management data from `lib.get_color_management_preferences` staging_dir (str): Output directory to write to. Returns: TextureResult: The resulting texture information. """ pass def __repr__(self): # Log instance as class name return self.__class__.__name__ class MakeRSTexBin(TextureProcessor): """Make `.rstexbin` using `redshiftTextureProcessor`""" extension = ".rstexbin" def process(self, source, colorspace, color_management, staging_dir): texture_processor_path = self.get_redshift_tool( "redshiftTextureProcessor" ) if not texture_processor_path: raise KnownPublishError("Must have Redshift available.") subprocess_args = [ texture_processor_path, source ] # if color management is enabled we pass color space information if color_management["enabled"]: config_path = color_management["config"] if not os.path.exists(config_path): raise RuntimeError("OCIO config not found at: " "{}".format(config_path)) if not os.getenv("OCIO"): self.log.debug( "OCIO environment variable not set." "Setting it with OCIO config from Maya." ) os.environ["OCIO"] = config_path self.log.debug("converting colorspace {0} to redshift render " "colorspace".format(colorspace)) subprocess_args.extend(["-cs", colorspace]) hash_args = ["rstex"] texture_hash = source_hash(source, *hash_args) # Redshift stores the output texture next to the input but with # the extension replaced to `.rstexbin` basename, ext = os.path.splitext(source) destination = "{}{}".format(basename, self.extension) self.log.debug(" ".join(subprocess_args)) try: run_subprocess(subprocess_args, logger=self.log) except Exception: self.log.error("Texture .rstexbin conversion failed", exc_info=True) six.reraise(*sys.exc_info()) return TextureResult( path=destination, file_hash=texture_hash, colorspace=colorspace, transfer_mode=COPY ) @staticmethod def get_redshift_tool(tool_name): """Path to redshift texture processor. On Windows it adds .exe extension if missing from tool argument. Args: tool_name (string): Tool name. Returns: str: Full path to redshift texture processor executable. """ if "REDSHIFT_COREDATAPATH" not in os.environ: raise RuntimeError("Must have Redshift available.") redshift_tool_path = os.path.join( os.environ["REDSHIFT_COREDATAPATH"], "bin", tool_name ) return find_executable(redshift_tool_path) class MakeTX(TextureProcessor): """Make `.tx` using `maketx` with some default settings. Some hardcoded arguments passed to `maketx` are based on the defaults used in Arnold's txManager tool. """ extension = ".tx" def __init__(self, log=None): super(MakeTX, self).__init__(log=log) self.extra_args = [] def apply_settings(self, system_settings, project_settings): # Allow extra maketx arguments from project settings args_settings = ( project_settings["maya"]["publish"] .get("ExtractLook", {}).get("maketx_arguments", []) ) extra_args = [] for arg_data in args_settings: argument = arg_data["argument"] parameters = arg_data["parameters"] if not argument: self.log.debug("Ignoring empty parameter from " "`maketx_arguments` setting..") continue extra_args.append(argument) extra_args.extend(parameters) self.extra_args = extra_args def process(self, source, colorspace, color_management, staging_dir): """Process the texture. This function requires the `maketx` executable to be available in an OpenImageIO toolset detectable by OpenPype. Args: source (str): Path to source file. colorspace (str): Colorspace of the source file. color_management (dict): Maya Color management data from `lib.get_color_management_preferences` staging_dir (str): Output directory to write to. Returns: TextureResult: The resulting texture information. """ try: maketx_args = get_oiio_tool_args("maketx") except ToolNotFoundError: raise KnownPublishError( "OpenImageIO is not available on the machine") # Define .tx filepath in staging if source file is not .tx fname, ext = os.path.splitext(os.path.basename(source)) if ext == ".tx": # Do nothing if the source file is already a .tx file. return TextureResult( path=source, file_hash=source_hash(source), colorspace=colorspace, transfer_mode=COPY ) # Hardcoded default arguments for maketx conversion based on Arnold's # txManager in Maya args = [ # unpremultiply before conversion (recommended when alpha present) "--unpremult", # use oiio-optimized settings for tile-size, planarconfig, metadata "--oiio", "--filter", "lanczos3", ] if color_management["enabled"]: config_path = color_management["config"] if not os.path.exists(config_path): raise RuntimeError("OCIO config not found at: " "{}".format(config_path)) render_colorspace = color_management["rendering_space"] self.log.debug("tx: converting colorspace {0} " "-> {1}".format(colorspace, render_colorspace)) args.extend(["--colorconvert", colorspace, render_colorspace]) args.extend(["--colorconfig", config_path]) else: # Maya Color management is disabled. We cannot rely on an OCIO self.log.debug("tx: Maya color management is disabled. No color " "conversion will be applied to .tx conversion for: " "{}".format(source)) # Assume linear render_colorspace = "linear" # Note: The texture hash is only reliable if we include any potential # conversion arguments provide to e.g. `maketx` hash_args = ["maketx"] + args + self.extra_args texture_hash = source_hash(source, *hash_args) # Ensure folder exists resources_dir = os.path.join(staging_dir, "resources") if not os.path.exists(resources_dir): os.makedirs(resources_dir) self.log.debug("Generating .tx file for %s .." % source) subprocess_args = maketx_args + [ "-v", # verbose "-u", # update mode # --checknan doesn't influence the output file but aborts the # conversion if it finds any. So we can avoid it for the file hash "--checknan", source ] subprocess_args.extend(args) if self.extra_args: subprocess_args.extend(self.extra_args) # Add source hash attribute after other arguments for log readability # Note: argument is excluded from the hash since it is the hash itself subprocess_args.extend([ "--sattrib", "sourceHash", texture_hash ]) destination = os.path.join(resources_dir, fname + ".tx") subprocess_args.extend(["-o", destination]) # We want to make sure we are explicit about what OCIO config gets # used. So when we supply no --colorconfig flag that no fallback to # an OCIO env var occurs. env = os.environ.copy() env.pop("OCIO", None) self.log.debug(" ".join(subprocess_args)) try: run_subprocess(subprocess_args, env=env) except Exception: self.log.error("Texture maketx conversion failed", exc_info=True) raise return TextureResult( path=destination, file_hash=texture_hash, colorspace=render_colorspace, transfer_mode=COPY ) @staticmethod def _has_arnold(): """Return whether the arnold package is available and importable.""" try: import arnold # noqa: F401 return True except (ImportError, ModuleNotFoundError): return False class ExtractLook(publish.Extractor): """Extract Look (Maya Scene + JSON) Only extracts the sets (shadingEngines and alike) alongside a .json file that stores it relationships for the sets and "attribute" data for the instance members. """ label = "Extract Look (Maya Scene + JSON)" hosts = ["maya"] families = ["look", "mvLook"] order = pyblish.api.ExtractorOrder + 0.2 scene_type = "ma" look_data_type = "json" def get_maya_scene_type(self, instance): """Get Maya scene type from settings. Args: instance (pyblish.api.Instance): Instance with collected project settings. """ ext_mapping = ( instance.context.data["project_settings"]["maya"]["ext_mapping"] ) if ext_mapping: self.log.debug("Looking in settings for scene type ...") # use extension mapping for first family found for family in self.families: try: self.scene_type = ext_mapping[family] self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: # no preset found pass return "mayaAscii" if self.scene_type == "ma" else "mayaBinary" def process(self, instance): """Plugin entry point. Args: instance: Instance to process. """ _scene_type = self.get_maya_scene_type(instance) # Define extract output file path dir_path = self.staging_dir(instance) maya_fname = "{0}.{1}".format(instance.name, self.scene_type) json_fname = "{0}.{1}".format(instance.name, self.look_data_type) maya_path = os.path.join(dir_path, maya_fname) json_path = os.path.join(dir_path, json_fname) # Remove all members of the sets so they are not included in the # exported file by accident self.log.debug("Processing sets..") lookdata = instance.data["lookData"] relationships = lookdata["relationships"] sets = list(relationships.keys()) if not sets: self.log.debug("No sets found for the look") return # Specify texture processing executables to activate # TODO: Load these more dynamically once we support more processors processors = [] context = instance.context for key, Processor in { # Instance data key to texture processor mapping "maketx": MakeTX, "rstex": MakeRSTexBin }.items(): if instance.data.get(key, False): processor = Processor(log=self.log) processor.apply_settings(context.data["system_settings"], context.data["project_settings"]) processors.append(processor) if processors: self.log.debug("Collected texture processors: " "{}".format(processors)) self.log.debug("Processing resources..") results = self.process_resources(instance, staging_dir=dir_path, processors=processors) transfers = results["fileTransfers"] hardlinks = results["fileHardlinks"] hashes = results["fileHashes"] remap = results["attrRemap"] # Extract in correct render layer self.log.debug("Extracting look maya scene file: {}".format(maya_path)) layer = instance.data.get("renderlayer", "defaultRenderLayer") with lib.renderlayer(layer): # TODO: Ensure membership edits don't become renderlayer overrides with lib.empty_sets(sets, force=True): # To avoid Maya trying to automatically remap the file # textures relative to the `workspace -directory` we force # it to a fake temporary workspace. This fixes textures # getting incorrectly remapped. with no_workspace_dir(): with lib.attribute_values(remap): with lib.maintained_selection(): cmds.select(sets, noExpand=True) cmds.file( maya_path, force=True, typ=_scene_type, exportSelected=True, preserveReferences=False, channels=True, constraints=True, expressions=True, constructionHistory=True, ) # Write the JSON data data = { "attributes": lookdata["attributes"], "relationships": relationships } self.log.debug("Extracting json file: {}".format(json_path)) with open(json_path, "w") as f: json.dump(data, f) if "files" not in instance.data: instance.data["files"] = [] if "hardlinks" not in instance.data: instance.data["hardlinks"] = [] if "transfers" not in instance.data: instance.data["transfers"] = [] instance.data["files"].append(maya_fname) instance.data["files"].append(json_fname) if instance.data.get("representations") is None: instance.data["representations"] = [] instance.data["representations"].append( { "name": self.scene_type, "ext": self.scene_type, "files": os.path.basename(maya_fname), "stagingDir": os.path.dirname(maya_fname), } ) instance.data["representations"].append( { "name": self.look_data_type, "ext": self.look_data_type, "files": os.path.basename(json_fname), "stagingDir": os.path.dirname(json_fname), } ) # Set up the resources transfers/links for the integrator instance.data["transfers"].extend(transfers) instance.data["hardlinks"].extend(hardlinks) # Source hash for the textures instance.data["sourceHashes"] = hashes self.log.debug("Extracted instance '%s' to: %s" % (instance.name, maya_path)) def _set_resource_result_colorspace(self, resource, colorspace): """Update resource resulting colorspace after texture processing""" if "result_color_space" in resource: if resource["result_color_space"] == colorspace: return self.log.warning( "Resource already has a resulting colorspace but is now " "being overridden to a new one: {} -> {}".format( resource["result_color_space"], colorspace ) ) resource["result_color_space"] = colorspace def process_resources(self, instance, staging_dir, processors): """Process all resources in the instance. It is assumed that all resources are nodes using file textures. Extract the textures to transfer, possibly convert with maketx and remap the node paths to the destination path. Note that a source might be included more than once amongst the resources as they could be the input file to multiple nodes. """ resources = instance.data["resources"] color_management = lib.get_color_management_preferences() # TODO: Temporary disable all hardlinking, due to the feature not being # used or properly working. self.log.info( "Forcing copy instead of hardlink." ) force_copy = True if not force_copy and platform.system().lower() == "windows": # Temporary fix to NOT create hardlinks on windows machines self.log.warning( "Forcing copy instead of hardlink due to issues on Windows..." ) force_copy = True destinations_cache = {} def get_resource_destination_cached(path): """Get resource destination with cached result per filepath""" if path not in destinations_cache: destination = self.get_resource_destination( path, instance.data["resourcesDir"], processors) destinations_cache[path] = destination return destinations_cache[path] # Process all resource's individual files processed_files = {} transfers = [] hardlinks = [] hashes = {} remap = OrderedDict() for resource in resources: colorspace = resource["color_space"] for filepath in resource["files"]: filepath = os.path.normpath(filepath) if filepath in processed_files: # The file was already processed, likely due to usage by # another resource in the scene. We confirm here it # didn't do color spaces different than the current # resource. processed_file = processed_files[filepath] self.log.debug( "File was already processed. Likely used by another " "resource too: {}".format(filepath) ) if colorspace != processed_file["color_space"]: self.log.warning( "File '{}' was already processed using colorspace " "'{}' instead of the current resource's " "colorspace '{}'. The already processed texture " "result's colorspace '{}' will be used." "".format(filepath, colorspace, processed_file["color_space"], processed_file["result_color_space"])) self._set_resource_result_colorspace( resource, colorspace=processed_file["result_color_space"] ) continue texture_result = self._process_texture( filepath, processors=processors, staging_dir=staging_dir, force_copy=force_copy, color_management=color_management, colorspace=colorspace ) # Set the resulting color space on the resource self._set_resource_result_colorspace( resource, colorspace=texture_result.colorspace ) processed_files[filepath] = { "color_space": colorspace, "result_color_space": texture_result.colorspace, } source = texture_result.path destination = get_resource_destination_cached(source) if force_copy or texture_result.transfer_mode == COPY: transfers.append((source, destination)) self.log.debug('file will be copied {} -> {}'.format( source, destination)) elif texture_result.transfer_mode == HARDLINK: hardlinks.append((source, destination)) self.log.debug('file will be hardlinked {} -> {}'.format( source, destination)) # Store the hashes from hash to destination to include in the # database hashes[texture_result.file_hash] = destination # Set up remapping attributes for the node during the publish # The order of these can be important if one attribute directly # affects another, e.g. we set colorspace after filepath because # maya sometimes tries to guess the colorspace when changing # filepaths (which is avoidable, but we don't want to have those # attributes changed in the resulting publish) # Remap filepath to publish destination # TODO It would be much better if we could use the destination path # from the actual processed texture results, but since the # attribute will need to preserve tokens like , etc for # now we will define the output path from the attribute value # including the tokens to persist them. filepath_attr = resource["attribute"] remap[filepath_attr] = get_resource_destination_cached( resource["source"] ) # Preserve color space values (force value after filepath change) # This will also trigger in the same order at end of context to # ensure after context it's still the original value. node = resource["node"] if cmds.attributeQuery("colorSpace", node=node, exists=True): color_space_attr = "{}.colorSpace".format(node) remap[color_space_attr] = resource["result_color_space"] self.log.debug("Finished remapping destinations ...") return { "fileTransfers": transfers, "fileHardlinks": hardlinks, "fileHashes": hashes, "attrRemap": remap, } def get_resource_destination(self, filepath, resources_dir, processors): """Get resource destination path. This is utility function to change path if resource file name is changed by some external tool like `maketx`. Args: filepath (str): Resource source path resources_dir (str): Destination dir for resources in publish. processors (list): Texture processors converting resource. Returns: str: Path to resource file """ # Compute destination location basename, ext = os.path.splitext(os.path.basename(filepath)) # Get extension from the last processor for processor in reversed(processors): processor_ext = processor.extension if processor_ext and ext != processor_ext: self.log.debug("Processor {} overrides extension to '{}' " "for path: {}".format(processor, processor_ext, filepath)) ext = processor_ext break return os.path.join( resources_dir, basename + ext ) def _get_existing_hashed_texture(self, texture_hash): """Return the first found filepath from a texture hash""" # If source has been published before with the same settings, # then don't reprocess but hardlink from the original existing = find_paths_by_hash(texture_hash) if existing: source = next((p for p in existing if os.path.exists(p)), None) if source: return source else: self.log.warning( "Paths not found on disk, " "skipping hardlink: {}".format(existing) ) def _process_texture(self, filepath, processors, staging_dir, force_copy, color_management, colorspace): """Process a single texture file on disk for publishing. This will: 1. Check whether it's already published, if so it will do hardlink (if the texture hash is found and force copy is not enabled) 2. It will process the texture using the supplied texture processors like MakeTX and MakeRSTexBin if enabled. 3. Compute the destination path for the source file. Args: filepath (str): The source file path to process. processors (list): List of TextureProcessor processing the texture staging_dir (str): The staging directory to write to. force_copy (bool): Whether to force a copy even if a file hash might have existed already in the project, otherwise hardlinking the existing file is allowed. color_management (dict): Maya's Color Management settings from `lib.get_color_management_preferences` colorspace (str): The source colorspace of the resources this texture belongs to. Returns: TextureResult: The texture result information. """ if len(processors) > 1: raise KnownPublishError( "More than one texture processor not supported. " "Current processors enabled: {}".format(processors) ) for processor in processors: self.log.debug("Processing texture {} with processor {}".format( filepath, processor )) processed_result = processor.process(filepath, colorspace, color_management, staging_dir) if not processed_result: raise RuntimeError("Texture Processor {} returned " "no result.".format(processor)) self.log.debug("Generated processed " "texture: {}".format(processed_result.path)) # TODO: Currently all processors force copy instead of allowing # hardlinks using source hashes. This should be refactored return processed_result # No texture processing for this file texture_hash = source_hash(filepath) if not force_copy: existing = self._get_existing_hashed_texture(filepath) if existing: self.log.debug("Found hash in database, preparing hardlink..") return TextureResult( path=filepath, file_hash=texture_hash, colorspace=colorspace, transfer_mode=HARDLINK ) return TextureResult( path=filepath, file_hash=texture_hash, colorspace=colorspace, transfer_mode=COPY ) class ExtractModelRenderSets(ExtractLook): """Extract model render attribute sets as model metadata Only extracts the render attrib sets (NO shadingEngines) alongside a .json file that stores it relationships for the sets and "attribute" data for the instance members. """ label = "Model Render Sets" hosts = ["maya"] families = ["model"] scene_type_prefix = "meta.render." look_data_type = "meta.render.json" def get_maya_scene_type(self, instance): typ = super(ExtractModelRenderSets, self).get_maya_scene_type(instance) # add prefix self.scene_type = self.scene_type_prefix + self.scene_type return typ ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py ================================================ # -*- coding: utf-8 -*- """Extract data as Maya scene (raw).""" import os from maya import cmds from openpype.hosts.maya.api.lib import maintained_selection from openpype.pipeline import AVALON_CONTAINER_ID, publish from openpype.pipeline.publish import OpenPypePyblishPluginMixin from openpype.lib import BoolDef class ExtractMayaSceneRaw(publish.Extractor, OpenPypePyblishPluginMixin): """Extract as Maya Scene (raw). This will preserve all references, construction history, etc. """ label = "Maya Scene (Raw)" hosts = ["maya"] families = ["mayaAscii", "mayaScene", "setdress", "layout", "camerarig"] scene_type = "ma" @classmethod def get_attribute_defs(cls): return [ BoolDef( "preserve_references", label="Preserve References", tooltip=( "When enabled references will still be references " "in the published file.\nWhen disabled the references " "are imported into the published file generating a " "file without references." ), default=True ) ] def process(self, instance): """Plugin entry point.""" ext_mapping = ( instance.context.data["project_settings"]["maya"]["ext_mapping"] ) if ext_mapping: self.log.debug("Looking in settings for scene type ...") # use extension mapping for first family found for family in self.families: try: self.scene_type = ext_mapping[family] self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: # no preset found pass # Define extract output file path dir_path = self.staging_dir(instance) filename = "{0}.{1}".format(instance.name, self.scene_type) path = os.path.join(dir_path, filename) # Whether to include all nodes in the instance (including those from # history) or only use the exact set members members_only = instance.data.get("exactSetMembersOnly", False) if members_only: members = instance.data.get("setMembers", list()) if not members: raise RuntimeError("Can't export 'exact set members only' " "when set is empty.") else: members = instance[:] selection = members if set(self.add_for_families).intersection( set(instance.data.get("families", []))) or \ instance.data.get("family") in self.add_for_families: selection += self._get_loaded_containers(members) # Perform extraction self.log.debug("Performing extraction ...") attribute_values = self.get_attr_values_from_data( instance.data ) with maintained_selection(): cmds.select(selection, noExpand=True) cmds.file(path, force=True, typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 exportSelected=True, preserveReferences=attribute_values[ "preserve_references" ], constructionHistory=True, shader=True, constraints=True, expressions=True) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': self.scene_type, 'ext': self.scene_type, 'files': filename, "stagingDir": dir_path } instance.data["representations"].append(representation) self.log.debug("Extracted instance '%s' to: %s" % (instance.name, path)) @staticmethod def _get_loaded_containers(members): # type: (list) -> list refs_to_include = { cmds.referenceQuery(node, referenceNode=True) for node in members if cmds.referenceQuery(node, isNodeReferenced=True) } members_with_refs = refs_to_include.union(members) obj_sets = cmds.ls("*.id", long=True, type="objectSet", recursive=True, objectsOnly=True) loaded_containers = [] for obj_set in obj_sets: if not cmds.attributeQuery("id", node=obj_set, exists=True): continue id_attr = "{}.id".format(obj_set) if cmds.getAttr(id_attr) != AVALON_CONTAINER_ID: continue set_content = set(cmds.sets(obj_set, query=True)) if set_content.intersection(members_with_refs): loaded_containers.append(obj_set) return loaded_containers ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_maya_usd.py ================================================ import os import six import json import contextlib from maya import cmds import pyblish.api from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection @contextlib.contextmanager def usd_export_attributes(nodes, attrs=None, attr_prefixes=None, mapping=None): """Define attributes for the given nodes that should be exported. MayaUSDExport will export custom attributes if the Maya node has a string attribute `USD_UserExportedAttributesJson` that provides an export mapping for the maya attributes. This context manager will try to autogenerate such an attribute during the export to include attributes for the export. Arguments: nodes (List[str]): Nodes to process. attrs (Optional[List[str]]): Full name of attributes to include. attr_prefixes (Optional[List[str]]): Prefixes of attributes to include. mapping (Optional[Dict[Dict]]): A mapping per attribute name for the conversion to a USD attribute, including renaming, defining type, converting attribute precision, etc. This match the usual `USD_UserExportedAttributesJson` json mapping of `mayaUSDExport`. When no mapping provided for an attribute it will use `{}` as value. Examples: >>> with usd_export_attributes( >>> ["pCube1"], attrs="myDoubleAttributeAsFloat", mapping={ >>> "myDoubleAttributeAsFloat": { >>> "usdAttrName": "my:namespace:attrib", >>> "translateMayaDoubleToUsdSinglePrecision": True, >>> } >>> }) """ # todo: this might be better done with a custom export chaser # see `chaser` argument for `mayaUSDExport` import maya.api.OpenMaya as om if not attrs and not attr_prefixes: # context manager does nothing yield return if attrs is None: attrs = [] if attr_prefixes is None: attr_prefixes = [] if mapping is None: mapping = {} usd_json_attr = "USD_UserExportedAttributesJson" strings = attrs + ["{}*".format(prefix) for prefix in attr_prefixes] context_state = {} for node in set(nodes): node_attrs = cmds.listAttr(node, st=strings) if not node_attrs: # Nothing to do for this node continue node_attr_data = {} for node_attr in set(node_attrs): node_attr_data[node_attr] = mapping.get(node_attr, {}) if cmds.attributeQuery(usd_json_attr, node=node, exists=True): existing_node_attr_value = cmds.getAttr( "{}.{}".format(node, usd_json_attr) ) if existing_node_attr_value and existing_node_attr_value != "{}": # Any existing attribute mappings in an existing # `USD_UserExportedAttributesJson` attribute always take # precedence over what this function tries to imprint existing_node_attr_data = json.loads(existing_node_attr_value) node_attr_data.update(existing_node_attr_data) context_state[node] = json.dumps(node_attr_data) sel = om.MSelectionList() dg_mod = om.MDGModifier() fn_string = om.MFnStringData() fn_typed = om.MFnTypedAttribute() try: for node, value in context_state.items(): data = fn_string.create(value) sel.clear() if cmds.attributeQuery(usd_json_attr, node=node, exists=True): # Set the attribute value sel.add("{}.{}".format(node, usd_json_attr)) plug = sel.getPlug(0) dg_mod.newPlugValue(plug, data) else: # Create attribute with the value as default value sel.add(node) node_obj = sel.getDependNode(0) attr_obj = fn_typed.create(usd_json_attr, usd_json_attr, om.MFnData.kString, data) dg_mod.addAttribute(node_obj, attr_obj) dg_mod.doIt() yield finally: dg_mod.undoIt() class ExtractMayaUsd(publish.Extractor): """Extractor for Maya USD Asset data. Upon publish a .usd (or .usdz) asset file will typically be written. """ label = "Extract Maya USD Asset" hosts = ["maya"] families = ["mayaUsd"] @property def options(self): """Overridable options for Maya USD Export Given in the following format - {NAME: EXPECTED TYPE} If the overridden option's type does not match, the option is not included and a warning is logged. """ # TODO: Support more `mayaUSDExport` parameters return { "defaultUSDFormat": str, "stripNamespaces": bool, "mergeTransformAndShape": bool, "exportDisplayColor": bool, "exportColorSets": bool, "exportInstances": bool, "exportUVs": bool, "exportVisibility": bool, "exportComponentTags": bool, "exportRefsAsInstanceable": bool, "eulerFilter": bool, "renderableOnly": bool, "jobContext": (list, None) # optional list # "worldspace": bool, } @property def default_options(self): """The default options for Maya USD Export.""" # TODO: Support more `mayaUSDExport` parameters return { "defaultUSDFormat": "usdc", "stripNamespaces": False, "mergeTransformAndShape": False, "exportDisplayColor": False, "exportColorSets": True, "exportInstances": True, "exportUVs": True, "exportVisibility": True, "exportComponentTags": True, "exportRefsAsInstanceable": False, "eulerFilter": True, "renderableOnly": False, "jobContext": None # "worldspace": False } def parse_overrides(self, instance, options): """Inspect data of instance to determine overridden options""" for key in instance.data: if key not in self.options: continue # Ensure the data is of correct type value = instance.data[key] if isinstance(value, six.text_type): value = str(value) if not isinstance(value, self.options[key]): self.log.warning( "Overridden attribute {key} was of " "the wrong type: {invalid_type} " "- should have been {valid_type}".format( key=key, invalid_type=type(value).__name__, valid_type=self.options[key].__name__)) continue options[key] = value return options def filter_members(self, members): # Can be overridden by inherited classes return members def process(self, instance): # Load plugin first cmds.loadPlugin("mayaUsdPlugin", quiet=True) # Define output file path staging_dir = self.staging_dir(instance) file_name = "{0}.usd".format(instance.name) file_path = os.path.join(staging_dir, file_name) file_path = file_path.replace('\\', '/') # Parse export options options = self.default_options options = self.parse_overrides(instance, options) self.log.debug("Export options: {0}".format(options)) # Perform extraction self.log.debug("Performing extraction ...") members = instance.data("setMembers") self.log.debug('Collected objects: {}'.format(members)) members = self.filter_members(members) if not members: self.log.error('No members!') return start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] def parse_attr_str(attr_str): result = list() for attr in attr_str.split(","): attr = attr.strip() if not attr: continue result.append(attr) return result attrs = parse_attr_str(instance.data.get("attr", "")) attrs += instance.data.get("userDefinedAttributes", []) attrs += ["cbId"] attr_prefixes = parse_attr_str(instance.data.get("attrPrefix", "")) self.log.debug('Exporting USD: {} / {}'.format(file_path, members)) with maintained_selection(): with usd_export_attributes(instance[:], attrs=attrs, attr_prefixes=attr_prefixes): cmds.mayaUSDExport(file=file_path, frameRange=(start, end), frameStride=instance.data.get("step", 1.0), exportRoots=members, **options) representation = { 'name': "usd", 'ext': "usd", 'files': file_name, 'stagingDir': staging_dir } instance.data.setdefault("representations", []).append(representation) self.log.debug( "Extracted instance {} to {}".format(instance.name, file_path) ) class ExtractMayaUsdAnim(ExtractMayaUsd): """Extractor for Maya USD Animation Sparse Cache data. This will extract the sparse cache data from the scene and generate a USD file with all the animation data. Upon publish a .usd sparse cache will be written. """ label = "Extract Maya USD Animation Sparse Cache" families = ["animation", "mayaUsd"] match = pyblish.api.Subset def filter_members(self, members): out_set = next((i for i in members if i.endswith("out_SET")), None) if out_set is None: self.log.warning("Expecting out_SET") return None members = cmds.ls(cmds.sets(out_set, query=True), long=True) return members ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_model.py ================================================ # -*- coding: utf-8 -*- """Extract model as Maya Scene.""" import os from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api import lib class ExtractModel(publish.Extractor, publish.OptionalPyblishPluginMixin): """Extract as Model (Maya Scene). Only extracts contents based on the original "setMembers" data to ensure publishing the least amount of required shapes. From that it only takes the shapes that are not intermediateObjects During export it sets a temporary context to perform a clean extraction. The context ensures: - Smooth preview is turned off for the geometry - Default shader is assigned (no materials are exported) - Remove display layers """ label = "Model (Maya Scene)" hosts = ["maya"] families = ["model"] scene_type = "ma" optional = True def process(self, instance): """Plugin entry point.""" if not self.is_active(instance.data): return ext_mapping = ( instance.context.data["project_settings"]["maya"]["ext_mapping"] ) if ext_mapping: self.log.debug("Looking in settings for scene type ...") # use extension mapping for first family found for family in self.families: try: self.scene_type = ext_mapping[family] self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: # no preset found pass # Define extract output file path stagingdir = self.staging_dir(instance) filename = "{0}.{1}".format(instance.name, self.scene_type) path = os.path.join(stagingdir, filename) # Perform extraction self.log.debug("Performing extraction ...") # Get only the shape contents we need in such a way that we avoid # taking along intermediateObjects members = instance.data("setMembers") members = cmds.ls(members, dag=True, shapes=True, type=("mesh", "nurbsCurve"), noIntermediate=True, long=True) with lib.no_display_layers(instance): with lib.displaySmoothness(members, divisionsU=0, divisionsV=0, pointsWire=4, pointsShaded=1, polygonObject=1): with lib.shader(members, shadingEngine="initialShadingGroup"): with lib.maintained_selection(): cmds.select(members, noExpand=True) cmds.file(path, force=True, typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 exportSelected=True, preserveReferences=False, channels=False, constraints=False, expressions=False, constructionHistory=False) # Store reference for integration if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': self.scene_type, 'ext': self.scene_type, 'files': filename, "stagingDir": stagingdir, } instance.data["representations"].append(representation) self.log.debug("Extracted instance '%s' to: %s" % (instance.name, path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_multiverse_look.py ================================================ import os from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection class ExtractMultiverseLook(publish.Extractor): """Extractor for Multiverse USD look data. This will extract: - the shading networks that are assigned in MEOW as Maya material overrides to a Multiverse Compound - settings for a Multiverse Write Override operation. Relevant settings are visible in the Maya set node created by a Multiverse USD Look instance creator. The input data contained in the set is: - a single Multiverse Compound node with any number of Maya material overrides (typically set in MEOW) Upon publish two files will be written: - a .usda override file containing material assignment information - a .ma file containing shading networks Note: when layering the material assignment override on a loaded Compound, remember to set a matching attribute override with the namespace of the loaded compound in order for the material assignment to resolve. """ label = "Extract Multiverse USD Look" hosts = ["maya"] families = ["mvLook"] scene_type = "usda" file_formats = ["usda", "usd"] @property def options(self): """Overridable options for Multiverse USD Export Given in the following format - {NAME: EXPECTED TYPE} If the overridden option's type does not match, the option is not included and a warning is logged. """ return { "writeAll": bool, "writeTransforms": bool, "writeVisibility": bool, "writeAttributes": bool, "writeMaterials": bool, "writeVariants": bool, "writeVariantsDefinition": bool, "writeActiveState": bool, "writeNamespaces": bool, "numTimeSamples": int, "timeSamplesSpan": float } @property def default_options(self): """The default options for Multiverse USD extraction.""" return { "writeAll": False, "writeTransforms": False, "writeVisibility": False, "writeAttributes": True, "writeMaterials": True, "writeVariants": False, "writeVariantsDefinition": False, "writeActiveState": False, "writeNamespaces": True, "numTimeSamples": 1, "timeSamplesSpan": 0.0 } def get_file_format(self, instance): fileFormat = instance.data["fileFormat"] if fileFormat in range(len(self.file_formats)): self.scene_type = self.file_formats[fileFormat] def process(self, instance): # Load plugin first cmds.loadPlugin("MultiverseForMaya", quiet=True) # Define output file path staging_dir = self.staging_dir(instance) self.get_file_format(instance) file_name = "{0}.{1}".format(instance.name, self.scene_type) file_path = os.path.join(staging_dir, file_name) file_path = file_path.replace('\\', '/') # Parse export options options = self.default_options self.log.debug("Export options: {0}".format(options)) # Perform extraction self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") members = cmds.ls(members, dag=True, shapes=False, type="mvUsdCompoundShape", noIntermediate=True, long=True) self.log.debug('Collected object {}'.format(members)) if len(members) > 1: self.log.error('More than one member: {}'.format(members)) import multiverse over_write_opts = multiverse.OverridesWriteOptions() options_discard_keys = { "numTimeSamples", "timeSamplesSpan", "frameStart", "frameEnd", "handleStart", "handleEnd", "step", "fps" } for key, value in options.items(): if key in options_discard_keys: continue setattr(over_write_opts, key, value) for member in members: # @TODO: Make sure there is only one here. self.log.debug("Writing Override for '{}'".format(member)) multiverse.WriteOverrides(file_path, member, over_write_opts) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': self.scene_type, 'ext': self.scene_type, 'files': file_name, 'stagingDir': staging_dir } instance.data["representations"].append(representation) self.log.debug("Extracted instance {} to {}".format( instance.name, file_path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py ================================================ import os import six from maya import cmds from maya import mel import pyblish.api from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection class ExtractMultiverseUsd(publish.Extractor): """Extractor for Multiverse USD Asset data. This will extract settings for a Multiverse Write Asset operation: they are visible in the Maya set node created by a Multiverse USD Asset instance creator. The input data contained in the set is: - a single hierarchy of Maya nodes. Multiverse supports a variety of Maya nodes such as transforms, mesh, curves, particles, instances, particle instancers, pfx, MASH, lights, cameras, joints, connected materials, shading networks etc. including many of their attributes. Upon publish a .usd (or .usdz) asset file will be typically written. """ label = "Extract Multiverse USD Asset" hosts = ["maya"] families = ["mvUsd"] scene_type = "usd" file_formats = ["usd", "usda", "usdz"] @property def options(self): """Overridable options for Multiverse USD Export Given in the following format - {NAME: EXPECTED TYPE} If the overridden option's type does not match, the option is not included and a warning is logged. """ return { "stripNamespaces": bool, "mergeTransformAndShape": bool, "writeAncestors": bool, "flattenParentXforms": bool, "writeSparseOverrides": bool, "useMetaPrimPath": bool, "customRootPath": str, "customAttributes": str, "nodeTypesToIgnore": str, "writeMeshes": bool, "writeCurves": bool, "writeParticles": bool, "writeCameras": bool, "writeLights": bool, "writeJoints": bool, "writeCollections": bool, "writePositions": bool, "writeNormals": bool, "writeUVs": bool, "writeColorSets": bool, "writeTangents": bool, "writeRefPositions": bool, "writeBlendShapes": bool, "writeDisplayColor": bool, "writeSkinWeights": bool, "writeMaterialAssignment": bool, "writeHardwareShader": bool, "writeShadingNetworks": bool, "writeTransformMatrix": bool, "writeUsdAttributes": bool, "writeInstancesAsReferences": bool, "timeVaryingTopology": bool, "customMaterialNamespace": str, "numTimeSamples": int, "timeSamplesSpan": float } @property def default_options(self): """The default options for Multiverse USD extraction.""" return { "stripNamespaces": False, "mergeTransformAndShape": False, "writeAncestors": False, "flattenParentXforms": False, "writeSparseOverrides": False, "useMetaPrimPath": False, "customRootPath": str(), "customAttributes": str(), "nodeTypesToIgnore": str(), "writeMeshes": True, "writeCurves": True, "writeParticles": True, "writeCameras": False, "writeLights": False, "writeJoints": False, "writeCollections": False, "writePositions": True, "writeNormals": True, "writeUVs": True, "writeColorSets": False, "writeTangents": False, "writeRefPositions": False, "writeBlendShapes": False, "writeDisplayColor": False, "writeSkinWeights": False, "writeMaterialAssignment": False, "writeHardwareShader": False, "writeShadingNetworks": False, "writeTransformMatrix": True, "writeUsdAttributes": False, "writeInstancesAsReferences": False, "timeVaryingTopology": False, "customMaterialNamespace": str(), "numTimeSamples": 1, "timeSamplesSpan": 0.0 } def parse_overrides(self, instance, options): """Inspect data of instance to determine overridden options""" for key in instance.data: if key not in self.options: continue # Ensure the data is of correct type value = instance.data[key] if isinstance(value, six.text_type): value = str(value) if not isinstance(value, self.options[key]): self.log.warning( "Overridden attribute {key} was of " "the wrong type: {invalid_type} " "- should have been {valid_type}".format( key=key, invalid_type=type(value).__name__, valid_type=self.options[key].__name__)) continue options[key] = value return options def get_default_options(self): return self.default_options def filter_members(self, members): return members def process(self, instance): # Load plugin first cmds.loadPlugin("MultiverseForMaya", quiet=True) # Define output file path staging_dir = self.staging_dir(instance) file_format = instance.data.get("fileFormat", 0) if file_format in range(len(self.file_formats)): self.scene_type = self.file_formats[file_format] file_name = "{0}.{1}".format(instance.name, self.scene_type) file_path = os.path.join(staging_dir, file_name) file_path = file_path.replace('\\', '/') # Parse export options options = self.get_default_options() options = self.parse_overrides(instance, options) self.log.debug("Export options: {0}".format(options)) # Perform extraction self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") self.log.debug('Collected objects: {}'.format(members)) members = self.filter_members(members) if not members: self.log.error('No members!') return self.log.debug(' - filtered: {}'.format(members)) import multiverse time_opts = None frame_start = instance.data['frameStart'] frame_end = instance.data['frameEnd'] if frame_end != frame_start: time_opts = multiverse.TimeOptions() time_opts.writeTimeRange = True handle_start = instance.data['handleStart'] handle_end = instance.data['handleEnd'] time_opts.frameRange = ( frame_start - handle_start, frame_end + handle_end) time_opts.frameIncrement = instance.data['step'] time_opts.numTimeSamples = instance.data.get( 'numTimeSamples', options['numTimeSamples']) time_opts.timeSamplesSpan = instance.data.get( 'timeSamplesSpan', options['timeSamplesSpan']) time_opts.framePerSecond = instance.data.get( 'fps', mel.eval('currentTimeUnitToFPS()')) asset_write_opts = multiverse.AssetWriteOptions(time_opts) options_discard_keys = { 'numTimeSamples', 'timeSamplesSpan', 'frameStart', 'frameEnd', 'handleStart', 'handleEnd', 'step', 'fps' } self.log.debug("Write Options:") for key, value in options.items(): if key in options_discard_keys: continue self.log.debug(" - {}={}".format(key, value)) setattr(asset_write_opts, key, value) self.log.debug('WriteAsset: {} / {}'.format(file_path, members)) multiverse.WriteAsset(file_path, members, asset_write_opts) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': self.scene_type, 'ext': self.scene_type, 'files': file_name, 'stagingDir': staging_dir } instance.data["representations"].append(representation) self.log.debug("Extracted instance {} to {}".format( instance.name, file_path)) class ExtractMultiverseUsdAnim(ExtractMultiverseUsd): """Extractor for Multiverse USD Animation Sparse Cache data. This will extract the sparse cache data from the scene and generate a USD file with all the animation data. Upon publish a .usd sparse cache will be written. """ label = "Extract Multiverse USD Animation Sparse Cache" families = ["animation", "usd"] match = pyblish.api.Subset def get_default_options(self): anim_options = self.default_options anim_options["writeSparseOverrides"] = True anim_options["writeUsdAttributes"] = True anim_options["stripNamespaces"] = True return anim_options def filter_members(self, members): out_set = next((i for i in members if i.endswith("out_SET")), None) if out_set is None: self.log.warning("Expecting out_SET") return None members = cmds.ls(cmds.sets(out_set, query=True), long=True) return members ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py ================================================ import os from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection class ExtractMultiverseUsdComposition(publish.Extractor): """Extractor of Multiverse USD Composition data. This will extract settings for a Multiverse Write Composition operation: they are visible in the Maya set node created by a Multiverse USD Composition instance creator. The input data contained in the set is either: - a single hierarchy consisting of several Multiverse Compound nodes, with any number of layers, and Maya transform nodes - a single Compound node with more than one layer (in this case the "Write as Compound Layers" option should be set). Upon publish a .usda composition file will be written. """ label = "Extract Multiverse USD Composition" hosts = ["maya"] families = ["mvUsdComposition"] scene_type = "usd" # Order of `fileFormat` must match create_multiverse_usd_comp.py file_formats = ["usda", "usd"] @property def options(self): """Overridable options for Multiverse USD Export Given in the following format - {NAME: EXPECTED TYPE} If the overridden option's type does not match, the option is not included and a warning is logged. """ return { "stripNamespaces": bool, "mergeTransformAndShape": bool, "flattenContent": bool, "writeAsCompoundLayers": bool, "writePendingOverrides": bool, "numTimeSamples": int, "timeSamplesSpan": float } @property def default_options(self): """The default options for Multiverse USD extraction.""" return { "stripNamespaces": True, "mergeTransformAndShape": False, "flattenContent": False, "writeAsCompoundLayers": False, "writePendingOverrides": False, "numTimeSamples": 1, "timeSamplesSpan": 0.0 } def parse_overrides(self, instance, options): """Inspect data of instance to determine overridden options""" for key in instance.data: if key not in self.options: continue # Ensure the data is of correct type value = instance.data[key] if not isinstance(value, self.options[key]): self.log.warning( "Overridden attribute {key} was of " "the wrong type: {invalid_type} " "- should have been {valid_type}".format( key=key, invalid_type=type(value).__name__, valid_type=self.options[key].__name__)) continue options[key] = value return options def process(self, instance): # Load plugin first cmds.loadPlugin("MultiverseForMaya", quiet=True) # Define output file path staging_dir = self.staging_dir(instance) file_format = instance.data.get("fileFormat", 0) if file_format in range(len(self.file_formats)): self.scene_type = self.file_formats[file_format] file_name = "{0}.{1}".format(instance.name, self.scene_type) file_path = os.path.join(staging_dir, file_name) file_path = file_path.replace('\\', '/') # Parse export options options = self.default_options options = self.parse_overrides(instance, options) self.log.debug("Export options: {0}".format(options)) # Perform extraction self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") self.log.debug('Collected object {}'.format(members)) import multiverse time_opts = None frame_start = instance.data['frameStart'] frame_end = instance.data['frameEnd'] handle_start = instance.data['handleStart'] handle_end = instance.data['handleEnd'] step = instance.data['step'] fps = instance.data['fps'] if frame_end != frame_start: time_opts = multiverse.TimeOptions() time_opts.writeTimeRange = True time_opts.frameRange = ( frame_start - handle_start, frame_end + handle_end) time_opts.frameIncrement = step time_opts.numTimeSamples = instance.data["numTimeSamples"] time_opts.timeSamplesSpan = instance.data["timeSamplesSpan"] time_opts.framePerSecond = fps comp_write_opts = multiverse.CompositionWriteOptions() """ OP tells MV to write to a staging directory, and then moves the file to it's final publish directory. By default, MV write relative paths, but these paths will break when the referencing file moves. This option forces writes to absolute paths, which is ok within OP because all published assets have static paths, and MV can only reference published assets. When a proper UsdAssetResolver is used, this won't be needed. """ comp_write_opts.forceAbsolutePaths = True options_discard_keys = { 'numTimeSamples', 'timeSamplesSpan', 'frameStart', 'frameEnd', 'handleStart', 'handleEnd', 'step', 'fps' } for key, value in options.items(): if key in options_discard_keys: continue setattr(comp_write_opts, key, value) multiverse.WriteComposition(file_path, members, comp_write_opts) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': self.scene_type, 'ext': self.scene_type, 'files': file_name, 'stagingDir': staging_dir } instance.data["representations"].append(representation) self.log.debug("Extracted instance {} to {}".format(instance.name, file_path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py ================================================ import os from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection from maya import cmds class ExtractMultiverseUsdOverride(publish.Extractor): """Extractor for Multiverse USD Override data. This will extract settings for a Multiverse Write Override operation: they are visible in the Maya set node created by a Multiverse USD Override instance creator. The input data contained in the set is: - a single Multiverse Compound node with any number of overrides (typically set in MEOW) Upon publish a .usda override file will be written. """ label = "Extract Multiverse USD Override" hosts = ["maya"] families = ["mvUsdOverride"] scene_type = "usd" # Order of `fileFormat` must match create_multiverse_usd_over.py file_formats = ["usda", "usd"] @property def options(self): """Overridable options for Multiverse USD Export Given in the following format - {NAME: EXPECTED TYPE} If the overridden option's type does not match, the option is not included and a warning is logged. """ return { "writeAll": bool, "writeTransforms": bool, "writeVisibility": bool, "writeAttributes": bool, "writeMaterials": bool, "writeVariants": bool, "writeVariantsDefinition": bool, "writeActiveState": bool, "writeNamespaces": bool, "numTimeSamples": int, "timeSamplesSpan": float } @property def default_options(self): """The default options for Multiverse USD extraction.""" return { "writeAll": False, "writeTransforms": True, "writeVisibility": True, "writeAttributes": True, "writeMaterials": True, "writeVariants": True, "writeVariantsDefinition": True, "writeActiveState": True, "writeNamespaces": False, "numTimeSamples": 1, "timeSamplesSpan": 0.0 } def process(self, instance): # Load plugin first cmds.loadPlugin("MultiverseForMaya", quiet=True) # Define output file path staging_dir = self.staging_dir(instance) file_format = instance.data.get("fileFormat", 0) if file_format in range(len(self.file_formats)): self.scene_type = self.file_formats[file_format] file_name = "{0}.{1}".format(instance.name, self.scene_type) file_path = os.path.join(staging_dir, file_name) file_path = file_path.replace("\\", "/") # Parse export options options = self.default_options self.log.debug("Export options: {0}".format(options)) # Perform extraction self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") members = cmds.ls(members, dag=True, shapes=False, type="mvUsdCompoundShape", noIntermediate=True, long=True) self.log.debug("Collected object {}".format(members)) # TODO: Deal with asset, composition, override with options. import multiverse time_opts = None frame_start = instance.data["frameStart"] frame_end = instance.data["frameEnd"] handle_start = instance.data["handleStart"] handle_end = instance.data["handleEnd"] step = instance.data["step"] fps = instance.data["fps"] if frame_end != frame_start: time_opts = multiverse.TimeOptions() time_opts.writeTimeRange = True time_opts.frameRange = ( frame_start - handle_start, frame_end + handle_end) time_opts.frameIncrement = step time_opts.numTimeSamples = instance.data["numTimeSamples"] time_opts.timeSamplesSpan = instance.data["timeSamplesSpan"] time_opts.framePerSecond = fps over_write_opts = multiverse.OverridesWriteOptions(time_opts) options_discard_keys = { "numTimeSamples", "timeSamplesSpan", "frameStart", "frameEnd", "handleStart", "handleEnd", "step", "fps" } for key, value in options.items(): if key in options_discard_keys: continue setattr(over_write_opts, key, value) for member in members: multiverse.WriteOverrides(file_path, member, over_write_opts) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': self.scene_type, 'ext': self.scene_type, 'files': file_name, 'stagingDir': staging_dir } instance.data["representations"].append(representation) self.log.debug("Extracted instance {} to {}".format( instance.name, file_path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_obj.py ================================================ # -*- coding: utf-8 -*- import os from maya import cmds import pyblish.api from openpype.pipeline import publish from openpype.hosts.maya.api import lib class ExtractObj(publish.Extractor): """Extract OBJ from Maya. This extracts reproducible OBJ exports ignoring any of the settings set on the local machine in the OBJ export options window. """ order = pyblish.api.ExtractorOrder hosts = ["maya"] label = "Extract OBJ" families = ["model"] def process(self, instance): # Define output path staging_dir = self.staging_dir(instance) filename = "{0}.obj".format(instance.name) path = os.path.join(staging_dir, filename) # The export requires forward slashes because we need to # format it into a string in a mel expression self.log.debug("Extracting OBJ to: {0}".format(path)) members = instance.data("setMembers") members = cmds.ls(members, dag=True, shapes=True, type=("mesh", "nurbsCurve"), noIntermediate=True, long=True) self.log.debug("Members: {0}".format(members)) self.log.debug("Instance: {0}".format(instance[:])) if not cmds.pluginInfo('objExport', query=True, loaded=True): cmds.loadPlugin('objExport') # Export with lib.no_display_layers(instance): with lib.displaySmoothness(members, divisionsU=0, divisionsV=0, pointsWire=4, pointsShaded=1, polygonObject=1): with lib.shader(members, shadingEngine="initialShadingGroup"): with lib.maintained_selection(): cmds.select(members, noExpand=True) cmds.file(path, exportSelected=True, type='OBJexport', preserveReferences=True, force=True) if "representation" not in instance.data: instance.data["representation"] = [] representation = { 'name': 'obj', 'ext': 'obj', 'files': filename, "stagingDir": staging_dir, } instance.data["representations"].append(representation) self.log.debug("Extract OBJ successful to: {0}".format(path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_playblast.py ================================================ import os import clique from openpype.pipeline import publish from openpype.hosts.maya.api import lib from maya import cmds class ExtractPlayblast(publish.Extractor): """Extract viewport playblast. Takes review camera and creates review Quicktime video based on viewport capture. """ label = "Extract Playblast" hosts = ["maya"] families = ["review"] optional = True capture_preset = {} profiles = None def process(self, instance): self.log.debug("Extracting playblast..") # get scene fps fps = instance.data.get("fps") or instance.context.data.get("fps") # if start and end frames cannot be determined, get them # from Maya timeline start = instance.data.get("frameStartFtrack") end = instance.data.get("frameEndFtrack") if start is None: start = cmds.playbackOptions(query=True, animationStartTime=True) if end is None: end = cmds.playbackOptions(query=True, animationEndTime=True) self.log.debug("start: {}, end: {}".format(start, end)) task_data = instance.data["anatomyData"].get("task", {}) capture_preset = lib.get_capture_preset( task_data.get("name"), task_data.get("type"), instance.data["subset"], instance.context.data["project_settings"], self.log ) stagingdir = self.staging_dir(instance) filename = instance.name path = os.path.join(stagingdir, filename) self.log.debug("Outputting images to %s" % path) # get cameras camera = instance.data["review_camera"] preset = lib.generate_capture_preset( instance, camera, path, start=start, end=end, capture_preset=capture_preset) lib.render_capture_preset(preset) # Find playblast sequence collected_files = os.listdir(stagingdir) patterns = [clique.PATTERNS["frames"]] collections, remainder = clique.assemble(collected_files, minimum_items=1, patterns=patterns) self.log.debug("Searching playblast collection for: %s", path) frame_collection = None for collection in collections: filebase = collection.format("{head}").rstrip(".") self.log.debug("Checking collection head: %s", filebase) if filebase in path: frame_collection = collection self.log.debug( "Found playblast collection: %s", frame_collection ) tags = ["review"] if not instance.data.get("keepImages"): tags.append("delete") # Add camera node name to representation data camera_node_name = cmds.listRelatives(camera, parent=True)[0] collected_files = list(frame_collection) # single frame file shouldn't be in list, only as a string if len(collected_files) == 1: collected_files = collected_files[0] if "representations" not in instance.data: instance.data["representations"] = [] representation = { "name": capture_preset["Codec"]["compression"], "ext": capture_preset["Codec"]["compression"], "files": collected_files, "stagingDir": stagingdir, "frameStart": int(start), "frameEnd": int(end), "fps": fps, "tags": tags, "camera_name": camera_node_name } instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_pointcache.py ================================================ import os from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api.alembic import extract_alembic from openpype.hosts.maya.api.lib import ( suspended_refresh, maintained_selection, iter_visible_nodes_in_range, ) from openpype.lib import ( BoolDef, TextDef, NumberDef, EnumDef, UISeparatorDef, UILabelDef, ) from openpype.pipeline.publish import OpenPypePyblishPluginMixin class ExtractAlembic(publish.Extractor, OpenPypePyblishPluginMixin): """Produce an alembic of just point positions and normals. Positions and normals, uvs, creases are preserved, but nothing more, for plain and predictable point caches. Plugin can run locally or remotely (on a farm - if instance is marked with "farm" it will be skipped in local processing, but processed on farm) """ label = "Extract Pointcache (Alembic)" hosts = ["maya"] families = ["pointcache", "model", "vrayproxy.alembic"] targets = ["local", "remote"] flags = [] attr = [] attrPrefix = [] dataFormat = "ogawa" melPerFrameCallback = "" melPostJobCallback = "" preRollStartFrame = 0 pythonPerFrameCallback = "" pythonPostJobCallback = "" userAttr = "" userAttrPrefix = "" visibleOnly = False overrides = [] def process(self, instance): if instance.data.get("farm"): self.log.debug("Should be processed on farm, skipping.") return nodes, roots = self.get_members_and_roots(instance) # Collect the start and end including handles start = float(instance.data.get("frameStartHandle", 1)) end = float(instance.data.get("frameEndHandle", 1)) attribute_values = self.get_attr_values_from_data( instance.data ) attrs = [ attr.strip() for attr in attribute_values.get("attr", "").split(";") if attr.strip() ] attrs += instance.data.get("userDefinedAttributes", []) attrs += ["cbId"] attr_prefixes = [ attr.strip() for attr in attribute_values.get("attrPrefix", "").split(";") if attr.strip() ] self.log.debug("Extracting pointcache...") dirname = self.staging_dir(instance) parent_dir = self.staging_dir(instance) filename = "{name}.abc".format(**instance.data) path = os.path.join(parent_dir, filename) root = None if not instance.data.get("includeParentHierarchy", True): # Set the root nodes if we don't want to include parents # The roots are to be considered the ones that are the actual # direct members of the set root = roots args = { "file": path, "attr": attrs, "attrPrefix": attr_prefixes, "dataFormat": attribute_values.get("dataFormat", "ogawa"), "endFrame": end, "eulerFilter": False, "noNormals": False, "preRoll": False, "preRollStartFrame": attribute_values.get( "preRollStartFrame", 0 ), "renderableOnly": False, "root": root, "selection": True, "startFrame": start, "step": instance.data.get( "creator_attributes", {} ).get("step", 1.0), "stripNamespaces": False, "uvWrite": False, "verbose": False, "wholeFrameGeo": False, "worldSpace": False, "writeColorSets": False, "writeCreases": False, "writeFaceSets": False, "writeUVSets": False, "writeVisibility": False, } # Export flags are defined as default enabled flags plus publisher # enabled flags. non_exposed_flags = list(set(self.flags) - set(self.overrides)) flags = attribute_values["flags"] + non_exposed_flags for flag in flags: args[flag] = True if instance.data.get("visibleOnly", False): # If we only want to include nodes that are visible in the frame # range then we need to do our own check. Alembic's `visibleOnly` # flag does not filter out those that are only hidden on some # frames as it counts "animated" or "connected" visibilities as # if it's always visible. nodes = list( iter_visible_nodes_in_range(nodes, start=start, end=end) ) suspend = not instance.data.get("refresh", False) with suspended_refresh(suspend=suspend): with maintained_selection(): cmds.select(nodes, noExpand=True) self.log.debug( "Running `extract_alembic` with the arguments: {}".format( args ) ) extract_alembic(**args) if "representations" not in instance.data: instance.data["representations"] = [] representation = { "name": "abc", "ext": "abc", "files": filename, "stagingDir": dirname, } instance.data["representations"].append(representation) if not instance.data.get("stagingDir_persistent", False): instance.context.data["cleanupFullPaths"].append(path) self.log.debug("Extracted {} to {}".format(instance, dirname)) # Extract proxy. if not instance.data.get("proxy"): self.log.debug("No proxy nodes found. Skipping proxy extraction.") return path = path.replace(".abc", "_proxy.abc") args["file"] = path if not instance.data.get("includeParentHierarchy", True): # Set the root nodes if we don't want to include parents # The roots are to be considered the ones that are the actual # direct members of the set args["root"] = instance.data["proxyRoots"] with suspended_refresh(suspend=suspend): with maintained_selection(): cmds.select(instance.data["proxy"]) extract_alembic(**args) representation = { "name": "proxy", "ext": "abc", "files": os.path.basename(path), "stagingDir": dirname, "outputName": "proxy", } instance.data["representations"].append(representation) def get_members_and_roots(self, instance): return instance[:], instance.data.get("setMembers") @classmethod def get_attribute_defs(cls): override_defs = { "attr": { "def": TextDef, "kwargs": { "label": "Custom Attributes", "placeholder": "attr1; attr2; ...", } }, "attrPrefix": { "def": TextDef, "kwargs": { "label": "Custom Attributes Prefix", "placeholder": "prefix1; prefix2; ...", } }, "dataFormat": { "def": EnumDef, "kwargs": { "label": "Data Format", "items": ["ogawa", "HDF"], } }, "melPerFrameCallback": { "def": TextDef, "kwargs": { "label": "melPerFrameCallback", } }, "melPostJobCallback": { "def": TextDef, "kwargs": { "label": "melPostJobCallback", } }, "preRollStartFrame": { "def": NumberDef, "kwargs": { "label": "Start frame for preroll", "tooltip": ( "The frame to start scene evaluation at. This is used" " to set the starting frame for time dependent " "translations and can be used to evaluate run-up that" " isn't actually translated." ), } }, "pythonPerFrameCallback": { "def": TextDef, "kwargs": { "label": "pythonPerFrameCallback", } }, "pythonPostJobCallback": { "def": TextDef, "kwargs": { "label": "pythonPostJobCallback", } }, "userAttr": { "def": TextDef, "kwargs": { "label": "userAttr", } }, "userAttrPrefix": { "def": TextDef, "kwargs": { "label": "userAttrPrefix", } }, "visibleOnly": { "def": BoolDef, "kwargs": { "label": "Visible Only", } } } defs = super(ExtractAlembic, cls).get_attribute_defs() defs.extend([ UISeparatorDef("sep_alembic_options"), UILabelDef("Alembic Options"), ]) # The Arguments that can be modified by the Publisher overrides = set(getattr(cls, "overrides", set())) # What we have set in the Settings as defaults. flags = set(getattr(cls, "flags", set())) enabled_flags = [x for x in flags if x in overrides] flags = overrides - set(override_defs.keys()) if flags: defs.append( EnumDef( "flags", flags, default=enabled_flags, multiselection=True, label="Export Flags", ) ) for key, value in override_defs.items(): if key not in overrides: continue kwargs = value["kwargs"] kwargs["default"] = getattr(cls, key, None) defs.append( value["def"](key, **value["kwargs"]) ) defs.append( UISeparatorDef("sep_alembic_options") ) return defs class ExtractAnimation(ExtractAlembic): label = "Extract Animation (Alembic)" families = ["animation"] def get_members_and_roots(self, instance): # Collect the out set nodes out_sets = [node for node in instance if node.endswith("out_SET")] if len(out_sets) != 1: raise RuntimeError( "Couldn't find exactly one out_SET: " "{0}".format(out_sets) ) out_set = out_sets[0] roots = cmds.sets(out_set, query=True) # Include all descendants nodes = ( roots + cmds.listRelatives(roots, allDescendents=True, fullPath=True) or [] ) return nodes, roots ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_proxy_abc.py ================================================ import os from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api.alembic import extract_alembic from openpype.hosts.maya.api.lib import ( suspended_refresh, maintained_selection, iter_visible_nodes_in_range ) class ExtractProxyAlembic(publish.Extractor): """Produce an alembic for bounding box geometry """ label = "Extract Proxy (Alembic)" hosts = ["maya"] families = ["proxyAbc"] def process(self, instance): name_suffix = instance.data.get("nameSuffix") # Collect the start and end including handles start = float(instance.data.get("frameStartHandle", 1)) end = float(instance.data.get("frameEndHandle", 1)) attrs = instance.data.get("attr", "").split(";") attrs = [value for value in attrs if value.strip()] attrs += ["cbId"] attr_prefixes = instance.data.get("attrPrefix", "").split(";") attr_prefixes = [value for value in attr_prefixes if value.strip()] self.log.debug("Extracting Proxy Alembic..") dirname = self.staging_dir(instance) filename = "{name}.abc".format(**instance.data) path = os.path.join(dirname, filename) proxy_root = self.create_proxy_geometry(instance, name_suffix, start, end) options = { "step": instance.data.get("step", 1.0), "attr": attrs, "attrPrefix": attr_prefixes, "writeVisibility": True, "writeCreases": True, "writeColorSets": instance.data.get("writeColorSets", False), "writeFaceSets": instance.data.get("writeFaceSets", False), "uvWrite": True, "selection": True, "worldSpace": instance.data.get("worldSpace", True), "root": proxy_root } if int(cmds.about(version=True)) >= 2017: # Since Maya 2017 alembic supports multiple uv sets - write them. options["writeUVSets"] = True with suspended_refresh(): with maintained_selection(): cmds.select(proxy_root, hi=True, noExpand=True) extract_alembic(file=path, startFrame=start, endFrame=end, **options) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'abc', 'ext': 'abc', 'files': filename, "stagingDir": dirname } instance.data["representations"].append(representation) if not instance.data.get("stagingDir_persistent", False): instance.context.data["cleanupFullPaths"].append(path) self.log.debug("Extracted {} to {}".format(instance, dirname)) # remove the bounding box bbox_master = cmds.ls("bbox_grp") cmds.delete(bbox_master) def create_proxy_geometry(self, instance, name_suffix, start, end): nodes = instance[:] nodes = list(iter_visible_nodes_in_range(nodes, start=start, end=end)) inst_selection = cmds.ls(nodes, long=True) cmds.geomToBBox(inst_selection, nameSuffix=name_suffix, keepOriginal=True, single=False, bakeAnimation=True, startTime=start, endTime=end) # create master group for bounding # boxes as the main root master_group = cmds.group(name="bbox_grp") bbox_sel = cmds.ls(master_group, long=True) self.log.debug("proxy_root: {}".format(bbox_sel)) return bbox_sel ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py ================================================ # -*- coding: utf-8 -*- """Redshift Proxy extractor.""" import os from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection class ExtractRedshiftProxy(publish.Extractor): """Extract the content of the instance to a redshift proxy file.""" label = "Redshift Proxy (.rs)" hosts = ["maya"] families = ["redshiftproxy"] def process(self, instance): """Extractor entry point.""" staging_dir = self.staging_dir(instance) file_name = "{}.rs".format(instance.name) file_path = os.path.join(staging_dir, file_name) anim_on = instance.data["animation"] rs_options = "exportConnectivity=0;enableCompression=1;keepUnused=0;" repr_files = file_name if not anim_on: # Remove animation information because it is not required for # non-animated subsets keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "frameStartHandle", "frameEndHandle"] for key in keys: instance.data.pop(key, None) else: start_frame = instance.data["frameStartHandle"] end_frame = instance.data["frameEndHandle"] rs_options = "{}startFrame={};endFrame={};frameStep={};".format( rs_options, start_frame, end_frame, instance.data["step"] ) root, ext = os.path.splitext(file_path) # Padding is taken from number of digits of the end_frame. # Not sure where Redshift is taking it. repr_files = [ "{}.{}{}".format(os.path.basename(root), str(frame).rjust(4, "0"), ext) # noqa: E501 for frame in range( int(start_frame), int(end_frame) + 1, int(instance.data["step"]) )] # vertex_colors = instance.data.get("vertexColors", False) # Write out rs file self.log.debug("Writing: '%s'" % file_path) with maintained_selection(): cmds.select(instance.data["setMembers"], noExpand=True) cmds.file(file_path, pr=False, force=True, type="Redshift Proxy", exportSelected=True, options=rs_options) if "representations" not in instance.data: instance.data["representations"] = [] self.log.debug("Files: {}".format(repr_files)) representation = { 'name': 'rs', 'ext': 'rs', 'files': repr_files, "stagingDir": staging_dir, } instance.data["representations"].append(representation) self.log.debug("Extracted instance '%s' to: %s" % (instance.name, staging_dir)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_rendersetup.py ================================================ import os import json import maya.app.renderSetup.model.renderSetup as renderSetup from openpype.pipeline import publish class ExtractRenderSetup(publish.Extractor): """ Produce renderSetup template file This will save whole renderSetup to json file for later use. """ label = "Extract RenderSetup" hosts = ["maya"] families = ["rendersetup"] def process(self, instance): parent_dir = self.staging_dir(instance) json_filename = "{}.json".format(instance.name) json_path = os.path.join(parent_dir, json_filename) with open(json_path, "w+") as file: json.dump( renderSetup.instance().encode(None), fp=file, indent=2, sort_keys=True) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'json', 'ext': 'json', 'files': json_filename, "stagingDir": parent_dir, } instance.data["representations"].append(representation) self.log.debug( "Extracted instance '%s' to: %s" % (instance.name, json_path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_rig.py ================================================ # -*- coding: utf-8 -*- """Extract rig as Maya Scene.""" import os from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection class ExtractRig(publish.Extractor): """Extract rig as Maya Scene.""" label = "Extract Rig (Maya Scene)" hosts = ["maya"] families = ["rig"] scene_type = "ma" def process(self, instance): """Plugin entry point.""" ext_mapping = ( instance.context.data["project_settings"]["maya"]["ext_mapping"] ) if ext_mapping: self.log.debug("Looking in settings for scene type ...") # use extension mapping for first family found for family in self.families: try: self.scene_type = ext_mapping[family] self.log.debug( "Using '.{}' as scene type".format(self.scene_type)) break except AttributeError: # no preset found pass # Define extract output file path dir_path = self.staging_dir(instance) filename = "{0}.{1}".format(instance.name, self.scene_type) path = os.path.join(dir_path, filename) # Perform extraction self.log.debug("Performing extraction ...") with maintained_selection(): cmds.select(instance, noExpand=True) cmds.file(path, force=True, typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 exportSelected=True, preserveReferences=False, channels=True, constraints=True, expressions=True, constructionHistory=True) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': self.scene_type, 'ext': self.scene_type, 'files': filename, "stagingDir": dir_path } instance.data["representations"].append(representation) self.log.debug("Extracted instance '%s' to: %s", instance.name, path) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py ================================================ # -*- coding: utf-8 -*- import os from maya import cmds # noqa import pyblish.api from openpype.pipeline import publish from openpype.pipeline.publish import OptionalPyblishPluginMixin from openpype.hosts.maya.api import fbx class ExtractSkeletonMesh(publish.Extractor, OptionalPyblishPluginMixin): """Extract Rig in FBX format from Maya. This extracts the rig in fbx with the constraints and referenced asset content included. This also optionally extract animated rig in fbx with geometries included. """ order = pyblish.api.ExtractorOrder label = "Extract Skeleton Mesh" hosts = ["maya"] families = ["rig.fbx"] def process(self, instance): if not self.is_active(instance.data): return # Define output path staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) path = os.path.join(staging_dir, filename) fbx_exporter = fbx.FBXExtractor(log=self.log) out_set = instance.data.get("skeleton_mesh", []) instance.data["constraints"] = True instance.data["skeletonDefinitions"] = True fbx_exporter.set_options_from_instance(instance) # Export fbx_exporter.export(out_set, path) representations = instance.data.setdefault("representations", []) representations.append({ 'name': 'fbx', 'ext': 'fbx', 'files': filename, "stagingDir": staging_dir }) self.log.debug("Extract FBX to: {0}".format(path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_thumbnail.py ================================================ import os import glob import tempfile from openpype.pipeline import publish from openpype.hosts.maya.api import lib class ExtractThumbnail(publish.Extractor): """Extract viewport thumbnail. Takes review camera and creates a thumbnail based on viewport capture. """ label = "Thumbnail" hosts = ["maya"] families = ["review"] def process(self, instance): self.log.debug("Extracting thumbnail..") camera = instance.data["review_camera"] task_data = instance.data["anatomyData"].get("task", {}) capture_preset = lib.get_capture_preset( task_data.get("name"), task_data.get("type"), instance.data["subset"], instance.context.data["project_settings"], self.log ) # Create temp directory for thumbnail # - this is to avoid "override" of source file dst_staging = tempfile.mkdtemp(prefix="pyblish_tmp_thumbnail") self.log.debug( "Create temp directory {} for thumbnail".format(dst_staging) ) # Store new staging to cleanup paths filename = instance.name path = os.path.join(dst_staging, filename) self.log.debug("Outputting images to %s" % path) preset = lib.generate_capture_preset( instance, camera, path, start=1, end=1, capture_preset=capture_preset) preset["camera_options"].update({ "displayGateMask": False, "displayResolution": False, "displayFilmGate": False, "displayFieldChart": False, "displaySafeAction": False, "displaySafeTitle": False, "displayFilmPivot": False, "displayFilmOrigin": False, "overscan": 1.0, }) path = lib.render_capture_preset(preset) playblast = self._fix_playblast_output_path(path) _, thumbnail = os.path.split(playblast) self.log.debug("file list {}".format(thumbnail)) if "representations" not in instance.data: instance.data["representations"] = [] representation = { "name": "thumbnail", "ext": "jpg", "files": thumbnail, "stagingDir": dst_staging, "thumbnail": True } instance.data["representations"].append(representation) def _fix_playblast_output_path(self, filepath): """Workaround a bug in maya.cmds.playblast to return correct filepath. When the `viewer` argument is set to False and maya.cmds.playblast does not automatically open the playblasted file the returned filepath does not have the file's extension added correctly. To workaround this we just glob.glob() for any file extensions and assume the latest modified file is the correct file and return it. """ # Catch cancelled playblast if filepath is None: self.log.warning("Playblast did not result in output path. " "Playblast is probably interrupted.") return None # Fix: playblast not returning correct filename (with extension) # Lets assume the most recently modified file is the correct one. if not os.path.exists(filepath): directory = os.path.dirname(filepath) filename = os.path.basename(filepath) # check if the filepath is has frame based filename # example : capture.####.png parts = filename.split(".") if len(parts) == 3: query = os.path.join(directory, "{}.*.{}".format(parts[0], parts[-1])) files = glob.glob(query) else: files = glob.glob("{}.*".format(filepath)) if not files: raise RuntimeError("Couldn't find playblast from: " "{0}".format(filepath)) filepath = max(files, key=os.path.getmtime) return filepath ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_abc.py ================================================ # -*- coding: utf-8 -*- """Create Unreal Skeletal Mesh data to be extracted as FBX.""" import os from contextlib import contextmanager from maya import cmds # noqa from openpype.pipeline import publish from openpype.hosts.maya.api.alembic import extract_alembic from openpype.hosts.maya.api.lib import ( suspended_refresh, maintained_selection ) class ExtractUnrealSkeletalMeshAbc(publish.Extractor): """Extract Unreal Skeletal Mesh as FBX from Maya. """ label = "Extract Unreal Skeletal Mesh - Alembic" hosts = ["maya"] families = ["skeletalMesh"] optional = True def process(self, instance): self.log.debug("Extracting pointcache..") geo = cmds.listRelatives( instance.data.get("geometry"), allDescendents=True, fullPath=True) joints = cmds.listRelatives( instance.data.get("joints"), allDescendents=True, fullPath=True) nodes = geo + joints attrs = instance.data.get("attr", "").split(";") attrs = [value for value in attrs if value.strip()] attrs += ["cbId"] attr_prefixes = instance.data.get("attrPrefix", "").split(";") attr_prefixes = [value for value in attr_prefixes if value.strip()] # Define output path staging_dir = self.staging_dir(instance) filename = "{0}.abc".format(instance.name) path = os.path.join(staging_dir, filename) # The export requires forward slashes because we need # to format it into a string in a mel expression path = path.replace('\\', '/') self.log.debug("Extracting ABC to: {0}".format(path)) self.log.debug("Members: {0}".format(nodes)) self.log.debug("Instance: {0}".format(instance[:])) options = { "step": instance.data.get("step", 1.0), "attr": attrs, "attrPrefix": attr_prefixes, "writeVisibility": True, "writeCreases": True, "writeColorSets": instance.data.get("writeColorSets", False), "writeFaceSets": instance.data.get("writeFaceSets", False), "uvWrite": True, "selection": True, "worldSpace": instance.data.get("worldSpace", True) } self.log.debug("Options: {}".format(options)) if int(cmds.about(version=True)) >= 2017: # Since Maya 2017 alembic supports multiple uv sets - write them. options["writeUVSets"] = True if not instance.data.get("includeParentHierarchy", True): # Set the root nodes if we don't want to include parents # The roots are to be considered the ones that are the actual # direct members of the set options["root"] = instance.data.get("setMembers") with suspended_refresh(suspend=instance.data.get("refresh", False)): with maintained_selection(): cmds.select(nodes, noExpand=True) extract_alembic(file=path, # startFrame=start, # endFrame=end, **options) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'abc', 'ext': 'abc', 'files': filename, "stagingDir": staging_dir, } instance.data["representations"].append(representation) self.log.debug("Extract ABC successful to: {0}".format(path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_fbx.py ================================================ # -*- coding: utf-8 -*- """Create Unreal Skeletal Mesh data to be extracted as FBX.""" import os from contextlib import contextmanager from maya import cmds # noqa import pyblish.api from openpype.pipeline import publish from openpype.hosts.maya.api import fbx @contextmanager def renamed(original_name, renamed_name): # type: (str, str) -> None try: cmds.rename(original_name, renamed_name) yield finally: cmds.rename(renamed_name, original_name) class ExtractUnrealSkeletalMeshFbx(publish.Extractor): """Extract Unreal Skeletal Mesh as FBX from Maya. """ order = pyblish.api.ExtractorOrder - 0.1 label = "Extract Unreal Skeletal Mesh - FBX" families = ["skeletalMesh"] optional = True def process(self, instance): fbx_exporter = fbx.FBXExtractor(log=self.log) # Define output path staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) path = os.path.join(staging_dir, filename) geo = instance.data.get("geometry") joints = instance.data.get("joints") to_extract = geo + joints # The export requires forward slashes because we need # to format it into a string in a mel expression path = path.replace('\\', '/') self.log.debug("Extracting FBX to: {0}".format(path)) self.log.debug("Members: {0}".format(to_extract)) self.log.debug("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) # This magic is done for variants. To let Unreal merge correctly # existing data, top node must have the same name. So for every # variant we extract we need to rename top node of the rig correctly. # It is finally done in context manager so it won't affect current # scene. # we rely on hierarchy under one root. original_parent = to_extract[0].split("|")[1] parent_node = instance.data.get("asset") # this needs to be done for AYON # WARNING: since AYON supports duplicity of asset names, # this needs to be refactored throughout the pipeline. parent_node = parent_node.split("/")[-1] renamed_to_extract = [] for node in to_extract: node_path = node.split("|") node_path[1] = parent_node renamed_to_extract.append("|".join(node_path)) with renamed(original_parent, parent_node): self.log.debug("Extracting: {}".format(renamed_to_extract, path)) fbx_exporter.export(renamed_to_extract, path) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'fbx', 'ext': 'fbx', 'files': filename, "stagingDir": staging_dir, } instance.data["representations"].append(representation) self.log.debug("Extract FBX successful to: {0}".format(path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py ================================================ # -*- coding: utf-8 -*- """Create Unreal Static Mesh data to be extracted as FBX.""" import os from maya import cmds # noqa import pyblish.api from openpype.pipeline import publish from openpype.hosts.maya.api.lib import ( parent_nodes, maintained_selection ) from openpype.hosts.maya.api import fbx class ExtractUnrealStaticMesh(publish.Extractor): """Extract Unreal Static Mesh as FBX from Maya. """ order = pyblish.api.ExtractorOrder - 0.1 label = "Extract Unreal Static Mesh" families = ["staticMesh"] def process(self, instance): members = instance.data.get("geometryMembers", []) if instance.data.get("collisionMembers"): members = members + instance.data.get("collisionMembers") fbx_exporter = fbx.FBXExtractor(log=self.log) # Define output path staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) path = os.path.join(staging_dir, filename) # The export requires forward slashes because we need # to format it into a string in a mel expression path = path.replace('\\', '/') self.log.debug("Extracting FBX to: {0}".format(path)) self.log.debug("Members: {0}".format(members)) self.log.debug("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) with maintained_selection(): with parent_nodes(members): self.log.debug("Un-parenting: {}".format(members)) fbx_exporter.export(members, path) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'fbx', 'ext': 'fbx', 'files': filename, "stagingDir": staging_dir, } instance.data["representations"].append(representation) self.log.debug("Extract FBX successful to: {0}".format(path)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_unreal_yeticache.py ================================================ import os from maya import cmds from openpype.pipeline import publish class ExtractUnrealYetiCache(publish.Extractor): """Producing Yeti cache files using scene time range. This will extract Yeti cache file sequence and fur settings. """ label = "Extract Yeti Cache (Unreal)" hosts = ["maya"] families = ["yeticacheUE"] def process(self, instance): yeti_nodes = cmds.ls(instance, type="pgYetiMaya") if not yeti_nodes: raise RuntimeError("No pgYetiMaya nodes found in the instance") # Define extract output file path dirname = self.staging_dir(instance) # Collect information for writing cache start_frame = instance.data["frameStartHandle"] end_frame = instance.data["frameEndHandle"] preroll = instance.data["preroll"] if preroll > 0: start_frame -= preroll kwargs = {} samples = instance.data.get("samples", 0) if samples == 0: kwargs.update({"sampleTimes": "0.0 1.0"}) else: kwargs.update({"samples": samples}) self.log.debug(f"Writing out cache {start_frame} - {end_frame}") filename = f"{instance.name}.abc" path = os.path.join(dirname, filename) cmds.pgYetiCommand(yeti_nodes, writeAlembic=path, range=(start_frame, end_frame), asUnrealAbc=True, **kwargs) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'abc', 'ext': 'abc', 'files': filename, 'stagingDir': dirname } instance.data["representations"].append(representation) self.log.debug(f"Extracted {instance} to {dirname}") ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_vrayproxy.py ================================================ import os from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection class ExtractVRayProxy(publish.Extractor): """Extract the content of the instance to a vrmesh file Things to pay attention to: - If animation is toggled, are the frames correct - """ label = "VRay Proxy (.vrmesh)" hosts = ["maya"] families = ["vrayproxy.vrmesh"] def process(self, instance): staging_dir = self.staging_dir(instance) file_name = "{}.vrmesh".format(instance.name) file_path = os.path.join(staging_dir, file_name) anim_on = instance.data["animation"] if not anim_on: # Remove animation information because it is not required for # non-animated subsets keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "frameStartHandle", "frameEndHandle"] for key in keys: instance.data.pop(key, None) start_frame = 1 end_frame = 1 else: start_frame = instance.data["frameStartHandle"] end_frame = instance.data["frameEndHandle"] vertex_colors = instance.data.get("vertexColors", False) # Write out vrmesh file self.log.debug("Writing: '%s'" % file_path) with maintained_selection(): cmds.select(instance.data["setMembers"], noExpand=True) cmds.vrayCreateProxy(exportType=1, dir=staging_dir, fname=file_name, animOn=anim_on, animType=3, startFrame=start_frame, endFrame=end_frame, vertexColorsOn=vertex_colors, ignoreHiddenObjects=True, createProxyNode=False) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': 'vrmesh', 'ext': 'vrmesh', 'files': file_name, "stagingDir": staging_dir, } instance.data["representations"].append(representation) self.log.debug("Extracted instance '%s' to: %s" % (instance.name, staging_dir)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_vrayscene.py ================================================ # -*- coding: utf-8 -*- """Extract vrayscene from specified families.""" import os import re from openpype.pipeline import publish from openpype.hosts.maya.api.render_setup_tools import export_in_rs_layer from openpype.hosts.maya.api.lib import maintained_selection from maya import cmds class ExtractVrayscene(publish.Extractor): """Extractor for vrscene.""" label = "VRay Scene (.vrscene)" hosts = ["maya"] families = ["vrayscene_layer"] def process(self, instance): """Plugin entry point.""" if instance.data.get("exportOnFarm"): self.log.debug("vrayscenes will be exported on farm.") raise NotImplementedError( "exporting vrayscenes is not implemented") # handle sequence if instance.data.get("vraySceneMultipleFiles"): self.log.debug("vrayscenes will be exported on farm.") raise NotImplementedError( "exporting vrayscene sequences not implemented yet") vray_settings = cmds.ls(type="VRaySettingsNode") if not vray_settings: node = cmds.createNode("VRaySettingsNode") else: node = vray_settings[0] # setMembers on vrayscene_layer should contain layer name. layer_name = instance.data.get("layer") staging_dir = self.staging_dir(instance) template = cmds.getAttr("{}.vrscene_filename".format(node)) start_frame = instance.data.get( "frameStartHandle") if instance.data.get( "vraySceneMultipleFiles") else None formatted_name = self.format_vray_output_filename( os.path.basename(instance.data.get("source")), layer_name, template, start_frame ) file_path = os.path.join( staging_dir, "vrayscene", *formatted_name.split("/")) # Write out vrscene file self.log.debug("Writing: '%s'" % file_path) with maintained_selection(): if "*" not in instance.data["setMembers"]: self.log.debug( "Exporting: {}".format(instance.data["setMembers"])) set_members = instance.data["setMembers"] cmds.select(set_members, noExpand=True) else: self.log.debug("Exporting all ...") set_members = cmds.ls( long=True, objectsOnly=True, geometry=True, lights=True, cameras=True) cmds.select(set_members, noExpand=True) self.log.debug("Appending layer name {}".format(layer_name)) set_members.append(layer_name) export_in_rs_layer( file_path, set_members, export=lambda: cmds.file( file_path, type="V-Ray Scene", pr=True, es=True, force=True)) if "representations" not in instance.data: instance.data["representations"] = [] files = file_path representation = { 'name': 'vrscene', 'ext': 'vrscene', 'files': os.path.basename(files), "stagingDir": os.path.dirname(files), } instance.data["representations"].append(representation) self.log.debug("Extracted instance '%s' to: %s" % (instance.name, staging_dir)) @staticmethod def format_vray_output_filename( filename, layer, template, start_frame=None): """Format the expected output file of the Export job. Example: filename: /mnt/projects/foo/shot010_v006.mb template: // result: "shot010_v006/CHARS/CHARS.vrscene" Args: filename (str): path to scene file. layer (str): layer name. template (str): token template. start_frame (int, optional): start frame - if set we use multiple files export mode. Returns: str: formatted path. """ # format template to match pythons format specs template = re.sub(r"<(\w+?)>", r"{\1}", template.lower()) # Ensure filename has no extension file_name, _ = os.path.splitext(filename) mapping = { "scene": file_name, "layer": layer } output_path = template.format(**mapping) if start_frame: filename_zero = "{}_{:04d}.vrscene".format( output_path, start_frame) else: filename_zero = "{}.vrscene".format(output_path) result = filename_zero.replace("\\", "/") return result ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py ================================================ import os import shutil import copy from maya import cmds import pyblish.api from openpype.hosts.maya.api.alembic import extract_alembic from openpype.pipeline import publish from openpype.lib import StringTemplate class ExtractWorkfileXgen(publish.Extractor): """Extract Workfile Xgen. When submitting a render, we need to prep Xgen side car files. """ # Offset to run before workfile scene save. order = pyblish.api.ExtractorOrder - 0.499 label = "Extract Workfile Xgen" families = ["workfile"] hosts = ["maya"] def get_render_max_frame_range(self, context): """Return start to end frame range including all renderlayers in context. This will return the full frame range which includes all frames of the renderlayer instances to be published/submitted. Args: context (pyblish.api.Context): Current publishing context. Returns: tuple or None: Start frame, end frame tuple if any renderlayers found. Otherwise None is returned. """ def _is_active_renderlayer(i): """Return whether instance is active renderlayer""" if not i.data.get("publish", True): return False is_renderlayer = ( "renderlayer" in i.data.get("families", []) or i.data["family"] == "renderlayer" ) return is_renderlayer start_frame = None end_frame = None for instance in context: if not _is_active_renderlayer(instance): # Only consider renderlyare instances continue render_start_frame = instance.data["frameStart"] render_end_frame = instance.data["frameEnd"] if start_frame is None: start_frame = render_start_frame else: start_frame = min(start_frame, render_start_frame) if end_frame is None: end_frame = render_end_frame else: end_frame = max(end_frame, render_end_frame) if start_frame is None or end_frame is None: return return start_frame, end_frame def process(self, instance): transfers = [] # Validate there is any palettes in the scene. if not cmds.ls(type="xgmPalette"): self.log.debug( "No collections found in the scene. Skipping Xgen extraction." ) return import xgenm # Validate to extract only when we are publishing a renderlayer as # well. render_range = self.get_render_max_frame_range(instance.context) if not render_range: self.log.debug( "No publishable renderlayers found in context. Skipping Xgen" " extraction." ) return start_frame, end_frame = render_range # We decrement start frame and increment end frame so motion blur will # render correctly. start_frame -= 1 end_frame += 1 # Extract patches alembic. path_no_ext, _ = os.path.splitext(instance.context.data["currentFile"]) kwargs = {"attrPrefix": ["xgen"], "stripNamespaces": True} alembic_files = [] for palette in cmds.ls(type="xgmPalette"): patch_names = [] for description in xgenm.descriptions(palette): for name in xgenm.boundGeometry(palette, description): patch_names.append(name) alembic_file = "{}__{}.abc".format( path_no_ext, palette.replace(":", "__ns__") ) extract_alembic( alembic_file, root=patch_names, selection=False, startFrame=float(start_frame), endFrame=float(end_frame), verbose=True, **kwargs ) alembic_files.append(alembic_file) template_data = copy.deepcopy(instance.data["anatomyData"]) published_maya_path = StringTemplate( instance.context.data["anatomy"].templates["publish"]["file"] ).format(template_data) published_basename, _ = os.path.splitext(published_maya_path) for source in alembic_files: destination = os.path.join( os.path.dirname(instance.data["resourcesDir"]), os.path.basename( source.replace(path_no_ext, published_basename) ) ) transfers.append((source, destination)) # Validate that we are using the published workfile. deadline_settings = instance.context.get("deadline") if deadline_settings: publish_settings = deadline_settings["publish"] if not publish_settings["MayaSubmitDeadline"]["use_published"]: self.log.debug( "Not using the published workfile. Abort Xgen extraction." ) return # Collect Xgen and Delta files. xgen_files = [] sources = [] current_dir = os.path.dirname(instance.context.data["currentFile"]) attrs = ["xgFileName", "xgBaseFile"] for palette in cmds.ls(type="xgmPalette"): for attr in attrs: source = os.path.join( current_dir, cmds.getAttr(palette + "." + attr) ) if not os.path.exists(source): continue ext = os.path.splitext(source)[1] if ext == ".xgen": xgen_files.append(source) if ext == ".xgd": sources.append(source) # Copy .xgen file to temporary location and modify. staging_dir = self.staging_dir(instance) for source in xgen_files: destination = os.path.join(staging_dir, os.path.basename(source)) shutil.copy(source, destination) lines = [] with open(destination, "r") as f: for line in [line.rstrip() for line in f]: if line.startswith("\txgProjectPath"): path = os.path.dirname(instance.data["resourcesDir"]) line = "\txgProjectPath\t\t{}/".format( path.replace("\\", "/") ) lines.append(line) with open(destination, "w") as f: f.write("\n".join(lines)) sources.append(destination) # Add resource files to workfile instance. for source in sources: basename = os.path.basename(source) destination = os.path.join( os.path.dirname(instance.data["resourcesDir"]), basename ) transfers.append((source, destination)) destination_dir = os.path.join( instance.data["resourcesDir"], "collections" ) for palette in cmds.ls(type="xgmPalette"): project_path = xgenm.getAttr("xgProjectPath", palette) data_path = xgenm.getAttr("xgDataPath", palette) data_path = data_path.replace("${PROJECT}", project_path) for path in data_path.split(";"): for root, _, files in os.walk(path): for f in files: source = os.path.join(root, f) destination = "{}/{}{}".format( destination_dir, palette.replace(":", "__ns__"), source.replace(path, "") ) transfers.append((source, destination)) for source, destination in transfers: self.log.debug("Transfer: {} > {}".format(source, destination)) instance.data["transfers"] = transfers # Set palette attributes in preparation for workfile publish. attrs = {"xgFileName": None, "xgBaseFile": ""} data = {} for palette in cmds.ls(type="xgmPalette"): attrs["xgFileName"] = "resources/{}.xgen".format( palette.replace(":", "__ns__") ) for attr, value in attrs.items(): node_attr = palette + "." + attr old_value = cmds.getAttr(node_attr) try: data[palette][attr] = old_value except KeyError: data[palette] = {attr: old_value} cmds.setAttr(node_attr, value, type="string") self.log.debug( "Setting \"{}\" on \"{}\"".format(value, node_attr) ) cmds.setAttr(palette + "." + "xgExportAsDelta", False) instance.data["xgenAttributes"] = data ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_xgen.py ================================================ import os import copy import tempfile from maya import cmds import xgenm from openpype.pipeline import publish from openpype.hosts.maya.api.lib import ( maintained_selection, attribute_values, write_xgen_file, delete_after ) from openpype.lib import StringTemplate class ExtractXgen(publish.Extractor): """Extract Xgen Workflow: - Duplicate nodes used for patches. - Export palette and import onto duplicate nodes. - Export/Publish duplicate nodes and palette. - Export duplicate palette to .xgen file and add to publish. - Publish all xgen files as resources. """ label = "Extract Xgen" hosts = ["maya"] families = ["xgen"] scene_type = "ma" def process(self, instance): if "representations" not in instance.data: instance.data["representations"] = [] staging_dir = self.staging_dir(instance) maya_filename = "{}.{}".format(instance.data["name"], self.scene_type) maya_filepath = os.path.join(staging_dir, maya_filename) # Get published xgen file name. template_data = copy.deepcopy(instance.data["anatomyData"]) template_data.update({"ext": "xgen"}) templates = instance.context.data["anatomy"].templates["publish"] xgen_filename = StringTemplate(templates["file"]).format(template_data) xgen_path = os.path.join( self.staging_dir(instance), xgen_filename ).replace("\\", "/") type = "mayaAscii" if self.scene_type == "ma" else "mayaBinary" # Duplicate xgen setup. with delete_after() as delete_bin: duplicate_nodes = [] # Collect nodes to export. for node in instance.data["xgenConnections"]: # Duplicate_transform subd patch geometry. duplicate_transform = cmds.duplicate(node)[0] delete_bin.append(duplicate_transform) # Discard the children. shapes = cmds.listRelatives(duplicate_transform, shapes=True) children = cmds.listRelatives( duplicate_transform, children=True ) cmds.delete(set(children) - set(shapes)) if cmds.listRelatives(duplicate_transform, parent=True): duplicate_transform = cmds.parent( duplicate_transform, world=True )[0] duplicate_nodes.append(duplicate_transform) # Export temp xgen palette files. temp_xgen_path = os.path.join( tempfile.gettempdir(), "temp.xgen" ).replace("\\", "/") xgenm.exportPalette( instance.data["xgmPalette"].replace("|", ""), temp_xgen_path ) self.log.debug("Extracted to {}".format(temp_xgen_path)) # Import xgen onto the duplicate. with maintained_selection(): cmds.select(duplicate_nodes) palette = xgenm.importPalette(temp_xgen_path, []) delete_bin.append(palette) # Copy shading assignments. nodes = ( instance.data["xgmDescriptions"] + instance.data["xgmSubdPatches"] ) for node in nodes: target_node = node.split(":")[-1] shading_engine = cmds.listConnections( node, type="shadingEngine" )[0] cmds.sets(target_node, edit=True, forceElement=shading_engine) # Export duplicated palettes. xgenm.exportPalette(palette, xgen_path) # Export Maya file. attribute_data = {"{}.xgFileName".format(palette): xgen_filename} with attribute_values(attribute_data): with maintained_selection(): cmds.select(duplicate_nodes + [palette]) cmds.file( maya_filepath, force=True, type=type, exportSelected=True, preserveReferences=False, constructionHistory=True, shader=True, constraints=True, expressions=True ) self.log.debug("Extracted to {}".format(maya_filepath)) if os.path.exists(temp_xgen_path): os.remove(temp_xgen_path) data = { "xgDataPath": os.path.join( instance.data["resourcesDir"], "collections", palette.replace(":", "__ns__") ).replace("\\", "/"), "xgProjectPath": os.path.dirname( instance.data["resourcesDir"] ).replace("\\", "/") } write_xgen_file(data, xgen_path) # Adding representations. representation = { "name": "xgen", "ext": "xgen", "files": xgen_filename, "stagingDir": staging_dir, } instance.data["representations"].append(representation) representation = { "name": self.scene_type, "ext": self.scene_type, "files": maya_filename, "stagingDir": staging_dir } instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_yeti_cache.py ================================================ import os import json from maya import cmds from openpype.pipeline import publish class ExtractYetiCache(publish.Extractor): """Producing Yeti cache files using scene time range. This will extract Yeti cache file sequence and fur settings. """ label = "Extract Yeti Cache" hosts = ["maya"] families = ["yetiRig", "yeticache"] def process(self, instance): yeti_nodes = cmds.ls(instance, type="pgYetiMaya") if not yeti_nodes: raise RuntimeError("No pgYetiMaya nodes found in the instance") # Define extract output file path dirname = self.staging_dir(instance) # Collect information for writing cache start_frame = instance.data["frameStartHandle"] end_frame = instance.data["frameEndHandle"] preroll = instance.data["preroll"] if preroll > 0: start_frame -= preroll kwargs = {} samples = instance.data.get("samples", 0) if samples == 0: kwargs.update({"sampleTimes": "0.0 1.0"}) else: kwargs.update({"samples": samples}) self.log.debug( "Writing out cache {} - {}".format(start_frame, end_frame)) # Start writing the files for snap shot # will be replace by the Yeti node name path = os.path.join(dirname, ".%04d.fur") cmds.pgYetiCommand(yeti_nodes, writeCache=path, range=(start_frame, end_frame), updateViewport=False, generatePreview=False, **kwargs) cache_files = [x for x in os.listdir(dirname) if x.endswith(".fur")] self.log.debug("Writing metadata file") settings = instance.data["fursettings"] fursettings_path = os.path.join(dirname, "yeti.fursettings") with open(fursettings_path, "w") as fp: json.dump(settings, fp, ensure_ascii=False) # build representations if "representations" not in instance.data: instance.data["representations"] = [] self.log.debug("cache files: {}".format(cache_files[0])) # Workaround: We do not explicitly register these files with the # representation solely so that we can write multiple sequences # a single Subset without renaming - it's a bit of a hack # TODO: Implement better way to manage this sort of integration if 'transfers' not in instance.data: instance.data['transfers'] = [] publish_dir = instance.data["publishDir"] for cache_filename in cache_files: src = os.path.join(dirname, cache_filename) dst = os.path.join(publish_dir, os.path.basename(cache_filename)) instance.data['transfers'].append([src, dst]) instance.data["representations"].append( { 'name': 'fur', 'ext': 'fursettings', 'files': os.path.basename(fursettings_path), 'stagingDir': dirname } ) self.log.debug("Extracted {} to {}".format(instance, dirname)) ================================================ FILE: openpype/hosts/maya/plugins/publish/extract_yeti_rig.py ================================================ # -*- coding: utf-8 -*- """Extract Yeti rig.""" import os import json import contextlib from maya import cmds from openpype.pipeline import publish from openpype.hosts.maya.api import lib @contextlib.contextmanager def disconnect_plugs(settings, members): """Disconnect and store attribute connections.""" members = cmds.ls(members, long=True) original_connections = [] try: for input in settings["inputs"]: # Get source shapes source_nodes = lib.lsattr("cbId", input["sourceID"]) if not source_nodes: continue source = next(s for s in source_nodes if s not in members) # Get destination shapes (the shapes used as hook up) destination_nodes = lib.lsattr("cbId", input["destinationID"]) destination = next(i for i in destination_nodes if i in members) # Create full connection connections = input["connections"] src_attribute = "%s.%s" % (source, connections[0]) dst_attribute = "%s.%s" % (destination, connections[1]) # Check if there is an actual connection if not cmds.isConnected(src_attribute, dst_attribute): print("No connection between %s and %s" % ( src_attribute, dst_attribute)) continue # Break and store connection cmds.disconnectAttr(src_attribute, dst_attribute) original_connections.append([src_attribute, dst_attribute]) yield finally: # Restore previous connections for connection in original_connections: try: cmds.connectAttr(connection[0], connection[1]) except Exception as e: print(e) continue @contextlib.contextmanager def yetigraph_attribute_values(assumed_destination, resources): """Get values from Yeti attributes in graph.""" try: for resource in resources: if "graphnode" not in resource: continue fname = os.path.basename(resource["source"]) new_fpath = os.path.join(assumed_destination, fname) new_fpath = new_fpath.replace("\\", "/") try: cmds.pgYetiGraph(resource["node"], node=resource["graphnode"], param=resource["param"], setParamValueString=new_fpath) except Exception as exc: print(">>> Exception:", exc) yield finally: for resource in resources: if "graphnode" not in resources: continue try: cmds.pgYetiGraph(resource["node"], node=resource["graphnode"], param=resource["param"], setParamValue=resource["source"]) except RuntimeError: pass class ExtractYetiRig(publish.Extractor): """Extract the Yeti rig to a Maya Scene and write the Yeti rig data.""" label = "Extract Yeti Rig" hosts = ["maya"] families = ["yetiRig"] scene_type = "ma" def process(self, instance): """Plugin entry point.""" ext_mapping = ( instance.context.data["project_settings"]["maya"]["ext_mapping"] ) if ext_mapping: self.log.debug("Looking in settings for scene type ...") # use extension mapping for first family found for family in self.families: try: self.scene_type = ext_mapping[family] self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: # no preset found pass yeti_nodes = cmds.ls(instance, type="pgYetiMaya") if not yeti_nodes: raise RuntimeError("No pgYetiMaya nodes found in the instance") # Define extract output file path dirname = self.staging_dir(instance) settings_path = os.path.join(dirname, "yeti.rigsettings") # Yeti related staging dirs maya_path = os.path.join(dirname, "yeti_rig.{}".format(self.scene_type)) self.log.debug("Writing metadata file: {}".format(settings_path)) image_search_path = resources_dir = instance.data["resourcesDir"] settings = instance.data.get("rigsettings", None) assert settings, "Yeti rig settings were not collected." settings["imageSearchPath"] = image_search_path with open(settings_path, "w") as fp: json.dump(settings, fp, ensure_ascii=False) # add textures to transfers if 'transfers' not in instance.data: instance.data['transfers'] = [] for resource in instance.data.get('resources', []): for file in resource['files']: src = file dst = os.path.join(image_search_path, os.path.basename(file)) instance.data['transfers'].append([src, dst]) self.log.debug("adding transfer {} -> {}". format(src, dst)) # Ensure the imageSearchPath is being remapped to the publish folder attr_value = {"%s.imageSearchPath" % n: str(image_search_path) for n in yeti_nodes} # Get input_SET members input_set = next(i for i in instance if i == "input_SET") # Get all items set_members = cmds.sets(input_set, query=True) or [] set_members += cmds.listRelatives(set_members, allDescendents=True, fullPath=True) or [] members = cmds.ls(set_members, long=True) nodes = instance.data["setMembers"] resources = instance.data.get("resources", {}) with disconnect_plugs(settings, members): with yetigraph_attribute_values(resources_dir, resources): with lib.attribute_values(attr_value): cmds.select(nodes, noExpand=True) cmds.file(maya_path, force=True, exportSelected=True, typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 preserveReferences=False, constructionHistory=True, shader=False) # Ensure files can be stored # build representations if "representations" not in instance.data: instance.data["representations"] = [] self.log.debug("rig file: {}".format(maya_path)) instance.data["representations"].append( { 'name': self.scene_type, 'ext': self.scene_type, 'files': os.path.basename(maya_path), 'stagingDir': dirname } ) self.log.debug("settings file: {}".format(settings_path)) instance.data["representations"].append( { 'name': 'rigsettings', 'ext': 'rigsettings', 'files': os.path.basename(settings_path), 'stagingDir': dirname } ) self.log.debug("Extracted {} to {}".format(instance, dirname)) cmds.select(clear=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/help/submit_maya_remote_publish_deadline.xml ================================================ Errors found ## Publish process has errors At least one plugin failed before this plugin, job won't be sent to Deadline for processing before all issues are fixed. ### How to repair? Check all failing plugins (should be highlighted in red) and fix issues if possible. ================================================ FILE: openpype/hosts/maya/plugins/publish/help/validate_maya_units.xml ================================================ Maya scene units ## Invalid maya scene units Detected invalid maya scene units: {issues} ### How to repair? You can automatically repair the scene units by clicking the Repair action on the right. After that restart publishing with Reload button. ================================================ FILE: openpype/hosts/maya/plugins/publish/help/validate_node_ids.xml ================================================ Missing node ids ## Nodes found with missing `cbId` Nodes were detected in your scene which are missing required `cbId` attributes for identification further in the pipeline. ### How to repair? The node ids are auto-generated on scene save, and thus the easiest fix is to save your scene again. After that restart publishing with Reload button. ### Invalid nodes {nodes} ### How could this happen? This often happens if you've generated new nodes but haven't saved your scene after creating the new nodes. ================================================ FILE: openpype/hosts/maya/plugins/publish/help/validate_skeletalmesh_hierarchy.xml ================================================ Skeletal Mesh Top Node ## Skeletal meshes needs common root Skeletal meshes and their joints must be under one common root. ### How to repair? Make sure all geometry and joints resides under same root. ================================================ FILE: openpype/hosts/maya/plugins/publish/increment_current_file_deadline.py ================================================ import pyblish.api class IncrementCurrentFileDeadline(pyblish.api.ContextPlugin): """Increment the current file. Saves the current maya scene with an increased version number. """ label = "Increment current file" order = pyblish.api.IntegratorOrder + 9.0 hosts = ["maya"] families = ["workfile"] optional = True def process(self, context): from maya import cmds from openpype.lib import version_up from openpype.pipeline.publish import get_errored_plugins_from_context errored_plugins = get_errored_plugins_from_context(context) if any(plugin.__name__ == "MayaSubmitDeadline" for plugin in errored_plugins): raise RuntimeError("Skipping incrementing current file because " "submission to deadline failed.") current_filepath = context.data["currentFile"] new_filepath = version_up(current_filepath) # # Ensure the suffix is .ma because we're saving to `mayaAscii` type if new_filepath.endswith(".ma"): fileType = "mayaAscii" elif new_filepath.endswith(".mb"): fileType = "mayaBinary" cmds.file(rename=new_filepath) cmds.file(save=True, force=True, type=fileType) ================================================ FILE: openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py ================================================ from maya import cmds import pyblish.api class ResetXgenAttributes(pyblish.api.InstancePlugin): """Reset Xgen attributes. When the incremental save of the workfile triggers, the Xgen attributes changes so this plugin will change it back to the values before publishing. """ label = "Reset Xgen Attributes." # Offset to run after workfile increment plugin. order = pyblish.api.IntegratorOrder + 10.0 families = ["workfile"] def process(self, instance): xgen_attributes = instance.data.get("xgenAttributes", {}) if not xgen_attributes: return for palette, data in xgen_attributes.items(): for attr, value in data.items(): node_attr = "{}.{}".format(palette, attr) self.log.debug( "Setting \"{}\" on \"{}\"".format(value, node_attr) ) cmds.setAttr(node_attr, value, type="string") cmds.setAttr(palette + ".xgExportAsDelta", True) # Need to save the scene, cause the attribute changes above does not # mark the scene as modified so user can exit without committing the # changes. self.log.debug("Saving changes.") cmds.file(save=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/save_scene.py ================================================ import pyblish.api from openpype.pipeline.workfile.lock_workfile import ( is_workfile_lock_enabled, remove_workfile_lock ) class SaveCurrentScene(pyblish.api.ContextPlugin): """Save current scene """ label = "Save current file" order = pyblish.api.ExtractorOrder - 0.49 hosts = ["maya"] families = ["renderlayer", "workfile"] def process(self, context): import maya.cmds as cmds current = cmds.file(query=True, sceneName=True) assert context.data['currentFile'] == current # If file has no modifications, skip forcing a file save if not cmds.file(query=True, modified=True): self.log.debug("Skipping file save as there " "are no modifications..") return project_name = context.data["projectName"] project_settings = context.data["project_settings"] # remove lockfile before saving if is_workfile_lock_enabled("maya", project_name, project_settings): remove_workfile_lock(current) self.log.info("Saving current file: {}".format(current)) cmds.file(save=True, force=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_alembic_options_defaults.py ================================================ import pyblish.api from openpype.pipeline import OptionalPyblishPluginMixin from openpype.pipeline.publish import RepairAction, PublishValidationError class ValidateAlembicOptionsDefaults( pyblish.api.InstancePlugin, OptionalPyblishPluginMixin ): """Validate the attributes on the instance are defaults.""" order = pyblish.api.ValidatorOrder families = ["pointcache", "animation"] hosts = ["maya"] label = "Validate Alembic Options Defaults" actions = [RepairAction] optional = True @classmethod def _get_plugin_name(self, publish_attributes): for key in ["ExtractAnimation", "ExtractAlembic"]: if key in publish_attributes.keys(): return key @classmethod def _get_settings(self, context): maya_settings = context.data["project_settings"]["maya"] settings = maya_settings["publish"]["ExtractAlembic"] # Flags are a special case since they are a combination of overrides # and default flags from the settings. settings["flags"] = [ x for x in settings["flags"] if x in settings["overrides"] ] return settings @classmethod def _get_publish_attributes(self, instance): attributes = instance.data["publish_attributes"][ self._get_plugin_name( instance.data["publish_attributes"] ) ] settings = self._get_settings(instance.context) # Flags are a special case since they are a combination of exposed # flags and default flags from the settings. So we need to add the # default flags from the settings and ensure unique items. non_exposed_flags = [ x for x in settings["flags"] if x not in settings["overrides"] ] attributes["flags"] = attributes["flags"] + non_exposed_flags return attributes def process(self, instance): if not self.is_active(instance.data): return settings = self._get_settings(instance.context) attributes = self._get_publish_attributes(instance) msg = ( "Alembic Extract setting \"{}\" is not the default value:" "\nCurrent: {}" "\nDefault Value: {}\n" ) errors = [] for key, value in attributes.items(): default_value = settings[key] # Lists are best to compared sorted since we cant rely on the order # of the items. if isinstance(value, list): value = sorted(value) default_value = sorted(default_value) if value != default_value: errors.append(msg.format(key, value, default_value)) if errors: raise PublishValidationError("\n".join(errors)) @classmethod def repair(cls, instance): # Find create instance twin. create_context = instance.context.data["create_context"] create_instance = None for Instance in create_context.instances: if Instance.data["instance_id"] == instance.data["instance_id"]: create_instance = Instance break assert create_instance is not None # Set the settings values on the create context then save to workfile. publish_attributes = instance.data["publish_attributes"] plugin_name = cls._get_plugin_name(publish_attributes) attributes = cls._get_publish_attributes(instance) settings = cls._get_settings(instance.context) create_publish_attributes = create_instance.data["publish_attributes"] for key in attributes.keys(): create_publish_attributes[plugin_name][key] = settings[key] create_context.save_changes() ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_animated_reference.py ================================================ import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder, OptionalPyblishPluginMixin ) from maya import cmds class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate all nodes in skeletonAnim_SET are referenced""" order = ValidateContentsOrder hosts = ["maya"] families = ["animation.fbx"] label = "Animated Reference Rig" accepted_controllers = ["transform", "locator"] actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False def process(self, instance): if not self.is_active(instance.data): return animated_sets = instance.data.get("animated_skeleton", []) if not animated_sets: self.log.debug( "No nodes found in skeletonAnim_SET. " "Skipping validation of animated reference rig..." ) return for animated_reference in animated_sets: is_referenced = cmds.referenceQuery( animated_reference, isNodeReferenced=True) if not bool(is_referenced): raise PublishValidationError( "All the content in skeletonAnim_SET" " should be referenced nodes" ) invalid_controls = self.validate_controls(animated_sets) if invalid_controls: raise PublishValidationError( "All the content in skeletonAnim_SET" " should be transforms" ) @classmethod def validate_controls(self, set_members): """Check if the controller set contains only accepted node types. Checks if all its set members are within the hierarchy of the root Checks if the node types of the set members valid Args: set_members: list of nodes of the skeleton_anim_set hierarchy: list of nodes which reside under the root node Returns: errors (list) """ # Validate control types invalid = [] set_members = cmds.ls(set_members, long=True) for node in set_members: if cmds.nodeType(node) not in self.accepted_controllers: invalid.append(node) return invalid ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_animation_content.py ================================================ import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder, OptionalPyblishPluginMixin ) class ValidateAnimationContent(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Adheres to the content of 'animation' product type - Must have collected `out_hierarchy` data. - All nodes in `out_hierarchy` must be in the instance. """ order = ValidateContentsOrder hosts = ["maya"] families = ["animation"] label = "Animation Content" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False @classmethod def get_invalid(cls, instance): out_set = next((i for i in instance.data["setMembers"] if i.endswith("out_SET")), None) assert out_set, ("Instance '%s' has no objectSet named: `OUT_set`. " "If this instance is an unloaded reference, " "please deactivate by toggling the 'Active' attribute" % instance.name) assert 'out_hierarchy' in instance.data, "Missing `out_hierarchy` data" out_sets = [node for node in instance if node.endswith("out_SET")] msg = "Couldn't find exactly one out_SET: {0}".format(out_sets) assert len(out_sets) == 1, msg # All nodes in the `out_hierarchy` must be among the nodes that are # in the instance. The nodes in the instance are found from the top # group, as such this tests whether all nodes are under that top group. lookup = set(instance[:]) invalid = [node for node in instance.data['out_hierarchy'] if node not in lookup] return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Animation content is invalid. See log.") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_animation_out_set_related_node_ids.py ================================================ import maya.cmds as cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateOutRelatedNodeIds(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate if deformed shapes have related IDs to the original shapes When a deformer is applied in the scene on a referenced mesh that already had deformers then Maya will create a new shape node for the mesh that does not have the original id. This validator checks whether the ids are valid on all the shape nodes in the instance. """ order = ValidateContentsOrder families = ['animation', "pointcache", "proxyAbc"] hosts = ['maya'] label = 'Animation Out Set Related Node Ids' actions = [ openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction ] optional = False def process(self, instance): """Process all meshes""" if not self.is_active(instance.data): return # Ensure all nodes have a cbId and a related ID to the original shapes # if a deformer has been created on the shape invalid = self.get_invalid(instance) if invalid: # TODO: Message formatting can be improved raise PublishValidationError("Nodes found with mismatching " "IDs: {0}".format(invalid), title="Invalid node ids") @classmethod def get_invalid(cls, instance): """Get all nodes which do not match the criteria""" invalid = [] types_to_skip = ["locator"] # get asset id nodes = instance.data.get("out_hierarchy", instance[:]) for node in nodes: # We only check when the node is *not* referenced if cmds.referenceQuery(node, isNodeReferenced=True): continue # Check if node is a shape as deformers only work on shapes obj_type = cmds.objectType(node, isAType="shape") if not obj_type: continue # Skip specific types if cmds.objectType(node) in types_to_skip: continue # Get the current id of the node node_id = lib.get_id(node) if not node_id: invalid.append(node) continue history_id = lib.get_id_from_sibling(node) if history_id is not None and node_id != history_id: invalid.append(node) return invalid @classmethod def repair(cls, instance): for node in cls.get_invalid(instance): # Get the original id from history history_id = lib.get_id_from_sibling(node) if not history_id: cls.log.error("Could not find ID in history for '%s'", node) continue lib.set_id(node, history_id, overwrite=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_arnold_scene_source.py ================================================ from maya import cmds import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError ) from openpype.hosts.maya.api.lib import is_visible class ValidateArnoldSceneSource(pyblish.api.InstancePlugin): """Validate Arnold Scene Source. Ensure no nodes are hidden. """ order = ValidateContentsOrder hosts = ["maya"] families = ["ass", "assProxy"] label = "Validate Arnold Scene Source" def process(self, instance): # Validate against having nodes hidden, which will result in the # extraction to ignore the node. nodes = instance.data["members"] + instance.data.get("proxy", []) nodes = [x for x in nodes if cmds.objectType(x, isAType='dagNode')] hidden_nodes = [ x for x in nodes if not is_visible(x, intermediateObject=False) ] if hidden_nodes: raise PublishValidationError( "Found hidden nodes:\n\n{}\n\nPlease unhide for" " publishing.".format("\n".join(hidden_nodes)) ) class ValidateArnoldSceneSourceProxy(pyblish.api.InstancePlugin): """Validate Arnold Scene Source Proxy. When using proxies we need the nodes to share the same names and not be parent to the world. This ends up needing at least two groups with content nodes and proxy nodes in another. """ order = ValidateContentsOrder hosts = ["maya"] families = ["assProxy"] label = "Validate Arnold Scene Source Proxy" def _get_nodes_by_name(self, nodes): ungrouped_nodes = [] nodes_by_name = {} parents = [] for node in nodes: node_split = node.split("|") if len(node_split) == 2: ungrouped_nodes.append(node) parent = "|".join(node_split[:-1]) if parent: parents.append(parent) node_name = node.rsplit("|", 1)[-1].rsplit(":", 1)[-1] nodes_by_name[node_name] = node return ungrouped_nodes, nodes_by_name, parents def process(self, instance): # Validate against nodes directly parented to world. ungrouped_nodes = [] nodes, content_nodes_by_name, content_parents = ( self._get_nodes_by_name(instance.data["members"]) ) ungrouped_nodes.extend(nodes) nodes, proxy_nodes_by_name, proxy_parents = self._get_nodes_by_name( instance.data.get("proxy", []) ) ungrouped_nodes.extend(nodes) if ungrouped_nodes: raise PublishValidationError( "Found nodes parented to the world: {}\n" "All nodes need to be grouped.".format(ungrouped_nodes) ) # Validate for content and proxy nodes amount being the same. if len(instance.data["members"]) != len(instance.data["proxy"]): raise PublishValidationError( "Amount of content nodes ({}) and proxy nodes ({}) needs to " "be the same.\nContent nodes: {}\nProxy nodes:{}".format( len(instance.data["members"]), len(instance.data["proxy"]), instance.data["members"], instance.data["proxy"] ) ) # Validate against content and proxy nodes sharing same parent. if list(set(content_parents) & set(proxy_parents)): raise PublishValidationError( "Content and proxy nodes cannot share the same parent." ) # Validate for content and proxy nodes sharing same names. sorted_content_names = sorted(content_nodes_by_name.keys()) sorted_proxy_names = sorted(proxy_nodes_by_name.keys()) odd_content_names = list( set(sorted_content_names) - set(sorted_proxy_names) ) odd_content_nodes = [ content_nodes_by_name[x] for x in odd_content_names ] odd_proxy_names = list( set(sorted_proxy_names) - set(sorted_content_names) ) odd_proxy_nodes = [ proxy_nodes_by_name[x] for x in odd_proxy_names ] if not sorted_content_names == sorted_proxy_names: raise PublishValidationError( "Content and proxy nodes need to share the same names.\n" "Content nodes not matching: {}\n" "Proxy nodes not matching: {}".format( odd_content_nodes, odd_proxy_nodes ) ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_arnold_scene_source_cbid.py ================================================ import pyblish.api from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError, RepairAction ) class ValidateArnoldSceneSourceCbid(pyblish.api.InstancePlugin): """Validate Arnold Scene Source Cbid. It is required for the proxy and content nodes to share the same cbid. """ order = ValidateContentsOrder hosts = ["maya"] families = ["assProxy"] label = "Validate Arnold Scene Source CBID" actions = [RepairAction] @staticmethod def _get_nodes_by_name(nodes): nodes_by_name = {} for node in nodes: node_name = node.rsplit("|", 1)[-1].rsplit(":", 1)[-1] nodes_by_name[node_name] = node return nodes_by_name @classmethod def get_invalid_couples(cls, instance): nodes_by_name = cls._get_nodes_by_name(instance.data["members"]) proxy_nodes_by_name = cls._get_nodes_by_name(instance.data["proxy"]) invalid_couples = [] for content_name, content_node in nodes_by_name.items(): proxy_node = proxy_nodes_by_name.get(content_name, None) if not proxy_node: cls.log.debug( "Content node '{}' has no matching proxy node.".format( content_node ) ) continue content_id = lib.get_id(content_node) proxy_id = lib.get_id(proxy_node) if content_id != proxy_id: invalid_couples.append((content_node, proxy_node)) return invalid_couples def process(self, instance): # Proxy validation. if not instance.data["proxy"]: return # Validate for proxy nodes sharing the same cbId as content nodes. invalid_couples = self.get_invalid_couples(instance) if invalid_couples: raise PublishValidationError( "Found proxy nodes with mismatching cbid:\n{}".format( invalid_couples ) ) @classmethod def repair(cls, instance): for content_node, proxy_node in cls.get_invalid_couples(instance): lib.set_id(proxy_node, lib.get_id(content_node), overwrite=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py ================================================ import os import types import maya.cmds as cmds from mtoa.core import createOptions import pyblish.api from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateAssRelativePaths(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Ensure exporting ass file has set relative texture paths""" order = ValidateContentsOrder hosts = ['maya'] families = ['ass'] label = "ASS has relative texture paths" actions = [RepairAction] optional = False def process(self, instance): if not self.is_active(instance.data): return # we cannot ask this until user open render settings as # `defaultArnoldRenderOptions` doesn't exist errors = [] try: absolute_texture = cmds.getAttr( "defaultArnoldRenderOptions.absolute_texture_paths") absolute_procedural = cmds.getAttr( "defaultArnoldRenderOptions.absolute_procedural_paths") texture_search_path = cmds.getAttr( "defaultArnoldRenderOptions.tspath" ) procedural_search_path = cmds.getAttr( "defaultArnoldRenderOptions.pspath" ) except ValueError: raise PublishValidationError( "Default Arnold options has not been created yet." ) scene_dir, scene_basename = os.path.split(cmds.file(q=True, loc=True)) scene_name, _ = os.path.splitext(scene_basename) if self.maya_is_true(absolute_texture): errors.append("Texture path is set to be absolute") if self.maya_is_true(absolute_procedural): errors.append("Procedural path is set to be absolute") anatomy = instance.context.data["anatomy"] # Use project root variables for multiplatform support, see: # https://docs.arnoldrenderer.com/display/A5AFMUG/Search+Path # ':' as path separator is supported by Arnold for all platforms. keys = anatomy.root_environments().keys() paths = [] for k in keys: paths.append("[{}]".format(k)) self.log.debug("discovered roots: {}".format(":".join(paths))) if ":".join(paths) not in texture_search_path: errors.append(( "Project roots {} are not in texture_search_path: {}" ).format(paths, texture_search_path)) if ":".join(paths) not in procedural_search_path: errors.append(( "Project roots {} are not in procedural_search_path: {}" ).format(paths, procedural_search_path)) if errors: raise PublishValidationError("\n".join(errors)) @classmethod def repair(cls, instance): createOptions() texture_path = cmds.getAttr("defaultArnoldRenderOptions.tspath") procedural_path = cmds.getAttr("defaultArnoldRenderOptions.pspath") # Use project root variables for multiplatform support, see: # https://docs.arnoldrenderer.com/display/A5AFMUG/Search+Path # ':' as path separator is supported by Arnold for all platforms. anatomy = instance.context.data["anatomy"] keys = anatomy.root_environments().keys() paths = [] for k in keys: paths.append("[{}]".format(k)) cmds.setAttr( "defaultArnoldRenderOptions.tspath", ":".join([p for p in paths + [texture_path] if p]), type="string" ) cmds.setAttr( "defaultArnoldRenderOptions.absolute_texture_paths", False ) cmds.setAttr( "defaultArnoldRenderOptions.pspath", ":".join([p for p in paths + [procedural_path] if p]), type="string" ) cmds.setAttr( "defaultArnoldRenderOptions.absolute_procedural_paths", False ) @staticmethod def find_absolute_path(relative_path, all_root_paths): for root_path in all_root_paths: possible_path = os.path.join(root_path, relative_path) if os.path.exists(possible_path): return possible_path def maya_is_true(self, attr_val): """ Whether a Maya attr evaluates to True. When querying an attribute value from an ambiguous object the Maya API will return a list of values, which need to be properly handled to evaluate properly. """ if isinstance(attr_val, bool): return attr_val elif isinstance(attr_val, (list, types.GeneratorType)): return any(attr_val) else: return bool(attr_val) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_assembly_name.py ================================================ import pyblish.api import maya.cmds as cmds import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, OptionalPyblishPluginMixin ) class ValidateAssemblyName(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """ Ensure Assembly name ends with `GRP` Check if assembly name ends with `_GRP` string. """ label = "Validate Assembly Name" order = pyblish.api.ValidatorOrder families = ["assembly"] actions = [openpype.hosts.maya.api.action.SelectInvalidAction] active = False optional = True @classmethod def get_invalid(cls, instance): cls.log.debug("Checking name of {}".format(instance.name)) content_instance = instance.data.get("setMembers", None) if not content_instance: cls.log.error("Instance has no nodes!") return True # All children will be included in the extracted export so we also # validate *all* descendents of the set members and we skip any # intermediate shapes descendants = cmds.listRelatives(content_instance, allDescendents=True, fullPath=True) or [] descendants = cmds.ls( descendants, noIntermediate=True, type="transform") content_instance = list(set(content_instance + descendants)) assemblies = cmds.ls(content_instance, assemblies=True, long=True) invalid = [] for cr in assemblies: if not cr.endswith('_GRP'): cls.log.error("{} doesn't end with _GRP".format(cr)) invalid.append(cr) return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError("Found {} invalid named assembly " "items".format(len(invalid))) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py ================================================ import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, OptionalPyblishPluginMixin ) class ValidateAssemblyNamespaces(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Ensure namespaces are not nested In the outliner an item in a normal namespace looks as following: props_desk_01_:modelDefault Any namespace which diverts from that is illegal, example of an illegal namespace: room_study_01_:props_desk_01_:modelDefault """ label = "Validate Assembly Namespaces" order = pyblish.api.ValidatorOrder families = ["assembly"] actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False def process(self, instance): if not self.is_active(instance.data): return self.log.debug("Checking namespace for %s" % instance.name) if self.get_invalid(instance): raise PublishValidationError("Nested namespaces found") @classmethod def get_invalid(cls, instance): from maya import cmds invalid = [] for item in cmds.ls(instance): item_parts = item.split("|", 1)[0].rsplit(":") if len(item_parts[:-1]) > 1: invalid.append(item) return invalid ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_assembly_transforms.py ================================================ import pyblish.api from maya import cmds import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, RepairAction, OptionalPyblishPluginMixin ) class ValidateAssemblyModelTransforms(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Verify only root nodes of the loaded asset have transformations. Note: This check is temporary and is subject to change. Example outliner: <> means referenced =================================================================== setdress_GRP| props_GRP| barrel_01_:modelDefault| [can have transforms] <> barrel_01_:barrel_GRP [CAN'T have transforms] fence_01_:modelDefault| [can have transforms] <> fence_01_:fence_GRP [CAN'T have transforms] """ order = pyblish.api.ValidatorOrder + 0.49 label = "Assembly Model Transforms" families = ["assembly"] actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] prompt_message = ("You are about to reset the matrix to the default values." " This can alter the look of your scene. " "Are you sure you want to continue?") optional = False def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( ("Found {} invalid transforms of assembly " "items").format(len(invalid))) @classmethod def get_invalid(cls, instance): from openpype.hosts.maya.api import lib # Get all transforms in the loaded containers container_roots = cmds.listRelatives(instance.data["nodesHierarchy"], children=True, type="transform", fullPath=True) transforms_in_container = cmds.listRelatives(container_roots, allDescendents=True, type="transform", fullPath=True) # Extra check due to the container roots still being passed through transforms_in_container = [i for i in transforms_in_container if i not in container_roots] # Ensure all are identity matrix invalid = [] for transform in transforms_in_container: node_matrix = cmds.xform(transform, query=True, matrix=True, objectSpace=True) if not lib.matrix_equals(node_matrix, lib.DEFAULT_MATRIX): invalid.append(transform) return invalid @classmethod def repair(cls, instance): """Reset matrix for illegally transformed nodes We want to ensure the user knows the reset will alter the look of the current scene because the transformations were done on asset nodes instead of the asset top node. Args: instance: Returns: None """ from qtpy import QtWidgets from openpype.hosts.maya.api import lib # Store namespace in variable, cosmetics thingy choice = QtWidgets.QMessageBox.warning( None, "Matrix reset", cls.prompt_message, QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel ) invalid = cls.get_invalid(instance) if not invalid: cls.log.info("No invalid nodes") return if choice: cmds.xform(invalid, matrix=lib.DEFAULT_MATRIX, objectSpace=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_attributes.py ================================================ from collections import defaultdict import pyblish.api from maya import cmds from openpype.hosts.maya.api.lib import set_attribute from openpype.pipeline.publish import ( OptionalPyblishPluginMixin, PublishValidationError, RepairAction, ValidateContentsOrder) class ValidateAttributes(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Ensure attributes are consistent. Attributes to validate and their values comes from the "maya/attributes.json" preset, which needs this structure: { "family": { "node_name.attribute_name": attribute_value } } """ order = ValidateContentsOrder label = "Attributes" hosts = ["maya"] actions = [RepairAction] optional = True attributes = None def process(self, instance): if not self.is_active(instance.data): return # Check for preset existence. if not self.attributes: return invalid = self.get_invalid(instance, compute=True) if invalid: raise PublishValidationError( "Found attributes with invalid values: {}".format(invalid) ) @classmethod def get_invalid(cls, instance, compute=False): if compute: return cls.get_invalid_attributes(instance) else: return instance.data.get("invalid_attributes", []) @classmethod def get_invalid_attributes(cls, instance): invalid_attributes = [] # Filter families. families = [instance.data["family"]] families += instance.data.get("families", []) families = set(families) & set(cls.attributes.keys()) if not families: return [] # Get all attributes to validate. attributes = defaultdict(dict) for family in families: if family not in cls.attributes: # No attributes to validate for family continue for preset_attr, preset_value in cls.attributes[family].items(): node_name, attribute_name = preset_attr.split(".", 1) attributes[node_name][attribute_name] = preset_value if not attributes: return [] # Get invalid attributes. nodes = cmds.ls(long=True) for node in nodes: node_name = node.rsplit("|", 1)[-1].rsplit(":", 1)[-1] if node_name not in attributes: continue for attr_name, expected in attributes[node_name].items(): # Skip if attribute does not exist if not cmds.attributeQuery(attr_name, node=node, exists=True): continue plug = "{}.{}".format(node, attr_name) value = cmds.getAttr(plug) if value != expected: invalid_attributes.append( { "attribute": plug, "expected": expected, "current": value } ) instance.data["invalid_attributes"] = invalid_attributes return invalid_attributes @classmethod def repair(cls, instance): invalid = cls.get_invalid(instance) for data in invalid: node, attr = data["attribute"].split(".", 1) value = data["expected"] set_attribute(node=node, attribute=attr, value=value) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_camera_attributes.py ================================================ import pyblish.api from maya import cmds import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder, OptionalPyblishPluginMixin ) class ValidateCameraAttributes(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates Camera has no invalid attribute keys or values. The Alembic file format does not a specific subset of attributes as such we validate that no values are set there as the output will not match the current scene. For example the preScale, film offsets and film roll. """ order = ValidateContentsOrder families = ['camera'] hosts = ['maya'] label = 'Camera Attributes' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = True DEFAULTS = [ ("filmFitOffset", 0.0), ("horizontalFilmOffset", 0.0), ("verticalFilmOffset", 0.0), ("preScale", 1.0), ("filmTranslateH", 0.0), ("filmTranslateV", 0.0), ("filmRollValue", 0.0) ] @classmethod def get_invalid(cls, instance): # get cameras members = instance.data['setMembers'] shapes = cmds.ls(members, dag=True, shapes=True, long=True) cameras = cmds.ls(shapes, type='camera', long=True) invalid = set() for cam in cameras: for attr, default_value in cls.DEFAULTS: plug = "{}.{}".format(cam, attr) value = cmds.getAttr(plug) # Check if is default value if value != default_value: cls.log.warning("Invalid attribute value: {0} " "(should be: {1}))".format(plug, default_value)) invalid.add(cam) if cmds.listConnections(plug, source=True, destination=False): # TODO: Validate correctly whether value always correct cls.log.warning("%s has incoming connections, validation " "is unpredictable." % plug) return list(invalid) def process(self, instance): """Process all the nodes in the instance""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Invalid camera attributes: {}".format(invalid)) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_camera_contents.py ================================================ import pyblish.api from maya import cmds import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder, OptionalPyblishPluginMixin) class ValidateCameraContents(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates Camera instance contents. A Camera instance may only hold a SINGLE camera's transform, nothing else. It may hold a "locator" as shape, but different shapes are down the hierarchy. """ order = ValidateContentsOrder families = ['camera'] hosts = ['maya'] label = 'Camera Contents' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] validate_shapes = True optional = False @classmethod def get_invalid(cls, instance): # get cameras members = instance.data['setMembers'] shapes = cmds.ls(members, dag=True, shapes=True, long=True) # single camera invalid = [] cameras = cmds.ls(shapes, type='camera', long=True) if len(cameras) != 1: cls.log.error("Camera instance must have a single camera. " "Found {0}: {1}".format(len(cameras), cameras)) invalid.extend(cameras) # We need to check this edge case because returning an extended # list when there are no actual cameras results in # still an empty 'invalid' list if len(cameras) < 1: if members: # If there are members in the instance return all of # them as 'invalid' so the user can still select invalid cls.log.error("No cameras found in instance " "members: {}".format(members)) return members raise PublishValidationError( "No cameras found in empty instance.") if not cls.validate_shapes: cls.log.debug("Not validating shapes in the camera content" " because 'validate shapes' is disabled") return invalid # non-camera shapes valid_shapes = cmds.ls(shapes, type=('camera', 'locator'), long=True) shapes = set(shapes) - set(valid_shapes) if shapes: shapes = list(shapes) cls.log.error("Camera instance should only contain camera " "shapes. Found: {0}".format(shapes)) invalid.extend(shapes) invalid = list(set(invalid)) return invalid def process(self, instance): """Process all the nodes in the instance""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError("Invalid camera contents: " "{0}".format(invalid)) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_color_sets.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateMeshOrder, OptionalPyblishPluginMixin, PublishValidationError, RepairAction ) class ValidateColorSets(pyblish.api.Validator, OptionalPyblishPluginMixin): """Validate all meshes in the instance have unlocked normals These can be removed manually through: Modeling > Mesh Display > Color Sets Editor """ order = ValidateMeshOrder hosts = ['maya'] families = ['model'] label = 'Mesh ColorSets' actions = [ openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction ] optional = True @staticmethod def has_color_sets(mesh): """Return whether a mesh node has locked normals""" return cmds.polyColorSet(mesh, allColorSets=True, query=True) @classmethod def get_invalid(cls, instance): """Return the meshes with ColorSets in instance""" meshes = cmds.ls(instance, type='mesh', long=True) return [mesh for mesh in meshes if cls.has_color_sets(mesh)] def process(self, instance): """Raise invalid when any of the meshes have ColorSets""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( message="Meshes found with Color Sets: {0}".format(invalid) ) @classmethod def repair(cls, instance): """Remove all Color Sets on the meshes in this instance.""" invalid = cls.get_invalid(instance) for mesh in invalid: for set in cmds.polyColorSet(mesh, acs=True, q=True): cmds.polyColorSet(mesh, colorSet=set, delete=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_current_renderlayer_renderable.py ================================================ import pyblish.api from maya import cmds from openpype.pipeline.publish import ( context_plugin_should_run, OptionalPyblishPluginMixin ) class ValidateCurrentRenderLayerIsRenderable(pyblish.api.ContextPlugin, OptionalPyblishPluginMixin): """Validate if current render layer has a renderable camera There is a bug in Redshift which occurs when the current render layer at file open has no renderable camera. The error raised is as follows: "No renderable cameras found. Aborting render" This error is raised even if that render layer will not be rendered. """ label = "Current Render Layer Has Renderable Camera" order = pyblish.api.ValidatorOrder hosts = ["maya"] families = ["renderlayer"] optional = False def process(self, context): if not self.is_active(context.data): return # Workaround bug pyblish-base#250 if not context_plugin_should_run(self, context): return layer = cmds.editRenderLayerGlobals(query=True, currentRenderLayer=True) cameras = cmds.ls(type="camera", long=True) renderable = any(c for c in cameras if cmds.getAttr(c + ".renderable")) assert renderable, ("Current render layer '%s' has no renderable " "camera" % layer) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_cycle_error.py ================================================ import pyblish.api from maya import cmds import openpype.hosts.maya.api.action from openpype.hosts.maya.api.lib import maintained_selection from openpype.pipeline.publish import ( OptionalPyblishPluginMixin, PublishValidationError, ValidateContentsOrder) class ValidateCycleError(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate nodes produce no cycle errors.""" order = ValidateContentsOrder + 0.05 label = "Cycle Errors" hosts = ["maya"] families = ["rig"] actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = True def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Nodes produce a cycle error: {}".format(invalid)) @classmethod def get_invalid(cls, instance): with maintained_selection(): cmds.select(instance[:], noExpand=True) plugs = cmds.cycleCheck(all=False, # check selection only list=True) invalid = cmds.ls(plugs, objectsOnly=True, long=True) return invalid ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_frame_range.py ================================================ import pyblish.api from maya import cmds from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) from openpype.hosts.maya.api.lib_rendersetup import ( get_attr_overrides, get_attr_in_layer, ) from maya.app.renderSetup.model.override import AbsOverride class ValidateFrameRange(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates the frame ranges. This is an optional validator checking if the frame range on instance matches the frame range specified for the asset. It also validates render frame ranges of render layers. Repair action will change everything to match the asset frame range. This can be turned off by the artist to allow custom ranges. """ label = "Validate Frame Range" order = ValidateContentsOrder families = ["animation", "pointcache", "camera", "proxyAbc", "renderlayer", "review", "yeticache"] optional = True actions = [RepairAction] exclude_families = [] def process(self, instance): if not self.is_active(instance.data): return context = instance.context if instance.data.get("tileRendering"): self.log.debug( "Skipping frame range validation because " "tile rendering is enabled." ) return frame_start_handle = int(context.data.get("frameStartHandle")) frame_end_handle = int(context.data.get("frameEndHandle")) handle_start = int(context.data.get("handleStart")) handle_end = int(context.data.get("handleEnd")) frame_start = int(context.data.get("frameStart")) frame_end = int(context.data.get("frameEnd")) inst_start = int(instance.data.get("frameStartHandle")) inst_end = int(instance.data.get("frameEndHandle")) inst_frame_start = int(instance.data.get("frameStart")) inst_frame_end = int(instance.data.get("frameEnd")) inst_handle_start = int(instance.data.get("handleStart")) inst_handle_end = int(instance.data.get("handleEnd")) # basic sanity checks assert frame_start_handle <= frame_end_handle, ( "start frame is lower then end frame") # compare with data on instance errors = [] if [ef for ef in self.exclude_families if instance.data["family"] in ef]: return if (inst_start != frame_start_handle): errors.append("Instance start frame [ {} ] doesn't " "match the one set on asset [ {} ]: " "{}/{}/{}/{} (handle/start/end/handle)".format( inst_start, frame_start_handle, handle_start, frame_start, frame_end, handle_end )) if (inst_end != frame_end_handle): errors.append("Instance end frame [ {} ] doesn't " "match the one set on asset [ {} ]: " "{}/{}/{}/{} (handle/start/end/handle)".format( inst_end, frame_end_handle, handle_start, frame_start, frame_end, handle_end )) checks = { "frame start": (frame_start, inst_frame_start), "frame end": (frame_end, inst_frame_end), "handle start": (handle_start, inst_handle_start), "handle end": (handle_end, inst_handle_end) } for label, values in checks.items(): if values[0] != values[1]: errors.append( "{} on instance ({}) does not match with the asset " "({}).".format(label.title(), values[1], values[0]) ) if errors: report = "Frame range settings are incorrect.\n\n" for error in errors: report += "- {}\n\n".format(error) raise PublishValidationError(report, title="Frame Range incorrect") @classmethod def repair(cls, instance): """ Repair instance container to match asset data. """ if "renderlayer" in instance.data.get("families"): # Special behavior for renderlayers cls.repair_renderlayer(instance) return node = instance.data["name"] context = instance.context frame_start_handle = int(context.data.get("frameStartHandle")) frame_end_handle = int(context.data.get("frameEndHandle")) handle_start = int(context.data.get("handleStart")) handle_end = int(context.data.get("handleEnd")) frame_start = int(context.data.get("frameStart")) frame_end = int(context.data.get("frameEnd")) # Start if cmds.attributeQuery("handleStart", node=node, exists=True): cmds.setAttr("{}.handleStart".format(node), handle_start) cmds.setAttr("{}.frameStart".format(node), frame_start) else: # Include start handle in frame start if no separate handleStart # attribute exists on the node cmds.setAttr("{}.frameStart".format(node), frame_start_handle) # End if cmds.attributeQuery("handleEnd", node=node, exists=True): cmds.setAttr("{}.handleEnd".format(node), handle_end) cmds.setAttr("{}.frameEnd".format(node), frame_end) else: # Include end handle in frame end if no separate handleEnd # attribute exists on the node cmds.setAttr("{}.frameEnd".format(node), frame_end_handle) @classmethod def repair_renderlayer(cls, instance): """Apply frame range in render settings""" layer = instance.data["renderlayer"] context = instance.context start_attr = "defaultRenderGlobals.startFrame" end_attr = "defaultRenderGlobals.endFrame" frame_start_handle = int(context.data.get("frameStartHandle")) frame_end_handle = int(context.data.get("frameEndHandle")) cls._set_attr_in_layer(start_attr, layer, frame_start_handle) cls._set_attr_in_layer(end_attr, layer, frame_end_handle) @classmethod def _set_attr_in_layer(cls, node_attr, layer, value): if get_attr_in_layer(node_attr, layer=layer) == value: # Already ok. This can happen if you have multiple renderlayers # validated and there are no frame range overrides. The first # layer's repair would have fixed the global value already return overrides = list(get_attr_overrides(node_attr, layer=layer)) if overrides: # We set the last absolute override if it is an absolute override # otherwise we'll add an Absolute override last_override = overrides[-1][1] if not isinstance(last_override, AbsOverride): collection = last_override.parent() node, attr = node_attr.split(".", 1) last_override = collection.createAbsoluteOverride(node, attr) cls.log.debug("Setting {attr} absolute override in " "layer '{layer}': {value}".format(layer=layer, attr=node_attr, value=value)) cmds.setAttr(last_override.name() + ".attrValue", value) else: # Set the attribute directly # (Note that this will set the global attribute) cls.log.debug("Setting global {attr}: {value}".format( attr=node_attr, value=value )) cmds.setAttr(node_attr, value) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_glsl_material.py ================================================ import os from maya import cmds import pyblish.api from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder ) from openpype.pipeline import ( PublishValidationError, OptionalPyblishPluginMixin ) class ValidateGLSLMaterial(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """ Validate if the asset uses GLSL Shader """ order = ValidateContentsOrder + 0.1 families = ['gltf'] hosts = ['maya'] label = 'GLSL Shader for GLTF' actions = [RepairAction] optional = True active = True def process(self, instance): if not self.is_active(instance.data): return shading_grp = self.get_material_from_shapes(instance) if not shading_grp: raise PublishValidationError("No shading group found") invalid = self.get_texture_shader_invalid(instance) if invalid: raise PublishValidationError("Non GLSL Shader found: " "{0}".format(invalid)) def get_material_from_shapes(self, instance): shapes = cmds.ls(instance, type="mesh", long=True) for shape in shapes: shading_grp = cmds.listConnections(shape, destination=True, type="shadingEngine") return shading_grp or [] def get_texture_shader_invalid(self, instance): invalid = set() shading_grp = self.get_material_from_shapes(instance) for shading_group in shading_grp: material_name = "{}.surfaceShader".format(shading_group) material = cmds.listConnections(material_name, source=True, destination=False, type="GLSLShader") if not material: # add material name material = cmds.listConnections(material_name)[0] invalid.add(material) return list(invalid) @classmethod def repair(cls, instance): """ Repair instance by assigning GLSL Shader to the material """ cls.assign_glsl_shader(instance) return @classmethod def assign_glsl_shader(cls, instance): """ Converting StingrayPBS material to GLSL Shaders for the glb export through Maya2GLTF plugin """ meshes = cmds.ls(instance, type="mesh", long=True) cls.log.debug("meshes: {}".format(meshes)) # load the glsl shader plugin cmds.loadPlugin("glslShader", quiet=True) for mesh in meshes: # create glsl shader glsl = cmds.createNode('GLSLShader') glsl_shading_grp = cmds.sets(name=glsl + "SG", empty=True, renderable=True, noSurfaceShader=True) cmds.connectAttr(glsl + ".outColor", glsl_shading_grp + ".surfaceShader") # load the maya2gltf shader ogsfx_path = instance.context.data["project_settings"]["maya"]["publish"]["ExtractGLB"]["ogsfx_path"] # noqa if not os.path.exists(ogsfx_path): if ogsfx_path: # if custom ogsfx path is not specified # the log below is the warning for the user cls.log.warning("ogsfx shader file " "not found in {}".format(ogsfx_path)) cls.log.debug("Searching the ogsfx shader file in " "default maya directory...") # re-direct to search the ogsfx path in maya_dir ogsfx_path = os.getenv("MAYA_APP_DIR") + ogsfx_path if not os.path.exists(ogsfx_path): raise PublishValidationError("The ogsfx shader file does not " # noqa "exist: {}".format(ogsfx_path)) # noqa cmds.setAttr(glsl + ".shader", ogsfx_path, typ="string") # list the materials used for the assets shading_grp = cmds.listConnections(mesh, destination=True, type="shadingEngine") # get the materials related to the selected assets for material in shading_grp: pbs_shader = cmds.listConnections(material, destination=True, type="StingrayPBS") if pbs_shader: cls.pbs_shader_conversion(pbs_shader, glsl) # setting up to relink the texture if # the mesh is with aiStandardSurface arnold_shader = cmds.listConnections(material, destination=True, type="aiStandardSurface") if arnold_shader: cls.arnold_shader_conversion(arnold_shader, glsl) cmds.sets(mesh, forceElement=str(glsl_shading_grp)) @classmethod def pbs_shader_conversion(cls, main_shader, glsl): cls.log.debug("StringrayPBS detected " "-> Can do texture conversion") for shader in main_shader: # get the file textures related to the PBS Shader albedo = cmds.listConnections(shader + ".TEX_color_map") if albedo: dif_output = albedo[0] + ".outColor" # get the glsl_shader input # reconnect the file nodes to maya2gltf shader glsl_dif = glsl + ".u_BaseColorTexture" cmds.connectAttr(dif_output, glsl_dif) # connect orm map if there is one orm_packed = cmds.listConnections(shader + ".TEX_ao_map") if orm_packed: orm_output = orm_packed[0] + ".outColor" mtl = glsl + ".u_MetallicTexture" ao = glsl + ".u_OcclusionTexture" rough = glsl + ".u_RoughnessTexture" cmds.connectAttr(orm_output, mtl) cmds.connectAttr(orm_output, ao) cmds.connectAttr(orm_output, rough) # connect nrm map if there is one nrm = cmds.listConnections(shader + ".TEX_normal_map") if nrm: nrm_output = nrm[0] + ".outColor" glsl_nrm = glsl + ".u_NormalTexture" cmds.connectAttr(nrm_output, glsl_nrm) @classmethod def arnold_shader_conversion(cls, main_shader, glsl): cls.log.debug("aiStandardSurface detected " "-> Can do texture conversion") for shader in main_shader: # get the file textures related to the PBS Shader albedo = cmds.listConnections(shader + ".baseColor") if albedo: dif_output = albedo[0] + ".outColor" # get the glsl_shader input # reconnect the file nodes to maya2gltf shader glsl_dif = glsl + ".u_BaseColorTexture" cmds.connectAttr(dif_output, glsl_dif) orm_packed = cmds.listConnections(shader + ".specularRoughness") if orm_packed: orm_output = orm_packed[0] + ".outColor" mtl = glsl + ".u_MetallicTexture" ao = glsl + ".u_OcclusionTexture" rough = glsl + ".u_RoughnessTexture" cmds.connectAttr(orm_output, mtl) cmds.connectAttr(orm_output, ao) cmds.connectAttr(orm_output, rough) # connect nrm map if there is one bump_node = cmds.listConnections(shader + ".normalCamera") if bump_node: for bump in bump_node: nrm = cmds.listConnections(bump + ".bumpValue") if nrm: nrm_output = nrm[0] + ".outColor" glsl_nrm = glsl + ".u_NormalTexture" cmds.connectAttr(nrm_output, glsl_nrm) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_glsl_plugin.py ================================================ from maya import cmds import pyblish.api from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateGLSLPlugin(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """ Validate if the asset uses GLSL Shader """ order = ValidateContentsOrder + 0.15 families = ['gltf'] hosts = ['maya'] label = 'maya2glTF plugin' actions = [RepairAction] optional = False def process(self, instance): if not self.is_active(instance.data): return if not cmds.pluginInfo("maya2glTF", query=True, loaded=True): raise PublishValidationError("maya2glTF is not loaded") @classmethod def repair(cls, instance): """ Repair instance by enabling the plugin """ return cmds.loadPlugin("maya2glTF", quiet=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_instance_has_members.py ================================================ import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError ) class ValidateInstanceHasMembers(pyblish.api.InstancePlugin): """Validates instance objectSet has *any* members.""" order = ValidateContentsOrder hosts = ["maya"] label = 'Instance has members' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] @classmethod def get_invalid(cls, instance): invalid = list() if not instance.data.get("setMembers"): objectset_name = instance.data['name'] invalid.append(objectset_name) return invalid def process(self, instance): # Allow renderlayer, rendersetup and workfile to be empty skip_families = {"workfile", "renderlayer", "rendersetup"} if instance.data.get("family") in skip_families: return invalid = self.get_invalid(instance) if invalid: # Invalid will always be a single entry, we log the single name name = invalid[0] raise PublishValidationError( title="Empty instance", message="Instance '{0}' is empty".format(name) ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_instance_in_context.py ================================================ # -*- coding: utf-8 -*- """Validate if instance asset is the same as context asset.""" from __future__ import absolute_import import pyblish.api from openpype import AYON_SERVER_ENABLED import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) from maya import cmds class ValidateInstanceInContext(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validator to check if instance asset match context asset. When working in per-shot style you always publish data in context of current asset (shot). This validator checks if this is so. It is optional so it can be disabled when needed. Action on this validator will select invalid instances in Outliner. """ order = ValidateContentsOrder label = "Instance in same Context" optional = True hosts = ["maya"] actions = [ openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction ] def process(self, instance): if not self.is_active(instance.data): return asset = instance.data.get("asset") context_asset = self.get_context_asset(instance) if asset != context_asset: raise PublishValidationError( message=( "Instance '{}' publishes to different asset than current " "context: {}. Current context: {}".format( instance.name, asset, context_asset ) ), description=( "## Publishing to a different asset\n" "There are publish instances present which are publishing " "into a different asset than your current context.\n\n" "Usually this is not what you want but there can be cases " "where you might want to publish into another asset or " "shot. If that's the case you can disable the validation " "on the instance to ignore it." ) ) @classmethod def get_invalid(cls, instance): return [instance.data["instance_node"]] @classmethod def repair(cls, instance): context_asset = cls.get_context_asset(instance) instance_node = instance.data["instance_node"] if AYON_SERVER_ENABLED: asset_name_attr = "folderPath" else: asset_name_attr = "asset" cmds.setAttr( "{}.{}".format(instance_node, asset_name_attr), context_asset, type="string" ) @staticmethod def get_context_asset(instance): return instance.context.data["asset"] ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_instance_subset.py ================================================ import pyblish.api import string import six from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError ) # Allow only characters, numbers and underscore allowed = set(string.ascii_lowercase + string.ascii_uppercase + string.digits + '_') def validate_name(subset): return all(x in allowed for x in subset) class ValidateSubsetName(pyblish.api.InstancePlugin): """Validates subset name has only valid characters""" order = ValidateContentsOrder families = ["*"] label = "Subset Name" def process(self, instance): subset = instance.data.get("subset", None) # Ensure subset data if subset is None: raise PublishValidationError("Instance is missing subset " "name: {0}".format(subset)) if not isinstance(subset, six.string_types): raise TypeError("Instance subset name must be string, " "got: {0} ({1})".format(subset, type(subset))) # Ensure is not empty subset if not subset: raise ValueError("Instance subset name is " "empty: {0}".format(subset)) # Validate subset characters if not validate_name(subset): raise ValueError("Instance subset name contains invalid " "characters: {0}".format(subset)) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_instancer_content.py ================================================ import maya.cmds as cmds import pyblish.api from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( PublishValidationError, OptionalPyblishPluginMixin ) class ValidateInstancerContent(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates that all meshes in the instance have object IDs. This skips a check on intermediate objects because we consider them not important. """ order = pyblish.api.ValidatorOrder label = 'Instancer Content' families = ['instancer'] optional = False def process(self, instance): if not self.is_active(instance.data): return error = False members = instance.data['setMembers'] export_members = instance.data['exactExportMembers'] self.log.debug("Contents {0}".format(members)) if not len(members) == len(cmds.ls(members, type="instancer")): self.log.error("Instancer can only contain instancers") error = True # TODO: Implement better check for particles are cached if not cmds.ls(export_members, type="nucleus"): self.log.error("Instancer must have a connected nucleus") error = True if not cmds.ls(export_members, type="cacheFile"): self.log.error("Instancer must be cached") error = True hidden = self.check_geometry_hidden(export_members) if not hidden: error = True self.log.error("Instancer input geometry must be hidden " "the scene. Invalid: {0}".format(hidden)) # Ensure all in one group parents = cmds.listRelatives(members, allParents=True, fullPath=True) or [] roots = list(set(cmds.ls(parents, assemblies=True, long=True))) if len(roots) > 1: self.log.error("Instancer should all be contained in a single " "group. Current roots: {0}".format(roots)) error = True if error: raise PublishValidationError( "Instancer Content is invalid. See log.") def check_geometry_hidden(self, export_members): # Ensure all instanced geometry is hidden shapes = cmds.ls(export_members, dag=True, shapes=True, noIntermediate=True) meshes = cmds.ls(shapes, type="mesh") visible = [node for node in meshes if lib.is_visible(node, displayLayer=False, intermediateObject=False)] if visible: return False return True ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_instancer_frame_ranges.py ================================================ import os import re import pyblish.api from openpype.pipeline.publish import ( PublishValidationError, OptionalPyblishPluginMixin ) def is_cache_resource(resource): """Return whether resource is a cacheFile resource""" required = set(["maya", "node", "cacheFile"]) tags = resource.get("tags", []) return required.issubset(tags) def valdidate_files(files): for f in files: assert os.path.exists(f) assert f.endswith(".mcx") or f.endswith(".mcc") return True def filter_ticks(files): tick_files = set() ticks = set() for path in files: match = re.match(".+Tick([0-9]+).mcx$", os.path.basename(path)) if match: tick_files.add(path) num = match.group(1) ticks.add(int(num)) return tick_files, ticks class ValidateInstancerFrameRanges(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates all instancer particle systems are cached correctly. This means they should have the files/frames as required by the start-end frame (including handles). This also checks the files exist and checks the "ticks" (substeps) files. """ order = pyblish.api.ValidatorOrder label = 'Instancer Cache Frame Ranges' families = ['instancer'] optional = False @classmethod def get_invalid(cls, instance): import pyseq start_frame = instance.data.get("frameStart", 0) end_frame = instance.data.get("frameEnd", 0) required = range(int(start_frame), int(end_frame) + 1) invalid = list() resources = instance.data.get("resources", []) for resource in resources: if not is_cache_resource(resource): continue node = resource['node'] all_files = resource['files'][:] all_lookup = set(all_files) # The first file is usually the .xml description file. xml = all_files.pop(0) assert xml.endswith(".xml") # Ensure all files exist (including ticks) # The remainder file paths should be the .mcx or .mcc files valdidate_files(all_files) # Maya particle caches support substeps by saving out additional # files that end with a Tick60.mcx, Tick120.mcx, etc. suffix. # To avoid `pyseq` getting confused we filter those out and then # for each file (except the last frame) check that at least all # ticks exist. tick_files, ticks = filter_ticks(all_files) if tick_files: files = [f for f in all_files if f not in tick_files] else: files = all_files sequences = pyseq.get_sequences(files) if len(sequences) != 1: invalid.append(node) cls.log.warning("More than one sequence found? " "{0} {1}".format(node, files)) cls.log.warning("Found caches: {0}".format(sequences)) continue sequence = sequences[0] cls.log.debug("Found sequence: {0}".format(sequence)) start = sequence.start() end = sequence.end() if start > start_frame or end < end_frame: invalid.append(node) cls.log.warning("Sequence does not have enough " "frames: {0}-{1} (requires: {2}-{3})" "".format(start, end, start_frame, end_frame)) continue # Ensure all frames are present missing = set(sequence.missing()) if missing: required_missing = [x for x in required if x in missing] if required_missing: invalid.append(node) cls.log.warning("Sequence is missing required frames: " "{0}".format(required_missing)) continue # Ensure all tick files (substep) exist for the files in the folder # for the frames required by the time range. if ticks: ticks = list(sorted(ticks)) cls.log.debug("Found ticks: {0} " "(substeps: {1})".format(ticks, len(ticks))) # Check all frames except the last since we don't # require subframes after our time range. tick_check_frames = set(required[:-1]) # Check all frames for item in sequence: frame = item.frame if not frame: invalid.append(node) cls.log.error("Path is not a frame in sequence: " "{0}".format(item)) continue # Not required for our time range if frame not in tick_check_frames: continue path = item.path for num in ticks: base, ext = os.path.splitext(path) tick_file = base + "Tick{0}".format(num) + ext if tick_file not in all_lookup: invalid.append(node) cls.log.warning("Tick file found that is not " "in cache query filenames: " "{0}".format(tick_file)) return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: self.log.error("Invalid nodes: {0}".format(invalid)) raise PublishValidationError( ("Invalid particle caches in instance. " "See logs for details.")) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_loaded_plugin.py ================================================ import os import pyblish.api import maya.cmds as cmds from openpype.pipeline.publish import ( RepairContextAction, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateLoadedPlugin(pyblish.api.ContextPlugin, OptionalPyblishPluginMixin): """Ensure there are no unauthorized loaded plugins""" label = "Loaded Plugin" order = pyblish.api.ValidatorOrder host = ["maya"] actions = [RepairContextAction] optional = True @classmethod def get_invalid(cls, context): invalid = [] loaded_plugin = cmds.pluginInfo(query=True, listPlugins=True) # get variable from OpenPype settings whitelist_native_plugins = cls.whitelist_native_plugins authorized_plugins = cls.authorized_plugins or [] for plugin in loaded_plugin: if not whitelist_native_plugins and os.getenv('MAYA_LOCATION') \ in cmds.pluginInfo(plugin, query=True, path=True): continue if plugin not in authorized_plugins: invalid.append(plugin) return invalid def process(self, context): if not self.is_active(context.data): return invalid = self.get_invalid(context) if invalid: raise PublishValidationError( "Found forbidden plugin name: {}".format(", ".join(invalid)) ) @classmethod def repair(cls, context): """Unload forbidden plugins""" for plugin in cls.get_invalid(context): cmds.pluginInfo(plugin, edit=True, autoload=False) cmds.unloadPlugin(plugin, force=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_look_contents.py ================================================ import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder ) from maya import cmds # noqa class ValidateLookContents(pyblish.api.InstancePlugin): """Validate look instance contents Rules: * Look data must have `relationships` and `attributes` keys. * At least one relationship must be collection. * All relationship object sets at least have an ID value Tip: * When no node IDs are found on shadingEngines please save your scene and try again. """ order = ValidateContentsOrder families = ['look'] hosts = ['maya'] label = 'Look Data Contents' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] def process(self, instance): """Process all the nodes in the instance""" if not instance[:]: raise PublishValidationError("Instance is empty") invalid = self.get_invalid(instance) if invalid: raise PublishValidationError("'{}' has invalid look " "content".format(instance.name)) @classmethod def get_invalid(cls, instance): """Get all invalid nodes""" # check if data has the right attributes and content attributes = cls.validate_lookdata_attributes(instance) # check the looks for ID looks = cls.validate_looks(instance) # check if file nodes have valid files files = cls.validate_files(instance) invalid = looks + attributes + files return invalid @classmethod def validate_lookdata_attributes(cls, instance): """Check if the lookData has the required attributes Args: instance """ invalid = set() keys = ["relationships", "attributes"] lookdata = instance.data["lookData"] for key in keys: if key not in lookdata: cls.log.error("Look Data has no key " "'{}'".format(key)) invalid.add(instance.name) # Validate at least one single relationship is collected if not lookdata["relationships"]: cls.log.error("Look '%s' has no " "`relationships`" % instance.name) invalid.add(instance.name) # Check if attributes are on a node with an ID, crucial for rebuild! for attr_changes in lookdata["attributes"]: if not attr_changes["uuid"] and not attr_changes["attributes"]: cls.log.error("Node '%s' has no cbId, please set the " "attributes to its children if it has any" % attr_changes["name"]) invalid.add(instance.name) return list(invalid) @classmethod def validate_looks(cls, instance): looks = instance.data["lookData"]["relationships"] invalid = [] for name, data in looks.items(): if not data["uuid"]: cls.log.error("Look '{}' has no UUID".format(name)) invalid.append(name) return invalid @classmethod def validate_files(cls, instance): invalid = [] resources = instance.data.get("resources", []) for resource in resources: files = resource["files"] if len(files) == 0: node = resource["node"] cls.log.error("File node '%s' uses no or non-existing " "files" % node) invalid.append(node) return invalid @classmethod def validate_renderer(cls, instance): # TODO: Rewrite this to be more specific and configurable renderer = cmds.getAttr( 'defaultRenderGlobals.currentRenderer').lower() do_maketx = instance.data.get("maketx", False) do_rstex = instance.data.get("rstex", False) processors = [] if do_maketx: processors.append('arnold') if do_rstex: processors.append('redshift') for processor in processors: if processor == renderer: continue else: cls.log.error("Converted texture does not match current renderer.") # noqa ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_look_default_shaders_connections.py ================================================ from maya import cmds import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, RepairContextAction, PublishValidationError ) class ValidateLookDefaultShadersConnections(pyblish.api.ContextPlugin): """Validate default shaders in the scene have their default connections. For example the standardSurface1 or lambert1 (maya 2023 and before) could potentially be disconnected from the initialShadingGroup. As such it's not lambert1 that will be identified as the default shader which can have unpredictable results. To fix the default connections need to be made again. See the logs for more details on which connections are missing. """ order = pyblish.api.ValidatorOrder - 0.4999 families = ['look'] hosts = ['maya'] label = 'Look Default Shader Connections' actions = [RepairContextAction] # The default connections to check DEFAULTS = { "initialShadingGroup.surfaceShader": ["standardSurface1.outColor", "lambert1.outColor"], "initialParticleSE.surfaceShader": ["standardSurface1.outColor", "lambert1.outColor"], "initialParticleSE.volumeShader": ["particleCloud1.outColor"] } def process(self, context): if self.get_invalid(): raise PublishValidationError( "Default shaders in your scene do not have their " "default shader connections. Please repair them to continue." ) @classmethod def get_invalid(cls): # Process as usual invalid = list() for plug, valid_inputs in cls.DEFAULTS.items(): inputs = cmds.listConnections(plug, source=True, destination=False, plugs=True) or None if not inputs or inputs[0] not in valid_inputs: cls.log.error( "{0} is not connected to {1}. This can result in " "unexpected behavior. Please reconnect to continue." "".format(plug, " or ".join(valid_inputs)) ) invalid.append(plug) return invalid @classmethod def repair(cls, context): invalid = cls.get_invalid() for plug in invalid: valid_inputs = cls.DEFAULTS[plug] for valid_input in valid_inputs: if cmds.objExists(valid_input): cls.log.info( "Connecting {} -> {}".format(valid_input, plug) ) cmds.connectAttr(valid_input, plug, force=True) break ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_look_id_reference_edits.py ================================================ from collections import defaultdict from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError ) class ValidateLookIdReferenceEdits(pyblish.api.InstancePlugin): """Validate nodes in look have no reference edits to cbId. Note: This only validates the cbId edits on the referenced nodes that are used in the look. For example, a transform can have its cbId changed without being invalidated when it is not used in the look's assignment. """ order = ValidateContentsOrder families = ['look'] hosts = ['maya'] label = 'Look Id Reference Edits' actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError("Invalid nodes %s" % (invalid,)) @staticmethod def get_invalid(instance): # Collect all referenced members references = defaultdict(set) relationships = instance.data["lookData"]["relationships"] for relationship in relationships.values(): for member in relationship['members']: node = member["name"] if cmds.referenceQuery(node, isNodeReferenced=True): ref = cmds.referenceQuery(node, referenceNode=True) references[ref].add(node) # Validate whether any has changes to 'cbId' attribute invalid = list() for ref, nodes in references.items(): edits = cmds.referenceQuery(editAttrs=True, editNodes=True, showDagPath=True, showNamespace=True, onReferenceNode=ref) for edit in edits: # Ensure it is an attribute ending with .cbId # thus also ignore just node edits (like parenting) if not edit.endswith(".cbId"): continue # Ensure the attribute is 'cbId' (and not a nested attribute) node, attr = edit.split(".", 1) if attr != "cbId": continue if node in nodes: invalid.append(node) return invalid @classmethod def repair(cls, instance): invalid = cls.get_invalid(instance) # Group invalid nodes by reference node references = defaultdict(set) for node in invalid: ref = cmds.referenceQuery(node, referenceNode=True) references[ref].add(node) # Remove the reference edits on the nodes per reference node for ref, nodes in references.items(): for node in nodes: # Somehow this only works if you run the the removal # per edit command. for command in ["addAttr", "connectAttr", "deleteAttr", "disconnectAttr", "setAttr"]: cmds.referenceEdit("{}.cbId".format(node), removeEdits=True, successfulEdits=True, failedEdits=True, editCommand=command, onReferenceNode=ref) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_look_no_default_shaders.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError ) class ValidateLookNoDefaultShaders(pyblish.api.InstancePlugin): """Validate if any node has a connection to a default shader. This checks whether the look has any members of: - lambert1 - initialShadingGroup - initialParticleSE - particleCloud1 If any of those is present it will raise an error. A look is not allowed to have any of the "default" shaders present in a scene as they can introduce problems when referenced (overriding local scene shaders). To fix this no shape nodes in the look must have any of default shaders applied. """ order = ValidateContentsOrder + 0.01 families = ['look'] hosts = ['maya'] label = 'Look No Default Shaders' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] DEFAULT_SHADERS = {"lambert1", "initialShadingGroup", "initialParticleSE", "particleCloud1"} def process(self, instance): """Process all the nodes in the instance""" invalid = self.get_invalid(instance) if invalid: raise PublishValidationError("Invalid node relationships found: " "{0}".format(invalid)) @classmethod def get_invalid(cls, instance): invalid = set() for node in instance: # Get shading engine connections shaders = cmds.listConnections(node, type="shadingEngine") or [] # Check for any disallowed connections on *all* nodes if any(s in cls.DEFAULT_SHADERS for s in shaders): # Explicitly log each individual "wrong" connection. for s in shaders: if s in cls.DEFAULT_SHADERS: cls.log.error("Node has unallowed connection to " "'{}': {}".format(s, node)) invalid.add(node) return list(invalid) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_look_sets.py ================================================ import pyblish.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError ) class ValidateLookSets(pyblish.api.InstancePlugin): """Validate if any sets relationships are not being collected. A shader can be assigned to a node that is missing a Colorbleed ID. Because it is missing the ID it has not been collected in the instance. This validator ensures those relationships and thus considers it invalid if a relationship was not collected. When the relationship needs to be maintained the artist might need to create a different* relationship or ensure the node has the Colorbleed ID. *The relationship might be too broad (assigned to top node of hierarchy). This can be countered by creating the relationship on the shape or its transform. In essence, ensure item the shader is assigned to has the Colorbleed ID! Examples: - Displacement objectSets (like V-Ray): It is best practice to add the transform of the shape to the displacement objectSet. Any parent groups will not work as groups do not receive a Colorbleed Id. As such the assignments need to be made to the shapes and their transform. Example content: [asset_GRP|geometry_GRP|body_GES, asset_GRP|geometry_GRP|L_eye_GES, asset_GRP|geometry_GRP|R_eye_GES, asset_GRP|geometry_GRP|wings_GEO] """ order = ValidateContentsOrder families = ['look'] hosts = ['maya'] label = 'Look Sets' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] def process(self, instance): """Process all the nodes in the instance""" invalid = self.get_invalid(instance) if invalid: raise PublishValidationError("'{}' has invalid look " "content".format(instance.name)) @classmethod def get_invalid(cls, instance): """Get all invalid nodes""" relationships = instance.data["lookData"]["relationships"] invalid = [] renderlayer = instance.data.get("renderlayer", "defaultRenderLayer") with lib.renderlayer(renderlayer): for node in instance: # get the connected objectSets of the node sets = lib.get_related_sets(node) if not sets: continue # check if any objectSets are not present ion the relationships missing_sets = [s for s in sets if s not in relationships] if missing_sets: for missing_set in missing_sets: cls.log.debug(missing_set) if '_SET' not in missing_set: # A set of this node is not coming along, this is wrong! cls.log.error("Missing sets '{}' for node " "'{}'".format(missing_sets, node)) invalid.append(node) continue # Ensure the node is in the sets that are collected for shader_set, data in relationships.items(): if shader_set not in sets: # no need to check for a set if the node # isn't in it anyway continue member_nodes = [member['name'] for member in data['members']] if node not in member_nodes: # The node is not found in the collected set # relationships cls.log.error("Missing '{}' in collected set node " "'{}'".format(node, shader_set)) invalid.append(node) continue return invalid ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_look_shading_group.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateShadingEngine(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate all shading engines are named after the surface material. Shading engines should be named "{surface_shader}SG" """ order = ValidateContentsOrder families = ["look"] hosts = ["maya"] label = "Look Shading Engine Naming" actions = [ openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction ] optional = True # The default connections to check def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Found shading engines with incorrect naming:" "\n{}".format(invalid) ) @classmethod def get_invalid(cls, instance): shapes = cmds.ls(instance, type=["nurbsSurface", "mesh"], long=True) invalid = [] for shape in shapes: shading_engines = cmds.listConnections( shape, destination=True, type="shadingEngine" ) or [] for shading_engine in shading_engines: name = ( cmds.listConnections(shading_engine + ".surfaceShader")[0] + "SG" ) if shading_engine != name: invalid.append(shading_engine) return list(set(invalid)) @classmethod def repair(cls, instance): shading_engines = cls.get_invalid(instance) for shading_engine in shading_engines: name = ( cmds.listConnections(shading_engine + ".surfaceShader")[0] + "SG" ) cmds.rename(shading_engine, name) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_look_single_shader.py ================================================ import pyblish.api from maya import cmds import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder) class ValidateSingleShader(pyblish.api.InstancePlugin): """Validate all nurbsSurfaces and meshes have exactly one shader assigned. This will error if a shape has no shaders or more than one shader. """ order = ValidateContentsOrder families = ['look'] hosts = ['maya'] label = 'Look Single Shader Per Shape' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] # The default connections to check def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( ("Found shapes which don't have a single shader " "assigned:\n{}").format(invalid)) @classmethod def get_invalid(cls, instance): # Get all shapes from the instance shapes = cmds.ls(instance, type=["nurbsSurface", "mesh"], long=True) # Check the number of connected shadingEngines per shape no_shaders = [] more_than_one_shaders = [] for shape in shapes: shading_engines = cmds.listConnections(shape, destination=True, type="shadingEngine") or [] # Only interested in unique shading engines. shading_engines = list(set(shading_engines)) if not shading_engines: no_shaders.append(shape) elif len(shading_engines) > 1: more_than_one_shaders.append(shape) if no_shaders: cls.log.error("No shaders found on: {}".format(no_shaders)) if more_than_one_shaders: cls.log.error("More than one shader found on: " "{}".format(more_than_one_shaders)) return no_shaders + more_than_one_shaders ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_maya_units.py ================================================ import maya.cmds as cmds import pyblish.api import openpype.hosts.maya.api.lib as mayalib from openpype.pipeline.context_tools import get_current_project_asset from openpype.pipeline.publish import ( RepairContextAction, ValidateSceneOrder, PublishXmlValidationError, OptionalPyblishPluginMixin ) class ValidateMayaUnits(pyblish.api.ContextPlugin, OptionalPyblishPluginMixin): """Check if the Maya units are set correct""" order = ValidateSceneOrder label = "Maya Units" hosts = ['maya'] actions = [RepairContextAction] validate_linear_units = True linear_units = "cm" validate_angular_units = True angular_units = "deg" validate_fps = True nice_message_format = ( "- {setting} must be {required_value}. " "Your scene is set to {current_value}" ) log_message_format = ( "Maya scene {setting} must be '{required_value}'. " "Current value is '{current_value}'." ) optional = False @classmethod def apply_settings(cls, project_settings): """Apply project settings to creator""" settings = ( project_settings["maya"]["publish"]["ValidateMayaUnits"] ) cls.validate_linear_units = settings.get("validate_linear_units", cls.validate_linear_units) cls.linear_units = settings.get("linear_units", cls.linear_units) cls.validate_angular_units = settings.get("validate_angular_units", cls.validate_angular_units) cls.angular_units = settings.get("angular_units", cls.angular_units) cls.validate_fps = settings.get("validate_fps", cls.validate_fps) def process(self, context): if not self.is_active(context.data): return # Collected units linearunits = context.data.get('linearUnits') angularunits = context.data.get('angularUnits') fps = context.data.get('fps') asset_doc = context.data["assetEntity"] asset_fps = mayalib.convert_to_maya_fps(asset_doc["data"]["fps"]) self.log.info('Units (linear): {0}'.format(linearunits)) self.log.info('Units (angular): {0}'.format(angularunits)) self.log.info('Units (time): {0} FPS'.format(fps)) invalid = [] # Check if units are correct if ( self.validate_linear_units and linearunits and linearunits != self.linear_units ): invalid.append({ "setting": "Linear units", "required_value": self.linear_units, "current_value": linearunits }) if ( self.validate_angular_units and angularunits and angularunits != self.angular_units ): invalid.append({ "setting": "Angular units", "required_value": self.angular_units, "current_value": angularunits }) if self.validate_fps and fps and fps != asset_fps: invalid.append({ "setting": "FPS", "required_value": asset_fps, "current_value": fps }) if invalid: issues = [] for data in invalid: self.log.error(self.log_message_format.format(**data)) issues.append(self.nice_message_format.format(**data)) issues = "\n".join(issues) raise PublishXmlValidationError( plugin=self, message="Invalid maya scene units", formatting_data={"issues": issues} ) @classmethod def repair(cls, context): """Fix the current FPS setting of the scene, set to PAL(25.0 fps)""" cls.log.info("Setting angular unit to '{}'".format(cls.angular_units)) cmds.currentUnit(angle=cls.angular_units) current_angle = cmds.currentUnit(query=True, angle=True) cls.log.debug(current_angle) cls.log.info("Setting linear unit to '{}'".format(cls.linear_units)) cmds.currentUnit(linear=cls.linear_units) current_linear = cmds.currentUnit(query=True, linear=True) cls.log.debug(current_linear) cls.log.info("Setting time unit to match project") # TODO replace query with using 'context.data["assetEntity"]' asset_doc = get_current_project_asset() asset_fps = asset_doc["data"]["fps"] mayalib.set_scene_fps(asset_fps) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api.lib import ( maintained_selection, delete_after, undo_chunk, get_attribute, set_attribute ) from openpype.pipeline.publish import ( OptionalPyblishPluginMixin, RepairAction, ValidateMeshOrder, PublishValidationError ) class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate the mesh has default Arnold attributes. It compares all Arnold attributes from a default mesh. This is to ensure later published looks can discover non-default Arnold attributes. """ order = ValidateMeshOrder hosts = ["maya"] families = ["model"] label = "Mesh Arnold Attributes" actions = [ openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction ] optional = True # cache (will be `dict` when cached) arnold_mesh_defaults = None @classmethod def get_default_attributes(cls): if cls.arnold_mesh_defaults is not None: # Use from cache return cls.arnold_mesh_defaults # Get default arnold attribute values for mesh type. defaults = {} with delete_after() as tmp: transform = cmds.createNode("transform", skipSelect=True) tmp.append(transform) mesh = cmds.createNode("mesh", parent=transform, skipSelect=True) arnold_attributes = cmds.listAttr(mesh, string="ai*", fromPlugin=True) or [] for attr in arnold_attributes: plug = "{}.{}".format(mesh, attr) try: defaults[attr] = get_attribute(plug) except PublishValidationError: cls.log.debug("Ignoring arnold attribute: {}".format(attr)) cls.arnold_mesh_defaults = defaults # assign cache return defaults @classmethod def get_invalid_attributes(cls, instance, compute=False): invalid = [] if compute: meshes = cmds.ls(instance, type="mesh", long=True) if not meshes: return [] # Compare the values against the defaults defaults = cls.get_default_attributes() for mesh in meshes: for attr_name, default_value in defaults.items(): plug = "{}.{}".format(mesh, attr_name) if get_attribute(plug) != default_value: invalid.append(plug) instance.data["nondefault_arnold_attributes"] = invalid return instance.data.get("nondefault_arnold_attributes", []) @classmethod def get_invalid(cls, instance): invalid_attrs = cls.get_invalid_attributes(instance, compute=False) invalid_nodes = set(attr.split(".", 1)[0] for attr in invalid_attrs) return sorted(invalid_nodes) @classmethod def repair(cls, instance): with maintained_selection(): with undo_chunk(): defaults = cls.get_default_attributes() attributes = cls.get_invalid_attributes( instance, compute=False ) for attr in attributes: node, attr_name = attr.split(".", 1) value = defaults[attr_name] set_attribute( node=node, attribute=attr_name, value=value ) def process(self, instance): if not self.is_active(instance.data): return if not cmds.pluginInfo("mtoa", query=True, loaded=True): # Arnold attributes only exist if plug-in is loaded return invalid = self.get_invalid_attributes(instance, compute=True) if invalid: raise PublishValidationError( "Non-default Arnold attributes found in instance:" " {0}".format(invalid) ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_empty.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, ValidateMeshOrder, PublishValidationError ) class ValidateMeshEmpty(pyblish.api.InstancePlugin): """Validate meshes have some vertices. Its possible to have meshes without any vertices. To replicate this issue, delete all faces/polygons then all edges. """ order = ValidateMeshOrder hosts = ["maya"] families = ["model"] label = "Mesh Empty" actions = [ openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction ] @classmethod def repair(cls, instance): invalid = cls.get_invalid(instance) for node in invalid: cmds.delete(node) @classmethod def get_invalid(cls, instance): invalid = [] meshes = cmds.ls(instance, type="mesh", long=True) for mesh in meshes: num_vertices = cmds.polyEvaluate(mesh, vertex=True) if num_vertices == 0: cls.log.warning( "\"{}\" does not have any vertices.".format(mesh) ) invalid.append(mesh) return invalid def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Meshes found in instance without any vertices: %s" % invalid ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_has_uv.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateMeshOrder, OptionalPyblishPluginMixin, PublishValidationError ) from openpype.hosts.maya.api.lib import len_flattened class ValidateMeshHasUVs(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate the current mesh has UVs. It validates whether the current UV set has non-zero UVs and at least more than the vertex count. It's not really bulletproof, but a simple quick validation to check if there are likely UVs for every face. """ order = ValidateMeshOrder hosts = ['maya'] families = ['model'] label = 'Mesh Has UVs' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = True @classmethod def get_invalid(cls, instance): invalid = [] for node in cmds.ls(instance, type='mesh'): num_vertices = cmds.polyEvaluate(node, vertex=True) if num_vertices == 0: cls.log.warning( "Skipping \"{}\", cause it does not have any " "vertices.".format(node) ) continue uv = cmds.polyEvaluate(node, uv=True) if uv == 0: invalid.append(node) continue vertex = cmds.polyEvaluate(node, vertex=True) if uv < vertex: # Workaround: # Maya can have instanced UVs in a single mesh, for example # imported from an Alembic. With instanced UVs the UV count # from `maya.cmds.polyEvaluate(uv=True)` will only result in # the unique UV count instead of for all vertices. # # Note: Maya can save instanced UVs to `mayaAscii` but cannot # load this as instanced. So saving, opening and saving # again will lose this information. map_attr = "{}.map[*]".format(node) uv_to_vertex = cmds.polyListComponentConversion(map_attr, toVertex=True) uv_vertex_count = len_flattened(uv_to_vertex) if uv_vertex_count < vertex: invalid.append(node) else: cls.log.warning("Node has instanced UV points: " "{0}".format(node)) return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: names = "
".join( " - {}".format(node) for node in invalid ) raise PublishValidationError( title="Mesh has missing UVs", message="Model meshes are required to have UVs.

" "Meshes detected with invalid or missing UVs:
" "{0}".format(names) ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_lamina_faces.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateMeshOrder, OptionalPyblishPluginMixin ) class ValidateMeshLaminaFaces(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate meshes don't have lamina faces. Lamina faces share all of their edges. """ order = ValidateMeshOrder hosts = ['maya'] families = ['model'] label = 'Mesh Lamina Faces' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = True @staticmethod def get_invalid(instance): meshes = cmds.ls(instance, type='mesh', long=True) invalid = [mesh for mesh in meshes if cmds.polyInfo(mesh, laminaFaces=True)] return invalid def process(self, instance): """Process all the nodes in the instance 'objectSet'""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise ValueError("Meshes found with lamina faces: " "{0}".format(invalid)) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin ) class ValidateMeshNgons(pyblish.api.Validator, OptionalPyblishPluginMixin): """Ensure that meshes don't have ngons Ngon are faces with more than 4 sides. To debug the problem on the meshes you can use Maya's modeling tool: "Mesh > Cleanup..." """ order = ValidateContentsOrder hosts = ["maya"] families = ["model"] label = "Mesh ngons" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = True @staticmethod def get_invalid(instance): meshes = cmds.ls(instance, type='mesh', long=True) # Get all faces faces = ['{0}.f[*]'.format(node) for node in meshes] # Filter to n-sided polygon faces (ngons) invalid = lib.polyConstraint(faces, t=0x0008, # type=face size=3) # size=nsided return invalid def process(self, instance): """Process all the nodes in the instance "objectSet""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise ValueError("Meshes found with n-gons" "values: {0}".format(invalid)) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_no_negative_scale.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateMeshOrder, PublishValidationError, OptionalPyblishPluginMixin ) def _as_report_list(values, prefix="- ", suffix="\n"): """Return list as bullet point list for a report""" if not values: return "" return prefix + (suffix + prefix).join(values) class ValidateMeshNoNegativeScale(pyblish.api.Validator, OptionalPyblishPluginMixin): """Ensure that meshes don't have a negative scale. Using negatively scaled proxies in a VRayMesh results in inverted normals. As such we want to avoid this. We also avoid this on the rig or model because these are often the previous steps for those that are cached to proxies so we can catch this issue early. """ order = ValidateMeshOrder hosts = ['maya'] families = ['model'] label = 'Mesh No Negative Scale' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False @staticmethod def get_invalid(instance): meshes = cmds.ls(instance, type='mesh', long=True, noIntermediate=True) invalid = [] for mesh in meshes: transform = cmds.listRelatives(mesh, parent=True, fullPath=True)[0] scale = cmds.getAttr("{0}.scale".format(transform))[0] if any(x < 0 for x in scale): invalid.append(mesh) return invalid def process(self, instance): """Process all the nodes in the instance 'objectSet'""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Meshes found with negative scale:\n\n{0}".format( _as_report_list(sorted(invalid)) ), title="Negative scale" ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_non_manifold.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateMeshOrder, PublishValidationError, OptionalPyblishPluginMixin ) def _as_report_list(values, prefix="- ", suffix="\n"): """Return list as bullet point list for a report""" if not values: return "" return prefix + (suffix + prefix).join(values) class ValidateMeshNonManifold(pyblish.api.Validator, OptionalPyblishPluginMixin): """Ensure that meshes don't have non-manifold edges or vertices To debug the problem on the meshes you can use Maya's modeling tool: "Mesh > Cleanup..." """ order = ValidateMeshOrder hosts = ['maya'] families = ['model'] label = 'Mesh Non-Manifold Edges/Vertices' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = True @staticmethod def get_invalid(instance): meshes = cmds.ls(instance, type='mesh', long=True) invalid = [] for mesh in meshes: if (cmds.polyInfo(mesh, nonManifoldVertices=True) or cmds.polyInfo(mesh, nonManifoldEdges=True)): invalid.append(mesh) return invalid def process(self, instance): """Process all the nodes in the instance 'objectSet'""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Meshes found with non-manifold edges/vertices:\n\n{0}".format( _as_report_list(sorted(invalid)) ), title="Non-Manifold Edges/Vertices" ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_non_zero_edge.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( ValidateMeshOrder, OptionalPyblishPluginMixin, PublishValidationError ) class ValidateMeshNonZeroEdgeLength(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate meshes don't have edges with a zero length. Based on Maya's polyCleanup 'Edges with zero length'. Note: This can be slow for high-res meshes. """ order = ValidateMeshOrder families = ['model'] hosts = ['maya'] label = 'Mesh Edge Length Non Zero' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = True __tolerance = 1e-5 @classmethod def get_invalid(cls, instance): """Return the invalid edges. Also see: http://help.autodesk.com/view/MAYAUL/2015/ENU/?guid=Mesh__Cleanup """ meshes = cmds.ls(instance, type='mesh', long=True) if not meshes: return list() valid_meshes = [] for mesh in meshes: num_vertices = cmds.polyEvaluate(mesh, vertex=True) if num_vertices == 0: cls.log.warning( "Skipping \"{}\", cause it does not have any " "vertices.".format(mesh) ) continue valid_meshes.append(mesh) # Get all edges edges = ['{0}.e[*]'.format(node) for node in valid_meshes] # Filter by constraint on edge length invalid = lib.polyConstraint(edges, t=0x8000, # type=edge length=1, lengthbound=(0, cls.__tolerance)) return invalid def process(self, instance): """Process all meshes""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: label = "Meshes found with zero edge length" raise PublishValidationError( message="{}: {}".format(label, invalid), title=label, description="{}:\n- ".format(label) + "\n- ".join(invalid) ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py ================================================ from maya import cmds import maya.api.OpenMaya as om2 import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, ValidateMeshOrder, OptionalPyblishPluginMixin, PublishValidationError ) def _as_report_list(values, prefix="- ", suffix="\n"): """Return list as bullet point list for a report""" if not values: return "" return prefix + (suffix + prefix).join(values) class ValidateMeshNormalsUnlocked(pyblish.api.Validator, OptionalPyblishPluginMixin): """Validate all meshes in the instance have unlocked normals These can be unlocked manually through: Modeling > Mesh Display > Unlock Normals """ order = ValidateMeshOrder hosts = ['maya'] families = ['model'] label = 'Mesh Normals Unlocked' actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] optional = True @staticmethod def has_locked_normals(mesh): """Return whether mesh has at least one locked normal""" sel = om2.MGlobal.getSelectionListByName(mesh) node = sel.getDependNode(0) fn_mesh = om2.MFnMesh(node) _, normal_ids = fn_mesh.getNormalIds() for normal_id in normal_ids: if fn_mesh.isNormalLocked(normal_id): return True return False @classmethod def get_invalid(cls, instance): """Return the meshes with locked normals in instance""" meshes = cmds.ls(instance, type='mesh', long=True) return [mesh for mesh in meshes if cls.has_locked_normals(mesh)] def process(self, instance): """Raise invalid when any of the meshes have locked normals""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Meshes found with locked normals:\n\n{0}".format( _as_report_list(sorted(invalid)) ), title="Locked normals" ) @classmethod def repair(cls, instance): """Unlocks all normals on the meshes in this instance.""" invalid = cls.get_invalid(instance) for mesh in invalid: cmds.polyNormalPerVertex(mesh, unFreezeNormal=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py ================================================ import math from six.moves import xrange from maya import cmds import maya.api.OpenMaya as om import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateMeshOrder, OptionalPyblishPluginMixin, PublishValidationError ) def _as_report_list(values, prefix="- ", suffix="\n"): """Return list as bullet point list for a report""" if not values: return "" return prefix + (suffix + prefix).join(values) class GetOverlappingUVs(object): def _createBoundingCircle(self, meshfn): """ Represent a face by center and radius :param meshfn: MFnMesh class :type meshfn: :class:`maya.api.OpenMaya.MFnMesh` :returns: (center, radius) :rtype: tuple """ center = [] radius = [] for i in xrange(meshfn.numPolygons): # noqa: F821 # get uvs from face uarray = [] varray = [] for j in range(len(meshfn.getPolygonVertices(i))): uv = meshfn.getPolygonUV(i, j) uarray.append(uv[0]) varray.append(uv[1]) # loop through all vertices to construct edges/rays cu = 0.0 cv = 0.0 for j in range(len(uarray)): cu += uarray[j] cv += varray[j] cu /= len(uarray) cv /= len(varray) rsqr = 0.0 for j in range(len(varray)): du = uarray[j] - cu dv = varray[j] - cv dsqr = du * du + dv * dv rsqr = dsqr if dsqr > rsqr else rsqr center.append(cu) center.append(cv) radius.append(math.sqrt(rsqr)) return center, radius def _createRayGivenFace(self, meshfn, faceId): """ Represent a face by a series of edges(rays), i.e. :param meshfn: MFnMesh class :type meshfn: :class:`maya.api.OpenMaya.MFnMesh` :param faceId: face id :type faceId: int :returns: False if no valid uv's. ""(True, orig, vec)"" or ""(False, None, None)"" :rtype: tuple .. code-block:: python orig = [orig1u, orig1v, orig2u, orig2v, ... ] vec = [vec1u, vec1v, vec2u, vec2v, ... ] """ orig = [] vec = [] # get uvs uarray = [] varray = [] for i in range(len(meshfn.getPolygonVertices(faceId))): uv = meshfn.getPolygonUV(faceId, i) uarray.append(uv[0]) varray.append(uv[1]) if len(uarray) == 0 or len(varray) == 0: return (False, None, None) # loop through all vertices to construct edges/rays u = uarray[-1] v = varray[-1] for i in xrange(len(uarray)): # noqa: F821 orig.append(uarray[i]) orig.append(varray[i]) vec.append(u - uarray[i]) vec.append(v - varray[i]) u = uarray[i] v = varray[i] return (True, orig, vec) def _checkCrossingEdges(self, face1Orig, face1Vec, face2Orig, face2Vec): """ Check if there are crossing edges between two faces. Return True if there are crossing edges and False otherwise. :param face1Orig: origin of face 1 :type face1Orig: tuple :param face1Vec: face 1 edges :type face1Vec: list :param face2Orig: origin of face 2 :type face2Orig: tuple :param face2Vec: face 2 edges :type face2Vec: list A face is represented by a series of edges(rays), i.e. .. code-block:: python faceOrig[] = [orig1u, orig1v, orig2u, orig2v, ... ] faceVec[] = [vec1u, vec1v, vec2u, vec2v, ... ] """ face1Size = len(face1Orig) face2Size = len(face2Orig) for i in xrange(0, face1Size, 2): # noqa: F821 o1x = face1Orig[i] o1y = face1Orig[i+1] v1x = face1Vec[i] v1y = face1Vec[i+1] n1x = v1y n1y = -v1x for j in xrange(0, face2Size, 2): # noqa: F821 # Given ray1(O1, V1) and ray2(O2, V2) # Normal of ray1 is (V1.y, V1.x) o2x = face2Orig[j] o2y = face2Orig[j+1] v2x = face2Vec[j] v2y = face2Vec[j+1] n2x = v2y n2y = -v2x # Find t for ray2 # t = [(o1x-o2x)n1x + (o1y-o2y)n1y] / # (v2x * n1x + v2y * n1y) denum = v2x * n1x + v2y * n1y # Edges are parallel if denum is close to 0. if math.fabs(denum) < 0.000001: continue t2 = ((o1x-o2x) * n1x + (o1y-o2y) * n1y) / denum if (t2 < 0.00001 or t2 > 0.99999): continue # Find t for ray1 # t = [(o2x-o1x)n2x # + (o2y-o1y)n2y] / (v1x * n2x + v1y * n2y) denum = v1x * n2x + v1y * n2y # Edges are parallel if denum is close to 0. if math.fabs(denum) < 0.000001: continue t1 = ((o2x-o1x) * n2x + (o2y-o1y) * n2y) / denum # Edges intersect if (t1 > 0.00001 and t1 < 0.99999): return 1 return 0 def _getOverlapUVFaces(self, meshName): """ Return overlapping faces :param meshName: name of mesh :type meshName: str :returns: list of overlapping faces :rtype: list """ faces = [] # find polygon mesh node selList = om.MSelectionList() selList.add(meshName) mesh = selList.getDependNode(0) if mesh.apiType() == om.MFn.kTransform: dagPath = selList.getDagPath(0) dagFn = om.MFnDagNode(dagPath) child = dagFn.child(0) if child.apiType() != om.MFn.kMesh: raise Exception("Can't find polygon mesh") mesh = child meshfn = om.MFnMesh(mesh) center, radius = self._createBoundingCircle(meshfn) for i in xrange(meshfn.numPolygons): # noqa: F821 rayb1, face1Orig, face1Vec = self._createRayGivenFace(meshfn, i) if not rayb1: continue cui = center[2*i] cvi = center[2*i+1] ri = radius[i] # Exclude the degenerate face # if(area(face1Orig) < 0.000001) continue; # Loop through face j where j != i for j in range(i+1, meshfn.numPolygons): cuj = center[2*j] cvj = center[2*j+1] rj = radius[j] du = cuj - cui dv = cvj - cvi dsqr = du * du + dv * dv # Quick rejection if bounding circles don't overlap if (dsqr >= (ri + rj) * (ri + rj)): continue rayb2, face2Orig, face2Vec = self._createRayGivenFace(meshfn, j) if not rayb2: continue # Exclude the degenerate face # if(area(face2Orig) < 0.000001): continue; if self._checkCrossingEdges(face1Orig, face1Vec, face2Orig, face2Vec): face1 = '%s.f[%d]' % (meshfn.name(), i) face2 = '%s.f[%d]' % (meshfn.name(), j) if face1 not in faces: faces.append(face1) if face2 not in faces: faces.append(face2) return faces class ValidateMeshHasOverlappingUVs(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """ Validate the current mesh overlapping UVs. It validates whether the current UVs are overlapping or not. It is optional to warn publisher about it. """ order = ValidateMeshOrder hosts = ['maya'] families = ['model'] label = 'Mesh Has Overlapping UVs' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = True @classmethod def _get_overlapping_uvs(cls, mesh): """Return overlapping UVs of mesh. Args: mesh (str): Mesh node name Returns: list: Overlapping uvs for the input mesh in all uv sets. """ ovl = GetOverlappingUVs() # Store original uv set original_current_uv_set = cmds.polyUVSet(mesh, query=True, currentUVSet=True)[0] overlapping_faces = [] for uv_set in cmds.polyUVSet(mesh, query=True, allUVSets=True): cmds.polyUVSet(mesh, currentUVSet=True, uvSet=uv_set) overlapping_faces.extend(ovl._getOverlapUVFaces(mesh)) # Restore original uv set cmds.polyUVSet(mesh, currentUVSet=True, uvSet=original_current_uv_set) return overlapping_faces @classmethod def get_invalid(cls, instance, compute=False): if compute: invalid = [] for node in cmds.ls(instance, type="mesh"): faces = cls._get_overlapping_uvs(node) invalid.extend(faces) instance.data["overlapping_faces"] = invalid return instance.data.get("overlapping_faces", []) def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance, compute=True) if invalid: raise PublishValidationError( "Meshes found with overlapping UVs:\n\n{0}".format( _as_report_list(sorted(invalid)) ), title="Overlapping UVs" ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_shader_connections.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, ValidateMeshOrder, PublishValidationError, OptionalPyblishPluginMixin ) def pairs(iterable): """Iterate over iterable per group of two""" a = iter(iterable) for i, y in zip(a, a): yield i, y def get_invalid_sets(shapes): """Return invalid sets for the given shapes. This takes a list of shape nodes to cache the set members for overlapping sets in the queries. This avoids many Maya set member queries. Returns: dict: Dictionary of shapes and their invalid sets, e.g. {"pCubeShape": ["set1", "set2"]} """ cache = dict() invalid = dict() # Collect the sets from the shape for shape in shapes: invalid_sets = [] sets = cmds.listSets(object=shape, t=1, extendToShape=False) or [] for set_ in sets: members = cache.get(set_, None) if members is None: members = set(cmds.ls(cmds.sets(set_, query=True, nodesOnly=True), long=True)) cache[set_] = members # If the shape is not actually present as a member of the set # consider it invalid if shape not in members: invalid_sets.append(set_) if invalid_sets: invalid[shape] = invalid_sets return invalid def disconnect(node_a, node_b): """Remove all connections between node a and b.""" # Disconnect outputs outputs = cmds.listConnections(node_a, plugs=True, connections=True, source=False, destination=True) for output, destination in pairs(outputs): if destination.split(".", 1)[0] == node_b: cmds.disconnectAttr(output, destination) # Disconnect inputs inputs = cmds.listConnections(node_a, plugs=True, connections=True, source=True, destination=False) for input, source in pairs(inputs): if source.split(".", 1)[0] == node_b: cmds.disconnectAttr(source, input) class ValidateMeshShaderConnections(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Ensure mesh shading engine connections are valid. In some scenarios Maya keeps connections to multiple shaders even if just a single one is assigned on the shape. These are related sets returned by `maya.cmds.listSets` that don't actually have the shape as member. """ order = ValidateMeshOrder hosts = ['maya'] families = ['model'] label = "Mesh Shader Connections" actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] optional = True def process(self, instance): """Process all the nodes in the instance 'objectSet'""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError("Shapes found with invalid shader " "connections: {0}".format(invalid)) @staticmethod def get_invalid(instance): nodes = instance[:] shapes = cmds.ls(nodes, noIntermediate=True, long=True, type="mesh") invalid = get_invalid_sets(shapes).keys() return invalid @classmethod def repair(cls, instance): shapes = cls.get_invalid(instance) invalid = get_invalid_sets(shapes) for shape, invalid_sets in invalid.items(): for set_node in invalid_sets: disconnect(shape, set_node) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_single_uv_set.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( RepairAction, ValidateMeshOrder, OptionalPyblishPluginMixin ) class ValidateMeshSingleUVSet(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Warn on multiple UV sets existing for each polygon mesh. On versions prior to Maya 2017 this will force no multiple uv sets because the Alembic exports in Maya prior to 2017 don't support writing multiple UV sets. """ order = ValidateMeshOrder hosts = ['maya'] families = ['model', 'pointcache'] optional = True label = "Mesh Single UV Set" actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] @staticmethod def get_invalid(instance): meshes = cmds.ls(instance, type='mesh', long=True) invalid = [] for mesh in meshes: uvSets = cmds.polyUVSet(mesh, query=True, allUVSets=True) or [] # ensure unique (sometimes maya will list 'map1' twice) uvSets = set(uvSets) if len(uvSets) != 1: invalid.append(mesh) return invalid def process(self, instance): """Process all the nodes in the instance 'objectSet'""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: message = "Nodes found with multiple UV sets: {0}".format(invalid) # Maya 2017 and up allows multiple UV sets in Alembic exports # so we allow it, yet just warn the user to ensure they know about # the other UV sets. allowed = int(cmds.about(version=True)) >= 2017 if allowed: self.log.warning(message) else: raise ValueError(message) @classmethod def repair(cls, instance): for mesh in cls.get_invalid(instance): lib.remove_other_uv_sets(mesh) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_uv_set_map1.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, ValidateMeshOrder, OptionalPyblishPluginMixin ) class ValidateMeshUVSetMap1(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate model's default set exists and is named 'map1'. In Maya meshes by default have a uv set named "map1" that cannot be deleted. It can be renamed however, introducing some issues with some renderers. As such we ensure the first (default) UV set index is named "map1". """ order = ValidateMeshOrder hosts = ['maya'] families = ['model'] optional = True label = "Mesh has map1 UV Set" actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] @staticmethod def get_invalid(instance): meshes = cmds.ls(instance, type='mesh', long=True) invalid = [] for mesh in meshes: # Get existing mapping of uv sets by index indices = cmds.polyUVSet(mesh, query=True, allUVSetsIndices=True) maps = cmds.polyUVSet(mesh, query=True, allUVSets=True) mapping = dict(zip(indices, maps)) # Get the uv set at index zero. name = mapping[0] if name != "map1": invalid.append(mesh) return invalid def process(self, instance): """Process all the nodes in the instance 'objectSet'""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise ValueError("Meshes found without 'map1' " "UV set: {0}".format(invalid)) @classmethod def repair(cls, instance): """Rename uv map at index zero to map1""" for mesh in cls.get_invalid(instance): # Get existing mapping of uv sets by index indices = cmds.polyUVSet(mesh, query=True, allUVSetsIndices=True) maps = cmds.polyUVSet(mesh, query=True, allUVSets=True) mapping = dict(zip(indices, maps)) # Ensure there is no uv set named map1 to avoid # a clash on renaming the "default uv set" to map1 existing = set(maps) if "map1" in existing: # Find a unique name index i = 2 while True: name = "map{0}".format(i) if name not in existing: break i += 1 cls.log.warning("Renaming clashing uv set name on mesh" " %s to '%s'", mesh, name) cmds.polyUVSet(mesh, rename=True, uvSet="map1", newUVSet=name) # Rename the initial index to map1 original = mapping[0] cmds.polyUVSet(mesh, rename=True, uvSet=original, newUVSet="map1") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mesh_vertices_have_edges.py ================================================ import pyblish.api from maya import cmds import openpype.hosts.maya.api.action from openpype.hosts.maya.api.lib import len_flattened from openpype.pipeline.publish import ( PublishValidationError, RepairAction, ValidateMeshOrder, OptionalPyblishPluginMixin ) class ValidateMeshVerticesHaveEdges(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate meshes have only vertices that are connected to edges. Maya can have invalid geometry with vertices that have no edges or faces connected to them. In Maya 2016 EXT 2 and later there's a command to fix this: `maya.cmds.polyClean(mesh, cleanVertices=True)` In older versions of Maya it works to select the invalid vertices and merge the components. To find these invalid vertices select all vertices of the mesh that are visible in the viewport (drag to select), afterwards invert your selection (Ctrl + Shift + I). The remaining selection contains the invalid vertices. """ order = ValidateMeshOrder hosts = ['maya'] families = ['model'] label = 'Mesh Vertices Have Edges' actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] optional = True @classmethod def repair(cls, instance): # This fix only works in Maya 2016 EXT2 and newer if float(cmds.about(version=True)) <= 2016.0: raise PublishValidationError( ("Repair not supported in Maya version below " "2016 EXT 2")) invalid = cls.get_invalid(instance) for node in invalid: cmds.polyClean(node, cleanVertices=True) @classmethod def get_invalid(cls, instance): invalid = [] meshes = cmds.ls(instance, type="mesh", long=True) for mesh in meshes: num_vertices = cmds.polyEvaluate(mesh, vertex=True) if num_vertices == 0: cls.log.warning( "Skipping \"{}\", cause it does not have any " "vertices.".format(mesh) ) continue # Vertices from all edges edges = "%s.e[*]" % mesh vertices = cmds.polyListComponentConversion(edges, toVertex=True) num_vertices_from_edges = len_flattened(vertices) if num_vertices != num_vertices_from_edges: invalid.append(mesh) return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( ("Meshes found in instance with vertices that " "have no edges: {}").format(invalid)) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_model_content.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateModelContent(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Adheres to the content of 'model' product type - Must have one top group. (configurable) - Must only contain: transforms, meshes and groups """ order = ValidateContentsOrder hosts = ["maya"] families = ["model"] label = "Model Content" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] validate_top_group = True optional = False @classmethod def get_invalid(cls, instance): content_instance = instance.data.get("setMembers", None) if not content_instance: cls.log.error("Instance has no nodes!") return [instance.data["name"]] # All children will be included in the extracted export so we also # validate *all* descendents of the set members and we skip any # intermediate shapes descendants = cmds.listRelatives(content_instance, allDescendents=True, fullPath=True) or [] descendants = cmds.ls(descendants, noIntermediate=True, long=True) content_instance = list(set(content_instance + descendants)) # Ensure only valid node types allowed = ('mesh', 'transform', 'nurbsCurve', 'nurbsSurface', 'locator') nodes = cmds.ls(content_instance, long=True) valid = cmds.ls(content_instance, long=True, type=allowed) invalid = set(nodes) - set(valid) if invalid: cls.log.error("These nodes are not allowed: %s" % invalid) return list(invalid) if not valid: cls.log.error("No valid nodes in the instance") return True # Ensure it has shapes shapes = cmds.ls(valid, long=True, shapes=True) if not shapes: cls.log.error("No shapes in the model instance") return True # Top group top_parents = set([x.split("|")[1] for x in content_instance]) if cls.validate_top_group and len(top_parents) != 1: cls.log.error("Must have exactly one top group") return top_parents def _is_visible(node): """Return whether node is visible""" return lib.is_visible(node, displayLayer=False, intermediateObject=True, parentHidden=True, visibility=True) # The roots must be visible (the assemblies) for parent in top_parents: if not _is_visible(parent): cls.log.error("Invisible parent (root node) is not " "allowed: {0}".format(parent)) invalid.add(parent) # Ensure at least one shape is visible if not any(_is_visible(shape) for shape in shapes): cls.log.error("No visible shapes in the model instance") invalid.update(shapes) return list(invalid) def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( title="Model content is invalid", message="See log for more details" ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_model_name.py ================================================ # -*- coding: utf-8 -*- """Validate model nodes names.""" import os import platform import re import gridfs import pyblish.api from maya import cmds import openpype.hosts.maya.api.action from openpype.client.mongo import OpenPypeMongoConnection from openpype.hosts.maya.api.shader_definition_editor import ( DEFINITION_FILENAME) from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( OptionalPyblishPluginMixin, PublishValidationError, ValidateContentsOrder) class ValidateModelName(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate name of model starts with (somename)_###_(materialID)_GEO materialID must be present in list padding number doesn't have limit """ optional = True order = ValidateContentsOrder hosts = ["maya"] families = ["model"] label = "Model Name" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] material_file = None database_file = DEFINITION_FILENAME @classmethod def get_invalid(cls, instance): """Get invalid nodes.""" use_db = cls.database def is_group(group_name): """Find out if supplied transform is group or not.""" try: children = cmds.listRelatives(group_name, children=True) for child in children: if not cmds.ls(child, transforms=True): return False return True except Exception: return False invalid = [] content_instance = instance.data.get("setMembers", None) if not content_instance: cls.log.error("Instance has no nodes!") return True pass # validate top level group name assemblies = cmds.ls(content_instance, assemblies=True, long=True) if len(assemblies) != 1: cls.log.error("Must have exactly one top group") return assemblies or True top_group = assemblies[0] regex = cls.top_level_regex r = re.compile(regex) m = r.match(top_group) project_name = instance.context.data["projectName"] current_asset_name = instance.context.data["asset"] if m is None: cls.log.error("invalid name on: {}".format(top_group)) cls.log.error("name doesn't match regex {}".format(regex)) invalid.append(top_group) else: if "asset" in r.groupindex: if m.group("asset") != current_asset_name: cls.log.error("Invalid asset name in top level group.") return top_group if "subset" in r.groupindex: if m.group("subset") != instance.data.get("subset"): cls.log.error("Invalid subset name in top level group.") return top_group if "project" in r.groupindex: if m.group("project") != project_name: cls.log.error("Invalid project name in top level group.") return top_group descendants = cmds.listRelatives(content_instance, allDescendents=True, fullPath=True) or [] descendants = cmds.ls(descendants, noIntermediate=True, long=True) trns = cmds.ls(descendants, long=False, type='transform') # filter out groups filtered = [node for node in trns if not is_group(node)] # load shader list file as utf-8 shaders = [] if not use_db: material_file = cls.material_file[platform.system().lower()] if material_file: if os.path.isfile(material_file): shader_file = open(material_file, "r") shaders = shader_file.readlines() shader_file.close() else: cls.log.error("Missing shader name definition file.") return True else: client = OpenPypeMongoConnection.get_mongo_client() fs = gridfs.GridFS(client[os.getenv("OPENPYPE_DATABASE_NAME")]) shader_file = fs.find_one({"filename": cls.database_file}) if not shader_file: cls.log.error("Missing shader name definition in database.") return True shaders = shader_file.read().splitlines() shader_file.close() # strip line endings from list shaders = [s.rstrip() for s in shaders if s.rstrip()] # compile regex for testing names regex = cls.regex r = re.compile(regex) for obj in filtered: cls.log.debug("testing: {}".format(obj)) m = r.match(obj) if m is None: cls.log.error("invalid name on: {}".format(obj)) invalid.append(obj) else: # if we have shader files and shader named group is in # regex, test this group against names in shader file if "shader" in r.groupindex and shaders: try: if not m.group('shader') in shaders: cls.log.error( "invalid materialID on: {0} ({1})".format( obj, m.group('shader'))) invalid.append(obj) except IndexError: # shader named group doesn't match cls.log.error( "shader group doesn't match: {}".format(obj)) invalid.append(obj) return invalid def process(self, instance): """Plugin entry point.""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Model naming is invalid. See the log.") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py ================================================ import os import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin, PublishValidationError ) COLOUR_SPACES = ['sRGB', 'linear', 'auto'] MIPMAP_EXTENSIONS = ['tdl'] class ValidateMvLookContents(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): order = ValidateContentsOrder families = ['mvLook'] hosts = ['maya'] label = 'Validate mvLook Data' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] # Allow this validation step to be skipped when you just need to # get things pushed through. optional = True # These intents get enforced checks, other ones get warnings. enforced_intents = ['-', 'Final'] def process(self, instance): if not self.is_active(instance.data): return intent = instance.context.data['intent']['value'] publishMipMap = instance.data["publishMipMap"] enforced = True if intent in self.enforced_intents: self.log.debug("This validation will be enforced: '{}'" .format(intent)) else: enforced = False self.log.debug("This validation will NOT be enforced: '{}'" .format(intent)) if not instance[:]: raise PublishValidationError("Instance is empty") invalid = set() resources = instance.data.get("resources", []) for resource in resources: files = resource["files"] self.log.debug( "Resource '{}', files: [{}]".format(resource, files)) node = resource["node"] if len(files) == 0: self.log.error("File node '{}' uses no or non-existing " "files".format(node)) invalid.add(node) continue for fname in files: if not self.valid_file(fname): self.log.error("File node '{}'/'{}' is not valid" .format(node, fname)) invalid.add(node) if publishMipMap and not self.is_or_has_mipmap(fname, files): msg = "File node '{}'/'{}' does not have a mipmap".format( node, fname) if enforced: invalid.add(node) self.log.error(msg) raise PublishValidationError(msg) else: self.log.warning(msg) if invalid: raise PublishValidationError( "'{}' has invalid look content".format(instance.name) ) def valid_file(self, fname): self.log.debug("Checking validity of '{}'".format(fname)) if not os.path.exists(fname): return False if os.path.getsize(fname) == 0: return False return True def is_or_has_mipmap(self, fname, files): ext = os.path.splitext(fname)[1][1:] if ext in MIPMAP_EXTENSIONS: self.log.debug(" - Is a mipmap '{}'".format(fname)) return True for colour_space in COLOUR_SPACES: for mipmap_ext in MIPMAP_EXTENSIONS: mipmap_fname = '.'.join([fname, colour_space, mipmap_ext]) if mipmap_fname in files: self.log.debug( " - Has a mipmap '{}'".format(mipmap_fname)) return True return False ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_no_animation.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin, PublishValidationError ) def _as_report_list(values, prefix="- ", suffix="\n"): """Return list as bullet point list for a report""" if not values: return "" return prefix + (suffix + prefix).join(values) class ValidateNoAnimation(pyblish.api.Validator, OptionalPyblishPluginMixin): """Ensure no keyframes on nodes in the Instance. Even though a Model would extract without animCurves correctly this avoids getting different output from a model when extracted from a different frame than the first frame. (Might be overly restrictive though) """ order = ValidateContentsOrder label = "No Animation" hosts = ["maya"] families = ["model"] optional = True actions = [openpype.hosts.maya.api.action.SelectInvalidAction] def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Keyframes found on:\n\n{0}".format( _as_report_list(sorted(invalid)) ), title="Keyframes on model" ) @staticmethod def get_invalid(instance): nodes = instance[:] if not nodes: return [] curves = cmds.keyframe(nodes, query=True, name=True) if curves: return list(set(cmds.listConnections(curves))) return [] ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_no_default_camera.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) def _as_report_list(values, prefix="- ", suffix="\n"): """Return list as bullet point list for a report""" if not values: return "" return prefix + (suffix + prefix).join(values) class ValidateNoDefaultCameras(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Ensure no default (startup) cameras are in the instance. This might be unnecessary. In the past there were some issues with referencing/importing files that contained the start up cameras overriding settings when being loaded and sometimes being skipped. """ order = ValidateContentsOrder hosts = ['maya'] families = ['camera'] label = "No Default Cameras" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False @staticmethod def get_invalid(instance): cameras = cmds.ls(instance, type='camera', long=True) return [cam for cam in cameras if cmds.camera(cam, query=True, startupCamera=True)] def process(self, instance): """Process all the cameras in the instance""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Default cameras found:\n\n{0}".format( _as_report_list(sorted(invalid)) ), title="Default cameras" ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_no_namespace.py ================================================ import maya.cmds as cmds import pyblish.api from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) import openpype.hosts.maya.api.action def _as_report_list(values, prefix="- ", suffix="\n"): """Return list as bullet point list for a report""" if not values: return "" return prefix + (suffix + prefix).join(values) def get_namespace(node_name): # ensure only node's name (not parent path) node_name = node_name.rsplit("|", 1)[-1] # ensure only namespace return node_name.rpartition(":")[0] class ValidateNoNamespace(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Ensure the nodes don't have a namespace""" order = ValidateContentsOrder hosts = ['maya'] families = ['model'] label = 'No Namespaces' actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] optional = False @staticmethod def get_invalid(instance): nodes = cmds.ls(instance, long=True) return [node for node in nodes if get_namespace(node)] def process(self, instance): """Process all the nodes in the instance""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Namespaces found:\n\n{0}".format( _as_report_list(sorted(invalid)) ), title="Namespaces in model" ) @classmethod def repair(cls, instance): """Remove all namespaces from the nodes in the instance""" invalid = cls.get_invalid(instance) # Iterate over the nodes by long to short names to iterate the lowest # in hierarchy nodes first. This way we avoid having renamed parents # before renaming children nodes for node in sorted(invalid, key=len, reverse=True): node_name = node.rsplit("|", 1)[-1] node_name_without_namespace = node_name.rsplit(":")[-1] cmds.rename(node, node_name_without_namespace) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_no_null_transforms.py ================================================ import maya.cmds as cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) def _as_report_list(values, prefix="- ", suffix="\n"): """Return list as bullet point list for a report""" if not values: return "" return prefix + (suffix + prefix).join(values) def has_shape_children(node): # Check if any descendants allDescendents = cmds.listRelatives(node, allDescendents=True, fullPath=True) if not allDescendents: return False # Check if there are any shapes at all shapes = cmds.ls(allDescendents, shapes=True) if not shapes: return False # Check if all descendent shapes are intermediateObjects; # if so we consider this node a null node and return False. if all(cmds.getAttr('{0}.intermediateObject'.format(x)) for x in shapes): return False return True class ValidateNoNullTransforms(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Ensure no null transforms are in the scene. Warning: Transforms with only intermediate shapes are also considered null transforms. These transform nodes could potentially be used in your construction history, so take care when automatically fixing this or when deleting the empty transforms manually. """ order = ValidateContentsOrder hosts = ['maya'] families = ['model'] label = 'No Empty/Null Transforms' actions = [RepairAction, openpype.hosts.maya.api.action.SelectInvalidAction] optional = False @staticmethod def get_invalid(instance): """Return invalid transforms in instance""" transforms = cmds.ls(instance, type='transform', long=True) invalid = [] for transform in transforms: if not has_shape_children(transform): invalid.append(transform) return invalid def process(self, instance): """Process all the transform nodes in the instance """ if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Empty transforms found without shapes:\n\n{0}".format( _as_report_list(sorted(invalid)) ), title="Empty transforms" ) @classmethod def repair(cls, instance): """Delete all null transforms. Note: If the node is used elsewhere (eg. connection to attributes or in history) deletion might mess up things. """ invalid = cls.get_invalid(instance) if invalid: cmds.delete(invalid) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_no_unknown_nodes.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin, PublishValidationError ) def _as_report_list(values, prefix="- ", suffix="\n"): """Return list as bullet point list for a report""" if not values: return "" return prefix + (suffix + prefix).join(values) class ValidateNoUnknownNodes(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Checks to see if there are any unknown nodes in the instance. This often happens if nodes from plug-ins are used but are not available on this machine. Note: Some studios use unknown nodes to store data on (as attributes) because it's a lightweight node. """ order = ValidateContentsOrder hosts = ['maya'] families = ['model', 'rig'] optional = True label = "Unknown Nodes" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] @staticmethod def get_invalid(instance): return cmds.ls(instance, type='unknown') def process(self, instance): """Process all the nodes in the instance""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Unknown nodes found:\n\n{0}".format( _as_report_list(sorted(invalid)) ), title="Unknown nodes" ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_no_vraymesh.py ================================================ import pyblish.api from maya import cmds from openpype.pipeline.publish import ( PublishValidationError, OptionalPyblishPluginMixin ) def _as_report_list(values, prefix="- ", suffix="\n"): """Return list as bullet point list for a report""" if not values: return "" return prefix + (suffix + prefix).join(values) class ValidateNoVRayMesh(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate there are no VRayMesh objects in the instance""" order = pyblish.api.ValidatorOrder label = 'No V-Ray Proxies (VRayMesh)' families = ["pointcache"] optional = False def process(self, instance): if not self.is_active(instance.data): return if not cmds.pluginInfo("vrayformaya", query=True, loaded=True): return shapes = cmds.ls(instance, shapes=True, type="mesh") inputs = cmds.listConnections(shapes, destination=False, source=True) or [] vray_meshes = cmds.ls(inputs, type='VRayMesh') if vray_meshes: raise PublishValidationError( "Meshes that are V-Ray Proxies should not be in an Alembic " "pointcache.\n" "Found V-Ray proxies:\n\n{}".format( _as_report_list(sorted(vray_meshes)) ), title="V-Ray Proxies in pointcache" ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_node_ids.py ================================================ import pyblish.api from openpype.pipeline.publish import ( ValidatePipelineOrder, PublishXmlValidationError ) import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib class ValidateNodeIDs(pyblish.api.InstancePlugin): """Validate nodes have a Colorbleed Id. When IDs are missing from nodes *save your scene* and they should be automatically generated because IDs are created on non-referenced nodes in Maya upon scene save. """ order = ValidatePipelineOrder label = 'Instance Nodes Have ID' hosts = ['maya'] families = ["model", "look", "rig", "pointcache", "animation", "yetiRig", "assembly"] actions = [openpype.hosts.maya.api.action.SelectInvalidAction, openpype.hosts.maya.api.action.GenerateUUIDsOnInvalidAction] def process(self, instance): """Process all meshes""" # Ensure all nodes have a cbId invalid = self.get_invalid(instance) if invalid: names = "\n".join( "- {}".format(node) for node in invalid ) raise PublishXmlValidationError( plugin=self, message="Nodes found without IDs: {}".format(invalid), formatting_data={"nodes": names} ) @classmethod def get_invalid(cls, instance): """Return the member nodes that are invalid""" # We do want to check the referenced nodes as it might be # part of the end product. id_nodes = lib.get_id_required_nodes(referenced_nodes=True, nodes=instance[:]) invalid = [n for n in id_nodes if not lib.get_id(n)] return invalid ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_node_ids_deformed_shapes.py ================================================ import pyblish.api from maya import cmds import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( PublishValidationError, RepairAction, ValidateContentsOrder) class ValidateNodeIdsDeformedShape(pyblish.api.InstancePlugin): """Validate if deformed shapes have related IDs to the original shapes. When a deformer is applied in the scene on a referenced mesh that already had deformers then Maya will create a new shape node for the mesh that does not have the original id. This validator checks whether the ids are valid on all the shape nodes in the instance. """ order = ValidateContentsOrder families = ['look'] hosts = ['maya'] label = 'Deformed shape ids' actions = [ openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction ] def process(self, instance): """Process all the nodes in the instance""" # Ensure all nodes have a cbId and a related ID to the original shapes # if a deformer has been created on the shape invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( ("Shapes found that are considered 'Deformed'" "without object ids: {0}").format(invalid)) @classmethod def get_invalid(cls, instance): """Get all nodes which do not match the criteria""" shapes = cmds.ls(instance[:], dag=True, leaf=True, shapes=True, long=True, noIntermediate=True) invalid = [] for shape in shapes: history_id = lib.get_id_from_sibling(shape) if history_id: current_id = lib.get_id(shape) if current_id != history_id: invalid.append(shape) return invalid @classmethod def repair(cls, instance): for node in cls.get_invalid(instance): # Get the original id from history history_id = lib.get_id_from_sibling(node) if not history_id: cls.log.error("Could not find ID in history for '%s'", node) continue lib.set_id(node, history_id, overwrite=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_node_ids_in_database.py ================================================ import pyblish.api import openpype.hosts.maya.api.action from openpype.client import get_assets from openpype.hosts.maya.api import lib from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( PublishValidationError, ValidatePipelineOrder) class ValidateNodeIdsInDatabase(pyblish.api.InstancePlugin): """Validate if the CB Id is related to an asset in the database All nodes with the `cbId` attribute will be validated to ensure that the loaded asset in the scene is related to the current project. Tip: If there is an asset which is being reused from a different project please ensure the asset is republished in the new project """ order = ValidatePipelineOrder label = 'Node Ids in Database' hosts = ['maya'] families = ["*"] actions = [openpype.hosts.maya.api.action.SelectInvalidAction, openpype.hosts.maya.api.action.GenerateUUIDsOnInvalidAction] def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( ("Found asset IDs which are not related to " "current project in instance: `{}`").format(instance.name)) @classmethod def get_invalid(cls, instance): invalid = [] # Get all id required nodes id_required_nodes = lib.get_id_required_nodes(referenced_nodes=True, nodes=instance[:]) # check ids against database ids project_name = legacy_io.active_project() asset_docs = get_assets(project_name, fields=["_id"]) db_asset_ids = { str(asset_doc["_id"]) for asset_doc in asset_docs } # Get all asset IDs for node in id_required_nodes: cb_id = lib.get_id(node) # Ignore nodes without id, those are validated elsewhere if not cb_id: continue asset_id = cb_id.split(":", 1)[0] if asset_id not in db_asset_ids: cls.log.error("`%s` has unassociated asset ID" % node) invalid.append(node) return invalid ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_node_ids_related.py ================================================ import pyblish.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( OptionalPyblishPluginMixin, PublishValidationError, ValidatePipelineOrder) class ValidateNodeIDsRelated(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate nodes have a related Colorbleed Id to the instance.data[asset] """ order = ValidatePipelineOrder label = 'Node Ids Related (ID)' hosts = ['maya'] families = ["model", "look", "rig"] optional = True actions = [openpype.hosts.maya.api.action.SelectInvalidAction, openpype.hosts.maya.api.action.GenerateUUIDsOnInvalidAction] def process(self, instance): """Process all nodes in instance (including hierarchy)""" if not self.is_active(instance.data): return # Ensure all nodes have a cbId invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( ("Nodes IDs found that are not related to asset " "'{}' : {}").format(instance.data['asset'], invalid)) @classmethod def get_invalid(cls, instance): """Return the member nodes that are invalid""" invalid = list() asset_id = str(instance.data['assetEntity']["_id"]) # We do want to check the referenced nodes as we it might be # part of the end product for node in instance: _id = lib.get_id(node) if not _id: continue node_asset_id = _id.split(":", 1)[0] if node_asset_id != asset_id: invalid.append(node) return invalid ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py ================================================ from collections import defaultdict import pyblish.api from openpype.pipeline.publish import ( ValidatePipelineOrder, PublishValidationError ) import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib class ValidateNodeIdsUnique(pyblish.api.InstancePlugin): """Validate the nodes in the instance have a unique Colorbleed Id Here we ensure that what has been added to the instance is unique """ order = ValidatePipelineOrder label = 'Non Duplicate Instance Members (ID)' hosts = ['maya'] families = ["model", "look", "rig", "yetiRig"] actions = [openpype.hosts.maya.api.action.SelectInvalidAction, openpype.hosts.maya.api.action.GenerateUUIDsOnInvalidAction] def process(self, instance): """Process all meshes""" # Ensure all nodes have a cbId invalid = self.get_invalid(instance) if invalid: label = "Nodes found with non-unique asset IDs" raise PublishValidationError( message="{}: {}".format(label, invalid), title="Non-unique asset ids on nodes", description="{}\n- {}".format(label, "\n- ".join(sorted(invalid))) ) @classmethod def get_invalid(cls, instance): """Return the member nodes that are invalid""" # Check only non intermediate shapes # todo: must the instance itself ensure to have no intermediates? # todo: how come there are intermediates? from maya import cmds instance_members = cmds.ls(instance, noIntermediate=True, long=True) # Collect each id with their members ids = defaultdict(list) for member in instance_members: object_id = lib.get_id(member) if not object_id: continue ids[object_id].append(member) # Take only the ids with more than one member invalid = list() _iteritems = getattr(ids, "iteritems", ids.items) for _ids, members in _iteritems(): if len(members) > 1: cls.log.error("ID found on multiple nodes: '%s'" % members) invalid.extend(members) return invalid ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin ) class ValidateNodeNoGhosting(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Ensure nodes do not have ghosting enabled. If one would publish towards a non-Maya format it's likely that stats like ghosting won't be exported, eg. exporting to Alembic. Instead of creating many micro-managing checks (like this one) to ensure attributes have not been changed from their default it could be more efficient to export to a format that will never hold such data anyway. """ order = ValidateContentsOrder hosts = ['maya'] families = ['model', 'rig'] label = "No Ghosting" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False _attributes = {'ghosting': 0} @classmethod def get_invalid(cls, instance): # Transforms and shapes seem to have ghosting nodes = cmds.ls(instance, long=True, type=['transform', 'shape']) invalid = [] for node in nodes: _iteritems = getattr( cls._attributes, "iteritems", cls._attributes.items ) for attr, required_value in _iteritems(): if cmds.attributeQuery(attr, node=node, exists=True): value = cmds.getAttr('{0}.{1}'.format(node, attr)) if value != required_value: invalid.append(node) return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise ValueError("Nodes with ghosting enabled found: " "{0}".format(invalid)) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py ================================================ import os from maya import cmds import pyblish.api from openpype.hosts.maya.api.lib import pairwise from openpype.hosts.maya.api.action import SelectInvalidAction from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidatePluginPathAttributes(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """ Validate plug-in path attributes point to existing file paths. """ order = ValidateContentsOrder hosts = ['maya'] families = ["workfile"] label = "Plug-in Path Attributes" actions = [SelectInvalidAction] optional = False # Attributes are defined in project settings attribute = [] @classmethod def get_invalid(cls, instance): invalid = list() file_attrs = cls.attribute if not file_attrs: return invalid # Consider only valid node types to avoid "Unknown object type" warning all_node_types = set(cmds.allNodeTypes()) node_types = [ key for key in file_attrs.keys() if key in all_node_types ] for node, node_type in pairwise(cmds.ls(type=node_types, showType=True)): # get the filepath file_attr = "{}.{}".format(node, file_attrs[node_type]) filepath = cmds.getAttr(file_attr) if filepath and not os.path.exists(filepath): cls.log.error("{} '{}' uses non-existing filepath: {}" .format(node_type, node, filepath)) invalid.append(node) return invalid def process(self, instance): """Process all directories Set as Filenames in Non-Maya Nodes""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( title="Plug-in Path Attributes", message="Non-existent filepath found on nodes: {}".format( ", ".join(invalid) ), description=( "## Plug-in nodes use invalid filepaths\n" "The workfile contains nodes from plug-ins that use " "filepaths which do not exist.\n\n" "Please make sure their filepaths are correct and the " "files exist on disk." ) ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_render_image_rule.py ================================================ import os import pyblish.api from maya import cmds from openpype.pipeline.publish import ( PublishValidationError, RepairAction, ValidateContentsOrder, OptionalPyblishPluginMixin ) class ValidateRenderImageRule(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates Maya Workpace "images" file rule matches project settings. This validates against the configured default render image folder: Studio Settings > Project > Maya > Render Settings > Default render image folder. """ order = ValidateContentsOrder label = "Images File Rule (Workspace)" hosts = ["maya"] families = ["renderlayer"] actions = [RepairAction] optional = False def process(self, instance): if not self.is_active(instance.data): return required_images_rule = os.path.normpath( self.get_default_render_image_folder(instance) ) current_images_rule = os.path.normpath( cmds.workspace(fileRuleEntry="images") ) if current_images_rule != required_images_rule: raise PublishValidationError( ( "Invalid workspace `images` file rule value: '{}'. " "Must be set to: '{}'" ).format(current_images_rule, required_images_rule)) @classmethod def repair(cls, instance): required_images_rule = cls.get_default_render_image_folder(instance) current_images_rule = cmds.workspace(fileRuleEntry="images") if current_images_rule != required_images_rule: cmds.workspace(fileRule=("images", required_images_rule)) cmds.workspace(saveWorkspace=True) @classmethod def get_default_render_image_folder(cls, instance): staging_dir = instance.data.get("stagingDir") if staging_dir: cls.log.debug( "Staging dir found: \"{}\". Ignoring setting from " "`project_settings/maya/RenderSettings/" "default_render_image_folder`.".format(staging_dir) ) return staging_dir return instance.context.data.get('project_settings')\ .get('maya') \ .get('RenderSettings') \ .get('default_render_image_folder') ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_render_no_default_cameras.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateRenderNoDefaultCameras(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Ensure no default (startup) cameras are to be rendered.""" order = ValidateContentsOrder hosts = ['maya'] families = ['renderlayer'] label = "No Default Cameras Renderable" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False @staticmethod def get_invalid(instance): renderable = set(instance.data["cameras"]) # Collect default cameras cameras = cmds.ls(type='camera', long=True) defaults = set(cam for cam in cameras if cmds.camera(cam, query=True, startupCamera=True)) return [cam for cam in renderable if cam in defaults] def process(self, instance): """Process all the cameras in the instance""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( title="Rendering default cameras", message="Renderable default cameras " "found: {0}".format(invalid)) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_render_single_camera.py ================================================ import re import pyblish.api from maya import cmds import openpype.hosts.maya.api.action from openpype.hosts.maya.api.lib_rendersettings import RenderSettings from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateRenderSingleCamera(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate renderable camera count for layer and token. Pipeline is supporting multiple renderable cameras per layer, but image prefix must contain token. """ order = ValidateContentsOrder label = "Render Single Camera" hosts = ['maya'] families = ["renderlayer", "vrayscene"] actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False R_CAMERA_TOKEN = re.compile(r'%c|', re.IGNORECASE) def process(self, instance): """Process all the cameras in the instance""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError("Invalid cameras for render.") @classmethod def get_invalid(cls, instance): cameras = instance.data.get("cameras", []) renderer = cmds.getAttr('defaultRenderGlobals.currentRenderer').lower() # handle various renderman names if renderer.startswith('renderman'): renderer = 'renderman' file_prefix = cmds.getAttr( RenderSettings.get_image_prefix_attr(renderer) ) if len(cameras) > 1: if re.search(cls.R_CAMERA_TOKEN, file_prefix): # if there is token in prefix and we have more then # 1 camera, all is ok. return cls.log.error("Multiple renderable cameras found for %s: %s " % (instance.data["setMembers"], cameras)) return [instance.data["setMembers"]] + cameras elif len(cameras) < 1: cls.log.error("No renderable cameras found for %s " % instance.data["setMembers"]) return [instance.data["setMembers"]] ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_renderlayer_aovs.py ================================================ import pyblish.api import openpype.hosts.maya.api.action from openpype.client import get_subset_by_name from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( PublishValidationError, OptionalPyblishPluginMixin ) class ValidateRenderLayerAOVs(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate created AOVs / RenderElement is registered in the database Each render element is registered as a product which is formatted based on the render layer and the render element, example: . This translates to something like this: CHAR.diffuse This check is needed to ensure the render output is still complete """ order = pyblish.api.ValidatorOrder + 0.1 label = "Render Passes / AOVs Are Registered" hosts = ["maya"] families = ["renderlayer"] actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Found unregistered subsets: {}".format(invalid)) def get_invalid(self, instance): invalid = [] project_name = legacy_io.active_project() asset_doc = instance.data["assetEntity"] render_passes = instance.data.get("renderPasses", []) for render_pass in render_passes: is_valid = self.validate_subset_registered( project_name, asset_doc, render_pass ) if not is_valid: invalid.append(render_pass) return invalid def validate_subset_registered(self, project_name, asset_doc, subset_name): """Check if subset is registered in the database under the asset""" return get_subset_by_name( project_name, subset_name, asset_doc["_id"], fields=["_id"] ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_rendersettings.py ================================================ # -*- coding: utf-8 -*- """Maya validator for render settings.""" import re from collections import OrderedDict from maya import cmds, mel import pyblish.api from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, ) from openpype.hosts.maya.api import lib from openpype.hosts.maya.api.lib_rendersettings import RenderSettings def convert_to_int_or_float(string_value): # Order of types are important here since float can convert string # representation of integer. types = [int, float] for t in types: try: result = t(string_value) except ValueError: continue else: return result # Neither integer or float. return string_value def get_redshift_image_format_labels(): """Return nice labels for Redshift image formats.""" var = "$g_redshiftImageFormatLabels" return mel.eval("{0}={0}".format(var)) class ValidateRenderSettings(pyblish.api.InstancePlugin): """Validates the global render settings * File Name Prefix must start with: `` all other token are customizable but sane values for Arnold are: `//_` token is supported also, useful for multiple renderable cameras per render layer. For Redshift omit token. Redshift will append it automatically if AOVs are enabled and if you user Multipart EXR it doesn't make much sense. * Frame Padding must be: * default: 4 * Animation must be toggle on, in Render Settings - Common tab: * vray: Animation on standard of specific * arnold: Frame / Animation ext: Any choice without "(Single Frame)" * redshift: Animation toggled on NOTE: The repair function of this plugin does not repair the animation setting of the render settings due to multiple possibilities. """ order = ValidateContentsOrder label = "Render Settings" hosts = ["maya"] families = ["renderlayer"] actions = [RepairAction] ImagePrefixes = { 'mentalray': 'defaultRenderGlobals.imageFilePrefix', 'vray': 'vraySettings.fileNamePrefix', 'arnold': 'defaultRenderGlobals.imageFilePrefix', 'renderman': 'rmanGlobals.imageFileFormat', 'redshift': 'defaultRenderGlobals.imageFilePrefix', 'mayahardware2': 'defaultRenderGlobals.imageFilePrefix', } ImagePrefixTokens = { 'mentalray': '//{aov_separator}', # noqa: E501 'arnold': '//{aov_separator}', # noqa: E501 'redshift': '//', 'vray': '//', 'renderman': '{aov_separator}..', 'mayahardware2': '//', } _aov_chars = { "dot": ".", "dash": "-", "underscore": "_" } redshift_AOV_prefix = "/{aov_separator}" # noqa: E501 renderman_dir_prefix = "/" R_AOV_TOKEN = re.compile( r'%a||', re.IGNORECASE) R_LAYER_TOKEN = re.compile( r'%l||', re.IGNORECASE) R_CAMERA_TOKEN = re.compile(r'%c|Camera>') R_SCENE_TOKEN = re.compile(r'%s|', re.IGNORECASE) DEFAULT_PADDING = 4 VRAY_PREFIX = "//" DEFAULT_PREFIX = "//_" def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( title="Invalid Render Settings", message=("Invalid render settings found " "for '{}'!".format(instance.name)) ) @classmethod def get_invalid(cls, instance): invalid = False renderer = instance.data['renderer'] layer = instance.data['renderlayer'] cameras = instance.data.get("cameras", []) # Prefix attribute can return None when a value was never set prefix = lib.get_attr_in_layer(cls.ImagePrefixes[renderer], layer=layer) or "" padding = lib.get_attr_in_layer( attr=RenderSettings.get_padding_attr(renderer), layer=layer ) anim_override = lib.get_attr_in_layer("defaultRenderGlobals.animation", layer=layer) prefix = prefix.replace( "{aov_separator}", instance.data.get("aovSeparator", "_")) default_prefix = cls.ImagePrefixTokens[renderer] if not anim_override: invalid = True cls.log.error("Animation needs to be enabled. Use the same " "frame for start and end to render single frame") if not re.search(cls.R_LAYER_TOKEN, prefix): invalid = True cls.log.error("Wrong image prefix [ {} ] - " "doesn't have: '' or " "'' token".format(prefix)) if len(cameras) > 1 and not re.search(cls.R_CAMERA_TOKEN, prefix): invalid = True cls.log.error("Wrong image prefix [ {} ] - " "doesn't have: '' token".format(prefix)) cls.log.error( "Note that to needs to have capital 'C' at the beginning") # renderer specific checks if renderer == "vray": vray_settings = cmds.ls(type="VRaySettingsNode") if not vray_settings: node = cmds.createNode("VRaySettingsNode") else: node = vray_settings[0] scene_sep = cmds.getAttr( "{}.fileNameRenderElementSeparator".format(node)) if scene_sep != instance.data.get("aovSeparator", "_"): cls.log.error("AOV separator is not set correctly.") invalid = True if renderer == "redshift": redshift_AOV_prefix = cls.redshift_AOV_prefix.replace( "{aov_separator}", instance.data.get("aovSeparator", "_") ) if re.search(cls.R_AOV_TOKEN, prefix): invalid = True cls.log.error(("Do not use AOV token [ {} ] - " "Redshift is using image prefixes per AOV so " "it doesn't make much sense using it in global" "image prefix").format(prefix)) # get redshift AOVs rs_aovs = cmds.ls(type="RedshiftAOV", referencedNodes=False) for aov in rs_aovs: aov_prefix = cmds.getAttr("{}.filePrefix".format(aov)) # check their image prefix if aov_prefix != redshift_AOV_prefix: cls.log.error(("AOV ({}) image prefix is not set " "correctly {} != {}").format( cmds.getAttr("{}.name".format(aov)), aov_prefix, redshift_AOV_prefix )) invalid = True # check aov file format aov_ext = cmds.getAttr("{}.fileFormat".format(aov)) default_ext = cmds.getAttr("redshiftOptions.imageFormat") aov_type = cmds.getAttr("{}.aovType".format(aov)) if aov_type == "Cryptomatte": # redshift Cryptomatte AOV always uses "Cryptomatte (EXR)" # so we ignore validating file format for it. pass elif default_ext != aov_ext: labels = get_redshift_image_format_labels() cls.log.error( "AOV file format {} does not match global file format " "{}".format(labels[aov_ext], labels[default_ext]) ) invalid = True if renderer == "renderman": file_prefix = cmds.getAttr("rmanGlobals.imageFileFormat") dir_prefix = cmds.getAttr("rmanGlobals.imageOutputDir") if file_prefix.lower() != prefix.lower(): invalid = True cls.log.error("Wrong image prefix [ {} ]".format(file_prefix)) if dir_prefix.lower() != cls.renderman_dir_prefix.lower(): invalid = True cls.log.error("Wrong directory prefix [ {} ]".format( dir_prefix)) if renderer == "arnold": multipart = cmds.getAttr("defaultArnoldDriver.mergeAOVs") if multipart: if re.search(cls.R_AOV_TOKEN, prefix): invalid = True cls.log.error("Wrong image prefix [ {} ] - " "You can't use '' token " "with merge AOVs turned on".format(prefix)) default_prefix = re.sub( cls.R_AOV_TOKEN, "", default_prefix) # remove aov token from prefix to pass validation default_prefix = default_prefix.split("{aov_separator}")[0] elif not re.search(cls.R_AOV_TOKEN, prefix): invalid = True cls.log.error("Wrong image prefix [ {} ] - " "doesn't have: '' or " "token".format(prefix)) default_prefix = default_prefix.replace( "{aov_separator}", instance.data.get("aovSeparator", "_")) if prefix.lower() != default_prefix.lower(): cls.log.warning("warning: prefix differs from " "recommended {}".format( default_prefix)) if padding != cls.DEFAULT_PADDING: invalid = True cls.log.error("Expecting padding of {} ( {} )".format( cls.DEFAULT_PADDING, "0" * cls.DEFAULT_PADDING)) # load validation definitions from settings settings_lights_flag = instance.context.data["project_settings"].get( "maya", {}).get( "RenderSettings", {}).get( "enable_all_lights", False) instance_lights_flag = instance.data.get("renderSetupIncludeLights") if settings_lights_flag != instance_lights_flag: cls.log.warning( "Instance flag for \"Render Setup Include Lights\" is set to " "{} and Settings flag is set to {}".format( instance_lights_flag, settings_lights_flag ) ) # go through definitions and test if such node.attribute exists. # if so, compare its value from the one required. for data in cls.get_nodes(instance, renderer): for node in data["nodes"]: try: render_value = cmds.getAttr( "{}.{}".format(node, data["attribute"]) ) except PublishValidationError: invalid = True cls.log.error( "Cannot get value of {}.{}".format( node, data["attribute"] ) ) else: if render_value not in data["values"]: invalid = True cls.log.error( "Invalid value {} set on {}.{}. Expecting " "{}".format( render_value, node, data["attribute"], data["values"] ) ) return invalid @classmethod def get_nodes(cls, instance, renderer): maya_settings = instance.context.data["project_settings"]["maya"] validation_settings = ( maya_settings["publish"]["ValidateRenderSettings"].get( "{}_render_attributes".format(renderer) ) or [] ) result = [] for attr, values in OrderedDict(validation_settings).items(): values = [convert_to_int_or_float(v) for v in values if v] # Validate the settings has values. if not values: cls.log.error( "Settings for {} is missing values.".format(attr) ) continue cls.log.debug("{}: {}".format(attr, values)) if "." not in attr: cls.log.warning( "Skipping invalid attribute defined in validation " "settings: \"{}\"".format(attr) ) continue node_type, attribute_name = attr.split(".", 1) # first get node of that type nodes = cmds.ls(type=node_type) if not nodes: cls.log.warning( "No nodes of type \"{}\" found.".format(node_type) ) continue result.append( { "attribute": attribute_name, "nodes": nodes, "values": values } ) return result @classmethod def repair(cls, instance): renderer = instance.data['renderer'] layer_node = instance.data['setMembers'] redshift_AOV_prefix = cls.redshift_AOV_prefix.replace( "{aov_separator}", instance.data.get("aovSeparator", "_") ) default_prefix = cls.ImagePrefixTokens[renderer].replace( "{aov_separator}", instance.data.get("aovSeparator", "_") ) for data in cls.get_nodes(instance, renderer): if not data["values"]: continue for node in data["nodes"]: lib.set_attribute(data["attribute"], data["values"][0], node) with lib.renderlayer(layer_node): # Repair animation must be enabled cmds.setAttr("defaultRenderGlobals.animation", True) # Repair prefix if renderer == "arnold": multipart = cmds.getAttr("defaultArnoldDriver.mergeAOVs") if multipart: separator_variations = [ "_", "_", "", ] for variant in separator_variations: default_prefix = default_prefix.replace(variant, "") if renderer != "renderman": prefix_attr = RenderSettings.get_image_prefix_attr(renderer) fname_prefix = default_prefix cmds.setAttr(prefix_attr, fname_prefix, type="string") # Repair padding padding_attr = RenderSettings.get_padding_attr(renderer) cmds.setAttr(padding_attr, cls.DEFAULT_PADDING) else: # renderman handles stuff differently cmds.setAttr("rmanGlobals.imageFileFormat", default_prefix, type="string") cmds.setAttr("rmanGlobals.imageOutputDir", cls.renderman_dir_prefix, type="string") if renderer == "vray": vray_settings = cmds.ls(type="VRaySettingsNode") if not vray_settings: node = cmds.createNode("VRaySettingsNode") else: node = vray_settings[0] cmds.optionMenuGrp("vrayRenderElementSeparator", v=instance.data.get("aovSeparator", "_")) cmds.setAttr( "{}.fileNameRenderElementSeparator".format(node), instance.data.get("aovSeparator", "_"), type="string" ) if renderer == "redshift": # get redshift AOVs rs_aovs = cmds.ls(type="RedshiftAOV", referencedNodes=False) for aov in rs_aovs: # fix AOV prefixes cmds.setAttr( "{}.filePrefix".format(aov), redshift_AOV_prefix, type="string") # fix AOV file format default_ext = cmds.getAttr( "redshiftOptions.imageFormat", asString=True) cmds.setAttr( "{}.fileFormat".format(aov), default_ext) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_resolution.py ================================================ import pyblish.api from openpype.pipeline import ( PublishValidationError, OptionalPyblishPluginMixin ) from maya import cmds from openpype.pipeline.publish import RepairAction from openpype.hosts.maya.api import lib from openpype.hosts.maya.api.lib import reset_scene_resolution class ValidateResolution(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate the render resolution setting aligned with DB""" order = pyblish.api.ValidatorOrder families = ["renderlayer"] hosts = ["maya"] label = "Validate Resolution" actions = [RepairAction] optional = True def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid_resolution(instance) if invalid: raise PublishValidationError( "Render resolution is invalid. See log for details.", description=( "Wrong render resolution setting. " "Please use repair button to fix it.\n\n" "If current renderer is V-Ray, " "make sure vraySettings node has been created." ) ) @classmethod def get_invalid_resolution(cls, instance): width, height, pixelAspect = cls.get_db_resolution(instance) current_renderer = instance.data["renderer"] layer = instance.data["renderlayer"] invalid = False if current_renderer == "vray": vray_node = "vraySettings" if cmds.objExists(vray_node): current_width = lib.get_attr_in_layer( "{}.width".format(vray_node), layer=layer) current_height = lib.get_attr_in_layer( "{}.height".format(vray_node), layer=layer) current_pixelAspect = lib.get_attr_in_layer( "{}.pixelAspect".format(vray_node), layer=layer ) else: cls.log.error( "Can't detect VRay resolution because there is no node " "named: `{}`".format(vray_node) ) return True else: current_width = lib.get_attr_in_layer( "defaultResolution.width", layer=layer) current_height = lib.get_attr_in_layer( "defaultResolution.height", layer=layer) current_pixelAspect = lib.get_attr_in_layer( "defaultResolution.pixelAspect", layer=layer ) if current_width != width or current_height != height: cls.log.error( "Render resolution {}x{} does not match " "asset resolution {}x{}".format( current_width, current_height, width, height )) invalid = True if current_pixelAspect != pixelAspect: cls.log.error( "Render pixel aspect {} does not match " "asset pixel aspect {}".format( current_pixelAspect, pixelAspect )) invalid = True return invalid @classmethod def get_db_resolution(cls, instance): asset_doc = instance.data["assetEntity"] project_doc = instance.context.data["projectEntity"] for data in [asset_doc["data"], project_doc["data"]]: if ( "resolutionWidth" in data and "resolutionHeight" in data and "pixelAspect" in data ): width = data["resolutionWidth"] height = data["resolutionHeight"] pixelAspect = data["pixelAspect"] return int(width), int(height), float(pixelAspect) # Defaults if not found in asset document or project document return 1920, 1080, 1.0 @classmethod def repair(cls, instance): # Usually without renderlayer overrides the renderlayers # all share the same resolution value - so fixing the first # will have fixed all the others too. It's much faster to # check whether it's invalid first instead of switching # into all layers individually if not cls.get_invalid_resolution(instance): cls.log.debug( "Nothing to repair on instance: {}".format(instance) ) return layer_node = instance.data['setMembers'] with lib.renderlayer(layer_node): reset_scene_resolution() ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_resources.py ================================================ import os from collections import defaultdict import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError ) class ValidateResources(pyblish.api.InstancePlugin): """Validates mapped resources. These are external files to the current application, for example these could be textures, image planes, cache files or other linked media. This validates: - The resources have unique filenames (without extension) """ order = ValidateContentsOrder label = "Resources Unique" def process(self, instance): resources = instance.data.get("resources", []) if not resources: self.log.debug("No resources to validate..") return basenames = defaultdict(set) for resource in resources: files = resource.get("files", []) for filename in files: # Use normalized paths in comparison and ignore case # sensitivity filename = os.path.normpath(filename).lower() basename = os.path.splitext(os.path.basename(filename))[0] basenames[basename].add(filename) invalid_resources = list() for basename, sources in basenames.items(): if len(sources) > 1: invalid_resources.extend(sources) self.log.error( "Non-unique resource name: {0}" "{0} (sources: {1})".format( basename, list(sources) ) ) if invalid_resources: raise PublishValidationError("Invalid resources in instance.") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_review.py ================================================ import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError ) class ValidateReview(pyblish.api.InstancePlugin): """Validate review.""" order = ValidateContentsOrder label = "Validate Review" families = ["review"] def process(self, instance): cameras = instance.data["cameras"] # validate required settings if len(cameras) == 0: raise PublishValidationError( "No camera found in review instance: {}".format(instance) ) elif len(cameras) > 2: raise PublishValidationError( "Only a single camera is allowed for a review instance but " "more than one camera found in review instance: {}. " "Cameras found: {}".format(instance, ", ".join(cameras)) ) self.log.debug('camera: {}'.format(instance.data["review_camera"])) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_rig_contents.py ================================================ import pyblish.api from maya import cmds import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder, OptionalPyblishPluginMixin ) class ValidateRigContents(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Ensure rig contains pipeline-critical content Every rig must contain at least two object sets: "controls_SET" - Set of all animatable controls "out_SET" - Set of all cacheable meshes """ order = ValidateContentsOrder label = "Rig Contents" hosts = ["maya"] families = ["rig"] action = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = True accepted_output = ["mesh", "transform"] accepted_controllers = ["transform"] def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Invalid rig content. See log for details.") @classmethod def get_invalid(cls, instance): # Find required sets by suffix required, rig_sets = cls.get_nodes(instance) cls.validate_missing_objectsets(instance, required, rig_sets) controls_set = rig_sets["controls_SET"] out_set = rig_sets["out_SET"] # Ensure contents in sets and retrieve long path for all objects output_content = cmds.sets(out_set, query=True) or [] if not output_content: raise PublishValidationError("Must have members in rig out_SET") output_content = cmds.ls(output_content, long=True) controls_content = cmds.sets(controls_set, query=True) or [] if not controls_content: raise PublishValidationError( "Must have members in rig controls_SET" ) controls_content = cmds.ls(controls_content, long=True) rig_content = output_content + controls_content invalid_hierarchy = cls.invalid_hierarchy(instance, rig_content) # Additional validations invalid_geometry = cls.validate_geometry(output_content) invalid_controls = cls.validate_controls(controls_content) error = False if invalid_hierarchy: cls.log.error("Found nodes which reside outside of root group " "while they are set up for publishing." "\n%s" % invalid_hierarchy) error = True if invalid_controls: cls.log.error("Only transforms can be part of the controls_SET." "\n%s" % invalid_controls) error = True if invalid_geometry: cls.log.error("Only meshes can be part of the out_SET\n%s" % invalid_geometry) error = True if error: return invalid_hierarchy + invalid_controls + invalid_geometry @classmethod def validate_missing_objectsets(cls, instance, required_objsets, rig_sets): """Validate missing objectsets in rig sets Args: instance (str): instance required_objsets (list): list of objectset names rig_sets (list): list of rig sets Raises: PublishValidationError: When the error is raised, it will show which instance has the missing object sets """ missing = [ key for key in required_objsets if key not in rig_sets ] if missing: raise PublishValidationError( "%s is missing sets: %s" % (instance, ", ".join(missing)) ) @classmethod def invalid_hierarchy(cls, instance, content): """ Check if all rig set members are within the hierarchy of the rig root Args: instance (str): instance content (list): list of content from rig sets Raises: PublishValidationError: It means no dag nodes in the rig instance Returns: list: invalid hierarchy """ # Ensure there are at least some transforms or dag nodes # in the rig instance set_members = instance.data['setMembers'] if not cmds.ls(set_members, type="dagNode", long=True): raise PublishValidationError( "No dag nodes in the rig instance. " "(Empty instance?)" ) # Validate members are inside the hierarchy from root node root_nodes = cmds.ls(set_members, assemblies=True, long=True) hierarchy = cmds.listRelatives(root_nodes, allDescendents=True, fullPath=True) + root_nodes hierarchy = set(hierarchy) invalid_hierarchy = [] for node in content: if node not in hierarchy: invalid_hierarchy.append(node) return invalid_hierarchy @classmethod def validate_geometry(cls, set_members): """ Checks if the node types of the set members valid Args: set_members: list of nodes of the controls_set hierarchy: list of nodes which reside under the root node Returns: errors (list) """ # Validate all shape types invalid = [] shapes = cmds.listRelatives(set_members, allDescendents=True, shapes=True, fullPath=True) or [] all_shapes = cmds.ls(set_members + shapes, long=True, shapes=True) for shape in all_shapes: if cmds.nodeType(shape) not in cls.accepted_output: invalid.append(shape) @classmethod def validate_controls(cls, set_members): """ Checks if the control set members are allowed node types. Checks if the node types of the set members valid Args: set_members: list of nodes of the controls_set hierarchy: list of nodes which reside under the root node Returns: errors (list) """ # Validate control types invalid = [] for node in set_members: if cmds.nodeType(node) not in cls.accepted_controllers: invalid.append(node) return invalid @classmethod def get_nodes(cls, instance): """Get the target objectsets and rig sets nodes Args: instance (str): instance Returns: tuple: 2-tuple of list of objectsets, list of rig sets nodes """ objectsets = ["controls_SET", "out_SET"] rig_sets_nodes = instance.data.get("rig_sets", []) return objectsets, rig_sets_nodes class ValidateSkeletonRigContents(ValidateRigContents): """Ensure skeleton rigs contains pipeline-critical content The rigs optionally contain at least two object sets: "skeletonMesh_SET" - Set of the skinned meshes with bone hierarchies """ order = ValidateContentsOrder label = "Skeleton Rig Contents" hosts = ["maya"] families = ["rig.fbx"] optional = True @classmethod def get_invalid(cls, instance): objectsets, skeleton_mesh_nodes = cls.get_nodes(instance) cls.validate_missing_objectsets( instance, objectsets, instance.data["rig_sets"]) # Ensure contents in sets and retrieve long path for all objects output_content = instance.data.get("skeleton_mesh", []) output_content = cmds.ls(skeleton_mesh_nodes, long=True) invalid_hierarchy = cls.invalid_hierarchy( instance, output_content) invalid_geometry = cls.validate_geometry(output_content) error = False if invalid_hierarchy: cls.log.error("Found nodes which reside outside of root group " "while they are set up for publishing." "\n%s" % invalid_hierarchy) error = True if invalid_geometry: cls.log.error("Found nodes which reside outside of root group " "while they are set up for publishing." "\n%s" % invalid_hierarchy) error = True if error: return invalid_hierarchy + invalid_geometry @classmethod def get_nodes(cls, instance): """Get the target objectsets and rig sets nodes Args: instance (str): instance Returns: tuple: 2-tuple of list of objectsets, list of rig sets nodes """ objectsets = ["skeletonMesh_SET"] skeleton_mesh_nodes = instance.data.get("skeleton_mesh", []) return objectsets, skeleton_mesh_nodes ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_rig_controllers.py ================================================ from maya import cmds import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, RepairAction, PublishValidationError, OptionalPyblishPluginMixin ) import openpype.hosts.maya.api.action from openpype.hosts.maya.api.lib import undo_chunk class ValidateRigControllers(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate rig controllers. Controls must have the transformation attributes on their default values of translate zero, rotate zero and scale one when they are unlocked attributes. Unlocked keyable attributes may not have any incoming connections. If these connections are required for the rig then lock the attributes. The visibility attribute must be locked. Note that `repair` will: - Lock all visibility attributes - Reset all default values for translate, rotate, scale - Break all incoming connections to keyable attributes """ order = ValidateContentsOrder + 0.05 label = "Rig Controllers" hosts = ["maya"] families = ["rig"] optional = True actions = [RepairAction, openpype.hosts.maya.api.action.SelectInvalidAction] # Default controller values CONTROLLER_DEFAULTS = { "translateX": 0, "translateY": 0, "translateZ": 0, "rotateX": 0, "rotateY": 0, "rotateZ": 0, "scaleX": 1, "scaleY": 1, "scaleZ": 1 } def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( '{} failed, see log information'.format(self.label) ) @classmethod def get_invalid(cls, instance): controls_set = cls.get_node(instance) if not controls_set: cls.log.error( "Must have 'controls_SET' in rig instance" ) return [instance.data["instance_node"]] controls = cmds.sets(controls_set, query=True) # Ensure all controls are within the top group lookup = set(instance[:]) if not all(control in lookup for control in cmds.ls(controls, long=True)): cls.log.error( "All controls must be inside the rig's group." ) return [controls_set] # Validate all controls has_connections = list() has_unlocked_visibility = list() has_non_default_values = list() for control in controls: if cls.get_connected_attributes(control): has_connections.append(control) # check if visibility is locked attribute = "{}.visibility".format(control) locked = cmds.getAttr(attribute, lock=True) if not locked: has_unlocked_visibility.append(control) if cls.get_non_default_attributes(control): has_non_default_values.append(control) if has_connections: cls.log.error("Controls have input connections: " "%s" % has_connections) if has_non_default_values: cls.log.error("Controls have non-default values: " "%s" % has_non_default_values) if has_unlocked_visibility: cls.log.error("Controls have unlocked visibility " "attribute: %s" % has_unlocked_visibility) invalid = [] if (has_connections or has_unlocked_visibility or has_non_default_values): invalid = set() invalid.update(has_connections) invalid.update(has_non_default_values) invalid.update(has_unlocked_visibility) invalid = list(invalid) cls.log.error("Invalid rig controllers. See log for details.") return invalid @classmethod def get_non_default_attributes(cls, control): """Return attribute plugs with non-default values Args: control (str): Name of control node. Returns: list: The invalid plugs """ invalid = [] for attr, default in cls.CONTROLLER_DEFAULTS.items(): if cmds.attributeQuery(attr, node=control, exists=True): plug = "{}.{}".format(control, attr) # Ignore locked attributes locked = cmds.getAttr(plug, lock=True) if locked: continue value = cmds.getAttr(plug) if value != default: cls.log.warning("Control non-default value: " "%s = %s" % (plug, value)) invalid.append(plug) return invalid @staticmethod def get_connected_attributes(control): """Return attribute plugs with incoming connections. This will also ensure no (driven) keys on unlocked keyable attributes. Args: control (str): Name of control node. Returns: list: The invalid plugs """ import maya.cmds as mc # Support controls without any attributes returning None attributes = mc.listAttr(control, keyable=True, scalar=True) or [] invalid = [] for attr in attributes: plug = "{}.{}".format(control, attr) # Ignore locked attributes locked = cmds.getAttr(plug, lock=True) if locked: continue # Ignore proxy connections. if (cmds.addAttr(plug, query=True, exists=True) and cmds.addAttr(plug, query=True, usedAsProxy=True)): continue # Check for incoming connections if cmds.listConnections(plug, source=True, destination=False): invalid.append(plug) return invalid @classmethod def repair(cls, instance): controls_set = cls.get_node(instance) if not controls_set: cls.log.error( "Unable to repair because no 'controls_SET' found in rig " "instance: {}".format(instance) ) return # Use a single undo chunk with undo_chunk(): controls = cmds.sets(controls_set, query=True) for control in controls: # Lock visibility attr = "{}.visibility".format(control) locked = cmds.getAttr(attr, lock=True) if not locked: cls.log.info("Locking visibility for %s" % control) cmds.setAttr(attr, lock=True) # Remove incoming connections invalid_plugs = cls.get_connected_attributes(control) if invalid_plugs: for plug in invalid_plugs: cls.log.info("Breaking input connection to %s" % plug) source = cmds.listConnections(plug, source=True, destination=False, plugs=True)[0] cmds.disconnectAttr(source, plug) # Reset non-default values invalid_plugs = cls.get_non_default_attributes(control) if invalid_plugs: for plug in invalid_plugs: attr = plug.split(".")[-1] default = cls.CONTROLLER_DEFAULTS[attr] cls.log.info("Setting %s to %s" % (plug, default)) cmds.setAttr(plug, default) @classmethod def get_node(cls, instance): """Get target object nodes from controls_SET Args: instance (str): instance Returns: list: list of object nodes from controls_SET """ return instance.data["rig_sets"].get("controls_SET") class ValidateSkeletonRigControllers(ValidateRigControllers): """Validate rig controller for skeletonAnim_SET Controls must have the transformation attributes on their default values of translate zero, rotate zero and scale one when they are unlocked attributes. Unlocked keyable attributes may not have any incoming connections. If these connections are required for the rig then lock the attributes. The visibility attribute must be locked. Note that `repair` will: - Lock all visibility attributes - Reset all default values for translate, rotate, scale - Break all incoming connections to keyable attributes """ order = ValidateContentsOrder + 0.05 label = "Skeleton Rig Controllers" hosts = ["maya"] families = ["rig.fbx"] # Default controller values CONTROLLER_DEFAULTS = { "translateX": 0, "translateY": 0, "translateZ": 0, "rotateX": 0, "rotateY": 0, "rotateZ": 0, "scaleX": 1, "scaleY": 1, "scaleZ": 1 } @classmethod def get_node(cls, instance): """Get target object nodes from skeletonMesh_SET Args: instance (str): instance Returns: list: list of object nodes from skeletonMesh_SET """ return instance.data["rig_sets"].get("skeletonMesh_SET") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py ================================================ from maya import cmds import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, RepairAction, PublishValidationError, OptionalPyblishPluginMixin ) from openpype.hosts.maya.api import lib import openpype.hosts.maya.api.action class ValidateRigControllersArnoldAttributes(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate rig control curves have no keyable arnold attributes. The Arnold plug-in will create curve attributes like: - aiRenderCurve - aiCurveWidth - aiSampleRate - aiCurveShaderR - aiCurveShaderG - aiCurveShaderB Unfortunately these attributes visible in the channelBox are *keyable* by default and visible in the channelBox. As such pressing a regular "S" set key shortcut will set keys on these attributes too, thus cluttering the animator's scene. This validator will ensure they are hidden or unkeyable attributes. """ order = ValidateContentsOrder + 0.05 label = "Rig Controllers (Arnold Attributes)" hosts = ["maya"] families = ["rig"] optional = False actions = [RepairAction, openpype.hosts.maya.api.action.SelectInvalidAction] attributes = [ "rcurve", "cwdth", "srate", "ai_curve_shaderr", "ai_curve_shaderg", "ai_curve_shaderb" ] def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError('{} failed, see log ' 'information'.format(self.label)) @classmethod def get_invalid(cls, instance): controls_set = instance.data["rig_sets"].get("controls_SET") if not controls_set: return [] controls = cmds.sets(controls_set, query=True) or [] if not controls: return [] shapes = cmds.ls(controls, dag=True, leaf=True, long=True, shapes=True, noIntermediate=True) curves = cmds.ls(shapes, type="nurbsCurve", long=True) invalid = list() for node in curves: for attribute in cls.attributes: if cmds.attributeQuery(attribute, node=node, exists=True): plug = "{}.{}".format(node, attribute) if cmds.getAttr(plug, keyable=True): invalid.append(node) break return invalid @classmethod def repair(cls, instance): invalid = cls.get_invalid(instance) with lib.undo_chunk(): for node in invalid: for attribute in cls.attributes: if cmds.attributeQuery(attribute, node=node, exists=True): plug = "{}.{}".format(node, attribute) cmds.setAttr(plug, channelBox=False, keyable=False) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_rig_joints_hidden.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateRigJointsHidden(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate all joints are hidden visually. This includes being hidden: - visibility off, - in a display layer that has visibility off, - having hidden parents or - being an intermediate object. """ order = ValidateContentsOrder hosts = ['maya'] families = ['rig'] label = "Joints Hidden" actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] optional = True @staticmethod def get_invalid(instance): joints = cmds.ls(instance, type='joint', long=True) return [j for j in joints if lib.is_visible(j, displayLayer=True)] def process(self, instance): """Process all the nodes in the instance 'objectSet'""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Visible joints found: {0}".format(invalid)) @classmethod def repair(cls, instance): import maya.mel as mel mel.eval("HideJoints") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py ================================================ import maya.cmds as cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate if deformed shapes have related IDs to the original shapes. When a deformer is applied in the scene on a referenced mesh that already had deformers then Maya will create a new shape node for the mesh that does not have the original id. This validator checks whether the ids are valid on all the shape nodes in the instance. """ order = ValidateContentsOrder families = ["rig"] hosts = ['maya'] label = 'Rig Out Set Node Ids' actions = [ openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction ] allow_history_only = False optional = False def process(self, instance): """Process all meshes""" if not self.is_active(instance.data): return # Ensure all nodes have a cbId and a related ID to the original shapes # if a deformer has been created on the shape invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Nodes found with mismatching IDs: {0}".format(invalid) ) @classmethod def get_invalid(cls, instance): """Get all nodes which do not match the criteria""" out_set = cls.get_node(instance) if not out_set: return [] invalid = [] members = cmds.sets(out_set, query=True) shapes = cmds.ls(members, dag=True, leaf=True, shapes=True, long=True, noIntermediate=True) for shape in shapes: sibling_id = lib.get_id_from_sibling( shape, history_only=cls.allow_history_only ) if sibling_id: current_id = lib.get_id(shape) if current_id != sibling_id: invalid.append(shape) return invalid @classmethod def repair(cls, instance): for node in cls.get_invalid(instance): # Get the original id from sibling sibling_id = lib.get_id_from_sibling( node, history_only=cls.allow_history_only ) if not sibling_id: cls.log.error("Could not find ID in siblings for '%s'", node) continue lib.set_id(node, sibling_id, overwrite=True) @classmethod def get_node(cls, instance): """Get target object nodes from out_SET Args: instance (str): instance Returns: list: list of object nodes from out_SET """ return instance.data["rig_sets"].get("out_SET") class ValidateSkeletonRigOutSetNodeIds(ValidateRigOutSetNodeIds): """Validate if deformed shapes have related IDs to the original shapes from skeleton set. When a deformer is applied in the scene on a referenced mesh that already had deformers then Maya will create a new shape node for the mesh that does not have the original id. This validator checks whether the ids are valid on all the shape nodes in the instance. """ order = ValidateContentsOrder families = ["rig.fbx"] hosts = ['maya'] label = 'Skeleton Rig Out Set Node Ids' optional = False @classmethod def get_node(cls, instance): """Get target object nodes from skeletonMesh_SET Args: instance (str): instance Returns: list: list of object nodes from skeletonMesh_SET """ return instance.data["rig_sets"].get( "skeletonMesh_SET") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py ================================================ from collections import defaultdict from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api.lib import get_id, set_id from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError ) def get_basename(node): """Return node short name without namespace""" return node.rsplit("|", 1)[-1].rsplit(":", 1)[-1] class ValidateRigOutputIds(pyblish.api.InstancePlugin): """Validate rig output ids. Ids must share the same id as similarly named nodes in the scene. This is to ensure the id from the model is preserved through animation. """ order = ValidateContentsOrder + 0.05 label = "Rig Output Ids" hosts = ["maya"] families = ["rig"] actions = [RepairAction, openpype.hosts.maya.api.action.SelectInvalidAction] def process(self, instance): invalid = self.get_invalid(instance, compute=True) if invalid: raise PublishValidationError("Found nodes with mismatched IDs.") @classmethod def get_invalid(cls, instance, compute=False): invalid_matches = cls.get_invalid_matches(instance, compute=compute) return list(invalid_matches.keys()) @classmethod def get_invalid_matches(cls, instance, compute=False): invalid = {} if compute: out_set = cls.get_node(instance) if not out_set: instance.data["mismatched_output_ids"] = invalid return invalid instance_nodes = cmds.sets(out_set, query=True, nodesOnly=True) instance_nodes = cmds.ls(instance_nodes, long=True) for node in instance_nodes: shapes = cmds.listRelatives(node, shapes=True, fullPath=True) if shapes: instance_nodes.extend(shapes) scene_nodes = cmds.ls(type="transform", long=True) scene_nodes += cmds.ls(type="mesh", long=True) scene_nodes = set(scene_nodes) - set(instance_nodes) scene_nodes_by_basename = defaultdict(list) for node in scene_nodes: basename = get_basename(node) scene_nodes_by_basename[basename].append(node) for instance_node in instance_nodes: basename = get_basename(instance_node) if basename not in scene_nodes_by_basename: continue matches = scene_nodes_by_basename[basename] ids = set(get_id(node) for node in matches) ids.add(get_id(instance_node)) if len(ids) > 1: cls.log.error( "\"{}\" id mismatch to: {}".format( instance_node, matches ) ) invalid[instance_node] = matches instance.data["mismatched_output_ids"] = invalid else: invalid = instance.data["mismatched_output_ids"] return invalid @classmethod def repair(cls, instance): invalid_matches = cls.get_invalid_matches(instance) multiple_ids_match = [] for instance_node, matches in invalid_matches.items(): ids = set(get_id(node) for node in matches) # If there are multiple scene ids matched, and error needs to be # raised for manual correction. if len(ids) > 1: multiple_ids_match.append({"node": instance_node, "matches": matches}) continue id_to_set = next(iter(ids)) set_id(instance_node, id_to_set, overwrite=True) if multiple_ids_match: raise PublishValidationError( "Multiple matched ids found. Please repair manually: " "{}".format(multiple_ids_match) ) @classmethod def get_node(cls, instance): """Get target object nodes from out_SET Args: instance (str): instance Returns: list: list of object nodes from out_SET """ return instance.data["rig_sets"].get("out_SET") class ValidateSkeletonRigOutputIds(ValidateRigOutputIds): """Validate rig output ids from the skeleton sets. Ids must share the same id as similarly named nodes in the scene. This is to ensure the id from the model is preserved through animation. """ order = ValidateContentsOrder + 0.05 label = "Skeleton Rig Output Ids" hosts = ["maya"] families = ["rig.fbx"] @classmethod def get_node(cls, instance): """Get target object nodes from skeletonMesh_SET Args: instance (str): instance Returns: list: list of object nodes from skeletonMesh_SET """ return instance.data["rig_sets"].get("skeletonMesh_SET") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_scene_set_workspace.py ================================================ import os import maya.cmds as cmds import pyblish.api from openpype.pipeline.publish import ( PublishValidationError, ValidatePipelineOrder) def is_subdir(path, root_dir): """ Returns whether path is a subdirectory (or file) within root_dir """ path = os.path.realpath(path) root_dir = os.path.realpath(root_dir) # If not on same drive if os.path.splitdrive(path)[0].lower() != os.path.splitdrive(root_dir)[0].lower(): # noqa: E501 return False # Get 'relative path' (can contain ../ which means going up) relative = os.path.relpath(path, root_dir) # Check if the path starts by going up, if so it's not a subdirectory. :) if relative.startswith(os.pardir) or relative == os.curdir: return False else: return True class ValidateSceneSetWorkspace(pyblish.api.ContextPlugin): """Validate the scene is inside the currently set Maya workspace""" order = ValidatePipelineOrder hosts = ['maya'] label = 'Maya Workspace Set' def process(self, context): scene_name = cmds.file(query=True, sceneName=True) if not scene_name: raise PublishValidationError( "Scene hasn't been saved. Workspace can't be validated.") root_dir = cmds.workspace(query=True, rootDirectory=True) if not is_subdir(scene_name, root_dir): raise PublishValidationError( "Maya workspace is not set correctly.\n\n" f"Current workfile `{scene_name}` is not inside the " "current Maya project root directory `{root_dir}`.\n\n" "Please use Workfile app to re-save." ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_setdress_root.py ================================================ import pyblish.api from openpype.pipeline.publish import ValidateContentsOrder class ValidateSetdressRoot(pyblish.api.InstancePlugin): """Validate if set dress top root node is published.""" order = ValidateContentsOrder label = "SetDress Root" hosts = ["maya"] families = ["setdress"] def process(self, instance): from maya import cmds if instance.data.get("exactSetMembersOnly"): return set_member = instance.data["setMembers"] root = cmds.ls(set_member, assemblies=True, long=True) if not root or root[0] not in set_member: raise Exception("Setdress top root node is not being published.") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_shader_name.py ================================================ import re import pyblish.api from maya import cmds import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( OptionalPyblishPluginMixin, PublishValidationError, ValidateContentsOrder) class ValidateShaderName(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate shader name assigned. It should be _<*>_SHD """ optional = True order = ValidateContentsOrder families = ["look"] hosts = ['maya'] label = 'Validate Shaders Name' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] regex = r'(?P.*)_(.*)_SHD' # The default connections to check def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( ("Found shapes with invalid shader names " "assigned:\n{}").format(invalid)) @classmethod def get_invalid(cls, instance): invalid = [] # Get all shapes from the instance content_instance = instance.data.get("setMembers", None) if not content_instance: cls.log.error("Instance has no nodes!") return True pass descendants = cmds.listRelatives(content_instance, allDescendents=True, fullPath=True) or [] descendants = cmds.ls(descendants, noIntermediate=True, long=True) shapes = cmds.ls(descendants, type=["nurbsSurface", "mesh"], long=True) asset_name = instance.data.get("asset") # Check the number of connected shadingEngines per shape regex_compile = re.compile(cls.regex) error_message = "object {0} has invalid shader name {1}" for shape in shapes: shading_engines = cmds.listConnections(shape, destination=True, type="shadingEngine") or [] shaders = cmds.ls( cmds.listConnections(shading_engines), materials=1 ) for shader in shaders: m = regex_compile.match(shader) if m is None: invalid.append(shape) cls.log.error(error_message.format(shape, shader)) else: if 'asset' in regex_compile.groupindex: if m.group('asset') != asset_name: invalid.append(shape) message = error_message message += " with missing asset name \"{2}\"" cls.log.error( message.format(shape, shader, asset_name) ) return invalid ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_shape_default_names.py ================================================ import re from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, RepairAction, OptionalPyblishPluginMixin ) def short_name(node): return node.rsplit("|", 1)[-1].rsplit(":", 1)[-1] class ValidateShapeDefaultNames(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates that Shape names are using Maya's default format. When you create a new polygon cube Maya will name the transform and shape respectively: - ['pCube1', 'pCubeShape1'] If you rename it to `bar1` it will become: - ['bar1', 'barShape1'] Then if you rename it to `bar` it will become: - ['bar', 'barShape'] Rename it again to `bar1` it will differ as opposed to before: - ['bar1', 'bar1Shape'] Note that bar1Shape != barShape1 Thus the suffix number can be either in front of Shape or behind it. Then it becomes harder to define where what number should be when a node contains multiple shapes, for example with many controls in rigs existing of multiple curves. """ order = ValidateContentsOrder hosts = ['maya'] families = ['model'] optional = True label = "Shape Default Naming" actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] @staticmethod def _define_default_name(shape): parent = cmds.listRelatives(shape, parent=True, fullPath=True)[0] transform = short_name(parent) return '{0}Shape'.format(transform) @staticmethod def _is_valid(shape): """ Return whether the shape's name is similar to Maya's default. """ transform = cmds.listRelatives(shape, parent=True, fullPath=True)[0] transform_name = short_name(transform) shape_name = short_name(shape) # A Shape's name can be either {transform}{numSuffix} # Shape or {transform}Shape{numSuffix} # Upon renaming nodes in Maya that is # the pattern Maya will act towards. transform_no_num = transform_name.rstrip("0123456789") pattern = '^{transform}[0-9]*Shape[0-9]*$'.format( transform=transform_no_num) if re.match(pattern, shape_name): return True else: return False @classmethod def get_invalid(cls, instance): shapes = cmds.ls(instance, shapes=True, long=True) return [shape for shape in shapes if not cls._is_valid(shape)] def process(self, instance): """Process all the shape nodes in the instance""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise ValueError("Incorrectly named shapes " "found: {0}".format(invalid)) @classmethod def repair(cls, instance): """Process all the shape nodes in the instance""" for shape in cls.get_invalid(instance): correct_shape_name = cls._define_default_name(shape) cmds.rename(shape, correct_shape_name) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py ================================================ import pyblish.api from maya import cmds import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, ValidateMeshOrder, OptionalPyblishPluginMixin ) class ValidateShapeRenderStats(pyblish.api.Validator, OptionalPyblishPluginMixin): """Ensure all render stats are set to the default values.""" order = ValidateMeshOrder hosts = ['maya'] families = ['model'] label = 'Shape Default Render Stats' actions = [openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction] optional = True defaults = {'castsShadows': 1, 'receiveShadows': 1, 'motionBlur': 1, 'primaryVisibility': 1, 'smoothShading': 1, 'visibleInReflections': 1, 'visibleInRefractions': 1, 'doubleSided': 1, 'opposite': 0} @classmethod def get_invalid(cls, instance): # It seems the "surfaceShape" and those derived from it have # `renderStat` attributes. shapes = cmds.ls(instance, long=True, type='surfaceShape') invalid = [] for shape in shapes: _iteritems = getattr(cls.defaults, "iteritems", cls.defaults.items) for attr, default_value in _iteritems(): if cmds.attributeQuery(attr, node=shape, exists=True): value = cmds.getAttr('{}.{}'.format(shape, attr)) if value != default_value: invalid.append(shape) return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise ValueError("Shapes with non-default renderStats " "found: {0}".format(invalid)) @classmethod def repair(cls, instance): for shape in cls.get_invalid(instance): _iteritems = getattr(cls.defaults, "iteritems", cls.defaults.items) for attr, default_value in _iteritems(): if cmds.attributeQuery(attr, node=shape, exists=True): plug = '{0}.{1}'.format(shape, attr) value = cmds.getAttr(plug) if value != default_value: cmds.setAttr(plug, default_value) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_shape_zero.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( ValidateContentsOrder, RepairAction, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateShapeZero(pyblish.api.Validator, OptionalPyblishPluginMixin): """Shape components may not have any "tweak" values To solve this issue, try freezing the shapes. """ order = ValidateContentsOrder hosts = ["maya"] families = ["model"] label = "Shape Zero (Freeze)" actions = [ openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction ] optional = True @staticmethod def get_invalid(instance): """Returns the invalid shapes in the instance. This is the same as checking: - all(pnt == [0,0,0] for pnt in shape.pnts[:]) Returns: list: Shape with non freezed vertex """ shapes = cmds.ls(instance, type="shape") invalid = [] for shape in shapes: if cmds.polyCollapseTweaks(shape, q=True, hasVertexTweaks=True): invalid.append(shape) return invalid @classmethod def repair(cls, instance): invalid_shapes = cls.get_invalid(instance) if not invalid_shapes: return with lib.maintained_selection(): with lib.tool("selectSuperContext"): for shape in invalid_shapes: cmds.polyCollapseTweaks(shape) # cmds.polyCollapseTweaks keeps selecting the geometry # after each command. When running on many meshes # after one another this tends to get really heavy cmds.select(clear=True) def process(self, instance): """Process all the nodes in the instance "objectSet""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( title="Shape Component Tweaks", message="Shapes found with non-zero component tweaks: '{}'" "".format(", ".join(invalid)), description=( "## Shapes found with component tweaks\n" "Shapes were detected that have component tweaks on their " "components. Please remove the component tweaks to " "continue.\n\n" "### Repair\n" "The repair action will try to *freeze* the component " "tweaks into the shapes, which is usually the correct fix " "if the mesh has no construction history (= has its " "history deleted)."), detail=( "Maya allows to store component tweaks within shape nodes " "which are applied between its `inMesh` and `outMesh` " "connections resulting in the output of a shape node " "differing from the input. We usually want to avoid this " "for published meshes (in particular for Maya scenes) as " "it can have unintended results when using these meshes " "as intermediate meshes since it applies positional " "differences without being visible edits in the node " "graph.\n\n" "These tweaks are traditionally stored in the `.pnts` " "attribute of shapes.") ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_single_assembly.py ================================================ import pyblish.api from openpype.pipeline.publish import ValidateContentsOrder class ValidateSingleAssembly(pyblish.api.InstancePlugin): """Ensure the content of the instance is grouped in a single hierarchy The instance must have a single root node containing all the content. This root node *must* be a top group in the outliner. Example outliner: root_GRP -- geometry_GRP -- mesh_GEO -- controls_GRP -- control_CTL """ order = ValidateContentsOrder hosts = ['maya'] families = ['rig'] label = 'Single Assembly' def process(self, instance): from maya import cmds assemblies = cmds.ls(instance, assemblies=True) # ensure unique (somehow `maya.cmds.ls` doesn't manage that) assemblies = set(assemblies) assert len(assemblies) > 0, ( "One assembly required for: %s (currently empty?)" % instance) assert len(assemblies) < 2, ( 'Multiple assemblies found: %s' % assemblies) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, OptionalPyblishPluginMixin ) from maya import cmds class ValidateSkeletalMeshHierarchy(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates that nodes has common root.""" order = ValidateContentsOrder hosts = ["maya"] families = ["skeletalMesh"] label = "Skeletal Mesh Top Node" optional = False def process(self, instance): if not self.is_active(instance.data): return geo = instance.data.get("geometry") joints = instance.data.get("joints") joints_parents = cmds.ls(joints, long=True) geo_parents = cmds.ls(geo, long=True) parents_set = { parent.split("|")[1] for parent in (joints_parents + geo_parents) } self.log.debug(parents_set) if len(set(parents_set)) > 2: raise PublishXmlValidationError( self, "Multiple roots on geometry or joints." ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_skeletalmesh_triangulated.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.hosts.maya.api.action import ( SelectInvalidAction, ) from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishValidationError ) from maya import cmds class ValidateSkeletalMeshTriangulated(pyblish.api.InstancePlugin): """Validates that the geometry has been triangulated.""" order = ValidateContentsOrder hosts = ["maya"] families = ["skeletalMesh"] label = "Skeletal Mesh Triangulated" optional = True actions = [ SelectInvalidAction, RepairAction ] def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "The following objects needs to be triangulated: " "{}".format(invalid)) @classmethod def get_invalid(cls, instance): geo = instance.data.get("geometry") invalid = [] for obj in cmds.listRelatives( cmds.ls(geo), allDescendents=True, fullPath=True): n_triangles = cmds.polyEvaluate(obj, triangle=True) n_faces = cmds.polyEvaluate(obj, face=True) if not (isinstance(n_triangles, int) and isinstance(n_faces, int)): continue # We check if the number of triangles is equal to the number of # faces for each transform node. # If it is, the object is triangulated. if cmds.objectType(obj, i="transform") and n_triangles != n_faces: invalid.append(obj) return invalid @classmethod def repair(cls, instance): for node in cls.get_invalid(instance): cmds.polyTriangulate(node) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py ================================================ # -*- coding: utf-8 -*- """Plugin for validating naming conventions.""" from maya import cmds import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin, PublishValidationError ) class ValidateSkeletonTopGroupHierarchy(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates top group hierarchy in the SETs Make sure the object inside the SETs are always top group of the hierarchy """ order = ValidateContentsOrder + 0.05 label = "Skeleton Rig Top Group Hierarchy" families = ["rig.fbx"] def process(self, instance): invalid = [] skeleton_mesh_data = instance.data("skeleton_mesh", []) if skeleton_mesh_data: invalid = self.get_top_hierarchy(skeleton_mesh_data) if invalid: raise PublishValidationError( "The skeletonMesh_SET includes the object which " "is not at the top hierarchy: {}".format(invalid)) def get_top_hierarchy(self, targets): targets = cmds.ls(targets, long=True) # ensure long names non_top_hierarchy_list = [ target for target in targets if target.count("|") > 2 ] return non_top_hierarchy_list ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_skinCluster_deformer_set.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin ) class ValidateSkinclusterDeformerSet(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate skinClusters on meshes have valid member relationships. In rare cases it can happen that a mesh has a skinCluster in its history but it is *not* included in the deformer relationship history. If this is the case then FBX will not export the skinning. """ order = ValidateContentsOrder hosts = ['maya'] families = ['fbx'] label = "Skincluster Deformer Relationships" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False def process(self, instance): """Process all the transform nodes in the instance""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise ValueError("Invalid skinCluster relationships " "found on meshes: {0}".format(invalid)) @classmethod def get_invalid(cls, instance): meshes = cmds.ls(instance, type="mesh", noIntermediate=True, long=True) invalid = list() for mesh in meshes: history = cmds.listHistory(mesh) or [] skins = cmds.ls(history, type="skinCluster") # Ensure at most one skinCluster assert len(skins) <= 1, "Cannot have more than one skinCluster" if skins: skin = skins[0] # Ensure the mesh is also in the skinCluster set # otherwise the skin will not be exported correctly # by the FBX Exporter. deformer_sets = cmds.listSets(object=mesh, type=2) for deformer_set in deformer_sets: used_by = cmds.listConnections(deformer_set + ".usedBy", source=True, destination=False) # Ignore those that don't seem to have a usedBy connection if not used_by: continue # We have a matching deformer set relationship if skin in set(used_by): break else: invalid.append(mesh) cls.log.warning( "Mesh has skinCluster in history but is not included " "in its deformer relationship set: " "{0} (skinCluster: {1})".format(mesh, skin) ) return invalid ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_step_size.py ================================================ import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder, OptionalPyblishPluginMixin ) class ValidateStepSize(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates the step size for the instance is in a valid range. For example the `step` size should never be lower or equal to zero. """ order = ValidateContentsOrder label = 'Step size' families = ['camera', 'pointcache', 'animation'] actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False MIN = 0.01 MAX = 1.0 @classmethod def get_invalid(cls, instance): objset = instance.data['name'] step = instance.data.get("step", 1.0) if step < cls.MIN or step > cls.MAX: cls.log.warning("Step size is outside of valid range: {0} " "(valid: {1} to {2})".format(step, cls.MIN, cls.MAX)) return objset return [] def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Invalid instances found: {0}".format(invalid)) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_transform_naming_suffix.py ================================================ # -*- coding: utf-8 -*- """Plugin for validating naming conventions.""" from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin, PublishValidationError ) class ValidateTransformNamingSuffix(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates transform suffix based on the type of its children shapes. Suffices must be: - mesh: _GEO (regular geometry) _GES (geometry to be smoothed at render) _GEP (proxy geometry; usually not to be rendered) _OSD (open subdiv smooth at rendertime) - nurbsCurve: _CRV - nurbsSurface: _NRB - locator: _LOC - null/group: _GRP Suffices can also be overridden by project settings. .. warning:: This grabs the first child shape as a reference and doesn't use the others in the check. """ order = ValidateContentsOrder hosts = ['maya'] families = ['model'] optional = True label = 'Suffix Naming Conventions' actions = [openpype.hosts.maya.api.action.SelectInvalidAction] SUFFIX_NAMING_TABLE = {"mesh": ["_GEO", "_GES", "_GEP", "_OSD"], "nurbsCurve": ["_CRV"], "nurbsSurface": ["_NRB"], "locator": ["_LOC"], "group": ["_GRP"]} ALLOW_IF_NOT_IN_SUFFIX_TABLE = True @classmethod def get_table_for_invalid(cls): ss = [] for k, v in cls.SUFFIX_NAMING_TABLE.items(): ss.append(" - {}: {}".format(k, ", ".join(v))) return "
".join(ss) @staticmethod def is_valid_name(node_name, shape_type, SUFFIX_NAMING_TABLE, ALLOW_IF_NOT_IN_SUFFIX_TABLE): """Return whether node's name is correct. The correctness for a transform's suffix is dependent on what `shape_type` it holds. E.g. a transform with a mesh might need and `_GEO` suffix. When `shape_type` is None the transform doesn't have any direct children shapes. Args: node_name (str): Node name. shape_type (str): Type of node. SUFFIX_NAMING_TABLE (dict): Mapping dict for suffixes. ALLOW_IF_NOT_IN_SUFFIX_TABLE (dict): Filter dict. """ if shape_type not in SUFFIX_NAMING_TABLE: return ALLOW_IF_NOT_IN_SUFFIX_TABLE else: suffices = SUFFIX_NAMING_TABLE[shape_type] for suffix in suffices: if node_name.endswith(suffix): return True return False @classmethod def get_invalid(cls, instance): """Get invalid nodes in instance. Args: instance (:class:`pyblish.api.Instance`): published instance. """ transforms = cmds.ls(instance, type='transform', long=True) invalid = [] for transform in transforms: shapes = cmds.listRelatives(transform, shapes=True, fullPath=True, noIntermediate=True) shape_type = cmds.nodeType(shapes[0]) if shapes else "group" if not cls.is_valid_name(transform, shape_type, cls.SUFFIX_NAMING_TABLE, cls.ALLOW_IF_NOT_IN_SUFFIX_TABLE): invalid.append(transform) return invalid def process(self, instance): """Process all the nodes in the instance. Args: instance (:class:`pyblish.api.Instance`): published instance. """ if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: valid = self.get_table_for_invalid() names = "
".join( " - {}".format(node) for node in invalid ) valid = valid.replace("\n", "
") raise PublishValidationError( title="Invalid naming suffix", message="Valid suffixes are:
{0}

" "Incorrectly named geometry transforms:
{1}" "".format(valid, names)) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_transform_zero.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateTransformZero(pyblish.api.Validator, OptionalPyblishPluginMixin): """Transforms can't have any values To solve this issue, try freezing the transforms. So long as the transforms, rotation and scale values are zero, you're all good. """ order = ValidateContentsOrder hosts = ["maya"] families = ["model"] label = "Transform Zero (Freeze)" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] _identity = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0] _tolerance = 1e-30 optional = True @classmethod def get_invalid(cls, instance): """Returns the invalid transforms in the instance. This is the same as checking: - translate == [0, 0, 0] and rotate == [0, 0, 0] and scale == [1, 1, 1] and shear == [0, 0, 0] .. note:: This will also catch camera transforms if those are in the instances. Returns: list: Transforms that are not identity matrix """ transforms = cmds.ls(instance, type="transform") invalid = [] for transform in transforms: if ('_LOC' in transform) or ('_loc' in transform): continue mat = cmds.xform(transform, q=1, matrix=True, objectSpace=True) if not all(abs(x-y) < cls._tolerance for x, y in zip(cls._identity, mat)): invalid.append(transform) return invalid def process(self, instance): """Process all the nodes in the instance "objectSet""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: names = "
".join( " - {}".format(node) for node in invalid ) raise PublishValidationError( title="Transform Zero", message="The model publish allows no transformations. You must" " freeze transformations to continue.

" "Nodes found with transform values: " "{0}".format(names)) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_unique_names.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin ) class ValidateUniqueNames(pyblish.api.Validator, OptionalPyblishPluginMixin): """transform names should be unique ie: using cmds.ls(someNodeName) should always return shortname """ order = ValidateContentsOrder hosts = ["maya"] families = ["model"] label = "Unique transform name" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = True @staticmethod def get_invalid(instance): """Returns the invalid transforms in the instance. Returns: list: Non-unique name transforms. """ return [tr for tr in cmds.ls(instance, type="transform") if '|' in tr] def process(self, instance): """Process all the nodes in the instance "objectSet""" if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise ValueError("Nodes found with none unique names. " "values: {0}".format(invalid)) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py ================================================ # -*- coding: utf-8 -*- from maya import cmds import pyblish.api from openpype.pipeline.publish import ( ValidateMeshOrder, OptionalPyblishPluginMixin ) import openpype.hosts.maya.api.action class ValidateUnrealMeshTriangulated(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate if mesh is made of triangles for Unreal Engine""" order = ValidateMeshOrder hosts = ["maya"] families = ["staticMesh"] label = "Mesh is Triangulated" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] active = False @classmethod def get_invalid(cls, instance): invalid = [] meshes = cmds.ls(instance, type="mesh", long=True) for mesh in meshes: faces = cmds.polyEvaluate(mesh, f=True) tris = cmds.polyEvaluate(mesh, t=True) if faces != tris: invalid.append(mesh) return invalid def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) assert len(invalid) == 0, ( "Found meshes without triangles") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py ================================================ # -*- coding: utf-8 -*- """Validator for correct naming of Static Meshes.""" import re import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline import legacy_io from openpype.settings import get_project_settings from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin, PublishValidationError ) class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate name of Unreal Static Mesh Unreals naming convention states that staticMesh should start with `SM` prefix - SM_[Name]_## (Eg. SM_sube_01).These prefixes can be configured in Settings UI. This plugin also validates other types of meshes - collision meshes: UBX_[RenderMeshName]*: Boxes are created with the Box objects type in Max or with the Cube polygonal primitive in Maya. You cannot move the vertices around or deform it in any way to make it something other than a rectangular prism, or else it will not work. UCP_[RenderMeshName]*: Capsules are created with the Capsule object type. The capsule does not need to have many segments (8 is a good number) at all because it is converted into a true capsule for collision. Like boxes, you should not move the individual vertices around. USP_[RenderMeshName]*: Spheres are created with the Sphere object type. The sphere does not need to have many segments (8 is a good number) at all because it is converted into a true sphere for collision. Like boxes, you should not move the individual vertices around. UCX_[RenderMeshName]*: Convex objects can be any completely closed convex 3D shape. For example, a box can also be a convex object This validator also checks if collision mesh [RenderMeshName] matches one of SM_[RenderMeshName]. """ optional = True order = ValidateContentsOrder hosts = ["maya"] families = ["staticMesh"] label = "Unreal Static Mesh Name" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] regex_mesh = r"(?P.*))" regex_collision = r"(?P.*)" @classmethod def get_invalid(cls, instance): invalid = [] collision_prefixes = ( instance.context.data["project_settings"] ["maya"] ["create"] ["CreateUnrealStaticMesh"] ["collision_prefixes"] ) if cls.validate_mesh: # compile regex for testing names regex_mesh = "{}{}".format( ("_" + cls.static_mesh_prefix) or "", cls.regex_mesh ) sm_r = re.compile(regex_mesh) if not sm_r.match(instance.data.get("subset")): cls.log.error("Mesh doesn't comply with name validation.") return True if cls.validate_collision: collision_set = instance.data.get("collisionMembers", None) # soft-fail is there are no collision objects if not collision_set: cls.log.warning("No collision objects to validate.") return False regex_collision = "{}{}_(\\d+)".format( "(?P({}))_".format( "|".join("{0}".format(p) for p in collision_prefixes) ) or "", cls.regex_collision ) cl_r = re.compile(regex_collision) asset_name = instance.data["assetEntity"]["name"] mesh_name = "{}{}".format(asset_name, instance.data.get("variant", [])) for obj in collision_set: cl_m = cl_r.match(obj) if not cl_m: cls.log.error("{} is invalid".format(obj)) invalid.append(obj) else: expected_collision = "{}_{}".format( cl_m.group("prefix"), mesh_name ) if not obj.startswith(expected_collision): cls.log.error( "Collision object name doesn't match " "static mesh name" ) cls.log.error("{}_{} != {}_{}*".format( cl_m.group("prefix"), cl_m.group("renderName"), cl_m.group("prefix"), mesh_name, )) invalid.append(obj) return invalid def process(self, instance): if not self.is_active(instance.data): return if not self.validate_mesh and not self.validate_collision: self.log.debug("Validation of both mesh and collision names" "is disabled.") return if not instance.data.get("collisionMembers", None): self.log.debug("There are no collision objects to validate") return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError("Model naming is invalid. See log.") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_unreal_up_axis.py ================================================ # -*- coding: utf-8 -*- from maya import cmds import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, RepairAction, OptionalPyblishPluginMixin ) class ValidateUnrealUpAxis(pyblish.api.ContextPlugin, OptionalPyblishPluginMixin): """Validate if Z is set as up axis in Maya""" optional = True active = False order = ValidateContentsOrder hosts = ["maya"] families = ["staticMesh"] label = "Unreal Up-Axis check" actions = [RepairAction] def process(self, context): if not self.is_active(context.data): return assert cmds.upAxis(q=True, axis=True) == "z", ( "Invalid axis set as up axis" ) @classmethod def repair(cls, instance): cmds.upAxis(axis="z", rotateView=True) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_visible_only.py ================================================ import pyblish.api from openpype.hosts.maya.api.lib import iter_visible_nodes_in_range import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateAlembicVisibleOnly(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates at least a single node is visible in frame range. This validation only validates if the `visibleOnly` flag is enabled on the instance - otherwise the validation is skipped. """ order = ValidateContentsOrder + 0.05 label = "Alembic Visible Only" hosts = ["maya"] families = ["pointcache", "animation"] actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False def process(self, instance): if not self.is_active(instance.data): return if not instance.data.get("visibleOnly", False): self.log.debug("Visible only is disabled. Validation skipped..") return invalid = self.get_invalid(instance) if invalid: start, end = self.get_frame_range(instance) raise PublishValidationError("No visible nodes found in " "frame range {}-{}.".format(start, end)) @classmethod def get_invalid(cls, instance): if instance.data["family"] == "animation": # Special behavior to use the nodes in out_SET nodes = instance.data["out_hierarchy"] else: nodes = instance[:] start, end = cls.get_frame_range(instance) if not any(iter_visible_nodes_in_range(nodes, start, end)): # Return the nodes we have considered so the user can identify # them with the select invalid action return nodes @staticmethod def get_frame_range(instance): data = instance.data return data["frameStartHandle"], data["frameEndHandle"] ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_vray.py ================================================ from maya import cmds import pyblish.api from openpype.pipeline.publish import PublishValidationError class ValidateVray(pyblish.api.InstancePlugin): """Validate general Vray setup.""" order = pyblish.api.ValidatorOrder label = 'VRay' hosts = ["maya"] families = ["vrayproxy"] def process(self, instance): # Validate vray plugin is loaded. if not cmds.pluginInfo("vrayformaya", query=True, loaded=True): raise PublishValidationError("Vray plugin is not loaded.") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_vray_distributed_rendering.py ================================================ import pyblish.api from maya import cmds from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( PublishValidationError, RepairAction, ValidateContentsOrder, OptionalPyblishPluginMixin ) class ValidateVRayDistributedRendering(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate V-Ray Distributed Rendering is ignored in batch mode. Whenever Distributed Rendering is enabled for V-Ray in the render settings ensure that the "Ignore in batch mode" is enabled so the submitted job won't try to render each frame with all machines resulting in faulty errors. """ order = ValidateContentsOrder label = "VRay Distributed Rendering" families = ["renderlayer"] actions = [RepairAction] optional = False # V-Ray attribute names enabled_attr = "vraySettings.sys_distributed_rendering_on" ignored_attr = "vraySettings.sys_distributed_rendering_ignore_batch" def process(self, instance): if not self.is_active(instance.data): return if instance.data.get("renderer") != "vray": # If not V-Ray ignore.. return vray_settings = cmds.ls("vraySettings", type="VRaySettingsNode") assert vray_settings, "Please ensure a VRay Settings Node is present" renderlayer = instance.data['renderlayer'] if not lib.get_attr_in_layer(self.enabled_attr, layer=renderlayer): # If not distributed rendering enabled, ignore.. return # If distributed rendering is enabled but it is *not* set to ignore # during batch mode we invalidate the instance if not lib.get_attr_in_layer(self.ignored_attr, layer=renderlayer): raise PublishValidationError( ("Renderlayer has distributed rendering enabled " "but is not set to ignore in batch mode.")) @classmethod def repair(cls, instance): renderlayer = instance.data.get("renderlayer") with lib.renderlayer(renderlayer): cls.log.debug("Enabling Distributed Rendering " "ignore in batch mode..") cmds.setAttr(cls.ignored_attr, True) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_vray_referenced_aovs.py ================================================ # -*- coding: utf-8 -*- """Validate if there are AOVs pulled from references.""" import pyblish.api import types from maya import cmds from openpype.pipeline.publish import ( RepairContextAction, OptionalPyblishPluginMixin ) class ValidateVrayReferencedAOVs(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate whether the V-Ray Render Elements (AOVs) include references. This will check if there are AOVs pulled from references. If `Vray Use Referenced Aovs` is checked on render instance, u must add those manually to Render Elements as Pype will expect them to be rendered. """ order = pyblish.api.ValidatorOrder label = 'VRay Referenced AOVs' hosts = ['maya'] families = ['renderlayer'] actions = [RepairContextAction] optional = False def process(self, instance): """Plugin main entry point.""" if not self.is_active(instance.data): return if instance.data.get("renderer") != "vray": # If not V-Ray ignore.. return ref_aovs = cmds.ls( type=["VRayRenderElement", "VRayRenderElementSet"], referencedNodes=True) ref_aovs_enabled = ValidateVrayReferencedAOVs.maya_is_true( cmds.getAttr("vraySettings.relements_usereferenced")) if not instance.data.get("vrayUseReferencedAovs"): if ref_aovs_enabled and ref_aovs: self.log.warning(( "Referenced AOVs are enabled in Vray " "Render Settings and are detected in scene, but " "Pype render instance option for referenced AOVs is " "disabled. Those AOVs will be rendered but not published " "by Pype." )) self.log.warning(", ".join(ref_aovs)) else: if not ref_aovs: self.log.warning(( "Use of referenced AOVs enabled but there are none " "in the scene." )) if not ref_aovs_enabled: self.log.error(( "'Use referenced' not enabled in Vray Render Settings." )) raise AssertionError("Invalid render settings") @classmethod def repair(cls, context): """Repair action.""" vray_settings = cmds.ls(type="VRaySettingsNode") if not vray_settings: node = cmds.createNode("VRaySettingsNode") else: node = vray_settings[0] cmds.setAttr("{}.relements_usereferenced".format(node), True) @staticmethod def maya_is_true(attr_val): """Whether a Maya attr evaluates to True. When querying an attribute value from an ambiguous object the Maya API will return a list of values, which need to be properly handled to evaluate properly. Args: attr_val (mixed): Maya attribute to be evaluated as bool. Returns: bool: cast Maya attribute to Pythons boolean value. """ if isinstance(attr_val, bool): return attr_val elif isinstance(attr_val, (list, types.GeneratorType)): return any(attr_val) else: return bool(attr_val) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_vray_translator_settings.py ================================================ # -*- coding: utf-8 -*- """Validate VRay Translator settings.""" import pyblish.api from openpype.pipeline.publish import ( context_plugin_should_run, RepairContextAction, ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) from maya import cmds class ValidateVRayTranslatorEnabled(pyblish.api.ContextPlugin, OptionalPyblishPluginMixin): """Validate VRay Translator settings for extracting vrscenes.""" order = ValidateContentsOrder label = "VRay Translator Settings" families = ["vrayscene_layer"] actions = [RepairContextAction] optional = False def process(self, context): """Plugin entry point.""" if not self.is_active(context.data): return # Workaround bug pyblish-base#250 if not context_plugin_should_run(self, context): return invalid = self.get_invalid(context) if invalid: raise PublishValidationError( message="Found invalid VRay Translator settings", title=self.label ) @classmethod def get_invalid(cls, context): """Get invalid instances.""" invalid = False # Get vraySettings node vray_settings = cmds.ls(type="VRaySettingsNode") if not vray_settings: raise PublishValidationError( "Please ensure a VRay Settings Node is present", title=cls.label ) node = vray_settings[0] if cmds.setAttr("{}.vrscene_render_on".format(node)): cls.log.error( "Render is enabled, for export it should be disabled") invalid = True if not cmds.getAttr("{}.vrscene_on".format(node)): cls.log.error("Export vrscene not enabled") invalid = True for instance in context: if "vrayscene_layer" not in instance.data.get("families"): continue if instance.data.get("vraySceneMultipleFiles"): if not cmds.getAttr("{}.misc_eachFrameInFile".format(node)): cls.log.error("Each Frame in File not enabled") invalid = True else: if cmds.getAttr("{}.misc_eachFrameInFile".format(node)): cls.log.error("Each Frame in File is enabled") invalid = True vrscene_filename = cmds.getAttr("{}.vrscene_filename".format(node)) if vrscene_filename != "vrayscene///": cls.log.error("Template for file name is wrong") invalid = True return invalid @classmethod def repair(cls, context): """Repair invalid settings.""" vray_settings = cmds.ls(type="VRaySettingsNode") if not vray_settings: node = cmds.createNode("VRaySettingsNode") else: node = vray_settings[0] cmds.setAttr("{}.vrscene_render_on".format(node), False) cmds.setAttr("{}.vrscene_on".format(node), True) for instance in context: if "vrayscene" not in instance.data.get("families"): continue if instance.data.get("vraySceneMultipleFiles"): cmds.setAttr("{}.misc_eachFrameInFile".format(node), True) else: cmds.setAttr("{}.misc_eachFrameInFile".format(node), False) cmds.setAttr("{}.vrscene_filename".format(node), "vrayscene///", type="string") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_vrayproxy.py ================================================ import pyblish.api from openpype.pipeline import KnownPublishError from openpype.pipeline.publish import OptionalPyblishPluginMixin class ValidateVrayProxy(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): order = pyblish.api.ValidatorOrder label = "VRay Proxy Settings" hosts = ["maya"] families = ["vrayproxy"] optional = False def process(self, instance): data = instance.data if not self.is_active(data): return if not data["setMembers"]: raise KnownPublishError( "'%s' is empty! This is a bug" % instance.name ) if data["animation"]: if data["frameEnd"] < data["frameStart"]: raise KnownPublishError( "End frame is smaller than start frame" ) if not data["vrmesh"] and not data["alembic"]: raise KnownPublishError( "Both vrmesh and alembic are off. Needs at least one to" " publish." ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_vrayproxy_members.py ================================================ import pyblish.api from maya import cmds import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, OptionalPyblishPluginMixin ) class ValidateVrayProxyMembers(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate whether the V-Ray Proxy instance has shape members""" order = pyblish.api.ValidatorOrder label = 'VRay Proxy Members' hosts = ['maya'] families = ['vrayproxy'] actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError("'%s' is invalid VRay Proxy for " "export!" % instance.name) @classmethod def get_invalid(cls, instance): shapes = cmds.ls(instance, shapes=True, noIntermediate=True, long=True) if not shapes: cls.log.error("'%s' contains no shapes." % instance.name) # Return the instance itself return [instance.name] ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_xgen.py ================================================ import json import maya.cmds as cmds import xgenm import pyblish.api from openpype.pipeline.publish import PublishValidationError class ValidateXgen(pyblish.api.InstancePlugin): """Validate Xgen data.""" label = "Validate Xgen" order = pyblish.api.ValidatorOrder host = ["maya"] families = ["xgen"] def process(self, instance): set_members = instance.data.get("setMembers") # Only 1 collection/node per instance. if len(set_members) != 1: raise PublishValidationError( "Only one collection per instance is allowed." " Found:\n{}".format(set_members) ) # Only xgen palette node is allowed. node_type = cmds.nodeType(set_members[0]) if node_type != "xgmPalette": raise PublishValidationError( "Only node of type \"xgmPalette\" are allowed. Referred to as" " \"collection\" in the Maya UI." " Node type found: {}".format(node_type) ) # Cant have inactive modifiers in collection cause Xgen will try and # look for them when loading. palette = instance.data["xgmPalette"].replace("|", "") inactive_modifiers = {} for description in instance.data["xgmDescriptions"]: description = description.split("|")[-2] modifier_names = xgenm.fxModules(palette, description) for name in modifier_names: attr = xgenm.getAttr("active", palette, description, name) # Attribute value are lowercase strings of false/true. if attr == "false": try: inactive_modifiers[description].append(name) except KeyError: inactive_modifiers[description] = [name] if inactive_modifiers: raise PublishValidationError( "There are inactive modifiers on the collection. " "Please delete these:\n{}".format( json.dumps(inactive_modifiers, indent=4, sort_keys=True) ) ) # We need a namespace else there will be a naming conflict when # extracting because of stripping namespaces and parenting to world. node_names = [instance.data["xgmPalette"]] node_names.extend(instance.data["xgenConnections"]) non_namespaced_nodes = [n for n in node_names if ":" not in n] if non_namespaced_nodes: raise PublishValidationError( "Could not find namespace on {}. Namespace is required for" " xgen publishing.".format(non_namespaced_nodes) ) ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py ================================================ from maya import cmds import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, OptionalPyblishPluginMixin ) class ValidateYetiRenderScriptCallbacks(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Check if the render script callbacks will be used during the rendering In order to ensure the render tasks are executed properly we need to check if the pre and post render callbacks are actually used. For example: Yeti is not loaded but its callback scripts are still set in the render settings. This will cause an error because Maya tries to find and execute the callbacks. Developer note: The pre and post render callbacks cannot be overridden """ order = ValidateContentsOrder label = "Yeti Render Script Callbacks" hosts = ["maya"] families = ["renderlayer"] optional = False # Settings per renderer callbacks = { "vray": { "pre": "catch(`pgYetiVRayPreRender`)", "post": "catch(`pgYetiVRayPostRender`)" }, "arnold": { "pre": "pgYetiPreRender" } } def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise ValueError("Invalid render callbacks found for '%s'!" % instance.name) @classmethod def get_invalid(cls, instance): yeti_loaded = cmds.pluginInfo("pgYetiMaya", query=True, loaded=True) if not yeti_loaded and not cmds.ls(type="pgYetiMaya"): # The yeti plug-in is available and loaded so at # this point we don't really care whether the scene # has any yeti callback set or not since if the callback # is there it wouldn't error and if it weren't then # nothing happens because there are no yeti nodes. cls.log.debug( "Yeti is loaded but no yeti nodes were found. " "Callback validation skipped.." ) return False renderer = instance.data["renderer"] if renderer == "redshift": cls.log.debug("Redshift ignores any pre and post render callbacks") return False callback_lookup = cls.callbacks.get(renderer, {}) if not callback_lookup: cls.log.warning("Renderer '%s' is not supported in this plugin" % renderer) return False pre_mel = cmds.getAttr("defaultRenderGlobals.preMel") or "" post_mel = cmds.getAttr("defaultRenderGlobals.postMel") or "" if pre_mel.strip(): cls.log.debug("Found pre mel: `%s`" % pre_mel) if post_mel.strip(): cls.log.debug("Found post mel: `%s`" % post_mel) # Strip callbacks and turn into a set for quick lookup pre_callbacks = {cmd.strip() for cmd in pre_mel.split(";")} post_callbacks = {cmd.strip() for cmd in post_mel.split(";")} pre_script = callback_lookup.get("pre", "") post_script = callback_lookup.get("post", "") # If Yeti is not loaded invalid = False if not yeti_loaded: if pre_script and pre_script in pre_callbacks: cls.log.error("Found pre render callback '%s' which is not " "uses!" % pre_script) invalid = True if post_script and post_script in post_callbacks: cls.log.error("Found post render callback '%s which is " "not used!" % post_script) invalid = True # If Yeti is loaded else: if pre_script and pre_script not in pre_callbacks: cls.log.error( "Could not find required pre render callback " "`%s`" % pre_script) invalid = True if post_script and post_script not in post_callbacks: cls.log.error( "Could not find required post render callback" " `%s`" % post_script) invalid = True return invalid ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_yeti_rig_cache_state.py ================================================ import pyblish.api import maya.cmds as cmds import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateYetiRigCacheState(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate the I/O attributes of the node Every pgYetiMaya cache node per instance should have: 1. Input Mode is set to `None` 2. Input Cache File Name is empty """ order = pyblish.api.ValidatorOrder label = "Yeti Rig Cache State" hosts = ["maya"] families = ["yetiRig"] actions = [RepairAction, openpype.hosts.maya.api.action.SelectInvalidAction] optional = False def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError("Nodes have incorrect I/O settings") @classmethod def get_invalid(cls, instance): invalid = [] yeti_nodes = cmds.ls(instance, type="pgYetiMaya") for node in yeti_nodes: # Check reading state state = cmds.getAttr("%s.fileMode" % node) if state == 1: cls.log.error("Node `%s` is set to mode `cache`" % node) invalid.append(node) continue # Check reading state has_cache = cmds.getAttr("%s.cacheFileName" % node) if has_cache: cls.log.error("Node `%s` has a cache file set" % node) invalid.append(node) continue return invalid @classmethod def repair(cls, instance): """Repair all errors""" # Create set to ensure all nodes only pass once invalid = cls.get_invalid(instance) for node in invalid: cmds.setAttr("%s.fileMode" % node, 0) cmds.setAttr("%s.cacheFileName" % node, "", type="string") ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py ================================================ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin ) class ValidateYetiRigInputShapesInInstance(pyblish.api.Validator, OptionalPyblishPluginMixin): """Validate if all input nodes are part of the instance's hierarchy""" order = ValidateContentsOrder hosts = ["maya"] families = ["yetiRig"] label = "Yeti Rig Input Shapes In Instance" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] optional = False def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError("Yeti Rig has invalid input meshes") @classmethod def get_invalid(cls, instance): input_set = next((i for i in instance if i == "input_SET"), None) assert input_set, "Current %s instance has no `input_SET`" % instance # Get all children, we do not care about intermediates input_nodes = cmds.ls(cmds.sets(input_set, query=True), long=True) dag = cmds.ls(input_nodes, dag=True, long=True) shapes = cmds.ls(dag, long=True, shapes=True, noIntermediate=True) # Allow publish without input meshes. if not shapes: cls.log.debug("Found no input meshes for %s, skipping ..." % instance) return [] # check if input node is part of groomRig instance instance_lookup = set(instance[:]) invalid = [s for s in shapes if s not in instance_lookup] return invalid ================================================ FILE: openpype/hosts/maya/plugins/publish/validate_yeti_rig_settings.py ================================================ import pyblish.api from openpype.pipeline.publish import ( PublishValidationError, OptionalPyblishPluginMixin ) class ValidateYetiRigSettings(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate Yeti Rig Settings have collected input connections. The input connections are collected for the nodes in the `input_SET`. When no input connections are found a warning is logged but it is allowed to pass validation. """ order = pyblish.api.ValidatorOrder label = "Yeti Rig Settings" families = ["yetiRig"] optional = False def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( ("Detected invalid Yeti Rig data. (See log) " "Tip: Save the scene")) @classmethod def get_invalid(cls, instance): rigsettings = instance.data.get("rigsettings", None) if rigsettings is None: cls.log.error("MAJOR ERROR: No rig settings found!") return True # Get inputs inputs = rigsettings.get("inputs", []) if not inputs: # Empty rig settings dictionary cls.log.warning("No rig inputs found. This can happen when " "the rig has no inputs from outside the rig.") return False for input in inputs: source_id = input["sourceID"] if source_id is None: cls.log.error("Discovered source with 'None' as ID, please " "check if the input shape has a cbId") return True destination_id = input["destinationID"] if destination_id is None: cls.log.error("Discovered None as destination ID value") return True return False ================================================ FILE: openpype/hosts/maya/startup/userSetup.py ================================================ import os from openpype.settings import get_project_settings from openpype.pipeline import install_host, get_current_project_name from openpype.hosts.maya.api import MayaHost from maya import cmds host = MayaHost() install_host(host) print("Starting OpenPype usersetup...") project_name = get_current_project_name() settings = get_project_settings(project_name) # Loading plugins explicitly. explicit_plugins_loading = settings["maya"]["explicit_plugins_loading"] if explicit_plugins_loading["enabled"]: def _explicit_load_plugins(): for plugin in explicit_plugins_loading["plugins_to_load"]: if plugin["enabled"]: print("Loading plug-in: " + plugin["name"]) try: cmds.loadPlugin(plugin["name"], quiet=True) except RuntimeError as e: print(e) # We need to load plugins deferred as loading them directly does not work # correctly due to Maya's initialization. cmds.evalDeferred( _explicit_load_plugins, lowestPriority=True ) # Open Workfile Post Initialization. key = "OPENPYPE_OPEN_WORKFILE_POST_INITIALIZATION" if bool(int(os.environ.get(key, "0"))): def _log_and_open(): path = os.environ["AVALON_LAST_WORKFILE"] print("Opening \"{}\"".format(path)) cmds.file(path, open=True, force=True) cmds.evalDeferred( _log_and_open, lowestPriority=True ) print("Finished OpenPype usersetup.") ================================================ FILE: openpype/hosts/maya/tools/__init__.py ================================================ from openpype.tools.utils.host_tools import qt_app_context class MayaToolsSingleton: _look_assigner = None def get_look_assigner_tool(parent): """Create, cache and return look assigner tool window.""" if MayaToolsSingleton._look_assigner is None: from .mayalookassigner import MayaLookAssignerWindow mayalookassigner_window = MayaLookAssignerWindow(parent) MayaToolsSingleton._look_assigner = mayalookassigner_window return MayaToolsSingleton._look_assigner def show_look_assigner(parent=None): """Look manager is Maya specific tool for look management.""" with qt_app_context(): look_assigner_tool = get_look_assigner_tool(parent) look_assigner_tool.show() # Pull window to the front. look_assigner_tool.raise_() look_assigner_tool.activateWindow() look_assigner_tool.showNormal() ================================================ FILE: openpype/hosts/maya/tools/mayalookassigner/LICENSE ================================================ MIT License Copyright (c) 2017 Colorbleed Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: openpype/hosts/maya/tools/mayalookassigner/__init__.py ================================================ from .app import ( MayaLookAssignerWindow, show ) __all__ = [ "MayaLookAssignerWindow", "show"] ================================================ FILE: openpype/hosts/maya/tools/mayalookassigner/alembic.py ================================================ # -*- coding: utf-8 -*- """Tools for loading looks to vray proxies.""" import os from collections import defaultdict import logging import six import alembic.Abc log = logging.getLogger(__name__) def get_alembic_paths_by_property(filename, attr, verbose=False): # type: (str, str, bool) -> dict """Return attribute value per objects in the Alembic file. Reads an Alembic archive hierarchy and retrieves the value from the `attr` properties on the objects. Args: filename (str): Full path to Alembic archive to read. attr (str): Id attribute. verbose (bool): Whether to verbosely log missing attributes. Returns: dict: Mapping of node full path with its id """ # Normalize alembic path filename = os.path.normpath(filename) filename = filename.replace("\\", "/") filename = str(filename) # path must be string try: archive = alembic.Abc.IArchive(filename) except RuntimeError: # invalid alembic file - probably vrmesh log.warning("{} is not an alembic file".format(filename)) return {} root = archive.getTop() iterator = list(root.children) obj_ids = {} for obj in iterator: name = obj.getFullName() # include children for coming iterations iterator.extend(obj.children) props = obj.getProperties() if props.getNumProperties() == 0: # Skip those without properties, e.g. '/materials' in a gpuCache continue # THe custom attribute is under the properties' first container under # the ".arbGeomParams" prop = props.getProperty(0) # get base property _property = None try: geo_params = prop.getProperty('.arbGeomParams') _property = geo_params.getProperty(attr) except KeyError: if verbose: log.debug("Missing attr on: {0}".format(name)) continue if not _property.isConstant(): log.warning("Id not constant on: {0}".format(name)) # Get first value sample value = _property.getValue()[0] obj_ids[name] = value return obj_ids def get_alembic_ids_cache(path): # type: (str) -> dict """Build a id to node mapping in Alembic file. Nodes without IDs are ignored. Returns: dict: Mapping of id to nodes in the Alembic. """ node_ids = get_alembic_paths_by_property(path, attr="cbId") id_nodes = defaultdict(list) for node, _id in six.iteritems(node_ids): id_nodes[_id].append(node) return dict(six.iteritems(id_nodes)) ================================================ FILE: openpype/hosts/maya/tools/mayalookassigner/app.py ================================================ import sys import time import logging from qtpy import QtWidgets, QtCore from openpype import style from openpype.client import get_last_version_by_subset_id from openpype.pipeline import get_current_project_name from openpype.tools.utils.lib import qt_app_context from openpype.hosts.maya.api.lib import ( assign_look_by_version, get_main_window ) from maya import cmds # old api for MFileIO import maya.OpenMaya import maya.api.OpenMaya as om from .widgets import ( AssetOutliner, LookOutliner ) from .commands import ( get_workfile, remove_unused_looks ) from .vray_proxies import vrayproxy_assign_look from . import arnold_standin module = sys.modules[__name__] module.window = None class MayaLookAssignerWindow(QtWidgets.QWidget): def __init__(self, parent=None): super(MayaLookAssignerWindow, self).__init__(parent=parent) self.log = logging.getLogger(__name__) # Store callback references self._callbacks = [] self._connections_set_up = False filename = get_workfile() self.setObjectName("lookManager") self.setWindowTitle("Look Manager 1.4.0 - [{}]".format(filename)) self.setWindowFlags(QtCore.Qt.Window) self.setParent(parent) self.resize(750, 500) self.setup_ui() # Force refresh check on initialization self._on_renderlayer_switch() def setup_ui(self): """Build the UI""" main_splitter = QtWidgets.QSplitter(self) # Assets (left) asset_outliner = AssetOutliner(main_splitter) # Looks (right) looks_widget = QtWidgets.QWidget(main_splitter) look_outliner = LookOutliner(looks_widget) # Database look overview assign_selected = QtWidgets.QCheckBox( "Assign to selected only", looks_widget ) assign_selected.setToolTip("Whether to assign only to selected nodes " "or to the full asset") remove_unused_btn = QtWidgets.QPushButton( "Remove Unused Looks", looks_widget ) looks_layout = QtWidgets.QVBoxLayout(looks_widget) looks_layout.addWidget(look_outliner) looks_layout.addWidget(assign_selected) looks_layout.addWidget(remove_unused_btn) main_splitter.addWidget(asset_outliner) main_splitter.addWidget(looks_widget) main_splitter.setSizes([350, 200]) # Footer status = QtWidgets.QStatusBar(self) status.setSizeGripEnabled(False) status.setFixedHeight(25) warn_layer = QtWidgets.QLabel( "Current Layer is not defaultRenderLayer", self ) warn_layer.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) warn_layer.setStyleSheet("color: #DD5555; font-weight: bold;") warn_layer.setFixedHeight(25) footer = QtWidgets.QHBoxLayout() footer.setContentsMargins(0, 0, 0, 0) footer.addWidget(status) footer.addWidget(warn_layer) # Build up widgets main_layout = QtWidgets.QVBoxLayout(self) main_layout.setSpacing(0) main_layout.addWidget(main_splitter) main_layout.addLayout(footer) # Set column width asset_outliner.view.setColumnWidth(0, 200) look_outliner.view.setColumnWidth(0, 150) asset_outliner.selection_changed.connect( self.on_asset_selection_changed) asset_outliner.refreshed.connect( lambda: self.echo("Loaded assets..") ) look_outliner.menu_apply_action.connect(self.on_process_selected) remove_unused_btn.clicked.connect(remove_unused_looks) # Open widgets self.asset_outliner = asset_outliner self.look_outliner = look_outliner self.status = status self.warn_layer = warn_layer # Buttons self.remove_unused = remove_unused_btn self.assign_selected = assign_selected self._first_show = True def setup_connections(self): """Connect interactive widgets with actions""" if self._connections_set_up: return # Maya renderlayer switch callback callback = om.MEventMessage.addEventCallback( "renderLayerManagerChange", self._on_renderlayer_switch ) self._callbacks.append(callback) self._connections_set_up = True def remove_connection(self): # Delete callbacks for callback in self._callbacks: om.MMessage.removeCallback(callback) self._callbacks = [] self._connections_set_up = False def showEvent(self, event): self.setup_connections() super(MayaLookAssignerWindow, self).showEvent(event) if self._first_show: self._first_show = False self.setStyleSheet(style.load_stylesheet()) def closeEvent(self, event): self.remove_connection() super(MayaLookAssignerWindow, self).closeEvent(event) def _on_renderlayer_switch(self, *args): """Callback that updates on Maya renderlayer switch""" if maya.OpenMaya.MFileIO.isNewingFile(): # Don't perform a check during file open or file new as # the renderlayers will not be in a valid state yet. return layer = cmds.editRenderLayerGlobals(query=True, currentRenderLayer=True) if layer != "defaultRenderLayer": self.warn_layer.show() else: self.warn_layer.hide() def echo(self, message): self.status.showMessage(message, 1500) def refresh(self): """Refresh the content""" # Get all containers and information self.asset_outliner.clear() found_items = self.asset_outliner.get_all_assets() if not found_items: self.look_outliner.clear() def on_asset_selection_changed(self): """Get selected items from asset loader and fill look outliner""" items = self.asset_outliner.get_selected_items() self.look_outliner.clear() self.look_outliner.add_items(items) def on_process_selected(self): """Process all selected looks for the selected assets""" assets = self.asset_outliner.get_selected_items() assert assets, "No asset selected" # Collect the looks we want to apply (by name) look_items = self.look_outliner.get_selected_items() looks = {look["subset"] for look in look_items} selection = self.assign_selected.isChecked() asset_nodes = self.asset_outliner.get_nodes(selection=selection) project_name = get_current_project_name() start = time.time() for i, (asset, item) in enumerate(asset_nodes.items()): # Label prefix prefix = "({}/{})".format(i + 1, len(asset_nodes)) # Assign the first matching look relevant for this asset # (since assigning multiple to the same nodes makes no sense) assign_look = next((subset for subset in item["looks"] if subset["name"] in looks), None) if not assign_look: self.echo( "{} No matching selected look for {}".format(prefix, asset) ) continue # Get the latest version of this asset's look subset version = get_last_version_by_subset_id( project_name, assign_look["_id"], fields=["_id"] ) subset_name = assign_look["name"] self.echo("{} Assigning {} to {}\t".format( prefix, subset_name, asset )) nodes = item["nodes"] # Assign Vray Proxy look. if cmds.pluginInfo('vrayformaya', query=True, loaded=True): self.echo("Getting vray proxy nodes ...") vray_proxies = set(cmds.ls(type="VRayProxy", long=True)) for vp in vray_proxies: if vp in nodes: vrayproxy_assign_look(vp, subset_name) nodes = list(set(nodes).difference(vray_proxies)) else: self.echo( "Could not assign to VRayProxy because vrayformaya plugin " "is not loaded." ) # Assign Arnold Standin look. if cmds.pluginInfo("mtoa", query=True, loaded=True): arnold_standins = set(cmds.ls(type="aiStandIn", long=True)) for standin in arnold_standins: if standin in nodes: arnold_standin.assign_look(standin, subset_name) nodes = list(set(nodes).difference(arnold_standins)) else: self.echo( "Could not assign to aiStandIn because mtoa plugin is not " "loaded." ) # Assign look if nodes: assign_look_by_version(nodes, version_id=version["_id"]) end = time.time() self.echo("Finished assigning.. ({0:.3f}s)".format(end - start)) def show(): """Display Loader GUI Arguments: debug (bool, optional): Run loader in debug-mode, defaults to False """ try: module.window.close() del module.window except (RuntimeError, AttributeError): pass # Get Maya main window mainwindow = get_main_window() with qt_app_context(): window = MayaLookAssignerWindow(parent=mainwindow) window.show() module.window = window ================================================ FILE: openpype/hosts/maya/tools/mayalookassigner/arnold_standin.py ================================================ import os import json from collections import defaultdict import logging from maya import cmds from openpype.pipeline import legacy_io from openpype.client import get_last_version_by_subset_name from openpype.hosts.maya import api from . import lib from .alembic import get_alembic_ids_cache from .usd import is_usd_lib_supported, get_usd_ids_cache log = logging.getLogger(__name__) ATTRIBUTE_MAPPING = { "primaryVisibility": "visibility", # Camera "castsShadows": "visibility", # Shadow "receiveShadows": "receive_shadows", "aiSelfShadows": "self_shadows", "aiOpaque": "opaque", "aiMatte": "matte", "aiVisibleInDiffuseTransmission": "visibility", "aiVisibleInSpecularTransmission": "visibility", "aiVisibleInVolume": "visibility", "aiVisibleInDiffuseReflection": "visibility", "aiVisibleInSpecularReflection": "visibility", "aiSubdivUvSmoothing": "subdiv_uv_smoothing", "aiDispHeight": "disp_height", "aiDispPadding": "disp_padding", "aiDispZeroValue": "disp_zero_value", "aiStepSize": "step_size", "aiVolumePadding": "volume_padding", "aiSubdivType": "subdiv_type", "aiSubdivIterations": "subdiv_iterations" } def calculate_visibility_mask(attributes): # https://arnoldsupport.com/2018/11/21/backdoor-setting-visibility/ mapping = { "primaryVisibility": 1, # Camera "castsShadows": 2, # Shadow "aiVisibleInDiffuseTransmission": 4, "aiVisibleInSpecularTransmission": 8, "aiVisibleInVolume": 16, "aiVisibleInDiffuseReflection": 32, "aiVisibleInSpecularReflection": 64 } mask = 255 for attr, value in mapping.items(): if attributes.get(attr, True): continue mask -= value return mask def get_nodes_by_id(standin): """Get node id from aiStandIn via json sidecar. Args: standin (string): aiStandIn node. Returns: (dict): Dictionary with node full name/path and id. """ path = cmds.getAttr(standin + ".dso") if path.endswith(".abc"): # Support alembic files directly return get_alembic_ids_cache(path) elif ( is_usd_lib_supported and any(path.endswith(ext) for ext in [".usd", ".usda", ".usdc"]) ): # Support usd files directly return get_usd_ids_cache(path) json_path = None for f in os.listdir(os.path.dirname(path)): if f.endswith(".json"): json_path = os.path.join(os.path.dirname(path), f) break if not json_path: log.warning("Could not find json file for {}.".format(standin)) return {} with open(json_path, "r") as f: return json.load(f) def shading_engine_assignments(shading_engine, attribute, nodes, assignments): """Full assignments with shader or disp_map. Args: shading_engine (string): Shading engine for material. attribute (string): "surfaceShader" or "displacementShader" nodes: (list): Nodes paths relative to aiStandIn. assignments (dict): Assignments by nodes. """ shader_inputs = cmds.listConnections( shading_engine + "." + attribute, source=True ) if not shader_inputs: log.info( "Shading engine \"{}\" missing input \"{}\"".format( shading_engine, attribute ) ) return # Strip off component assignments for i, node in enumerate(nodes): if "." in node: log.warning( "Converting face assignment to full object assignment. This " "conversion can be lossy: {}".format(node) ) nodes[i] = node.split(".")[0] shader_type = "shader" if attribute == "surfaceShader" else "disp_map" assignment = "{}='{}'".format(shader_type, shader_inputs[0]) for node in nodes: assignments[node].append(assignment) def assign_look(standin, subset): log.info("Assigning {} to {}.".format(subset, standin)) nodes_by_id = get_nodes_by_id(standin) # Group by asset id so we run over the look per asset node_ids_by_asset_id = defaultdict(set) for node_id in nodes_by_id: asset_id = node_id.split(":", 1)[0] node_ids_by_asset_id[asset_id].add(node_id) project_name = legacy_io.active_project() for asset_id, node_ids in node_ids_by_asset_id.items(): # Get latest look version version = get_last_version_by_subset_name( project_name, subset_name=subset, asset_id=asset_id, fields=["_id"] ) if not version: log.info("Didn't find last version for subset name {}".format( subset )) continue relationships = lib.get_look_relationships(version["_id"]) shader_nodes, container_node = lib.load_look(version["_id"]) namespace = shader_nodes[0].split(":")[0] # Get only the node ids and paths related to this asset # And get the shader edits the look supplies asset_nodes_by_id = { node_id: nodes_by_id[node_id] for node_id in node_ids } edits = list( api.lib.iter_shader_edits( relationships, shader_nodes, asset_nodes_by_id ) ) # Create assignments node_assignments = {} for edit in edits: for node in edit["nodes"]: if node not in node_assignments: node_assignments[node] = [] if edit["action"] == "assign": if not cmds.ls(edit["shader"], type="shadingEngine"): log.info("Skipping non-shader: %s" % edit["shader"]) continue shading_engine_assignments( shading_engine=edit["shader"], attribute="surfaceShader", nodes=edit["nodes"], assignments=node_assignments ) shading_engine_assignments( shading_engine=edit["shader"], attribute="displacementShader", nodes=edit["nodes"], assignments=node_assignments ) if edit["action"] == "setattr": visibility = False for attr, value in edit["attributes"].items(): if attr not in ATTRIBUTE_MAPPING: log.warning( "Skipping setting attribute {} on {} because it is" " not recognized.".format(attr, edit["nodes"]) ) continue if isinstance(value, str): value = "'{}'".format(value) if ATTRIBUTE_MAPPING[attr] == "visibility": visibility = True continue assignment = "{}={}".format(ATTRIBUTE_MAPPING[attr], value) for node in edit["nodes"]: node_assignments[node].append(assignment) if visibility: mask = calculate_visibility_mask(edit["attributes"]) assignment = "visibility={}".format(mask) for node in edit["nodes"]: node_assignments[node].append(assignment) # Assign shader # Clear all current shader assignments plug = standin + ".operators" num = cmds.getAttr(plug, size=True) for i in reversed(range(num)): cmds.removeMultiInstance("{}[{}]".format(plug, i), b=True) # Create new assignment overrides index = 0 for node, assignments in node_assignments.items(): if not assignments: continue with api.lib.maintained_selection(): operator = cmds.createNode("aiSetParameter") operator = cmds.rename(operator, namespace + ":" + operator) cmds.setAttr(operator + ".selection", node, type="string") for i, assignment in enumerate(assignments): cmds.setAttr( "{}.assignment[{}]".format(operator, i), assignment, type="string" ) cmds.connectAttr( operator + ".out", "{}[{}]".format(plug, index) ) index += 1 cmds.sets(operator, edit=True, addElement=container_node) ================================================ FILE: openpype/hosts/maya/tools/mayalookassigner/commands.py ================================================ import os import logging from collections import defaultdict import maya.cmds as cmds from openpype.client import get_assets, get_asset_name_identifier from openpype.pipeline import ( remove_container, registered_host, get_current_project_name, ) from openpype.hosts.maya.api import lib from .vray_proxies import get_alembic_ids_cache from . import arnold_standin log = logging.getLogger(__name__) def get_workfile(): path = cmds.file(query=True, sceneName=True) or "untitled" return os.path.basename(path) def get_workfolder(): return os.path.dirname(cmds.file(query=True, sceneName=True)) def select(nodes): cmds.select(nodes) def get_namespace_from_node(node): """Get the namespace from the given node Args: node (str): name of the node Returns: namespace (str) """ parts = node.rsplit("|", 1)[-1].rsplit(":", 1) return parts[0] if len(parts) > 1 else u":" def get_selected_nodes(): """Get information from current selection""" selection = cmds.ls(selection=True, long=True) hierarchy = lib.get_all_children(selection) return list(set(selection + hierarchy)) def get_all_asset_nodes(): """Get all assets from the scene, container based Returns: list: list of dictionaries """ return cmds.ls(dag=True, noIntermediate=True, long=True) def create_asset_id_hash(nodes): """Create a hash based on cbId attribute value Args: nodes (list): a list of nodes Returns: dict """ node_id_hash = defaultdict(list) for node in nodes: # iterate over content of reference node if cmds.nodeType(node) == "reference": ref_hashes = create_asset_id_hash( list(set(cmds.referenceQuery(node, nodes=True, dp=True)))) for asset_id, ref_nodes in ref_hashes.items(): node_id_hash[asset_id] += ref_nodes elif cmds.pluginInfo('vrayformaya', query=True, loaded=True) and cmds.nodeType( node) == "VRayProxy": path = cmds.getAttr("{}.fileName".format(node)) ids = get_alembic_ids_cache(path) for k, _ in ids.items(): id = k.split(":")[0] node_id_hash[id].append(node) elif cmds.nodeType(node) == "aiStandIn": for id, _ in arnold_standin.get_nodes_by_id(node).items(): id = id.split(":")[0] node_id_hash[id].append(node) else: value = lib.get_id(node) if value is None: continue asset_id = value.split(":")[0] node_id_hash[asset_id].append(node) return dict(node_id_hash) def create_items_from_nodes(nodes): """Create an item for the view based the container and content of it It fetches the look document based on the asset ID found in the content. The item will contain all important information for the tool to work. If there is an asset ID which is not registered in the project's collection it will log a warning message. Args: nodes (list): list of maya nodes Returns: list of dicts """ asset_view_items = [] id_hashes = create_asset_id_hash(nodes) if not id_hashes: log.warning("No id hashes") return asset_view_items project_name = get_current_project_name() asset_ids = set(id_hashes.keys()) fields = {"_id", "name", "data.parents"} asset_docs = get_assets(project_name, asset_ids, fields=fields) asset_docs_by_id = { str(asset_doc["_id"]): asset_doc for asset_doc in asset_docs } for asset_id, id_nodes in id_hashes.items(): asset_doc = asset_docs_by_id.get(asset_id) # Skip if asset id is not found if not asset_doc: log.warning( "Id found on {num} nodes for which no asset is found database," " skipping '{asset_id}'".format( num=len(nodes), asset_id=asset_id ) ) continue # Collect available look subsets for this asset looks = lib.list_looks(project_name, asset_doc["_id"]) # Collect namespaces the asset is found in namespaces = set() for node in id_nodes: namespace = get_namespace_from_node(node) namespaces.add(namespace) label = get_asset_name_identifier(asset_doc) asset_view_items.append({ "label": label, "asset": asset_doc, "looks": looks, "namespaces": namespaces }) return asset_view_items def remove_unused_looks(): """Removes all loaded looks for which none of the shaders are used. This will cleanup all loaded "LookLoader" containers that are unused in the current scene. """ host = registered_host() unused = [] for container in host.ls(): if container['loader'] == "LookLoader": members = lib.get_container_members(container['objectName']) look_sets = cmds.ls(members, type="objectSet") for look_set in look_sets: # If the set is used than we consider this look *in use* if cmds.sets(look_set, query=True): break else: unused.append(container) for container in unused: log.info("Removing unused look container: %s", container['objectName']) remove_container(container) log.info("Finished removing unused looks. (see log for details)") ================================================ FILE: openpype/hosts/maya/tools/mayalookassigner/lib.py ================================================ import json import logging from openpype.pipeline import ( legacy_io, get_representation_path, registered_host, discover_loader_plugins, loaders_from_representation, load_container ) from openpype.client import get_representation_by_name from openpype.hosts.maya.api import lib log = logging.getLogger(__name__) def get_look_relationships(version_id): # type: (str) -> dict """Get relations for the look. Args: version_id (str): Parent version Id. Returns: dict: Dictionary of relations. """ project_name = legacy_io.active_project() json_representation = get_representation_by_name( project_name, representation_name="json", version_id=version_id ) # Load relationships shader_relation = get_representation_path(json_representation) with open(shader_relation, "r") as f: relationships = json.load(f) return relationships def load_look(version_id): # type: (str) -> list """Load look from version. Get look from version and invoke Loader for it. Args: version_id (str): Version ID Returns: list of shader nodes. """ project_name = legacy_io.active_project() # Get representations of shader file and relationships look_representation = get_representation_by_name( project_name, representation_name="ma", version_id=version_id ) # See if representation is already loaded, if so reuse it. host = registered_host() representation_id = str(look_representation['_id']) for container in host.ls(): if (container['loader'] == "LookLoader" and container['representation'] == representation_id): log.info("Reusing loaded look ...") container_node = container['objectName'] break else: log.info("Using look for the first time ...") # Load file all_loaders = discover_loader_plugins() loaders = loaders_from_representation(all_loaders, representation_id) loader = next( (i for i in loaders if i.__name__ == "LookLoader"), None) if loader is None: raise RuntimeError("Could not find LookLoader, this is a bug") # Reference the look file with lib.maintained_selection(): container_node = load_container(loader, look_representation)[0] return lib.get_container_members(container_node), container_node ================================================ FILE: openpype/hosts/maya/tools/mayalookassigner/models.py ================================================ from collections import defaultdict from qtpy import QtCore import qtawesome from openpype.tools.utils import models from openpype.style import get_default_entity_icon_color class AssetModel(models.TreeModel): Columns = ["label"] def __init__(self, *args, **kwargs): super(AssetModel, self).__init__(*args, **kwargs) self._icon_color = get_default_entity_icon_color() def add_items(self, items): """ Add items to model with needed data Args: items(list): collection of item data Returns: None """ self.beginResetModel() # Add the items sorted by label sorter = lambda x: x["label"] for item in sorted(items, key=sorter): asset_item = models.Item() asset_item.update(item) asset_item["icon"] = "folder" # Add namespace children namespaces = item["namespaces"] for namespace in sorted(namespaces): child = models.Item() child.update(item) child.update({ "label": (namespace if namespace != ":" else "(no namespace)"), "namespace": namespace, "looks": item["looks"], "icon": "folder-o" }) asset_item.add_child(child) self.add_child(asset_item) self.endResetModel() def data(self, index, role): if not index.isValid(): return if role == models.TreeModel.ItemRole: node = index.internalPointer() return node # Add icon if role == QtCore.Qt.DecorationRole: if index.column() == 0: node = index.internalPointer() icon = node.get("icon") if icon: return qtawesome.icon( "fa.{0}".format(icon), color=self._icon_color ) return super(AssetModel, self).data(index, role) class LookModel(models.TreeModel): """Model displaying a list of looks and matches for assets""" Columns = ["label", "match"] def add_items(self, items): """Add items to model with needed data An item exists of: { "subset": 'name of subset', "asset": asset_document } Args: items(list): collection of item data Returns: None """ self.beginResetModel() # Collect the assets per look name (from the items of the AssetModel) look_subsets = defaultdict(list) for asset_item in items: asset = asset_item["asset"] for look in asset_item["looks"]: look_subsets[look["name"]].append(asset) for subset in sorted(look_subsets.keys()): assets = look_subsets[subset] # Define nice label without "look" prefix for readability label = subset if not subset.startswith("look") else subset[4:] item_node = models.Item() item_node["label"] = label item_node["subset"] = subset # Amount of matching assets for this look item_node["match"] = len(assets) # Store the assets that have this subset available item_node["assets"] = assets self.add_child(item_node) self.endResetModel() ================================================ FILE: openpype/hosts/maya/tools/mayalookassigner/usd.py ================================================ from collections import defaultdict try: from pxr import Usd is_usd_lib_supported = True except ImportError: is_usd_lib_supported = False def get_usd_ids_cache(path): # type: (str) -> dict """Build a id to node mapping in a USD file. Nodes without IDs are ignored. Returns: dict: Mapping of id to nodes in the USD file. """ if not is_usd_lib_supported: raise RuntimeError("No pxr.Usd python library available.") stage = Usd.Stage.Open(path) ids = {} for prim in stage.Traverse(): attr = prim.GetAttribute("userProperties:cbId") if not attr.IsValid(): continue value = attr.Get() if not value: continue path = str(prim.GetPath()) ids[path] = value cache = defaultdict(list) for path, value in ids.items(): cache[value].append(path) return dict(cache) ================================================ FILE: openpype/hosts/maya/tools/mayalookassigner/views.py ================================================ from qtpy import QtWidgets, QtCore class View(QtWidgets.QTreeView): data_changed = QtCore.Signal() def __init__(self, parent=None): super(View, self).__init__(parent=parent) # view settings self.setAlternatingRowColors(False) self.setSortingEnabled(True) self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) def get_indices(self): """Get the selected rows""" selection_model = self.selectionModel() return selection_model.selectedRows() def extend_to_children(self, indices): """Extend the indices to the children indices. Top-level indices are extended to its children indices. Sub-items are kept as is. :param indices: The indices to extend. :type indices: list :return: The children indices :rtype: list """ subitems = set() for i in indices: valid_parent = i.parent().isValid() if valid_parent and i not in subitems: subitems.add(i) else: # is top level node model = i.model() rows = model.rowCount(parent=i) for row in range(rows): child = model.index(row, 0, parent=i) subitems.add(child) return list(subitems) ================================================ FILE: openpype/hosts/maya/tools/mayalookassigner/vray_proxies.py ================================================ # -*- coding: utf-8 -*- """Tools for loading looks to vray proxies.""" from collections import defaultdict import logging from maya import cmds from openpype.client import get_last_version_by_subset_name from openpype.pipeline import get_current_project_name import openpype.hosts.maya.lib as maya_lib from . import lib from .alembic import get_alembic_ids_cache log = logging.getLogger(__name__) def assign_vrayproxy_shaders(vrayproxy, assignments): # type: (str, dict) -> None """Assign shaders to content of Vray Proxy. This will create shader overrides on Vray Proxy to assign shaders to its content. Todo: Allow to optimize and assign a single shader to multiple shapes at once or maybe even set it to the highest available path? Args: vrayproxy (str): Name of Vray Proxy assignments (dict): Mapping of shader assignments. Returns: None """ # Clear all current shader assignments plug = vrayproxy + ".shaders" num = cmds.getAttr(plug, size=True) for i in reversed(range(num)): cmds.removeMultiInstance("{}[{}]".format(plug, i), b=True) # Create new assignment overrides index = 0 for material, paths in assignments.items(): for path in paths: plug = "{}.shaders[{}]".format(vrayproxy, index) cmds.setAttr(plug + ".shadersNames", path, type="string") cmds.connectAttr(material + ".outColor", plug + ".shadersConnections", force=True) index += 1 def vrayproxy_assign_look(vrayproxy, subset="lookDefault"): # type: (str, str) -> None """Assign look to vray proxy. Args: vrayproxy (str): Name of vrayproxy to apply look to. subset (str): Name of look subset. Returns: None """ path = cmds.getAttr(vrayproxy + ".fileName") nodes_by_id = get_alembic_ids_cache(path) if not nodes_by_id: log.warning("Alembic file has no cbId attributes: %s" % path) return # Group by asset id so we run over the look per asset node_ids_by_asset_id = defaultdict(set) for node_id in nodes_by_id: asset_id = node_id.split(":", 1)[0] node_ids_by_asset_id[asset_id].add(node_id) project_name = get_current_project_name() for asset_id, node_ids in node_ids_by_asset_id.items(): # Get latest look version version = get_last_version_by_subset_name( project_name, subset_name=subset, asset_id=asset_id, fields=["_id"] ) if not version: print("Didn't find last version for subset name {}".format( subset )) continue relationships = lib.get_look_relationships(version["_id"]) shadernodes, _ = lib.load_look(version["_id"]) # Get only the node ids and paths related to this asset # And get the shader edits the look supplies asset_nodes_by_id = { node_id: nodes_by_id[node_id] for node_id in node_ids } edits = list( maya_lib.iter_shader_edits( relationships, shadernodes, asset_nodes_by_id ) ) # Create assignments assignments = {} for edit in edits: if edit["action"] == "assign": nodes = edit["nodes"] shader = edit["shader"] if not cmds.ls(shader, type="shadingEngine"): print("Skipping non-shader: %s" % shader) continue inputs = cmds.listConnections( shader + ".surfaceShader", source=True) if not inputs: print("Shading engine missing material: %s" % shader) # Strip off component assignments for i, node in enumerate(nodes): if "." in node: log.warning( ("Converting face assignment to full object " "assignment. This conversion can be lossy: " "{}").format(node)) nodes[i] = node.split(".")[0] material = inputs[0] assignments[material] = nodes assign_vrayproxy_shaders(vrayproxy, assignments) ================================================ FILE: openpype/hosts/maya/tools/mayalookassigner/widgets.py ================================================ import logging from collections import defaultdict from qtpy import QtWidgets, QtCore from openpype.client import get_asset_name_identifier from openpype.tools.utils.models import TreeModel from openpype.tools.utils.lib import ( preserve_expanded_rows, preserve_selection, ) from .models import ( AssetModel, LookModel ) from . import commands from .views import View from maya import cmds class AssetOutliner(QtWidgets.QWidget): refreshed = QtCore.Signal() selection_changed = QtCore.Signal() def __init__(self, parent=None): super(AssetOutliner, self).__init__(parent) title = QtWidgets.QLabel("Assets", self) title.setAlignment(QtCore.Qt.AlignCenter) title.setStyleSheet("font-weight: bold; font-size: 12px") model = AssetModel() view = View(self) view.setModel(model) view.customContextMenuRequested.connect(self.right_mouse_menu) view.setSortingEnabled(False) view.setHeaderHidden(True) view.setIndentation(10) from_all_asset_btn = QtWidgets.QPushButton( "Get All Assets", self ) from_selection_btn = QtWidgets.QPushButton( "Get Assets From Selection", self ) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(title) layout.addWidget(from_all_asset_btn) layout.addWidget(from_selection_btn) layout.addWidget(view) # Build connections from_selection_btn.clicked.connect(self.get_selected_assets) from_all_asset_btn.clicked.connect(self.get_all_assets) selection_model = view.selectionModel() selection_model.selectionChanged.connect(self.selection_changed) self.view = view self.model = model self.log = logging.getLogger(__name__) def clear(self): self.model.clear() # fix looks remaining visible when no items present after "refresh" # todo: figure out why this workaround is needed. self.selection_changed.emit() def add_items(self, items): """Add new items to the outliner""" self.model.add_items(items) self.refreshed.emit() def get_selected_items(self): """Get current selected items from view Returns: list: list of dictionaries """ selection_model = self.view.selectionModel() return [row.data(TreeModel.ItemRole) for row in selection_model.selectedRows(0)] def get_all_assets(self): """Add all items from the current scene""" with preserve_expanded_rows(self.view): with preserve_selection(self.view): self.clear() nodes = commands.get_all_asset_nodes() items = commands.create_items_from_nodes(nodes) self.add_items(items) return len(items) > 0 def get_selected_assets(self): """Add all selected items from the current scene""" with preserve_expanded_rows(self.view): with preserve_selection(self.view): self.clear() nodes = commands.get_selected_nodes() items = commands.create_items_from_nodes(nodes) self.add_items(items) def get_nodes(self, selection=False): """Find the nodes in the current scene per asset.""" items = self.get_selected_items() # Collect all nodes by hash (optimization) if not selection: nodes = cmds.ls(dag=True, long=True) else: nodes = commands.get_selected_nodes() id_nodes = commands.create_asset_id_hash(nodes) # Collect the asset item entries per asset # and collect the namespaces we'd like to apply assets = {} asset_namespaces = defaultdict(set) for item in items: asset_id = str(item["asset"]["_id"]) asset_name = get_asset_name_identifier(item["asset"]) asset_namespaces[asset_name].add(item.get("namespace")) if asset_name in assets: continue assets[asset_name] = item assets[asset_name]["nodes"] = id_nodes.get(asset_id, []) # Filter nodes to namespace (if only namespaces were selected) for asset_name in assets: namespaces = asset_namespaces[asset_name] # When None is present there should be no filtering if None in namespaces: continue # Else only namespaces are selected and *not* the top entry so # we should filter to only those namespaces. nodes = assets[asset_name]["nodes"] nodes = [node for node in nodes if commands.get_namespace_from_node(node) in namespaces] assets[asset_name]["nodes"] = nodes return assets def select_asset_from_items(self): """Select nodes from listed asset""" items = self.get_nodes(selection=False) nodes = [] for item in items.values(): nodes.extend(item["nodes"]) commands.select(nodes) def right_mouse_menu(self, pos): """Build RMB menu for asset outliner""" active = self.view.currentIndex() # index under mouse active = active.sibling(active.row(), 0) # get first column globalpos = self.view.viewport().mapToGlobal(pos) menu = QtWidgets.QMenu(self.view) # Direct assignment apply_action = QtWidgets.QAction(menu, text="Select nodes") apply_action.triggered.connect(self.select_asset_from_items) if not active.isValid(): apply_action.setEnabled(False) menu.addAction(apply_action) menu.exec_(globalpos) class LookOutliner(QtWidgets.QWidget): menu_apply_action = QtCore.Signal() def __init__(self, parent=None): super(LookOutliner, self).__init__(parent) # Looks from database title = QtWidgets.QLabel("Looks", self) title.setAlignment(QtCore.Qt.AlignCenter) title.setStyleSheet("font-weight: bold; font-size: 12px") title.setAlignment(QtCore.Qt.AlignCenter) model = LookModel() # Proxy for dynamic sorting proxy = QtCore.QSortFilterProxyModel() proxy.setSourceModel(model) view = View(self) view.setModel(proxy) view.setMinimumHeight(180) view.setToolTip("Use right mouse button menu for direct actions") view.customContextMenuRequested.connect(self.right_mouse_menu) view.sortByColumn(0, QtCore.Qt.AscendingOrder) # look manager layout layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(10) layout.addWidget(title) layout.addWidget(view) self.view = view self.model = model def clear(self): self.model.clear() def add_items(self, items): self.model.add_items(items) def get_selected_items(self): """Get current selected items from view Returns: list: list of dictionaries """ items = [i.data(TreeModel.ItemRole) for i in self.view.get_indices()] return [item for item in items if item is not None] def right_mouse_menu(self, pos): """Build RMB menu for look view""" active = self.view.currentIndex() # index under mouse active = active.sibling(active.row(), 0) # get first column globalpos = self.view.viewport().mapToGlobal(pos) if not active.isValid(): return menu = QtWidgets.QMenu(self.view) # Direct assignment apply_action = QtWidgets.QAction(menu, text="Assign looks..") apply_action.triggered.connect(self.menu_apply_action) menu.addAction(apply_action) menu.exec_(globalpos) ================================================ FILE: openpype/hosts/nuke/__init__.py ================================================ from .addon import ( NUKE_ROOT_DIR, NukeAddon, ) __all__ = ( "NUKE_ROOT_DIR", "NukeAddon", ) ================================================ FILE: openpype/hosts/nuke/addon.py ================================================ import os import platform from openpype.modules import OpenPypeModule, IHostAddon NUKE_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) class NukeAddon(OpenPypeModule, IHostAddon): name = "nuke" host_name = "nuke" def initialize(self, module_settings): self.enabled = True def add_implementation_envs(self, env, _app): # Add requirements to NUKE_PATH new_nuke_paths = [ os.path.join(NUKE_ROOT_DIR, "startup") ] old_nuke_path = env.get("NUKE_PATH") or "" for path in old_nuke_path.split(os.pathsep): if not path: continue norm_path = os.path.normpath(path) if norm_path not in new_nuke_paths: new_nuke_paths.append(norm_path) env["NUKE_PATH"] = os.pathsep.join(new_nuke_paths) # Remove auto screen scale factor for Qt # - let Nuke decide it's value env.pop("QT_AUTO_SCREEN_SCALE_FACTOR", None) # Remove tkinter library paths if are set env.pop("TK_LIBRARY", None) env.pop("TCL_LIBRARY", None) # Add vendor to PYTHONPATH python_path = env["PYTHONPATH"] python_path_parts = [] if python_path: python_path_parts = python_path.split(os.pathsep) vendor_path = os.path.join(NUKE_ROOT_DIR, "vendor") python_path_parts.insert(0, vendor_path) env["PYTHONPATH"] = os.pathsep.join(python_path_parts) # Set default values if are not already set via settings defaults = { "LOGLEVEL": "DEBUG" } for key, value in defaults.items(): if not env.get(key): env[key] = value # Try to add QuickTime to PATH quick_time_path = "C:/Program Files (x86)/QuickTime/QTSystem" if platform.system() == "windows" and os.path.exists(quick_time_path): path_value = env.get("PATH") or "" path_paths = [ path for path in path_value.split(os.pathsep) if path ] path_paths.append(quick_time_path) env["PATH"] = os.pathsep.join(path_paths) def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] return [ os.path.join(NUKE_ROOT_DIR, "hooks") ] def get_workfile_extensions(self): return [".nk"] ================================================ FILE: openpype/hosts/nuke/api/__init__.py ================================================ from .workio import ( file_extensions, has_unsaved_changes, save_file, open_file, current_file, work_root, ) from .command import ( viewer_update_and_undo_stop ) from .plugin import ( NukeCreator, NukeWriteCreator, NukeCreatorError, OpenPypeCreator, get_instance_group_node_childs, get_colorspace_from_node ) from .pipeline import ( NukeHost, ls, list_instances, remove_instance, select_instance, containerise, parse_container, update_container, ) from .lib import ( INSTANCE_DATA_KNOB, ROOT_DATA_KNOB, maintained_selection, reset_selection, select_nodes, get_view_process_node, duplicate_node, convert_knob_value_to_correct_type, get_node_data, set_node_data, update_node_data, create_write_node, link_knobs ) from .utils import ( colorspace_exists_on_node, get_colorspace_list ) from .actions import ( SelectInvalidAction, SelectInstanceNodeAction ) __all__ = ( "file_extensions", "has_unsaved_changes", "save_file", "open_file", "current_file", "work_root", "viewer_update_and_undo_stop", "NukeCreator", "NukeWriteCreator", "NukeCreatorError", "OpenPypeCreator", "NukeHost", "get_instance_group_node_childs", "get_colorspace_from_node", "ls", "list_instances", "remove_instance", "select_instance", "containerise", "parse_container", "update_container", "INSTANCE_DATA_KNOB", "ROOT_DATA_KNOB", "maintained_selection", "reset_selection", "select_nodes", "get_view_process_node", "duplicate_node", "convert_knob_value_to_correct_type", "get_node_data", "set_node_data", "update_node_data", "create_write_node", "link_knobs", "colorspace_exists_on_node", "get_colorspace_list", "SelectInvalidAction", "SelectInstanceNodeAction" ) ================================================ FILE: openpype/hosts/nuke/api/actions.py ================================================ import pyblish.api from openpype.pipeline.publish import get_errored_instances_from_context from .lib import ( reset_selection, select_nodes ) class SelectInvalidAction(pyblish.api.Action): """Select invalid nodes in Nuke when plug-in failed. To retrieve the invalid nodes this assumes a static `get_invalid()` method is available on the plugin. """ label = "Select invalid nodes" on = "failed" # This action is only available on a failed plug-in icon = "search" # Icon from Awesome Icon def process(self, context, plugin): errored_instances = get_errored_instances_from_context(context, plugin=plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes..") invalid = set() for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: if isinstance(invalid_nodes, (list, tuple)): invalid.update(invalid_nodes) else: self.log.warning("Plug-in returned to be invalid, " "but has no selectable nodes.") if invalid: self.log.info("Selecting invalid nodes: {}".format(invalid)) reset_selection() select_nodes(invalid) else: self.log.info("No invalid nodes found.") class SelectInstanceNodeAction(pyblish.api.Action): """Select instance node for failed plugin.""" label = "Select instance node" on = "failed" # This action is only available on a failed plug-in icon = "mdi.cursor-default-click" def process(self, context, plugin): # Get the errored instances for the plug-in errored_instances = get_errored_instances_from_context( context, plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding instance nodes..") nodes = set() for instance in errored_instances: instance_node = instance.data.get("transientData", {}).get("node") if not instance_node: raise RuntimeError( "No transientData['node'] found on instance: {}".format( instance ) ) nodes.add(instance_node) if nodes: self.log.info("Selecting instance nodes: {}".format(nodes)) reset_selection() select_nodes(nodes) else: self.log.info("No instance nodes found.") ================================================ FILE: openpype/hosts/nuke/api/command.py ================================================ import logging import contextlib import nuke log = logging.getLogger(__name__) @contextlib.contextmanager def viewer_update_and_undo_stop(): """Lock viewer from updating and stop recording undo steps""" try: # stop active viewer to update any change viewer = nuke.activeViewer() if viewer: viewer.stop() else: log.warning("No available active Viewer") nuke.Undo.disable() yield finally: nuke.Undo.enable() ================================================ FILE: openpype/hosts/nuke/api/constants.py ================================================ import os ASSIST = bool(os.getenv("NUKEASSIST")) ================================================ FILE: openpype/hosts/nuke/api/gizmo_menu.py ================================================ import os import re import nuke from openpype.lib import Logger log = Logger.get_logger(__name__) class GizmoMenu(): def __init__(self, title, icon=None): self.toolbar = self._create_toolbar_menu( title, icon=icon ) self._script_actions = [] def _create_toolbar_menu(self, name, icon=None): nuke_node_menu = nuke.menu("Nodes") return nuke_node_menu.addMenu( name, icon=icon ) def _make_menu_path(self, path, icon=None): parent = self.toolbar for folder in re.split(r"/|\\", path): if not folder: continue existing_menu = parent.findItem(folder) if existing_menu: parent = existing_menu else: parent = parent.addMenu(folder, icon=icon) return parent def build_from_configuration(self, configuration): for menu in configuration: # Construct parent path else parent is toolbar parent = self.toolbar gizmo_toolbar_path = menu.get("gizmo_toolbar_path") if gizmo_toolbar_path: parent = self._make_menu_path(gizmo_toolbar_path) for item in menu["sub_gizmo_list"]: assert isinstance(item, dict), "Configuration is wrong!" if not item.get("title"): continue item_type = item.get("sourcetype") if item_type == "python": parent.addCommand( item["title"], command=str(item["command"]), icon=item.get("icon"), shortcut=item.get("shortcut") ) elif item_type == "file": parent.addCommand( item['title'], "nuke.createNode('{}')".format(item.get('file_name')), shortcut=item.get('shortcut') ) # add separator # Special behavior for separators elif item_type == "separator": parent.addSeparator() # add submenu # items should hold a collection of submenu items (dict) elif item_type == "menu": # assert "items" in item, "Menu is missing 'items' key" parent.addMenu( item['title'], icon=item.get('icon') ) def add_gizmo_path(self, gizmo_paths): for gizmo_path in gizmo_paths: if os.path.isdir(gizmo_path): for folder in os.listdir(gizmo_path): if os.path.isdir(os.path.join(gizmo_path, folder)): nuke.pluginAddPath(os.path.join(gizmo_path, folder)) nuke.pluginAddPath(gizmo_path) else: log.warning("This path doesn't exist: {}".format(gizmo_path)) ================================================ FILE: openpype/hosts/nuke/api/lib.py ================================================ import os from pprint import pformat import re import json import six import functools import warnings import platform import tempfile import contextlib from collections import OrderedDict import nuke from qtpy import QtCore, QtWidgets from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_project, get_asset_by_name, get_versions, get_last_versions, get_representations, ) from openpype.host import HostDirmap from openpype.tools.utils import host_tools from openpype.pipeline.workfile.workfile_template_builder import ( TemplateProfileNotFound ) from openpype.lib import ( env_value_to_bool, Logger, get_version_from_path, StringTemplate, ) from openpype.settings import ( get_project_settings, get_current_project_settings, ) from openpype.modules import ModulesManager from openpype.pipeline.template_data import get_template_data_with_names from openpype.pipeline import ( discover_legacy_creator_plugins, Anatomy, get_current_host_name, get_current_project_name, get_current_asset_name, ) from openpype.pipeline.context_tools import ( get_custom_workfile_template_from_session ) from openpype.pipeline.colorspace import get_imageio_config from openpype.pipeline.workfile import BuildWorkfile from . import gizmo_menu from .constants import ASSIST from .workio import save_file from .utils import get_node_outputs log = Logger.get_logger(__name__) _NODE_TAB_NAME = "{}".format(os.getenv("AVALON_LABEL") or "Avalon") AVALON_LABEL = os.getenv("AVALON_LABEL") or "Avalon" AVALON_TAB = "{}".format(AVALON_LABEL) AVALON_DATA_GROUP = "{}DataGroup".format(AVALON_LABEL.capitalize()) EXCLUDED_KNOB_TYPE_ON_READ = ( 20, # Tab Knob 26, # Text Knob (But for backward compatibility, still be read # if value is not an empty string.) ) JSON_PREFIX = "JSON:::" ROOT_DATA_KNOB = "publish_context" INSTANCE_DATA_KNOB = "publish_instance" class DeprecatedWarning(DeprecationWarning): pass def deprecated(new_destination): """Mark functions as deprecated. It will result in a warning being emitted when the function is used. """ func = None if callable(new_destination): func = new_destination new_destination = None def _decorator(decorated_func): if new_destination is None: warning_message = ( " Please check content of deprecated function to figure out" " possible replacement." ) else: warning_message = " Please replace your usage with '{}'.".format( new_destination ) @functools.wraps(decorated_func) def wrapper(*args, **kwargs): warnings.simplefilter("always", DeprecatedWarning) warnings.warn( ( "Call to deprecated function '{}'" "\nFunction was moved or removed.{}" ).format(decorated_func.__name__, warning_message), category=DeprecatedWarning, stacklevel=4 ) return decorated_func(*args, **kwargs) return wrapper if func is None: return _decorator return _decorator(func) class Context: main_window = None context_action_item = None project_name = os.getenv("AVALON_PROJECT") # Workfile related code workfiles_launched = False workfiles_tool_timer = None # Seems unused _project_doc = None def get_main_window(): """Acquire Nuke's main window""" if Context.main_window is None: top_widgets = QtWidgets.QApplication.topLevelWidgets() name = "Foundry::UI::DockMainWindow" for widget in top_widgets: if ( widget.inherits("QMainWindow") and widget.metaObject().className() == name ): Context.main_window = widget break return Context.main_window def set_node_data(node, knobname, data): """Write data to node invisible knob Will create new in case it doesn't exists or update the one already created. Args: node (nuke.Node): node object knobname (str): knob name data (dict): data to be stored in knob """ # if exists then update data if knobname in node.knobs(): log.debug("Updating knobname `{}` on node `{}`".format( knobname, node.name() )) update_node_data(node, knobname, data) return log.debug("Creating knobname `{}` on node `{}`".format( knobname, node.name() )) # else create new knob_value = JSON_PREFIX + json.dumps(data) knob = nuke.String_Knob(knobname) knob.setValue(knob_value) knob.setFlag(nuke.INVISIBLE) node.addKnob(knob) def get_node_data(node, knobname): """Read data from node. Args: node (nuke.Node): node object knobname (str): knob name Returns: dict: data stored in knob """ if knobname not in node.knobs(): return rawdata = node[knobname].getValue() if ( isinstance(rawdata, six.string_types) and rawdata.startswith(JSON_PREFIX) ): try: return json.loads(rawdata[len(JSON_PREFIX):]) except json.JSONDecodeError: return def update_node_data(node, knobname, data): """Update already present data. Args: node (nuke.Node): node object knobname (str): knob name data (dict): data to update knob value """ knob = node[knobname] node_data = get_node_data(node, knobname) or {} node_data.update(data) knob_value = JSON_PREFIX + json.dumps(node_data) knob.setValue(knob_value) class Knobby(object): """[DEPRECATED] For creating knob which it's type isn't mapped in `create_knobs` Args: type (string): Nuke knob type name value: Value to be set with `Knob.setValue`, put `None` if not required flags (list, optional): Knob flags to be set with `Knob.setFlag` *args: Args other than knob name for initializing knob class """ def __init__(self, type, value, flags=None, *args): self.type = type self.value = value self.flags = flags or [] self.args = args def create(self, name, nice=None): knob_cls = getattr(nuke, self.type) knob = knob_cls(name, nice, *self.args) if self.value is not None: knob.setValue(self.value) for flag in self.flags: knob.setFlag(flag) return knob @staticmethod def nice_naming(key): """Convert camelCase name into UI Display Name""" words = re.findall('[A-Z][^A-Z]*', key[0].upper() + key[1:]) return " ".join(words) def create_knobs(data, tab=None): """Create knobs by data Depending on the type of each dict value and creates the correct Knob. Mapped types: bool: nuke.Boolean_Knob int: nuke.Int_Knob float: nuke.Double_Knob list: nuke.Enumeration_Knob six.string_types: nuke.String_Knob dict: If it's a nested dict (all values are dict), will turn into A tabs group. Or just a knobs group. Args: data (dict): collection of attributes and their value tab (string, optional): Knobs' tab name Returns: list: A list of `nuke.Knob` objects """ def nice_naming(key): """Convert camelCase name into UI Display Name""" words = re.findall('[A-Z][^A-Z]*', key[0].upper() + key[1:]) return " ".join(words) # Turn key-value pairs into knobs knobs = list() if tab: knobs.append(nuke.Tab_Knob(tab)) for key, value in data.items(): # Knob name if isinstance(key, tuple): name, nice = key else: name, nice = key, nice_naming(key) # Create knob by value type if isinstance(value, Knobby): knobby = value knob = knobby.create(name, nice) elif isinstance(value, float): knob = nuke.Double_Knob(name, nice) knob.setValue(value) elif isinstance(value, bool): knob = nuke.Boolean_Knob(name, nice) knob.setValue(value) knob.setFlag(nuke.STARTLINE) elif isinstance(value, int): knob = nuke.Int_Knob(name, nice) knob.setValue(value) elif isinstance(value, six.string_types): knob = nuke.String_Knob(name, nice) knob.setValue(value) elif isinstance(value, list): knob = nuke.Enumeration_Knob(name, nice, value) elif isinstance(value, dict): if all(isinstance(v, dict) for v in value.values()): # Create a group of tabs begain = nuke.BeginTabGroup_Knob() end = nuke.EndTabGroup_Knob() begain.setName(name) end.setName(name + "_End") knobs.append(begain) for k, v in value.items(): knobs += create_knobs(v, tab=k) knobs.append(end) else: # Create a group of knobs knobs.append(nuke.Tab_Knob( name, nice, nuke.TABBEGINCLOSEDGROUP)) knobs += create_knobs(value) knobs.append( nuke.Tab_Knob(name + "_End", nice, nuke.TABENDGROUP)) continue else: raise TypeError("Unsupported type: %r" % type(value)) knobs.append(knob) return knobs def imprint(node, data, tab=None): """Store attributes with value on node Parse user data into Node knobs. Use `collections.OrderedDict` to ensure knob order. Args: node(nuke.Node): node object from Nuke data(dict): collection of attributes and their value Returns: None Examples: ``` import nuke from openpype.hosts.nuke.api import lib node = nuke.createNode("NoOp") data = { # Regular type of attributes "myList": ["x", "y", "z"], "myBool": True, "myFloat": 0.1, "myInt": 5, # Creating non-default imprint type of knob "MyFilePath": lib.Knobby("File_Knob", "/file/path"), "divider": lib.Knobby("Text_Knob", ""), # Manual nice knob naming ("my_knob", "Nice Knob Name"): "some text", # dict type will be created as knob group "KnobGroup": { "knob1": 5, "knob2": "hello", "knob3": ["a", "b"], }, # Nested dict will be created as tab group "TabGroup": { "tab1": {"count": 5}, "tab2": {"isGood": True}, "tab3": {"direction": ["Left", "Right"]}, }, } lib.imprint(node, data, tab="Demo") ``` """ for knob in create_knobs(data, tab): node.addKnob(knob) @deprecated def add_publish_knob(node): """[DEPRECATED] Add Publish knob to node Arguments: node (nuke.Node): nuke node to be processed Returns: node (nuke.Node): processed nuke node """ if "publish" not in node.knobs(): body = OrderedDict() body[("divd", "Publishing")] = Knobby("Text_Knob", '') body["publish"] = True imprint(node, body) return node @deprecated("openpype.hosts.nuke.api.lib.set_node_data") def set_avalon_knob_data(node, data=None, prefix="avalon:"): """[DEPRECATED] Sets data into nodes's avalon knob This function is still used but soon will be deprecated. Use `set_node_data` instead. Arguments: node (nuke.Node): Nuke node to imprint with data, data (dict, optional): Data to be imprinted into AvalonTab prefix (str, optional): filtering prefix Returns: node (nuke.Node) Examples: data = { 'asset': 'sq020sh0280', 'family': 'render', 'subset': 'subsetMain' } """ data = data or dict() create = OrderedDict() tab_name = AVALON_TAB editable = ["asset", "subset", "name", "namespace"] existed_knobs = node.knobs() for key, value in data.items(): knob_name = prefix + key gui_name = key if knob_name in existed_knobs: # Set value try: node[knob_name].setValue(value) except TypeError: node[knob_name].setValue(str(value)) else: # New knob name = (knob_name, gui_name) # Hide prefix on GUI if key in editable: create[name] = value else: create[name] = Knobby("String_Knob", str(value), flags=[nuke.READ_ONLY]) if tab_name in existed_knobs: tab_name = None else: tab = OrderedDict() warn = Knobby("Text_Knob", "Warning! Do not change following data!") divd = Knobby("Text_Knob", "") head = [ (("warn", ""), warn), (("divd", ""), divd), ] tab[AVALON_DATA_GROUP] = OrderedDict(head + list(create.items())) create = tab imprint(node, create, tab=tab_name) return node @deprecated("openpype.hosts.nuke.api.lib.get_node_data") def get_avalon_knob_data(node, prefix="avalon:", create=True): """[DEPRECATED] Gets a data from nodes's avalon knob This function is still used but soon will be deprecated. Use `get_node_data` instead. Arguments: node (obj): Nuke node to search for data, prefix (str, optional): filtering prefix Returns: data (dict) """ data = {} if AVALON_TAB not in node.knobs(): return data # check if lists if not isinstance(prefix, list): prefix = [prefix] # loop prefix for p in prefix: # check if the node is avalon tracked try: # check if data available on the node test = node[AVALON_DATA_GROUP].value() log.debug("Only testing if data available: `{}`".format(test)) except NameError as e: # if it doesn't then create it log.debug("Creating avalon knob: `{}`".format(e)) if create: node = set_avalon_knob_data(node) return get_avalon_knob_data(node) return {} # get data from filtered knobs data.update({k.replace(p, ''): node[k].value() for k in node.knobs().keys() if p in k}) return data @deprecated def fix_data_for_node_create(data): """[DEPRECATED] Fixing data to be used for nuke knobs """ for k, v in data.items(): if isinstance(v, six.text_type): data[k] = str(v) if str(v).startswith("0x"): data[k] = int(v, 16) return data @deprecated def add_write_node_legacy(name, **kwarg): """[DEPRECATED] Adding nuke write node Arguments: name (str): nuke node name kwarg (attrs): data for nuke knobs Returns: node (obj): nuke write node """ use_range_limit = kwarg.get("use_range_limit", None) w = nuke.createNode( "Write", "name {}".format(name), inpanel=False ) w["file"].setValue(kwarg["file"]) for k, v in kwarg.items(): if "frame_range" in k: continue log.info([k, v]) try: w[k].setValue(v) except KeyError as e: log.debug(e) continue if use_range_limit: w["use_limit"].setValue(True) w["first"].setValue(kwarg["frame_range"][0]) w["last"].setValue(kwarg["frame_range"][1]) return w def add_write_node(name, file_path, knobs, **kwarg): """Adding nuke write node Arguments: name (str): nuke node name kwarg (attrs): data for nuke knobs Returns: node (obj): nuke write node """ use_range_limit = kwarg.get("use_range_limit", None) w = nuke.createNode( "Write", "name {}".format(name), inpanel=False ) w["file"].setValue(file_path) # finally add knob overrides set_node_knobs_from_settings(w, knobs, **kwarg) if use_range_limit: w["use_limit"].setValue(True) w["first"].setValue(kwarg["frame_range"][0]) w["last"].setValue(kwarg["frame_range"][1]) return w def read_avalon_data(node): """Return user-defined knobs from given `node` Args: node (nuke.Node): Nuke node object Returns: list: A list of nuke.Knob object """ def compat_prefixed(knob_name): if knob_name.startswith("avalon:"): return knob_name[len("avalon:"):] elif knob_name.startswith("ak:"): return knob_name[len("ak:"):] data = dict() pattern = ("(?<=addUserKnob {)" "([0-9]*) (\\S*)" # Matching knob type and knob name "(?=[ |}])") tcl_script = node.writeKnobs(nuke.WRITE_USER_KNOB_DEFS) result = re.search(pattern, tcl_script) if result: first_user_knob = result.group(2) # Collect user knobs from the end of the knob list for knob in reversed(node.allKnobs()): knob_name = knob.name() if not knob_name: # Ignore unnamed knob continue knob_type = nuke.knob(knob.fullyQualifiedName(), type=True) value = knob.value() if ( knob_type not in EXCLUDED_KNOB_TYPE_ON_READ or # For compating read-only string data that imprinted # by `nuke.Text_Knob`. (knob_type == 26 and value) ): key = compat_prefixed(knob_name) if key is not None: data[key] = value if knob_name == first_user_knob: break return data def get_node_path(path, padding=4): """Get filename for the Nuke write with padded number as '#' Arguments: path (str): The path to render to. Returns: tuple: head, padding, tail (extension) Examples: >>> get_frame_path("test.exr") ('test', 4, '.exr') >>> get_frame_path("filename.#####.tif") ('filename.', 5, '.tif') >>> get_frame_path("foobar##.tif") ('foobar', 2, '.tif') >>> get_frame_path("foobar_%08d.tif") ('foobar_', 8, '.tif') """ filename, ext = os.path.splitext(path) # Find a final number group if '%' in filename: match = re.match('.*?(%[0-9]+d)$', filename) if match: padding = int(match.group(1).replace('%', '').replace('d', '')) # remove number from end since fusion # will swap it with the frame number filename = filename.replace(match.group(1), '') elif '#' in filename: match = re.match('.*?(#+)$', filename) if match: padding = len(match.group(1)) # remove number from end since fusion # will swap it with the frame number filename = filename.replace(match.group(1), '') return filename, padding, ext def get_nuke_imageio_settings(): return get_project_settings(Context.project_name)["nuke"]["imageio"] @deprecated("openpype.hosts.nuke.api.lib.get_nuke_imageio_settings") def get_created_node_imageio_setting_legacy(nodeclass, creator, subset): '''[DEPRECATED] Get preset data for dataflow (fileType, compression, bitDepth) ''' assert any([creator, nodeclass]), nuke.message( "`{}`: Missing mandatory kwargs `host`, `cls`".format(__file__)) imageio_nodes = get_nuke_imageio_settings()["nodes"] required_nodes = imageio_nodes["requiredNodes"] # HACK: for backward compatibility this needs to be optional override_nodes = imageio_nodes.get("overrideNodes", []) imageio_node = None for node in required_nodes: log.info(node) if ( nodeclass in node["nukeNodeClass"] and creator in node["plugins"] ): imageio_node = node break log.debug("__ imageio_node: {}".format(imageio_node)) # find matching override node override_imageio_node = None for onode in override_nodes: log.info(onode) if nodeclass not in node["nukeNodeClass"]: continue if creator not in node["plugins"]: continue if ( onode["subsets"] and not any( re.search(s.lower(), subset.lower()) for s in onode["subsets"] ) ): continue override_imageio_node = onode break log.debug("__ override_imageio_node: {}".format(override_imageio_node)) # add overrides to imageio_node if override_imageio_node: # get all knob names in imageio_node knob_names = [k["name"] for k in imageio_node["knobs"]] for oknob in override_imageio_node["knobs"]: for knob in imageio_node["knobs"]: # override matching knob name if oknob["name"] == knob["name"]: log.debug( "_ overriding knob: `{}` > `{}`".format( knob, oknob )) if not oknob["value"]: # remove original knob if no value found in oknob imageio_node["knobs"].remove(knob) else: # override knob value with oknob's knob["value"] = oknob["value"] # add missing knobs into imageio_node if oknob["name"] not in knob_names: log.debug( "_ adding knob: `{}`".format(oknob)) imageio_node["knobs"].append(oknob) knob_names.append(oknob["name"]) log.info("ImageIO node: {}".format(imageio_node)) return imageio_node def get_imageio_node_setting(node_class, plugin_name, subset): ''' Get preset data for dataflow (fileType, compression, bitDepth) ''' imageio_nodes = get_nuke_imageio_settings()["nodes"] required_nodes = imageio_nodes["requiredNodes"] imageio_node = None for node in required_nodes: log.info(node) if ( node_class in node["nukeNodeClass"] and plugin_name in node["plugins"] ): imageio_node = node break log.debug("__ imageio_node: {}".format(imageio_node)) if not imageio_node: return # find overrides and update knobs with them get_imageio_node_override_setting( node_class, plugin_name, subset, imageio_node["knobs"] ) log.info("ImageIO node: {}".format(imageio_node)) return imageio_node def get_imageio_node_override_setting( node_class, plugin_name, subset, knobs_settings ): ''' Get imageio node overrides from settings ''' imageio_nodes = get_nuke_imageio_settings()["nodes"] override_nodes = imageio_nodes["overrideNodes"] # find matching override node override_imageio_node = None for onode in override_nodes: log.debug("__ onode: {}".format(onode)) log.debug("__ subset: {}".format(subset)) if node_class not in onode["nukeNodeClass"]: continue if plugin_name not in onode["plugins"]: continue if ( onode["subsets"] and not any( re.search(s.lower(), subset.lower()) for s in onode["subsets"] ) ): continue override_imageio_node = onode break log.debug("__ override_imageio_node: {}".format(override_imageio_node)) # add overrides to imageio_node if override_imageio_node: # get all knob names in imageio_node knob_names = [k["name"] for k in knobs_settings] for oknob in override_imageio_node["knobs"]: for knob in knobs_settings: # override matching knob name if oknob["name"] == knob["name"]: log.debug( "_ overriding knob: `{}` > `{}`".format( knob, oknob )) if not oknob["value"]: # remove original knob if no value found in oknob knobs_settings.remove(knob) else: # override knob value with oknob's knob["value"] = oknob["value"] # add missing knobs into imageio_node if oknob["name"] not in knob_names: log.debug( "_ adding knob: `{}`".format(oknob)) knobs_settings.append(oknob) knob_names.append(oknob["name"]) return knobs_settings def get_imageio_input_colorspace(filename): ''' Get input file colorspace based on regex in settings. ''' imageio_regex_inputs = ( get_nuke_imageio_settings()["regexInputs"]["inputs"]) preset_clrsp = None for regexInput in imageio_regex_inputs: if bool(re.search(regexInput["regex"], filename)): preset_clrsp = str(regexInput["colorspace"]) return preset_clrsp def get_view_process_node(): reset_selection() ipn_node = None for v_ in nuke.allNodes(filter="Viewer"): ipn = v_['input_process_node'].getValue() ipn_node = nuke.toNode(ipn) # skip if no input node is set if not ipn: continue if ipn == "VIEWER_INPUT" and not ipn_node: # since it is set by default we can ignore it # nobody usually use this but use it if # it exists in nodes continue if not ipn_node: # in case a Viewer node is transferred from # different workfile with old values raise NameError(( "Input process node name '{}' set in " "Viewer '{}' is doesn't exists in nodes" ).format(ipn, v_.name())) ipn_node.setSelected(True) if ipn_node: return duplicate_node(ipn_node) def on_script_load(): ''' Callback for ffmpeg support ''' if nuke.env["LINUX"]: nuke.tcl('load ffmpegReader') nuke.tcl('load ffmpegWriter') else: nuke.tcl('load movReader') nuke.tcl('load movWriter') def check_inventory_versions(): """ Actual version idetifier of Loaded containers Any time this function is run it will check all nodes and filter only Loader nodes for its version. It will get all versions from database and check if the node is having actual version. If not then it will color it to red. """ from .pipeline import parse_container # get all Loader nodes by avalon attribute metadata node_with_repre_id = [] repre_ids = set() # Find all containers and collect it's node and representation ids for node in nuke.allNodes(): container = parse_container(node) if container: node = nuke.toNode(container["objectName"]) avalon_knob_data = read_avalon_data(node) repre_id = avalon_knob_data["representation"] repre_ids.add(repre_id) node_with_repre_id.append((node, repre_id)) # Skip if nothing was found if not repre_ids: return project_name = get_current_project_name() # Find representations based on found containers repre_docs = get_representations( project_name, representation_ids=repre_ids, fields=["_id", "parent"] ) # Store representations by id and collect version ids repre_docs_by_id = {} version_ids = set() for repre_doc in repre_docs: # Use stringed representation id to match value in containers repre_id = str(repre_doc["_id"]) repre_docs_by_id[repre_id] = repre_doc version_ids.add(repre_doc["parent"]) version_docs = get_versions( project_name, version_ids, fields=["_id", "name", "parent"] ) # Store versions by id and collect subset ids version_docs_by_id = {} subset_ids = set() for version_doc in version_docs: version_docs_by_id[version_doc["_id"]] = version_doc subset_ids.add(version_doc["parent"]) # Query last versions based on subset ids last_versions_by_subset_id = get_last_versions( project_name, subset_ids=subset_ids, fields=["_id", "parent"] ) # Loop through collected container nodes and their representation ids for item in node_with_repre_id: # Some python versions of nuke can't unfold tuple in for loop node, repre_id = item repre_doc = repre_docs_by_id.get(repre_id) # Failsafe for not finding the representation. if not repre_doc: log.warning(( "Could not find the representation on node \"{}\"" ).format(node.name())) continue version_id = repre_doc["parent"] version_doc = version_docs_by_id.get(version_id) if not version_doc: log.warning(( "Could not find the version on node \"{}\"" ).format(node.name())) continue # Get last version based on subset id subset_id = version_doc["parent"] last_version = last_versions_by_subset_id[subset_id] # Check if last version is same as current version if last_version["_id"] == version_doc["_id"]: color_value = "0x4ecd25ff" else: color_value = "0xd84f20ff" node["tile_color"].setValue(int(color_value, 16)) def writes_version_sync(): ''' Callback synchronizing version of publishable write nodes ''' try: rootVersion = get_version_from_path(nuke.root().name()) padding = len(rootVersion) new_version = "v" + str("{" + ":0>{}".format(padding) + "}").format( int(rootVersion) ) log.debug("new_version: {}".format(new_version)) except Exception: return for each in nuke.allNodes(filter="Write"): # check if the node is avalon tracked if _NODE_TAB_NAME not in each.knobs(): continue avalon_knob_data = read_avalon_data(each) try: if avalon_knob_data["families"] not in ["render"]: log.debug(avalon_knob_data["families"]) continue node_file = each["file"].value() node_version = "v" + get_version_from_path(node_file) log.debug("node_version: {}".format(node_version)) node_new_file = node_file.replace(node_version, new_version) each["file"].setValue(node_new_file) if not os.path.isdir(os.path.dirname(node_new_file)): log.warning("Path does not exist! I am creating it.") os.makedirs(os.path.dirname(node_new_file)) except Exception as e: log.warning( "Write node: `{}` has no version in path: {}".format( each.name(), e)) def version_up_script(): ''' Raising working script's version ''' import nukescripts nukescripts.script_and_write_nodes_version_up() def check_subsetname_exists(nodes, subset_name): """ Checking if node is not already created to secure there is no duplicity Arguments: nodes (list): list of nuke.Node objects subset_name (str): name we try to find Returns: bool: True of False """ return next((True for n in nodes if subset_name in read_avalon_data(n).get("subset", "")), False) def format_anatomy(data): ''' Helping function for formatting of anatomy paths Arguments: data (dict): dictionary with attributes used for formatting Return: path (str) ''' project_name = get_current_project_name() anatomy = Anatomy(project_name) log.debug("__ anatomy.templates: {}".format(anatomy.templates)) padding = None if "frame_padding" in anatomy.templates.keys(): padding = int(anatomy.templates["frame_padding"]) elif "render" in anatomy.templates.keys(): padding = int( anatomy.templates["render"].get( "frame_padding" ) ) version = data.get("version", None) if not version: file = script_name() data["version"] = get_version_from_path(file) if AYON_SERVER_ENABLED: asset_name = data["folderPath"] else: asset_name = data["asset"] task_name = data["task"] host_name = get_current_host_name() context_data = get_template_data_with_names( project_name, asset_name, task_name, host_name ) data.update(context_data) data.update({ "subset": data["subset"], "family": data["family"], "frame": "#" * padding, }) return anatomy.format(data) def script_name(): ''' Returns nuke script path ''' return nuke.root().knob("name").value() def add_button_write_to_read(node): name = "createReadNode" label = "Read From Rendered" value = "import write_to_read;\ write_to_read.write_to_read(nuke.thisNode(), allow_relative=False)" knob = nuke.PyScript_Knob(name, label, value) knob.clearFlag(nuke.STARTLINE) node.addKnob(knob) def add_button_clear_rendered(node, path): name = "clearRendered" label = "Clear Rendered" value = "import clear_rendered;\ clear_rendered.clear_rendered(\"{}\")".format(path) knob = nuke.PyScript_Knob(name, label, value) node.addKnob(knob) def create_prenodes( prev_node, nodes_setting, plugin_name=None, subset=None, **kwargs ): last_node = None for_dependency = {} for name, node in nodes_setting.items(): # get attributes nodeclass = node["nodeclass"] knobs = node["knobs"] # create node now_node = nuke.createNode( nodeclass, "name {}".format(name), inpanel=False ) # add for dependency linking for_dependency[name] = { "node": now_node, "dependent": node["dependent"] } if all([plugin_name, subset]): # find imageio overrides get_imageio_node_override_setting( now_node.Class(), plugin_name, subset, knobs ) # add data to knob set_node_knobs_from_settings(now_node, knobs, **kwargs) # switch actual node to previous last_node = now_node for _node_name, node_prop in for_dependency.items(): if not node_prop["dependent"]: node_prop["node"].setInput( 0, prev_node) elif node_prop["dependent"] in for_dependency: _prev_node = for_dependency[node_prop["dependent"]]["node"] node_prop["node"].setInput( 0, _prev_node) else: log.warning("Dependency has wrong name of node: {}".format( node_prop )) return last_node def create_write_node( name, data, input=None, prenodes=None, linked_knobs=None, **kwargs ): ''' Creating write node which is group node Arguments: name (str): name of node data (dict): creator write instance data input (node)[optional]: selected node to connect to prenodes (dict)[optional]: nodes to be created before write with dependency review (bool)[optional]: adding review knob farm (bool)[optional]: rendering workflow target kwargs (dict)[optional]: additional key arguments for formatting Example: prenodes = { "nodeName": { "nodeclass": "Reformat", "dependent": [ following_node_01, ... ], "knobs": [ { "type": "text", "name": "knobname", "value": "knob value" }, ... ] }, ... } Return: node (obj): group node with avalon data as Knobs ''' prenodes = prenodes or {} # filtering variables plugin_name = data["creator"] subset = data["subset"] # get knob settings for write node imageio_writes = get_imageio_node_setting( node_class="Write", plugin_name=plugin_name, subset=subset ) for knob in imageio_writes["knobs"]: if knob["name"] == "file_type": ext = knob["value"] data.update({ "imageio_writes": imageio_writes, "ext": ext }) anatomy_filled = format_anatomy(data) # build file path to workfiles fdir = str(anatomy_filled["work"]["folder"]).replace("\\", "/") data["work"] = fdir fpath = StringTemplate(data["fpath_template"]).format_strict(data) # create directory if not os.path.isdir(os.path.dirname(fpath)): log.warning("Path does not exist! I am creating it.") os.makedirs(os.path.dirname(fpath)) GN = nuke.createNode("Group", "name {}".format(name)) prev_node = None with GN: if input: input_name = str(input.name()).replace(" ", "") # if connected input node was defined prev_node = nuke.createNode( "Input", "name {}".format(input_name), inpanel=False ) else: # generic input node connected to nothing prev_node = nuke.createNode( "Input", "name {}".format("rgba"), inpanel=False ) # creating pre-write nodes `prenodes` last_prenode = create_prenodes( prev_node, prenodes, plugin_name, subset, **kwargs ) if last_prenode: prev_node = last_prenode # creating write node write_node = now_node = add_write_node( "inside_{}".format(name), fpath, imageio_writes["knobs"], **data ) # connect to previous node now_node.setInput(0, prev_node) # switch actual node to previous prev_node = now_node now_node = nuke.createNode("Output", "name Output1", inpanel=False) # connect to previous node now_node.setInput(0, prev_node) # add divider GN.addKnob(nuke.Text_Knob('', 'Rendering')) # Add linked knobs. linked_knob_names = [] # add input linked knobs and create group only if any input if linked_knobs: linked_knob_names.append("_grp-start_") linked_knob_names.extend(linked_knobs) linked_knob_names.append("_grp-end_") linked_knob_names.append("Render") for _k_name in linked_knob_names: if "_grp-start_" in _k_name: knob = nuke.Tab_Knob( "rnd_attr", "Rendering attributes", nuke.TABBEGINCLOSEDGROUP) GN.addKnob(knob) elif "_grp-end_" in _k_name: knob = nuke.Tab_Knob( "rnd_attr_end", "Rendering attributes", nuke.TABENDGROUP) GN.addKnob(knob) else: if "___" in _k_name: # add divider GN.addKnob(nuke.Text_Knob("")) else: # add linked knob by _k_name link = nuke.Link_Knob("") link.makeLink(write_node.name(), _k_name) link.setName(_k_name) # make render if "Render" in _k_name: link.setLabel("Render Local") link.setFlag(0x1000) GN.addKnob(link) # adding write to read button add_button_write_to_read(GN) # adding write to read button add_button_clear_rendered(GN, os.path.dirname(fpath)) # set tile color tile_color = next( iter( k["value"] for k in imageio_writes["knobs"] if "tile_color" in k["name"] ), [255, 0, 0, 255] ) GN["tile_color"].setValue( color_gui_to_int(tile_color)) return GN @deprecated("openpype.hosts.nuke.api.lib.create_write_node") def create_write_node_legacy( name, data, input=None, prenodes=None, review=True, linked_knobs=None, farm=True ): ''' Creating write node which is group node Arguments: name (str): name of node data (dict): data to be imprinted input (node): selected node to connect to prenodes (list, optional): list of lists, definitions for nodes to be created before write review (bool): adding review knob Example: prenodes = [ { "nodeName": { "class": "" # string "knobs": [ ("knobName": value), ... ], "dependent": [ following_node_01, ... ] } }, ... ] Return: node (obj): group node with avalon data as Knobs ''' knob_overrides = data.get("knobs", []) nodeclass = data["nodeclass"] creator = data["creator"] subset = data["subset"] imageio_writes = get_created_node_imageio_setting_legacy( nodeclass, creator, subset ) for knob in imageio_writes["knobs"]: if knob["name"] == "file_type": representation = knob["value"] host_name = get_current_host_name() try: data.update({ "app": host_name, "imageio_writes": imageio_writes, "representation": representation, }) anatomy_filled = format_anatomy(data) except Exception as e: msg = "problem with resolving anatomy template: {}".format(e) log.error(msg) nuke.message(msg) # build file path to workfiles fdir = str(anatomy_filled["work"]["folder"]).replace("\\", "/") fpath = data["fpath_template"].format( work=fdir, version=data["version"], subset=data["subset"], frame=data["frame"], ext=representation ) # create directory if not os.path.isdir(os.path.dirname(fpath)): log.warning("Path does not exist! I am creating it.") os.makedirs(os.path.dirname(fpath)) _data = OrderedDict({ "file": fpath }) # adding dataflow template log.debug("imageio_writes: `{}`".format(imageio_writes)) for knob in imageio_writes["knobs"]: _data[knob["name"]] = knob["value"] _data = fix_data_for_node_create(_data) log.debug("_data: `{}`".format(_data)) if "frame_range" in data.keys(): _data["frame_range"] = data.get("frame_range", None) log.debug("_data[frame_range]: `{}`".format(_data["frame_range"])) GN = nuke.createNode("Group", "name {}".format(name)) prev_node = None with GN: if input: input_name = str(input.name()).replace(" ", "") # if connected input node was defined prev_node = nuke.createNode( "Input", "name {}".format(input_name)) else: # generic input node connected to nothing prev_node = nuke.createNode( "Input", "name {}".format("rgba"), inpanel=False ) # creating pre-write nodes `prenodes` if prenodes: for node in prenodes: # get attributes pre_node_name = node["name"] klass = node["class"] knobs = node["knobs"] dependent = node["dependent"] # create node now_node = nuke.createNode( klass, "name {}".format(pre_node_name), inpanel=False ) # add data to knob for _knob in knobs: knob, value = _knob try: now_node[knob].value() except NameError: log.warning( "knob `{}` does not exist on node `{}`".format( knob, now_node["name"].value() )) continue if not knob and not value: continue log.info((knob, value)) if isinstance(value, str): if "[" in value: now_node[knob].setExpression(value) else: now_node[knob].setValue(value) # connect to previous node if dependent: if isinstance(dependent, (tuple or list)): for i, node_name in enumerate(dependent): input_node = nuke.createNode( "Input", "name {}".format(node_name), inpanel=False ) now_node.setInput(1, input_node) elif isinstance(dependent, str): input_node = nuke.createNode( "Input", "name {}".format(node_name), inpanel=False ) now_node.setInput(0, input_node) else: now_node.setInput(0, prev_node) # switch actual node to previous prev_node = now_node # creating write node write_node = now_node = add_write_node_legacy( "inside_{}".format(name), **_data ) # connect to previous node now_node.setInput(0, prev_node) # switch actual node to previous prev_node = now_node now_node = nuke.createNode("Output", "name Output1", inpanel=False) # connect to previous node now_node.setInput(0, prev_node) # imprinting group node set_avalon_knob_data(GN, data["avalon"]) add_publish_knob(GN) add_rendering_knobs(GN, farm) if review: add_review_knob(GN) # add divider GN.addKnob(nuke.Text_Knob('', 'Rendering')) # Add linked knobs. linked_knob_names = [] # add input linked knobs and create group only if any input if linked_knobs: linked_knob_names.append("_grp-start_") linked_knob_names.extend(linked_knobs) linked_knob_names.append("_grp-end_") linked_knob_names.append("Render") for _k_name in linked_knob_names: if "_grp-start_" in _k_name: knob = nuke.Tab_Knob( "rnd_attr", "Rendering attributes", nuke.TABBEGINCLOSEDGROUP) GN.addKnob(knob) elif "_grp-end_" in _k_name: knob = nuke.Tab_Knob( "rnd_attr_end", "Rendering attributes", nuke.TABENDGROUP) GN.addKnob(knob) else: if "___" in _k_name: # add divider GN.addKnob(nuke.Text_Knob("")) else: # add linked knob by _k_name link = nuke.Link_Knob("") link.makeLink(write_node.name(), _k_name) link.setName(_k_name) # make render if "Render" in _k_name: link.setLabel("Render Local") link.setFlag(0x1000) GN.addKnob(link) # adding write to read button add_button_write_to_read(GN) # adding write to read button add_button_clear_rendered(GN, os.path.dirname(fpath)) # Deadline tab. add_deadline_tab(GN) # open the our Tab as default GN[_NODE_TAB_NAME].setFlag(0) # set tile color tile_color = _data.get("tile_color", "0xff0000ff") GN["tile_color"].setValue(tile_color) # override knob values from settings for knob in knob_overrides: knob_type = knob["type"] knob_name = knob["name"] knob_value = knob["value"] if knob_name not in GN.knobs(): continue if not knob_value: continue # set correctly knob types if knob_type == "string": knob_value = str(knob_value) if knob_type == "number": knob_value = int(knob_value) if knob_type == "decimal_number": knob_value = float(knob_value) if knob_type == "bool": knob_value = bool(knob_value) if knob_type in ["2d_vector", "3d_vector", "color", "box"]: knob_value = list(knob_value) GN[knob_name].setValue(knob_value) return GN def set_node_knobs_from_settings(node, knob_settings, **kwargs): """ Overriding knob values from settings Using `schema_nuke_knob_inputs` for knob type definitions. Args: node (nuke.Node): nuke node knob_settings (list): list of dict. Keys are `type`, `name`, `value` kwargs (dict)[optional]: keys for formattable knob settings """ for knob in knob_settings: log.debug("__ knob: {}".format(pformat(knob))) knob_type = knob["type"] knob_name = knob["name"] if knob_name not in node.knobs(): continue if knob_type == "expression": knob_expression = knob["expression"] node[knob_name].setExpression( knob_expression ) continue # first deal with formattable knob settings if knob_type == "formatable": template = knob["template"] to_type = knob["to_type"] try: _knob_value = template.format( **kwargs ) except KeyError as msg: raise KeyError( "Not able to format expression: {}".format(msg)) # convert value to correct type if to_type == "2d_vector": knob_value = _knob_value.split(";").split(",") else: knob_value = _knob_value knob_type = to_type else: knob_value = knob["value"] if not knob_value: continue knob_value = convert_knob_value_to_correct_type( knob_type, knob_value) node[knob_name].setValue(knob_value) def convert_knob_value_to_correct_type(knob_type, knob_value): # first convert string types to string # just to ditch unicode if isinstance(knob_value, six.text_type): knob_value = str(knob_value) # set correctly knob types if knob_type == "bool": knob_value = bool(knob_value) elif knob_type == "decimal_number": knob_value = float(knob_value) elif knob_type == "number": knob_value = int(knob_value) elif knob_type == "text": knob_value = knob_value elif knob_type == "color_gui": knob_value = color_gui_to_int(knob_value) elif knob_type in ["2d_vector", "3d_vector", "color", "box"]: knob_value = [float(val_) for val_ in knob_value] return knob_value def color_gui_to_int(color_gui): hex_value = ( "0x{0:0>2x}{1:0>2x}{2:0>2x}{3:0>2x}").format(*color_gui) return int(hex_value, 16) @deprecated def add_rendering_knobs(node, farm=True): ''' Adds additional rendering knobs to given node Arguments: node (obj): nuke node object to be fixed Return: node (obj): with added knobs ''' knob_options = ["Use existing frames", "Local"] if farm: knob_options.append("On farm") if "render" not in node.knobs(): knob = nuke.Enumeration_Knob("render", "", knob_options) knob.clearFlag(nuke.STARTLINE) node.addKnob(knob) return node @deprecated def add_review_knob(node): ''' Adds additional review knob to given node Arguments: node (obj): nuke node object to be fixed Return: node (obj): with added knob ''' if "review" not in node.knobs(): knob = nuke.Boolean_Knob("review", "Review") knob.setValue(True) node.addKnob(knob) return node @deprecated def add_deadline_tab(node): # TODO: remove this as it is only linked to legacy create node.addKnob(nuke.Tab_Knob("Deadline")) knob = nuke.Int_Knob("deadlinePriority", "Priority") knob.setValue(50) node.addKnob(knob) knob = nuke.Int_Knob("deadlineChunkSize", "Chunk Size") knob.setValue(0) node.addKnob(knob) knob = nuke.Int_Knob("deadlineConcurrentTasks", "Concurrent tasks") # zero as default will get value from Settings during collection # instead of being an explicit user override, see precollect_write.py knob.setValue(0) node.addKnob(knob) knob = nuke.Text_Knob("divd", '') knob.setValue('') node.addKnob(knob) knob = nuke.Boolean_Knob("suspend_publish", "Suspend publish") knob.setValue(False) node.addKnob(knob) @deprecated def get_deadline_knob_names(): # TODO: remove this as it is only linked to legacy # validate_write_deadline_tab return [ "Deadline", "deadlineChunkSize", "deadlinePriority", "deadlineConcurrentTasks" ] def create_backdrop(label="", color=None, layer=0, nodes=None): """ Create Backdrop node Arguments: color (str): nuke compatible string with color code layer (int): layer of node usually used (self.pos_layer - 1) label (str): the message nodes (list): list of nodes to be wrapped into backdrop """ assert isinstance(nodes, list), "`nodes` should be a list of nodes" # Calculate bounds for the backdrop node. bdX = min([node.xpos() for node in nodes]) bdY = min([node.ypos() for node in nodes]) bdW = max([node.xpos() + node.screenWidth() for node in nodes]) - bdX bdH = max([node.ypos() + node.screenHeight() for node in nodes]) - bdY # Expand the bounds to leave a little border. Elements are offsets # for left, top, right and bottom edges respectively left, top, right, bottom = (-20, -65, 20, 60) bdX += left bdY += top bdW += (right - left) bdH += (bottom - top) bdn = nuke.createNode("BackdropNode") bdn["z_order"].setValue(layer) if color: bdn["tile_color"].setValue(int(color, 16)) bdn["xpos"].setValue(bdX) bdn["ypos"].setValue(bdY) bdn["bdwidth"].setValue(bdW) bdn["bdheight"].setValue(bdH) if label: bdn["label"].setValue(label) bdn["note_font_size"].setValue(20) return bdn class WorkfileSettings(object): """ All settings for workfile will be set This object is setting all possible root settings to the workfile. Including Colorspace, Frame ranges, Resolution format. It can set it to Root node or to any given node. Arguments: root (node): nuke's root node nodes (list): list of nuke's nodes nodes_filter (list): filtering classes for nodes """ def __init__(self, root_node=None, nodes=None, **kwargs): project_doc = kwargs.get("project") if project_doc is None: project_name = get_current_project_name() project_doc = get_project(project_name) else: project_name = project_doc["name"] Context._project_doc = project_doc self._project_name = project_name self._asset = ( kwargs.get("asset_name") or get_current_asset_name() ) self._asset_entity = get_asset_by_name(project_name, self._asset) self._root_node = root_node or nuke.root() self._nodes = self.get_nodes(nodes=nodes) self.data = kwargs def get_nodes(self, nodes=None, nodes_filter=None): if not isinstance(nodes, list) and not isinstance(nodes_filter, list): return [n for n in nuke.allNodes()] elif not isinstance(nodes, list) and isinstance(nodes_filter, list): nodes = list() for filter in nodes_filter: [nodes.append(n) for n in nuke.allNodes(filter=filter)] return nodes elif isinstance(nodes, list) and not isinstance(nodes_filter, list): return [n for n in self._nodes] elif isinstance(nodes, list) and isinstance(nodes_filter, list): for filter in nodes_filter: return [n for n in self._nodes if filter in n.Class()] def set_viewers_colorspace(self, viewer_dict): ''' Adds correct colorspace to viewer Arguments: viewer_dict (dict): adjustments from presets ''' if not isinstance(viewer_dict, dict): msg = "set_viewers_colorspace(): argument should be dictionary" log.error(msg) nuke.message(msg) return filter_knobs = [ "viewerProcess", "wipe_position" ] erased_viewers = [] for v in nuke.allNodes(filter="Viewer"): # set viewProcess to preset from settings v["viewerProcess"].setValue( str(viewer_dict["viewerProcess"]) ) if str(viewer_dict["viewerProcess"]) \ not in v["viewerProcess"].value(): copy_inputs = v.dependencies() copy_knobs = {k: v[k].value() for k in v.knobs() if k not in filter_knobs} # delete viewer with wrong settings erased_viewers.append(v["name"].value()) nuke.delete(v) # create new viewer nv = nuke.createNode("Viewer") # connect to original inputs for i, n in enumerate(copy_inputs): nv.setInput(i, n) # set copied knobs for k, v in copy_knobs.items(): print(k, v) nv[k].setValue(v) # set viewerProcess nv["viewerProcess"].setValue(str(viewer_dict["viewerProcess"])) if erased_viewers: log.warning( "Attention! Viewer nodes {} were erased." "It had wrong color profile".format(erased_viewers)) def set_root_colorspace(self, imageio_host): ''' Adds correct colorspace to root Arguments: imageio_host (dict): host colorspace configurations ''' config_data = get_imageio_config( project_name=get_current_project_name(), host_name="nuke" ) workfile_settings = imageio_host["workfile"] viewer_process_settings = imageio_host["viewer"]["viewerProcess"] if not config_data: # TODO: backward compatibility for old projects - remove later # perhaps old project overrides is having it set to older version # with use of `customOCIOConfigPath` resolved_path = None if workfile_settings.get("customOCIOConfigPath"): unresolved_path = workfile_settings["customOCIOConfigPath"] ocio_paths = unresolved_path[platform.system().lower()] for ocio_p in ocio_paths: resolved_path = str(ocio_p).format(**os.environ) if not os.path.exists(resolved_path): continue if resolved_path: # set values to root self._root_node["colorManagement"].setValue("OCIO") self._root_node["OCIO_config"].setValue("custom") self._root_node["customOCIOConfigPath"].setValue( resolved_path) else: # no ocio config found and no custom path used if self._root_node["colorManagement"].value() \ not in str(workfile_settings["colorManagement"]): self._root_node["colorManagement"].setValue( str(workfile_settings["colorManagement"])) # second set ocio version if self._root_node["OCIO_config"].value() \ not in str(workfile_settings["OCIO_config"]): self._root_node["OCIO_config"].setValue( str(workfile_settings["OCIO_config"])) else: # OCIO config path is defined from prelaunch hook self._root_node["colorManagement"].setValue("OCIO") # print previous settings in case some were found in workfile residual_path = self._root_node["customOCIOConfigPath"].value() if residual_path: log.info("Residual OCIO config path found: `{}`".format( residual_path )) # we dont need the key anymore workfile_settings.pop("customOCIOConfigPath", None) workfile_settings.pop("colorManagement", None) workfile_settings.pop("OCIO_config", None) # get monitor lut from settings respecting Nuke version differences monitor_lut = workfile_settings.pop("monitorLut", None) monitor_lut_data = self._get_monitor_settings( viewer_process_settings, monitor_lut) # set monitor related knobs luts (MonitorOut, Thumbnails) for knob, value_ in monitor_lut_data.items(): workfile_settings[knob] = value_ # then set the rest for knob, value_ in workfile_settings.items(): # skip unfilled ocio config path # it will be dict in value if isinstance(value_, dict): continue # skip empty values if not value_: continue if self._root_node[knob].value() not in value_: self._root_node[knob].setValue(str(value_)) log.debug("nuke.root()['{}'] changed to: {}".format( knob, value_)) # set ocio config path if config_data: config_path = config_data["path"].replace("\\", "/") log.info("OCIO config path found: `{}`".format( config_path)) # check if there's a mismatch between environment and settings correct_settings = self._is_settings_matching_environment( config_data) # if there's no mismatch between environment and settings if correct_settings: self._set_ocio_config_path_to_workfile(config_data) def _get_monitor_settings(self, viewer_lut, monitor_lut): """ Get monitor settings from viewer and monitor lut Args: viewer_lut (str): viewer lut string monitor_lut (str): monitor lut string Returns: dict: monitor settings """ output_data = {} m_display, m_viewer = get_viewer_config_from_string(monitor_lut) v_display, v_viewer = get_viewer_config_from_string(viewer_lut) # set monitor lut differently for nuke version 14 if nuke.NUKE_VERSION_MAJOR >= 14: output_data["monitorOutLUT"] = create_viewer_profile_string( m_viewer, m_display, path_like=False) # monitorLut=thumbnails - viewerProcess makes more sense output_data["monitorLut"] = create_viewer_profile_string( v_viewer, v_display, path_like=False) if nuke.NUKE_VERSION_MAJOR == 13: output_data["monitorOutLUT"] = create_viewer_profile_string( m_viewer, m_display, path_like=False) # monitorLut=thumbnails - viewerProcess makes more sense output_data["monitorLut"] = create_viewer_profile_string( v_viewer, v_display, path_like=True) if nuke.NUKE_VERSION_MAJOR <= 12: output_data["monitorLut"] = create_viewer_profile_string( m_viewer, m_display, path_like=True) return output_data def _is_settings_matching_environment(self, config_data): """ Check if OCIO config path is different from environment Args: config_data (dict): OCIO config data from settings Returns: bool: True if settings are matching environment, False otherwise """ current_ocio_path = os.environ["OCIO"] settings_ocio_path = config_data["path"] # normalize all paths to forward slashes current_ocio_path = current_ocio_path.replace("\\", "/") settings_ocio_path = settings_ocio_path.replace("\\", "/") if current_ocio_path != settings_ocio_path: message = """ It seems like there's a mismatch between the OCIO config path set in your Nuke settings and the actual path set in your OCIO environment. To resolve this, please follow these steps: 1. Close Nuke if it's currently open. 2. Reopen Nuke. Please note the paths for your reference: - The OCIO environment path currently set: `{env_path}` - The path in your current Nuke settings: `{settings_path}` Reopening Nuke should synchronize these paths and resolve any discrepancies. """ nuke.message( message.format( env_path=current_ocio_path, settings_path=settings_ocio_path ) ) return False return True def _set_ocio_config_path_to_workfile(self, config_data): """ Set OCIO config path to workfile Path set into nuke workfile. It is trying to replace path with environment variable if possible. If not, it will set it as it is. It also saves the script to apply the change, but only if it's not empty Untitled script. Args: config_data (dict): OCIO config data from settings """ # replace path with env var if possible ocio_path = self._replace_ocio_path_with_env_var(config_data) log.info("Setting OCIO config path to: `{}`".format( ocio_path)) self._root_node["customOCIOConfigPath"].setValue( ocio_path ) self._root_node["OCIO_config"].setValue("custom") # only save script if it's not empty if self._root_node["name"].value() != "": log.info("Saving script to apply OCIO config path change.") nuke.scriptSave() def _get_included_vars(self, config_template): """ Get all environment variables included in template Args: config_template (str): OCIO config template from settings Returns: list: list of environment variables included in template """ # resolve all environments for whitelist variables included_vars = [ "BUILTIN_OCIO_ROOT", ] # include all project root related env vars for env_var in os.environ: if env_var.startswith("OPENPYPE_PROJECT_ROOT_"): included_vars.append(env_var) # use regex to find env var in template with format {ENV_VAR} # this way we make sure only template used env vars are included env_var_regex = r"\{([A-Z0-9_]+)\}" env_var = re.findall(env_var_regex, config_template) if env_var: included_vars.append(env_var[0]) return included_vars def _replace_ocio_path_with_env_var(self, config_data): """ Replace OCIO config path with environment variable Environment variable is added as TCL expression to path. TCL expression is also replacing backward slashes found in path for windows formatted values. Args: config_data (str): OCIO config dict from settings Returns: str: OCIO config path with environment variable TCL expression """ config_path = config_data["path"].replace("\\", "/") config_template = config_data["template"] included_vars = self._get_included_vars(config_template) # make sure we return original path if no env var is included new_path = config_path for env_var in included_vars: env_path = os.getenv(env_var) if not env_path: continue # it has to be directory current process can see if not os.path.isdir(env_path): continue # make sure paths are in same format env_path = env_path.replace("\\", "/") path = config_path.replace("\\", "/") # check if env_path is in path and replace to first found positive if env_path in path: # with regsub we make sure path format of slashes is correct resub_expr = ( "[regsub -all {{\\\\}} [getenv {}] \"/\"]").format(env_var) new_path = path.replace( env_path, resub_expr ) break return new_path def set_writes_colorspace(self): ''' Adds correct colorspace to write node dict ''' for node in nuke.allNodes(filter="Group", group=self._root_node): log.info("Setting colorspace to `{}`".format(node.name())) # get data from avalon knob avalon_knob_data = read_avalon_data(node) node_data = get_node_data(node, INSTANCE_DATA_KNOB) if ( # backward compatibility # TODO: remove this once old avalon data api will be removed avalon_knob_data and avalon_knob_data.get("id") != "pyblish.avalon.instance" ): continue elif ( node_data and node_data.get("id") != "pyblish.avalon.instance" ): continue if ( # backward compatibility # TODO: remove this once old avalon data api will be removed avalon_knob_data and "creator" not in avalon_knob_data ): continue elif ( node_data and "creator_identifier" not in node_data ): continue nuke_imageio_writes = None if avalon_knob_data: # establish families families = [avalon_knob_data["family"]] if avalon_knob_data.get("families"): families.append(avalon_knob_data.get("families")) nuke_imageio_writes = get_imageio_node_setting( node_class=avalon_knob_data["families"], plugin_name=avalon_knob_data["creator"], subset=avalon_knob_data["subset"] ) elif node_data: nuke_imageio_writes = get_write_node_template_attr(node) log.debug("nuke_imageio_writes: `{}`".format(nuke_imageio_writes)) if not nuke_imageio_writes: return write_node = None # get into the group node node.begin() for x in nuke.allNodes(): if x.Class() == "Write": write_node = x node.end() if not write_node: return try: # write all knobs to node for knob in nuke_imageio_writes["knobs"]: value = knob["value"] if isinstance(value, six.text_type): value = str(value) if str(value).startswith("0x"): value = int(value, 16) log.debug("knob: {}| value: {}".format( knob["name"], value )) write_node[knob["name"]].setValue(value) except TypeError: log.warning( "Legacy workflow didn't work, switching to current") set_node_knobs_from_settings( write_node, nuke_imageio_writes["knobs"]) def set_reads_colorspace(self, read_clrs_inputs): """ Setting colorspace to Read nodes Looping through all read nodes and tries to set colorspace based on regex rules in presets """ changes = {} for n in nuke.allNodes(): file = nuke.filename(n) if n.Class() != "Read": continue # check if any colorspace presets for read is matching preset_clrsp = None for input in read_clrs_inputs: if not bool(re.search(input["regex"], file)): continue preset_clrsp = input["colorspace"] if preset_clrsp is not None: current = n["colorspace"].value() future = str(preset_clrsp) if current != future: changes[n.name()] = { "from": current, "to": future } log.debug(changes) if changes: msg = "Read nodes are not set to correct colorspace:\n\n" for nname, knobs in changes.items(): msg += ( " - node: '{0}' is now '{1}' but should be '{2}'\n" ).format(nname, knobs["from"], knobs["to"]) msg += "\nWould you like to change it?" if nuke.ask(msg): for nname, knobs in changes.items(): n = nuke.toNode(nname) n["colorspace"].setValue(knobs["to"]) log.info( "Setting `{0}` to `{1}`".format( nname, knobs["to"])) def set_colorspace(self): ''' Setting colorspace following presets ''' # get imageio nuke_colorspace = get_nuke_imageio_settings() log.info("Setting colorspace to workfile...") try: self.set_root_colorspace(nuke_colorspace) except AttributeError as _error: msg = "Set Colorspace to workfile error: {}".format(_error) nuke.message(msg) log.info("Setting colorspace to viewers...") try: self.set_viewers_colorspace(nuke_colorspace["viewer"]) except AttributeError as _error: msg = "Set Colorspace to viewer error: {}".format(_error) nuke.message(msg) log.info("Setting colorspace to write nodes...") try: self.set_writes_colorspace() except AttributeError as _error: nuke.message(_error) log.error(_error) log.info("Setting colorspace to read nodes...") read_clrs_inputs = nuke_colorspace["regexInputs"].get("inputs", []) if read_clrs_inputs: self.set_reads_colorspace(read_clrs_inputs) def reset_frame_range_handles(self): """Set frame range to current asset""" if "data" not in self._asset_entity: msg = "Asset {} don't have set any 'data'".format(self._asset) log.warning(msg) nuke.message(msg) return asset_data = self._asset_entity["data"] missing_cols = [] check_cols = ["fps", "frameStart", "frameEnd", "handleStart", "handleEnd"] for col in check_cols: if col not in asset_data: missing_cols.append(col) if len(missing_cols) > 0: missing = ", ".join(missing_cols) msg = "'{}' are not set for asset '{}'!".format( missing, self._asset) log.warning(msg) nuke.message(msg) return # get handles values handle_start = asset_data["handleStart"] handle_end = asset_data["handleEnd"] fps = float(asset_data["fps"]) frame_start_handle = int(asset_data["frameStart"]) - handle_start frame_end_handle = int(asset_data["frameEnd"]) + handle_end self._root_node["lock_range"].setValue(False) self._root_node["fps"].setValue(fps) self._root_node["first_frame"].setValue(frame_start_handle) self._root_node["last_frame"].setValue(frame_end_handle) self._root_node["lock_range"].setValue(True) # update node graph so knobs are updated update_node_graph() frame_range = '{0}-{1}'.format( int(asset_data["frameStart"]), int(asset_data["frameEnd"]) ) for node in nuke.allNodes(filter="Viewer"): node['frame_range'].setValue(frame_range) node['frame_range_lock'].setValue(True) node['frame_range'].setValue(frame_range) node['frame_range_lock'].setValue(True) if not ASSIST: set_node_data( self._root_node, INSTANCE_DATA_KNOB, { "handleStart": int(handle_start), "handleEnd": int(handle_end) } ) else: log.warning( "NukeAssist mode is not allowing " "updating custom knobs..." ) def reset_resolution(self): """Set resolution to project resolution.""" log.info("Resetting resolution") project_name = get_current_project_name() asset_data = self._asset_entity["data"] format_data = { "width": int(asset_data.get( 'resolutionWidth', asset_data.get('resolution_width'))), "height": int(asset_data.get( 'resolutionHeight', asset_data.get('resolution_height'))), "pixel_aspect": asset_data.get( 'pixelAspect', asset_data.get('pixel_aspect', 1)), "name": project_name } if any(x_ for x_ in format_data.values() if x_ is None): msg = ("Missing set shot attributes in DB." "\nContact your supervisor!." "\n\nWidth: `{width}`" "\nHeight: `{height}`" "\nPixel Aspect: `{pixel_aspect}`").format(**format_data) log.error(msg) nuke.message(msg) existing_format = None for format in nuke.formats(): if format_data["name"] == format.name(): existing_format = format break if existing_format: # Enforce existing format to be correct. existing_format.setWidth(format_data["width"]) existing_format.setHeight(format_data["height"]) existing_format.setPixelAspect(format_data["pixel_aspect"]) else: format_string = self.make_format_string(**format_data) log.info("Creating new format: {}".format(format_string)) nuke.addFormat(format_string) nuke.root()["format"].setValue(format_data["name"]) log.info("Format is set.") # update node graph so knobs are updated update_node_graph() def make_format_string(self, **kwargs): if kwargs.get("r"): return ( "{width} " "{height} " "{x} " "{y} " "{r} " "{t} " "{pixel_aspect:.2f} " "{name}".format(**kwargs) ) else: return ( "{width} " "{height} " "{pixel_aspect:.2f} " "{name}".format(**kwargs) ) def set_context_settings(self): # replace reset resolution from avalon core to pype's self.reset_resolution() # replace reset resolution from avalon core to pype's self.reset_frame_range_handles() # add colorspace menu item self.set_colorspace() def set_favorites(self): from .utils import set_context_favorites work_dir = os.getenv("AVALON_WORKDIR") asset = get_current_asset_name() favorite_items = OrderedDict() # project # get project's root and split to parts projects_root = os.path.normpath(work_dir.split( Context.project_name)[0]) # add project name project_dir = os.path.join(projects_root, Context.project_name) + "/" # add to favorites favorite_items.update({"Project dir": project_dir.replace("\\", "/")}) # asset asset_root = os.path.normpath(work_dir.split( asset)[0]) # add asset name asset_dir = os.path.join(asset_root, asset) + "/" # add to favorites favorite_items.update({"Shot dir": asset_dir.replace("\\", "/")}) # workdir favorite_items.update({"Work dir": work_dir.replace("\\", "/")}) set_context_favorites(favorite_items) def get_write_node_template_attr(node): ''' Gets all defined data from presets ''' # TODO: add identifiers to settings and rename settings key plugin_names_mapping = { "create_write_image": "CreateWriteImage", "create_write_prerender": "CreateWritePrerender", "create_write_render": "CreateWriteRender" } # get avalon data from node node_data = get_node_data(node, INSTANCE_DATA_KNOB) identifier = node_data["creator_identifier"] # return template data return get_imageio_node_setting( node_class="Write", plugin_name=plugin_names_mapping[identifier], subset=node_data["subset"] ) def get_dependent_nodes(nodes): """Get all dependent nodes connected to the list of nodes. Looking for connections outside of the nodes in incoming argument. Arguments: nodes (list): list of nuke.Node objects Returns: connections_in: dictionary of nodes and its dependencies connections_out: dictionary of nodes and its dependency """ connections_in = dict() connections_out = dict() node_names = [n.name() for n in nodes] for node in nodes: inputs = node.dependencies() outputs = node.dependent() # collect all inputs outside test_in = [(i, n) for i, n in enumerate(inputs) if n.name() not in node_names] if test_in: connections_in.update({ node: test_in }) # collect all outputs outside test_out = [i for i in outputs if i.name() not in node_names] if test_out: # only one dependent node is allowed connections_out.update({ node: test_out[-1] }) return connections_in, connections_out def update_node_graph(): # Resetting frame will update knob values try: root_node_lock = nuke.root()["lock_range"].value() nuke.root()["lock_range"].setValue(not root_node_lock) nuke.root()["lock_range"].setValue(root_node_lock) current_frame = nuke.frame() nuke.frame(1) nuke.frame(int(current_frame)) except Exception as error: log.warning(error) def find_free_space_to_paste_nodes( nodes, group=nuke.root(), direction="right", offset=300 ): """ For getting coordinates in DAG (node graph) for placing new nodes Arguments: nodes (list): list of nuke.Node objects group (nuke.Node) [optional]: object in which context it is direction (str) [optional]: where we want it to be placed [left, right, top, bottom] offset (int) [optional]: what offset it is from rest of nodes Returns: xpos (int): x coordinace in DAG ypos (int): y coordinace in DAG """ if len(nodes) == 0: return 0, 0 group_xpos = list() group_ypos = list() # get local coordinates of all nodes nodes_xpos = [n.xpos() for n in nodes] + \ [n.xpos() + n.screenWidth() for n in nodes] nodes_ypos = [n.ypos() for n in nodes] + \ [n.ypos() + n.screenHeight() for n in nodes] # get complete screen size of all nodes to be placed in nodes_screen_width = max(nodes_xpos) - min(nodes_xpos) nodes_screen_heigth = max(nodes_ypos) - min(nodes_ypos) # get screen size (r,l,t,b) of all nodes in `group` with group: group_xpos = [n.xpos() for n in nuke.allNodes() if n not in nodes] + \ [n.xpos() + n.screenWidth() for n in nuke.allNodes() if n not in nodes] group_ypos = [n.ypos() for n in nuke.allNodes() if n not in nodes] + \ [n.ypos() + n.screenHeight() for n in nuke.allNodes() if n not in nodes] # calc output left if direction in "left": xpos = min(group_xpos) - abs(nodes_screen_width) - abs(offset) ypos = min(group_ypos) return xpos, ypos # calc output right if direction in "right": xpos = max(group_xpos) + abs(offset) ypos = min(group_ypos) return xpos, ypos # calc output top if direction in "top": xpos = min(group_xpos) ypos = min(group_ypos) - abs(nodes_screen_heigth) - abs(offset) return xpos, ypos # calc output bottom if direction in "bottom": xpos = min(group_xpos) ypos = max(group_ypos) + abs(offset) return xpos, ypos @contextlib.contextmanager def maintained_selection(exclude_nodes=None): """Maintain selection during context Maintain selection during context and unselect all nodes after context is done. Arguments: exclude_nodes (list[nuke.Node]): list of nodes to be unselected before context is done Example: >>> with maintained_selection(): ... node["selected"].setValue(True) >>> print(node["selected"].value()) False """ if exclude_nodes: for node in exclude_nodes: node["selected"].setValue(False) previous_selection = nuke.selectedNodes() try: yield finally: # unselect all selection in case there is some reset_selection() # and select all previously selected nodes if previous_selection: select_nodes(previous_selection) @contextlib.contextmanager def swap_node_with_dependency(old_node, new_node): """ Swap node with dependency Swap node with dependency and reconnect all inputs and outputs. It removes old node. Arguments: old_node (nuke.Node): node to be replaced new_node (nuke.Node): node to replace with Example: >>> old_node_name = old_node["name"].value() >>> print(old_node_name) old_node_name_01 >>> with swap_node_with_dependency(old_node, new_node) as node_name: ... new_node["name"].setValue(node_name) >>> print(new_node["name"].value()) old_node_name_01 """ # preserve position xpos, ypos = old_node.xpos(), old_node.ypos() # preserve selection after all is done outputs = get_node_outputs(old_node) inputs = old_node.dependencies() node_name = old_node["name"].value() try: nuke.delete(old_node) yield node_name finally: # Reconnect inputs for i, node in enumerate(inputs): new_node.setInput(i, node) # Reconnect outputs if outputs: for n, pipes in outputs.items(): for i in pipes: n.setInput(i, new_node) # return to original position new_node.setXYpos(xpos, ypos) def reset_selection(): """Deselect all selected nodes""" for node in nuke.selectedNodes(): node["selected"].setValue(False) def select_nodes(nodes): """Selects all inputted nodes Arguments: nodes (Union[list, tuple, set]): nuke nodes to be selected """ assert isinstance(nodes, (list, tuple, set)), \ "nodes has to be list, tuple or set" for node in nodes: node["selected"].setValue(True) def launch_workfiles_app(): """Show workfiles tool on nuke launch. Trigger to show workfiles tool on application launch. Can be executed only once all other calls are ignored. Workfiles tool show is deferred after application initialization using QTimer. """ if Context.workfiles_launched: return Context.workfiles_launched = True # get all imortant settings open_at_start = env_value_to_bool( env_key="OPENPYPE_WORKFILE_TOOL_ON_START", default=None) # return if none is defined if not open_at_start: return # Show workfiles tool using timer # - this will be probably triggered during initialization in that case # the application is not be able to show uis so it must be # deferred using timer # - timer should be processed when initialization ends # When applications starts to process events. timer = QtCore.QTimer() timer.timeout.connect(_launch_workfile_app) timer.setInterval(100) Context.workfiles_tool_timer = timer timer.start() def _launch_workfile_app(): # Safeguard to not show window when application is still starting up # or is already closing down. closing_down = QtWidgets.QApplication.closingDown() starting_up = QtWidgets.QApplication.startingUp() # Stop the timer if application finished start up of is closing down if closing_down or not starting_up: Context.workfiles_tool_timer.stop() Context.workfiles_tool_timer = None # Skip if application is starting up or closing down if starting_up or closing_down: return # Make sure on top is enabled on first show so the window is not hidden # under main nuke window # - this happened on Centos 7 and it is because the focus of nuke # changes to the main window after showing because of initialization # which moves workfiles tool under it host_tools.show_workfiles(parent=None, on_top=True) @deprecated("openpype.hosts.nuke.api.lib.start_workfile_template_builder") def process_workfile_builder(): """ [DEPRECATED] Process workfile builder on nuke start This function is deprecated and will be removed in future versions. Use settings for `project_settings/nuke/templated_workfile_build` which are supported by api `start_workfile_template_builder()`. """ # to avoid looping of the callback, remove it! nuke.removeOnCreate(process_workfile_builder, nodeClass="Root") # get state from settings project_settings = get_current_project_settings() workfile_builder = project_settings["nuke"].get( "workfile_builder", {}) # get settings create_fv_on = workfile_builder.get("create_first_version") or None builder_on = workfile_builder.get("builder_on_start") or None last_workfile_path = os.environ.get("AVALON_LAST_WORKFILE") # generate first version in file not existing and feature is enabled if create_fv_on and not os.path.exists(last_workfile_path): # get custom template path if any custom_template_path = get_custom_workfile_template_from_session( project_settings=project_settings ) # if custom template is defined if custom_template_path: log.info("Adding nodes from `{}`...".format( custom_template_path )) try: # import nodes into current script nuke.nodePaste(custom_template_path) except RuntimeError: raise RuntimeError(( "Template defined for project: {} is not working. " "Talk to your manager for an advise").format( custom_template_path)) # if builder at start is defined if builder_on: log.info("Building nodes from presets...") # build nodes by defined presets BuildWorkfile().process() log.info("Saving script as version `{}`...".format( last_workfile_path )) # safe file as version save_file(last_workfile_path) return def start_workfile_template_builder(): from .workfile_template_builder import ( build_workfile_template ) # remove callback since it would be duplicating the workfile nuke.removeOnCreate(start_workfile_template_builder, nodeClass="Root") # to avoid looping of the callback, remove it! log.info("Starting workfile template builder...") try: build_workfile_template(workfile_creation_enabled=True) except TemplateProfileNotFound: log.warning("Template profile not found. Skipping...") @deprecated def recreate_instance(origin_node, avalon_data=None): """Recreate input instance to different data Args: origin_node (nuke.Node): Nuke node to be recreating from avalon_data (dict, optional): data to be used in new node avalon_data Returns: nuke.Node: newly created node """ knobs_wl = ["render", "publish", "review", "ypos", "use_limit", "first", "last"] # get data from avalon knobs data = get_avalon_knob_data( origin_node) # add input data to avalon data if avalon_data: data.update(avalon_data) # capture all node knobs allowed in op_knobs knobs_data = {k: origin_node[k].value() for k in origin_node.knobs() for key in knobs_wl if key in k} # get node dependencies inputs = origin_node.dependencies() outputs = origin_node.dependent() # remove the node nuke.delete(origin_node) # create new node # get appropriate plugin class creator_plugin = None for Creator in discover_legacy_creator_plugins(): if Creator.__name__ == data["creator"]: creator_plugin = Creator break # create write node with creator new_node_name = data["subset"] new_node = creator_plugin(new_node_name, data["asset"]).process() # white listed knobs to the new node for _k, _v in knobs_data.items(): try: print(_k, _v) new_node[_k].setValue(_v) except Exception as e: print(e) # connect to original inputs for i, n in enumerate(inputs): new_node.setInput(i, n) # connect to outputs if len(outputs) > 0: for dn in outputs: dn.setInput(0, new_node) return new_node def add_scripts_menu(): try: from scriptsmenu import launchfornuke except ImportError: log.warning( "Skipping studio.menu install, because " "'scriptsmenu' module seems unavailable." ) return # load configuration of custom menu project_name = get_current_project_name() project_settings = get_project_settings(project_name) config = project_settings["nuke"]["scriptsmenu"]["definition"] _menu = project_settings["nuke"]["scriptsmenu"]["name"] if not config: log.warning("Skipping studio menu, no definition found.") return # run the launcher for Maya menu studio_menu = launchfornuke.main(title=_menu.title()) # apply configuration studio_menu.build_from_configuration(studio_menu, config) def add_scripts_gizmo(): # load configuration of custom menu project_name = get_current_project_name() project_settings = get_project_settings(project_name) platform_name = platform.system().lower() for gizmo_settings in project_settings["nuke"]["gizmo"]: gizmo_list_definition = gizmo_settings["gizmo_definition"] toolbar_name = gizmo_settings["toolbar_menu_name"] # gizmo_toolbar_path = gizmo_settings["gizmo_toolbar_path"] gizmo_source_dir = gizmo_settings.get( "gizmo_source_dir", {}).get(platform_name) toolbar_icon_path = gizmo_settings.get( "toolbar_icon_path", {}).get(platform_name) if not gizmo_source_dir: log.debug("Skipping studio gizmo `{}`, " "no gizmo path found.".format(toolbar_name) ) return if not gizmo_list_definition: log.debug("Skipping studio gizmo `{}`, " "no definition found.".format(toolbar_name) ) return if toolbar_icon_path: try: toolbar_icon_path = toolbar_icon_path.format(**os.environ) except KeyError as e: log.error( "This environment variable doesn't exist: {}".format(e) ) existing_gizmo_path = [] for source_dir in gizmo_source_dir: try: resolve_source_dir = source_dir.format(**os.environ) except KeyError as e: log.error( "This environment variable doesn't exist: {}".format(e) ) continue if not os.path.exists(resolve_source_dir): log.warning( "The source of gizmo `{}` does not exists".format( resolve_source_dir ) ) continue existing_gizmo_path.append(resolve_source_dir) # run the launcher for Nuke toolbar toolbar_menu = gizmo_menu.GizmoMenu( title=toolbar_name, icon=toolbar_icon_path ) # apply configuration toolbar_menu.add_gizmo_path(existing_gizmo_path) toolbar_menu.build_from_configuration(gizmo_list_definition) class NukeDirmap(HostDirmap): def __init__(self, file_name, *args, **kwargs): """ Args: file_name (str): full path of referenced file from workfiles *args (tuple): Positional arguments for 'HostDirmap' class **kwargs (dict): Keyword arguments for 'HostDirmap' class """ self.file_name = file_name super(NukeDirmap, self).__init__(*args, **kwargs) def on_enable_dirmap(self): pass def dirmap_routine(self, source_path, destination_path): source_path = source_path.lower().replace(os.sep, '/') destination_path = destination_path.lower().replace(os.sep, '/') log.debug("Map: {} with: {}->{}".format(self.file_name, source_path, destination_path)) if platform.system().lower() == "windows": self.file_name = self.file_name.lower().replace( source_path, destination_path) else: self.file_name = self.file_name.replace( source_path, destination_path) class DirmapCache: """Caching class to get settings and sync_module easily and only once.""" _project_name = None _project_settings = None _sync_module_discovered = False _sync_module = None _mapping = None @classmethod def project_name(cls): if cls._project_name is None: cls._project_name = os.getenv("AVALON_PROJECT") return cls._project_name @classmethod def project_settings(cls): if cls._project_settings is None: cls._project_settings = get_project_settings(cls.project_name()) return cls._project_settings @classmethod def sync_module(cls): if not cls._sync_module_discovered: cls._sync_module_discovered = True cls._sync_module = ModulesManager().modules_by_name.get( "sync_server") return cls._sync_module @classmethod def mapping(cls): return cls._mapping @classmethod def set_mapping(cls, mapping): cls._mapping = mapping def dirmap_file_name_filter(file_name): """Nuke callback function with single full path argument. Checks project settings for potential mapping from source to dest. """ dirmap_processor = NukeDirmap( file_name, "nuke", DirmapCache.project_name(), DirmapCache.project_settings(), DirmapCache.sync_module(), ) if not DirmapCache.mapping(): DirmapCache.set_mapping(dirmap_processor.get_mappings()) dirmap_processor.process_dirmap(DirmapCache.mapping()) if os.path.exists(dirmap_processor.file_name): return dirmap_processor.file_name return file_name @contextlib.contextmanager def node_tempfile(): """Create a temp file where node is pasted during duplication. This is to avoid using clipboard for node duplication. """ tmp_file = tempfile.NamedTemporaryFile( mode="w", prefix="openpype_nuke_temp_", suffix=".nk", delete=False ) tmp_file.close() node_tempfile_path = tmp_file.name try: # Yield the path where node can be copied yield node_tempfile_path finally: # Remove the file at the end os.remove(node_tempfile_path) def duplicate_node(node): reset_selection() # select required node for duplication node.setSelected(True) with node_tempfile() as filepath: # copy selected to temp filepath nuke.nodeCopy(filepath) # reset selection reset_selection() # paste node and selection is on it only dupli_node = nuke.nodePaste(filepath) # reset selection reset_selection() return dupli_node def get_group_io_nodes(nodes): """Get the input and the output of a group of nodes.""" if not nodes: raise ValueError("there is no nodes in the list") input_node = None output_node = None if len(nodes) == 1: input_node = output_node = nodes[0] else: for node in nodes: if "Input" in node.name(): input_node = node if "Output" in node.name(): output_node = node if input_node is not None and output_node is not None: break if input_node is None: log.warning("No Input found") if output_node is None: log.warning("No Output found") return input_node, output_node def get_extreme_positions(nodes): """Get the 4 numbers that represent the box of a group of nodes.""" if not nodes: raise ValueError("there is no nodes in the list") nodes_xpos = [n.xpos() for n in nodes] + \ [n.xpos() + n.screenWidth() for n in nodes] nodes_ypos = [n.ypos() for n in nodes] + \ [n.ypos() + n.screenHeight() for n in nodes] min_x, min_y = (min(nodes_xpos), min(nodes_ypos)) max_x, max_y = (max(nodes_xpos), max(nodes_ypos)) return min_x, min_y, max_x, max_y def refresh_node(node): """Correct a bug caused by the multi-threading of nuke. Refresh the node to make sure that it takes the desired attributes. """ x = node.xpos() y = node.ypos() nuke.autoplaceSnap(node) node.setXYpos(x, y) def refresh_nodes(nodes): for node in nodes: refresh_node(node) def get_names_from_nodes(nodes): """Get list of nodes names. Args: nodes(List[nuke.Node]): List of nodes to convert into names. Returns: List[str]: Name of passed nodes. """ return [ node.name() for node in nodes ] def get_nodes_by_names(names): """Get list of nuke nodes based on their names. Args: names (List[str]): List of node names to be found. Returns: List[nuke.Node]: List of nodes found by name. """ return [ nuke.toNode(name) for name in names ] def get_viewer_config_from_string(input_string): """Convert string to display and viewer string Args: input_string (str): string with viewer Raises: IndexError: if more then one slash in input string IndexError: if missing closing bracket Returns: tuple[str]: display, viewer """ display = None viewer = input_string # check if () or / or \ in name if "/" in viewer: split = viewer.split("/") # rise if more then one column if len(split) > 2: raise IndexError(( "Viewer Input string is not correct. " "more then two `/` slashes! {}" ).format(input_string)) viewer = split[1] display = split[0] elif "(" in viewer: pattern = r"([\w\d\s\.\-]+).*[(](.*)[)]" result_ = re.findall(pattern, viewer) try: result_ = result_.pop() display = str(result_[1]).rstrip() viewer = str(result_[0]).rstrip() except IndexError: raise IndexError(( "Viewer Input string is not correct. " "Missing bracket! {}" ).format(input_string)) return (display, viewer) def create_viewer_profile_string(viewer, display=None, path_like=False): """Convert viewer and display to string Args: viewer (str): viewer name display (Optional[str]): display name path_like (Optional[bool]): if True, return path like string Returns: str: viewer config string """ if not display: return viewer if path_like: return "{}/{}".format(display, viewer) return "{} ({})".format(viewer, display) def get_filenames_without_hash(filename, frame_start, frame_end): """Get filenames without frame hash i.e. "renderCompositingMain.baking.0001.exr" Args: filename (str): filename with frame hash frame_start (str): start of the frame frame_end (str): end of the frame Returns: list: filename per frame of the sequence """ filenames = [] for frame in range(int(frame_start), (int(frame_end) + 1)): if "#" in filename: # use regex to convert #### to {:0>4} def replace(match): return "{{:0>{}}}".format(len(match.group())) filename_without_hashes = re.sub("#+", replace, filename) new_filename = filename_without_hashes.format(frame) filenames.append(new_filename) return filenames def create_camera_node_by_version(): """Function to create the camera with the latest node class For Nuke version 14.0 or later, the Camera4 camera node class would be used For the version before, the Camera2 camera node class would be used Returns: Node: camera node """ nuke_number_version = nuke.NUKE_VERSION_MAJOR if nuke_number_version >= 14: return nuke.createNode("Camera4") else: return nuke.createNode("Camera2") def link_knobs(knobs, node, group_node): """Link knobs from inside `group_node`""" missing_knobs = [] for knob in knobs: if knob in group_node.knobs(): continue if knob not in node.knobs().keys(): missing_knobs.append(knob) link = nuke.Link_Knob("") link.makeLink(node.name(), knob) link.setName(knob) link.setFlag(0x1000) group_node.addKnob(link) if missing_knobs: raise ValueError( "Write node exposed knobs missing:\n\n{}\n\nPlease review" " project settings.".format("\n".join(missing_knobs)) ) ================================================ FILE: openpype/hosts/nuke/api/pipeline.py ================================================ import nuke import os import importlib from collections import OrderedDict, defaultdict import pyblish.api import openpype from openpype.host import ( HostBase, IWorkfileHost, ILoadHost, IPublishHost ) from openpype.settings import get_current_project_settings from openpype.lib import register_event_callback, Logger from openpype.pipeline import ( register_loader_plugin_path, register_creator_plugin_path, register_inventory_action_path, AVALON_CONTAINER_ID, get_current_asset_name, get_current_task_name, ) from openpype.pipeline.workfile import BuildWorkfile from openpype.tools.utils import host_tools from .command import viewer_update_and_undo_stop from .lib import ( Context, ROOT_DATA_KNOB, INSTANCE_DATA_KNOB, get_main_window, add_publish_knob, WorkfileSettings, # TODO: remove this once workfile builder will be removed process_workfile_builder, start_workfile_template_builder, launch_workfiles_app, check_inventory_versions, set_avalon_knob_data, read_avalon_data, on_script_load, dirmap_file_name_filter, add_scripts_menu, add_scripts_gizmo, get_node_data, set_node_data ) from .workfile_template_builder import ( NukePlaceholderLoadPlugin, NukePlaceholderCreatePlugin, build_workfile_template, create_placeholder, update_placeholder, ) from .workio import ( open_file, save_file, file_extensions, has_unsaved_changes, work_root, current_file ) from .constants import ASSIST from . import push_to_project log = Logger.get_logger(__name__) HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.nuke.__file__)) PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") MENU_LABEL = os.environ["AVALON_LABEL"] # registering pyblish gui regarding settings in presets if os.getenv("PYBLISH_GUI", None): pyblish.api.register_gui(os.getenv("PYBLISH_GUI", None)) class NukeHost( HostBase, IWorkfileHost, ILoadHost, IPublishHost ): name = "nuke" def open_workfile(self, filepath): return open_file(filepath) def save_workfile(self, filepath=None): return save_file(filepath) def work_root(self, session): return work_root(session) def get_current_workfile(self): return current_file() def workfile_has_unsaved_changes(self): return has_unsaved_changes() def get_workfile_extensions(self): return file_extensions() def get_workfile_build_placeholder_plugins(self): return [ NukePlaceholderLoadPlugin, NukePlaceholderCreatePlugin ] def get_containers(self): return ls() def install(self): ''' Installing all requarements for Nuke host ''' pyblish.api.register_host("nuke") self.log.info("Registering Nuke plug-ins..") pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) register_inventory_action_path(INVENTORY_PATH) # Register Avalon event for workfiles loading. register_event_callback("workio.open_file", check_inventory_versions) register_event_callback("taskChanged", change_context_label) _install_menu() # add script menu add_scripts_menu() add_scripts_gizmo() add_nuke_callbacks() launch_workfiles_app() def get_context_data(self): root_node = nuke.root() return get_node_data(root_node, ROOT_DATA_KNOB) def update_context_data(self, data, changes): root_node = nuke.root() set_node_data(root_node, ROOT_DATA_KNOB, data) def add_nuke_callbacks(): """ Adding all available nuke callbacks """ nuke_settings = get_current_project_settings()["nuke"] workfile_settings = WorkfileSettings() # Set context settings. nuke.addOnCreate( workfile_settings.set_context_settings, nodeClass="Root") # adding favorites to file browser nuke.addOnCreate(workfile_settings.set_favorites, nodeClass="Root") # template builder callbacks nuke.addOnCreate(start_workfile_template_builder, nodeClass="Root") # TODO: remove this callback once workfile builder will be removed nuke.addOnCreate(process_workfile_builder, nodeClass="Root") # fix ffmpeg settings on script nuke.addOnScriptLoad(on_script_load) # set checker for last versions on loaded containers nuke.addOnScriptLoad(check_inventory_versions) nuke.addOnScriptSave(check_inventory_versions) # set apply all workfile settings on script load and save nuke.addOnScriptLoad(WorkfileSettings().set_context_settings) if nuke_settings["nuke-dirmap"]["enabled"]: log.info("Added Nuke's dir-mapping callback ...") # Add dirmap for file paths. nuke.addFilenameFilter(dirmap_file_name_filter) log.info("Added Nuke callbacks ...") def reload_config(): """Attempt to reload pipeline at run-time. CAUTION: This is primarily for development and debugging purposes. """ for module in ( "openpype.hosts.nuke.api.actions", "openpype.hosts.nuke.api.menu", "openpype.hosts.nuke.api.plugin", "openpype.hosts.nuke.api.lib", ): log.info("Reloading module: {}...".format(module)) module = importlib.import_module(module) try: importlib.reload(module) except AttributeError as e: from importlib import reload log.warning("Cannot reload module: {}".format(e)) reload(module) def _show_workfiles(): # Make sure parent is not set # - this makes Workfiles tool as separated window which # avoid issues with reopening # - it is possible to explicitly change on top flag of the tool host_tools.show_workfiles(parent=None, on_top=False) def get_context_label(): return "{0}, {1}".format( get_current_asset_name(), get_current_task_name() ) def _install_menu(): """Install Avalon menu into Nuke's main menu bar.""" # uninstall original avalon menu main_window = get_main_window() menubar = nuke.menu("Nuke") menu = menubar.addMenu(MENU_LABEL) if not ASSIST: label = get_context_label() context_action_item = menu.addCommand("Context") context_action_item.setEnabled(False) Context.context_action_item = context_action_item context_action = context_action_item.action() context_action.setText(label) # add separator after context label menu.addSeparator() menu.addCommand( "Work Files...", _show_workfiles ) menu.addSeparator() if not ASSIST: # only add parent if nuke version is 14 or higher # known issue with no solution yet menu.addCommand( "Create...", lambda: host_tools.show_publisher( parent=main_window, tab="create" ) ) # only add parent if nuke version is 14 or higher # known issue with no solution yet menu.addCommand( "Publish...", lambda: host_tools.show_publisher( parent=main_window, tab="publish" ) ) menu.addCommand( "Load...", lambda: host_tools.show_loader( parent=main_window, use_context=True ) ) menu.addCommand( "Manage...", lambda: host_tools.show_scene_inventory(parent=main_window) ) menu.addSeparator() menu.addCommand( "Library...", lambda: host_tools.show_library_loader( parent=main_window ) ) menu.addSeparator() menu.addCommand( "Set Resolution", lambda: WorkfileSettings().reset_resolution() ) menu.addCommand( "Set Frame Range", lambda: WorkfileSettings().reset_frame_range_handles() ) menu.addCommand( "Set Colorspace", lambda: WorkfileSettings().set_colorspace() ) menu.addCommand( "Apply All Settings", lambda: WorkfileSettings().set_context_settings() ) menu.addSeparator() menu.addCommand( "Build Workfile", lambda: BuildWorkfile().process() ) menu_template = menu.addMenu("Template Builder") # creating template menu menu_template.addCommand( "Build Workfile from template", lambda: build_workfile_template() ) if not ASSIST: menu_template.addSeparator() menu_template.addCommand( "Create Place Holder", lambda: create_placeholder() ) menu_template.addCommand( "Update Place Holder", lambda: update_placeholder() ) menu.addSeparator() menu.addCommand( "Experimental tools...", lambda: host_tools.show_experimental_tools_dialog(parent=main_window) ) menu.addCommand( "Push to Project", lambda: push_to_project.main() ) menu.addSeparator() # add reload pipeline only in debug mode if bool(os.getenv("NUKE_DEBUG")): menu.addSeparator() menu.addCommand("Reload Pipeline", reload_config) # adding shortcuts add_shortcuts_from_presets() def change_context_label(): if ASSIST: return context_action_item = Context.context_action_item if context_action_item is None: return context_action = context_action_item.action() old_label = context_action.text() new_label = get_context_label() context_action.setText(new_label) log.info("Task label changed from `{}` to `{}`".format( old_label, new_label)) def add_shortcuts_from_presets(): menubar = nuke.menu("Nuke") nuke_presets = get_current_project_settings()["nuke"]["general"] if nuke_presets.get("menu"): menu_label_mapping = { "create": "Create...", "manage": "Manage...", "load": "Load...", "build_workfile": "Build Workfile", "publish": "Publish..." } for command_name, shortcut_str in nuke_presets.get("menu").items(): log.info("menu_name `{}` | menu_label `{}`".format( command_name, MENU_LABEL )) log.info("Adding Shortcut `{}` to `{}`".format( shortcut_str, command_name )) try: menu = menubar.findItem(MENU_LABEL) item_label = menu_label_mapping[command_name] menuitem = menu.findItem(item_label) menuitem.setShortcut(shortcut_str) except (AttributeError, KeyError) as e: log.error(e) def containerise(node, name, namespace, context, loader=None, data=None): """Bundle `node` into an assembly and imprint it with metadata Containerisation enables a tracking of version, author and origin for loaded assets. Arguments: node (nuke.Node): Nuke's node object to imprint as container name (str): Name of resulting assembly namespace (str): Namespace under which to host container context (dict): Asset information loader (str, optional): Name of node used to produce this container. Returns: node (nuke.Node): containerised nuke's node object """ data = OrderedDict( [ ("schema", "openpype:container-2.0"), ("id", AVALON_CONTAINER_ID), ("name", name), ("namespace", namespace), ("loader", str(loader)), ("representation", context["representation"]["_id"]), ], **data or dict() ) set_avalon_knob_data(node, data) # set tab to first native node.setTab(0) return node def parse_container(node): """Returns containerised data of a node Reads the imprinted data from `containerise`. Arguments: node (nuke.Node): Nuke's node object to read imprinted data Returns: dict: The container schema data for this container node. """ data = read_avalon_data(node) # If not all required data return the empty container required = ["schema", "id", "name", "namespace", "loader", "representation"] if not all(key in data for key in required): return # Store the node's name data.update({ "objectName": node.fullName(), "node": node, }) return data def update_container(node, keys=None): """Returns node with updateted containder data Arguments: node (nuke.Node): The node in Nuke to imprint as container, keys (dict, optional): data which should be updated Returns: node (nuke.Node): nuke node with updated container data Raises: TypeError on given an invalid container node """ keys = keys or dict() container = parse_container(node) if not container: raise TypeError("Not a valid container node.") container.update(keys) node = set_avalon_knob_data(node, container) return node def ls(): """List available containers. This function is used by the Container Manager in Nuke. You'll need to implement a for-loop that then *yields* one Container at a time. See the `container.json` schema for details on how it should look, and the Maya equivalent, which is in `avalon.maya.pipeline` """ all_nodes = nuke.allNodes(recurseGroups=False) nodes = [n for n in all_nodes] for n in nodes: container = parse_container(n) if container: yield container def list_instances(creator_id=None): """List all created instances to publish from current workfile. For SubsetManager Args: creator_id (Optional[str]): creator identifier Returns: (list) of dictionaries matching instances format """ instances_by_order = defaultdict(list) subset_instances = [] instance_ids = set() for node in nuke.allNodes(recurseGroups=True): if node.Class() in ["Viewer", "Dot"]: continue try: if node["disable"].value(): continue except NameError: # pass if disable knob doesn't exist pass # get data from avalon knob instance_data = get_node_data( node, INSTANCE_DATA_KNOB) if not instance_data: continue if instance_data["id"] != "pyblish.avalon.instance": continue if creator_id and instance_data["creator_identifier"] != creator_id: continue instance_id = instance_data.get("instance_id") if not instance_id: pass elif instance_id in instance_ids: instance_data.pop("instance_id") else: instance_ids.add(instance_id) # node name could change, so update subset name data _update_subset_name_data(instance_data, node) if "render_order" not in node.knobs(): subset_instances.append((node, instance_data)) continue order = int(node["render_order"].value()) instances_by_order[order].append((node, instance_data)) # Sort instances based on order attribute or subset name. # TODO: remove in future Publisher enhanced with sorting ordered_instances = [] for key in sorted(instances_by_order.keys()): instances_by_subset = defaultdict(list) for node, data_ in instances_by_order[key]: instances_by_subset[data_["subset"]].append((node, data_)) for subkey in sorted(instances_by_subset.keys()): ordered_instances.extend(instances_by_subset[subkey]) instances_by_subset = defaultdict(list) for node, data_ in subset_instances: instances_by_subset[data_["subset"]].append((node, data_)) for key in sorted(instances_by_subset.keys()): ordered_instances.extend(instances_by_subset[key]) return ordered_instances def _update_subset_name_data(instance_data, node): """Update subset name data in instance data. Args: instance_data (dict): instance creator data node (nuke.Node): nuke node """ # make sure node name is subset name old_subset_name = instance_data["subset"] old_variant = instance_data["variant"] subset_name_root = old_subset_name.replace(old_variant, "") new_subset_name = node.name() new_variant = new_subset_name.replace(subset_name_root, "") instance_data["subset"] = new_subset_name instance_data["variant"] = new_variant def remove_instance(instance): """Remove instance from current workfile metadata. For SubsetManager Args: instance (dict): instance representation from subsetmanager model """ instance_node = instance.transient_data["node"] instance_knob = instance_node.knobs()[INSTANCE_DATA_KNOB] instance_node.removeKnob(instance_knob) nuke.delete(instance_node) def select_instance(instance): """ Select instance in Node View Args: instance (dict): instance representation from subsetmanager model """ instance_node = instance.transient_data["node"] instance_node["selected"].setValue(True) ================================================ FILE: openpype/hosts/nuke/api/plugin.py ================================================ import nuke import re import os import sys import six import random import string from collections import OrderedDict, defaultdict from abc import abstractmethod from openpype.settings import get_current_project_settings from openpype.lib import ( BoolDef, EnumDef ) from openpype.pipeline import ( LegacyCreator, LoaderPlugin, CreatorError, Creator as NewCreator, CreatedInstance, get_current_task_name ) from openpype.pipeline.colorspace import ( get_display_view_colorspace_name, get_colorspace_settings_from_publish_context, set_colorspace_data_to_representation ) from openpype.lib.transcoding import ( VIDEO_EXTENSIONS ) from .lib import ( INSTANCE_DATA_KNOB, Knobby, check_subsetname_exists, maintained_selection, get_avalon_knob_data, set_avalon_knob_data, add_publish_knob, get_nuke_imageio_settings, set_node_knobs_from_settings, set_node_data, get_node_data, get_view_process_node, get_viewer_config_from_string, deprecated, get_filenames_without_hash, link_knobs ) from .pipeline import ( list_instances, remove_instance ) def _collect_and_cache_nodes(creator): key = "openpype.nuke.nodes" if key not in creator.collection_shared_data: instances_by_identifier = defaultdict(list) for item in list_instances(): _, instance_data = item identifier = instance_data["creator_identifier"] instances_by_identifier[identifier].append(item) creator.collection_shared_data[key] = instances_by_identifier return creator.collection_shared_data[key] class NukeCreatorError(CreatorError): pass class NukeCreator(NewCreator): selected_nodes = [] def pass_pre_attributes_to_instance( self, instance_data, pre_create_data, keys=None ): if not keys: keys = pre_create_data.keys() creator_attrs = instance_data["creator_attributes"] = {} for pass_key in keys: creator_attrs[pass_key] = pre_create_data[pass_key] def check_existing_subset(self, subset_name): """Make sure subset name is unique. It search within all nodes recursively and checks if subset name is found in any node having instance data knob. Arguments: subset_name (str): Subset name """ for node in nuke.allNodes(recurseGroups=True): # make sure testing node is having instance knob if INSTANCE_DATA_KNOB not in node.knobs().keys(): continue node_data = get_node_data(node, INSTANCE_DATA_KNOB) if not node_data: # a node has no instance data continue # test if subset name is matching if node_data.get("subset") == subset_name: raise NukeCreatorError( ( "A publish instance for '{}' already exists " "in nodes! Please change the variant " "name to ensure unique output." ).format(subset_name) ) def create_instance_node( self, node_name, knobs=None, parent=None, node_type=None ): """Create node representing instance. Arguments: node_name (str): Name of the new node. knobs (OrderedDict): node knobs name and values parent (str): Name of the parent node. node_type (str, optional): Nuke node Class. Returns: nuke.Node: Newly created instance node. """ node_type = node_type or "NoOp" node_knobs = knobs or {} # set parent node parent_node = nuke.root() if parent: parent_node = nuke.toNode(parent) try: with parent_node: created_node = nuke.createNode(node_type) created_node["name"].setValue(node_name) for key, values in node_knobs.items(): if key in created_node.knobs(): created_node["key"].setValue(values) except Exception as _err: raise NukeCreatorError("Creating have failed: {}".format(_err)) return created_node def set_selected_nodes(self, pre_create_data): if pre_create_data.get("use_selection"): self.selected_nodes = nuke.selectedNodes() if self.selected_nodes == []: raise NukeCreatorError("Creator error: No active selection") else: self.selected_nodes = [] def create(self, subset_name, instance_data, pre_create_data): # make sure selected nodes are added self.set_selected_nodes(pre_create_data) # make sure subset name is unique self.check_existing_subset(subset_name) try: instance_node = self.create_instance_node( subset_name, node_type=instance_data.pop("node_type", None) ) instance = CreatedInstance( self.family, subset_name, instance_data, self ) instance.transient_data["node"] = instance_node self._add_instance_to_context(instance) set_node_data( instance_node, INSTANCE_DATA_KNOB, instance.data_to_store()) return instance except Exception as er: six.reraise( NukeCreatorError, NukeCreatorError("Creator error: {}".format(er)), sys.exc_info()[2]) def collect_instances(self): cached_instances = _collect_and_cache_nodes(self) attr_def_keys = { attr_def.key for attr_def in self.get_instance_attr_defs() } attr_def_keys.discard(None) for (node, data) in cached_instances[self.identifier]: created_instance = CreatedInstance.from_existing( data, self ) created_instance.transient_data["node"] = node self._add_instance_to_context(created_instance) for key in ( set(created_instance["creator_attributes"].keys()) - attr_def_keys ): created_instance["creator_attributes"].pop(key) def update_instances(self, update_list): for created_inst, changes in update_list: instance_node = created_inst.transient_data["node"] # update instance node name if subset name changed if "subset" in changes.changed_keys: instance_node["name"].setValue( changes["subset"].new_value ) # in case node is not existing anymore (user erased it manually) try: instance_node.fullName() except ValueError: self.remove_instances([created_inst]) continue set_node_data( instance_node, INSTANCE_DATA_KNOB, created_inst.data_to_store() ) def remove_instances(self, instances): for instance in instances: remove_instance(instance) self._remove_instance_from_context(instance) def get_pre_create_attr_defs(self): return [ BoolDef( "use_selection", default=not self.create_context.headless, label="Use selection" ) ] def get_creator_settings(self, project_settings, settings_key=None): if not settings_key: settings_key = self.__class__.__name__ return project_settings["nuke"]["create"][settings_key] class NukeWriteCreator(NukeCreator): """Add Publishable Write node""" identifier = "create_write" label = "Create Write" family = "write" icon = "sign-out" def get_linked_knobs(self): linked_knobs = [] if "channels" in self.instance_attributes: linked_knobs.append("channels") if "ordered" in self.instance_attributes: linked_knobs.append("render_order") if "use_range_limit" in self.instance_attributes: linked_knobs.extend(["___", "first", "last", "use_limit"]) return linked_knobs def integrate_links(self, node, outputs=True): # skip if no selection if not self.selected_node: return # collect dependencies input_nodes = [self.selected_node] dependent_nodes = self.selected_node.dependent() if outputs else [] # relinking to collected connections for i, input in enumerate(input_nodes): node.setInput(i, input) # make it nicer in graph node.autoplace() # relink also dependent nodes for dep_nodes in dependent_nodes: dep_nodes.setInput(0, node) def set_selected_nodes(self, pre_create_data): if pre_create_data.get("use_selection"): selected_nodes = nuke.selectedNodes() if selected_nodes == []: raise NukeCreatorError("Creator error: No active selection") elif len(selected_nodes) > 1: NukeCreatorError("Creator error: Select only one camera node") self.selected_node = selected_nodes[0] else: self.selected_node = None def get_pre_create_attr_defs(self): attr_defs = [ BoolDef("use_selection", label="Use selection"), self._get_render_target_enum() ] return attr_defs def get_instance_attr_defs(self): attr_defs = [ self._get_render_target_enum(), ] # add reviewable attribute if "reviewable" in self.instance_attributes: attr_defs.append(self._get_reviewable_bool()) return attr_defs def _get_render_target_enum(self): rendering_targets = { "local": "Local machine rendering", "frames": "Use existing frames" } if ("farm_rendering" in self.instance_attributes): rendering_targets["frames_farm"] = "Use existing frames - farm" rendering_targets["farm"] = "Farm rendering" return EnumDef( "render_target", items=rendering_targets, label="Render target" ) def _get_reviewable_bool(self): return BoolDef( "review", default=True, label="Review" ) def create(self, subset_name, instance_data, pre_create_data): # make sure selected nodes are added self.set_selected_nodes(pre_create_data) # make sure subset name is unique self.check_existing_subset(subset_name) instance_node = self.create_instance_node( subset_name, instance_data ) try: instance = CreatedInstance( self.family, subset_name, instance_data, self ) instance.transient_data["node"] = instance_node self._add_instance_to_context(instance) set_node_data( instance_node, INSTANCE_DATA_KNOB, instance.data_to_store()) return instance except Exception as er: six.reraise( NukeCreatorError, NukeCreatorError("Creator error: {}".format(er)), sys.exc_info()[2] ) def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" # plugin settings plugin_settings = self.get_creator_settings(project_settings) # individual attributes self.instance_attributes = plugin_settings.get( "instance_attributes") or self.instance_attributes self.prenodes = plugin_settings["prenodes"] self.default_variants = plugin_settings.get( "default_variants") or self.default_variants self.temp_rendering_path_template = ( plugin_settings.get("temp_rendering_path_template") or self.temp_rendering_path_template ) class OpenPypeCreator(LegacyCreator): """Pype Nuke Creator class wrapper""" node_color = "0xdfea5dff" def __init__(self, *args, **kwargs): super(OpenPypeCreator, self).__init__(*args, **kwargs) if check_subsetname_exists( nuke.allNodes(), self.data["subset"]): msg = ("The subset name `{0}` is already used on a node in" "this workfile.".format(self.data["subset"])) self.log.error(msg + "\n\nPlease use other subset name!") raise NameError("`{0}: {1}".format(__name__, msg)) return def process(self): from nukescripts import autoBackdrop instance = None if (self.options or {}).get("useSelection"): nodes = nuke.selectedNodes() if not nodes: nuke.message("Please select nodes that you " "wish to add to a container") return elif len(nodes) == 1: # only one node is selected instance = nodes[0] if not instance: # Not using selection or multiple nodes selected bckd_node = autoBackdrop() bckd_node["tile_color"].setValue(int(self.node_color, 16)) bckd_node["note_font_size"].setValue(24) bckd_node["label"].setValue("[{}]".format(self.name)) instance = bckd_node # add avalon knobs set_avalon_knob_data(instance, self.data) add_publish_knob(instance) return instance def get_instance_group_node_childs(instance): """Return list of instance group node children Args: instance (pyblish.Instance): pyblish instance Returns: list: [nuke.Node] """ node = instance.data["transientData"]["node"] if node.Class() != "Group": return # collect child nodes child_nodes = [] # iterate all nodes for node in nuke.allNodes(group=node): # add contained nodes to instance's node list child_nodes.append(node) return child_nodes def get_colorspace_from_node(node): # Add version data to instance colorspace = node["colorspace"].value() # remove default part of the string if "default (" in colorspace: colorspace = re.sub(r"default.\(|\)", "", colorspace) return colorspace def get_review_presets_config(): settings = get_current_project_settings() review_profiles = ( settings["global"] ["publish"] ["ExtractReview"] ["profiles"] ) outputs = {} for profile in review_profiles: outputs.update(profile.get("outputs", {})) return [str(name) for name, _prop in outputs.items()] class NukeLoader(LoaderPlugin): container_id_knob = "containerId" container_id = None def reset_container_id(self): self.container_id = "".join(random.choice( string.ascii_uppercase + string.digits) for _ in range(10)) def get_container_id(self, node): id_knob = node.knobs().get(self.container_id_knob) return id_knob.value() if id_knob else None def get_members(self, source): """Return nodes that has same "containerId" as `source`""" source_id = self.get_container_id(source) return [node for node in nuke.allNodes(recurseGroups=True) if self.get_container_id(node) == source_id and node is not source] if source_id else [] def set_as_member(self, node): source_id = self.get_container_id(node) if source_id: node[self.container_id_knob].setValue(source_id) else: HIDEN_FLAG = 0x00040000 _knob = Knobby( "String_Knob", self.container_id, flags=[ nuke.READ_ONLY, HIDEN_FLAG ]) knob = _knob.create(self.container_id_knob) node.addKnob(knob) def clear_members(self, parent_node): parent_class = parent_node.Class() members = self.get_members(parent_node) dependent_nodes = None for node in members: _depndc = [n for n in node.dependent() if n not in members] if not _depndc: continue dependent_nodes = _depndc break for member in members: if member.Class() == parent_class: continue self.log.info("removing node: `{}".format(member.name())) nuke.delete(member) return dependent_nodes class ExporterReview(object): """ Base class object for generating review data from Nuke Args: klass (pyblish.plugin): pyblish plugin parent instance (pyblish.instance): instance of pyblish context """ data = None publish_on_farm = False def __init__(self, klass, instance, multiple_presets=True ): self.log = klass.log self.instance = instance self.multiple_presets = multiple_presets self.path_in = self.instance.data.get("path", None) self.staging_dir = self.instance.data["stagingDir"] self.collection = self.instance.data.get("collection", None) self.data = {"representations": []} def get_file_info(self): if self.collection: # get path self.fname = os.path.basename( self.collection.format("{head}{padding}{tail}") ) self.fhead = self.collection.format("{head}") # get first and last frame self.first_frame = min(self.collection.indexes) self.last_frame = max(self.collection.indexes) # make sure slate frame is not included frame_start_handle = self.instance.data["frameStartHandle"] if frame_start_handle > self.first_frame: self.first_frame = frame_start_handle else: self.fname = os.path.basename(self.path_in) self.fhead = os.path.splitext(self.fname)[0] + "." self.first_frame = self.instance.data["frameStartHandle"] self.last_frame = self.instance.data["frameEndHandle"] if "#" in self.fhead: self.fhead = self.fhead.replace("#", "")[:-1] def get_representation_data( self, tags=None, range=False, custom_tags=None, colorspace=None ): """ Add representation data to self.data Args: tags (list[str], optional): list of defined tags. Defaults to None. range (bool, optional): flag for adding ranges. Defaults to False. custom_tags (list[str], optional): user inputted custom tags. Defaults to None. """ add_tags = tags or [] repre = { "name": self.name, "ext": self.ext, "files": self.file, "stagingDir": self.staging_dir, "tags": [self.name.replace("_", "-")] + add_tags } if custom_tags: repre["custom_tags"] = custom_tags if range: repre.update({ "frameStart": self.first_frame, "frameEnd": self.last_frame, }) if ".{}".format(self.ext) not in VIDEO_EXTENSIONS: filenames = get_filenames_without_hash( self.file, self.first_frame, self.last_frame) repre["files"] = filenames if self.multiple_presets: repre["outputName"] = self.name if self.publish_on_farm: repre["tags"].append("publish_on_farm") # add colorspace data to representation if colorspace: set_colorspace_data_to_representation( repre, self.instance.context.data, colorspace=colorspace, log=self.log ) self.data["representations"].append(repre) def get_imageio_baking_profile(self): from . import lib as opnlib nuke_imageio = opnlib.get_nuke_imageio_settings() # TODO: this is only securing backward compatibility lets remove # this once all projects's anatomy are updated to newer config if "baking" in nuke_imageio.keys(): return nuke_imageio["baking"]["viewerProcess"] else: return nuke_imageio["viewer"]["viewerProcess"] class ExporterReviewLut(ExporterReview): """ Generator object for review lut from Nuke Args: klass (pyblish.plugin): pyblish plugin parent instance (pyblish.instance): instance of pyblish context """ _temp_nodes = [] def __init__(self, klass, instance, name=None, ext=None, cube_size=None, lut_size=None, lut_style=None, multiple_presets=True): # initialize parent class super(ExporterReviewLut, self).__init__( klass, instance, multiple_presets) # deal with now lut defined in viewer lut if hasattr(klass, "viewer_lut_raw"): self.viewer_lut_raw = klass.viewer_lut_raw else: self.viewer_lut_raw = False self.name = name or "baked_lut" self.ext = ext or "cube" self.cube_size = cube_size or 32 self.lut_size = lut_size or 1024 self.lut_style = lut_style or "linear" # set frame start / end and file name to self self.get_file_info() self.log.info("File info was set...") self.file = self.fhead + self.name + ".{}".format(self.ext) self.path = os.path.join( self.staging_dir, self.file).replace("\\", "/") def clean_nodes(self): for node in self._temp_nodes: nuke.delete(node) self._temp_nodes = [] self.log.info("Deleted nodes...") def generate_lut(self, **kwargs): bake_viewer_process = kwargs["bake_viewer_process"] bake_viewer_input_process_node = kwargs[ "bake_viewer_input_process"] # ---------- start nodes creation # CMSTestPattern cms_node = nuke.createNode("CMSTestPattern") cms_node["cube_size"].setValue(self.cube_size) # connect self._temp_nodes.append(cms_node) self.previous_node = cms_node if bake_viewer_process: # Node View Process if bake_viewer_input_process_node: ipn = get_view_process_node() if ipn is not None: # connect ipn.setInput(0, self.previous_node) self._temp_nodes.append(ipn) self.previous_node = ipn self.log.debug( "ViewProcess... `{}`".format(self._temp_nodes)) if not self.viewer_lut_raw: # OCIODisplay dag_node = nuke.createNode("OCIODisplay") # connect dag_node.setInput(0, self.previous_node) self._temp_nodes.append(dag_node) self.previous_node = dag_node self.log.debug( "OCIODisplay... `{}`".format(self._temp_nodes)) # GenerateLUT gen_lut_node = nuke.createNode("GenerateLUT") gen_lut_node["file"].setValue(self.path) gen_lut_node["file_type"].setValue(".{}".format(self.ext)) gen_lut_node["lut1d"].setValue(self.lut_size) gen_lut_node["style1d"].setValue(self.lut_style) # connect gen_lut_node.setInput(0, self.previous_node) self._temp_nodes.append(gen_lut_node) # ---------- end nodes creation # Export lut file nuke.execute( gen_lut_node.name(), int(self.first_frame), int(self.first_frame)) self.log.info("Exported...") # ---------- generate representation data self.get_representation_data() # ---------- Clean up self.clean_nodes() return self.data class ExporterReviewMov(ExporterReview): """ Metaclass for generating review mov files Args: klass (pyblish.plugin): pyblish plugin parent instance (pyblish.instance): instance of pyblish context """ _temp_nodes = {} def __init__(self, klass, instance, name=None, ext=None, multiple_presets=True ): # initialize parent class super(ExporterReviewMov, self).__init__( klass, instance, multiple_presets) # passing presets for nodes to self self.nodes = klass.nodes if hasattr(klass, "nodes") else {} # deal with now lut defined in viewer lut self.viewer_lut_raw = klass.viewer_lut_raw self.write_colorspace = instance.data["colorspace"] self.name = name or "baked" self.ext = ext or "mov" # set frame start / end and file name to self self.get_file_info() self.log.info("File info was set...") if ".{}".format(self.ext) in VIDEO_EXTENSIONS: self.file = "{}{}.{}".format( self.fhead, self.name, self.ext) else: # Output is image (or image sequence) # When the file is an image it's possible it # has extra information after the `fhead` that # we want to preserve, e.g. like frame numbers # or frames hashes like `####` filename_no_ext = os.path.splitext( os.path.basename(self.path_in))[0] after_head = filename_no_ext[len(self.fhead):] self.file = "{}{}.{}.{}".format( self.fhead, self.name, after_head, self.ext) self.path = os.path.join( self.staging_dir, self.file).replace("\\", "/") def clean_nodes(self, node_name): for node in self._temp_nodes[node_name]: nuke.delete(node) self._temp_nodes[node_name] = [] self.log.info("Deleted nodes...") def render(self, render_node_name): self.log.info("Rendering... ") # Render Write node nuke.execute( render_node_name, int(self.first_frame), int(self.last_frame)) self.log.info("Rendered...") def save_file(self): import shutil with maintained_selection(): self.log.info("Saving nodes as file... ") # create nk path path = os.path.splitext(self.path)[0] + ".nk" # save file to the path if not os.path.exists(os.path.dirname(path)): os.makedirs(os.path.dirname(path)) shutil.copyfile(self.instance.context.data["currentFile"], path) self.log.info("Nodes exported...") return path def generate_mov(self, farm=False, **kwargs): # colorspace data colorspace = None # get colorspace settings # get colorspace data from context config_data, _ = get_colorspace_settings_from_publish_context( self.instance.context.data) add_tags = [] self.publish_on_farm = farm read_raw = kwargs["read_raw"] bake_viewer_process = kwargs["bake_viewer_process"] bake_viewer_input_process_node = kwargs[ "bake_viewer_input_process"] viewer_process_override = kwargs[ "viewer_process_override"] baking_view_profile = ( viewer_process_override or self.get_imageio_baking_profile()) fps = self.instance.context.data["fps"] self.log.debug(">> baking_view_profile `{}`".format( baking_view_profile)) add_custom_tags = kwargs.get("add_custom_tags", []) self.log.info( "__ add_custom_tags: `{0}`".format(add_custom_tags)) subset = self.instance.data["subset"] self._temp_nodes[subset] = [] # Read node r_node = nuke.createNode("Read") r_node["file"].setValue(self.path_in) r_node["first"].setValue(self.first_frame) r_node["origfirst"].setValue(self.first_frame) r_node["last"].setValue(self.last_frame) r_node["origlast"].setValue(self.last_frame) r_node["colorspace"].setValue(self.write_colorspace) # do not rely on defaults, set explicitly # to be sure it is set correctly r_node["frame_mode"].setValue("expression") r_node["frame"].setValue("") if read_raw: r_node["raw"].setValue(1) # connect to Read node self._shift_to_previous_node_and_temp(subset, r_node, "Read... `{}`") # add reformat node reformat_nodes_config = kwargs["reformat_nodes_config"] if reformat_nodes_config["enabled"]: reposition_nodes = reformat_nodes_config["reposition_nodes"] for reposition_node in reposition_nodes: node_class = reposition_node["node_class"] knobs = reposition_node["knobs"] node = nuke.createNode(node_class) set_node_knobs_from_settings(node, knobs) # connect in order self._connect_to_above_nodes( node, subset, "Reposition node... `{}`" ) # append reformated tag add_tags.append("reformated") # only create colorspace baking if toggled on if bake_viewer_process: if bake_viewer_input_process_node: # View Process node ipn = get_view_process_node() if ipn is not None: # connect to ViewProcess node self._connect_to_above_nodes(ipn, subset, "ViewProcess... `{}`") if not self.viewer_lut_raw: # OCIODisplay dag_node = nuke.createNode("OCIODisplay") # assign display display, viewer = get_viewer_config_from_string( str(baking_view_profile) ) if display: dag_node["display"].setValue(display) # assign viewer dag_node["view"].setValue(viewer) if config_data: # convert display and view to colorspace colorspace = get_display_view_colorspace_name( config_path=config_data["path"], display=display, view=viewer ) self._connect_to_above_nodes(dag_node, subset, "OCIODisplay... `{}`") # Write node write_node = nuke.createNode("Write") self.log.debug("Path: {}".format(self.path)) write_node["file"].setValue(str(self.path)) write_node["file_type"].setValue(str(self.ext)) # Knobs `meta_codec` and `mov64_codec` are not available on centos. # TODO shouldn't this come from settings on outputs? try: write_node["meta_codec"].setValue("ap4h") except Exception: self.log.info("`meta_codec` knob was not found") try: write_node["mov64_codec"].setValue("ap4h") write_node["mov64_fps"].setValue(float(fps)) except Exception: self.log.info("`mov64_codec` knob was not found") try: write_node["mov64_write_timecode"].setValue(1) except Exception: self.log.info("`mov64_write_timecode` knob was not found") write_node["raw"].setValue(1) # connect write_node.setInput(0, self.previous_node) self._temp_nodes[subset].append(write_node) self.log.debug("Write... `{}`".format(self._temp_nodes[subset])) # ---------- end nodes creation # ---------- render or save to nk if self.publish_on_farm: nuke.scriptSave() path_nk = self.save_file() self.data.update({ "bakeScriptPath": path_nk, "bakeWriteNodeName": write_node.name(), "bakeRenderPath": self.path }) else: self.render(write_node.name()) # ---------- generate representation data self.get_representation_data( tags=["review", "need_thumbnail", "delete"] + add_tags, custom_tags=add_custom_tags, range=True, colorspace=colorspace ) self.log.debug("Representation... `{}`".format(self.data)) self.clean_nodes(subset) nuke.scriptSave() return self.data def _shift_to_previous_node_and_temp(self, subset, node, message): self._temp_nodes[subset].append(node) self.previous_node = node self.log.debug(message.format(self._temp_nodes[subset])) def _connect_to_above_nodes(self, node, subset, message): node.setInput(0, self.previous_node) self._shift_to_previous_node_and_temp(subset, node, message) @deprecated("openpype.hosts.nuke.api.plugin.NukeWriteCreator") class AbstractWriteRender(OpenPypeCreator): """Abstract creator to gather similar implementation for Write creators""" name = "" label = "" hosts = ["nuke"] n_class = "Write" family = "render" icon = "sign-out" defaults = ["Main", "Mask"] knobs = [] prenodes = {} def __init__(self, *args, **kwargs): super(AbstractWriteRender, self).__init__(*args, **kwargs) data = OrderedDict() data["family"] = self.family data["families"] = self.n_class for k, v in self.data.items(): if k not in data.keys(): data.update({k: v}) self.data = data self.nodes = nuke.selectedNodes() def process(self): inputs = [] outputs = [] instance = nuke.toNode(self.data["subset"]) selected_node = None # use selection if (self.options or {}).get("useSelection"): nodes = self.nodes if not (len(nodes) < 2): msg = ("Select only one node. " "The node you want to connect to, " "or tick off `Use selection`") self.log.error(msg) nuke.message(msg) return if len(nodes) == 0: msg = ( "No nodes selected. Please select a single node to connect" " to or tick off `Use selection`" ) self.log.error(msg) nuke.message(msg) return selected_node = nodes[0] inputs = [selected_node] outputs = selected_node.dependent() if instance: if (instance.name() in selected_node.name()): selected_node = instance.dependencies()[0] # if node already exist if instance: # collect input / outputs inputs = instance.dependencies() outputs = instance.dependent() selected_node = inputs[0] # remove old one nuke.delete(instance) # recreate new write_data = { "nodeclass": self.n_class, "families": [self.family], "avalon": self.data, "subset": self.data["subset"], "knobs": self.knobs } # add creator data creator_data = {"creator": self.__class__.__name__} self.data.update(creator_data) write_data.update(creator_data) write_node = self._create_write_node( selected_node, inputs, outputs, write_data ) # relinking to collected connections for i, input in enumerate(inputs): write_node.setInput(i, input) write_node.autoplace() for output in outputs: output.setInput(0, write_node) write_node = self._modify_write_node(write_node) return write_node def is_legacy(self): """Check if it needs to run legacy code In case where `type` key is missing in single knob it is legacy project anatomy. Returns: bool: True if legacy """ imageio_nodes = get_nuke_imageio_settings()["nodes"] node = imageio_nodes["requiredNodes"][0] if "type" not in node["knobs"][0]: # if type is not yet in project anatomy return True elif next(iter( _k for _k in node["knobs"] if _k.get("type") == "__legacy__" ), None): # in case someone re-saved anatomy # with old configuration return True @abstractmethod def _create_write_node(self, selected_node, inputs, outputs, write_data): """Family dependent implementation of Write node creation Args: selected_node (nuke.Node) inputs (list of nuke.Node) - input dependencies (what is connected) outputs (list of nuke.Node) - output dependencies write_data (dict) - values used to fill Knobs Returns: node (nuke.Node): group node with data as Knobs """ pass @abstractmethod def _modify_write_node(self, write_node): """Family dependent modification of created 'write_node' Returns: node (nuke.Node): group node with data as Knobs """ pass def convert_to_valid_instaces(): """ Check and convert to latest publisher instances Also save as new minor version of workfile. """ def family_to_identifier(family): mapping = { "render": "create_write_render", "prerender": "create_write_prerender", "still": "create_write_image", "model": "create_model", "camera": "create_camera", "nukenodes": "create_backdrop", "gizmo": "create_gizmo", "source": "create_source" } return mapping[family] from openpype.hosts.nuke.api import workio task_name = get_current_task_name() # save into new workfile current_file = workio.current_file() # add file suffex if not if "_publisherConvert" not in current_file: new_workfile = ( current_file[:-3] + "_publisherConvert" + current_file[-3:] ) else: new_workfile = current_file path = new_workfile.replace("\\", "/") nuke.scriptSaveAs(new_workfile, overwrite=1) nuke.Root()["name"].setValue(path) nuke.Root()["project_directory"].setValue(os.path.dirname(path)) nuke.Root().setModified(False) _remove_old_knobs(nuke.Root()) # loop all nodes and convert for node in nuke.allNodes(recurseGroups=True): transfer_data = { "creator_attributes": {} } creator_attr = transfer_data["creator_attributes"] if node.Class() in ["Viewer", "Dot"]: continue if get_node_data(node, INSTANCE_DATA_KNOB): continue # get data from avalon knob avalon_knob_data = get_avalon_knob_data( node, ["avalon:", "ak:"]) if not avalon_knob_data: continue if avalon_knob_data["id"] != "pyblish.avalon.instance": continue transfer_data.update({ k: v for k, v in avalon_knob_data.items() if k not in ["families", "creator"] }) transfer_data["task"] = task_name family = avalon_knob_data["family"] # establish families families_ak = avalon_knob_data.get("families", []) if "suspend_publish" in node.knobs(): creator_attr["suspended_publish"] = ( node["suspend_publish"].value()) # get review knob value if "review" in node.knobs(): creator_attr["review"] = ( node["review"].value()) if "publish" in node.knobs(): transfer_data["active"] = ( node["publish"].value()) # add idetifier transfer_data["creator_identifier"] = family_to_identifier(family) # Add all nodes in group instances. if node.Class() == "Group": # only alter families for render family if families_ak and "write" in families_ak.lower(): target = node["render"].value() if target == "Use existing frames": creator_attr["render_target"] = "frames" elif target == "Local": # Local rendering creator_attr["render_target"] = "local" elif target == "On farm": # Farm rendering creator_attr["render_target"] = "farm" if "deadlinePriority" in node.knobs(): transfer_data["farm_priority"] = ( node["deadlinePriority"].value()) if "deadlineChunkSize" in node.knobs(): creator_attr["farm_chunk"] = ( node["deadlineChunkSize"].value()) if "deadlineConcurrentTasks" in node.knobs(): creator_attr["farm_concurrency"] = ( node["deadlineConcurrentTasks"].value()) _remove_old_knobs(node) # add new instance knob with transfer data set_node_data( node, INSTANCE_DATA_KNOB, transfer_data) nuke.scriptSave() def _remove_old_knobs(node): remove_knobs = [ "review", "publish", "render", "suspend_publish", "warn", "divd", "OpenpypeDataGroup", "OpenpypeDataGroup_End", "deadlinePriority", "deadlineChunkSize", "deadlineConcurrentTasks", "Deadline" ] print(node.name()) # remove all old knobs for knob in node.allKnobs(): try: if knob.name() in remove_knobs: node.removeKnob(knob) elif "avalon" in knob.name(): node.removeKnob(knob) except ValueError: pass def exposed_write_knobs(settings, plugin_name, instance_node): exposed_knobs = settings["nuke"]["create"][plugin_name].get( "exposed_knobs", [] ) if exposed_knobs: instance_node.addKnob(nuke.Text_Knob('', 'Write Knobs')) write_node = nuke.allNodes(group=instance_node, filter="Write")[0] link_knobs(exposed_knobs, write_node, instance_node) ================================================ FILE: openpype/hosts/nuke/api/push_to_project.py ================================================ from collections import defaultdict import shutil import os from openpype.client import get_project, get_asset_by_id from openpype.settings import get_system_settings, get_project_settings from openpype.pipeline import Anatomy, registered_host from openpype.pipeline.template_data import get_template_data from openpype.pipeline.workfile import get_workdir_with_workdir_data from openpype.tools.push_to_project.app import show from .utils import bake_gizmos_recursively import nuke def bake_container(container): """Bake containers to read nodes.""" node = container["node"] # Fetch knobs to remove in order. knobs_to_remove = [] remove = False for count in range(0, node.numKnobs()): knob = node.knob(count) # All knobs from "OpenPype" tab knob onwards. if knob.name() == "OpenPype": remove = True if remove: knobs_to_remove.append(knob) # Dont remove knobs from "containerId" onwards. if knob.name() == "containerId": remove = False # Knobs needs to be remove in reverse order, because child knobs needs to # be remove first. for knob in reversed(knobs_to_remove): node.removeKnob(knob) node["tile_color"].setValue(0) def main(): context = show("", "", False, True) if context is None: return # Get workfile path to save to. project_name = context["project_name"] project_doc = get_project(project_name) asset_doc = get_asset_by_id(project_name, context["asset_id"]) task_name = context["task_name"] host = registered_host() system_settings = get_system_settings() project_settings = get_project_settings(project_name) anatomy = Anatomy(project_name) workdir_data = get_template_data( project_doc, asset_doc, task_name, host.name, system_settings ) workdir = get_workdir_with_workdir_data( workdir_data, project_name, anatomy, project_settings=project_settings ) # Save current workfile. current_file = host.current_file() host.save_file(current_file) for container in host.ls(): bake_container(container) # Bake gizmos. bake_gizmos_recursively() # Copy all read node files to "resources" folder next to workfile and # change file path. first_frame = int(nuke.root()["first_frame"].value()) last_frame = int(nuke.root()["last_frame"].value()) files_by_node_name = defaultdict(set) nodes_by_name = {} for count in range(first_frame, last_frame + 1): nuke.frame(count) for node in nuke.allNodes(filter="Read"): files_by_node_name[node.name()].add( nuke.filename(node, nuke.REPLACE) ) nodes_by_name[node.name()] = node resources_dir = os.path.join(workdir, "resources") for name, files in files_by_node_name.items(): dir = os.path.join(resources_dir, name) if not os.path.exists(dir): os.makedirs(dir) for f in files: shutil.copy(f, os.path.join(dir, os.path.basename(f))) node = nodes_by_name[name] path = node["file"].value().replace(os.path.dirname(f), dir) node["file"].setValue(path.replace("\\", "/")) # Save current workfile to new context. basename = os.path.basename(current_file) host.save_file(os.path.join(workdir, basename)) # Open current contex workfile. host.open_file(current_file) ================================================ FILE: openpype/hosts/nuke/api/utils.py ================================================ import os import re import nuke from openpype import resources from qtpy import QtWidgets def set_context_favorites(favorites=None): """ Adding favorite folders to nuke's browser Arguments: favorites (dict): couples of {name:path} """ favorites = favorites or {} icon_path = resources.get_resource("icons", "folder-favorite.png") for name, path in favorites.items(): nuke.addFavoriteDir( name, path, nuke.IMAGE | nuke.SCRIPT | nuke.GEO, icon=icon_path) def get_node_outputs(node): ''' Return a dictionary of the nodes and pipes that are connected to node ''' dep_dict = {} dependencies = node.dependent(nuke.INPUTS | nuke.HIDDEN_INPUTS) for d in dependencies: dep_dict[d] = [] for i in range(d.inputs()): if d.input(i) == node: dep_dict[d].append(i) return dep_dict def is_node_gizmo(node): ''' return True if node is gizmo ''' return 'gizmo_file' in node.knobs() def gizmo_is_nuke_default(gizmo): '''Check if gizmo is in default install path''' plug_dir = os.path.join(os.path.dirname( nuke.env['ExecutablePath']), 'plugins') return gizmo.filename().startswith(plug_dir) def bake_gizmos_recursively(in_group=None): """Converting a gizmo to group Arguments: is_group (nuke.Node)[optonal]: group node or all nodes """ from .lib import maintained_selection if in_group is None: in_group = nuke.Root() # preserve selection after all is done with maintained_selection(): # jump to the group with in_group: for node in nuke.allNodes(): if is_node_gizmo(node) and not gizmo_is_nuke_default(node): with node: outputs = get_node_outputs(node) group = node.makeGroup() # Reconnect inputs and outputs if any if outputs: for n, pipes in outputs.items(): for i in pipes: n.setInput(i, group) for i in range(node.inputs()): group.setInput(i, node.input(i)) # set node position and name group.setXYpos(node.xpos(), node.ypos()) name = node.name() nuke.delete(node) group.setName(name) node = group if node.Class() == "Group": bake_gizmos_recursively(node) def colorspace_exists_on_node(node, colorspace_name): """ Check if colorspace exists on node Look through all options in the colorspace knob, and see if we have an exact match to one of the items. Args: node (nuke.Node): nuke node object colorspace_name (str): color profile name Returns: bool: True if exists """ try: colorspace_knob = node['colorspace'] except ValueError: # knob is not available on input node return False return colorspace_name in get_colorspace_list(colorspace_knob) def get_colorspace_list(colorspace_knob): """Get available colorspace profile names Args: colorspace_knob (nuke.Knob): nuke knob object Returns: list: list of strings names of profiles """ results = [] # This pattern is to match with roles which uses an indentation and # parentheses with original colorspace. The value returned from the # colorspace is the string before the indentation, so we'll need to # convert the values to match with value returned from the knob, # ei. knob.value(). pattern = r".*\t.* \(.*\)" for colorspace in nuke.getColorspaceList(colorspace_knob): match = re.search(pattern, colorspace) if match: results.append(colorspace.split("\t", 1)[0]) else: results.append(colorspace) return results def is_headless(): """ Returns: bool: headless """ return QtWidgets.QApplication.instance() is None ================================================ FILE: openpype/hosts/nuke/api/workfile_template_builder.py ================================================ import collections import nuke from openpype.pipeline import registered_host from openpype.pipeline.workfile.workfile_template_builder import ( AbstractTemplateBuilder, PlaceholderPlugin, LoadPlaceholderItem, CreatePlaceholderItem, PlaceholderLoadMixin, PlaceholderCreateMixin ) from openpype.tools.workfile_template_build import ( WorkfileBuildPlaceholderDialog, ) from .lib import ( find_free_space_to_paste_nodes, get_extreme_positions, get_group_io_nodes, imprint, refresh_node, refresh_nodes, reset_selection, get_names_from_nodes, get_nodes_by_names, select_nodes, duplicate_node, node_tempfile, get_main_window, WorkfileSettings, ) PLACEHOLDER_SET = "PLACEHOLDERS_SET" class NukeTemplateBuilder(AbstractTemplateBuilder): """Concrete implementation of AbstractTemplateBuilder for nuke""" def import_template(self, path): """Import template into current scene. Block if a template is already loaded. Args: path (str): A path to current template (usually given by get_template_preset implementation) Returns: bool: Whether the template was successfully imported or not """ # TODO check if the template is already imported nuke.nodePaste(path) reset_selection() return True class NukePlaceholderPlugin(PlaceholderPlugin): node_color = 4278190335 def _collect_scene_placeholders(self): # Cache placeholder data to shared data placeholder_nodes = self.builder.get_shared_populate_data( "placeholder_nodes" ) if placeholder_nodes is None: placeholder_nodes = {} all_groups = collections.deque() all_groups.append(nuke.thisGroup()) while all_groups: group = all_groups.popleft() for node in group.nodes(): if isinstance(node, nuke.Group): all_groups.append(node) node_knobs = node.knobs() if ( "is_placeholder" not in node_knobs or not node.knob("is_placeholder").value() ): continue if "empty" in node_knobs and node.knob("empty").value(): continue placeholder_nodes[node.fullName()] = node self.builder.set_shared_populate_data( "placeholder_nodes", placeholder_nodes ) return placeholder_nodes def create_placeholder(self, placeholder_data): placeholder_data["plugin_identifier"] = self.identifier placeholder = nuke.nodes.NoOp() placeholder.setName("PLACEHOLDER") placeholder.knob("tile_color").setValue(self.node_color) imprint(placeholder, placeholder_data) imprint(placeholder, {"is_placeholder": True}) placeholder.knob("is_placeholder").setVisible(False) def update_placeholder(self, placeholder_item, placeholder_data): node = nuke.toNode(placeholder_item.scene_identifier) imprint(node, placeholder_data) def _parse_placeholder_node_data(self, node): placeholder_data = {} for key in self.get_placeholder_keys(): knob = node.knob(key) value = None if knob is not None: value = knob.getValue() placeholder_data[key] = value return placeholder_data def delete_placeholder(self, placeholder): """Remove placeholder if building was successful""" placeholder_node = nuke.toNode(placeholder.scene_identifier) nuke.delete(placeholder_node) class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): identifier = "nuke.load" label = "Nuke load" def _parse_placeholder_node_data(self, node): placeholder_data = super( NukePlaceholderLoadPlugin, self )._parse_placeholder_node_data(node) node_knobs = node.knobs() nb_children = 0 if "nb_children" in node_knobs: nb_children = int(node_knobs["nb_children"].getValue()) placeholder_data["nb_children"] = nb_children siblings = [] if "siblings" in node_knobs: siblings = node_knobs["siblings"].values() placeholder_data["siblings"] = siblings node_full_name = node.fullName() placeholder_data["group_name"] = node_full_name.rpartition(".")[0] placeholder_data["last_loaded"] = [] placeholder_data["delete"] = False return placeholder_data def _get_loaded_repre_ids(self): loaded_representation_ids = self.builder.get_shared_populate_data( "loaded_representation_ids" ) if loaded_representation_ids is None: loaded_representation_ids = set() for node in nuke.allNodes(): if "repre_id" in node.knobs(): loaded_representation_ids.add( node.knob("repre_id").getValue() ) self.builder.set_shared_populate_data( "loaded_representation_ids", loaded_representation_ids ) return loaded_representation_ids def _before_placeholder_load(self, placeholder): placeholder.data["nodes_init"] = nuke.allNodes() def _before_repre_load(self, placeholder, representation): placeholder.data["last_repre_id"] = str(representation["_id"]) def collect_placeholders(self): output = [] scene_placeholders = self._collect_scene_placeholders() for node_name, node in scene_placeholders.items(): plugin_identifier_knob = node.knob("plugin_identifier") if ( plugin_identifier_knob is None or plugin_identifier_knob.getValue() != self.identifier ): continue placeholder_data = self._parse_placeholder_node_data(node) # TODO do data validations and maybe updgrades if are invalid output.append( LoadPlaceholderItem(node_name, placeholder_data, self) ) return output def populate_placeholder(self, placeholder): self.populate_load_placeholder(placeholder) def repopulate_placeholder(self, placeholder): repre_ids = self._get_loaded_repre_ids() self.populate_load_placeholder(placeholder, repre_ids) def get_placeholder_options(self, options=None): return self.get_load_plugin_options(options) def post_placeholder_process(self, placeholder, failed): """Cleanup placeholder after load of its corresponding representations. Args: placeholder (PlaceholderItem): Item which was just used to load representation. failed (bool): Loading of representation failed. """ # deselect all selected nodes placeholder_node = nuke.toNode(placeholder.scene_identifier) # getting the latest nodes added # TODO get from shared populate data! nodes_init = placeholder.data["nodes_init"] nodes_loaded = list(set(nuke.allNodes()) - set(nodes_init)) self.log.debug("Loaded nodes: {}".format(nodes_loaded)) if not nodes_loaded: return placeholder.data["delete"] = True nodes_loaded = self._move_to_placeholder_group( placeholder, nodes_loaded ) placeholder.data["last_loaded"] = nodes_loaded refresh_nodes(nodes_loaded) # positioning of the loaded nodes min_x, min_y, _, _ = get_extreme_positions(nodes_loaded) for node in nodes_loaded: xpos = (node.xpos() - min_x) + placeholder_node.xpos() ypos = (node.ypos() - min_y) + placeholder_node.ypos() node.setXYpos(xpos, ypos) refresh_nodes(nodes_loaded) # fix the problem of z_order for backdrops self._fix_z_order(placeholder) if placeholder.data.get("keep_placeholder"): self._imprint_siblings(placeholder) if placeholder.data["nb_children"] == 0: # save initial nodes positions and dimensions, update them # and set inputs and outputs of loaded nodes if placeholder.data.get("keep_placeholder"): self._imprint_inits() self._update_nodes(placeholder, nuke.allNodes(), nodes_loaded) self._set_loaded_connections(placeholder) elif placeholder.data["siblings"]: # create copies of placeholder siblings for the new loaded nodes, # set their inputs and outputs and update all nodes positions and # dimensions and siblings names siblings = get_nodes_by_names(placeholder.data["siblings"]) refresh_nodes(siblings) copies = self._create_sib_copies(placeholder) new_nodes = list(copies.values()) # copies nodes self._update_nodes(new_nodes, nodes_loaded) placeholder_node.removeKnob(placeholder_node.knob("siblings")) new_nodes_name = get_names_from_nodes(new_nodes) imprint(placeholder_node, {"siblings": new_nodes_name}) self._set_copies_connections(placeholder, copies) self._update_nodes( nuke.allNodes(), new_nodes + nodes_loaded, 20 ) new_siblings = get_names_from_nodes(new_nodes) placeholder.data["siblings"] = new_siblings else: # if the placeholder doesn't have siblings, the loaded # nodes will be placed in a free space xpointer, ypointer = find_free_space_to_paste_nodes( nodes_loaded, direction="bottom", offset=200 ) node = nuke.createNode("NoOp") reset_selection() nuke.delete(node) for node in nodes_loaded: xpos = (node.xpos() - min_x) + xpointer ypos = (node.ypos() - min_y) + ypointer node.setXYpos(xpos, ypos) placeholder.data["nb_children"] += 1 reset_selection() # go back to root group nuke.root().begin() def _move_to_placeholder_group(self, placeholder, nodes_loaded): """ opening the placeholder's group and copying loaded nodes in it. Returns : nodes_loaded (list): the new list of pasted nodes """ groups_name = placeholder.data["group_name"] reset_selection() select_nodes(nodes_loaded) if groups_name: with node_tempfile() as filepath: nuke.nodeCopy(filepath) for node in nuke.selectedNodes(): nuke.delete(node) group = nuke.toNode(groups_name) group.begin() nuke.nodePaste(filepath) nodes_loaded = nuke.selectedNodes() return nodes_loaded def _fix_z_order(self, placeholder): """Fix the problem of z_order when a backdrop is loaded.""" nodes_loaded = placeholder.data["last_loaded"] loaded_backdrops = [] bd_orders = set() for node in nodes_loaded: if isinstance(node, nuke.BackdropNode): loaded_backdrops.append(node) bd_orders.add(node.knob("z_order").getValue()) if not bd_orders: return sib_orders = set() for node_name in placeholder.data["siblings"]: node = nuke.toNode(node_name) if isinstance(node, nuke.BackdropNode): sib_orders.add(node.knob("z_order").getValue()) if not sib_orders: return min_order = min(bd_orders) max_order = max(sib_orders) for backdrop_node in loaded_backdrops: z_order = backdrop_node.knob("z_order").getValue() backdrop_node.knob("z_order").setValue( z_order + max_order - min_order + 1) def _imprint_siblings(self, placeholder): """ - add siblings names to placeholder attributes (nodes loaded with it) - add Id to the attributes of all the other nodes """ loaded_nodes = placeholder.data["last_loaded"] loaded_nodes_set = set(loaded_nodes) data = {"repre_id": str(placeholder.data["last_repre_id"])} for node in loaded_nodes: node_knobs = node.knobs() if "builder_type" not in node_knobs: # save the id of representation for all imported nodes imprint(node, data) node.knob("repre_id").setVisible(False) refresh_node(node) continue if ( "is_placeholder" not in node_knobs or ( "is_placeholder" in node_knobs and node.knob("is_placeholder").value() ) ): siblings = list(loaded_nodes_set - {node}) siblings_name = get_names_from_nodes(siblings) siblings = {"siblings": siblings_name} imprint(node, siblings) def _imprint_inits(self): """Add initial positions and dimensions to the attributes""" for node in nuke.allNodes(): refresh_node(node) imprint(node, {"x_init": node.xpos(), "y_init": node.ypos()}) node.knob("x_init").setVisible(False) node.knob("y_init").setVisible(False) width = node.screenWidth() height = node.screenHeight() if "bdwidth" in node.knobs(): imprint(node, {"w_init": width, "h_init": height}) node.knob("w_init").setVisible(False) node.knob("h_init").setVisible(False) refresh_node(node) def _update_nodes( self, placeholder, nodes, considered_nodes, offset_y=None ): """Adjust backdrop nodes dimensions and positions. Considering some nodes sizes. Args: nodes (list): list of nodes to update considered_nodes (list): list of nodes to consider while updating positions and dimensions offset (int): distance between copies """ placeholder_node = nuke.toNode(placeholder.scene_identifier) min_x, min_y, max_x, max_y = get_extreme_positions(considered_nodes) diff_x = diff_y = 0 contained_nodes = [] # for backdrops if offset_y is None: width_ph = placeholder_node.screenWidth() height_ph = placeholder_node.screenHeight() diff_y = max_y - min_y - height_ph diff_x = max_x - min_x - width_ph contained_nodes = [placeholder_node] min_x = placeholder_node.xpos() min_y = placeholder_node.ypos() else: siblings = get_nodes_by_names(placeholder.data["siblings"]) minX, _, maxX, _ = get_extreme_positions(siblings) diff_y = max_y - min_y + 20 diff_x = abs(max_x - min_x - maxX + minX) contained_nodes = considered_nodes if diff_y <= 0 and diff_x <= 0: return for node in nodes: refresh_node(node) if ( node == placeholder_node or node in considered_nodes ): continue if ( not isinstance(node, nuke.BackdropNode) or ( isinstance(node, nuke.BackdropNode) and not set(contained_nodes) <= set(node.getNodes()) ) ): if offset_y is None and node.xpos() >= min_x: node.setXpos(node.xpos() + diff_x) if node.ypos() >= min_y: node.setYpos(node.ypos() + diff_y) else: width = node.screenWidth() height = node.screenHeight() node.knob("bdwidth").setValue(width + diff_x) node.knob("bdheight").setValue(height + diff_y) refresh_node(node) def _set_loaded_connections(self, placeholder): """ set inputs and outputs of loaded nodes""" placeholder_node = nuke.toNode(placeholder.scene_identifier) input_node, output_node = get_group_io_nodes( placeholder.data["last_loaded"] ) for node in placeholder_node.dependent(): for idx in range(node.inputs()): if node.input(idx) == placeholder_node and output_node: node.setInput(idx, output_node) for node in placeholder_node.dependencies(): for idx in range(placeholder_node.inputs()): if placeholder_node.input(idx) == node and input_node: input_node.setInput(0, node) def _create_sib_copies(self, placeholder): """ creating copies of the palce_holder siblings (the ones who were loaded with it) for the new nodes added Returns : copies (dict) : with copied nodes names and their copies """ copies = {} siblings = get_nodes_by_names(placeholder.data["siblings"]) for node in siblings: new_node = duplicate_node(node) x_init = int(new_node.knob("x_init").getValue()) y_init = int(new_node.knob("y_init").getValue()) new_node.setXYpos(x_init, y_init) if isinstance(new_node, nuke.BackdropNode): w_init = new_node.knob("w_init").getValue() h_init = new_node.knob("h_init").getValue() new_node.knob("bdwidth").setValue(w_init) new_node.knob("bdheight").setValue(h_init) refresh_node(node) if "repre_id" in node.knobs().keys(): node.removeKnob(node.knob("repre_id")) copies[node.name()] = new_node return copies def _set_copies_connections(self, placeholder, copies): """Set inputs and outputs of the copies. Args: copies (dict): Copied nodes by their names. """ last_input, last_output = get_group_io_nodes( placeholder.data["last_loaded"] ) siblings = get_nodes_by_names(placeholder.data["siblings"]) siblings_input, siblings_output = get_group_io_nodes(siblings) copy_input = copies[siblings_input.name()] copy_output = copies[siblings_output.name()] for node_init in siblings: if node_init == siblings_output: continue node_copy = copies[node_init.name()] for node in node_init.dependent(): for idx in range(node.inputs()): if node.input(idx) != node_init: continue if node in siblings: copies[node.name()].setInput(idx, node_copy) else: last_input.setInput(0, node_copy) for node in node_init.dependencies(): for idx in range(node_init.inputs()): if node_init.input(idx) != node: continue if node_init == siblings_input: copy_input.setInput(idx, node) elif node in siblings: node_copy.setInput(idx, copies[node.name()]) else: node_copy.setInput(idx, last_output) siblings_input.setInput(0, copy_output) class NukePlaceholderCreatePlugin( NukePlaceholderPlugin, PlaceholderCreateMixin ): identifier = "nuke.create" label = "Nuke create" def _parse_placeholder_node_data(self, node): placeholder_data = super( NukePlaceholderCreatePlugin, self )._parse_placeholder_node_data(node) node_knobs = node.knobs() nb_children = 0 if "nb_children" in node_knobs: nb_children = int(node_knobs["nb_children"].getValue()) placeholder_data["nb_children"] = nb_children siblings = [] if "siblings" in node_knobs: siblings = node_knobs["siblings"].values() placeholder_data["siblings"] = siblings node_full_name = node.fullName() placeholder_data["group_name"] = node_full_name.rpartition(".")[0] placeholder_data["last_loaded"] = [] placeholder_data["delete"] = False return placeholder_data def _before_instance_create(self, placeholder): placeholder.data["nodes_init"] = nuke.allNodes() def collect_placeholders(self): output = [] scene_placeholders = self._collect_scene_placeholders() for node_name, node in scene_placeholders.items(): plugin_identifier_knob = node.knob("plugin_identifier") if ( plugin_identifier_knob is None or plugin_identifier_knob.getValue() != self.identifier ): continue placeholder_data = self._parse_placeholder_node_data(node) output.append( CreatePlaceholderItem(node_name, placeholder_data, self) ) return output def populate_placeholder(self, placeholder): self.populate_create_placeholder(placeholder) def repopulate_placeholder(self, placeholder): self.populate_create_placeholder(placeholder) def get_placeholder_options(self, options=None): return self.get_create_plugin_options(options) def post_placeholder_process(self, placeholder, failed): """Cleanup placeholder after load of its corresponding representations. Args: placeholder (PlaceholderItem): Item which was just used to load representation. failed (bool): Loading of representation failed. """ # deselect all selected nodes placeholder_node = nuke.toNode(placeholder.scene_identifier) # getting the latest nodes added nodes_init = placeholder.data["nodes_init"] nodes_created = list(set(nuke.allNodes()) - set(nodes_init)) self.log.debug("Created nodes: {}".format(nodes_created)) if not nodes_created: return placeholder.data["delete"] = True nodes_created = self._move_to_placeholder_group( placeholder, nodes_created ) placeholder.data["last_created"] = nodes_created refresh_nodes(nodes_created) # positioning of the created nodes min_x, min_y, _, _ = get_extreme_positions(nodes_created) for node in nodes_created: xpos = (node.xpos() - min_x) + placeholder_node.xpos() ypos = (node.ypos() - min_y) + placeholder_node.ypos() node.setXYpos(xpos, ypos) refresh_nodes(nodes_created) # fix the problem of z_order for backdrops self._fix_z_order(placeholder) if placeholder.data.get("keep_placeholder"): self._imprint_siblings(placeholder) if placeholder.data["nb_children"] == 0: # save initial nodes positions and dimensions, update them # and set inputs and outputs of created nodes if placeholder.data.get("keep_placeholder"): self._imprint_inits() self._update_nodes(placeholder, nuke.allNodes(), nodes_created) self._set_created_connections(placeholder) elif placeholder.data["siblings"]: # create copies of placeholder siblings for the new created nodes, # set their inputs and outputs and update all nodes positions and # dimensions and siblings names siblings = get_nodes_by_names(placeholder.data["siblings"]) refresh_nodes(siblings) copies = self._create_sib_copies(placeholder) new_nodes = list(copies.values()) # copies nodes self._update_nodes(new_nodes, nodes_created) placeholder_node.removeKnob(placeholder_node.knob("siblings")) new_nodes_name = get_names_from_nodes(new_nodes) imprint(placeholder_node, {"siblings": new_nodes_name}) self._set_copies_connections(placeholder, copies) self._update_nodes( nuke.allNodes(), new_nodes + nodes_created, 20 ) new_siblings = get_names_from_nodes(new_nodes) placeholder.data["siblings"] = new_siblings else: # if the placeholder doesn't have siblings, the created # nodes will be placed in a free space xpointer, ypointer = find_free_space_to_paste_nodes( nodes_created, direction="bottom", offset=200 ) node = nuke.createNode("NoOp") reset_selection() nuke.delete(node) for node in nodes_created: xpos = (node.xpos() - min_x) + xpointer ypos = (node.ypos() - min_y) + ypointer node.setXYpos(xpos, ypos) placeholder.data["nb_children"] += 1 reset_selection() # go back to root group nuke.root().begin() def _move_to_placeholder_group(self, placeholder, nodes_created): """ opening the placeholder's group and copying created nodes in it. Returns : nodes_created (list): the new list of pasted nodes """ groups_name = placeholder.data["group_name"] reset_selection() select_nodes(nodes_created) if groups_name: with node_tempfile() as filepath: nuke.nodeCopy(filepath) for node in nuke.selectedNodes(): nuke.delete(node) group = nuke.toNode(groups_name) group.begin() nuke.nodePaste(filepath) nodes_created = nuke.selectedNodes() return nodes_created def _fix_z_order(self, placeholder): """Fix the problem of z_order when a backdrop is create.""" nodes_created = placeholder.data["last_created"] created_backdrops = [] bd_orders = set() for node in nodes_created: if isinstance(node, nuke.BackdropNode): created_backdrops.append(node) bd_orders.add(node.knob("z_order").getValue()) if not bd_orders: return sib_orders = set() for node_name in placeholder.data["siblings"]: node = nuke.toNode(node_name) if isinstance(node, nuke.BackdropNode): sib_orders.add(node.knob("z_order").getValue()) if not sib_orders: return min_order = min(bd_orders) max_order = max(sib_orders) for backdrop_node in created_backdrops: z_order = backdrop_node.knob("z_order").getValue() backdrop_node.knob("z_order").setValue( z_order + max_order - min_order + 1) def _imprint_siblings(self, placeholder): """ - add siblings names to placeholder attributes (nodes created with it) - add Id to the attributes of all the other nodes """ created_nodes = placeholder.data["last_created"] created_nodes_set = set(created_nodes) for node in created_nodes: node_knobs = node.knobs() if ( "is_placeholder" not in node_knobs or ( "is_placeholder" in node_knobs and node.knob("is_placeholder").value() ) ): siblings = list(created_nodes_set - {node}) siblings_name = get_names_from_nodes(siblings) siblings = {"siblings": siblings_name} imprint(node, siblings) def _imprint_inits(self): """Add initial positions and dimensions to the attributes""" for node in nuke.allNodes(): refresh_node(node) imprint(node, {"x_init": node.xpos(), "y_init": node.ypos()}) node.knob("x_init").setVisible(False) node.knob("y_init").setVisible(False) width = node.screenWidth() height = node.screenHeight() if "bdwidth" in node.knobs(): imprint(node, {"w_init": width, "h_init": height}) node.knob("w_init").setVisible(False) node.knob("h_init").setVisible(False) refresh_node(node) def _update_nodes( self, placeholder, nodes, considered_nodes, offset_y=None ): """Adjust backdrop nodes dimensions and positions. Considering some nodes sizes. Args: nodes (list): list of nodes to update considered_nodes (list): list of nodes to consider while updating positions and dimensions offset (int): distance between copies """ placeholder_node = nuke.toNode(placeholder.scene_identifier) min_x, min_y, max_x, max_y = get_extreme_positions(considered_nodes) diff_x = diff_y = 0 contained_nodes = [] # for backdrops if offset_y is None: width_ph = placeholder_node.screenWidth() height_ph = placeholder_node.screenHeight() diff_y = max_y - min_y - height_ph diff_x = max_x - min_x - width_ph contained_nodes = [placeholder_node] min_x = placeholder_node.xpos() min_y = placeholder_node.ypos() else: siblings = get_nodes_by_names(placeholder.data["siblings"]) minX, _, maxX, _ = get_extreme_positions(siblings) diff_y = max_y - min_y + 20 diff_x = abs(max_x - min_x - maxX + minX) contained_nodes = considered_nodes if diff_y <= 0 and diff_x <= 0: return for node in nodes: refresh_node(node) if ( node == placeholder_node or node in considered_nodes ): continue if ( not isinstance(node, nuke.BackdropNode) or ( isinstance(node, nuke.BackdropNode) and not set(contained_nodes) <= set(node.getNodes()) ) ): if offset_y is None and node.xpos() >= min_x: node.setXpos(node.xpos() + diff_x) if node.ypos() >= min_y: node.setYpos(node.ypos() + diff_y) else: width = node.screenWidth() height = node.screenHeight() node.knob("bdwidth").setValue(width + diff_x) node.knob("bdheight").setValue(height + diff_y) refresh_node(node) def _set_created_connections(self, placeholder): """ set inputs and outputs of created nodes""" placeholder_node = nuke.toNode(placeholder.scene_identifier) input_node, output_node = get_group_io_nodes( placeholder.data["last_created"] ) for node in placeholder_node.dependent(): for idx in range(node.inputs()): if node.input(idx) == placeholder_node and output_node: node.setInput(idx, output_node) for node in placeholder_node.dependencies(): for idx in range(placeholder_node.inputs()): if placeholder_node.input(idx) == node and input_node: input_node.setInput(0, node) def _create_sib_copies(self, placeholder): """ creating copies of the palce_holder siblings (the ones who were created with it) for the new nodes added Returns : copies (dict) : with copied nodes names and their copies """ copies = {} siblings = get_nodes_by_names(placeholder.data["siblings"]) for node in siblings: new_node = duplicate_node(node) x_init = int(new_node.knob("x_init").getValue()) y_init = int(new_node.knob("y_init").getValue()) new_node.setXYpos(x_init, y_init) if isinstance(new_node, nuke.BackdropNode): w_init = new_node.knob("w_init").getValue() h_init = new_node.knob("h_init").getValue() new_node.knob("bdwidth").setValue(w_init) new_node.knob("bdheight").setValue(h_init) refresh_node(node) if "repre_id" in node.knobs().keys(): node.removeKnob(node.knob("repre_id")) copies[node.name()] = new_node return copies def _set_copies_connections(self, placeholder, copies): """Set inputs and outputs of the copies. Args: copies (dict): Copied nodes by their names. """ last_input, last_output = get_group_io_nodes( placeholder.data["last_created"] ) siblings = get_nodes_by_names(placeholder.data["siblings"]) siblings_input, siblings_output = get_group_io_nodes(siblings) copy_input = copies[siblings_input.name()] copy_output = copies[siblings_output.name()] for node_init in siblings: if node_init == siblings_output: continue node_copy = copies[node_init.name()] for node in node_init.dependent(): for idx in range(node.inputs()): if node.input(idx) != node_init: continue if node in siblings: copies[node.name()].setInput(idx, node_copy) else: last_input.setInput(0, node_copy) for node in node_init.dependencies(): for idx in range(node_init.inputs()): if node_init.input(idx) != node: continue if node_init == siblings_input: copy_input.setInput(idx, node) elif node in siblings: node_copy.setInput(idx, copies[node.name()]) else: node_copy.setInput(idx, last_output) siblings_input.setInput(0, copy_output) def build_workfile_template(*args, **kwargs): builder = NukeTemplateBuilder(registered_host()) builder.build_template(*args, **kwargs) # set all settings to shot context default WorkfileSettings().set_context_settings() def update_workfile_template(*args): builder = NukeTemplateBuilder(registered_host()) builder.rebuild_template() def create_placeholder(*args): host = registered_host() builder = NukeTemplateBuilder(host) window = WorkfileBuildPlaceholderDialog(host, builder, parent=get_main_window()) window.show() def update_placeholder(*args): host = registered_host() builder = NukeTemplateBuilder(host) placeholder_items_by_id = { placeholder_item.scene_identifier: placeholder_item for placeholder_item in builder.get_placeholders() } placeholder_items = [] for node in nuke.selectedNodes(): node_name = node.fullName() if node_name in placeholder_items_by_id: placeholder_items.append(placeholder_items_by_id[node_name]) # TODO show UI at least if len(placeholder_items) == 0: raise ValueError("No node selected") if len(placeholder_items) > 1: raise ValueError("Too many selected nodes") placeholder_item = placeholder_items[0] window = WorkfileBuildPlaceholderDialog(host, builder, parent=get_main_window()) window.set_update_mode(placeholder_item) window.exec_() ================================================ FILE: openpype/hosts/nuke/api/workio.py ================================================ """Host API required Work Files tool""" import os import nuke import shutil from .utils import is_headless def file_extensions(): return [".nk"] def has_unsaved_changes(): return nuke.root().modified() def save_file(filepath): path = filepath.replace("\\", "/") nuke.scriptSaveAs(path, overwrite=1) nuke.Root()["name"].setValue(path) nuke.Root()["project_directory"].setValue(os.path.dirname(path)) nuke.Root().setModified(False) def open_file(filepath): def read_script(nuke_script): nuke.scriptClear() nuke.scriptReadFile(nuke_script) nuke.Root()["name"].setValue(nuke_script) nuke.Root()["project_directory"].setValue(os.path.dirname(nuke_script)) nuke.Root().setModified(False) filepath = filepath.replace("\\", "/") # To remain in the same window, we have to clear the script and read # in the contents of the workfile. # Nuke Preferences can be read after the script is read. read_script(filepath) if not is_headless(): autosave = nuke.toNode("preferences")["AutoSaveName"].evaluate() autosave_prmpt = "Autosave detected.\n" \ "Would you like to load the autosave file?" # noqa if os.path.isfile(autosave) and nuke.ask(autosave_prmpt): try: # Overwrite the filepath with autosave shutil.copy(autosave, filepath) # Now read the (auto-saved) script again read_script(filepath) except shutil.Error as err: nuke.message( "Detected autosave file could not be used.\n{}" .format(err)) return True def current_file(): current_file = nuke.root().name() # Unsaved current file if current_file == 'Root': return None return os.path.normpath(current_file).replace("\\", "/") def work_root(session): work_dir = session["AVALON_WORKDIR"] scene_dir = session.get("AVALON_SCENEDIR") if scene_dir: path = os.path.join(work_dir, scene_dir) else: path = work_dir return os.path.normpath(path).replace("\\", "/") ================================================ FILE: openpype/hosts/nuke/hooks/pre_nukeassist_setup.py ================================================ from openpype.lib.applications import PreLaunchHook class PrelaunchNukeAssistHook(PreLaunchHook): """ Adding flag when nukeassist """ app_groups = {"nukeassist"} launch_types = set() def execute(self): self.launch_context.env["NUKEASSIST"] = "1" ================================================ FILE: openpype/hosts/nuke/plugins/__init__.py ================================================ ================================================ FILE: openpype/hosts/nuke/plugins/create/__init__.py ================================================ ================================================ FILE: openpype/hosts/nuke/plugins/create/convert_legacy.py ================================================ from openpype.pipeline.create.creator_plugins import SubsetConvertorPlugin from openpype.hosts.nuke.api.lib import ( INSTANCE_DATA_KNOB, get_node_data, get_avalon_knob_data, AVALON_TAB, ) from openpype.hosts.nuke.api.plugin import convert_to_valid_instaces import nuke class LegacyConverted(SubsetConvertorPlugin): identifier = "legacy.converter" def find_instances(self): legacy_found = False # search for first available legacy item for node in nuke.allNodes(recurseGroups=True): if node.Class() in ["Viewer", "Dot"]: continue if get_node_data(node, INSTANCE_DATA_KNOB): continue if AVALON_TAB not in node.knobs(): continue # get data from avalon knob avalon_knob_data = get_avalon_knob_data( node, ["avalon:", "ak:"], create=False) if not avalon_knob_data: continue if avalon_knob_data["id"] != "pyblish.avalon.instance": continue # catch and break legacy_found = True break if legacy_found: # if not item do not add legacy instance converter self.add_convertor_item("Convert legacy instances") def convert(self): # loop all instances and convert them convert_to_valid_instaces() # remove legacy item if all is fine self.remove_convertor_item() ================================================ FILE: openpype/hosts/nuke/plugins/create/create_backdrop.py ================================================ from nukescripts import autoBackdrop from openpype.hosts.nuke.api import ( NukeCreator, maintained_selection, select_nodes ) class CreateBackdrop(NukeCreator): """Add Publishable Backdrop""" identifier = "create_backdrop" label = "Nukenodes (backdrop)" family = "nukenodes" icon = "file-archive-o" maintain_selection = True # plugin attributes node_color = "0xdfea5dff" def create_instance_node( self, node_name, knobs=None, parent=None, node_type=None ): with maintained_selection(): if len(self.selected_nodes) >= 1: select_nodes(self.selected_nodes) created_node = autoBackdrop() created_node["name"].setValue(node_name) created_node["tile_color"].setValue(int(self.node_color, 16)) created_node["note_font_size"].setValue(24) created_node["label"].setValue("[{}]".format(node_name)) return created_node def create(self, subset_name, instance_data, pre_create_data): # make sure subset name is unique self.check_existing_subset(subset_name) instance = super(CreateBackdrop, self).create( subset_name, instance_data, pre_create_data ) return instance ================================================ FILE: openpype/hosts/nuke/plugins/create/create_camera.py ================================================ import nuke from openpype.hosts.nuke.api import ( NukeCreator, NukeCreatorError, maintained_selection ) from openpype.hosts.nuke.api.lib import ( create_camera_node_by_version ) class CreateCamera(NukeCreator): """Add Publishable Camera""" identifier = "create_camera" label = "Camera (3d)" family = "camera" icon = "camera" # plugin attributes node_color = "0xff9100ff" def create_instance_node( self, node_name, knobs=None, parent=None, node_type=None ): with maintained_selection(): if self.selected_nodes: node = self.selected_nodes[0] if node.Class() != "Camera3": raise NukeCreatorError( "Creator error: Select only camera node type") created_node = self.selected_nodes[0] else: created_node = create_camera_node_by_version() created_node["tile_color"].setValue( int(self.node_color, 16)) created_node["name"].setValue(node_name) return created_node def create(self, subset_name, instance_data, pre_create_data): # make sure subset name is unique self.check_existing_subset(subset_name) instance = super(CreateCamera, self).create( subset_name, instance_data, pre_create_data ) return instance def set_selected_nodes(self, pre_create_data): if pre_create_data.get("use_selection"): self.selected_nodes = nuke.selectedNodes() if self.selected_nodes == []: raise NukeCreatorError( "Creator error: No active selection") elif len(self.selected_nodes) > 1: raise NukeCreatorError( "Creator error: Select only one camera node") else: self.selected_nodes = [] ================================================ FILE: openpype/hosts/nuke/plugins/create/create_gizmo.py ================================================ import nuke from openpype.hosts.nuke.api import ( NukeCreator, NukeCreatorError, maintained_selection ) class CreateGizmo(NukeCreator): """Add Publishable Group as gizmo""" identifier = "create_gizmo" label = "Gizmo (group)" family = "gizmo" icon = "file-archive-o" default_variants = ["ViewerInput", "Lut", "Effect"] # plugin attributes node_color = "0x7533c1ff" def create_instance_node( self, node_name, knobs=None, parent=None, node_type=None ): with maintained_selection(): if self.selected_nodes: node = self.selected_nodes[0] if node.Class() != "Group": raise NukeCreatorError( "Creator error: Select only 'Group' node type") created_node = node else: created_node = nuke.collapseToGroup() created_node["tile_color"].setValue( int(self.node_color, 16)) created_node["name"].setValue(node_name) return created_node def create(self, subset_name, instance_data, pre_create_data): # make sure subset name is unique self.check_existing_subset(subset_name) instance = super(CreateGizmo, self).create( subset_name, instance_data, pre_create_data ) return instance def set_selected_nodes(self, pre_create_data): if pre_create_data.get("use_selection"): self.selected_nodes = nuke.selectedNodes() if self.selected_nodes == []: raise NukeCreatorError("Creator error: No active selection") elif len(self.selected_nodes) > 1: NukeCreatorError("Creator error: Select only one 'Group' node") else: self.selected_nodes = [] ================================================ FILE: openpype/hosts/nuke/plugins/create/create_model.py ================================================ import nuke from openpype.hosts.nuke.api import ( NukeCreator, NukeCreatorError, maintained_selection ) class CreateModel(NukeCreator): """Add Publishable Camera""" identifier = "create_model" label = "Model (3d)" family = "model" icon = "cube" default_variants = ["Main"] # plugin attributes node_color = "0xff3200ff" def create_instance_node( self, node_name, knobs=None, parent=None, node_type=None ): with maintained_selection(): if self.selected_nodes: node = self.selected_nodes[0] if node.Class() != "Scene": raise NukeCreatorError( "Creator error: Select only 'Scene' node type") created_node = node else: created_node = nuke.createNode("Scene") created_node["tile_color"].setValue( int(self.node_color, 16)) created_node["name"].setValue(node_name) return created_node def create(self, subset_name, instance_data, pre_create_data): # make sure subset name is unique self.check_existing_subset(subset_name) instance = super(CreateModel, self).create( subset_name, instance_data, pre_create_data ) return instance def set_selected_nodes(self, pre_create_data): if pre_create_data.get("use_selection"): self.selected_nodes = nuke.selectedNodes() if self.selected_nodes == []: raise NukeCreatorError("Creator error: No active selection") elif len(self.selected_nodes) > 1: NukeCreatorError("Creator error: Select only one 'Scene' node") else: self.selected_nodes = [] ================================================ FILE: openpype/hosts/nuke/plugins/create/create_source.py ================================================ import nuke import six import sys from openpype.hosts.nuke.api import ( INSTANCE_DATA_KNOB, NukeCreator, NukeCreatorError, set_node_data ) from openpype.pipeline import ( CreatedInstance ) class CreateSource(NukeCreator): """Add Publishable Read with source""" identifier = "create_source" label = "Source (read)" family = "source" icon = "film" default_variants = ["Effect", "Backplate", "Fire", "Smoke"] # plugin attributes node_color = "0xff9100ff" def create_instance_node( self, node_name, read_node ): read_node["tile_color"].setValue( int(self.node_color, 16)) read_node["name"].setValue(node_name) return read_node def create(self, subset_name, instance_data, pre_create_data): # make sure selected nodes are added self.set_selected_nodes(pre_create_data) try: for read_node in self.selected_nodes: if read_node.Class() != 'Read': continue node_name = read_node.name() _subset_name = subset_name + node_name # make sure subset name is unique self.check_existing_subset(_subset_name) instance_node = self.create_instance_node( _subset_name, read_node ) instance = CreatedInstance( self.family, _subset_name, instance_data, self ) instance.transient_data["node"] = instance_node self._add_instance_to_context(instance) set_node_data( instance_node, INSTANCE_DATA_KNOB, instance.data_to_store() ) except Exception as er: six.reraise( NukeCreatorError, NukeCreatorError("Creator error: {}".format(er)), sys.exc_info()[2]) def set_selected_nodes(self, pre_create_data): if pre_create_data.get("use_selection"): self.selected_nodes = nuke.selectedNodes() if self.selected_nodes == []: raise NukeCreatorError("Creator error: No active selection") else: NukeCreatorError( "Creator error: only supported with active selection") ================================================ FILE: openpype/hosts/nuke/plugins/create/create_write_image.py ================================================ import nuke import sys import six from openpype.pipeline import ( CreatedInstance ) from openpype.lib import ( BoolDef, NumberDef, UISeparatorDef, EnumDef ) from openpype.hosts.nuke import api as napi from openpype.hosts.nuke.api.plugin import exposed_write_knobs class CreateWriteImage(napi.NukeWriteCreator): identifier = "create_write_image" label = "Image (write)" family = "image" icon = "sign-out" instance_attributes = [ "use_range_limit" ] default_variants = [ "StillFrame", "MPFrame", "LayoutFrame" ] temp_rendering_path_template = ( "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}") def get_pre_create_attr_defs(self): attr_defs = [ BoolDef( "use_selection", default=not self.create_context.headless, label="Use selection" ), self._get_render_target_enum(), UISeparatorDef(), self._get_frame_source_number() ] return attr_defs def _get_render_target_enum(self): rendering_targets = { "local": "Local machine rendering", "frames": "Use existing frames" } return EnumDef( "render_target", items=rendering_targets, label="Render target" ) def _get_frame_source_number(self): return NumberDef( "active_frame", label="Active frame", default=nuke.frame() ) def create_instance_node(self, subset_name, instance_data): # add fpath_template write_data = { "creator": self.__class__.__name__, "subset": subset_name, "fpath_template": self.temp_rendering_path_template } write_data.update(instance_data) created_node = napi.create_write_node( subset_name, write_data, input=self.selected_node, prenodes=self.prenodes, linked_knobs=self.get_linked_knobs(), **{ "frame": nuke.frame() } ) self._add_frame_range_limit(created_node, instance_data) self.integrate_links(created_node, outputs=True) return created_node def create(self, subset_name, instance_data, pre_create_data): subset_name = subset_name.format(**pre_create_data) # pass values from precreate to instance self.pass_pre_attributes_to_instance( instance_data, pre_create_data, [ "active_frame", "render_target" ] ) # make sure selected nodes are added self.set_selected_nodes(pre_create_data) # make sure subset name is unique self.check_existing_subset(subset_name) instance_node = self.create_instance_node( subset_name, instance_data, ) try: instance = CreatedInstance( self.family, subset_name, instance_data, self ) instance.transient_data["node"] = instance_node self._add_instance_to_context(instance) napi.set_node_data( instance_node, napi.INSTANCE_DATA_KNOB, instance.data_to_store() ) exposed_write_knobs( self.project_settings, self.__class__.__name__, instance_node ) return instance except Exception as er: six.reraise( napi.NukeCreatorError, napi.NukeCreatorError("Creator error: {}".format(er)), sys.exc_info()[2] ) def _add_frame_range_limit(self, write_node, instance_data): if "use_range_limit" not in self.instance_attributes: return active_frame = ( instance_data["creator_attributes"].get("active_frame")) write_node.begin() for n in nuke.allNodes(): # get write node if n.Class() in "Write": w_node = n write_node.end() w_node["use_limit"].setValue(True) w_node["first"].setValue(active_frame or nuke.frame()) w_node["last"].setExpression("first") return write_node ================================================ FILE: openpype/hosts/nuke/plugins/create/create_write_prerender.py ================================================ import nuke import sys import six from openpype.pipeline import ( CreatedInstance ) from openpype.lib import ( BoolDef ) from openpype.hosts.nuke import api as napi from openpype.hosts.nuke.api.plugin import exposed_write_knobs class CreateWritePrerender(napi.NukeWriteCreator): identifier = "create_write_prerender" label = "Prerender (write)" family = "prerender" icon = "sign-out" instance_attributes = [ "use_range_limit" ] default_variants = [ "Key01", "Bg01", "Fg01", "Branch01", "Part01" ] temp_rendering_path_template = ( "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}") # Before write node render. order = 90 def get_pre_create_attr_defs(self): attr_defs = [ BoolDef( "use_selection", default=not self.create_context.headless, label="Use selection" ), self._get_render_target_enum() ] return attr_defs def create_instance_node(self, subset_name, instance_data): # add fpath_template write_data = { "creator": self.__class__.__name__, "subset": subset_name, "fpath_template": self.temp_rendering_path_template } write_data.update(instance_data) # get width and height if self.selected_node: width, height = ( self.selected_node.width(), self.selected_node.height()) else: actual_format = nuke.root().knob('format').value() width, height = (actual_format.width(), actual_format.height()) created_node = napi.create_write_node( subset_name, write_data, input=self.selected_node, prenodes=self.prenodes, linked_knobs=self.get_linked_knobs(), **{ "width": width, "height": height } ) self._add_frame_range_limit(created_node) self.integrate_links(created_node, outputs=True) return created_node def create(self, subset_name, instance_data, pre_create_data): # pass values from precreate to instance self.pass_pre_attributes_to_instance( instance_data, pre_create_data, [ "render_target" ] ) # make sure selected nodes are added self.set_selected_nodes(pre_create_data) # make sure subset name is unique self.check_existing_subset(subset_name) instance_node = self.create_instance_node( subset_name, instance_data ) try: instance = CreatedInstance( self.family, subset_name, instance_data, self ) instance.transient_data["node"] = instance_node self._add_instance_to_context(instance) napi.set_node_data( instance_node, napi.INSTANCE_DATA_KNOB, instance.data_to_store() ) exposed_write_knobs( self.project_settings, self.__class__.__name__, instance_node ) return instance except Exception as er: six.reraise( napi.NukeCreatorError, napi.NukeCreatorError("Creator error: {}".format(er)), sys.exc_info()[2] ) def _add_frame_range_limit(self, write_node): if "use_range_limit" not in self.instance_attributes: return write_node.begin() for n in nuke.allNodes(): # get write node if n.Class() in "Write": w_node = n write_node.end() w_node["use_limit"].setValue(True) w_node["first"].setValue(nuke.root()["first_frame"].value()) w_node["last"].setValue(nuke.root()["last_frame"].value()) return write_node ================================================ FILE: openpype/hosts/nuke/plugins/create/create_write_render.py ================================================ import nuke import sys import six from openpype.pipeline import ( CreatedInstance ) from openpype.lib import ( BoolDef ) from openpype.hosts.nuke import api as napi from openpype.hosts.nuke.api.plugin import exposed_write_knobs class CreateWriteRender(napi.NukeWriteCreator): identifier = "create_write_render" label = "Render (write)" family = "render" icon = "sign-out" instance_attributes = [ "reviewable" ] default_variants = [ "Main", "Mask" ] temp_rendering_path_template = ( "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}") def get_pre_create_attr_defs(self): attr_defs = [ BoolDef( "use_selection", default=not self.create_context.headless, label="Use selection" ), self._get_render_target_enum() ] return attr_defs def create_instance_node(self, subset_name, instance_data): # add fpath_template write_data = { "creator": self.__class__.__name__, "subset": subset_name, "fpath_template": self.temp_rendering_path_template } write_data.update(instance_data) # get width and height if self.selected_node: width, height = ( self.selected_node.width(), self.selected_node.height()) else: actual_format = nuke.root().knob('format').value() width, height = (actual_format.width(), actual_format.height()) self.log.debug(">>>>>>> : {}".format(self.instance_attributes)) self.log.debug(">>>>>>> : {}".format(self.get_linked_knobs())) created_node = napi.create_write_node( subset_name, write_data, input=self.selected_node, prenodes=self.prenodes, linked_knobs=self.get_linked_knobs(), **{ "width": width, "height": height } ) self.integrate_links(created_node, outputs=False) return created_node def create(self, subset_name, instance_data, pre_create_data): # pass values from precreate to instance self.pass_pre_attributes_to_instance( instance_data, pre_create_data, [ "render_target" ] ) # make sure selected nodes are added self.set_selected_nodes(pre_create_data) # make sure subset name is unique self.check_existing_subset(subset_name) instance_node = self.create_instance_node( subset_name, instance_data ) try: instance = CreatedInstance( self.family, subset_name, instance_data, self ) instance.transient_data["node"] = instance_node self._add_instance_to_context(instance) napi.set_node_data( instance_node, napi.INSTANCE_DATA_KNOB, instance.data_to_store() ) exposed_write_knobs( self.project_settings, self.__class__.__name__, instance_node ) return instance except Exception as er: six.reraise( napi.NukeCreatorError, napi.NukeCreatorError("Creator error: {}".format(er)), sys.exc_info()[2] ) ================================================ FILE: openpype/hosts/nuke/plugins/create/workfile_creator.py ================================================ import openpype.hosts.nuke.api as api from openpype.client import get_asset_by_name from openpype.pipeline import ( AutoCreator, CreatedInstance, ) from openpype.hosts.nuke.api import ( INSTANCE_DATA_KNOB, set_node_data ) import nuke class WorkfileCreator(AutoCreator): identifier = "workfile" family = "workfile" default_variant = "Main" def get_instance_attr_defs(self): return [] def collect_instances(self): root_node = nuke.root() instance_data = api.get_node_data( root_node, api.INSTANCE_DATA_KNOB ) project_name = self.create_context.get_current_project_name() asset_name = self.create_context.get_current_asset_name() task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, project_name, host_name ) instance_data.update({ "asset": asset_name, "task": task_name, "variant": self.default_variant }) instance_data.update(self.get_dynamic_data( self.default_variant, task_name, asset_doc, project_name, host_name, instance_data )) instance = CreatedInstance( self.family, subset_name, instance_data, self ) instance.transient_data["node"] = root_node self._add_instance_to_context(instance) def update_instances(self, update_list): for created_inst, _changes in update_list: instance_node = created_inst.transient_data["node"] set_node_data( instance_node, INSTANCE_DATA_KNOB, created_inst.data_to_store() ) def create(self, options=None): # no need to create if it is created # in `collect_instances` pass ================================================ FILE: openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py ================================================ from openpype.lib import Logger from openpype.pipeline import InventoryAction from openpype.hosts.nuke.api.lib import set_avalon_knob_data class RepairOldLoaders(InventoryAction): label = "Repair Old Loaders" icon = "gears" color = "#cc0000" log = Logger.get_logger(__name__) def process(self, containers): import nuke new_loader = "LoadClip" for cdata in containers: orig_loader = cdata["loader"] orig_name = cdata["objectName"] if orig_loader not in ["LoadSequence", "LoadMov"]: self.log.warning( "This repair action is only working on " "`LoadSequence` and `LoadMov` Loaders") continue new_name = orig_name.replace(orig_loader, new_loader) node = nuke.toNode(cdata["objectName"]) cdata.update({ "loader": new_loader, "objectName": new_name }) node["name"].setValue(new_name) # get data from avalon knob set_avalon_knob_data(node, cdata) ================================================ FILE: openpype/hosts/nuke/plugins/inventory/select_containers.py ================================================ from openpype.pipeline import InventoryAction from openpype.hosts.nuke.api.command import viewer_update_and_undo_stop class SelectContainers(InventoryAction): label = "Select Containers" icon = "mouse-pointer" color = "#d8d8d8" def process(self, containers): import nuke nodes = [nuke.toNode(i["objectName"]) for i in containers] with viewer_update_and_undo_stop(): # clear previous_selection [n['selected'].setValue(False) for n in nodes] # Select tool for node in nodes: node["selected"].setValue(True) ================================================ FILE: openpype/hosts/nuke/plugins/load/actions.py ================================================ """A module containing generic loader actions that will display in the Loader. """ from openpype.lib import Logger from openpype.pipeline import load log = Logger.get_logger(__name__) class SetFrameRangeLoader(load.LoaderPlugin): """Set frame range excluding pre- and post-handles""" families = ["animation", "camera", "write", "yeticache", "pointcache"] representations = ["*"] extensions = {"*"} label = "Set frame range" order = 11 icon = "clock-o" color = "white" def load(self, context, name, namespace, data): from openpype.hosts.nuke.api import lib version = context['version'] version_data = version.get("data", {}) start = version_data.get("frameStart", None) end = version_data.get("frameEnd", None) log.info("start: {}, end: {}".format(start, end)) if start is None or end is None: log.info("Skipping setting frame range because start or " "end frame data is missing..") return lib.update_frame_range(start, end) class SetFrameRangeWithHandlesLoader(load.LoaderPlugin): """Set frame range including pre- and post-handles""" families = ["animation", "camera", "write", "yeticache", "pointcache"] representations = ["*"] label = "Set frame range (with handles)" order = 12 icon = "clock-o" color = "white" def load(self, context, name, namespace, data): from openpype.hosts.nuke.api import lib version = context['version'] version_data = version.get("data", {}) start = version_data.get("frameStart", None) end = version_data.get("frameEnd", None) if start is None or end is None: print("Skipping setting frame range because start or " "end frame data is missing..") return # Include handles start -= version_data.get("handleStart", 0) end += version_data.get("handleEnd", 0) lib.update_frame_range(start, end) ================================================ FILE: openpype/hosts/nuke/plugins/load/load_backdrop.py ================================================ import nuke import nukescripts from openpype.client import ( get_version_by_id, get_last_version_by_subset_id, ) from openpype.pipeline import ( load, get_current_project_name, get_representation_path, ) from openpype.hosts.nuke.api.lib import ( find_free_space_to_paste_nodes, maintained_selection, reset_selection, select_nodes, get_avalon_knob_data, set_avalon_knob_data ) from openpype.hosts.nuke.api.command import viewer_update_and_undo_stop from openpype.hosts.nuke.api import containerise, update_container class LoadBackdropNodes(load.LoaderPlugin): """Loading Published Backdrop nodes (workfile, nukenodes)""" families = ["workfile", "nukenodes", "matchmove"] representations = ["*"] extensions = {"nk"} label = "Import Nuke Nodes" order = 0 icon = "eye" color = "white" node_color = "0x7533c1ff" def load(self, context, name, namespace, data): """ Loading function to import .nk file into script and wrap it on backdrop Arguments: context (dict): context of version name (str): name of the version namespace (str): asset name data (dict): compulsory attribute > not used Returns: nuke node: containerised nuke node object """ # get main variables version = context['version'] version_data = version.get("data", {}) vname = version.get("name", None) namespace = namespace or context['asset']['name'] colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) # prepare data for imprinting # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] data_imprint = { "version": vname, "colorspaceInput": colorspace } for k in add_keys: data_imprint.update({k: version_data[k]}) # getting file path file = self.filepath_from_context(context).replace("\\", "/") # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() # Get mouse position n = nuke.createNode("NoOp") xcursor, ycursor = (n.xpos(), n.ypos()) reset_selection() nuke.delete(n) bdn_frame = 50 with maintained_selection(): # add group from nk nuke.nodePaste(file) # get all pasted nodes new_nodes = list() nodes = nuke.selectedNodes() # get pointer position in DAG xpointer, ypointer = find_free_space_to_paste_nodes( nodes, direction="right", offset=200 + bdn_frame ) # reset position to all nodes and replace inputs and output for n in nodes: reset_selection() xpos = (n.xpos() - xcursor) + xpointer ypos = (n.ypos() - ycursor) + ypointer n.setXYpos(xpos, ypos) # replace Input nodes for dots if n.Class() in "Input": dot = nuke.createNode("Dot") new_name = n.name().replace("INP", "DOT") dot.setName(new_name) dot["label"].setValue(new_name) dot.setXYpos(xpos, ypos) new_nodes.append(dot) # rewire dep = n.dependent() for d in dep: index = next((i for i, dpcy in enumerate( d.dependencies()) if n is dpcy), 0) d.setInput(index, dot) # remove Input node reset_selection() nuke.delete(n) continue # replace Input nodes for dots elif n.Class() in "Output": dot = nuke.createNode("Dot") new_name = n.name() + "_DOT" dot.setName(new_name) dot["label"].setValue(new_name) dot.setXYpos(xpos, ypos) new_nodes.append(dot) # rewire dep = next((d for d in n.dependencies()), None) if dep: dot.setInput(0, dep) # remove Input node reset_selection() nuke.delete(n) continue else: new_nodes.append(n) # reselect nodes with new Dot instead of Inputs and Output reset_selection() select_nodes(new_nodes) # place on backdrop bdn = nukescripts.autoBackdrop() # add frame offset xpos = bdn.xpos() - bdn_frame ypos = bdn.ypos() - bdn_frame bdwidth = bdn["bdwidth"].value() + (bdn_frame*2) bdheight = bdn["bdheight"].value() + (bdn_frame*2) bdn["xpos"].setValue(xpos) bdn["ypos"].setValue(ypos) bdn["bdwidth"].setValue(bdwidth) bdn["bdheight"].setValue(bdheight) bdn["name"].setValue(object_name) bdn["label"].setValue("Version tracked frame: \n`{}`\n\nPLEASE DO NOT REMOVE OR MOVE \nANYTHING FROM THIS FRAME!".format(object_name)) bdn["note_font_size"].setValue(20) return containerise( node=bdn, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, data=data_imprint) def update(self, container, representation): """Update the Loader's path Nuke automatically tries to reset some variables when changing the loader's path to a new file. These automatic changes are to its inputs: """ # get main variables # Get version from io project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node GN = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] version_data = version_doc.get("data", {}) vname = version_doc.get("name", None) namespace = container['namespace'] colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) add_keys = ["source", "author", "fps"] data_imprint = { "representation": str(representation["_id"]), "version": vname, "colorspaceInput": colorspace, } for k in add_keys: data_imprint.update({k: version_data[k]}) # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() with maintained_selection(): xpos = GN.xpos() ypos = GN.ypos() avalon_data = get_avalon_knob_data(GN) nuke.delete(GN) # add group from nk nuke.nodePaste(file) GN = nuke.selectedNode() set_avalon_knob_data(GN, avalon_data) GN.setXYpos(xpos, ypos) GN["name"].setValue(object_name) # get all versions in list last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] ) # change color of node if version_doc["_id"] == last_version_doc["_id"]: color_value = self.node_color else: color_value = "0xd88467ff" GN["tile_color"].setValue(int(color_value, 16)) self.log.info("updated to version: {}".format(version_doc.get("name"))) return update_container(GN, data_imprint) def switch(self, container, representation): self.update(container, representation) def remove(self, container): node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) ================================================ FILE: openpype/hosts/nuke/plugins/load/load_camera_abc.py ================================================ import nuke from openpype.client import ( get_version_by_id, get_last_version_by_subset_id ) from openpype.pipeline import ( load, get_current_project_name, get_representation_path, ) from openpype.hosts.nuke.api import ( containerise, update_container, viewer_update_and_undo_stop ) from openpype.hosts.nuke.api.lib import ( maintained_selection ) class AlembicCameraLoader(load.LoaderPlugin): """ This will load alembic camera into script. """ families = ["camera"] representations = ["*"] extensions = {"abc"} label = "Load Alembic Camera" icon = "camera" color = "orange" node_color = "0x3469ffff" def load(self, context, name, namespace, data): # get main variables version = context['version'] version_data = version.get("data", {}) vname = version.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) fps = version_data.get("fps") or nuke.root()["fps"].getValue() namespace = namespace or context['asset']['name'] object_name = "{}_{}".format(name, namespace) # prepare data for imprinting # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] data_imprint = { "frameStart": first, "frameEnd": last, "version": vname, } for k in add_keys: data_imprint.update({k: version_data[k]}) # getting file path file = self.filepath_from_context(context).replace("\\", "/") with maintained_selection(): camera_node = nuke.createNode( "Camera2", "name {} file {} read_from_file True".format( object_name, file), inpanel=False ) camera_node.forceValidate() camera_node["frame_rate"].setValue(float(fps)) # workaround because nuke's bug is not adding # animation keys properly xpos = camera_node.xpos() ypos = camera_node.ypos() nuke.nodeCopy("%clipboard%") nuke.delete(camera_node) nuke.nodePaste("%clipboard%") camera_node = nuke.toNode(object_name) camera_node.setXYpos(xpos, ypos) # color node by correct color by actual version self.node_version_color(version, camera_node) return containerise( node=camera_node, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, data=data_imprint) def update(self, container, representation): """ Called by Scene Inventory when look should be updated to current version. If any reference edits cannot be applied, eg. shader renamed and material not present, reference is unloaded and cleaned. All failed edits are highlighted to the user via message box. Args: container: object that has look to be updated representation: (dict): relationship data to get proper representation from DB and persisted data in .json Returns: None """ # Get version from io project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) # get main variables version_data = version_doc.get("data", {}) vname = version_doc.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) fps = version_data.get("fps") or nuke.root()["fps"].getValue() # prepare data for imprinting # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] data_imprint = { "representation": str(representation["_id"]), "frameStart": first, "frameEnd": last, "version": vname } for k in add_keys: data_imprint.update({k: version_data[k]}) # getting file path file = get_representation_path(representation).replace("\\", "/") with maintained_selection(): camera_node = container["node"] camera_node['selected'].setValue(True) # collect input output dependencies dependencies = camera_node.dependencies() dependent = camera_node.dependent() camera_node["frame_rate"].setValue(float(fps)) camera_node["file"].setValue(file) # workaround because nuke's bug is # not adding animation keys properly xpos = camera_node.xpos() ypos = camera_node.ypos() nuke.nodeCopy("%clipboard%") camera_name = camera_node.name() nuke.delete(camera_node) nuke.nodePaste("%clipboard%") camera_node = nuke.toNode(camera_name) camera_node.setXYpos(xpos, ypos) # link to original input nodes for i, input in enumerate(dependencies): camera_node.setInput(i, input) # link to original output nodes for d in dependent: index = next((i for i, dpcy in enumerate( d.dependencies()) if camera_node is dpcy), 0) d.setInput(index, camera_node) # color node by correct color by actual version self.node_version_color(version_doc, camera_node) self.log.info("updated to version: {}".format(version_doc.get("name"))) return update_container(camera_node, data_imprint) def node_version_color(self, version_doc, node): """ Coloring a node by correct color by actual version """ # get all versions in list project_name = get_current_project_name() last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] ) # change color of node if version_doc["_id"] == last_version_doc["_id"]: color_value = self.node_color else: color_value = "0xd88467ff" node["tile_color"].setValue(int(color_value, 16)) def switch(self, container, representation): self.update(container, representation) def remove(self, container): node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) ================================================ FILE: openpype/hosts/nuke/plugins/load/load_clip.py ================================================ import nuke import qargparse from pprint import pformat from copy import deepcopy from openpype.lib import Logger from openpype.client import ( get_version_by_id, get_last_version_by_subset_id, ) from openpype.pipeline import ( get_current_project_name, get_representation_path, ) from openpype.pipeline.colorspace import ( get_imageio_file_rules_colorspace_from_filepath ) from openpype.hosts.nuke.api.lib import ( get_imageio_input_colorspace, maintained_selection ) from openpype.hosts.nuke.api import ( containerise, update_container, viewer_update_and_undo_stop, colorspace_exists_on_node ) from openpype.lib.transcoding import ( VIDEO_EXTENSIONS, IMAGE_EXTENSIONS ) from openpype.hosts.nuke.api import plugin class LoadClip(plugin.NukeLoader): """Load clip into Nuke Either it is image sequence or video file. """ log = Logger.get_logger(__name__) families = [ "source", "plate", "render", "prerender", "review" ] representations = ["*"] extensions = set( ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) ) label = "Load Clip" order = -20 icon = "file-video-o" color = "white" # Loaded from settings _representations = [] script_start = int(nuke.root()["first_frame"].value()) # option gui options_defaults = { "start_at_workfile": True, "add_retime": True } node_name_template = "{class_name}_{ext}" @classmethod def get_options(cls, *args): return [ qargparse.Boolean( "start_at_workfile", help="Load at workfile start frame", default=cls.options_defaults["start_at_workfile"] ), qargparse.Boolean( "add_retime", help="Load with retime", default=cls.options_defaults["add_retime"] ) ] @classmethod def get_representations(cls): return cls._representations or cls.representations def load(self, context, name, namespace, options): """Load asset via database """ representation = context["representation"] # reset container id so it is always unique for each instance self.reset_container_id() is_sequence = len(representation["files"]) > 1 if is_sequence: context["representation"] = \ self._representation_with_hash_in_frame( representation ) filepath = self.filepath_from_context(context) filepath = filepath.replace("\\", "/") start_at_workfile = options.get( "start_at_workfile", self.options_defaults["start_at_workfile"]) add_retime = options.get( "add_retime", self.options_defaults["add_retime"]) version = context['version'] version_data = version.get("data", {}) repre_id = representation["_id"] self.log.debug("_ version_data: {}\n".format( pformat(version_data))) self.log.debug( "Representation id `{}` ".format(repre_id)) self.handle_start = version_data.get("handleStart", 0) self.handle_end = version_data.get("handleEnd", 0) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) first -= self.handle_start last += self.handle_end if not is_sequence: duration = last - first first = 1 last = first + duration # Fallback to asset name when namespace is None if namespace is None: namespace = context['asset']['name'] if not filepath: self.log.warning( "Representation id `{}` is failing to load".format(repre_id)) return read_name = self._get_node_name(representation) # Create the Loader with the filename path set read_node = nuke.createNode( "Read", "name {}".format(read_name), inpanel=False ) # to avoid multiple undo steps for rest of process # we will switch off undo-ing with viewer_update_and_undo_stop(): read_node["file"].setValue(filepath) self.set_colorspace_to_node( read_node, filepath, version, representation) self._set_range_to_node(read_node, first, last, start_at_workfile) # add additional metadata from the version to imprint Avalon knob add_keys = ["frameStart", "frameEnd", "source", "colorspace", "author", "fps", "version", "handleStart", "handleEnd"] data_imprint = {} for key in add_keys: if key == 'version': version_doc = context["version"] if version_doc["type"] == "hero_version": version = "hero" else: version = version_doc.get("name") if version: data_imprint[key] = version elif key == 'colorspace': colorspace = representation["data"].get(key) colorspace = colorspace or version_data.get(key) data_imprint["db_colorspace"] = colorspace else: value_ = context["version"]['data'].get( key, str(None)) if isinstance(value_, (str)): value_ = value_.replace("\\", "/") data_imprint[key] = value_ if add_retime and version_data.get("retime", None): data_imprint["addRetime"] = True read_node["tile_color"].setValue(int("0x4ecd25ff", 16)) container = containerise( read_node, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, data=data_imprint) if add_retime and version_data.get("retime", None): self._make_retimes(read_node, version_data) self.set_as_member(read_node) return container def switch(self, container, representation): self.update(container, representation) def _representation_with_hash_in_frame(self, representation): """Convert frame key value to padded hash Args: representation (dict): representation data Returns: dict: altered representation data """ representation = deepcopy(representation) context = representation["context"] # Get the frame from the context and hash it frame = context["frame"] hashed_frame = "#" * len(str(frame)) # Replace the frame with the hash in the originalBasename if ( "{originalBasename}" in representation["data"]["template"] ): origin_basename = context["originalBasename"] context["originalBasename"] = origin_basename.replace( frame, hashed_frame ) # Replace the frame with the hash in the frame representation["context"]["frame"] = hashed_frame return representation def update(self, container, representation): """Update the Loader's path Nuke automatically tries to reset some variables when changing the loader's path to a new file. These automatic changes are to its inputs: """ is_sequence = len(representation["files"]) > 1 read_node = container["node"] if is_sequence: representation = self._representation_with_hash_in_frame( representation ) filepath = get_representation_path(representation).replace("\\", "/") self.log.debug("_ filepath: {}".format(filepath)) start_at_workfile = "start at" in read_node['frame_mode'].value() add_retime = [ key for key in read_node.knobs().keys() if "addRetime" in key ] project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) version_data = version_doc.get("data", {}) repre_id = representation["_id"] # colorspace profile colorspace = representation["data"].get("colorspace") colorspace = colorspace or version_data.get("colorspace") self.handle_start = version_data.get("handleStart", 0) self.handle_end = version_data.get("handleEnd", 0) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) first -= self.handle_start last += self.handle_end if not is_sequence: duration = last - first first = 1 last = first + duration if not filepath: self.log.warning( "Representation id `{}` is failing to load".format(repre_id)) return read_node["file"].setValue(filepath) # to avoid multiple undo steps for rest of process # we will switch off undo-ing with viewer_update_and_undo_stop(): self.set_colorspace_to_node( read_node, filepath, version_doc, representation) self._set_range_to_node(read_node, first, last, start_at_workfile) updated_dict = { "representation": str(representation["_id"]), "frameStart": str(first), "frameEnd": str(last), "version": str(version_doc.get("name")), "db_colorspace": colorspace, "source": version_data.get("source"), "handleStart": str(self.handle_start), "handleEnd": str(self.handle_end), "fps": str(version_data.get("fps")), "author": version_data.get("author") } last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] ) # change color of read_node if version_doc["_id"] == last_version_doc["_id"]: color_value = "0x4ecd25ff" else: color_value = "0xd84f20ff" read_node["tile_color"].setValue(int(color_value, 16)) # Update the imprinted representation update_container( read_node, updated_dict ) self.log.info( "updated to version: {}".format(version_doc.get("name")) ) if add_retime and version_data.get("retime", None): self._make_retimes(read_node, version_data) else: self.clear_members(read_node) self.set_as_member(read_node) def set_colorspace_to_node( self, read_node, filepath, version_doc, representation_doc, ): """Set colorspace to read node. Sets colorspace with available names validation. Args: read_node (nuke.Node): The nuke's read node filepath (str): file path version_doc (dict): version document representation_doc (dict): representation document """ used_colorspace = self._get_colorspace_data( version_doc, representation_doc, filepath) if ( used_colorspace and colorspace_exists_on_node(read_node, used_colorspace) ): self.log.info(f"Used colorspace: {used_colorspace}") read_node["colorspace"].setValue(used_colorspace) else: self.log.info("Colorspace not set...") def remove(self, container): read_node = container["node"] assert read_node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): members = self.get_members(read_node) nuke.delete(read_node) for member in members: nuke.delete(member) def _set_range_to_node(self, read_node, first, last, start_at_workfile): read_node['origfirst'].setValue(int(first)) read_node['first'].setValue(int(first)) read_node['origlast'].setValue(int(last)) read_node['last'].setValue(int(last)) # set start frame depending on workfile or version self._loader_shift(read_node, start_at_workfile) def _make_retimes(self, parent_node, version_data): ''' Create all retime and timewarping nodes with copied animation ''' speed = version_data.get('speed', 1) time_warp_nodes = version_data.get('timewarps', []) last_node = None source_id = self.get_container_id(parent_node) self.log.debug("__ source_id: {}".format(source_id)) self.log.debug("__ members: {}".format( self.get_members(parent_node))) dependent_nodes = self.clear_members(parent_node) with maintained_selection(): parent_node['selected'].setValue(True) if speed != 1: rtn = nuke.createNode( "Retime", "speed {}".format(speed)) rtn["before"].setValue("continue") rtn["after"].setValue("continue") rtn["input.first_lock"].setValue(True) rtn["input.first"].setValue( self.script_start ) self.set_as_member(rtn) last_node = rtn if time_warp_nodes != []: start_anim = self.script_start + (self.handle_start / speed) for timewarp in time_warp_nodes: twn = nuke.createNode( timewarp["Class"], "name {}".format(timewarp["name"]) ) if isinstance(timewarp["lookup"], list): # if array for animation twn["lookup"].setAnimated() for i, value in enumerate(timewarp["lookup"]): twn["lookup"].setValueAt( (start_anim + i) + value, (start_anim + i)) else: # if static value `int` twn["lookup"].setValue(timewarp["lookup"]) self.set_as_member(twn) last_node = twn if dependent_nodes: # connect to original inputs for i, n in enumerate(dependent_nodes): last_node.setInput(i, n) def _loader_shift(self, read_node, workfile_start=False): """ Set start frame of read node to a workfile start Args: read_node (nuke.Node): The nuke's read node workfile_start (bool): set workfile start frame if true """ if workfile_start: read_node['frame_mode'].setValue("start at") read_node['frame'].setValue(str(self.script_start)) def _get_node_name(self, representation): repre_cont = representation["context"] name_data = { "asset": repre_cont["asset"], "subset": repre_cont["subset"], "representation": representation["name"], "ext": repre_cont["representation"], "id": representation["_id"], "class_name": self.__class__.__name__ } return self.node_name_template.format(**name_data) def _get_colorspace_data(self, version_doc, representation_doc, filepath): """Get colorspace data from version and representation documents Args: version_doc (dict): version document representation_doc (dict): representation document filepath (str): file path Returns: Any[str,None]: colorspace name or None """ # Get backward compatible colorspace key. colorspace = representation_doc["data"].get("colorspace") self.log.debug( f"Colorspace from representation colorspace: {colorspace}" ) # Get backward compatible version data key if colorspace is not found. colorspace = colorspace or version_doc["data"].get("colorspace") self.log.debug(f"Colorspace from version colorspace: {colorspace}") # Get colorspace from representation colorspaceData if colorspace is # not found. colorspace_data = representation_doc["data"].get("colorspaceData", {}) colorspace = colorspace or colorspace_data.get("colorspace") self.log.debug( f"Colorspace from representation colorspaceData: {colorspace}" ) print(f"Colorspace found: {colorspace}") # check if any filerules are not applicable new_parsed_colorspace = get_imageio_file_rules_colorspace_from_filepath( # noqa filepath, "nuke", get_current_project_name() ) self.log.debug(f"Colorspace new filerules: {new_parsed_colorspace}") # colorspace from `project_settings/nuke/imageio/regexInputs` old_parsed_colorspace = get_imageio_input_colorspace(filepath) self.log.debug(f"Colorspace old filerules: {old_parsed_colorspace}") return ( new_parsed_colorspace or old_parsed_colorspace or colorspace ) ================================================ FILE: openpype/hosts/nuke/plugins/load/load_effects.py ================================================ import json from collections import OrderedDict import nuke import six from openpype.client import ( get_version_by_id, get_last_version_by_subset_id, ) from openpype.pipeline import ( load, get_current_project_name, get_representation_path, ) from openpype.hosts.nuke.api import ( containerise, update_container, viewer_update_and_undo_stop ) class LoadEffects(load.LoaderPlugin): """Loading colorspace soft effect exported from nukestudio""" families = ["effect"] representations = ["*"] extensions = {"json"} label = "Load Effects - nodes" order = 0 icon = "cc" color = "white" ignore_attr = ["useLifetime"] def load(self, context, name, namespace, data): """ Loading function to get the soft effects to particular read node Arguments: context (dict): context of version name (str): name of the version namespace (str): asset name data (dict): compulsory attribute > not used Returns: nuke node: containerised nuke node object """ # get main variables version = context['version'] version_data = version.get("data", {}) vname = version.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) workfile_first_frame = int(nuke.root()["first_frame"].getValue()) namespace = namespace or context['asset']['name'] colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) # prepare data for imprinting # add additional metadata from the version to imprint to Avalon knob add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] data_imprint = { "frameStart": first, "frameEnd": last, "version": vname, "colorspaceInput": colorspace, } for k in add_keys: data_imprint.update({k: version_data[k]}) # getting file path file = self.filepath_from_context(context).replace("\\", "/") # getting data from json file with unicode conversion with open(file, "r") as f: json_f = {self.byteify(key): self.byteify(value) for key, value in json.load(f).items()} # get correct order of nodes by positions on track and subtrack nodes_order = self.reorder_nodes(json_f) # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() GN = nuke.createNode( "Group", "name {}_1".format(object_name), inpanel=False ) # adding content to the group node with GN: pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") for ef_name, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: continue try: node[k].value() except NameError as e: self.log.warning(e) continue if isinstance(v, list) and len(v) > 4: node[k].setAnimated() for i, value in enumerate(v): if isinstance(value, list): for ci, cv in enumerate(value): node[k].setValueAt( cv, (workfile_first_frame + i), ci) else: node[k].setValueAt( value, (workfile_first_frame + i)) else: node[k].setValue(v) node.setInput(0, pre_node) pre_node = node output = nuke.createNode("Output") output.setInput(0, pre_node) # try to find parent read node self.connect_read_node(GN, namespace, json_f["assignTo"]) GN["tile_color"].setValue(int("0x3469ffff", 16)) self.log.info("Loaded lut setup: `{}`".format(GN["name"].value())) return containerise( node=GN, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, data=data_imprint) def update(self, container, representation): """Update the Loader's path Nuke automatically tries to reset some variables when changing the loader's path to a new file. These automatic changes are to its inputs: """ # get main variables # Get version from io project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node GN = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] version_data = version_doc.get("data", {}) vname = version_doc.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) workfile_first_frame = int(nuke.root()["first_frame"].getValue()) namespace = container['namespace'] colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] data_imprint = { "representation": str(representation["_id"]), "frameStart": first, "frameEnd": last, "version": vname, "colorspaceInput": colorspace } for k in add_keys: data_imprint.update({k: version_data[k]}) # Update the imprinted representation update_container( GN, data_imprint ) # getting data from json file with unicode conversion with open(file, "r") as f: json_f = {self.byteify(key): self.byteify(value) for key, value in json.load(f).items()} # get correct order of nodes by positions on track and subtrack nodes_order = self.reorder_nodes(json_f) # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() # adding content to the group node with GN: # first remove all nodes [nuke.delete(n) for n in nuke.allNodes()] # create input node pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: continue try: node[k].value() except NameError as e: self.log.warning(e) continue if isinstance(v, list) and len(v) > 4: node[k].setAnimated() for i, value in enumerate(v): if isinstance(value, list): for ci, cv in enumerate(value): node[k].setValueAt( cv, (workfile_first_frame + i), ci) else: node[k].setValueAt( value, (workfile_first_frame + i)) else: node[k].setValue(v) node.setInput(0, pre_node) pre_node = node # create output node output = nuke.createNode("Output") output.setInput(0, pre_node) # try to find parent read node self.connect_read_node(GN, namespace, json_f["assignTo"]) last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] ) # change color of node if version_doc["_id"] == last_version_doc["_id"]: color_value = "0x3469ffff" else: color_value = "0xd84f20ff" GN["tile_color"].setValue(int(color_value, 16)) self.log.info("updated to version: {}".format(version_doc.get("name"))) def connect_read_node(self, group_node, asset, subset): """ Finds read node and selects it Arguments: asset (str): asset name Returns: nuke node: node is selected None: if nothing found """ search_name = "{0}_{1}".format(asset, subset) node = [ n for n in nuke.allNodes(filter="Read") if search_name in n["file"].value() ] if len(node) > 0: rn = node[0] else: rn = None # Parent read node has been found # solving connections if rn: dep_nodes = rn.dependent() if len(dep_nodes) > 0: for dn in dep_nodes: dn.setInput(0, group_node) group_node.setInput(0, rn) group_node.autoplace() def reorder_nodes(self, data): new_order = OrderedDict() trackNums = [v["trackIndex"] for k, v in data.items() if isinstance(v, dict)] subTrackNums = [v["subTrackIndex"] for k, v in data.items() if isinstance(v, dict)] for trackIndex in range( min(trackNums), max(trackNums) + 1): for subTrackIndex in range( min(subTrackNums), max(subTrackNums) + 1): item = self.get_item(data, trackIndex, subTrackIndex) if item is not {}: new_order.update(item) return new_order def get_item(self, data, trackIndex, subTrackIndex): return {key: val for key, val in data.items() if isinstance(val, dict) if subTrackIndex == val["subTrackIndex"] if trackIndex == val["trackIndex"]} def byteify(self, input): """ Converts unicode strings to strings It goes through all dictionary Arguments: input (dict/str): input Returns: dict: with fixed values and keys """ if isinstance(input, dict): return {self.byteify(key): self.byteify(value) for key, value in input.items()} elif isinstance(input, list): return [self.byteify(element) for element in input] elif isinstance(input, six.text_type): return str(input) else: return input def switch(self, container, representation): self.update(container, representation) def remove(self, container): node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) ================================================ FILE: openpype/hosts/nuke/plugins/load/load_effects_ip.py ================================================ import json from collections import OrderedDict import six import nuke from openpype.client import ( get_version_by_id, get_last_version_by_subset_id, ) from openpype.pipeline import ( load, get_current_project_name, get_representation_path, ) from openpype.hosts.nuke.api import lib from openpype.hosts.nuke.api import ( containerise, update_container, viewer_update_and_undo_stop ) class LoadEffectsInputProcess(load.LoaderPlugin): """Loading colorspace soft effect exported from nukestudio""" families = ["effect"] representations = ["*"] extensions = {"json"} label = "Load Effects - Input Process" order = 0 icon = "eye" color = "#cc0000" ignore_attr = ["useLifetime"] def load(self, context, name, namespace, data): """ Loading function to get the soft effects to particular read node Arguments: context (dict): context of version name (str): name of the version namespace (str): asset name data (dict): compulsory attribute > not used Returns: nuke node: containerised nuke node object """ # get main variables version = context['version'] version_data = version.get("data", {}) vname = version.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) workfile_first_frame = int(nuke.root()["first_frame"].getValue()) namespace = namespace or context['asset']['name'] colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) # prepare data for imprinting # add additional metadata from the version to imprint to Avalon knob add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] data_imprint = { "frameStart": first, "frameEnd": last, "version": vname, "colorspaceInput": colorspace, } for k in add_keys: data_imprint.update({k: version_data[k]}) # getting file path file = self.filepath_from_context(context).replace("\\", "/") # getting data from json file with unicode conversion with open(file, "r") as f: json_f = {self.byteify(key): self.byteify(value) for key, value in json.load(f).items()} # get correct order of nodes by positions on track and subtrack nodes_order = self.reorder_nodes(json_f) # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() GN = nuke.createNode( "Group", "name {}_1".format(object_name), inpanel=False ) # adding content to the group node with GN: pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: continue try: node[k].value() except NameError as e: self.log.warning(e) continue if isinstance(v, list) and len(v) > 4: node[k].setAnimated() for i, value in enumerate(v): if isinstance(value, list): for ci, cv in enumerate(value): node[k].setValueAt( cv, (workfile_first_frame + i), ci) else: node[k].setValueAt( value, (workfile_first_frame + i)) else: node[k].setValue(v) node.setInput(0, pre_node) pre_node = node output = nuke.createNode("Output") output.setInput(0, pre_node) # try to place it under Viewer1 if not self.connect_active_viewer(GN): nuke.delete(GN) return GN["tile_color"].setValue(int("0x3469ffff", 16)) self.log.info("Loaded lut setup: `{}`".format(GN["name"].value())) return containerise( node=GN, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, data=data_imprint) def update(self, container, representation): """Update the Loader's path Nuke automatically tries to reset some variables when changing the loader's path to a new file. These automatic changes are to its inputs: """ # get main variables # Get version from io project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node GN = container["node"] file = get_representation_path(representation).replace("\\", "/") version_data = version_doc.get("data", {}) vname = version_doc.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) workfile_first_frame = int(nuke.root()["first_frame"].getValue()) colorspace = version_data.get("colorspace", None) add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] data_imprint = { "representation": str(representation["_id"]), "frameStart": first, "frameEnd": last, "version": vname, "colorspaceInput": colorspace, } for k in add_keys: data_imprint.update({k: version_data[k]}) # Update the imprinted representation update_container( GN, data_imprint ) # getting data from json file with unicode conversion with open(file, "r") as f: json_f = {self.byteify(key): self.byteify(value) for key, value in json.load(f).items()} # get correct order of nodes by positions on track and subtrack nodes_order = self.reorder_nodes(json_f) # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() # adding content to the group node with GN: # first remove all nodes [nuke.delete(n) for n in nuke.allNodes()] # create input node pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: continue try: node[k].value() except NameError as e: self.log.warning(e) continue if isinstance(v, list) and len(v) > 4: node[k].setAnimated() for i, value in enumerate(v): if isinstance(value, list): for ci, cv in enumerate(value): node[k].setValueAt( cv, (workfile_first_frame + i), ci) else: node[k].setValueAt( value, (workfile_first_frame + i)) else: node[k].setValue(v) node.setInput(0, pre_node) pre_node = node # create output node output = nuke.createNode("Output") output.setInput(0, pre_node) # get all versions in list last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] ) # change color of node if version_doc["_id"] == last_version_doc["_id"]: color_value = "0x3469ffff" else: color_value = "0xd84f20ff" GN["tile_color"].setValue(int(color_value, 16)) self.log.info("updated to version: {}".format(version_doc.get("name"))) def connect_active_viewer(self, group_node): """ Finds Active viewer and place the node under it, also adds name of group into Input Process of the viewer Arguments: group_node (nuke node): nuke group node object """ group_node_name = group_node["name"].value() viewer = [n for n in nuke.allNodes() if "Viewer1" in n["name"].value()] if len(viewer) > 0: viewer = viewer[0] else: msg = str("Please create Viewer node before you " "run this action again") self.log.error(msg) nuke.message(msg) return None # get coordinates of Viewer1 xpos = viewer["xpos"].value() ypos = viewer["ypos"].value() ypos += 150 viewer["ypos"].setValue(ypos) # set coordinates to group node group_node["xpos"].setValue(xpos) group_node["ypos"].setValue(ypos + 50) # add group node name to Viewer Input Process viewer["input_process_node"].setValue(group_node_name) # put backdrop under lib.create_backdrop( label="Input Process", layer=2, nodes=[viewer, group_node], color="0x7c7faaff") return True def reorder_nodes(self, data): new_order = OrderedDict() trackNums = [v["trackIndex"] for k, v in data.items() if isinstance(v, dict)] subTrackNums = [v["subTrackIndex"] for k, v in data.items() if isinstance(v, dict)] for trackIndex in range( min(trackNums), max(trackNums) + 1): for subTrackIndex in range( min(subTrackNums), max(subTrackNums) + 1): item = self.get_item(data, trackIndex, subTrackIndex) if item is not {}: new_order.update(item) return new_order def get_item(self, data, trackIndex, subTrackIndex): return {key: val for key, val in data.items() if isinstance(val, dict) if subTrackIndex == val["subTrackIndex"] if trackIndex == val["trackIndex"]} def byteify(self, input): """ Converts unicode strings to strings It goes through all dictionary Arguments: input (dict/str): input Returns: dict: with fixed values and keys """ if isinstance(input, dict): return {self.byteify(key): self.byteify(value) for key, value in input.items()} elif isinstance(input, list): return [self.byteify(element) for element in input] elif isinstance(input, six.text_type): return str(input) else: return input def switch(self, container, representation): self.update(container, representation) def remove(self, container): node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) ================================================ FILE: openpype/hosts/nuke/plugins/load/load_gizmo.py ================================================ import nuke from openpype.client import ( get_version_by_id, get_last_version_by_subset_id, ) from openpype.pipeline import ( load, get_current_project_name, get_representation_path, ) from openpype.hosts.nuke.api.lib import ( maintained_selection, get_avalon_knob_data, set_avalon_knob_data, swap_node_with_dependency, ) from openpype.hosts.nuke.api import ( containerise, update_container, viewer_update_and_undo_stop ) class LoadGizmo(load.LoaderPlugin): """Loading nuke Gizmo""" families = ["gizmo", "lensDistortion"] representations = ["*"] extensions = {"nk"} label = "Load Gizmo" order = 0 icon = "dropbox" color = "white" node_color = "0x75338eff" def load(self, context, name, namespace, data): """ Loading function to get Gizmo into node graph Arguments: context (dict): context of version name (str): name of the version namespace (str): asset name data (dict): compulsory attribute > not used Returns: nuke node: containerized nuke node object """ # get main variables version = context['version'] version_data = version.get("data", {}) vname = version.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) namespace = namespace or context['asset']['name'] colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) # prepare data for imprinting # add additional metadata from the version to imprint to Avalon knob add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] data_imprint = { "frameStart": first, "frameEnd": last, "version": vname, "colorspaceInput": colorspace } for k in add_keys: data_imprint.update({k: version_data[k]}) # getting file path file = self.filepath_from_context(context).replace("\\", "/") # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() with maintained_selection(): # add group from nk nuke.nodePaste(file) group_node = nuke.selectedNode() group_node["name"].setValue(object_name) return containerise( node=group_node, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, data=data_imprint) def update(self, container, representation): """Update the Loader's path Nuke automatically tries to reset some variables when changing the loader's path to a new file. These automatic changes are to its inputs: """ # get main variables # Get version from io project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node group_node = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] version_data = version_doc.get("data", {}) vname = version_doc.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) namespace = container['namespace'] colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] data_imprint = { "representation": str(representation["_id"]), "frameStart": first, "frameEnd": last, "version": vname, "colorspaceInput": colorspace } for k in add_keys: data_imprint.update({k: version_data[k]}) # capture pipeline metadata avalon_data = get_avalon_knob_data(group_node) # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() with maintained_selection([group_node]): # insert nuke script to the script nuke.nodePaste(file) # convert imported to selected node new_group_node = nuke.selectedNode() # swap nodes with maintained connections with swap_node_with_dependency( group_node, new_group_node) as node_name: new_group_node["name"].setValue(node_name) # set updated pipeline metadata set_avalon_knob_data(new_group_node, avalon_data) last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] ) # change color of node if version_doc["_id"] == last_version_doc["_id"]: color_value = self.node_color else: color_value = "0xd88467ff" new_group_node["tile_color"].setValue(int(color_value, 16)) self.log.info("updated to version: {}".format(version_doc.get("name"))) return update_container(new_group_node, data_imprint) def switch(self, container, representation): self.update(container, representation) def remove(self, container): node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) ================================================ FILE: openpype/hosts/nuke/plugins/load/load_gizmo_ip.py ================================================ import nuke import six from openpype.client import ( get_version_by_id, get_last_version_by_subset_id, ) from openpype.pipeline import ( load, get_current_project_name, get_representation_path, ) from openpype.hosts.nuke.api.lib import ( maintained_selection, create_backdrop, get_avalon_knob_data, set_avalon_knob_data, swap_node_with_dependency, ) from openpype.hosts.nuke.api import ( containerise, update_container, viewer_update_and_undo_stop ) class LoadGizmoInputProcess(load.LoaderPlugin): """Loading colorspace soft effect exported from nukestudio""" families = ["gizmo"] representations = ["*"] extensions = {"nk"} label = "Load Gizmo - Input Process" order = 0 icon = "eye" color = "#cc0000" node_color = "0x7533c1ff" def load(self, context, name, namespace, data): """ Loading function to get Gizmo as Input Process on viewer Arguments: context (dict): context of version name (str): name of the version namespace (str): asset name data (dict): compulsory attribute > not used Returns: nuke node: containerized nuke node object """ # get main variables version = context['version'] version_data = version.get("data", {}) vname = version.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) namespace = namespace or context['asset']['name'] colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) # prepare data for imprinting # add additional metadata from the version to imprint to Avalon knob add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] data_imprint = { "frameStart": first, "frameEnd": last, "version": vname, "colorspaceInput": colorspace } for k in add_keys: data_imprint.update({k: version_data[k]}) # getting file path file = self.filepath_from_context(context).replace("\\", "/") # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() with maintained_selection(): # add group from nk nuke.nodePaste(file) group_node = nuke.selectedNode() group_node["name"].setValue(object_name) # try to place it under Viewer1 if not self.connect_active_viewer(group_node): nuke.delete(group_node) return return containerise( node=group_node, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, data=data_imprint) def update(self, container, representation): """Update the Loader's path Nuke automatically tries to reset some variables when changing the loader's path to a new file. These automatic changes are to its inputs: """ # get main variables # Get version from io project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node group_node = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] version_data = version_doc.get("data", {}) vname = version_doc.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) namespace = container['namespace'] colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] data_imprint = { "representation": str(representation["_id"]), "frameStart": first, "frameEnd": last, "version": vname, "colorspaceInput": colorspace } for k in add_keys: data_imprint.update({k: version_data[k]}) # capture pipeline metadata avalon_data = get_avalon_knob_data(group_node) # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() with maintained_selection([group_node]): # insert nuke script to the script nuke.nodePaste(file) # convert imported to selected node new_group_node = nuke.selectedNode() # swap nodes with maintained connections with swap_node_with_dependency( group_node, new_group_node) as node_name: new_group_node["name"].setValue(node_name) # set updated pipeline metadata set_avalon_knob_data(new_group_node, avalon_data) last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] ) # change color of node if version_doc["_id"] == last_version_doc["_id"]: color_value = self.node_color else: color_value = "0xd88467ff" new_group_node["tile_color"].setValue(int(color_value, 16)) self.log.info("updated to version: {}".format(version_doc.get("name"))) return update_container(new_group_node, data_imprint) def connect_active_viewer(self, group_node): """ Finds Active viewer and place the node under it, also adds name of group into Input Process of the viewer Arguments: group_node (nuke node): nuke group node object """ group_node_name = group_node["name"].value() viewer = [n for n in nuke.allNodes() if "Viewer1" in n["name"].value()] if len(viewer) > 0: viewer = viewer[0] else: msg = str("Please create Viewer node before you " "run this action again") self.log.error(msg) nuke.message(msg) return None # get coordinates of Viewer1 xpos = viewer["xpos"].value() ypos = viewer["ypos"].value() ypos += 150 viewer["ypos"].setValue(ypos) # set coordinates to group node group_node["xpos"].setValue(xpos) group_node["ypos"].setValue(ypos + 50) # add group node name to Viewer Input Process viewer["input_process_node"].setValue(group_node_name) # put backdrop under create_backdrop( label="Input Process", layer=2, nodes=[viewer, group_node], color="0x7c7faaff" ) return True def get_item(self, data, trackIndex, subTrackIndex): return {key: val for key, val in data.items() if subTrackIndex == val["subTrackIndex"] if trackIndex == val["trackIndex"]} def byteify(self, input): """ Converts unicode strings to strings It goes through all dictionary Arguments: input (dict/str): input Returns: dict: with fixed values and keys """ if isinstance(input, dict): return {self.byteify(key): self.byteify(value) for key, value in input.items()} elif isinstance(input, list): return [self.byteify(element) for element in input] elif isinstance(input, six.text_type): return str(input) else: return input def switch(self, container, representation): self.update(container, representation) def remove(self, container): node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) ================================================ FILE: openpype/hosts/nuke/plugins/load/load_image.py ================================================ import nuke import qargparse from openpype.client import ( get_version_by_id, get_last_version_by_subset_id, ) from openpype.pipeline import ( load, get_current_project_name, get_representation_path, ) from openpype.hosts.nuke.api.lib import ( get_imageio_input_colorspace ) from openpype.hosts.nuke.api import ( containerise, update_container, viewer_update_and_undo_stop ) from openpype.lib.transcoding import ( IMAGE_EXTENSIONS ) class LoadImage(load.LoaderPlugin): """Load still image into Nuke""" families = [ "render2d", "source", "plate", "render", "prerender", "review", "image" ] representations = ["*"] extensions = set( ext.lstrip(".") for ext in IMAGE_EXTENSIONS ) label = "Load Image" order = -10 icon = "image" color = "white" # Loaded from settings _representations = [] node_name_template = "{class_name}_{ext}" options = [ qargparse.Integer( "frame_number", label="Frame Number", default=int(nuke.root()["first_frame"].getValue()), min=1, max=999999, help="What frame is reading from?" ) ] @classmethod def get_representations(cls): return cls._representations or cls.representations def load(self, context, name, namespace, options): self.log.info("__ options: `{}`".format(options)) frame_number = options.get( "frame_number", int(nuke.root()["first_frame"].getValue()) ) version = context['version'] version_data = version.get("data", {}) repr_id = context["representation"]["_id"] self.log.info("version_data: {}\n".format(version_data)) self.log.debug( "Representation id `{}` ".format(repr_id)) last = first = int(frame_number) # Fallback to asset name when namespace is None if namespace is None: namespace = context['asset']['name'] file = self.filepath_from_context(context) if not file: repr_id = context["representation"]["_id"] self.log.warning( "Representation id `{}` is failing to load".format(repr_id)) return file = file.replace("\\", "/") representation = context["representation"] repr_cont = representation["context"] frame = repr_cont.get("frame") if frame: padding = len(frame) file = file.replace( frame, format(frame_number, "0{}".format(padding))) read_name = self._get_node_name(representation) # Create the Loader with the filename path set with viewer_update_and_undo_stop(): r = nuke.createNode( "Read", "name {}".format(read_name), inpanel=False ) r["file"].setValue(file) # Set colorspace defined in version data colorspace = context["version"]["data"].get("colorspace") if colorspace: r["colorspace"].setValue(str(colorspace)) preset_clrsp = get_imageio_input_colorspace(file) if preset_clrsp is not None: r["colorspace"].setValue(preset_clrsp) r["origfirst"].setValue(first) r["first"].setValue(first) r["origlast"].setValue(last) r["last"].setValue(last) # add additional metadata from the version to imprint Avalon knob add_keys = ["source", "colorspace", "author", "fps", "version"] data_imprint = { "frameStart": first, "frameEnd": last } for k in add_keys: if k == 'version': data_imprint.update({k: context["version"]['name']}) else: data_imprint.update( {k: context["version"]['data'].get(k, str(None))}) r["tile_color"].setValue(int("0x4ecd25ff", 16)) return containerise(r, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, data=data_imprint) def switch(self, container, representation): self.update(container, representation) def update(self, container, representation): """Update the Loader's path Nuke automatically tries to reset some variables when changing the loader's path to a new file. These automatic changes are to its inputs: """ node = container["node"] frame_number = node["first"].value() assert node.Class() == "Read", "Must be Read" repr_cont = representation["context"] file = get_representation_path(representation) if not file: repr_id = representation["_id"] self.log.warning( "Representation id `{}` is failing to load".format(repr_id)) return file = file.replace("\\", "/") frame = repr_cont.get("frame") if frame: padding = len(frame) file = file.replace( frame, format(frame_number, "0{}".format(padding))) # Get start frame from version data project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] ) version_data = version_doc.get("data", {}) last = first = int(frame_number) # Set the global in to the start frame of the sequence node["file"].setValue(file) node["origfirst"].setValue(first) node["first"].setValue(first) node["origlast"].setValue(last) node["last"].setValue(last) updated_dict = {} updated_dict.update({ "representation": str(representation["_id"]), "frameStart": str(first), "frameEnd": str(last), "version": str(version_doc.get("name")), "colorspace": version_data.get("colorspace"), "source": version_data.get("source"), "fps": str(version_data.get("fps")), "author": version_data.get("author") }) # change color of node if version_doc["_id"] == last_version_doc["_id"]: color_value = "0x4ecd25ff" else: color_value = "0xd84f20ff" node["tile_color"].setValue(int(color_value, 16)) # Update the imprinted representation update_container( node, updated_dict ) self.log.info("updated to version: {}".format(version_doc.get("name"))) def remove(self, container): node = container["node"] assert node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): nuke.delete(node) def _get_node_name(self, representation): repre_cont = representation["context"] name_data = { "asset": repre_cont["asset"], "subset": repre_cont["subset"], "representation": representation["name"], "ext": repre_cont["representation"], "id": representation["_id"], "class_name": self.__class__.__name__ } return self.node_name_template.format(**name_data) ================================================ FILE: openpype/hosts/nuke/plugins/load/load_matchmove.py ================================================ import nuke from openpype.pipeline import load class MatchmoveLoader(load.LoaderPlugin): """ This will run matchmove script to create track in script. """ families = ["matchmove"] representations = ["*"] extensions = {"py"} defaults = ["Camera", "Object"] label = "Run matchmove script" icon = "empire" color = "orange" def load(self, context, name, namespace, data): path = self.filepath_from_context(context) if path.lower().endswith(".py"): exec(open(path).read()) else: msg = "Unsupported script type" self.log.error(msg) nuke.message(msg) return True ================================================ FILE: openpype/hosts/nuke/plugins/load/load_model.py ================================================ import nuke from openpype.client import ( get_version_by_id, get_last_version_by_subset_id, ) from openpype.pipeline import ( load, get_current_project_name, get_representation_path, ) from openpype.hosts.nuke.api.lib import maintained_selection from openpype.hosts.nuke.api import ( containerise, update_container, viewer_update_and_undo_stop ) class AlembicModelLoader(load.LoaderPlugin): """ This will load alembic model or anim into script. """ families = ["model", "pointcache", "animation"] representations = ["*"] extensions = {"abc"} label = "Load Alembic" icon = "cube" color = "orange" node_color = "0x4ecd91ff" def load(self, context, name, namespace, data): # get main variables version = context['version'] version_data = version.get("data", {}) vname = version.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) fps = version_data.get("fps") or nuke.root()["fps"].getValue() namespace = namespace or context['asset']['name'] object_name = "{}_{}".format(name, namespace) # prepare data for imprinting # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] data_imprint = { "frameStart": first, "frameEnd": last, "version": vname } for k in add_keys: data_imprint.update({k: version_data[k]}) # getting file path file = self.filepath_from_context(context).replace("\\", "/") with maintained_selection(): model_node = nuke.createNode( "ReadGeo2", "name {} file {} ".format( object_name, file), inpanel=False ) model_node.forceValidate() # Ensure all items are imported and selected. scene_view = model_node.knob('scene_view') scene_view.setImportedItems(scene_view.getAllItems()) scene_view.setSelectedItems(scene_view.getAllItems()) model_node["frame_rate"].setValue(float(fps)) # workaround because nuke's bug is not adding # animation keys properly xpos = model_node.xpos() ypos = model_node.ypos() nuke.nodeCopy("%clipboard%") nuke.delete(model_node) nuke.nodePaste("%clipboard%") model_node = nuke.toNode(object_name) model_node.setXYpos(xpos, ypos) # color node by correct color by actual version self.node_version_color(version, model_node) return containerise( node=model_node, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, data=data_imprint) def update(self, container, representation): """ Called by Scene Inventory when look should be updated to current version. If any reference edits cannot be applied, eg. shader renamed and material not present, reference is unloaded and cleaned. All failed edits are highlighted to the user via message box. Args: container: object that has look to be updated representation: (dict): relationship data to get proper representation from DB and persisted data in .json Returns: None """ # Get version from io project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node model_node = container["node"] # get main variables version_data = version_doc.get("data", {}) vname = version_doc.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) fps = version_data.get("fps") or nuke.root()["fps"].getValue() # prepare data for imprinting # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] data_imprint = { "representation": str(representation["_id"]), "frameStart": first, "frameEnd": last, "version": vname } for k in add_keys: data_imprint.update({k: version_data[k]}) # getting file path file = get_representation_path(representation).replace("\\", "/") with maintained_selection(): model_node['selected'].setValue(True) # collect input output dependencies dependencies = model_node.dependencies() dependent = model_node.dependent() model_node["frame_rate"].setValue(float(fps)) model_node["file"].setValue(file) # Ensure all items are imported and selected. scene_view = model_node.knob('scene_view') scene_view.setImportedItems(scene_view.getAllItems()) scene_view.setSelectedItems(scene_view.getAllItems()) # workaround because nuke's bug is # not adding animation keys properly xpos = model_node.xpos() ypos = model_node.ypos() nuke.nodeCopy("%clipboard%") nuke.delete(model_node) # paste the node back and set the position nuke.nodePaste("%clipboard%") model_node = nuke.selectedNode() model_node.setXYpos(xpos, ypos) # link to original input nodes for i, input in enumerate(dependencies): model_node.setInput(i, input) # link to original output nodes for d in dependent: index = next((i for i, dpcy in enumerate( d.dependencies()) if model_node is dpcy), 0) d.setInput(index, model_node) # color node by correct color by actual version self.node_version_color(version_doc, model_node) self.log.info("updated to version: {}".format(version_doc.get("name"))) return update_container(model_node, data_imprint) def node_version_color(self, version, node): """ Coloring a node by correct color by actual version""" project_name = get_current_project_name() last_version_doc = get_last_version_by_subset_id( project_name, version["parent"], fields=["_id"] ) # change color of node if version["_id"] == last_version_doc["_id"]: color_value = self.node_color else: color_value = "0xd88467ff" node["tile_color"].setValue(int(color_value, 16)) def switch(self, container, representation): self.update(container, representation) def remove(self, container): node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) ================================================ FILE: openpype/hosts/nuke/plugins/load/load_ociolook.py ================================================ import os import json import secrets import nuke import six from openpype.client import ( get_version_by_id, get_last_version_by_subset_id ) from openpype.pipeline import ( load, get_current_project_name, get_representation_path, ) from openpype.hosts.nuke.api import ( containerise, viewer_update_and_undo_stop, update_container, ) class LoadOcioLookNodes(load.LoaderPlugin): """Loading Ocio look to the nuke.Node graph""" families = ["ociolook"] representations = ["*"] extensions = {"json"} label = "Load OcioLook [nodes]" order = 0 icon = "cc" color = "white" ignore_attr = ["useLifetime"] # plugin attributes current_node_color = "0x4ecd91ff" old_node_color = "0xd88467ff" # json file variables schema_version = 1 def load(self, context, name, namespace, data): """ Loading function to get the soft effects to particular read node Arguments: context (dict): context of version name (str): name of the version namespace (str): asset name data (dict): compulsory attribute > not used Returns: nuke.Node: containerized nuke.Node object """ namespace = namespace or context['asset']['name'] suffix = secrets.token_hex(nbytes=4) node_name = "{}_{}_{}".format( name, namespace, suffix) # getting file path filepath = self.filepath_from_context(context) json_f = self._load_json_data(filepath) group_node = self._create_group_node( filepath, json_f["data"]) # renaming group node group_node["name"].setValue(node_name) self._node_version_color(context["version"], group_node) self.log.info( "Loaded lut setup: `{}`".format(group_node["name"].value())) return containerise( node=group_node, name=name, namespace=namespace, context=context, loader=self.__class__.__name__ ) def _create_group_node( self, filepath, data, group_node=None ): """Creates group node with all the nodes inside. Creating mainly `OCIOFileTransform` nodes with `OCIOColorSpace` nodes in between - in case those are needed. Arguments: filepath (str): path to json file data (dict): data from json file group_node (Optional[nuke.Node]): group node or None Returns: nuke.Node: group node with all the nodes inside """ # get corresponding node root_working_colorspace = nuke.root()["workingSpaceLUT"].value() dir_path = os.path.dirname(filepath) all_files = os.listdir(dir_path) ocio_working_colorspace = _colorspace_name_by_type( data["ocioLookWorkingSpace"]) # adding nodes to node graph # just in case we are in group lets jump out of it nuke.endGroup() input_node = None output_node = None if group_node: # remove all nodes between Input and Output nodes for node in group_node.nodes(): if node.Class() not in ["Input", "Output"]: nuke.delete(node) elif node.Class() == "Input": input_node = node elif node.Class() == "Output": output_node = node else: group_node = nuke.createNode( "Group", inpanel=False ) # adding content to the group node with group_node: pre_colorspace = root_working_colorspace # reusing input node if it exists during update if input_node: pre_node = input_node else: pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") # Compare script working colorspace with ocio working colorspace # found in json file and convert to json's if needed if pre_colorspace != ocio_working_colorspace: pre_node = _add_ocio_colorspace_node( pre_node, pre_colorspace, ocio_working_colorspace ) pre_colorspace = ocio_working_colorspace for ocio_item in data["ocioLookItems"]: input_space = _colorspace_name_by_type( ocio_item["input_colorspace"]) output_space = _colorspace_name_by_type( ocio_item["output_colorspace"]) # making sure we are set to correct colorspace for otio item if pre_colorspace != input_space: pre_node = _add_ocio_colorspace_node( pre_node, pre_colorspace, input_space ) node = nuke.createNode("OCIOFileTransform") # file path from lut representation extension = ocio_item["ext"] item_name = ocio_item["name"] item_lut_file = next( ( file for file in all_files if file.endswith(extension) ), None ) if not item_lut_file: raise ValueError( "File with extension '{}' not " "found in directory".format(extension) ) item_lut_path = os.path.join( dir_path, item_lut_file).replace("\\", "/") node["file"].setValue(item_lut_path) node["name"].setValue(item_name) node["direction"].setValue(ocio_item["direction"]) node["interpolation"].setValue(ocio_item["interpolation"]) node["working_space"].setValue(input_space) pre_node.autoplace() node.setInput(0, pre_node) node.autoplace() # pass output space into pre_colorspace for next iteration # or for output node comparison pre_colorspace = output_space pre_node = node # making sure we are back in script working colorspace if pre_colorspace != root_working_colorspace: pre_node = _add_ocio_colorspace_node( pre_node, pre_colorspace, root_working_colorspace ) # reusing output node if it exists during update if not output_node: output = nuke.createNode("Output") else: output = output_node output.setInput(0, pre_node) return group_node def update(self, container, representation): project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) group_node = container["node"] filepath = get_representation_path(representation) json_f = self._load_json_data(filepath) group_node = self._create_group_node( filepath, json_f["data"], group_node ) self._node_version_color(version_doc, group_node) self.log.info("Updated lut setup: `{}`".format( group_node["name"].value())) return update_container( group_node, {"representation": str(representation["_id"])}) def _load_json_data(self, filepath): # getting data from json file with unicode conversion with open(filepath, "r") as _file: json_f = {self._bytify(key): self._bytify(value) for key, value in json.load(_file).items()} # check if the version in json_f is the same as plugin version if json_f["version"] != self.schema_version: raise KeyError( "Version of json file is not the same as plugin version") return json_f def _bytify(self, input): """ Converts unicode strings to strings It goes through all dictionary Arguments: input (dict/str): input Returns: dict: with fixed values and keys """ if isinstance(input, dict): return {self._bytify(key): self._bytify(value) for key, value in input.items()} elif isinstance(input, list): return [self._bytify(element) for element in input] elif isinstance(input, six.text_type): return str(input) else: return input def switch(self, container, representation): self.update(container, representation) def remove(self, container): node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) def _node_version_color(self, version, node): """ Coloring a node by correct color by actual version""" project_name = get_current_project_name() last_version_doc = get_last_version_by_subset_id( project_name, version["parent"], fields=["_id"] ) # change color of node if version["_id"] == last_version_doc["_id"]: color_value = self.current_node_color else: color_value = self.old_node_color node["tile_color"].setValue(int(color_value, 16)) def _colorspace_name_by_type(colorspace_data): """ Returns colorspace name by type Arguments: colorspace_data (dict): colorspace data Returns: str: colorspace name """ if colorspace_data["type"] == "colorspaces": return colorspace_data["name"] elif colorspace_data["type"] == "roles": return colorspace_data["colorspace"] else: raise KeyError("Unknown colorspace type: {}".format( colorspace_data["type"])) def _add_ocio_colorspace_node(pre_node, input_space, output_space): """ Adds OCIOColorSpace node to the node graph Arguments: pre_node (nuke.Node): node to connect to input_space (str): input colorspace output_space (str): output colorspace Returns: nuke.Node: node with OCIOColorSpace node """ node = nuke.createNode("OCIOColorSpace") node.setInput(0, pre_node) node["in_colorspace"].setValue(input_space) node["out_colorspace"].setValue(output_space) pre_node.autoplace() node.setInput(0, pre_node) node.autoplace() return node ================================================ FILE: openpype/hosts/nuke/plugins/load/load_script_precomp.py ================================================ import nuke from openpype.client import ( get_version_by_id, get_last_version_by_subset_id, ) from openpype.pipeline import ( get_current_project_name, load, get_representation_path, ) from openpype.hosts.nuke.api.lib import get_avalon_knob_data from openpype.hosts.nuke.api import ( containerise, update_container, viewer_update_and_undo_stop ) class LinkAsGroup(load.LoaderPlugin): """Copy the published file to be pasted at the desired location""" families = ["workfile", "nukenodes"] representations = ["*"] extensions = {"nk"} label = "Load Precomp" order = 0 icon = "file" color = "#cc0000" def load(self, context, name, namespace, data): # for k, v in context.items(): # log.info("key: `{}`, value: {}\n".format(k, v)) version = context['version'] version_data = version.get("data", {}) vname = version.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) # Fallback to asset name when namespace is None if namespace is None: namespace = context['asset']['name'] file = self.filepath_from_context(context).replace("\\", "/") self.log.info("file: {}\n".format(file)) self.log.info("versionData: {}\n".format(context["version"]["data"])) # add additional metadata from the version to imprint to Avalon knob add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] data_imprint = { "startingFrame": first, "frameStart": first, "frameEnd": last, "version": vname } for k in add_keys: data_imprint.update({k: context["version"]['data'][k]}) # group context is set to precomp, so back up one level. nuke.endGroup() # P = nuke.nodes.LiveGroup("file {}".format(file)) P = nuke.createNode( "Precomp", "file {}".format(file), inpanel=False ) # Set colorspace defined in version data colorspace = context["version"]["data"].get("colorspace", None) self.log.info("colorspace: {}\n".format(colorspace)) P["name"].setValue("{}_{}".format(name, namespace)) P["useOutput"].setValue(True) with P: # iterate through all nodes in group node and find pype writes writes = [n.name() for n in nuke.allNodes() if n.Class() == "Group" if get_avalon_knob_data(n)] if writes: # create panel for selecting output panel_choices = " ".join(writes) panel_label = "Select write node for output" p = nuke.Panel("Select Write Node") p.addEnumerationPulldown( panel_label, panel_choices) p.show() P["output"].setValue(p.value(panel_label)) P["tile_color"].setValue(0xff0ff0ff) return containerise( node=P, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, data=data_imprint) def switch(self, container, representation): self.update(container, representation) def update(self, container, representation): """Update the Loader's path Nuke automatically tries to reset some variables when changing the loader's path to a new file. These automatic changes are to its inputs: """ node = container["node"] root = get_representation_path(representation).replace("\\", "/") # Get start frame from version data project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] ) updated_dict = {} version_data = version_doc["data"] updated_dict.update({ "representation": str(representation["_id"]), "frameEnd": version_data.get("frameEnd"), "version": version_doc.get("name"), "colorspace": version_data.get("colorspace"), "source": version_data.get("source"), "fps": version_data.get("fps"), "author": version_data.get("author") }) # Update the imprinted representation update_container( node, updated_dict ) node["file"].setValue(root) # change color of node if version_doc["_id"] == last_version_doc["_id"]: color_value = "0xff0ff0ff" else: color_value = "0xd84f20ff" node["tile_color"].setValue(int(color_value, 16)) self.log.info("updated to version: {}".format(version_doc.get("name"))) def remove(self, container): node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) ================================================ FILE: openpype/hosts/nuke/plugins/publish/collect_backdrop.py ================================================ from pprint import pformat import pyblish.api from openpype.hosts.nuke.api import lib as pnlib import nuke class CollectBackdrops(pyblish.api.InstancePlugin): """Collect Backdrop node instance and its content """ order = pyblish.api.CollectorOrder + 0.22 label = "Collect Backdrop" hosts = ["nuke"] families = ["nukenodes"] def process(self, instance): self.log.debug(pformat(instance.data)) bckn = instance.data["transientData"]["node"] # define size of the backdrop left = bckn.xpos() top = bckn.ypos() right = left + bckn['bdwidth'].value() bottom = top + bckn['bdheight'].value() instance.data["transientData"]["childNodes"] = [] # iterate all nodes for node in nuke.allNodes(): # exclude viewer if node.Class() == "Viewer": continue # find all related nodes if (node.xpos() > left) \ and (node.xpos() + node.screenWidth() < right) \ and (node.ypos() > top) \ and (node.ypos() + node.screenHeight() < bottom): # add contained nodes to instance's node list instance.data["transientData"]["childNodes"].append(node) # get all connections from outside of backdrop nodes = instance.data["transientData"]["childNodes"] connections_in, connections_out = pnlib.get_dependent_nodes(nodes) instance.data["transientData"]["nodeConnectionsIn"] = connections_in instance.data["transientData"]["nodeConnectionsOut"] = connections_out # make label nicer instance.data["label"] = "{0} ({1} nodes)".format( bckn.name(), len(instance.data["transientData"]["childNodes"])) # get version version = instance.context.data.get('version') if version: instance.data['version'] = version self.log.debug("Backdrop instance collected: `{}`".format(instance)) ================================================ FILE: openpype/hosts/nuke/plugins/publish/collect_context_data.py ================================================ import os import nuke import pyblish.api from openpype.lib import get_version_from_path import openpype.hosts.nuke.api as napi from openpype.pipeline import KnownPublishError class CollectContextData(pyblish.api.ContextPlugin): """Collect current context publish.""" order = pyblish.api.CollectorOrder - 0.499 label = "Collect context data" hosts = ['nuke'] def process(self, context): # sourcery skip: avoid-builtin-shadow root_node = nuke.root() current_file = os.path.normpath(root_node.name()) if current_file.lower() == "root": raise KnownPublishError( "Workfile is not correct file name. \n" "Use workfile tool to manage the name correctly." ) # Get frame range first_frame = int(root_node["first_frame"].getValue()) last_frame = int(root_node["last_frame"].getValue()) # get instance data from root root_instance_context = napi.get_node_data( root_node, napi.INSTANCE_DATA_KNOB ) handle_start = root_instance_context["handleStart"] handle_end = root_instance_context["handleEnd"] # Get format format = root_node['format'].value() resolution_width = format.width() resolution_height = format.height() pixel_aspect = format.pixelAspect() script_data = { "frameStart": first_frame + handle_start, "frameEnd": last_frame - handle_end, "resolutionWidth": resolution_width, "resolutionHeight": resolution_height, "pixelAspect": pixel_aspect, "handleStart": handle_start, "handleEnd": handle_end, "step": 1, "fps": root_node['fps'].value(), "currentFile": current_file, "version": int(get_version_from_path(current_file)), "host": pyblish.api.current_host(), "hostVersion": nuke.NUKE_VERSION_STRING } context.data["scriptData"] = script_data context.data.update(script_data) self.log.debug('Context from Nuke script collected') ================================================ FILE: openpype/hosts/nuke/plugins/publish/collect_framerate.py ================================================ import nuke import pyblish.api class CollectFramerate(pyblish.api.ContextPlugin): """Collect framerate.""" order = pyblish.api.CollectorOrder label = "Collect Framerate" hosts = [ "nuke", "nukeassist" ] def process(self, context): context.data["fps"] = nuke.root()["fps"].getValue() ================================================ FILE: openpype/hosts/nuke/plugins/publish/collect_gizmo.py ================================================ import pyblish.api import nuke class CollectGizmo(pyblish.api.InstancePlugin): """Collect Gizmo (group) node instance and its content """ order = pyblish.api.CollectorOrder + 0.22 label = "Collect Gizmo (group)" hosts = ["nuke"] families = ["gizmo"] def process(self, instance): gizmo_node = instance.data["transientData"]["node"] # add family to familiess instance.data["families"].insert(0, instance.data["family"]) # make label nicer instance.data["label"] = gizmo_node.name() # Get frame range handle_start = instance.context.data["handleStart"] handle_end = instance.context.data["handleEnd"] first_frame = int(nuke.root()["first_frame"].getValue()) last_frame = int(nuke.root()["last_frame"].getValue()) # Add version data to instance version_data = { "handleStart": handle_start, "handleEnd": handle_end, "frameStart": first_frame + handle_start, "frameEnd": last_frame - handle_end, "colorspace": nuke.root().knob('workingSpaceLUT').value(), "families": [instance.data["family"]] + instance.data["families"], "subset": instance.data["subset"], "fps": instance.context.data["fps"] } instance.data.update({ "versionData": version_data, "frameStart": first_frame, "frameEnd": last_frame }) self.log.debug("Gizmo instance collected: `{}`".format(instance)) ================================================ FILE: openpype/hosts/nuke/plugins/publish/collect_model.py ================================================ import pyblish.api import nuke class CollectModel(pyblish.api.InstancePlugin): """Collect Model node instance and its content """ order = pyblish.api.CollectorOrder + 0.22 label = "Collect Model" hosts = ["nuke"] families = ["model"] def process(self, instance): geo_node = instance.data["transientData"]["node"] # add family to familiess instance.data["families"].insert(0, instance.data["family"]) # make label nicer instance.data["label"] = geo_node.name() # Get frame range handle_start = instance.context.data["handleStart"] handle_end = instance.context.data["handleEnd"] first_frame = int(nuke.root()["first_frame"].getValue()) last_frame = int(nuke.root()["last_frame"].getValue()) # Add version data to instance version_data = { "handleStart": handle_start, "handleEnd": handle_end, "frameStart": first_frame + handle_start, "frameEnd": last_frame - handle_end, "colorspace": nuke.root().knob('workingSpaceLUT').value(), "families": [instance.data["family"]] + instance.data["families"], "subset": instance.data["subset"], "fps": instance.context.data["fps"] } instance.data.update({ "versionData": version_data, "frameStart": first_frame, "frameEnd": last_frame }) self.log.debug("Model instance collected: `{}`".format(instance)) ================================================ FILE: openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py ================================================ import nuke import pyblish.api class CollectInstanceData(pyblish.api.InstancePlugin): """Collect Nuke instance data """ order = pyblish.api.CollectorOrder - 0.49 label = "Collect Nuke Instance Data" hosts = ["nuke", "nukeassist"] # presets sync_workfile_version_on_families = [] def process(self, instance): family = instance.data["family"] # Get format root = nuke.root() format_ = root['format'].value() resolution_width = format_.width() resolution_height = format_.height() pixel_aspect = format_.pixelAspect() # sync workfile version if family in self.sync_workfile_version_on_families: self.log.debug( "Syncing version with workfile for '{}'".format( family ) ) # get version to instance for integration instance.data['version'] = instance.context.data['version'] instance.data.update({ "step": 1, "fps": root['fps'].value(), "resolutionWidth": resolution_width, "resolutionHeight": resolution_height, "pixelAspect": pixel_aspect }) # add creator attributes to instance creator_attributes = instance.data["creator_attributes"] instance.data.update(creator_attributes) # add review family if review activated on instance if instance.data.get("review"): instance.data["families"].append("review") self.log.debug("Collected instance: {}".format( instance.data)) ================================================ FILE: openpype/hosts/nuke/plugins/publish/collect_reads.py ================================================ import os import re import nuke import pyblish.api class CollectNukeReads(pyblish.api.InstancePlugin): """Collect all read nodes.""" order = pyblish.api.CollectorOrder + 0.04 label = "Collect Source Reads" hosts = ["nuke", "nukeassist"] families = ["source"] def process(self, instance): self.log.debug("checking instance: {}".format(instance)) node = instance.data["transientData"]["node"] if node.Class() != "Read": return file_path = node["file"].value() file_name = os.path.basename(file_path) items = file_name.split(".") if len(items) < 2: raise ValueError ext = items[-1] # Get frame range handle_start = instance.context.data["handleStart"] handle_end = instance.context.data["handleEnd"] first_frame = node['first'].value() last_frame = node['last'].value() # colorspace colorspace = node["colorspace"].value() if "default" in colorspace: colorspace = colorspace.replace("default (", "").replace(")", "") # # Easier way to sequence - Not tested # isSequence = True # if first_frame == last_frame: # isSequence = False isSequence = False if len(items) > 1: sequence = items[-2] hash_regex = re.compile(r'([#*])') seq_regex = re.compile(r'[%0-9*d]') hash_match = re.match(hash_regex, sequence) seq_match = re.match(seq_regex, sequence) if hash_match or seq_match: isSequence = True # get source path path = nuke.filename(node) source_dir = os.path.dirname(path) self.log.debug('source dir: {}'.format(source_dir)) if isSequence: source_files = [f for f in os.listdir(source_dir) if ext in f if items[0] in f] else: source_files = file_name # Include start and end render frame in label name = node.name() label = "{0} ({1}-{2})".format( name, int(first_frame), int(last_frame) ) self.log.debug("collected_frames: {}".format(label)) if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': ext, 'ext': ext, 'files': source_files, "stagingDir": source_dir, "frameStart": "%0{}d".format( len(str(last_frame))) % first_frame } instance.data["representations"].append(representation) transfer = node["publish"] if "publish" in node.knobs() else False instance.data['transfer'] = transfer # Add version data to instance version_data = { "handleStart": handle_start, "handleEnd": handle_end, "frameStart": first_frame + handle_start, "frameEnd": last_frame - handle_end, "colorspace": colorspace, "families": [instance.data["family"]], "subset": instance.data["subset"], "fps": instance.context.data["fps"] } instance.data.update({ "versionData": version_data, "path": path, "stagingDir": source_dir, "ext": ext, "label": label, "frameStart": first_frame, "frameEnd": last_frame, "colorspace": colorspace, "handleStart": handle_start, "handleEnd": handle_end, "step": 1, "fps": int(nuke.root()['fps'].value()) }) self.log.debug("instance.data: {}".format(instance.data)) ================================================ FILE: openpype/hosts/nuke/plugins/publish/collect_slate_node.py ================================================ import pyblish.api import nuke class CollectSlate(pyblish.api.InstancePlugin): """Check if SLATE node is in scene and connected to rendering tree""" order = pyblish.api.CollectorOrder + 0.002 label = "Collect Slate Node" hosts = ["nuke"] families = ["render"] def process(self, instance): node = instance.data["transientData"]["node"] slate = next( ( n_ for n_ in nuke.allNodes() if "slate" in n_.name().lower() if not n_["disable"].getValue() and "publish_instance" not in n_.knobs() # Exclude instance nodes. ), None ) if slate: # check if slate node is connected to write node tree slate_check = 0 slate_node = None while slate_check == 0: try: node = node.dependencies()[0] if slate.name() in node.name(): slate_node = node slate_check = 1 except IndexError: break if slate_node: instance.data["slateNode"] = slate_node instance.data["slate"] = True instance.data["families"].append("slate") self.log.debug( "Slate node is in node graph: `{}`".format(slate.name())) self.log.debug( "__ instance.data: `{}`".format(instance.data)) ================================================ FILE: openpype/hosts/nuke/plugins/publish/collect_workfile.py ================================================ import os import nuke import pyblish.api class CollectWorkfile(pyblish.api.InstancePlugin): """Collect current script for publish.""" order = pyblish.api.CollectorOrder label = "Collect Workfile" hosts = ['nuke'] families = ["workfile"] def process(self, instance): # sourcery skip: avoid-builtin-shadow script_data = instance.context.data["scriptData"] current_file = os.path.normpath(nuke.root().name()) # creating instances per write node staging_dir = os.path.dirname(current_file) base_name = os.path.basename(current_file) # creating representation representation = { 'name': 'nk', 'ext': 'nk', 'files': base_name, "stagingDir": staging_dir, } # creating instance data instance.data.update({ "name": base_name, "representations": [representation] }) # adding basic script data instance.data.update(script_data) self.log.debug( "Collected current script version: {}".format(current_file) ) ================================================ FILE: openpype/hosts/nuke/plugins/publish/collect_writes.py ================================================ import os import nuke import pyblish.api from openpype.hosts.nuke import api as napi from openpype.pipeline import publish class CollectNukeWrites(pyblish.api.InstancePlugin, publish.ColormanagedPyblishPluginMixin): """Collect all write nodes.""" order = pyblish.api.CollectorOrder + 0.0021 label = "Collect Writes" hosts = ["nuke", "nukeassist"] families = ["render", "prerender", "image"] # cache _write_nodes = {} _frame_ranges = {} def process(self, instance): group_node = instance.data["transientData"]["node"] render_target = instance.data["render_target"] write_node = self._write_node_helper(instance) if write_node is None: self.log.warning( "Created node '{}' is missing write node!".format( group_node.name() ) ) return # get colorspace and add to version data colorspace = napi.get_colorspace_from_node(write_node) if render_target == "frames": self._set_existing_files_data(instance, colorspace) elif render_target == "frames_farm": collected_frames = self._set_existing_files_data( instance, colorspace) self._set_expected_files(instance, collected_frames) self._add_farm_instance_data(instance) elif render_target == "farm": self._add_farm_instance_data(instance) # set additional instance data self._set_additional_instance_data(instance, render_target, colorspace) def _set_existing_files_data(self, instance, colorspace): """Set existing files data to instance data. Args: instance (pyblish.api.Instance): pyblish instance colorspace (str): colorspace Returns: list: collected frames """ collected_frames = self._get_collected_frames(instance) representation = self._get_existing_frames_representation( instance, collected_frames ) # inject colorspace data self.set_representation_colorspace( representation, instance.context, colorspace=colorspace ) instance.data["representations"].append(representation) return collected_frames def _set_expected_files(self, instance, collected_frames): """Set expected files to instance data. Args: instance (pyblish.api.Instance): pyblish instance collected_frames (list): collected frames """ write_node = self._write_node_helper(instance) write_file_path = nuke.filename(write_node) output_dir = os.path.dirname(write_file_path) instance.data["expectedFiles"] = [ os.path.join(output_dir, source_file) for source_file in collected_frames ] def _get_frame_range_data(self, instance): """Get frame range data from instance. Args: instance (pyblish.api.Instance): pyblish instance Returns: tuple: first_frame, last_frame """ instance_name = instance.data["name"] if self._frame_ranges.get(instance_name): # return cashed write node return self._frame_ranges[instance_name] write_node = self._write_node_helper(instance) # Get frame range from workfile first_frame = int(nuke.root()["first_frame"].getValue()) last_frame = int(nuke.root()["last_frame"].getValue()) # Get frame range from write node if activated if write_node["use_limit"].getValue(): first_frame = int(write_node["first"].getValue()) last_frame = int(write_node["last"].getValue()) # add to cache self._frame_ranges[instance_name] = (first_frame, last_frame) return first_frame, last_frame def _set_additional_instance_data( self, instance, render_target, colorspace ): """Set additional instance data. Args: instance (pyblish.api.Instance): pyblish instance render_target (str): render target colorspace (str): colorspace """ family = instance.data["family"] # add targeted family to families instance.data["families"].append( "{}.{}".format(family, render_target) ) self.log.debug("Appending render target to families: {}.{}".format( family, render_target) ) write_node = self._write_node_helper(instance) # Determine defined file type ext = write_node["file_type"].value() # get frame range data handle_start = instance.context.data["handleStart"] handle_end = instance.context.data["handleEnd"] first_frame, last_frame = self._get_frame_range_data(instance) # get output paths write_file_path = nuke.filename(write_node) output_dir = os.path.dirname(write_file_path) # TODO: remove this when we have proper colorspace support version_data = { "colorspace": colorspace } instance.data.update({ "versionData": version_data, "path": write_file_path, "outputDir": output_dir, "ext": ext, "colorspace": colorspace }) if family == "render": instance.data.update({ "handleStart": handle_start, "handleEnd": handle_end, "frameStart": first_frame + handle_start, "frameEnd": last_frame - handle_end, "frameStartHandle": first_frame, "frameEndHandle": last_frame, }) else: instance.data.update({ "handleStart": 0, "handleEnd": 0, "frameStart": first_frame, "frameEnd": last_frame, "frameStartHandle": first_frame, "frameEndHandle": last_frame, }) # TODO temporarily set stagingDir as persistent for backward # compatibility. This is mainly focused on `renders`folders which # were previously not cleaned up (and could be used in read notes) # this logic should be removed and replaced with custom staging dir instance.data["stagingDir_persistent"] = True def _write_node_helper(self, instance): """Helper function to get write node from instance. Also sets instance transient data with child nodes. Args: instance (pyblish.api.Instance): pyblish instance Returns: nuke.Node: write node """ instance_name = instance.data["name"] if self._write_nodes.get(instance_name): # return cashed write node return self._write_nodes[instance_name] # get all child nodes from group node child_nodes = napi.get_instance_group_node_childs(instance) # set child nodes to instance transient data instance.data["transientData"]["childNodes"] = child_nodes write_node = None for node_ in child_nodes: if node_.Class() == "Write": write_node = node_ if write_node: # for slate frame extraction instance.data["transientData"]["writeNode"] = write_node # add to cache self._write_nodes[instance_name] = write_node return self._write_nodes[instance_name] def _get_existing_frames_representation( self, instance, collected_frames ): """Get existing frames representation. Args: instance (pyblish.api.Instance): pyblish instance collected_frames (list): collected frames Returns: dict: representation """ first_frame, last_frame = self._get_frame_range_data(instance) write_node = self._write_node_helper(instance) write_file_path = nuke.filename(write_node) output_dir = os.path.dirname(write_file_path) # Determine defined file type ext = write_node["file_type"].value() representation = { "name": ext, "ext": ext, "stagingDir": output_dir, "tags": [] } # set slate frame collected_frames = self._add_slate_frame_to_collected_frames( instance, collected_frames, first_frame, last_frame ) if len(collected_frames) == 1: representation['files'] = collected_frames.pop() else: representation['files'] = collected_frames return representation def _get_frame_start_str(self, first_frame, last_frame): """Get frame start string. Args: first_frame (int): first frame last_frame (int): last frame Returns: str: frame start string """ # convert first frame to string with padding return ( "{{:0{}d}}".format(len(str(last_frame))) ).format(first_frame) def _add_slate_frame_to_collected_frames( self, instance, collected_frames, first_frame, last_frame ): """Add slate frame to collected frames. Args: instance (pyblish.api.Instance): pyblish instance collected_frames (list): collected frames first_frame (int): first frame last_frame (int): last frame Returns: list: collected frames """ frame_start_str = self._get_frame_start_str(first_frame, last_frame) frame_length = int(last_frame - first_frame + 1) # this will only run if slate frame is not already # rendered from previews publishes if ( "slate" in instance.data["families"] and frame_length == len(collected_frames) ): frame_slate_str = self._get_frame_start_str( first_frame - 1, last_frame ) slate_frame = collected_frames[0].replace( frame_start_str, frame_slate_str) collected_frames.insert(0, slate_frame) return collected_frames def _add_farm_instance_data(self, instance): """Add farm publishing related instance data. Args: instance (pyblish.api.Instance): pyblish instance """ # make sure rendered sequence on farm will # be used for extract review if not instance.data.get("review"): instance.data["useSequenceForReview"] = False # Farm rendering instance.data.update({ "transfer": False, "farm": True # to skip integrate }) self.log.info("Farm rendering ON ...") def _get_collected_frames(self, instance): """Get collected frames. Args: instance (pyblish.api.Instance): pyblish instance Returns: list: collected frames """ first_frame, last_frame = self._get_frame_range_data(instance) write_node = self._write_node_helper(instance) write_file_path = nuke.filename(write_node) output_dir = os.path.dirname(write_file_path) # get file path knob node_file_knob = write_node["file"] # list file paths based on input frames expected_paths = list(sorted({ node_file_knob.evaluate(frame) for frame in range(first_frame, last_frame + 1) })) # convert only to base names expected_filenames = { os.path.basename(filepath) for filepath in expected_paths } # make sure files are existing at folder collected_frames = [ filename for filename in os.listdir(output_dir) if filename in expected_filenames ] return collected_frames ================================================ FILE: openpype/hosts/nuke/plugins/publish/extract_backdrop.py ================================================ import os import nuke import pyblish.api from openpype.pipeline import publish from openpype.hosts.nuke.api.lib import ( maintained_selection, reset_selection, select_nodes ) class ExtractBackdropNode(publish.Extractor): """Extracting content of backdrop nodes Will create nuke script only with containing nodes. Also it will solve Input and Output nodes. """ order = pyblish.api.ExtractorOrder label = "Extract Backdrop" hosts = ["nuke"] families = ["nukenodes"] def process(self, instance): tmp_nodes = [] child_nodes = instance.data["transientData"]["childNodes"] # all connections outside of backdrop connections_in = instance.data["transientData"]["nodeConnectionsIn"] connections_out = instance.data["transientData"]["nodeConnectionsOut"] self.log.debug("_ connections_in: `{}`".format(connections_in)) self.log.debug("_ connections_out: `{}`".format(connections_out)) # Define extract output file path stagingdir = self.staging_dir(instance) filename = "{0}.nk".format(instance.name) path = os.path.join(stagingdir, filename) # maintain selection with maintained_selection(): # create input child_nodes and name them as passing node (*_INP) for n, inputs in connections_in.items(): for i, input in inputs: inpn = nuke.createNode("Input") inpn["name"].setValue("{}_{}_INP".format(n.name(), i)) n.setInput(i, inpn) inpn.setXYpos(input.xpos(), input.ypos()) child_nodes.append(inpn) tmp_nodes.append(inpn) reset_selection() # connect output node for n, output in connections_out.items(): opn = nuke.createNode("Output") output.setInput( next((i for i, d in enumerate(output.dependencies()) if d.name() in n.name()), 0), opn) opn.setInput(0, n) opn.autoplace() child_nodes.append(opn) tmp_nodes.append(opn) reset_selection() # select child_nodes to copy reset_selection() select_nodes(child_nodes) # create tmp nk file # save file to the path nuke.nodeCopy(path) # Clean up for tn in tmp_nodes: nuke.delete(tn) # restore original connections # reconnect input node for n, inputs in connections_in.items(): for i, input in inputs: n.setInput(i, input) # reconnect output node for n, output in connections_out.items(): output.setInput( next((i for i, d in enumerate(output.dependencies()) if d.name() in n.name()), 0), n) if "representations" not in instance.data: instance.data["representations"] = [] # create representation representation = { 'name': 'nk', 'ext': 'nk', 'files': filename, "stagingDir": stagingdir } instance.data["representations"].append(representation) self.log.debug("Extracted instance '{}' to: {}".format( instance.name, path)) ================================================ FILE: openpype/hosts/nuke/plugins/publish/extract_camera.py ================================================ import os import math from pprint import pformat import nuke import pyblish.api from openpype.pipeline import publish from openpype.hosts.nuke.api.lib import maintained_selection class ExtractCamera(publish.Extractor): """ 3D camera extractor """ label = 'Extract Camera' order = pyblish.api.ExtractorOrder families = ["camera"] hosts = ["nuke"] # presets write_geo_knobs = [ ("file_type", "abc"), ("storageFormat", "Ogawa"), ("writeGeometries", False), ("writePointClouds", False), ("writeAxes", False) ] def process(self, instance): camera_node = instance.data["transientData"]["node"] handle_start = instance.context.data["handleStart"] handle_end = instance.context.data["handleEnd"] first_frame = int(nuke.root()["first_frame"].getValue()) last_frame = int(nuke.root()["last_frame"].getValue()) step = 1 output_range = str(nuke.FrameRange(first_frame, last_frame, step)) rm_nodes = [] self.log.debug("Creating additional nodes for 3D Camera Extractor") subset = instance.data["subset"] staging_dir = self.staging_dir(instance) # get extension form preset extension = next((k[1] for k in self.write_geo_knobs if k[0] == "file_type"), None) if not extension: raise RuntimeError( "Bad config for extension in presets. " "Talk to your supervisor or pipeline admin") # create file name and path filename = subset + ".{}".format(extension) file_path = os.path.join(staging_dir, filename).replace("\\", "/") with maintained_selection(): # bake camera with axeses onto word coordinate XYZ rm_n = bakeCameraWithAxeses( camera_node, output_range) rm_nodes.append(rm_n) # create scene node rm_n = nuke.createNode("Scene") rm_nodes.append(rm_n) # create write geo node wg_n = nuke.createNode("WriteGeo") wg_n["file"].setValue(file_path) # add path to write to for k, v in self.write_geo_knobs: wg_n[k].setValue(v) rm_nodes.append(wg_n) # write out camera nuke.execute( wg_n, int(first_frame), int(last_frame) ) # erase additional nodes for n in rm_nodes: nuke.delete(n) # create representation data if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': extension, 'ext': extension, 'files': filename, "stagingDir": staging_dir, "frameStart": first_frame, "frameEnd": last_frame } instance.data["representations"].append(representation) instance.data.update({ "path": file_path, "outputDir": staging_dir, "ext": extension, "handleStart": handle_start, "handleEnd": handle_end, "frameStart": first_frame + handle_start, "frameEnd": last_frame - handle_end, "frameStartHandle": first_frame, "frameEndHandle": last_frame, }) self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, file_path)) def bakeCameraWithAxeses(camera_node, output_range): """ Baking all perent hierarchy of axeses into camera with transposition onto word XYZ coordinance """ bakeFocal = False bakeHaperture = False bakeVaperture = False camera_matrix = camera_node['world_matrix'] new_cam_n = nuke.createNode("Camera2") new_cam_n.setInput(0, None) new_cam_n['rotate'].setAnimated() new_cam_n['translate'].setAnimated() old_focal = camera_node['focal'] if old_focal.isAnimated() and not (old_focal.animation(0).constant()): new_cam_n['focal'].setAnimated() bakeFocal = True else: new_cam_n['focal'].setValue(old_focal.value()) old_haperture = camera_node['haperture'] if old_haperture.isAnimated() and not ( old_haperture.animation(0).constant()): new_cam_n['haperture'].setAnimated() bakeHaperture = True else: new_cam_n['haperture'].setValue(old_haperture.value()) old_vaperture = camera_node['vaperture'] if old_vaperture.isAnimated() and not ( old_vaperture.animation(0).constant()): new_cam_n['vaperture'].setAnimated() bakeVaperture = True else: new_cam_n['vaperture'].setValue(old_vaperture.value()) new_cam_n['win_translate'].setValue(camera_node['win_translate'].value()) new_cam_n['win_scale'].setValue(camera_node['win_scale'].value()) for x in nuke.FrameRange(output_range): math_matrix = nuke.math.Matrix4() for y in range(camera_matrix.height()): for z in range(camera_matrix.width()): matrix_pointer = z + (y * camera_matrix.width()) math_matrix[matrix_pointer] = camera_matrix.getValueAt( x, (y + (z * camera_matrix.width()))) rot_matrix = nuke.math.Matrix4(math_matrix) rot_matrix.rotationOnly() rot = rot_matrix.rotationsZXY() new_cam_n['rotate'].setValueAt(math.degrees(rot[0]), x, 0) new_cam_n['rotate'].setValueAt(math.degrees(rot[1]), x, 1) new_cam_n['rotate'].setValueAt(math.degrees(rot[2]), x, 2) new_cam_n['translate'].setValueAt( camera_matrix.getValueAt(x, 3), x, 0) new_cam_n['translate'].setValueAt( camera_matrix.getValueAt(x, 7), x, 1) new_cam_n['translate'].setValueAt( camera_matrix.getValueAt(x, 11), x, 2) if bakeFocal: new_cam_n['focal'].setValueAt(old_focal.getValueAt(x), x) if bakeHaperture: new_cam_n['haperture'].setValueAt(old_haperture.getValueAt(x), x) if bakeVaperture: new_cam_n['vaperture'].setValueAt(old_vaperture.getValueAt(x), x) return new_cam_n ================================================ FILE: openpype/hosts/nuke/plugins/publish/extract_gizmo.py ================================================ import os import nuke import pyblish.api from openpype.pipeline import publish from openpype.hosts.nuke.api import utils as pnutils from openpype.hosts.nuke.api.lib import ( maintained_selection, reset_selection, select_nodes ) class ExtractGizmo(publish.Extractor): """Extracting Gizmo (Group) node Will create nuke script only with the Gizmo node. """ order = pyblish.api.ExtractorOrder label = "Extract Gizmo (group)" hosts = ["nuke"] families = ["gizmo"] def process(self, instance): tmp_nodes = [] orig_grpn = instance.data["transientData"]["node"] # Define extract output file path stagingdir = self.staging_dir(instance) filename = "{0}.nk".format(instance.name) path = os.path.join(stagingdir, filename) # maintain selection with maintained_selection(): orig_grpn_name = orig_grpn.name() tmp_grpn_name = orig_grpn_name + "_tmp" # select original group node select_nodes([orig_grpn]) # copy to clipboard nuke.nodeCopy("%clipboard%") # reset selection to none reset_selection() # paste clipboard nuke.nodePaste("%clipboard%") # assign pasted node copy_grpn = nuke.selectedNode() copy_grpn.setXYpos((orig_grpn.xpos() + 120), orig_grpn.ypos()) # convert gizmos to groups pnutils.bake_gizmos_recursively(copy_grpn) # add to temporary nodes tmp_nodes.append(copy_grpn) # swap names orig_grpn.setName(tmp_grpn_name) copy_grpn.setName(orig_grpn_name) # create tmp nk file # save file to the path nuke.nodeCopy(path) # Clean up for tn in tmp_nodes: nuke.delete(tn) # rename back to original orig_grpn.setName(orig_grpn_name) if "representations" not in instance.data: instance.data["representations"] = [] # create representation representation = { 'name': 'gizmo', 'ext': 'nk', 'files': filename, "stagingDir": stagingdir } instance.data["representations"].append(representation) self.log.debug("Extracted instance '{}' to: {}".format( instance.name, path)) ================================================ FILE: openpype/hosts/nuke/plugins/publish/extract_model.py ================================================ import os from pprint import pformat import nuke import pyblish.api from openpype.pipeline import publish from openpype.hosts.nuke.api.lib import ( maintained_selection, select_nodes ) class ExtractModel(publish.Extractor): """ 3D model extractor """ label = 'Extract Model' order = pyblish.api.ExtractorOrder families = ["model"] hosts = ["nuke"] # presets write_geo_knobs = [ ("file_type", "abc"), ("storageFormat", "Ogawa"), ("writeGeometries", True), ("writePointClouds", False), ("writeAxes", False) ] def process(self, instance): handle_start = instance.context.data["handleStart"] handle_end = instance.context.data["handleEnd"] first_frame = int(nuke.root()["first_frame"].getValue()) last_frame = int(nuke.root()["last_frame"].getValue()) self.log.debug("instance.data: `{}`".format( pformat(instance.data))) rm_nodes = [] model_node = instance.data["transientData"]["node"] self.log.debug("Creating additional nodes for Extract Model") subset = instance.data["subset"] staging_dir = self.staging_dir(instance) extension = next((k[1] for k in self.write_geo_knobs if k[0] == "file_type"), None) if not extension: raise RuntimeError( "Bad config for extension in presets. " "Talk to your supervisor or pipeline admin") # create file name and path filename = subset + ".{}".format(extension) file_path = os.path.join(staging_dir, filename).replace("\\", "/") with maintained_selection(): # select model node select_nodes([model_node]) # create write geo node wg_n = nuke.createNode("WriteGeo") wg_n["file"].setValue(file_path) # add path to write to for k, v in self.write_geo_knobs: wg_n[k].setValue(v) rm_nodes.append(wg_n) # write out model nuke.execute( wg_n, int(first_frame), int(last_frame) ) # erase additional nodes for n in rm_nodes: nuke.delete(n) self.log.debug("Filepath: {}".format(file_path)) # create representation data if "representations" not in instance.data: instance.data["representations"] = [] representation = { 'name': extension, 'ext': extension, 'files': filename, "stagingDir": staging_dir, "frameStart": first_frame, "frameEnd": last_frame } instance.data["representations"].append(representation) instance.data.update({ "path": file_path, "outputDir": staging_dir, "ext": extension, "handleStart": handle_start, "handleEnd": handle_end, "frameStart": first_frame + handle_start, "frameEnd": last_frame - handle_end, "frameStartHandle": first_frame, "frameEndHandle": last_frame, }) self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, file_path)) ================================================ FILE: openpype/hosts/nuke/plugins/publish/extract_ouput_node.py ================================================ import nuke import pyblish.api from openpype.hosts.nuke.api.lib import maintained_selection class CreateOutputNode(pyblish.api.ContextPlugin): """Adding output node for each output write node So when latly user will want to Load .nk as LifeGroup or Precomp Nuke will not complain about missing Output node """ label = 'Output Node Create' order = pyblish.api.ExtractorOrder + 0.4 families = ["workfile"] hosts = ['nuke'] def process(self, context): # capture selection state with maintained_selection(): active_node = [ inst.data.get("transientData", {}).get("node") for inst in context if inst.data.get("transientData", {}).get("node") if inst.data.get( "transientData", {}).get("node").Class() != "Root" ] if active_node: active_node = active_node.pop() self.log.debug("Active node: {}".format(active_node)) active_node['selected'].setValue(True) # select only instance render node output_node = nuke.createNode("Output") # deselect all and select the original selection output_node['selected'].setValue(False) # save script nuke.scriptSave() # add node to instance node list context.data["outputNode"] = output_node ================================================ FILE: openpype/hosts/nuke/plugins/publish/extract_output_directory.py ================================================ import os import pyblish.api class ExtractOutputDirectory(pyblish.api.InstancePlugin): """Extracts the output path for any collection or single output_path.""" order = pyblish.api.ExtractorOrder - 0.05 label = "Output Directory" optional = True # targets = ["process"] def process(self, instance): path = None if "output_path" in instance.data.keys(): path = instance.data["path"] if not path: return if not os.path.exists(os.path.dirname(path)): os.makedirs(os.path.dirname(path)) ================================================ FILE: openpype/hosts/nuke/plugins/publish/extract_render_local.py ================================================ import os import shutil import pyblish.api import clique import nuke from openpype.hosts.nuke import api as napi from openpype.pipeline import publish from openpype.lib import collect_frames class NukeRenderLocal(publish.Extractor, publish.ColormanagedPyblishPluginMixin): """Render the current Nuke composition locally. Extract the result of savers by starting a comp render This will run the local render of Fusion. Allows to use last published frames and overwrite only specific ones (set in instance.data.get("frames_to_fix")) """ order = pyblish.api.ExtractorOrder label = "Render Local" hosts = ["nuke"] families = ["render.local", "prerender.local", "image.local"] def process(self, instance): child_nodes = ( instance.data.get("transientData", {}).get("childNodes") or instance ) node = None for x in child_nodes: if x.Class() == "Write": node = x self.log.debug("instance collected: {}".format(instance.data)) node_subset_name = instance.data.get("name", None) first_frame = instance.data.get("frameStartHandle", None) last_frame = instance.data.get("frameEndHandle", None) filenames = [] node_file = node["file"] # Collect expected filepaths for each frame # - for cases that output is still image is first created set of # paths which is then sorted and converted to list expected_paths = list(sorted({ node_file.evaluate(frame) for frame in range(first_frame, last_frame + 1) })) # Extract only filenames for representation filenames.extend([ os.path.basename(filepath) for filepath in expected_paths ]) # Ensure output directory exists. out_dir = os.path.dirname(expected_paths[0]) if not os.path.exists(out_dir): os.makedirs(out_dir) frames_to_render = [(first_frame, last_frame)] frames_to_fix = instance.data.get("frames_to_fix") if instance.data.get("last_version_published_files") and frames_to_fix: frames_to_render = self._get_frames_to_render(frames_to_fix) anatomy = instance.context.data["anatomy"] self._copy_last_published(anatomy, instance, out_dir, filenames) for render_first_frame, render_last_frame in frames_to_render: self.log.info("Starting render") self.log.info("Start frame: {}".format(render_first_frame)) self.log.info("End frame: {}".format(render_last_frame)) # Render frames nuke.execute( str(node_subset_name), int(render_first_frame), int(render_last_frame) ) ext = node["file_type"].value() colorspace = napi.get_colorspace_from_node(node) if "representations" not in instance.data: instance.data["representations"] = [] if len(filenames) == 1: repre = { 'name': ext, 'ext': ext, 'files': filenames[0], "stagingDir": out_dir } else: repre = { 'name': ext, 'ext': ext, 'frameStart': ( "{{:0>{}}}" .format(len(str(last_frame))) .format(first_frame) ), 'files': filenames, "stagingDir": out_dir } # inject colorspace data self.set_representation_colorspace( repre, instance.context, colorspace=colorspace ) instance.data["representations"].append(repre) self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, out_dir )) families = instance.data["families"] # redefinition of families if "render.local" in families: instance.data['family'] = 'render' families.remove('render.local') families.insert(0, "render2d") instance.data["anatomyData"]["family"] = "render" elif "prerender.local" in families: instance.data['family'] = 'prerender' families.remove('prerender.local') families.insert(0, "prerender") instance.data["anatomyData"]["family"] = "prerender" elif "image.local" in families: instance.data['family'] = 'image' families.remove('image.local') instance.data["anatomyData"]["family"] = "image" instance.data["families"] = families collections, remainder = clique.assemble(filenames) self.log.debug('collections: {}'.format(str(collections))) if collections: collection = collections[0] instance.data['collection'] = collection self.log.info('Finished render') self.log.debug("_ instance.data: {}".format(instance.data)) def _copy_last_published(self, anatomy, instance, out_dir, expected_filenames): """Copies last published files to temporary out_dir. These are base of files which will be extended/fixed for specific frames. Renames published file to expected file name based on frame, eg. test_project_test_asset_subset_v005.1001.exr > new_render.1001.exr """ last_published = instance.data["last_version_published_files"] last_published_and_frames = collect_frames(last_published) expected_and_frames = collect_frames(expected_filenames) frames_and_expected = {v: k for k, v in expected_and_frames.items()} for file_path, frame in last_published_and_frames.items(): file_path = anatomy.fill_root(file_path) if not os.path.exists(file_path): continue target_file_name = frames_and_expected.get(frame) if not target_file_name: continue out_path = os.path.join(out_dir, target_file_name) self.log.debug("Copying '{}' -> '{}'".format(file_path, out_path)) shutil.copy(file_path, out_path) # TODO shouldn't this be uncommented # instance.context.data["cleanupFullPaths"].append(out_path) def _get_frames_to_render(self, frames_to_fix): """Return list of frame range tuples to render Args: frames_to_fix (str): specific or range of frames to be rerendered (1005, 1009-1010) Returns: (list): [(1005, 1005), (1009-1010)] """ frames_to_render = [] for frame_range in frames_to_fix.split(","): if frame_range.isdigit(): render_first_frame = frame_range render_last_frame = frame_range elif '-' in frame_range: frames = frame_range.split('-') render_first_frame = int(frames[0]) render_last_frame = int(frames[1]) else: raise ValueError("Wrong format of frames to fix {}" .format(frames_to_fix)) frames_to_render.append((render_first_frame, render_last_frame)) return frames_to_render ================================================ FILE: openpype/hosts/nuke/plugins/publish/extract_review_data.py ================================================ import os from pprint import pformat import pyblish.api from openpype.pipeline import publish class ExtractReviewData(publish.Extractor): """Extracts review tag into available representation """ order = pyblish.api.ExtractorOrder + 0.01 # order = pyblish.api.CollectorOrder + 0.499 label = "Extract Review Data" families = ["review"] hosts = ["nuke"] def process(self, instance): fpath = instance.data["path"] ext = os.path.splitext(fpath)[-1][1:] representations = instance.data.get("representations", []) # review can be removed since `ProcessSubmittedJobOnFarm` will create # reviewable representation if needed if ( instance.data.get("farm") and "review" in instance.data["families"] ): instance.data["families"].remove("review") # iterate representations and add `review` tag for repre in representations: if ext != repre["ext"]: continue if not repre.get("tags"): repre["tags"] = [] if "review" not in repre["tags"]: repre["tags"].append("review") self.log.debug("Matching representation: {}".format( pformat(repre) )) instance.data["representations"] = representations ================================================ FILE: openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py ================================================ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.nuke.api import plugin from openpype.hosts.nuke.api.lib import maintained_selection class ExtractReviewDataLut(publish.Extractor): """Extracts movie and thumbnail with baked in luts must be run after extract_render_local.py """ order = pyblish.api.ExtractorOrder + 0.005 label = "Extract Review Data Lut" families = ["review"] hosts = ["nuke"] def process(self, instance): self.log.debug("Creating staging dir...") if "representations" in instance.data: staging_dir = instance.data[ "representations"][0]["stagingDir"].replace("\\", "/") instance.data["stagingDir"] = staging_dir instance.data["representations"][0]["tags"] = ["review"] else: instance.data["representations"] = [] # get output path render_path = instance.data['path'] staging_dir = os.path.normpath(os.path.dirname(render_path)) instance.data["stagingDir"] = staging_dir self.log.debug( "StagingDir `{0}`...".format(instance.data["stagingDir"])) # generate data with maintained_selection(): exporter = plugin.ExporterReviewLut( self, instance ) data = exporter.generate_lut() # assign to representations instance.data["lutPath"] = os.path.join( exporter.stagingDir, exporter.file).replace("\\", "/") instance.data["representations"] += data["representations"] # review can be removed since `ProcessSubmittedJobOnFarm` will create # reviewable representation if needed if ( instance.data.get("farm") and "review" in instance.data["families"] ): instance.data["families"].remove("review") self.log.debug( "_ lutPath: {}".format(instance.data["lutPath"])) self.log.debug( "_ representations: {}".format(instance.data["representations"])) ================================================ FILE: openpype/hosts/nuke/plugins/publish/extract_review_intermediates.py ================================================ import os import re from pprint import pformat import pyblish.api from openpype.pipeline import publish from openpype.hosts.nuke.api import plugin from openpype.hosts.nuke.api.lib import maintained_selection class ExtractReviewIntermediates(publish.Extractor): """Extracting intermediate videos or sequences with thumbnail for transcoding. must be run after extract_render_local.py """ order = pyblish.api.ExtractorOrder + 0.01 label = "Extract Review Intermediates" families = ["review"] hosts = ["nuke"] # presets viewer_lut_raw = None outputs = {} @classmethod def apply_settings(cls, project_settings): """Apply the settings from the deprecated ExtractReviewDataMov plugin for backwards compatibility """ nuke_publish = project_settings["nuke"]["publish"] deprecated_setting = nuke_publish["ExtractReviewDataMov"] current_setting = nuke_publish.get("ExtractReviewIntermediates") if not deprecated_setting["enabled"] and ( not current_setting["enabled"] ): cls.enabled = False if deprecated_setting["enabled"]: # Use deprecated settings if they are still enabled cls.viewer_lut_raw = deprecated_setting["viewer_lut_raw"] cls.outputs = deprecated_setting["outputs"] elif current_setting is None: pass elif current_setting["enabled"]: cls.viewer_lut_raw = current_setting["viewer_lut_raw"] cls.outputs = current_setting["outputs"] def process(self, instance): families = set(instance.data["families"]) # add main family to make sure all families are compared families.add(instance.data["family"]) task_type = instance.context.data["taskType"] subset = instance.data["subset"] self.log.debug("Creating staging dir...") if "representations" not in instance.data: instance.data["representations"] = [] staging_dir = os.path.normpath( os.path.dirname(instance.data["path"])) instance.data["stagingDir"] = staging_dir self.log.debug( "StagingDir `{0}`...".format(instance.data["stagingDir"])) self.log.debug("Outputs: {}".format(self.outputs)) # generate data with maintained_selection(): generated_repres = [] for o_name, o_data in self.outputs.items(): self.log.debug( "o_name: {}, o_data: {}".format(o_name, pformat(o_data))) f_families = o_data["filter"]["families"] f_task_types = o_data["filter"]["task_types"] f_subsets = o_data["filter"]["subsets"] self.log.debug( "f_families `{}` > families: {}".format( f_families, families)) self.log.debug( "f_task_types `{}` > task_type: {}".format( f_task_types, task_type)) self.log.debug( "f_subsets `{}` > subset: {}".format( f_subsets, subset)) # test if family found in context # using intersection to make sure all defined # families are present in combination if f_families and not families.intersection(f_families): continue # test task types from filter if f_task_types and task_type not in f_task_types: continue # test subsets from filter if f_subsets and not any( re.search(s, subset) for s in f_subsets): continue self.log.debug( "Baking output `{}` with settings: {}".format( o_name, o_data) ) # check if settings have more then one preset # so we dont need to add outputName to representation # in case there is only one preset multiple_presets = len(self.outputs.keys()) > 1 # adding bake presets to instance data for other plugins if not instance.data.get("bakePresets"): instance.data["bakePresets"] = {} # add preset to bakePresets instance.data["bakePresets"][o_name] = o_data # create exporter instance exporter = plugin.ExporterReviewMov( self, instance, o_name, o_data["extension"], multiple_presets) if instance.data.get("farm"): if "review" in instance.data["families"]: instance.data["families"].remove("review") data = exporter.generate_mov(farm=True, **o_data) self.log.debug( "_ data: {}".format(data)) if not instance.data.get("bakingNukeScripts"): instance.data["bakingNukeScripts"] = [] instance.data["bakingNukeScripts"].append({ "bakeRenderPath": data.get("bakeRenderPath"), "bakeScriptPath": data.get("bakeScriptPath"), "bakeWriteNodeName": data.get("bakeWriteNodeName") }) else: data = exporter.generate_mov(**o_data) # add representation generated by exporter generated_repres.extend(data["representations"]) self.log.debug( "__ generated_repres: {}".format(generated_repres)) if generated_repres: # assign to representations instance.data["representations"] += generated_repres instance.data["useSequenceForReview"] = False else: instance.data["families"].remove("review") self.log.debug( "Removing `review` from families. " "Not available baking profile." ) self.log.debug(instance.data["families"]) self.log.debug( "_ representations: {}".format( instance.data["representations"])) ================================================ FILE: openpype/hosts/nuke/plugins/publish/extract_script_save.py ================================================ import nuke import pyblish.api class ExtractScriptSave(pyblish.api.Extractor): """Save current Nuke workfile script""" label = 'Script Save' order = pyblish.api.Extractor.order - 0.1 hosts = ['nuke'] def process(self, instance): self.log.debug('Saving current script') nuke.scriptSave() ================================================ FILE: openpype/hosts/nuke/plugins/publish/extract_slate_frame.py ================================================ import os from pprint import pformat import nuke import copy import pyblish.api import six from openpype.pipeline import publish from openpype.hosts.nuke.api import ( maintained_selection, duplicate_node, get_view_process_node ) class ExtractSlateFrame(publish.Extractor): """Extracts movie and thumbnail with baked in luts must be run after extract_render_local.py """ order = pyblish.api.ExtractorOrder + 0.011 label = "Extract Slate Frame" families = ["slate"] hosts = ["nuke"] # Settings values key_value_mapping = { "f_submission_note": [True, "{comment}"], "f_submitting_for": [True, "{intent[value]}"], "f_vfx_scope_of_work": [False, ""] } def process(self, instance): if "representations" not in instance.data: instance.data["representations"] = [] self._create_staging_dir(instance) with maintained_selection(): self.log.debug("instance: {}".format(instance)) self.log.debug("instance.data[families]: {}".format( instance.data["families"])) if instance.data.get("bakePresets"): for o_name, o_data in instance.data["bakePresets"].items(): self.log.debug("_ o_name: {}, o_data: {}".format( o_name, pformat(o_data))) self.render_slate( instance, o_name, o_data["bake_viewer_process"], o_data["bake_viewer_input_process"] ) else: # backward compatibility self.render_slate(instance) # also render image to sequence self._render_slate_to_sequence(instance) def _create_staging_dir(self, instance): self.log.debug("Creating staging dir...") staging_dir = os.path.normpath( os.path.dirname(instance.data["path"])) instance.data["stagingDir"] = staging_dir self.log.debug( "StagingDir `{0}`...".format(instance.data["stagingDir"])) def _check_frames_exists(self, instance): # rendering path from group write node fpath = instance.data["path"] # instance frame range with handles first = instance.data["frameStartHandle"] last = instance.data["frameEndHandle"] padding = fpath.count('#') test_path_template = fpath if padding: repl_string = "#" * padding test_path_template = fpath.replace( repl_string, "%0{}d".format(padding)) for frame in range(first, last + 1): test_file = test_path_template % frame if not os.path.exists(test_file): self.log.debug("__ test_file: `{}`".format(test_file)) return None return True def render_slate( self, instance, output_name=None, bake_viewer_process=True, bake_viewer_input_process=True ): """Slate frame renderer Args: instance (PyblishInstance): Pyblish instance with subset data output_name (str, optional): Slate variation name. Defaults to None. bake_viewer_process (bool, optional): Switch for viewer profile baking. Defaults to True. bake_viewer_input_process (bool, optional): Switch for input process node baking. Defaults to True. """ slate_node = instance.data["slateNode"] # rendering path from group write node fpath = instance.data["path"] # instance frame range with handles first_frame = instance.data["frameStartHandle"] last_frame = instance.data["frameEndHandle"] # fill slate node with comments self.add_comment_slate_node(instance, slate_node) # solve output name if any is set _output_name = output_name or "" if _output_name: _output_name = "_" + _output_name slate_first_frame = first_frame - 1 collection = instance.data.get("collection", None) if collection: # get path fname = os.path.basename(collection.format( "{head}{padding}{tail}")) fhead = collection.format("{head}") else: fname = os.path.basename(fpath) fhead = os.path.splitext(fname)[0] + "." if "#" in fhead: fhead = fhead.replace("#", "")[:-1] self.log.debug("__ first_frame: {}".format(first_frame)) self.log.debug("__ slate_first_frame: {}".format(slate_first_frame)) above_slate_node = slate_node.dependencies().pop() # fallback if files does not exists if self._check_frames_exists(instance): # Read node r_node = nuke.createNode("Read") r_node["file"].setValue(fpath) r_node["first"].setValue(first_frame) r_node["origfirst"].setValue(first_frame) r_node["last"].setValue(last_frame) r_node["origlast"].setValue(last_frame) r_node["colorspace"].setValue(instance.data["colorspace"]) previous_node = r_node temporary_nodes = [previous_node] # adding copy metadata node for correct frame metadata cm_node = nuke.createNode("CopyMetaData") cm_node.setInput(0, previous_node) cm_node.setInput(1, above_slate_node) previous_node = cm_node temporary_nodes.append(cm_node) else: previous_node = above_slate_node temporary_nodes = [] # only create colorspace baking if toggled on if bake_viewer_process: if bake_viewer_input_process: # get input process and connect it to baking ipn = get_view_process_node() if ipn is not None: ipn.setInput(0, previous_node) previous_node = ipn temporary_nodes.append(ipn) # add duplicate slate node and connect to previous duply_slate_node = duplicate_node(slate_node) duply_slate_node.setInput(0, previous_node) previous_node = duply_slate_node temporary_nodes.append(duply_slate_node) # add viewer display transformation node dag_node = nuke.createNode("OCIODisplay") dag_node.setInput(0, previous_node) previous_node = dag_node temporary_nodes.append(dag_node) else: # add duplicate slate node and connect to previous duply_slate_node = duplicate_node(slate_node) duply_slate_node.setInput(0, previous_node) previous_node = duply_slate_node temporary_nodes.append(duply_slate_node) # create write node write_node = nuke.createNode("Write") file = fhead[:-1] + _output_name + "_slate.png" path = os.path.join( instance.data["stagingDir"], file).replace("\\", "/") # add slate path to `slateFrames` instance data attr if not instance.data.get("slateFrames"): instance.data["slateFrames"] = {} instance.data["slateFrames"][output_name or "*"] = path # create write node write_node["file"].setValue(path) write_node["file_type"].setValue("png") write_node["raw"].setValue(1) write_node.setInput(0, previous_node) temporary_nodes.append(write_node) # Render frames nuke.execute( write_node.name(), int(slate_first_frame), int(slate_first_frame)) # Clean up for node in temporary_nodes: nuke.delete(node) def _render_slate_to_sequence(self, instance): # set slate frame first_frame = instance.data["frameStartHandle"] last_frame = instance.data["frameEndHandle"] slate_first_frame = first_frame - 1 # render slate as sequence frame nuke.execute( instance.data["name"], int(slate_first_frame), int(slate_first_frame) ) # Add file to representation files # - get write node write_node = instance.data["transientData"]["writeNode"] # - evaluate filepaths for first frame and slate frame first_filename = os.path.basename( write_node["file"].evaluate(first_frame)) slate_filename = os.path.basename( write_node["file"].evaluate(slate_first_frame)) # Find matching representation based on first filename matching_repre = None is_sequence = None for repre in instance.data["representations"]: files = repre["files"] if ( not isinstance(files, six.string_types) and first_filename in files ): matching_repre = repre is_sequence = True break elif files == first_filename: matching_repre = repre is_sequence = False break if not matching_repre: self.log.info( "Matching representation was not found." " Representation files were not filled with slate." ) return # Add frame to matching representation files if not is_sequence: matching_repre["files"] = [first_filename, slate_filename] elif slate_filename not in matching_repre["files"]: matching_repre["files"].insert(0, slate_filename) matching_repre["frameStart"] = ( "{{:0>{}}}" .format(len(str(last_frame))) .format(slate_first_frame) ) self.log.debug( "__ matching_repre: {}".format(pformat(matching_repre))) self.log.info("Added slate frame to representation files") def add_comment_slate_node(self, instance, node): comment = instance.data["comment"] intent = instance.context.data.get("intent") if not isinstance(intent, dict): intent = { "label": intent, "value": intent } fill_data = copy.deepcopy(instance.data["anatomyData"]) fill_data.update({ "custom": copy.deepcopy( instance.data.get("customData") or {} ), "comment": comment, "intent": intent }) for key, _values in self.key_value_mapping.items(): enabled, template = _values if not enabled: self.log.debug("Key \"{}\" is disabled".format(key)) continue try: value = template.format(**fill_data) except ValueError: self.log.warning( "Couldn't fill template \"{}\" with data: {}".format( template, fill_data ), exc_info=True ) continue except KeyError: self.log.warning( ( "Template contains unknown key." " Template \"{}\" Data: {}" ).format(template, fill_data), exc_info=True ) continue try: node[key].setValue(value) self.log.debug("Change key \"{}\" to value \"{}\"".format( key, value )) except NameError: self.log.warning(( "Failed to set value \"{0}\" on node attribute \"{0}\"" ).format(value)) ================================================ FILE: openpype/hosts/nuke/plugins/publish/help/validate_asset_context.xml ================================================ Shot/Asset name ## Publishing to a different asset context There are publish instances present which are publishing into a different asset than your current context. Usually this is not what you want but there can be cases where you might want to publish into another asset/shot or task. If that's the case you can disable the validation on the instance to ignore it. The wrong node's name is: \`{node_name}\` ### Correct context keys and values: \`{correct_values}\` ### Wrong keys and values: \`{wrong_values}\`. ## How to repair? 1. Use \"Repair\" button. 2. Hit Reload button on the publisher. ================================================ FILE: openpype/hosts/nuke/plugins/publish/help/validate_backdrop.xml ================================================ Found multiple outputs ## Invalid output amount Backdrop is having more than one outgoing connections. ### How to repair? 1. Use button `Center node in node graph` and navigate to the backdrop. 2. Reorganize nodes the way only one outgoing connection is present. 3. Hit reload button on the publisher. ### How could this happen? More than one node, which are found above the backdrop, are linked downstream or more output connections from a node also linked downstream. Empty backdrop ## Invalid empty backdrop Backdrop is empty and no nodes are found above it. ### How to repair? 1. Use button `Center node in node graph` and navigate to the backdrop. 2. Add any node above it or delete it. 3. Hit reload button on the publisher. ================================================ FILE: openpype/hosts/nuke/plugins/publish/help/validate_gizmo.xml ================================================ Found multiple outputs ## Invalid amount of Output nodes Group node `{node_name}` is having more than one Output node. ### How to repair? 1. Use button `Open Group`. 2. Remove redundant Output node. 3. Hit reload button on the publisher. ### How could this happen? Perhaps you had created exciently more than one Output node. Missing Input nodes ## Missing Input nodes Make sure there is at least one connected Input node inside the group node with name `{node_name}` ### How to repair? 1. Use button `Open Group`. 2. Add at least one Input node and connect to other nodes. 3. Hit reload button on the publisher. ================================================ FILE: openpype/hosts/nuke/plugins/publish/help/validate_knobs.xml ================================================ Knobs value ## Invalid node's knobs values Following node knobs needs to be repaired: {invalid_items} ### How to repair? 1. Use Repair button. 2. Hit Reload button on the publisher. ================================================ FILE: openpype/hosts/nuke/plugins/publish/help/validate_output_resolution.xml ================================================ Output format ## Invalid format setting Either the Reformat node inside of the render group is missing or the Reformat node output format knob is not set to `root.format`. ### How to repair? 1. Use Repair button. 2. Hit Reload button on the publisher. ================================================ FILE: openpype/hosts/nuke/plugins/publish/help/validate_proxy_mode.xml ================================================ Proxy mode ## Invalid proxy mode value Nuke is set to use Proxy. This is not supported by publisher. ### How to repair? 1. Use Repair button. 2. Hit Reload button on the publisher. ================================================ FILE: openpype/hosts/nuke/plugins/publish/help/validate_rendered_frames.xml ================================================ Rendered Frames ## Missing Rendered Frames Render node "{node_name}" is set to "Use existing frames", but frames are missing. ### How to repair? 1. Use Repair button. 2. Set different target. 2. Hit Reload button on the publisher. ================================================ FILE: openpype/hosts/nuke/plugins/publish/help/validate_script_attributes.xml ================================================ Script attributes ## Invalid Script attributes Following script root attributes need to be fixed: {failed_attributes} ### How to repair? 1. Use Repair. 2. Hit Reload button on the publisher. ================================================ FILE: openpype/hosts/nuke/plugins/publish/help/validate_write_nodes.xml ================================================ Knobs values ## Invalid node's knobs values Following write node knobs needs to be repaired: {xml_msg} ### How to repair? 1. Use Repair button. 2. Hit Reload button on the publisher. Legacy knob types ## Knobs are in obsolete configuration Settings needs to be fixed. ### How to repair? Contact your supervisor or fix it in project settings at 'project_settings/nuke/imageio/nodes/requiredNodes' at knobs. Each '__legacy__' type has to be defined accordingly to its type. ================================================ FILE: openpype/hosts/nuke/plugins/publish/increment_script_version.py ================================================ import nuke import pyblish.api class IncrementScriptVersion(pyblish.api.ContextPlugin): """Increment current script version.""" order = pyblish.api.IntegratorOrder + 0.9 label = "Increment Script Version" optional = True families = ["workfile"] hosts = ['nuke'] def process(self, context): assert all(result["success"] for result in context.data["results"]), ( "Publishing not successful so version is not increased.") from openpype.lib import version_up path = context.data["currentFile"] nuke.scriptSaveAs(version_up(path)) self.log.info('Incrementing script version') ================================================ FILE: openpype/hosts/nuke/plugins/publish/remove_ouput_node.py ================================================ import nuke import pyblish.api class RemoveOutputNode(pyblish.api.ContextPlugin): """Removing output node for each output write node """ label = 'Output Node Remove' order = pyblish.api.IntegratorOrder + 0.4 families = ["workfile"] hosts = ['nuke'] def process(self, context): try: output_node = context.data["outputNode"] name = output_node["name"].value() self.log.info("Removing output node: '{}'".format(name)) nuke.delete(output_node) except Exception: return ================================================ FILE: openpype/hosts/nuke/plugins/publish/validate_asset_context.py ================================================ # -*- coding: utf-8 -*- """Validate if instance asset is the same as context asset.""" from __future__ import absolute_import import pyblish.api from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, PublishXmlValidationError, OptionalPyblishPluginMixin ) from openpype.hosts.nuke.api import SelectInstanceNodeAction class ValidateCorrectAssetContext( pyblish.api.InstancePlugin, OptionalPyblishPluginMixin ): """Validator to check if instance asset context match context asset. When working in per-shot style you always publish data in context of current asset (shot). This validator checks if this is so. It is optional so it can be disabled when needed. Checking `asset` and `task` keys. """ order = ValidateContentsOrder label = "Validate asset context" hosts = ["nuke"] actions = [ RepairAction, SelectInstanceNodeAction ] optional = True @classmethod def apply_settings(cls, project_settings): """Apply deprecated settings from project settings. """ nuke_publish = project_settings["nuke"]["publish"] if "ValidateCorrectAssetName" in nuke_publish: settings = nuke_publish["ValidateCorrectAssetName"] else: settings = nuke_publish["ValidateCorrectAssetContext"] cls.enabled = settings["enabled"] cls.optional = settings["optional"] cls.active = settings["active"] def process(self, instance): if not self.is_active(instance.data): return invalid_keys = self.get_invalid(instance) if not invalid_keys: return message_values = { "node_name": instance.data["transientData"]["node"].name(), "correct_values": ", ".join([ "{} > {}".format(_key, instance.context.data[_key]) for _key in invalid_keys ]), "wrong_values": ", ".join([ "{} > {}".format(_key, instance.data.get(_key)) for _key in invalid_keys ]) } msg = ( "Instance `{node_name}` has wrong context keys:\n" "Correct: `{correct_values}` | Wrong: `{wrong_values}`").format( **message_values) self.log.debug(msg) raise PublishXmlValidationError( self, msg, formatting_data=message_values ) @classmethod def get_invalid(cls, instance): """Get invalid keys from instance data and context data.""" invalid_keys = [] testing_keys = ["asset", "task"] for _key in testing_keys: if _key not in instance.data: invalid_keys.append(_key) continue if instance.data[_key] != instance.context.data[_key]: invalid_keys.append(_key) return invalid_keys @classmethod def repair(cls, instance): """Repair instance data with context data.""" invalid_keys = cls.get_invalid(instance) create_context = instance.context.data["create_context"] instance_id = instance.data.get("instance_id") created_instance = create_context.get_instance_by_id( instance_id ) for _key in invalid_keys: created_instance[_key] = instance.context.data[_key] create_context.save_changes() ================================================ FILE: openpype/hosts/nuke/plugins/publish/validate_backdrop.py ================================================ import nuke import pyblish from openpype.hosts.nuke import api as napi from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, OptionalPyblishPluginMixin ) class SelectCenterInNodeGraph(pyblish.api.Action): """ Centering failed instance node in node grap """ label = "Center node in node graph" icon = "wrench" on = "failed" def process(self, context, plugin): # Get the errored instances failed = [] for result in context.data["results"]: if (result["error"] is not None and result["instance"] is not None and result["instance"] not in failed): failed.append(result["instance"]) # Apply pyblish.logic to get the instances for the plug-in instances = pyblish.api.instances_by_plugin(failed, plugin) all_xC = [] all_yC = [] # maintain selection with napi.maintained_selection(): # collect all failed nodes xpos and ypos for instance in instances: bdn = instance.data["transientData"]["node"] xC = bdn.xpos() + bdn.screenWidth() / 2 yC = bdn.ypos() + bdn.screenHeight() / 2 all_xC.append(xC) all_yC.append(yC) self.log.debug("all_xC: `{}`".format(all_xC)) self.log.debug("all_yC: `{}`".format(all_yC)) # zoom to nodes in node graph nuke.zoom(2, [min(all_xC), min(all_yC)]) class ValidateBackdrop( pyblish.api.InstancePlugin, OptionalPyblishPluginMixin ): """ Validate amount of nodes on backdrop node in case user forgotten to add nodes above the publishing backdrop node. """ order = ValidateContentsOrder optional = True families = ["nukenodes"] label = "Validate Backdrop" hosts = ["nuke"] actions = [SelectCenterInNodeGraph] def process(self, instance): if not self.is_active(instance.data): return child_nodes = instance.data["transientData"]["childNodes"] connections_out = instance.data["transientData"]["nodeConnectionsOut"] msg_multiple_outputs = ( "Only one outcoming connection from " "\"{}\" is allowed").format(instance.data["name"]) if len(connections_out.keys()) > 1: raise PublishXmlValidationError( self, msg_multiple_outputs, "multiple_outputs" ) msg_no_nodes = "No content on backdrop node: \"{}\"".format( instance.data["name"]) self.log.debug( "Amount of nodes on instance: {}".format( len(child_nodes)) ) if child_nodes == []: raise PublishXmlValidationError( self, msg_no_nodes, "no_nodes" ) ================================================ FILE: openpype/hosts/nuke/plugins/publish/validate_exposed_knobs.py ================================================ import pyblish.api from openpype.pipeline.publish import get_errored_instances_from_context from openpype.hosts.nuke.api.lib import link_knobs from openpype.pipeline.publish import ( OptionalPyblishPluginMixin, PublishValidationError ) class RepairExposedKnobs(pyblish.api.Action): label = "Repair" on = "failed" icon = "wrench" def process(self, context, plugin): instances = get_errored_instances_from_context(context) for instance in instances: child_nodes = ( instance.data.get("transientData", {}).get("childNodes") or instance ) write_group_node = instance.data["transientData"]["node"] # get write node from inside of group write_node = None for x in child_nodes: if x.Class() == "Write": write_node = x plugin_name = plugin.families_mapping[instance.data["family"]] nuke_settings = instance.context.data["project_settings"]["nuke"] create_settings = nuke_settings["create"][plugin_name] exposed_knobs = create_settings["exposed_knobs"] link_knobs(exposed_knobs, write_node, write_group_node) class ValidateExposedKnobs( OptionalPyblishPluginMixin, pyblish.api.InstancePlugin ): """ Validate write node exposed knobs. Compare exposed linked knobs to settings. """ order = pyblish.api.ValidatorOrder optional = True families = ["render", "prerender", "image"] label = "Validate Exposed Knobs" actions = [RepairExposedKnobs] hosts = ["nuke"] families_mapping = { "render": "CreateWriteRender", "prerender": "CreateWritePrerender", "image": "CreateWriteImage" } def process(self, instance): if not self.is_active(instance.data): return plugin = self.families_mapping[instance.data["family"]] group_node = instance.data["transientData"]["node"] nuke_settings = instance.context.data["project_settings"]["nuke"] create_settings = nuke_settings["create"][plugin] exposed_knobs = create_settings.get("exposed_knobs", []) unexposed_knobs = [] for knob in exposed_knobs: if knob not in group_node.knobs(): unexposed_knobs.append(knob) if unexposed_knobs: raise PublishValidationError( "Missing exposed knobs: {}".format(unexposed_knobs) ) ================================================ FILE: openpype/hosts/nuke/plugins/publish/validate_gizmo.py ================================================ import pyblish from openpype.pipeline import PublishXmlValidationError from openpype.hosts.nuke import api as napi import nuke class OpenFailedGroupNode(pyblish.api.Action): """ Centering failed instance node in node grap """ label = "Open Group" icon = "wrench" on = "failed" def process(self, context, plugin): # Get the errored instances failed = [] for result in context.data["results"]: if (result["error"] is not None and result["instance"] is not None and result["instance"] not in failed): failed.append(result["instance"]) # Apply pyblish.logic to get the instances for the plug-in instances = pyblish.api.instances_by_plugin(failed, plugin) # maintain selection with napi.maintained_selection(): # collect all failed nodes xpos and ypos for instance in instances: grpn = instance.data["transientData"]["node"] nuke.showDag(grpn) class ValidateGizmo(pyblish.api.InstancePlugin): """Validate amount of output nodes in gizmo (group) node""" order = pyblish.api.ValidatorOrder optional = True families = ["gizmo"] label = "Validate Gizmo (group)" hosts = ["nuke"] actions = [OpenFailedGroupNode] def process(self, instance): grpn = instance.data["transientData"]["node"] with grpn: connections_out = nuke.allNodes('Output') if len(connections_out) > 1: msg_multiple_outputs = ( "Only one outcoming connection from " "\"{}\" is allowed").format(instance.data["name"]) raise PublishXmlValidationError( self, msg_multiple_outputs, "multiple_outputs", {"node_name": grpn["name"].value()} ) connections_in = nuke.allNodes('Input') if len(connections_in) == 0: msg_missing_inputs = ( "At least one Input node has to be inside Group: " "\"{}\"").format(instance.data["name"]) raise PublishXmlValidationError( self, msg_missing_inputs, "no_inputs", {"node_name": grpn["name"].value()} ) ================================================ FILE: openpype/hosts/nuke/plugins/publish/validate_knobs.py ================================================ import nuke import six import pyblish.api from openpype.pipeline.publish import ( RepairContextAction, PublishXmlValidationError, ) class ValidateKnobs(pyblish.api.ContextPlugin): """Ensure knobs are consistent. Knobs to validate and their values comes from the Controlled by plugin settings that require json in following structure: "ValidateKnobs": { "enabled": true, "knobs": { "family": { "knob_name": knob_value } } } """ order = pyblish.api.ValidatorOrder label = "Validate Knobs" hosts = ["nuke"] actions = [RepairContextAction] optional = True def process(self, context): invalid = self.get_invalid(context, compute=True) if invalid: invalid_items = [ ( "Node __{node_name}__ with knob _{label}_ " "expecting _{expected}_, " "but is set to _{current}_" ).format(**i) for i in invalid ] raise PublishXmlValidationError( self, "Found knobs with invalid values:\n{}".format(invalid), formatting_data={ "invalid_items": "\n".join(invalid_items)} ) @classmethod def get_invalid(cls, context, compute=False): invalid = context.data.get("invalid_knobs", []) if compute: invalid = cls.get_invalid_knobs(context) return invalid @classmethod def get_invalid_knobs(cls, context): invalid_knobs = [] for instance in context: # Filter families. families = [instance.data["family"]] families += instance.data.get("families", []) # Get all knobs to validate. knobs = {} for family in families: # check if dot in family if "." in family: family = family.split(".")[0] # avoid families not in settings if family not in cls.knobs: continue # get presets of knobs for preset in cls.knobs[family]: knobs[preset] = cls.knobs[family][preset] # Get invalid knobs. nodes = [] for node in nuke.allNodes(): nodes.append(node) if node.Class() == "Group": node.begin() nodes.extend(iter(nuke.allNodes())) node.end() for node in nodes: for knob in node.knobs(): if knob not in knobs.keys(): continue expected = knobs[knob] if node[knob].value() != expected: invalid_knobs.append( { "node_name": node.name(), "knob": node[knob], "name": node[knob].name(), "label": node[knob].label(), "expected": expected, "current": node[knob].value() } ) context.data["invalid_knobs"] = invalid_knobs return invalid_knobs @classmethod def repair(cls, instance): invalid = cls.get_invalid(instance) for data in invalid: # TODO: will need to improve type definitions # with the new settings for knob types if isinstance(data["expected"], six.text_type): data["knob"].setValue(str(data["expected"])) continue data["knob"].setValue(data["expected"]) ================================================ FILE: openpype/hosts/nuke/plugins/publish/validate_output_resolution.py ================================================ import pyblish.api from openpype.hosts.nuke import api as napi from openpype.pipeline.publish import RepairAction from openpype.pipeline import ( PublishXmlValidationError, OptionalPyblishPluginMixin ) import nuke class ValidateOutputResolution( OptionalPyblishPluginMixin, pyblish.api.InstancePlugin ): """Validates Output Resolution. It is making sure the resolution of write's input is the same as Format definition of script in Root node. """ order = pyblish.api.ValidatorOrder optional = True families = ["render"] label = "Validate Write resolution" hosts = ["nuke"] actions = [RepairAction] missing_msg = "Missing Reformat node in render group node" resolution_msg = "Reformat is set to wrong format" def process(self, instance): if not self.is_active(instance.data): return invalid = self.get_invalid(instance) if invalid: raise PublishXmlValidationError(self, invalid) @classmethod def get_reformat(cls, instance): child_nodes = ( instance.data.get("transientData", {}).get("childNodes") or instance ) reformat = None for inode in child_nodes: if inode.Class() != "Reformat": continue reformat = inode return reformat @classmethod def get_invalid(cls, instance): def _check_resolution(instance, reformat): root_width = instance.data["resolutionWidth"] root_height = instance.data["resolutionHeight"] write_width = reformat.format().width() write_height = reformat.format().height() if (root_width != write_width) or (root_height != write_height): return None else: return True # check if reformat is in render node reformat = cls.get_reformat(instance) if not reformat: return cls.missing_msg # check if reformat is set to correct root format correct_format = _check_resolution(instance, reformat) if not correct_format: return cls.resolution_msg @classmethod def repair(cls, instance): child_nodes = ( instance.data.get("transientData", {}).get("childNodes") or instance ) invalid = cls.get_invalid(instance) grp_node = instance.data["transientData"]["node"] if cls.missing_msg == invalid: # make sure we are inside of the group node with grp_node: # find input node and select it _input = None for inode in child_nodes: if inode.Class() != "Input": continue _input = inode # add reformat node under it with napi.maintained_selection(): _input['selected'].setValue(True) _rfn = nuke.createNode("Reformat", "name Reformat01") _rfn["resize"].setValue(0) _rfn["black_outside"].setValue(1) cls.log.info("Adding reformat node") if cls.resolution_msg == invalid: reformat = cls.get_reformat(instance) reformat["format"].setValue(nuke.root()["format"].value()) cls.log.info("Fixing reformat to root.format") ================================================ FILE: openpype/hosts/nuke/plugins/publish/validate_proxy_mode.py ================================================ import pyblish import nuke from openpype.pipeline import PublishXmlValidationError class FixProxyMode(pyblish.api.Action): """ Togger off proxy switch OFF """ label = "Repair" icon = "wrench" on = "failed" def process(self, context, plugin): rootNode = nuke.root() rootNode["proxy"].setValue(False) class ValidateProxyMode(pyblish.api.ContextPlugin): """Validate active proxy mode""" order = pyblish.api.ValidatorOrder label = "Validate Proxy Mode" hosts = ["nuke"] actions = [FixProxyMode] def process(self, context): rootNode = nuke.root() isProxy = rootNode["proxy"].value() if isProxy: raise PublishXmlValidationError( self, "Proxy mode should be toggled OFF" ) ================================================ FILE: openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py ================================================ import os import pyblish.api import clique from openpype.pipeline import PublishXmlValidationError from openpype.pipeline.publish import get_errored_instances_from_context class RepairActionBase(pyblish.api.Action): on = "failed" icon = "wrench" @staticmethod def get_instance(context, plugin): # Get the errored instances return get_errored_instances_from_context(context, plugin=plugin) def repair_knob(self, context, instances, state): create_context = context.data["create_context"] for instance in instances: # Reset the render knob instance_id = instance.data.get("instance_id") created_instance = create_context.get_instance_by_id( instance_id ) created_instance.creator_attributes["render_target"] = state self.log.info("Rendering toggled to `{}`".format(state)) create_context.save_changes() class RepairCollectionActionToLocal(RepairActionBase): label = "Repair - rerender with \"Local\"" def process(self, context, plugin): instances = self.get_instance(context, plugin) self.repair_knob(context, instances, "local") class RepairCollectionActionToFarm(RepairActionBase): label = "Repair - rerender with \"On farm\"" def process(self, context, plugin): instances = self.get_instance(context, plugin) self.repair_knob(context, instances, "farm") class ValidateRenderedFrames(pyblish.api.InstancePlugin): """ Validates file output. """ order = pyblish.api.ValidatorOrder + 0.1 families = ["render", "prerender", "still"] label = "Validate rendered frame" hosts = ["nuke", "nukestudio"] actions = [RepairCollectionActionToLocal, RepairCollectionActionToFarm] def process(self, instance): node = instance.data["transientData"]["node"] f_data = { "node_name": node.name() } for repre in instance.data["representations"]: if not repre.get("files"): msg = ("no frames were collected, " "you need to render them.\n" "Check properties of write node (group) and" "select 'Local' option in 'Publish' dropdown.") self.log.error(msg) raise PublishXmlValidationError( self, msg, formatting_data=f_data) if isinstance(repre["files"], str): return collections, remainder = clique.assemble(repre["files"]) self.log.debug("collections: {}".format(str(collections))) self.log.debug("remainder: {}".format(str(remainder))) collection = collections[0] f_start_h = instance.data["frameStartHandle"] f_end_h = instance.data["frameEndHandle"] frame_length = int(f_end_h - f_start_h + 1) if frame_length != 1: if len(collections) != 1: msg = "There are multiple collections in the folder" self.log.error(msg) raise PublishXmlValidationError( self, msg, formatting_data=f_data) if not collection.is_contiguous(): msg = "Some frames appear to be missing" self.log.error(msg) raise PublishXmlValidationError( self, msg, formatting_data=f_data) collected_frames_len = len(collection.indexes) coll_start = min(collection.indexes) coll_end = max(collection.indexes) self.log.debug("frame_length: {}".format(frame_length)) self.log.debug("collected_frames_len: {}".format( collected_frames_len)) self.log.debug("f_start_h-f_end_h: {}-{}".format( f_start_h, f_end_h)) self.log.debug( "coll_start-coll_end: {}-{}".format(coll_start, coll_end)) self.log.debug( "len(collection.indexes): {}".format(collected_frames_len) ) if ("slate" in instance.data["families"]) \ and (frame_length != collected_frames_len): collected_frames_len -= 1 f_start_h += 1 if ( collected_frames_len != frame_length and coll_start <= f_start_h and coll_end >= f_end_h ): raise PublishXmlValidationError( self, ( "{} missing frames. Use repair to " "render all frames" ).format(__name__), formatting_data=f_data ) instance.data["collection"] = collection return ================================================ FILE: openpype/hosts/nuke/plugins/publish/validate_script_attributes.py ================================================ from copy import deepcopy import pyblish.api from openpype.pipeline import ( PublishXmlValidationError, OptionalPyblishPluginMixin ) from openpype.pipeline.publish import RepairAction from openpype.hosts.nuke.api.lib import ( WorkfileSettings ) class ValidateScriptAttributes( OptionalPyblishPluginMixin, pyblish.api.InstancePlugin ): """ Validates file output. """ order = pyblish.api.ValidatorOrder + 0.1 families = ["workfile"] label = "Validate script attributes" hosts = ["nuke"] optional = True actions = [RepairAction] def process(self, instance): if not self.is_active(instance.data): return script_data = deepcopy(instance.context.data["scriptData"]) asset = instance.data["assetEntity"] # These attributes will be checked attributes = [ "fps", "frameStart", "frameEnd", "resolutionWidth", "resolutionHeight", "handleStart", "handleEnd" ] # get only defined attributes from asset data asset_attributes = { attr: asset["data"][attr] for attr in attributes if attr in asset["data"] } # fix frame values to include handles asset_attributes["fps"] = float("{0:.4f}".format( asset_attributes["fps"])) script_data["fps"] = float("{0:.4f}".format( script_data["fps"])) # Compare asset's values Nukescript X Database not_matching = [] for attr in attributes: self.log.debug( "Asset vs Script attribute \"{}\": {}, {}".format( attr, asset_attributes[attr], script_data[attr] ) ) if asset_attributes[attr] != script_data[attr]: not_matching.append({ "name": attr, "expected": asset_attributes[attr], "actual": script_data[attr] }) # Raise error if not matching if not_matching: msg = "Following attributes are not set correctly: \n{}" attrs_wrong_str = "\n".join([ ( "`{0}` is set to `{1}`, " "but should be set to `{2}`" ).format(at["name"], at["actual"], at["expected"]) for at in not_matching ]) attrs_wrong_html = "
".join([ ( "-- __{0}__ is set to __{1}__, " "but should be set to __{2}__" ).format(at["name"], at["actual"], at["expected"]) for at in not_matching ]) raise PublishXmlValidationError( self, msg.format(attrs_wrong_str), formatting_data={ "failed_attributes": attrs_wrong_html } ) @classmethod def repair(cls, instance): cls.log.debug("__ repairing instance: {}".format(instance)) WorkfileSettings().set_context_settings() ================================================ FILE: openpype/hosts/nuke/plugins/publish/validate_write_nodes.py ================================================ from collections import defaultdict import pyblish.api from openpype.pipeline.publish import get_errored_instances_from_context from openpype.hosts.nuke.api.lib import ( get_write_node_template_attr, set_node_knobs_from_settings, color_gui_to_int ) from openpype.pipeline.publish import ( PublishXmlValidationError, OptionalPyblishPluginMixin ) class RepairNukeWriteNodeAction(pyblish.api.Action): label = "Repair" on = "failed" icon = "wrench" def process(self, context, plugin): instances = get_errored_instances_from_context(context) for instance in instances: child_nodes = ( instance.data.get("transientData", {}).get("childNodes") or instance ) write_group_node = instance.data["transientData"]["node"] # get write node from inside of group write_node = None for x in child_nodes: if x.Class() == "Write": write_node = x correct_data = get_write_node_template_attr(write_group_node) set_node_knobs_from_settings(write_node, correct_data["knobs"]) self.log.debug("Node attributes were fixed") class ValidateNukeWriteNode( OptionalPyblishPluginMixin, pyblish.api.InstancePlugin ): """ Validate Write node's knobs. Compare knobs on write node inside the render group with settings. At the moment supporting only `file` knob. """ order = pyblish.api.ValidatorOrder optional = True families = ["render"] label = "Validate write node" actions = [RepairNukeWriteNodeAction] hosts = ["nuke"] def process(self, instance): if not self.is_active(instance.data): return child_nodes = ( instance.data.get("transientData", {}).get("childNodes") or instance ) write_group_node = instance.data["transientData"]["node"] # get write node from inside of group write_node = None for x in child_nodes: if x.Class() == "Write": write_node = x if write_node is None: return correct_data = get_write_node_template_attr(write_group_node) check = [] # Collect key values of same type in a list. values_by_name = defaultdict(list) for knob_data in correct_data["knobs"]: values_by_name[knob_data["name"]].append(knob_data["value"]) for knob_data in correct_data["knobs"]: knob_type = knob_data["type"] if ( knob_type == "__legacy__" ): raise PublishXmlValidationError( self, ( "Please update data in settings 'project_settings" "/nuke/imageio/nodes/requiredNodes'" ), key="legacy" ) key = knob_data["name"] values = values_by_name[key] node_value = write_node[key].value() # fix type differences fixed_values = [] for value in values: if type(node_value) in (int, float): try: if isinstance(value, list): value = color_gui_to_int(value) else: value = float(value) node_value = float(node_value) except ValueError: value = str(value) else: value = str(value) node_value = str(node_value) fixed_values.append(value) if ( node_value not in fixed_values and key != "file" and key != "tile_color" ): check.append([key, fixed_values, write_node[key].value()]) if check: self._make_error(check) def _make_error(self, check): # sourcery skip: merge-assign-and-aug-assign, move-assign-in-block dbg_msg = "Write node's knobs values are not correct!\n" msg_add = "Knob '{0}' > Expected: `{1}` > Current: `{2}`" details = [ msg_add.format(item[0], item[1], item[2]) for item in check ] xml_msg = "
".join(details) dbg_msg += "\n\t".join(details) raise PublishXmlValidationError( self, dbg_msg, formatting_data={"xml_msg": xml_msg} ) ================================================ FILE: openpype/hosts/nuke/startup/__init__.py ================================================ ================================================ FILE: openpype/hosts/nuke/startup/clear_rendered.py ================================================ import os from openpype.lib import Logger def clear_rendered(dir_path): log = Logger.get_logger(__name__) for _f in os.listdir(dir_path): _f_path = os.path.join(dir_path, _f) log.info("Removing: `{}`".format(_f_path)) os.remove(_f_path) ================================================ FILE: openpype/hosts/nuke/startup/custom_write_node.py ================================================ """ OpenPype custom script for setting up write nodes for non-publish """ import os import nuke import nukescripts from openpype.pipeline import Anatomy from openpype.hosts.nuke.api.lib import ( set_node_knobs_from_settings, get_nuke_imageio_settings ) temp_rendering_path_template = ( "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}") knobs_setting = { "knobs": [ { "type": "text", "name": "file_type", "value": "exr" }, { "type": "text", "name": "datatype", "value": "16 bit half" }, { "type": "text", "name": "compression", "value": "Zip (1 scanline)" }, { "type": "bool", "name": "autocrop", "value": True }, { "type": "color_gui", "name": "tile_color", "value": [ 186, 35, 35, 255 ] }, { "type": "text", "name": "channels", "value": "rgb" }, { "type": "bool", "name": "create_directories", "value": True } ] } class WriteNodeKnobSettingPanel(nukescripts.PythonPanel): """ Write Node's Knobs Settings Panel """ def __init__(self): nukescripts.PythonPanel.__init__(self, "Set Knobs Value(Write Node)") preset_name, _ = self.get_node_knobs_setting() # create knobs self.selected_preset_name = nuke.Enumeration_Knob( 'preset_selector', 'presets', preset_name) # add knobs to panel self.addKnob(self.selected_preset_name) def process(self): """ Process the panel values. """ write_selected_nodes = [ selected_nodes for selected_nodes in nuke.selectedNodes() if selected_nodes.Class() == "Write"] selected_preset = self.selected_preset_name.value() ext = None knobs = knobs_setting["knobs"] preset_name, node_knobs_presets = ( self.get_node_knobs_setting(selected_preset) ) if selected_preset and preset_name: if not node_knobs_presets: nuke.message( "No knobs value found in subset group.." "\nDefault setting will be used..") else: knobs = node_knobs_presets ext_knob_list = [knob for knob in knobs if knob["name"] == "file_type"] if not ext_knob_list: nuke.message( "ERROR: No file type found in the subset's knobs." "\nPlease add one to complete setting up the node") return else: for knob in ext_knob_list: ext = knob["value"] anatomy = Anatomy() frame_padding = int( anatomy.templates["render"].get( "frame_padding" ) ) for write_node in write_selected_nodes: # data for mapping the path data = { "work": os.getenv("AVALON_WORKDIR"), "subset": write_node["name"].value(), "frame": "#" * frame_padding, "ext": ext } file_path = temp_rendering_path_template.format(**data) file_path = file_path.replace("\\", "/") write_node["file"].setValue(file_path) set_node_knobs_from_settings(write_node, knobs) def get_node_knobs_setting(self, selected_preset=None): preset_name = [] knobs_nodes = [] settings = [ node_settings for node_settings in get_nuke_imageio_settings()["nodes"]["overrideNodes"] if node_settings["nukeNodeClass"] == "Write" and node_settings["subsets"] ] if not settings: return for i, _ in enumerate(settings): if selected_preset in settings[i]["subsets"]: knobs_nodes = settings[i]["knobs"] for setting in settings: for subset in setting["subsets"]: preset_name.append(subset) return preset_name, knobs_nodes def main(): p_ = WriteNodeKnobSettingPanel() if p_.showModalDialog(): print(p_.process()) ================================================ FILE: openpype/hosts/nuke/startup/frame_setting_for_read_nodes.py ================================================ """ OpenPype custom script for resetting read nodes start frame values """ import nuke import nukescripts class FrameSettingsPanel(nukescripts.PythonPanel): """ Frame Settings Panel """ def __init__(self): nukescripts.PythonPanel.__init__(self, "Set Frame Start (Read Node)") # create knobs self.frame = nuke.Int_Knob( 'frame', 'Frame Number') self.selected = nuke.Boolean_Knob("selection") # add knobs to panel self.addKnob(self.selected) self.addKnob(self.frame) # set values self.selected.setValue(False) self.frame.setValue(nuke.root().firstFrame()) def process(self): """ Process the panel values. """ # get values frame = self.frame.value() if self.selected.value(): # selected nodes processing if not nuke.selectedNodes(): return for rn_ in nuke.selectedNodes(): if rn_.Class() != "Read": continue rn_["frame_mode"].setValue("start_at") rn_["frame"].setValue(str(frame)) else: # all nodes processing for rn_ in nuke.allNodes(filter="Read"): rn_["frame_mode"].setValue("start_at") rn_["frame"].setValue(str(frame)) def main(): p_ = FrameSettingsPanel() if p_.showModalDialog(): print(p_.process()) ================================================ FILE: openpype/hosts/nuke/startup/menu.py ================================================ from openpype.pipeline import install_host from openpype.hosts.nuke.api import NukeHost host = NukeHost() install_host(host) ================================================ FILE: openpype/hosts/nuke/startup/write_to_read.py ================================================ import re import os import glob import nuke from openpype.lib import Logger log = Logger.get_logger(__name__) SINGLE_FILE_FORMATS = ['avi', 'mp4', 'mxf', 'mov', 'mpg', 'mpeg', 'wmv', 'm4v', 'm2v'] def evaluate_filepath_new( k_value, k_eval, project_dir, first_frame, allow_relative): # get combined relative path combined_relative_path = None if k_eval is not None and project_dir is not None: combined_relative_path = os.path.abspath( os.path.join(project_dir, k_eval)) combined_relative_path = combined_relative_path.replace('\\', '/') filetype = combined_relative_path.split('.')[-1] frame_number = re.findall(r'\d+', combined_relative_path)[-1] basename = combined_relative_path[: combined_relative_path.rfind( frame_number)] filepath_glob = basename + '*' + filetype glob_search_results = glob.glob(filepath_glob) if len(glob_search_results) <= 0: combined_relative_path = None try: # k_value = k_value % first_frame if os.path.isdir(os.path.basename(k_value)): # doesn't check for file, only parent dir filepath = k_value elif os.path.exists(k_eval): filepath = k_eval elif not isinstance(project_dir, type(None)) and \ not isinstance(combined_relative_path, type(None)): filepath = combined_relative_path filepath = os.path.abspath(filepath) except Exception as E: log.error("Cannot create Read node. Perhaps it needs to be \ rendered first :) Error: `{}`".format(E)) return None filepath = filepath.replace('\\', '/') # assumes last number is a sequence counter current_frame = re.findall(r'\d+', filepath)[-1] padding = len(current_frame) basename = filepath[: filepath.rfind(current_frame)] filetype = filepath.split('.')[-1] # sequence or not? if filetype in SINGLE_FILE_FORMATS: pass else: # Image sequence needs hashes # to do still with no number not handled filepath = basename + '#' * padding + '.' + filetype # relative path? make it relative again if allow_relative: if (not isinstance(project_dir, type(None))) and project_dir != "": filepath = filepath.replace(project_dir, '.') # get first and last frame from disk frames = [] firstframe = 0 lastframe = 0 filepath_glob = basename + '*' + filetype glob_search_results = glob.glob(filepath_glob) for f in glob_search_results: frame = re.findall(r'\d+', f)[-1] frames.append(frame) frames = sorted(frames) firstframe = frames[0] lastframe = frames[len(frames) - 1] if int(lastframe) < 0: lastframe = firstframe return filepath, firstframe, lastframe def create_read_node(ndata, comp_start): read = nuke.createNode('Read', 'file "' + ndata['filepath'] + '"') read.knob('colorspace').setValue(int(ndata['colorspace'])) read.knob('raw').setValue(ndata['rawdata']) read.knob('first').setValue(int(ndata['firstframe'])) read.knob('last').setValue(int(ndata['lastframe'])) read.knob('origfirst').setValue(int(ndata['firstframe'])) read.knob('origlast').setValue(int(ndata['lastframe'])) if comp_start == int(ndata['firstframe']): read.knob('frame_mode').setValue("1") read.knob('frame').setValue(str(comp_start)) else: read.knob('frame_mode').setValue("0") read.knob('xpos').setValue(ndata['new_xpos']) read.knob('ypos').setValue(ndata['new_ypos']) nuke.inputs(read, 0) return def write_to_read(gn, allow_relative=False): comp_start = nuke.Root().knob('first_frame').value() project_dir = nuke.Root().knob('project_directory').getValue() if not os.path.exists(project_dir): project_dir = nuke.Root().knob('project_directory').evaluate() group_read_nodes = [] with gn: height = gn.screenHeight() # get group height and position new_xpos = int(gn.knob('xpos').value()) new_ypos = int(gn.knob('ypos').value()) + height + 20 group_writes = [n for n in nuke.allNodes() if n.Class() == "Write"] if group_writes != []: # there can be only 1 write node, taking first n = group_writes[0] if n.knob('file') is not None: myfile, firstFrame, lastFrame = evaluate_filepath_new( n.knob('file').getValue(), n.knob('file').evaluate(), project_dir, comp_start, allow_relative ) if not myfile: return # get node data ndata = { 'filepath': myfile, 'firstframe': int(firstFrame), 'lastframe': int(lastFrame), 'new_xpos': new_xpos, 'new_ypos': new_ypos, 'colorspace': n.knob('colorspace').getValue(), 'rawdata': n.knob('raw').value(), 'write_frame_mode': str(n.knob('frame_mode').value()), 'write_frame': n.knob('frame').value() } group_read_nodes.append(ndata) # create reads in one go for oneread in group_read_nodes: # create read node create_read_node(oneread, comp_start) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/__init__.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # Copyright 2007 Google Inc. All Rights Reserved. __version__ = '3.20.1' ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/any_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/any.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19google/protobuf/any.proto\x12\x0fgoogle.protobuf\"&\n\x03\x41ny\x12\x10\n\x08type_url\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c\x42v\n\x13\x63om.google.protobufB\x08\x41nyProtoP\x01Z,google.golang.org/protobuf/types/known/anypb\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.any_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\010AnyProtoP\001Z,google.golang.org/protobuf/types/known/anypb\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _ANY._serialized_start=46 _ANY._serialized_end=84 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/api_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/api.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() from google.protobuf import source_context_pb2 as google_dot_protobuf_dot_source__context__pb2 from google.protobuf import type_pb2 as google_dot_protobuf_dot_type__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19google/protobuf/api.proto\x12\x0fgoogle.protobuf\x1a$google/protobuf/source_context.proto\x1a\x1agoogle/protobuf/type.proto\"\x81\x02\n\x03\x41pi\x12\x0c\n\x04name\x18\x01 \x01(\t\x12(\n\x07methods\x18\x02 \x03(\x0b\x32\x17.google.protobuf.Method\x12(\n\x07options\x18\x03 \x03(\x0b\x32\x17.google.protobuf.Option\x12\x0f\n\x07version\x18\x04 \x01(\t\x12\x36\n\x0esource_context\x18\x05 \x01(\x0b\x32\x1e.google.protobuf.SourceContext\x12&\n\x06mixins\x18\x06 \x03(\x0b\x32\x16.google.protobuf.Mixin\x12\'\n\x06syntax\x18\x07 \x01(\x0e\x32\x17.google.protobuf.Syntax\"\xd5\x01\n\x06Method\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x18\n\x10request_type_url\x18\x02 \x01(\t\x12\x19\n\x11request_streaming\x18\x03 \x01(\x08\x12\x19\n\x11response_type_url\x18\x04 \x01(\t\x12\x1a\n\x12response_streaming\x18\x05 \x01(\x08\x12(\n\x07options\x18\x06 \x03(\x0b\x32\x17.google.protobuf.Option\x12\'\n\x06syntax\x18\x07 \x01(\x0e\x32\x17.google.protobuf.Syntax\"#\n\x05Mixin\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04root\x18\x02 \x01(\tBv\n\x13\x63om.google.protobufB\x08\x41piProtoP\x01Z,google.golang.org/protobuf/types/known/apipb\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.api_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\010ApiProtoP\001Z,google.golang.org/protobuf/types/known/apipb\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _API._serialized_start=113 _API._serialized_end=370 _METHOD._serialized_start=373 _METHOD._serialized_end=586 _MIXIN._serialized_start=588 _MIXIN._serialized_end=623 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/compiler/__init__.py ================================================ ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/compiler/plugin_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/compiler/plugin.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%google/protobuf/compiler/plugin.proto\x12\x18google.protobuf.compiler\x1a google/protobuf/descriptor.proto\"F\n\x07Version\x12\r\n\x05major\x18\x01 \x01(\x05\x12\r\n\x05minor\x18\x02 \x01(\x05\x12\r\n\x05patch\x18\x03 \x01(\x05\x12\x0e\n\x06suffix\x18\x04 \x01(\t\"\xba\x01\n\x14\x43odeGeneratorRequest\x12\x18\n\x10\x66ile_to_generate\x18\x01 \x03(\t\x12\x11\n\tparameter\x18\x02 \x01(\t\x12\x38\n\nproto_file\x18\x0f \x03(\x0b\x32$.google.protobuf.FileDescriptorProto\x12;\n\x10\x63ompiler_version\x18\x03 \x01(\x0b\x32!.google.protobuf.compiler.Version\"\xc1\x02\n\x15\x43odeGeneratorResponse\x12\r\n\x05\x65rror\x18\x01 \x01(\t\x12\x1a\n\x12supported_features\x18\x02 \x01(\x04\x12\x42\n\x04\x66ile\x18\x0f \x03(\x0b\x32\x34.google.protobuf.compiler.CodeGeneratorResponse.File\x1a\x7f\n\x04\x46ile\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0finsertion_point\x18\x02 \x01(\t\x12\x0f\n\x07\x63ontent\x18\x0f \x01(\t\x12?\n\x13generated_code_info\x18\x10 \x01(\x0b\x32\".google.protobuf.GeneratedCodeInfo\"8\n\x07\x46\x65\x61ture\x12\x10\n\x0c\x46\x45\x41TURE_NONE\x10\x00\x12\x1b\n\x17\x46\x45\x41TURE_PROTO3_OPTIONAL\x10\x01\x42W\n\x1c\x63om.google.protobuf.compilerB\x0cPluginProtosZ)google.golang.org/protobuf/types/pluginpb') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.compiler.plugin_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\034com.google.protobuf.compilerB\014PluginProtosZ)google.golang.org/protobuf/types/pluginpb' _VERSION._serialized_start=101 _VERSION._serialized_end=171 _CODEGENERATORREQUEST._serialized_start=174 _CODEGENERATORREQUEST._serialized_end=360 _CODEGENERATORRESPONSE._serialized_start=363 _CODEGENERATORRESPONSE._serialized_end=684 _CODEGENERATORRESPONSE_FILE._serialized_start=499 _CODEGENERATORRESPONSE_FILE._serialized_end=626 _CODEGENERATORRESPONSE_FEATURE._serialized_start=628 _CODEGENERATORRESPONSE_FEATURE._serialized_end=684 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/descriptor.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Descriptors essentially contain exactly the information found in a .proto file, in types that make this information accessible in Python. """ __author__ = 'robinson@google.com (Will Robinson)' import threading import warnings from google.protobuf.internal import api_implementation _USE_C_DESCRIPTORS = False if api_implementation.Type() == 'cpp': # Used by MakeDescriptor in cpp mode import binascii import os from google.protobuf.pyext import _message _USE_C_DESCRIPTORS = True class Error(Exception): """Base error for this module.""" class TypeTransformationError(Error): """Error transforming between python proto type and corresponding C++ type.""" if _USE_C_DESCRIPTORS: # This metaclass allows to override the behavior of code like # isinstance(my_descriptor, FieldDescriptor) # and make it return True when the descriptor is an instance of the extension # type written in C++. class DescriptorMetaclass(type): def __instancecheck__(cls, obj): if super(DescriptorMetaclass, cls).__instancecheck__(obj): return True if isinstance(obj, cls._C_DESCRIPTOR_CLASS): return True return False else: # The standard metaclass; nothing changes. DescriptorMetaclass = type class _Lock(object): """Wrapper class of threading.Lock(), which is allowed by 'with'.""" def __new__(cls): self = object.__new__(cls) self._lock = threading.Lock() # pylint: disable=protected-access return self def __enter__(self): self._lock.acquire() def __exit__(self, exc_type, exc_value, exc_tb): self._lock.release() _lock = threading.Lock() def _Deprecated(name): if _Deprecated.count > 0: _Deprecated.count -= 1 warnings.warn( 'Call to deprecated create function %s(). Note: Create unlinked ' 'descriptors is going to go away. Please use get/find descriptors from ' 'generated code or query the descriptor_pool.' % name, category=DeprecationWarning, stacklevel=3) # Deprecated warnings will print 100 times at most which should be enough for # users to notice and do not cause timeout. _Deprecated.count = 100 _internal_create_key = object() class DescriptorBase(metaclass=DescriptorMetaclass): """Descriptors base class. This class is the base of all descriptor classes. It provides common options related functionality. Attributes: has_options: True if the descriptor has non-default options. Usually it is not necessary to read this -- just call GetOptions() which will happily return the default instance. However, it's sometimes useful for efficiency, and also useful inside the protobuf implementation to avoid some bootstrapping issues. """ if _USE_C_DESCRIPTORS: # The class, or tuple of classes, that are considered as "virtual # subclasses" of this descriptor class. _C_DESCRIPTOR_CLASS = () def __init__(self, options, serialized_options, options_class_name): """Initialize the descriptor given its options message and the name of the class of the options message. The name of the class is required in case the options message is None and has to be created. """ self._options = options self._options_class_name = options_class_name self._serialized_options = serialized_options # Does this descriptor have non-default options? self.has_options = (options is not None) or (serialized_options is not None) def _SetOptions(self, options, options_class_name): """Sets the descriptor's options This function is used in generated proto2 files to update descriptor options. It must not be used outside proto2. """ self._options = options self._options_class_name = options_class_name # Does this descriptor have non-default options? self.has_options = options is not None def GetOptions(self): """Retrieves descriptor options. This method returns the options set or creates the default options for the descriptor. """ if self._options: return self._options from google.protobuf import descriptor_pb2 try: options_class = getattr(descriptor_pb2, self._options_class_name) except AttributeError: raise RuntimeError('Unknown options class name %s!' % (self._options_class_name)) with _lock: if self._serialized_options is None: self._options = options_class() else: self._options = _ParseOptions(options_class(), self._serialized_options) return self._options class _NestedDescriptorBase(DescriptorBase): """Common class for descriptors that can be nested.""" def __init__(self, options, options_class_name, name, full_name, file, containing_type, serialized_start=None, serialized_end=None, serialized_options=None): """Constructor. Args: options: Protocol message options or None to use default message options. options_class_name (str): The class name of the above options. name (str): Name of this protocol message type. full_name (str): Fully-qualified name of this protocol message type, which will include protocol "package" name and the name of any enclosing types. file (FileDescriptor): Reference to file info. containing_type: if provided, this is a nested descriptor, with this descriptor as parent, otherwise None. serialized_start: The start index (inclusive) in block in the file.serialized_pb that describes this descriptor. serialized_end: The end index (exclusive) in block in the file.serialized_pb that describes this descriptor. serialized_options: Protocol message serialized options or None. """ super(_NestedDescriptorBase, self).__init__( options, serialized_options, options_class_name) self.name = name # TODO(falk): Add function to calculate full_name instead of having it in # memory? self.full_name = full_name self.file = file self.containing_type = containing_type self._serialized_start = serialized_start self._serialized_end = serialized_end def CopyToProto(self, proto): """Copies this to the matching proto in descriptor_pb2. Args: proto: An empty proto instance from descriptor_pb2. Raises: Error: If self couldn't be serialized, due to to few constructor arguments. """ if (self.file is not None and self._serialized_start is not None and self._serialized_end is not None): proto.ParseFromString(self.file.serialized_pb[ self._serialized_start:self._serialized_end]) else: raise Error('Descriptor does not contain serialization.') class Descriptor(_NestedDescriptorBase): """Descriptor for a protocol message type. Attributes: name (str): Name of this protocol message type. full_name (str): Fully-qualified name of this protocol message type, which will include protocol "package" name and the name of any enclosing types. containing_type (Descriptor): Reference to the descriptor of the type containing us, or None if this is top-level. fields (list[FieldDescriptor]): Field descriptors for all fields in this type. fields_by_number (dict(int, FieldDescriptor)): Same :class:`FieldDescriptor` objects as in :attr:`fields`, but indexed by "number" attribute in each FieldDescriptor. fields_by_name (dict(str, FieldDescriptor)): Same :class:`FieldDescriptor` objects as in :attr:`fields`, but indexed by "name" attribute in each :class:`FieldDescriptor`. nested_types (list[Descriptor]): Descriptor references for all protocol message types nested within this one. nested_types_by_name (dict(str, Descriptor)): Same Descriptor objects as in :attr:`nested_types`, but indexed by "name" attribute in each Descriptor. enum_types (list[EnumDescriptor]): :class:`EnumDescriptor` references for all enums contained within this type. enum_types_by_name (dict(str, EnumDescriptor)): Same :class:`EnumDescriptor` objects as in :attr:`enum_types`, but indexed by "name" attribute in each EnumDescriptor. enum_values_by_name (dict(str, EnumValueDescriptor)): Dict mapping from enum value name to :class:`EnumValueDescriptor` for that value. extensions (list[FieldDescriptor]): All extensions defined directly within this message type (NOT within a nested type). extensions_by_name (dict(str, FieldDescriptor)): Same FieldDescriptor objects as :attr:`extensions`, but indexed by "name" attribute of each FieldDescriptor. is_extendable (bool): Does this type define any extension ranges? oneofs (list[OneofDescriptor]): The list of descriptors for oneof fields in this message. oneofs_by_name (dict(str, OneofDescriptor)): Same objects as in :attr:`oneofs`, but indexed by "name" attribute. file (FileDescriptor): Reference to file descriptor. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.Descriptor def __new__( cls, name=None, full_name=None, filename=None, containing_type=None, fields=None, nested_types=None, enum_types=None, extensions=None, options=None, serialized_options=None, is_extendable=True, extension_ranges=None, oneofs=None, file=None, # pylint: disable=redefined-builtin serialized_start=None, serialized_end=None, syntax=None, create_key=None): _message.Message._CheckCalledFromGeneratedFile() return _message.default_pool.FindMessageTypeByName(full_name) # NOTE(tmarek): The file argument redefining a builtin is nothing we can # fix right now since we don't know how many clients already rely on the # name of the argument. def __init__(self, name, full_name, filename, containing_type, fields, nested_types, enum_types, extensions, options=None, serialized_options=None, is_extendable=True, extension_ranges=None, oneofs=None, file=None, serialized_start=None, serialized_end=None, # pylint: disable=redefined-builtin syntax=None, create_key=None): """Arguments to __init__() are as described in the description of Descriptor fields above. Note that filename is an obsolete argument, that is not used anymore. Please use file.name to access this as an attribute. """ if create_key is not _internal_create_key: _Deprecated('Descriptor') super(Descriptor, self).__init__( options, 'MessageOptions', name, full_name, file, containing_type, serialized_start=serialized_start, serialized_end=serialized_end, serialized_options=serialized_options) # We have fields in addition to fields_by_name and fields_by_number, # so that: # 1. Clients can index fields by "order in which they're listed." # 2. Clients can easily iterate over all fields with the terse # syntax: for f in descriptor.fields: ... self.fields = fields for field in self.fields: field.containing_type = self self.fields_by_number = dict((f.number, f) for f in fields) self.fields_by_name = dict((f.name, f) for f in fields) self._fields_by_camelcase_name = None self.nested_types = nested_types for nested_type in nested_types: nested_type.containing_type = self self.nested_types_by_name = dict((t.name, t) for t in nested_types) self.enum_types = enum_types for enum_type in self.enum_types: enum_type.containing_type = self self.enum_types_by_name = dict((t.name, t) for t in enum_types) self.enum_values_by_name = dict( (v.name, v) for t in enum_types for v in t.values) self.extensions = extensions for extension in self.extensions: extension.extension_scope = self self.extensions_by_name = dict((f.name, f) for f in extensions) self.is_extendable = is_extendable self.extension_ranges = extension_ranges self.oneofs = oneofs if oneofs is not None else [] self.oneofs_by_name = dict((o.name, o) for o in self.oneofs) for oneof in self.oneofs: oneof.containing_type = self self.syntax = syntax or "proto2" @property def fields_by_camelcase_name(self): """Same FieldDescriptor objects as in :attr:`fields`, but indexed by :attr:`FieldDescriptor.camelcase_name`. """ if self._fields_by_camelcase_name is None: self._fields_by_camelcase_name = dict( (f.camelcase_name, f) for f in self.fields) return self._fields_by_camelcase_name def EnumValueName(self, enum, value): """Returns the string name of an enum value. This is just a small helper method to simplify a common operation. Args: enum: string name of the Enum. value: int, value of the enum. Returns: string name of the enum value. Raises: KeyError if either the Enum doesn't exist or the value is not a valid value for the enum. """ return self.enum_types_by_name[enum].values_by_number[value].name def CopyToProto(self, proto): """Copies this to a descriptor_pb2.DescriptorProto. Args: proto: An empty descriptor_pb2.DescriptorProto. """ # This function is overridden to give a better doc comment. super(Descriptor, self).CopyToProto(proto) # TODO(robinson): We should have aggressive checking here, # for example: # * If you specify a repeated field, you should not be allowed # to specify a default value. # * [Other examples here as needed]. # # TODO(robinson): for this and other *Descriptor classes, we # might also want to lock things down aggressively (e.g., # prevent clients from setting the attributes). Having # stronger invariants here in general will reduce the number # of runtime checks we must do in reflection.py... class FieldDescriptor(DescriptorBase): """Descriptor for a single field in a .proto file. Attributes: name (str): Name of this field, exactly as it appears in .proto. full_name (str): Name of this field, including containing scope. This is particularly relevant for extensions. index (int): Dense, 0-indexed index giving the order that this field textually appears within its message in the .proto file. number (int): Tag number declared for this field in the .proto file. type (int): (One of the TYPE_* constants below) Declared type. cpp_type (int): (One of the CPPTYPE_* constants below) C++ type used to represent this field. label (int): (One of the LABEL_* constants below) Tells whether this field is optional, required, or repeated. has_default_value (bool): True if this field has a default value defined, otherwise false. default_value (Varies): Default value of this field. Only meaningful for non-repeated scalar fields. Repeated fields should always set this to [], and non-repeated composite fields should always set this to None. containing_type (Descriptor): Descriptor of the protocol message type that contains this field. Set by the Descriptor constructor if we're passed into one. Somewhat confusingly, for extension fields, this is the descriptor of the EXTENDED message, not the descriptor of the message containing this field. (See is_extension and extension_scope below). message_type (Descriptor): If a composite field, a descriptor of the message type contained in this field. Otherwise, this is None. enum_type (EnumDescriptor): If this field contains an enum, a descriptor of that enum. Otherwise, this is None. is_extension: True iff this describes an extension field. extension_scope (Descriptor): Only meaningful if is_extension is True. Gives the message that immediately contains this extension field. Will be None iff we're a top-level (file-level) extension field. options (descriptor_pb2.FieldOptions): Protocol message field options or None to use default field options. containing_oneof (OneofDescriptor): If the field is a member of a oneof union, contains its descriptor. Otherwise, None. file (FileDescriptor): Reference to file descriptor. """ # Must be consistent with C++ FieldDescriptor::Type enum in # descriptor.h. # # TODO(robinson): Find a way to eliminate this repetition. TYPE_DOUBLE = 1 TYPE_FLOAT = 2 TYPE_INT64 = 3 TYPE_UINT64 = 4 TYPE_INT32 = 5 TYPE_FIXED64 = 6 TYPE_FIXED32 = 7 TYPE_BOOL = 8 TYPE_STRING = 9 TYPE_GROUP = 10 TYPE_MESSAGE = 11 TYPE_BYTES = 12 TYPE_UINT32 = 13 TYPE_ENUM = 14 TYPE_SFIXED32 = 15 TYPE_SFIXED64 = 16 TYPE_SINT32 = 17 TYPE_SINT64 = 18 MAX_TYPE = 18 # Must be consistent with C++ FieldDescriptor::CppType enum in # descriptor.h. # # TODO(robinson): Find a way to eliminate this repetition. CPPTYPE_INT32 = 1 CPPTYPE_INT64 = 2 CPPTYPE_UINT32 = 3 CPPTYPE_UINT64 = 4 CPPTYPE_DOUBLE = 5 CPPTYPE_FLOAT = 6 CPPTYPE_BOOL = 7 CPPTYPE_ENUM = 8 CPPTYPE_STRING = 9 CPPTYPE_MESSAGE = 10 MAX_CPPTYPE = 10 _PYTHON_TO_CPP_PROTO_TYPE_MAP = { TYPE_DOUBLE: CPPTYPE_DOUBLE, TYPE_FLOAT: CPPTYPE_FLOAT, TYPE_ENUM: CPPTYPE_ENUM, TYPE_INT64: CPPTYPE_INT64, TYPE_SINT64: CPPTYPE_INT64, TYPE_SFIXED64: CPPTYPE_INT64, TYPE_UINT64: CPPTYPE_UINT64, TYPE_FIXED64: CPPTYPE_UINT64, TYPE_INT32: CPPTYPE_INT32, TYPE_SFIXED32: CPPTYPE_INT32, TYPE_SINT32: CPPTYPE_INT32, TYPE_UINT32: CPPTYPE_UINT32, TYPE_FIXED32: CPPTYPE_UINT32, TYPE_BYTES: CPPTYPE_STRING, TYPE_STRING: CPPTYPE_STRING, TYPE_BOOL: CPPTYPE_BOOL, TYPE_MESSAGE: CPPTYPE_MESSAGE, TYPE_GROUP: CPPTYPE_MESSAGE } # Must be consistent with C++ FieldDescriptor::Label enum in # descriptor.h. # # TODO(robinson): Find a way to eliminate this repetition. LABEL_OPTIONAL = 1 LABEL_REQUIRED = 2 LABEL_REPEATED = 3 MAX_LABEL = 3 # Must be consistent with C++ constants kMaxNumber, kFirstReservedNumber, # and kLastReservedNumber in descriptor.h MAX_FIELD_NUMBER = (1 << 29) - 1 FIRST_RESERVED_FIELD_NUMBER = 19000 LAST_RESERVED_FIELD_NUMBER = 19999 if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.FieldDescriptor def __new__(cls, name, full_name, index, number, type, cpp_type, label, default_value, message_type, enum_type, containing_type, is_extension, extension_scope, options=None, serialized_options=None, has_default_value=True, containing_oneof=None, json_name=None, file=None, create_key=None): # pylint: disable=redefined-builtin _message.Message._CheckCalledFromGeneratedFile() if is_extension: return _message.default_pool.FindExtensionByName(full_name) else: return _message.default_pool.FindFieldByName(full_name) def __init__(self, name, full_name, index, number, type, cpp_type, label, default_value, message_type, enum_type, containing_type, is_extension, extension_scope, options=None, serialized_options=None, has_default_value=True, containing_oneof=None, json_name=None, file=None, create_key=None): # pylint: disable=redefined-builtin """The arguments are as described in the description of FieldDescriptor attributes above. Note that containing_type may be None, and may be set later if necessary (to deal with circular references between message types, for example). Likewise for extension_scope. """ if create_key is not _internal_create_key: _Deprecated('FieldDescriptor') super(FieldDescriptor, self).__init__( options, serialized_options, 'FieldOptions') self.name = name self.full_name = full_name self.file = file self._camelcase_name = None if json_name is None: self.json_name = _ToJsonName(name) else: self.json_name = json_name self.index = index self.number = number self.type = type self.cpp_type = cpp_type self.label = label self.has_default_value = has_default_value self.default_value = default_value self.containing_type = containing_type self.message_type = message_type self.enum_type = enum_type self.is_extension = is_extension self.extension_scope = extension_scope self.containing_oneof = containing_oneof if api_implementation.Type() == 'cpp': if is_extension: self._cdescriptor = _message.default_pool.FindExtensionByName(full_name) else: self._cdescriptor = _message.default_pool.FindFieldByName(full_name) else: self._cdescriptor = None @property def camelcase_name(self): """Camelcase name of this field. Returns: str: the name in CamelCase. """ if self._camelcase_name is None: self._camelcase_name = _ToCamelCase(self.name) return self._camelcase_name @property def has_presence(self): """Whether the field distinguishes between unpopulated and default values. Raises: RuntimeError: singular field that is not linked with message nor file. """ if self.label == FieldDescriptor.LABEL_REPEATED: return False if (self.cpp_type == FieldDescriptor.CPPTYPE_MESSAGE or self.containing_oneof): return True if hasattr(self.file, 'syntax'): return self.file.syntax == 'proto2' if hasattr(self.message_type, 'syntax'): return self.message_type.syntax == 'proto2' raise RuntimeError( 'has_presence is not ready to use because field %s is not' ' linked with message type nor file' % self.full_name) @staticmethod def ProtoTypeToCppProtoType(proto_type): """Converts from a Python proto type to a C++ Proto Type. The Python ProtocolBuffer classes specify both the 'Python' datatype and the 'C++' datatype - and they're not the same. This helper method should translate from one to another. Args: proto_type: the Python proto type (descriptor.FieldDescriptor.TYPE_*) Returns: int: descriptor.FieldDescriptor.CPPTYPE_*, the C++ type. Raises: TypeTransformationError: when the Python proto type isn't known. """ try: return FieldDescriptor._PYTHON_TO_CPP_PROTO_TYPE_MAP[proto_type] except KeyError: raise TypeTransformationError('Unknown proto_type: %s' % proto_type) class EnumDescriptor(_NestedDescriptorBase): """Descriptor for an enum defined in a .proto file. Attributes: name (str): Name of the enum type. full_name (str): Full name of the type, including package name and any enclosing type(s). values (list[EnumValueDescriptor]): List of the values in this enum. values_by_name (dict(str, EnumValueDescriptor)): Same as :attr:`values`, but indexed by the "name" field of each EnumValueDescriptor. values_by_number (dict(int, EnumValueDescriptor)): Same as :attr:`values`, but indexed by the "number" field of each EnumValueDescriptor. containing_type (Descriptor): Descriptor of the immediate containing type of this enum, or None if this is an enum defined at the top level in a .proto file. Set by Descriptor's constructor if we're passed into one. file (FileDescriptor): Reference to file descriptor. options (descriptor_pb2.EnumOptions): Enum options message or None to use default enum options. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.EnumDescriptor def __new__(cls, name, full_name, filename, values, containing_type=None, options=None, serialized_options=None, file=None, # pylint: disable=redefined-builtin serialized_start=None, serialized_end=None, create_key=None): _message.Message._CheckCalledFromGeneratedFile() return _message.default_pool.FindEnumTypeByName(full_name) def __init__(self, name, full_name, filename, values, containing_type=None, options=None, serialized_options=None, file=None, # pylint: disable=redefined-builtin serialized_start=None, serialized_end=None, create_key=None): """Arguments are as described in the attribute description above. Note that filename is an obsolete argument, that is not used anymore. Please use file.name to access this as an attribute. """ if create_key is not _internal_create_key: _Deprecated('EnumDescriptor') super(EnumDescriptor, self).__init__( options, 'EnumOptions', name, full_name, file, containing_type, serialized_start=serialized_start, serialized_end=serialized_end, serialized_options=serialized_options) self.values = values for value in self.values: value.type = self self.values_by_name = dict((v.name, v) for v in values) # Values are reversed to ensure that the first alias is retained. self.values_by_number = dict((v.number, v) for v in reversed(values)) def CopyToProto(self, proto): """Copies this to a descriptor_pb2.EnumDescriptorProto. Args: proto (descriptor_pb2.EnumDescriptorProto): An empty descriptor proto. """ # This function is overridden to give a better doc comment. super(EnumDescriptor, self).CopyToProto(proto) class EnumValueDescriptor(DescriptorBase): """Descriptor for a single value within an enum. Attributes: name (str): Name of this value. index (int): Dense, 0-indexed index giving the order that this value appears textually within its enum in the .proto file. number (int): Actual number assigned to this enum value. type (EnumDescriptor): :class:`EnumDescriptor` to which this value belongs. Set by :class:`EnumDescriptor`'s constructor if we're passed into one. options (descriptor_pb2.EnumValueOptions): Enum value options message or None to use default enum value options options. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.EnumValueDescriptor def __new__(cls, name, index, number, type=None, # pylint: disable=redefined-builtin options=None, serialized_options=None, create_key=None): _message.Message._CheckCalledFromGeneratedFile() # There is no way we can build a complete EnumValueDescriptor with the # given parameters (the name of the Enum is not known, for example). # Fortunately generated files just pass it to the EnumDescriptor() # constructor, which will ignore it, so returning None is good enough. return None def __init__(self, name, index, number, type=None, # pylint: disable=redefined-builtin options=None, serialized_options=None, create_key=None): """Arguments are as described in the attribute description above.""" if create_key is not _internal_create_key: _Deprecated('EnumValueDescriptor') super(EnumValueDescriptor, self).__init__( options, serialized_options, 'EnumValueOptions') self.name = name self.index = index self.number = number self.type = type class OneofDescriptor(DescriptorBase): """Descriptor for a oneof field. Attributes: name (str): Name of the oneof field. full_name (str): Full name of the oneof field, including package name. index (int): 0-based index giving the order of the oneof field inside its containing type. containing_type (Descriptor): :class:`Descriptor` of the protocol message type that contains this field. Set by the :class:`Descriptor` constructor if we're passed into one. fields (list[FieldDescriptor]): The list of field descriptors this oneof can contain. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.OneofDescriptor def __new__( cls, name, full_name, index, containing_type, fields, options=None, serialized_options=None, create_key=None): _message.Message._CheckCalledFromGeneratedFile() return _message.default_pool.FindOneofByName(full_name) def __init__( self, name, full_name, index, containing_type, fields, options=None, serialized_options=None, create_key=None): """Arguments are as described in the attribute description above.""" if create_key is not _internal_create_key: _Deprecated('OneofDescriptor') super(OneofDescriptor, self).__init__( options, serialized_options, 'OneofOptions') self.name = name self.full_name = full_name self.index = index self.containing_type = containing_type self.fields = fields class ServiceDescriptor(_NestedDescriptorBase): """Descriptor for a service. Attributes: name (str): Name of the service. full_name (str): Full name of the service, including package name. index (int): 0-indexed index giving the order that this services definition appears within the .proto file. methods (list[MethodDescriptor]): List of methods provided by this service. methods_by_name (dict(str, MethodDescriptor)): Same :class:`MethodDescriptor` objects as in :attr:`methods_by_name`, but indexed by "name" attribute in each :class:`MethodDescriptor`. options (descriptor_pb2.ServiceOptions): Service options message or None to use default service options. file (FileDescriptor): Reference to file info. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.ServiceDescriptor def __new__( cls, name=None, full_name=None, index=None, methods=None, options=None, serialized_options=None, file=None, # pylint: disable=redefined-builtin serialized_start=None, serialized_end=None, create_key=None): _message.Message._CheckCalledFromGeneratedFile() # pylint: disable=protected-access return _message.default_pool.FindServiceByName(full_name) def __init__(self, name, full_name, index, methods, options=None, serialized_options=None, file=None, # pylint: disable=redefined-builtin serialized_start=None, serialized_end=None, create_key=None): if create_key is not _internal_create_key: _Deprecated('ServiceDescriptor') super(ServiceDescriptor, self).__init__( options, 'ServiceOptions', name, full_name, file, None, serialized_start=serialized_start, serialized_end=serialized_end, serialized_options=serialized_options) self.index = index self.methods = methods self.methods_by_name = dict((m.name, m) for m in methods) # Set the containing service for each method in this service. for method in self.methods: method.containing_service = self def FindMethodByName(self, name): """Searches for the specified method, and returns its descriptor. Args: name (str): Name of the method. Returns: MethodDescriptor or None: the descriptor for the requested method, if found. """ return self.methods_by_name.get(name, None) def CopyToProto(self, proto): """Copies this to a descriptor_pb2.ServiceDescriptorProto. Args: proto (descriptor_pb2.ServiceDescriptorProto): An empty descriptor proto. """ # This function is overridden to give a better doc comment. super(ServiceDescriptor, self).CopyToProto(proto) class MethodDescriptor(DescriptorBase): """Descriptor for a method in a service. Attributes: name (str): Name of the method within the service. full_name (str): Full name of method. index (int): 0-indexed index of the method inside the service. containing_service (ServiceDescriptor): The service that contains this method. input_type (Descriptor): The descriptor of the message that this method accepts. output_type (Descriptor): The descriptor of the message that this method returns. client_streaming (bool): Whether this method uses client streaming. server_streaming (bool): Whether this method uses server streaming. options (descriptor_pb2.MethodOptions or None): Method options message, or None to use default method options. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.MethodDescriptor def __new__(cls, name, full_name, index, containing_service, input_type, output_type, client_streaming=False, server_streaming=False, options=None, serialized_options=None, create_key=None): _message.Message._CheckCalledFromGeneratedFile() # pylint: disable=protected-access return _message.default_pool.FindMethodByName(full_name) def __init__(self, name, full_name, index, containing_service, input_type, output_type, client_streaming=False, server_streaming=False, options=None, serialized_options=None, create_key=None): """The arguments are as described in the description of MethodDescriptor attributes above. Note that containing_service may be None, and may be set later if necessary. """ if create_key is not _internal_create_key: _Deprecated('MethodDescriptor') super(MethodDescriptor, self).__init__( options, serialized_options, 'MethodOptions') self.name = name self.full_name = full_name self.index = index self.containing_service = containing_service self.input_type = input_type self.output_type = output_type self.client_streaming = client_streaming self.server_streaming = server_streaming def CopyToProto(self, proto): """Copies this to a descriptor_pb2.MethodDescriptorProto. Args: proto (descriptor_pb2.MethodDescriptorProto): An empty descriptor proto. Raises: Error: If self couldn't be serialized, due to too few constructor arguments. """ if self.containing_service is not None: from google.protobuf import descriptor_pb2 service_proto = descriptor_pb2.ServiceDescriptorProto() self.containing_service.CopyToProto(service_proto) proto.CopyFrom(service_proto.method[self.index]) else: raise Error('Descriptor does not contain a service.') class FileDescriptor(DescriptorBase): """Descriptor for a file. Mimics the descriptor_pb2.FileDescriptorProto. Note that :attr:`enum_types_by_name`, :attr:`extensions_by_name`, and :attr:`dependencies` fields are only set by the :py:mod:`google.protobuf.message_factory` module, and not by the generated proto code. Attributes: name (str): Name of file, relative to root of source tree. package (str): Name of the package syntax (str): string indicating syntax of the file (can be "proto2" or "proto3") serialized_pb (bytes): Byte string of serialized :class:`descriptor_pb2.FileDescriptorProto`. dependencies (list[FileDescriptor]): List of other :class:`FileDescriptor` objects this :class:`FileDescriptor` depends on. public_dependencies (list[FileDescriptor]): A subset of :attr:`dependencies`, which were declared as "public". message_types_by_name (dict(str, Descriptor)): Mapping from message names to their :class:`Descriptor`. enum_types_by_name (dict(str, EnumDescriptor)): Mapping from enum names to their :class:`EnumDescriptor`. extensions_by_name (dict(str, FieldDescriptor)): Mapping from extension names declared at file scope to their :class:`FieldDescriptor`. services_by_name (dict(str, ServiceDescriptor)): Mapping from services' names to their :class:`ServiceDescriptor`. pool (DescriptorPool): The pool this descriptor belongs to. When not passed to the constructor, the global default pool is used. """ if _USE_C_DESCRIPTORS: _C_DESCRIPTOR_CLASS = _message.FileDescriptor def __new__(cls, name, package, options=None, serialized_options=None, serialized_pb=None, dependencies=None, public_dependencies=None, syntax=None, pool=None, create_key=None): # FileDescriptor() is called from various places, not only from generated # files, to register dynamic proto files and messages. # pylint: disable=g-explicit-bool-comparison if serialized_pb == b'': # Cpp generated code must be linked in if serialized_pb is '' try: return _message.default_pool.FindFileByName(name) except KeyError: raise RuntimeError('Please link in cpp generated lib for %s' % (name)) elif serialized_pb: return _message.default_pool.AddSerializedFile(serialized_pb) else: return super(FileDescriptor, cls).__new__(cls) def __init__(self, name, package, options=None, serialized_options=None, serialized_pb=None, dependencies=None, public_dependencies=None, syntax=None, pool=None, create_key=None): """Constructor.""" if create_key is not _internal_create_key: _Deprecated('FileDescriptor') super(FileDescriptor, self).__init__( options, serialized_options, 'FileOptions') if pool is None: from google.protobuf import descriptor_pool pool = descriptor_pool.Default() self.pool = pool self.message_types_by_name = {} self.name = name self.package = package self.syntax = syntax or "proto2" self.serialized_pb = serialized_pb self.enum_types_by_name = {} self.extensions_by_name = {} self.services_by_name = {} self.dependencies = (dependencies or []) self.public_dependencies = (public_dependencies or []) def CopyToProto(self, proto): """Copies this to a descriptor_pb2.FileDescriptorProto. Args: proto: An empty descriptor_pb2.FileDescriptorProto. """ proto.ParseFromString(self.serialized_pb) def _ParseOptions(message, string): """Parses serialized options. This helper function is used to parse serialized options in generated proto2 files. It must not be used outside proto2. """ message.ParseFromString(string) return message def _ToCamelCase(name): """Converts name to camel-case and returns it.""" capitalize_next = False result = [] for c in name: if c == '_': if result: capitalize_next = True elif capitalize_next: result.append(c.upper()) capitalize_next = False else: result += c # Lower-case the first letter. if result and result[0].isupper(): result[0] = result[0].lower() return ''.join(result) def _OptionsOrNone(descriptor_proto): """Returns the value of the field `options`, or None if it is not set.""" if descriptor_proto.HasField('options'): return descriptor_proto.options else: return None def _ToJsonName(name): """Converts name to Json name and returns it.""" capitalize_next = False result = [] for c in name: if c == '_': capitalize_next = True elif capitalize_next: result.append(c.upper()) capitalize_next = False else: result += c return ''.join(result) def MakeDescriptor(desc_proto, package='', build_file_if_cpp=True, syntax=None): """Make a protobuf Descriptor given a DescriptorProto protobuf. Handles nested descriptors. Note that this is limited to the scope of defining a message inside of another message. Composite fields can currently only be resolved if the message is defined in the same scope as the field. Args: desc_proto: The descriptor_pb2.DescriptorProto protobuf message. package: Optional package name for the new message Descriptor (string). build_file_if_cpp: Update the C++ descriptor pool if api matches. Set to False on recursion, so no duplicates are created. syntax: The syntax/semantics that should be used. Set to "proto3" to get proto3 field presence semantics. Returns: A Descriptor for protobuf messages. """ if api_implementation.Type() == 'cpp' and build_file_if_cpp: # The C++ implementation requires all descriptors to be backed by the same # definition in the C++ descriptor pool. To do this, we build a # FileDescriptorProto with the same definition as this descriptor and build # it into the pool. from google.protobuf import descriptor_pb2 file_descriptor_proto = descriptor_pb2.FileDescriptorProto() file_descriptor_proto.message_type.add().MergeFrom(desc_proto) # Generate a random name for this proto file to prevent conflicts with any # imported ones. We need to specify a file name so the descriptor pool # accepts our FileDescriptorProto, but it is not important what that file # name is actually set to. proto_name = binascii.hexlify(os.urandom(16)).decode('ascii') if package: file_descriptor_proto.name = os.path.join(package.replace('.', '/'), proto_name + '.proto') file_descriptor_proto.package = package else: file_descriptor_proto.name = proto_name + '.proto' _message.default_pool.Add(file_descriptor_proto) result = _message.default_pool.FindFileByName(file_descriptor_proto.name) if _USE_C_DESCRIPTORS: return result.message_types_by_name[desc_proto.name] full_message_name = [desc_proto.name] if package: full_message_name.insert(0, package) # Create Descriptors for enum types enum_types = {} for enum_proto in desc_proto.enum_type: full_name = '.'.join(full_message_name + [enum_proto.name]) enum_desc = EnumDescriptor( enum_proto.name, full_name, None, [ EnumValueDescriptor(enum_val.name, ii, enum_val.number, create_key=_internal_create_key) for ii, enum_val in enumerate(enum_proto.value)], create_key=_internal_create_key) enum_types[full_name] = enum_desc # Create Descriptors for nested types nested_types = {} for nested_proto in desc_proto.nested_type: full_name = '.'.join(full_message_name + [nested_proto.name]) # Nested types are just those defined inside of the message, not all types # used by fields in the message, so no loops are possible here. nested_desc = MakeDescriptor(nested_proto, package='.'.join(full_message_name), build_file_if_cpp=False, syntax=syntax) nested_types[full_name] = nested_desc fields = [] for field_proto in desc_proto.field: full_name = '.'.join(full_message_name + [field_proto.name]) enum_desc = None nested_desc = None if field_proto.json_name: json_name = field_proto.json_name else: json_name = None if field_proto.HasField('type_name'): type_name = field_proto.type_name full_type_name = '.'.join(full_message_name + [type_name[type_name.rfind('.')+1:]]) if full_type_name in nested_types: nested_desc = nested_types[full_type_name] elif full_type_name in enum_types: enum_desc = enum_types[full_type_name] # Else type_name references a non-local type, which isn't implemented field = FieldDescriptor( field_proto.name, full_name, field_proto.number - 1, field_proto.number, field_proto.type, FieldDescriptor.ProtoTypeToCppProtoType(field_proto.type), field_proto.label, None, nested_desc, enum_desc, None, False, None, options=_OptionsOrNone(field_proto), has_default_value=False, json_name=json_name, create_key=_internal_create_key) fields.append(field) desc_name = '.'.join(full_message_name) return Descriptor(desc_proto.name, desc_name, None, None, fields, list(nested_types.values()), list(enum_types.values()), [], options=_OptionsOrNone(desc_proto), create_key=_internal_create_key) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/descriptor_database.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Provides a container for DescriptorProtos.""" __author__ = 'matthewtoia@google.com (Matt Toia)' import warnings class Error(Exception): pass class DescriptorDatabaseConflictingDefinitionError(Error): """Raised when a proto is added with the same name & different descriptor.""" class DescriptorDatabase(object): """A container accepting FileDescriptorProtos and maps DescriptorProtos.""" def __init__(self): self._file_desc_protos_by_file = {} self._file_desc_protos_by_symbol = {} def Add(self, file_desc_proto): """Adds the FileDescriptorProto and its types to this database. Args: file_desc_proto: The FileDescriptorProto to add. Raises: DescriptorDatabaseConflictingDefinitionError: if an attempt is made to add a proto with the same name but different definition than an existing proto in the database. """ proto_name = file_desc_proto.name if proto_name not in self._file_desc_protos_by_file: self._file_desc_protos_by_file[proto_name] = file_desc_proto elif self._file_desc_protos_by_file[proto_name] != file_desc_proto: raise DescriptorDatabaseConflictingDefinitionError( '%s already added, but with different descriptor.' % proto_name) else: return # Add all the top-level descriptors to the index. package = file_desc_proto.package for message in file_desc_proto.message_type: for name in _ExtractSymbols(message, package): self._AddSymbol(name, file_desc_proto) for enum in file_desc_proto.enum_type: self._AddSymbol(('.'.join((package, enum.name))), file_desc_proto) for enum_value in enum.value: self._file_desc_protos_by_symbol[ '.'.join((package, enum_value.name))] = file_desc_proto for extension in file_desc_proto.extension: self._AddSymbol(('.'.join((package, extension.name))), file_desc_proto) for service in file_desc_proto.service: self._AddSymbol(('.'.join((package, service.name))), file_desc_proto) def FindFileByName(self, name): """Finds the file descriptor proto by file name. Typically the file name is a relative path ending to a .proto file. The proto with the given name will have to have been added to this database using the Add method or else an error will be raised. Args: name: The file name to find. Returns: The file descriptor proto matching the name. Raises: KeyError if no file by the given name was added. """ return self._file_desc_protos_by_file[name] def FindFileContainingSymbol(self, symbol): """Finds the file descriptor proto containing the specified symbol. The symbol should be a fully qualified name including the file descriptor's package and any containing messages. Some examples: 'some.package.name.Message' 'some.package.name.Message.NestedEnum' 'some.package.name.Message.some_field' The file descriptor proto containing the specified symbol must be added to this database using the Add method or else an error will be raised. Args: symbol: The fully qualified symbol name. Returns: The file descriptor proto containing the symbol. Raises: KeyError if no file contains the specified symbol. """ try: return self._file_desc_protos_by_symbol[symbol] except KeyError: # Fields, enum values, and nested extensions are not in # _file_desc_protos_by_symbol. Try to find the top level # descriptor. Non-existent nested symbol under a valid top level # descriptor can also be found. The behavior is the same with # protobuf C++. top_level, _, _ = symbol.rpartition('.') try: return self._file_desc_protos_by_symbol[top_level] except KeyError: # Raise the original symbol as a KeyError for better diagnostics. raise KeyError(symbol) def FindFileContainingExtension(self, extendee_name, extension_number): # TODO(jieluo): implement this API. return None def FindAllExtensionNumbers(self, extendee_name): # TODO(jieluo): implement this API. return [] def _AddSymbol(self, name, file_desc_proto): if name in self._file_desc_protos_by_symbol: warn_msg = ('Conflict register for file "' + file_desc_proto.name + '": ' + name + ' is already defined in file "' + self._file_desc_protos_by_symbol[name].name + '"') warnings.warn(warn_msg, RuntimeWarning) self._file_desc_protos_by_symbol[name] = file_desc_proto def _ExtractSymbols(desc_proto, package): """Pulls out all the symbols from a descriptor proto. Args: desc_proto: The proto to extract symbols from. package: The package containing the descriptor type. Yields: The fully qualified name found in the descriptor. """ message_name = package + '.' + desc_proto.name if package else desc_proto.name yield message_name for nested_type in desc_proto.nested_type: for symbol in _ExtractSymbols(nested_type, message_name): yield symbol for enum_type in desc_proto.enum_type: yield '.'.join((message_name, enum_type.name)) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/descriptor_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/descriptor.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR = _descriptor.FileDescriptor( name='google/protobuf/descriptor.proto', package='google.protobuf', syntax='proto2', serialized_options=None, create_key=_descriptor._internal_create_key, serialized_pb=b'\n google/protobuf/descriptor.proto\x12\x0fgoogle.protobuf\"G\n\x11\x46ileDescriptorSet\x12\x32\n\x04\x66ile\x18\x01 \x03(\x0b\x32$.google.protobuf.FileDescriptorProto\"\xdb\x03\n\x13\x46ileDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07package\x18\x02 \x01(\t\x12\x12\n\ndependency\x18\x03 \x03(\t\x12\x19\n\x11public_dependency\x18\n \x03(\x05\x12\x17\n\x0fweak_dependency\x18\x0b \x03(\x05\x12\x36\n\x0cmessage_type\x18\x04 \x03(\x0b\x32 .google.protobuf.DescriptorProto\x12\x37\n\tenum_type\x18\x05 \x03(\x0b\x32$.google.protobuf.EnumDescriptorProto\x12\x38\n\x07service\x18\x06 \x03(\x0b\x32\'.google.protobuf.ServiceDescriptorProto\x12\x38\n\textension\x18\x07 \x03(\x0b\x32%.google.protobuf.FieldDescriptorProto\x12-\n\x07options\x18\x08 \x01(\x0b\x32\x1c.google.protobuf.FileOptions\x12\x39\n\x10source_code_info\x18\t \x01(\x0b\x32\x1f.google.protobuf.SourceCodeInfo\x12\x0e\n\x06syntax\x18\x0c \x01(\t\"\xa9\x05\n\x0f\x44\x65scriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x34\n\x05\x66ield\x18\x02 \x03(\x0b\x32%.google.protobuf.FieldDescriptorProto\x12\x38\n\textension\x18\x06 \x03(\x0b\x32%.google.protobuf.FieldDescriptorProto\x12\x35\n\x0bnested_type\x18\x03 \x03(\x0b\x32 .google.protobuf.DescriptorProto\x12\x37\n\tenum_type\x18\x04 \x03(\x0b\x32$.google.protobuf.EnumDescriptorProto\x12H\n\x0f\x65xtension_range\x18\x05 \x03(\x0b\x32/.google.protobuf.DescriptorProto.ExtensionRange\x12\x39\n\noneof_decl\x18\x08 \x03(\x0b\x32%.google.protobuf.OneofDescriptorProto\x12\x30\n\x07options\x18\x07 \x01(\x0b\x32\x1f.google.protobuf.MessageOptions\x12\x46\n\x0ereserved_range\x18\t \x03(\x0b\x32..google.protobuf.DescriptorProto.ReservedRange\x12\x15\n\rreserved_name\x18\n \x03(\t\x1a\x65\n\x0e\x45xtensionRange\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\x12\x37\n\x07options\x18\x03 \x01(\x0b\x32&.google.protobuf.ExtensionRangeOptions\x1a+\n\rReservedRange\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\"g\n\x15\x45xtensionRangeOptions\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\xd5\x05\n\x14\x46ieldDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06number\x18\x03 \x01(\x05\x12:\n\x05label\x18\x04 \x01(\x0e\x32+.google.protobuf.FieldDescriptorProto.Label\x12\x38\n\x04type\x18\x05 \x01(\x0e\x32*.google.protobuf.FieldDescriptorProto.Type\x12\x11\n\ttype_name\x18\x06 \x01(\t\x12\x10\n\x08\x65xtendee\x18\x02 \x01(\t\x12\x15\n\rdefault_value\x18\x07 \x01(\t\x12\x13\n\x0boneof_index\x18\t \x01(\x05\x12\x11\n\tjson_name\x18\n \x01(\t\x12.\n\x07options\x18\x08 \x01(\x0b\x32\x1d.google.protobuf.FieldOptions\x12\x17\n\x0fproto3_optional\x18\x11 \x01(\x08\"\xb6\x02\n\x04Type\x12\x0f\n\x0bTYPE_DOUBLE\x10\x01\x12\x0e\n\nTYPE_FLOAT\x10\x02\x12\x0e\n\nTYPE_INT64\x10\x03\x12\x0f\n\x0bTYPE_UINT64\x10\x04\x12\x0e\n\nTYPE_INT32\x10\x05\x12\x10\n\x0cTYPE_FIXED64\x10\x06\x12\x10\n\x0cTYPE_FIXED32\x10\x07\x12\r\n\tTYPE_BOOL\x10\x08\x12\x0f\n\x0bTYPE_STRING\x10\t\x12\x0e\n\nTYPE_GROUP\x10\n\x12\x10\n\x0cTYPE_MESSAGE\x10\x0b\x12\x0e\n\nTYPE_BYTES\x10\x0c\x12\x0f\n\x0bTYPE_UINT32\x10\r\x12\r\n\tTYPE_ENUM\x10\x0e\x12\x11\n\rTYPE_SFIXED32\x10\x0f\x12\x11\n\rTYPE_SFIXED64\x10\x10\x12\x0f\n\x0bTYPE_SINT32\x10\x11\x12\x0f\n\x0bTYPE_SINT64\x10\x12\"C\n\x05Label\x12\x12\n\x0eLABEL_OPTIONAL\x10\x01\x12\x12\n\x0eLABEL_REQUIRED\x10\x02\x12\x12\n\x0eLABEL_REPEATED\x10\x03\"T\n\x14OneofDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12.\n\x07options\x18\x02 \x01(\x0b\x32\x1d.google.protobuf.OneofOptions\"\xa4\x02\n\x13\x45numDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x38\n\x05value\x18\x02 \x03(\x0b\x32).google.protobuf.EnumValueDescriptorProto\x12-\n\x07options\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.EnumOptions\x12N\n\x0ereserved_range\x18\x04 \x03(\x0b\x32\x36.google.protobuf.EnumDescriptorProto.EnumReservedRange\x12\x15\n\rreserved_name\x18\x05 \x03(\t\x1a/\n\x11\x45numReservedRange\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\"l\n\x18\x45numValueDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06number\x18\x02 \x01(\x05\x12\x32\n\x07options\x18\x03 \x01(\x0b\x32!.google.protobuf.EnumValueOptions\"\x90\x01\n\x16ServiceDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x36\n\x06method\x18\x02 \x03(\x0b\x32&.google.protobuf.MethodDescriptorProto\x12\x30\n\x07options\x18\x03 \x01(\x0b\x32\x1f.google.protobuf.ServiceOptions\"\xc1\x01\n\x15MethodDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ninput_type\x18\x02 \x01(\t\x12\x13\n\x0boutput_type\x18\x03 \x01(\t\x12/\n\x07options\x18\x04 \x01(\x0b\x32\x1e.google.protobuf.MethodOptions\x12\x1f\n\x10\x63lient_streaming\x18\x05 \x01(\x08:\x05\x66\x61lse\x12\x1f\n\x10server_streaming\x18\x06 \x01(\x08:\x05\x66\x61lse\"\xa5\x06\n\x0b\x46ileOptions\x12\x14\n\x0cjava_package\x18\x01 \x01(\t\x12\x1c\n\x14java_outer_classname\x18\x08 \x01(\t\x12\"\n\x13java_multiple_files\x18\n \x01(\x08:\x05\x66\x61lse\x12)\n\x1djava_generate_equals_and_hash\x18\x14 \x01(\x08\x42\x02\x18\x01\x12%\n\x16java_string_check_utf8\x18\x1b \x01(\x08:\x05\x66\x61lse\x12\x46\n\x0coptimize_for\x18\t \x01(\x0e\x32).google.protobuf.FileOptions.OptimizeMode:\x05SPEED\x12\x12\n\ngo_package\x18\x0b \x01(\t\x12\"\n\x13\x63\x63_generic_services\x18\x10 \x01(\x08:\x05\x66\x61lse\x12$\n\x15java_generic_services\x18\x11 \x01(\x08:\x05\x66\x61lse\x12\"\n\x13py_generic_services\x18\x12 \x01(\x08:\x05\x66\x61lse\x12#\n\x14php_generic_services\x18* \x01(\x08:\x05\x66\x61lse\x12\x19\n\ndeprecated\x18\x17 \x01(\x08:\x05\x66\x61lse\x12\x1e\n\x10\x63\x63_enable_arenas\x18\x1f \x01(\x08:\x04true\x12\x19\n\x11objc_class_prefix\x18$ \x01(\t\x12\x18\n\x10\x63sharp_namespace\x18% \x01(\t\x12\x14\n\x0cswift_prefix\x18\' \x01(\t\x12\x18\n\x10php_class_prefix\x18( \x01(\t\x12\x15\n\rphp_namespace\x18) \x01(\t\x12\x1e\n\x16php_metadata_namespace\x18, \x01(\t\x12\x14\n\x0cruby_package\x18- \x01(\t\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption\":\n\x0cOptimizeMode\x12\t\n\x05SPEED\x10\x01\x12\r\n\tCODE_SIZE\x10\x02\x12\x10\n\x0cLITE_RUNTIME\x10\x03*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08&\x10\'\"\x84\x02\n\x0eMessageOptions\x12&\n\x17message_set_wire_format\x18\x01 \x01(\x08:\x05\x66\x61lse\x12.\n\x1fno_standard_descriptor_accessor\x18\x02 \x01(\x08:\x05\x66\x61lse\x12\x19\n\ndeprecated\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\x11\n\tmap_entry\x18\x07 \x01(\x08\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08\x04\x10\x05J\x04\x08\x05\x10\x06J\x04\x08\x06\x10\x07J\x04\x08\x08\x10\tJ\x04\x08\t\x10\n\"\xbe\x03\n\x0c\x46ieldOptions\x12:\n\x05\x63type\x18\x01 \x01(\x0e\x32#.google.protobuf.FieldOptions.CType:\x06STRING\x12\x0e\n\x06packed\x18\x02 \x01(\x08\x12?\n\x06jstype\x18\x06 \x01(\x0e\x32$.google.protobuf.FieldOptions.JSType:\tJS_NORMAL\x12\x13\n\x04lazy\x18\x05 \x01(\x08:\x05\x66\x61lse\x12\x1e\n\x0funverified_lazy\x18\x0f \x01(\x08:\x05\x66\x61lse\x12\x19\n\ndeprecated\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\x13\n\x04weak\x18\n \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption\"/\n\x05\x43Type\x12\n\n\x06STRING\x10\x00\x12\x08\n\x04\x43ORD\x10\x01\x12\x10\n\x0cSTRING_PIECE\x10\x02\"5\n\x06JSType\x12\r\n\tJS_NORMAL\x10\x00\x12\r\n\tJS_STRING\x10\x01\x12\r\n\tJS_NUMBER\x10\x02*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08\x04\x10\x05\"^\n\x0cOneofOptions\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\x93\x01\n\x0b\x45numOptions\x12\x13\n\x0b\x61llow_alias\x18\x02 \x01(\x08\x12\x19\n\ndeprecated\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08\x05\x10\x06\"}\n\x10\x45numValueOptions\x12\x19\n\ndeprecated\x18\x01 \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"{\n\x0eServiceOptions\x12\x19\n\ndeprecated\x18! \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\xad\x02\n\rMethodOptions\x12\x19\n\ndeprecated\x18! \x01(\x08:\x05\x66\x61lse\x12_\n\x11idempotency_level\x18\" \x01(\x0e\x32/.google.protobuf.MethodOptions.IdempotencyLevel:\x13IDEMPOTENCY_UNKNOWN\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption\"P\n\x10IdempotencyLevel\x12\x17\n\x13IDEMPOTENCY_UNKNOWN\x10\x00\x12\x13\n\x0fNO_SIDE_EFFECTS\x10\x01\x12\x0e\n\nIDEMPOTENT\x10\x02*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\x9e\x02\n\x13UninterpretedOption\x12;\n\x04name\x18\x02 \x03(\x0b\x32-.google.protobuf.UninterpretedOption.NamePart\x12\x18\n\x10identifier_value\x18\x03 \x01(\t\x12\x1a\n\x12positive_int_value\x18\x04 \x01(\x04\x12\x1a\n\x12negative_int_value\x18\x05 \x01(\x03\x12\x14\n\x0c\x64ouble_value\x18\x06 \x01(\x01\x12\x14\n\x0cstring_value\x18\x07 \x01(\x0c\x12\x17\n\x0f\x61ggregate_value\x18\x08 \x01(\t\x1a\x33\n\x08NamePart\x12\x11\n\tname_part\x18\x01 \x02(\t\x12\x14\n\x0cis_extension\x18\x02 \x02(\x08\"\xd5\x01\n\x0eSourceCodeInfo\x12:\n\x08location\x18\x01 \x03(\x0b\x32(.google.protobuf.SourceCodeInfo.Location\x1a\x86\x01\n\x08Location\x12\x10\n\x04path\x18\x01 \x03(\x05\x42\x02\x10\x01\x12\x10\n\x04span\x18\x02 \x03(\x05\x42\x02\x10\x01\x12\x18\n\x10leading_comments\x18\x03 \x01(\t\x12\x19\n\x11trailing_comments\x18\x04 \x01(\t\x12!\n\x19leading_detached_comments\x18\x06 \x03(\t\"\xa7\x01\n\x11GeneratedCodeInfo\x12\x41\n\nannotation\x18\x01 \x03(\x0b\x32-.google.protobuf.GeneratedCodeInfo.Annotation\x1aO\n\nAnnotation\x12\x10\n\x04path\x18\x01 \x03(\x05\x42\x02\x10\x01\x12\x13\n\x0bsource_file\x18\x02 \x01(\t\x12\r\n\x05\x62\x65gin\x18\x03 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x04 \x01(\x05\x42~\n\x13\x63om.google.protobufB\x10\x44\x65scriptorProtosH\x01Z-google.golang.org/protobuf/types/descriptorpb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1aGoogle.Protobuf.Reflection' ) else: DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n google/protobuf/descriptor.proto\x12\x0fgoogle.protobuf\"G\n\x11\x46ileDescriptorSet\x12\x32\n\x04\x66ile\x18\x01 \x03(\x0b\x32$.google.protobuf.FileDescriptorProto\"\xdb\x03\n\x13\x46ileDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07package\x18\x02 \x01(\t\x12\x12\n\ndependency\x18\x03 \x03(\t\x12\x19\n\x11public_dependency\x18\n \x03(\x05\x12\x17\n\x0fweak_dependency\x18\x0b \x03(\x05\x12\x36\n\x0cmessage_type\x18\x04 \x03(\x0b\x32 .google.protobuf.DescriptorProto\x12\x37\n\tenum_type\x18\x05 \x03(\x0b\x32$.google.protobuf.EnumDescriptorProto\x12\x38\n\x07service\x18\x06 \x03(\x0b\x32\'.google.protobuf.ServiceDescriptorProto\x12\x38\n\textension\x18\x07 \x03(\x0b\x32%.google.protobuf.FieldDescriptorProto\x12-\n\x07options\x18\x08 \x01(\x0b\x32\x1c.google.protobuf.FileOptions\x12\x39\n\x10source_code_info\x18\t \x01(\x0b\x32\x1f.google.protobuf.SourceCodeInfo\x12\x0e\n\x06syntax\x18\x0c \x01(\t\"\xa9\x05\n\x0f\x44\x65scriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x34\n\x05\x66ield\x18\x02 \x03(\x0b\x32%.google.protobuf.FieldDescriptorProto\x12\x38\n\textension\x18\x06 \x03(\x0b\x32%.google.protobuf.FieldDescriptorProto\x12\x35\n\x0bnested_type\x18\x03 \x03(\x0b\x32 .google.protobuf.DescriptorProto\x12\x37\n\tenum_type\x18\x04 \x03(\x0b\x32$.google.protobuf.EnumDescriptorProto\x12H\n\x0f\x65xtension_range\x18\x05 \x03(\x0b\x32/.google.protobuf.DescriptorProto.ExtensionRange\x12\x39\n\noneof_decl\x18\x08 \x03(\x0b\x32%.google.protobuf.OneofDescriptorProto\x12\x30\n\x07options\x18\x07 \x01(\x0b\x32\x1f.google.protobuf.MessageOptions\x12\x46\n\x0ereserved_range\x18\t \x03(\x0b\x32..google.protobuf.DescriptorProto.ReservedRange\x12\x15\n\rreserved_name\x18\n \x03(\t\x1a\x65\n\x0e\x45xtensionRange\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\x12\x37\n\x07options\x18\x03 \x01(\x0b\x32&.google.protobuf.ExtensionRangeOptions\x1a+\n\rReservedRange\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\"g\n\x15\x45xtensionRangeOptions\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\xd5\x05\n\x14\x46ieldDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06number\x18\x03 \x01(\x05\x12:\n\x05label\x18\x04 \x01(\x0e\x32+.google.protobuf.FieldDescriptorProto.Label\x12\x38\n\x04type\x18\x05 \x01(\x0e\x32*.google.protobuf.FieldDescriptorProto.Type\x12\x11\n\ttype_name\x18\x06 \x01(\t\x12\x10\n\x08\x65xtendee\x18\x02 \x01(\t\x12\x15\n\rdefault_value\x18\x07 \x01(\t\x12\x13\n\x0boneof_index\x18\t \x01(\x05\x12\x11\n\tjson_name\x18\n \x01(\t\x12.\n\x07options\x18\x08 \x01(\x0b\x32\x1d.google.protobuf.FieldOptions\x12\x17\n\x0fproto3_optional\x18\x11 \x01(\x08\"\xb6\x02\n\x04Type\x12\x0f\n\x0bTYPE_DOUBLE\x10\x01\x12\x0e\n\nTYPE_FLOAT\x10\x02\x12\x0e\n\nTYPE_INT64\x10\x03\x12\x0f\n\x0bTYPE_UINT64\x10\x04\x12\x0e\n\nTYPE_INT32\x10\x05\x12\x10\n\x0cTYPE_FIXED64\x10\x06\x12\x10\n\x0cTYPE_FIXED32\x10\x07\x12\r\n\tTYPE_BOOL\x10\x08\x12\x0f\n\x0bTYPE_STRING\x10\t\x12\x0e\n\nTYPE_GROUP\x10\n\x12\x10\n\x0cTYPE_MESSAGE\x10\x0b\x12\x0e\n\nTYPE_BYTES\x10\x0c\x12\x0f\n\x0bTYPE_UINT32\x10\r\x12\r\n\tTYPE_ENUM\x10\x0e\x12\x11\n\rTYPE_SFIXED32\x10\x0f\x12\x11\n\rTYPE_SFIXED64\x10\x10\x12\x0f\n\x0bTYPE_SINT32\x10\x11\x12\x0f\n\x0bTYPE_SINT64\x10\x12\"C\n\x05Label\x12\x12\n\x0eLABEL_OPTIONAL\x10\x01\x12\x12\n\x0eLABEL_REQUIRED\x10\x02\x12\x12\n\x0eLABEL_REPEATED\x10\x03\"T\n\x14OneofDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12.\n\x07options\x18\x02 \x01(\x0b\x32\x1d.google.protobuf.OneofOptions\"\xa4\x02\n\x13\x45numDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x38\n\x05value\x18\x02 \x03(\x0b\x32).google.protobuf.EnumValueDescriptorProto\x12-\n\x07options\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.EnumOptions\x12N\n\x0ereserved_range\x18\x04 \x03(\x0b\x32\x36.google.protobuf.EnumDescriptorProto.EnumReservedRange\x12\x15\n\rreserved_name\x18\x05 \x03(\t\x1a/\n\x11\x45numReservedRange\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\"l\n\x18\x45numValueDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06number\x18\x02 \x01(\x05\x12\x32\n\x07options\x18\x03 \x01(\x0b\x32!.google.protobuf.EnumValueOptions\"\x90\x01\n\x16ServiceDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x36\n\x06method\x18\x02 \x03(\x0b\x32&.google.protobuf.MethodDescriptorProto\x12\x30\n\x07options\x18\x03 \x01(\x0b\x32\x1f.google.protobuf.ServiceOptions\"\xc1\x01\n\x15MethodDescriptorProto\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ninput_type\x18\x02 \x01(\t\x12\x13\n\x0boutput_type\x18\x03 \x01(\t\x12/\n\x07options\x18\x04 \x01(\x0b\x32\x1e.google.protobuf.MethodOptions\x12\x1f\n\x10\x63lient_streaming\x18\x05 \x01(\x08:\x05\x66\x61lse\x12\x1f\n\x10server_streaming\x18\x06 \x01(\x08:\x05\x66\x61lse\"\xa5\x06\n\x0b\x46ileOptions\x12\x14\n\x0cjava_package\x18\x01 \x01(\t\x12\x1c\n\x14java_outer_classname\x18\x08 \x01(\t\x12\"\n\x13java_multiple_files\x18\n \x01(\x08:\x05\x66\x61lse\x12)\n\x1djava_generate_equals_and_hash\x18\x14 \x01(\x08\x42\x02\x18\x01\x12%\n\x16java_string_check_utf8\x18\x1b \x01(\x08:\x05\x66\x61lse\x12\x46\n\x0coptimize_for\x18\t \x01(\x0e\x32).google.protobuf.FileOptions.OptimizeMode:\x05SPEED\x12\x12\n\ngo_package\x18\x0b \x01(\t\x12\"\n\x13\x63\x63_generic_services\x18\x10 \x01(\x08:\x05\x66\x61lse\x12$\n\x15java_generic_services\x18\x11 \x01(\x08:\x05\x66\x61lse\x12\"\n\x13py_generic_services\x18\x12 \x01(\x08:\x05\x66\x61lse\x12#\n\x14php_generic_services\x18* \x01(\x08:\x05\x66\x61lse\x12\x19\n\ndeprecated\x18\x17 \x01(\x08:\x05\x66\x61lse\x12\x1e\n\x10\x63\x63_enable_arenas\x18\x1f \x01(\x08:\x04true\x12\x19\n\x11objc_class_prefix\x18$ \x01(\t\x12\x18\n\x10\x63sharp_namespace\x18% \x01(\t\x12\x14\n\x0cswift_prefix\x18\' \x01(\t\x12\x18\n\x10php_class_prefix\x18( \x01(\t\x12\x15\n\rphp_namespace\x18) \x01(\t\x12\x1e\n\x16php_metadata_namespace\x18, \x01(\t\x12\x14\n\x0cruby_package\x18- \x01(\t\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption\":\n\x0cOptimizeMode\x12\t\n\x05SPEED\x10\x01\x12\r\n\tCODE_SIZE\x10\x02\x12\x10\n\x0cLITE_RUNTIME\x10\x03*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08&\x10\'\"\x84\x02\n\x0eMessageOptions\x12&\n\x17message_set_wire_format\x18\x01 \x01(\x08:\x05\x66\x61lse\x12.\n\x1fno_standard_descriptor_accessor\x18\x02 \x01(\x08:\x05\x66\x61lse\x12\x19\n\ndeprecated\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\x11\n\tmap_entry\x18\x07 \x01(\x08\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08\x04\x10\x05J\x04\x08\x05\x10\x06J\x04\x08\x06\x10\x07J\x04\x08\x08\x10\tJ\x04\x08\t\x10\n\"\xbe\x03\n\x0c\x46ieldOptions\x12:\n\x05\x63type\x18\x01 \x01(\x0e\x32#.google.protobuf.FieldOptions.CType:\x06STRING\x12\x0e\n\x06packed\x18\x02 \x01(\x08\x12?\n\x06jstype\x18\x06 \x01(\x0e\x32$.google.protobuf.FieldOptions.JSType:\tJS_NORMAL\x12\x13\n\x04lazy\x18\x05 \x01(\x08:\x05\x66\x61lse\x12\x1e\n\x0funverified_lazy\x18\x0f \x01(\x08:\x05\x66\x61lse\x12\x19\n\ndeprecated\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\x13\n\x04weak\x18\n \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption\"/\n\x05\x43Type\x12\n\n\x06STRING\x10\x00\x12\x08\n\x04\x43ORD\x10\x01\x12\x10\n\x0cSTRING_PIECE\x10\x02\"5\n\x06JSType\x12\r\n\tJS_NORMAL\x10\x00\x12\r\n\tJS_STRING\x10\x01\x12\r\n\tJS_NUMBER\x10\x02*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08\x04\x10\x05\"^\n\x0cOneofOptions\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\x93\x01\n\x0b\x45numOptions\x12\x13\n\x0b\x61llow_alias\x18\x02 \x01(\x08\x12\x19\n\ndeprecated\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02J\x04\x08\x05\x10\x06\"}\n\x10\x45numValueOptions\x12\x19\n\ndeprecated\x18\x01 \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"{\n\x0eServiceOptions\x12\x19\n\ndeprecated\x18! \x01(\x08:\x05\x66\x61lse\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\xad\x02\n\rMethodOptions\x12\x19\n\ndeprecated\x18! \x01(\x08:\x05\x66\x61lse\x12_\n\x11idempotency_level\x18\" \x01(\x0e\x32/.google.protobuf.MethodOptions.IdempotencyLevel:\x13IDEMPOTENCY_UNKNOWN\x12\x43\n\x14uninterpreted_option\x18\xe7\x07 \x03(\x0b\x32$.google.protobuf.UninterpretedOption\"P\n\x10IdempotencyLevel\x12\x17\n\x13IDEMPOTENCY_UNKNOWN\x10\x00\x12\x13\n\x0fNO_SIDE_EFFECTS\x10\x01\x12\x0e\n\nIDEMPOTENT\x10\x02*\t\x08\xe8\x07\x10\x80\x80\x80\x80\x02\"\x9e\x02\n\x13UninterpretedOption\x12;\n\x04name\x18\x02 \x03(\x0b\x32-.google.protobuf.UninterpretedOption.NamePart\x12\x18\n\x10identifier_value\x18\x03 \x01(\t\x12\x1a\n\x12positive_int_value\x18\x04 \x01(\x04\x12\x1a\n\x12negative_int_value\x18\x05 \x01(\x03\x12\x14\n\x0c\x64ouble_value\x18\x06 \x01(\x01\x12\x14\n\x0cstring_value\x18\x07 \x01(\x0c\x12\x17\n\x0f\x61ggregate_value\x18\x08 \x01(\t\x1a\x33\n\x08NamePart\x12\x11\n\tname_part\x18\x01 \x02(\t\x12\x14\n\x0cis_extension\x18\x02 \x02(\x08\"\xd5\x01\n\x0eSourceCodeInfo\x12:\n\x08location\x18\x01 \x03(\x0b\x32(.google.protobuf.SourceCodeInfo.Location\x1a\x86\x01\n\x08Location\x12\x10\n\x04path\x18\x01 \x03(\x05\x42\x02\x10\x01\x12\x10\n\x04span\x18\x02 \x03(\x05\x42\x02\x10\x01\x12\x18\n\x10leading_comments\x18\x03 \x01(\t\x12\x19\n\x11trailing_comments\x18\x04 \x01(\t\x12!\n\x19leading_detached_comments\x18\x06 \x03(\t\"\xa7\x01\n\x11GeneratedCodeInfo\x12\x41\n\nannotation\x18\x01 \x03(\x0b\x32-.google.protobuf.GeneratedCodeInfo.Annotation\x1aO\n\nAnnotation\x12\x10\n\x04path\x18\x01 \x03(\x05\x42\x02\x10\x01\x12\x13\n\x0bsource_file\x18\x02 \x01(\t\x12\r\n\x05\x62\x65gin\x18\x03 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x04 \x01(\x05\x42~\n\x13\x63om.google.protobufB\x10\x44\x65scriptorProtosH\x01Z-google.golang.org/protobuf/types/descriptorpb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1aGoogle.Protobuf.Reflection') if _descriptor._USE_C_DESCRIPTORS == False: _FIELDDESCRIPTORPROTO_TYPE = _descriptor.EnumDescriptor( name='Type', full_name='google.protobuf.FieldDescriptorProto.Type', filename=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key, values=[ _descriptor.EnumValueDescriptor( name='TYPE_DOUBLE', index=0, number=1, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_FLOAT', index=1, number=2, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_INT64', index=2, number=3, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_UINT64', index=3, number=4, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_INT32', index=4, number=5, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_FIXED64', index=5, number=6, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_FIXED32', index=6, number=7, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_BOOL', index=7, number=8, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_STRING', index=8, number=9, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_GROUP', index=9, number=10, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_MESSAGE', index=10, number=11, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_BYTES', index=11, number=12, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_UINT32', index=12, number=13, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_ENUM', index=13, number=14, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_SFIXED32', index=14, number=15, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_SFIXED64', index=15, number=16, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_SINT32', index=16, number=17, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='TYPE_SINT64', index=17, number=18, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, ) _sym_db.RegisterEnumDescriptor(_FIELDDESCRIPTORPROTO_TYPE) _FIELDDESCRIPTORPROTO_LABEL = _descriptor.EnumDescriptor( name='Label', full_name='google.protobuf.FieldDescriptorProto.Label', filename=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key, values=[ _descriptor.EnumValueDescriptor( name='LABEL_OPTIONAL', index=0, number=1, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='LABEL_REQUIRED', index=1, number=2, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='LABEL_REPEATED', index=2, number=3, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, ) _sym_db.RegisterEnumDescriptor(_FIELDDESCRIPTORPROTO_LABEL) _FILEOPTIONS_OPTIMIZEMODE = _descriptor.EnumDescriptor( name='OptimizeMode', full_name='google.protobuf.FileOptions.OptimizeMode', filename=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key, values=[ _descriptor.EnumValueDescriptor( name='SPEED', index=0, number=1, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='CODE_SIZE', index=1, number=2, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='LITE_RUNTIME', index=2, number=3, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, ) _sym_db.RegisterEnumDescriptor(_FILEOPTIONS_OPTIMIZEMODE) _FIELDOPTIONS_CTYPE = _descriptor.EnumDescriptor( name='CType', full_name='google.protobuf.FieldOptions.CType', filename=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key, values=[ _descriptor.EnumValueDescriptor( name='STRING', index=0, number=0, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='CORD', index=1, number=1, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='STRING_PIECE', index=2, number=2, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, ) _sym_db.RegisterEnumDescriptor(_FIELDOPTIONS_CTYPE) _FIELDOPTIONS_JSTYPE = _descriptor.EnumDescriptor( name='JSType', full_name='google.protobuf.FieldOptions.JSType', filename=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key, values=[ _descriptor.EnumValueDescriptor( name='JS_NORMAL', index=0, number=0, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='JS_STRING', index=1, number=1, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='JS_NUMBER', index=2, number=2, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, ) _sym_db.RegisterEnumDescriptor(_FIELDOPTIONS_JSTYPE) _METHODOPTIONS_IDEMPOTENCYLEVEL = _descriptor.EnumDescriptor( name='IdempotencyLevel', full_name='google.protobuf.MethodOptions.IdempotencyLevel', filename=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key, values=[ _descriptor.EnumValueDescriptor( name='IDEMPOTENCY_UNKNOWN', index=0, number=0, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='NO_SIDE_EFFECTS', index=1, number=1, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), _descriptor.EnumValueDescriptor( name='IDEMPOTENT', index=2, number=2, serialized_options=None, type=None, create_key=_descriptor._internal_create_key), ], containing_type=None, serialized_options=None, ) _sym_db.RegisterEnumDescriptor(_METHODOPTIONS_IDEMPOTENCYLEVEL) _FILEDESCRIPTORSET = _descriptor.Descriptor( name='FileDescriptorSet', full_name='google.protobuf.FileDescriptorSet', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='file', full_name='google.protobuf.FileDescriptorSet.file', index=0, number=1, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _FILEDESCRIPTORPROTO = _descriptor.Descriptor( name='FileDescriptorProto', full_name='google.protobuf.FileDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.FileDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='package', full_name='google.protobuf.FileDescriptorProto.package', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='dependency', full_name='google.protobuf.FileDescriptorProto.dependency', index=2, number=3, type=9, cpp_type=9, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='public_dependency', full_name='google.protobuf.FileDescriptorProto.public_dependency', index=3, number=10, type=5, cpp_type=1, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='weak_dependency', full_name='google.protobuf.FileDescriptorProto.weak_dependency', index=4, number=11, type=5, cpp_type=1, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='message_type', full_name='google.protobuf.FileDescriptorProto.message_type', index=5, number=4, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='enum_type', full_name='google.protobuf.FileDescriptorProto.enum_type', index=6, number=5, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='service', full_name='google.protobuf.FileDescriptorProto.service', index=7, number=6, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='extension', full_name='google.protobuf.FileDescriptorProto.extension', index=8, number=7, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.FileDescriptorProto.options', index=9, number=8, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='source_code_info', full_name='google.protobuf.FileDescriptorProto.source_code_info', index=10, number=9, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='syntax', full_name='google.protobuf.FileDescriptorProto.syntax', index=11, number=12, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _DESCRIPTORPROTO_EXTENSIONRANGE = _descriptor.Descriptor( name='ExtensionRange', full_name='google.protobuf.DescriptorProto.ExtensionRange', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='start', full_name='google.protobuf.DescriptorProto.ExtensionRange.start', index=0, number=1, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='end', full_name='google.protobuf.DescriptorProto.ExtensionRange.end', index=1, number=2, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.DescriptorProto.ExtensionRange.options', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _DESCRIPTORPROTO_RESERVEDRANGE = _descriptor.Descriptor( name='ReservedRange', full_name='google.protobuf.DescriptorProto.ReservedRange', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='start', full_name='google.protobuf.DescriptorProto.ReservedRange.start', index=0, number=1, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='end', full_name='google.protobuf.DescriptorProto.ReservedRange.end', index=1, number=2, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _DESCRIPTORPROTO = _descriptor.Descriptor( name='DescriptorProto', full_name='google.protobuf.DescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.DescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='field', full_name='google.protobuf.DescriptorProto.field', index=1, number=2, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='extension', full_name='google.protobuf.DescriptorProto.extension', index=2, number=6, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='nested_type', full_name='google.protobuf.DescriptorProto.nested_type', index=3, number=3, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='enum_type', full_name='google.protobuf.DescriptorProto.enum_type', index=4, number=4, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='extension_range', full_name='google.protobuf.DescriptorProto.extension_range', index=5, number=5, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='oneof_decl', full_name='google.protobuf.DescriptorProto.oneof_decl', index=6, number=8, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.DescriptorProto.options', index=7, number=7, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='reserved_range', full_name='google.protobuf.DescriptorProto.reserved_range', index=8, number=9, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='reserved_name', full_name='google.protobuf.DescriptorProto.reserved_name', index=9, number=10, type=9, cpp_type=9, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[_DESCRIPTORPROTO_EXTENSIONRANGE, _DESCRIPTORPROTO_RESERVEDRANGE, ], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _EXTENSIONRANGEOPTIONS = _descriptor.Descriptor( name='ExtensionRangeOptions', full_name='google.protobuf.ExtensionRangeOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.ExtensionRangeOptions.uninterpreted_option', index=0, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _FIELDDESCRIPTORPROTO = _descriptor.Descriptor( name='FieldDescriptorProto', full_name='google.protobuf.FieldDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.FieldDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='number', full_name='google.protobuf.FieldDescriptorProto.number', index=1, number=3, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='label', full_name='google.protobuf.FieldDescriptorProto.label', index=2, number=4, type=14, cpp_type=8, label=1, has_default_value=False, default_value=1, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='type', full_name='google.protobuf.FieldDescriptorProto.type', index=3, number=5, type=14, cpp_type=8, label=1, has_default_value=False, default_value=1, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='type_name', full_name='google.protobuf.FieldDescriptorProto.type_name', index=4, number=6, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='extendee', full_name='google.protobuf.FieldDescriptorProto.extendee', index=5, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='default_value', full_name='google.protobuf.FieldDescriptorProto.default_value', index=6, number=7, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='oneof_index', full_name='google.protobuf.FieldDescriptorProto.oneof_index', index=7, number=9, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='json_name', full_name='google.protobuf.FieldDescriptorProto.json_name', index=8, number=10, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.FieldDescriptorProto.options', index=9, number=8, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='proto3_optional', full_name='google.protobuf.FieldDescriptorProto.proto3_optional', index=10, number=17, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ _FIELDDESCRIPTORPROTO_TYPE, _FIELDDESCRIPTORPROTO_LABEL, ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _ONEOFDESCRIPTORPROTO = _descriptor.Descriptor( name='OneofDescriptorProto', full_name='google.protobuf.OneofDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.OneofDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.OneofDescriptorProto.options', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _ENUMDESCRIPTORPROTO_ENUMRESERVEDRANGE = _descriptor.Descriptor( name='EnumReservedRange', full_name='google.protobuf.EnumDescriptorProto.EnumReservedRange', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='start', full_name='google.protobuf.EnumDescriptorProto.EnumReservedRange.start', index=0, number=1, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='end', full_name='google.protobuf.EnumDescriptorProto.EnumReservedRange.end', index=1, number=2, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _ENUMDESCRIPTORPROTO = _descriptor.Descriptor( name='EnumDescriptorProto', full_name='google.protobuf.EnumDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.EnumDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='value', full_name='google.protobuf.EnumDescriptorProto.value', index=1, number=2, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.EnumDescriptorProto.options', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='reserved_range', full_name='google.protobuf.EnumDescriptorProto.reserved_range', index=3, number=4, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='reserved_name', full_name='google.protobuf.EnumDescriptorProto.reserved_name', index=4, number=5, type=9, cpp_type=9, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[_ENUMDESCRIPTORPROTO_ENUMRESERVEDRANGE, ], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _ENUMVALUEDESCRIPTORPROTO = _descriptor.Descriptor( name='EnumValueDescriptorProto', full_name='google.protobuf.EnumValueDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.EnumValueDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='number', full_name='google.protobuf.EnumValueDescriptorProto.number', index=1, number=2, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.EnumValueDescriptorProto.options', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _SERVICEDESCRIPTORPROTO = _descriptor.Descriptor( name='ServiceDescriptorProto', full_name='google.protobuf.ServiceDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.ServiceDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='method', full_name='google.protobuf.ServiceDescriptorProto.method', index=1, number=2, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.ServiceDescriptorProto.options', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _METHODDESCRIPTORPROTO = _descriptor.Descriptor( name='MethodDescriptorProto', full_name='google.protobuf.MethodDescriptorProto', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.MethodDescriptorProto.name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='input_type', full_name='google.protobuf.MethodDescriptorProto.input_type', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='output_type', full_name='google.protobuf.MethodDescriptorProto.output_type', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='options', full_name='google.protobuf.MethodDescriptorProto.options', index=3, number=4, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='client_streaming', full_name='google.protobuf.MethodDescriptorProto.client_streaming', index=4, number=5, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='server_streaming', full_name='google.protobuf.MethodDescriptorProto.server_streaming', index=5, number=6, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _FILEOPTIONS = _descriptor.Descriptor( name='FileOptions', full_name='google.protobuf.FileOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='java_package', full_name='google.protobuf.FileOptions.java_package', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='java_outer_classname', full_name='google.protobuf.FileOptions.java_outer_classname', index=1, number=8, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='java_multiple_files', full_name='google.protobuf.FileOptions.java_multiple_files', index=2, number=10, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='java_generate_equals_and_hash', full_name='google.protobuf.FileOptions.java_generate_equals_and_hash', index=3, number=20, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='java_string_check_utf8', full_name='google.protobuf.FileOptions.java_string_check_utf8', index=4, number=27, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='optimize_for', full_name='google.protobuf.FileOptions.optimize_for', index=5, number=9, type=14, cpp_type=8, label=1, has_default_value=True, default_value=1, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='go_package', full_name='google.protobuf.FileOptions.go_package', index=6, number=11, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='cc_generic_services', full_name='google.protobuf.FileOptions.cc_generic_services', index=7, number=16, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='java_generic_services', full_name='google.protobuf.FileOptions.java_generic_services', index=8, number=17, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='py_generic_services', full_name='google.protobuf.FileOptions.py_generic_services', index=9, number=18, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='php_generic_services', full_name='google.protobuf.FileOptions.php_generic_services', index=10, number=42, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.FileOptions.deprecated', index=11, number=23, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='cc_enable_arenas', full_name='google.protobuf.FileOptions.cc_enable_arenas', index=12, number=31, type=8, cpp_type=7, label=1, has_default_value=True, default_value=True, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='objc_class_prefix', full_name='google.protobuf.FileOptions.objc_class_prefix', index=13, number=36, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='csharp_namespace', full_name='google.protobuf.FileOptions.csharp_namespace', index=14, number=37, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='swift_prefix', full_name='google.protobuf.FileOptions.swift_prefix', index=15, number=39, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='php_class_prefix', full_name='google.protobuf.FileOptions.php_class_prefix', index=16, number=40, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='php_namespace', full_name='google.protobuf.FileOptions.php_namespace', index=17, number=41, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='php_metadata_namespace', full_name='google.protobuf.FileOptions.php_metadata_namespace', index=18, number=44, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='ruby_package', full_name='google.protobuf.FileOptions.ruby_package', index=19, number=45, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.FileOptions.uninterpreted_option', index=20, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ _FILEOPTIONS_OPTIMIZEMODE, ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _MESSAGEOPTIONS = _descriptor.Descriptor( name='MessageOptions', full_name='google.protobuf.MessageOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='message_set_wire_format', full_name='google.protobuf.MessageOptions.message_set_wire_format', index=0, number=1, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='no_standard_descriptor_accessor', full_name='google.protobuf.MessageOptions.no_standard_descriptor_accessor', index=1, number=2, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.MessageOptions.deprecated', index=2, number=3, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='map_entry', full_name='google.protobuf.MessageOptions.map_entry', index=3, number=7, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.MessageOptions.uninterpreted_option', index=4, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _FIELDOPTIONS = _descriptor.Descriptor( name='FieldOptions', full_name='google.protobuf.FieldOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='ctype', full_name='google.protobuf.FieldOptions.ctype', index=0, number=1, type=14, cpp_type=8, label=1, has_default_value=True, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='packed', full_name='google.protobuf.FieldOptions.packed', index=1, number=2, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='jstype', full_name='google.protobuf.FieldOptions.jstype', index=2, number=6, type=14, cpp_type=8, label=1, has_default_value=True, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='lazy', full_name='google.protobuf.FieldOptions.lazy', index=3, number=5, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='unverified_lazy', full_name='google.protobuf.FieldOptions.unverified_lazy', index=4, number=15, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.FieldOptions.deprecated', index=5, number=3, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='weak', full_name='google.protobuf.FieldOptions.weak', index=6, number=10, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.FieldOptions.uninterpreted_option', index=7, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ _FIELDOPTIONS_CTYPE, _FIELDOPTIONS_JSTYPE, ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _ONEOFOPTIONS = _descriptor.Descriptor( name='OneofOptions', full_name='google.protobuf.OneofOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.OneofOptions.uninterpreted_option', index=0, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _ENUMOPTIONS = _descriptor.Descriptor( name='EnumOptions', full_name='google.protobuf.EnumOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='allow_alias', full_name='google.protobuf.EnumOptions.allow_alias', index=0, number=2, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.EnumOptions.deprecated', index=1, number=3, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.EnumOptions.uninterpreted_option', index=2, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _ENUMVALUEOPTIONS = _descriptor.Descriptor( name='EnumValueOptions', full_name='google.protobuf.EnumValueOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.EnumValueOptions.deprecated', index=0, number=1, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.EnumValueOptions.uninterpreted_option', index=1, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _SERVICEOPTIONS = _descriptor.Descriptor( name='ServiceOptions', full_name='google.protobuf.ServiceOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.ServiceOptions.deprecated', index=0, number=33, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.ServiceOptions.uninterpreted_option', index=1, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _METHODOPTIONS = _descriptor.Descriptor( name='MethodOptions', full_name='google.protobuf.MethodOptions', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='deprecated', full_name='google.protobuf.MethodOptions.deprecated', index=0, number=33, type=8, cpp_type=7, label=1, has_default_value=True, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='idempotency_level', full_name='google.protobuf.MethodOptions.idempotency_level', index=1, number=34, type=14, cpp_type=8, label=1, has_default_value=True, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='uninterpreted_option', full_name='google.protobuf.MethodOptions.uninterpreted_option', index=2, number=999, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ _METHODOPTIONS_IDEMPOTENCYLEVEL, ], serialized_options=None, is_extendable=True, syntax='proto2', extension_ranges=[(1000, 536870912), ], oneofs=[ ], ) _UNINTERPRETEDOPTION_NAMEPART = _descriptor.Descriptor( name='NamePart', full_name='google.protobuf.UninterpretedOption.NamePart', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name_part', full_name='google.protobuf.UninterpretedOption.NamePart.name_part', index=0, number=1, type=9, cpp_type=9, label=2, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='is_extension', full_name='google.protobuf.UninterpretedOption.NamePart.is_extension', index=1, number=2, type=8, cpp_type=7, label=2, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _UNINTERPRETEDOPTION = _descriptor.Descriptor( name='UninterpretedOption', full_name='google.protobuf.UninterpretedOption', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='name', full_name='google.protobuf.UninterpretedOption.name', index=0, number=2, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='identifier_value', full_name='google.protobuf.UninterpretedOption.identifier_value', index=1, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='positive_int_value', full_name='google.protobuf.UninterpretedOption.positive_int_value', index=2, number=4, type=4, cpp_type=4, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='negative_int_value', full_name='google.protobuf.UninterpretedOption.negative_int_value', index=3, number=5, type=3, cpp_type=2, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='double_value', full_name='google.protobuf.UninterpretedOption.double_value', index=4, number=6, type=1, cpp_type=5, label=1, has_default_value=False, default_value=float(0), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='string_value', full_name='google.protobuf.UninterpretedOption.string_value', index=5, number=7, type=12, cpp_type=9, label=1, has_default_value=False, default_value=b"", message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='aggregate_value', full_name='google.protobuf.UninterpretedOption.aggregate_value', index=6, number=8, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[_UNINTERPRETEDOPTION_NAMEPART, ], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _SOURCECODEINFO_LOCATION = _descriptor.Descriptor( name='Location', full_name='google.protobuf.SourceCodeInfo.Location', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='path', full_name='google.protobuf.SourceCodeInfo.Location.path', index=0, number=1, type=5, cpp_type=1, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='span', full_name='google.protobuf.SourceCodeInfo.Location.span', index=1, number=2, type=5, cpp_type=1, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='leading_comments', full_name='google.protobuf.SourceCodeInfo.Location.leading_comments', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='trailing_comments', full_name='google.protobuf.SourceCodeInfo.Location.trailing_comments', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='leading_detached_comments', full_name='google.protobuf.SourceCodeInfo.Location.leading_detached_comments', index=4, number=6, type=9, cpp_type=9, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _SOURCECODEINFO = _descriptor.Descriptor( name='SourceCodeInfo', full_name='google.protobuf.SourceCodeInfo', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='location', full_name='google.protobuf.SourceCodeInfo.location', index=0, number=1, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[_SOURCECODEINFO_LOCATION, ], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _GENERATEDCODEINFO_ANNOTATION = _descriptor.Descriptor( name='Annotation', full_name='google.protobuf.GeneratedCodeInfo.Annotation', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='path', full_name='google.protobuf.GeneratedCodeInfo.Annotation.path', index=0, number=1, type=5, cpp_type=1, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='source_file', full_name='google.protobuf.GeneratedCodeInfo.Annotation.source_file', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=b"".decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='begin', full_name='google.protobuf.GeneratedCodeInfo.Annotation.begin', index=2, number=3, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( name='end', full_name='google.protobuf.GeneratedCodeInfo.Annotation.end', index=3, number=4, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _GENERATEDCODEINFO = _descriptor.Descriptor( name='GeneratedCodeInfo', full_name='google.protobuf.GeneratedCodeInfo', filename=None, file=DESCRIPTOR, containing_type=None, create_key=_descriptor._internal_create_key, fields=[ _descriptor.FieldDescriptor( name='annotation', full_name='google.protobuf.GeneratedCodeInfo.annotation', index=0, number=1, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], nested_types=[_GENERATEDCODEINFO_ANNOTATION, ], enum_types=[ ], serialized_options=None, is_extendable=False, syntax='proto2', extension_ranges=[], oneofs=[ ], ) _FILEDESCRIPTORSET.fields_by_name['file'].message_type = _FILEDESCRIPTORPROTO _FILEDESCRIPTORPROTO.fields_by_name['message_type'].message_type = _DESCRIPTORPROTO _FILEDESCRIPTORPROTO.fields_by_name['enum_type'].message_type = _ENUMDESCRIPTORPROTO _FILEDESCRIPTORPROTO.fields_by_name['service'].message_type = _SERVICEDESCRIPTORPROTO _FILEDESCRIPTORPROTO.fields_by_name['extension'].message_type = _FIELDDESCRIPTORPROTO _FILEDESCRIPTORPROTO.fields_by_name['options'].message_type = _FILEOPTIONS _FILEDESCRIPTORPROTO.fields_by_name['source_code_info'].message_type = _SOURCECODEINFO _DESCRIPTORPROTO_EXTENSIONRANGE.fields_by_name['options'].message_type = _EXTENSIONRANGEOPTIONS _DESCRIPTORPROTO_EXTENSIONRANGE.containing_type = _DESCRIPTORPROTO _DESCRIPTORPROTO_RESERVEDRANGE.containing_type = _DESCRIPTORPROTO _DESCRIPTORPROTO.fields_by_name['field'].message_type = _FIELDDESCRIPTORPROTO _DESCRIPTORPROTO.fields_by_name['extension'].message_type = _FIELDDESCRIPTORPROTO _DESCRIPTORPROTO.fields_by_name['nested_type'].message_type = _DESCRIPTORPROTO _DESCRIPTORPROTO.fields_by_name['enum_type'].message_type = _ENUMDESCRIPTORPROTO _DESCRIPTORPROTO.fields_by_name['extension_range'].message_type = _DESCRIPTORPROTO_EXTENSIONRANGE _DESCRIPTORPROTO.fields_by_name['oneof_decl'].message_type = _ONEOFDESCRIPTORPROTO _DESCRIPTORPROTO.fields_by_name['options'].message_type = _MESSAGEOPTIONS _DESCRIPTORPROTO.fields_by_name['reserved_range'].message_type = _DESCRIPTORPROTO_RESERVEDRANGE _EXTENSIONRANGEOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _FIELDDESCRIPTORPROTO.fields_by_name['label'].enum_type = _FIELDDESCRIPTORPROTO_LABEL _FIELDDESCRIPTORPROTO.fields_by_name['type'].enum_type = _FIELDDESCRIPTORPROTO_TYPE _FIELDDESCRIPTORPROTO.fields_by_name['options'].message_type = _FIELDOPTIONS _FIELDDESCRIPTORPROTO_TYPE.containing_type = _FIELDDESCRIPTORPROTO _FIELDDESCRIPTORPROTO_LABEL.containing_type = _FIELDDESCRIPTORPROTO _ONEOFDESCRIPTORPROTO.fields_by_name['options'].message_type = _ONEOFOPTIONS _ENUMDESCRIPTORPROTO_ENUMRESERVEDRANGE.containing_type = _ENUMDESCRIPTORPROTO _ENUMDESCRIPTORPROTO.fields_by_name['value'].message_type = _ENUMVALUEDESCRIPTORPROTO _ENUMDESCRIPTORPROTO.fields_by_name['options'].message_type = _ENUMOPTIONS _ENUMDESCRIPTORPROTO.fields_by_name['reserved_range'].message_type = _ENUMDESCRIPTORPROTO_ENUMRESERVEDRANGE _ENUMVALUEDESCRIPTORPROTO.fields_by_name['options'].message_type = _ENUMVALUEOPTIONS _SERVICEDESCRIPTORPROTO.fields_by_name['method'].message_type = _METHODDESCRIPTORPROTO _SERVICEDESCRIPTORPROTO.fields_by_name['options'].message_type = _SERVICEOPTIONS _METHODDESCRIPTORPROTO.fields_by_name['options'].message_type = _METHODOPTIONS _FILEOPTIONS.fields_by_name['optimize_for'].enum_type = _FILEOPTIONS_OPTIMIZEMODE _FILEOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _FILEOPTIONS_OPTIMIZEMODE.containing_type = _FILEOPTIONS _MESSAGEOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _FIELDOPTIONS.fields_by_name['ctype'].enum_type = _FIELDOPTIONS_CTYPE _FIELDOPTIONS.fields_by_name['jstype'].enum_type = _FIELDOPTIONS_JSTYPE _FIELDOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _FIELDOPTIONS_CTYPE.containing_type = _FIELDOPTIONS _FIELDOPTIONS_JSTYPE.containing_type = _FIELDOPTIONS _ONEOFOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _ENUMOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _ENUMVALUEOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _SERVICEOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _METHODOPTIONS.fields_by_name['idempotency_level'].enum_type = _METHODOPTIONS_IDEMPOTENCYLEVEL _METHODOPTIONS.fields_by_name['uninterpreted_option'].message_type = _UNINTERPRETEDOPTION _METHODOPTIONS_IDEMPOTENCYLEVEL.containing_type = _METHODOPTIONS _UNINTERPRETEDOPTION_NAMEPART.containing_type = _UNINTERPRETEDOPTION _UNINTERPRETEDOPTION.fields_by_name['name'].message_type = _UNINTERPRETEDOPTION_NAMEPART _SOURCECODEINFO_LOCATION.containing_type = _SOURCECODEINFO _SOURCECODEINFO.fields_by_name['location'].message_type = _SOURCECODEINFO_LOCATION _GENERATEDCODEINFO_ANNOTATION.containing_type = _GENERATEDCODEINFO _GENERATEDCODEINFO.fields_by_name['annotation'].message_type = _GENERATEDCODEINFO_ANNOTATION DESCRIPTOR.message_types_by_name['FileDescriptorSet'] = _FILEDESCRIPTORSET DESCRIPTOR.message_types_by_name['FileDescriptorProto'] = _FILEDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['DescriptorProto'] = _DESCRIPTORPROTO DESCRIPTOR.message_types_by_name['ExtensionRangeOptions'] = _EXTENSIONRANGEOPTIONS DESCRIPTOR.message_types_by_name['FieldDescriptorProto'] = _FIELDDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['OneofDescriptorProto'] = _ONEOFDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['EnumDescriptorProto'] = _ENUMDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['EnumValueDescriptorProto'] = _ENUMVALUEDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['ServiceDescriptorProto'] = _SERVICEDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['MethodDescriptorProto'] = _METHODDESCRIPTORPROTO DESCRIPTOR.message_types_by_name['FileOptions'] = _FILEOPTIONS DESCRIPTOR.message_types_by_name['MessageOptions'] = _MESSAGEOPTIONS DESCRIPTOR.message_types_by_name['FieldOptions'] = _FIELDOPTIONS DESCRIPTOR.message_types_by_name['OneofOptions'] = _ONEOFOPTIONS DESCRIPTOR.message_types_by_name['EnumOptions'] = _ENUMOPTIONS DESCRIPTOR.message_types_by_name['EnumValueOptions'] = _ENUMVALUEOPTIONS DESCRIPTOR.message_types_by_name['ServiceOptions'] = _SERVICEOPTIONS DESCRIPTOR.message_types_by_name['MethodOptions'] = _METHODOPTIONS DESCRIPTOR.message_types_by_name['UninterpretedOption'] = _UNINTERPRETEDOPTION DESCRIPTOR.message_types_by_name['SourceCodeInfo'] = _SOURCECODEINFO DESCRIPTOR.message_types_by_name['GeneratedCodeInfo'] = _GENERATEDCODEINFO _sym_db.RegisterFileDescriptor(DESCRIPTOR) else: _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.descriptor_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None _FILEDESCRIPTORSET._serialized_start=53 _FILEDESCRIPTORSET._serialized_end=124 _FILEDESCRIPTORPROTO._serialized_start=127 _FILEDESCRIPTORPROTO._serialized_end=602 _DESCRIPTORPROTO._serialized_start=605 _DESCRIPTORPROTO._serialized_end=1286 _DESCRIPTORPROTO_EXTENSIONRANGE._serialized_start=1140 _DESCRIPTORPROTO_EXTENSIONRANGE._serialized_end=1241 _DESCRIPTORPROTO_RESERVEDRANGE._serialized_start=1243 _DESCRIPTORPROTO_RESERVEDRANGE._serialized_end=1286 _EXTENSIONRANGEOPTIONS._serialized_start=1288 _EXTENSIONRANGEOPTIONS._serialized_end=1391 _FIELDDESCRIPTORPROTO._serialized_start=1394 _FIELDDESCRIPTORPROTO._serialized_end=2119 _FIELDDESCRIPTORPROTO_TYPE._serialized_start=1740 _FIELDDESCRIPTORPROTO_TYPE._serialized_end=2050 _FIELDDESCRIPTORPROTO_LABEL._serialized_start=2052 _FIELDDESCRIPTORPROTO_LABEL._serialized_end=2119 _ONEOFDESCRIPTORPROTO._serialized_start=2121 _ONEOFDESCRIPTORPROTO._serialized_end=2205 _ENUMDESCRIPTORPROTO._serialized_start=2208 _ENUMDESCRIPTORPROTO._serialized_end=2500 _ENUMDESCRIPTORPROTO_ENUMRESERVEDRANGE._serialized_start=2453 _ENUMDESCRIPTORPROTO_ENUMRESERVEDRANGE._serialized_end=2500 _ENUMVALUEDESCRIPTORPROTO._serialized_start=2502 _ENUMVALUEDESCRIPTORPROTO._serialized_end=2610 _SERVICEDESCRIPTORPROTO._serialized_start=2613 _SERVICEDESCRIPTORPROTO._serialized_end=2757 _METHODDESCRIPTORPROTO._serialized_start=2760 _METHODDESCRIPTORPROTO._serialized_end=2953 _FILEOPTIONS._serialized_start=2956 _FILEOPTIONS._serialized_end=3761 _FILEOPTIONS_OPTIMIZEMODE._serialized_start=3686 _FILEOPTIONS_OPTIMIZEMODE._serialized_end=3744 _MESSAGEOPTIONS._serialized_start=3764 _MESSAGEOPTIONS._serialized_end=4024 _FIELDOPTIONS._serialized_start=4027 _FIELDOPTIONS._serialized_end=4473 _FIELDOPTIONS_CTYPE._serialized_start=4354 _FIELDOPTIONS_CTYPE._serialized_end=4401 _FIELDOPTIONS_JSTYPE._serialized_start=4403 _FIELDOPTIONS_JSTYPE._serialized_end=4456 _ONEOFOPTIONS._serialized_start=4475 _ONEOFOPTIONS._serialized_end=4569 _ENUMOPTIONS._serialized_start=4572 _ENUMOPTIONS._serialized_end=4719 _ENUMVALUEOPTIONS._serialized_start=4721 _ENUMVALUEOPTIONS._serialized_end=4846 _SERVICEOPTIONS._serialized_start=4848 _SERVICEOPTIONS._serialized_end=4971 _METHODOPTIONS._serialized_start=4974 _METHODOPTIONS._serialized_end=5275 _METHODOPTIONS_IDEMPOTENCYLEVEL._serialized_start=5184 _METHODOPTIONS_IDEMPOTENCYLEVEL._serialized_end=5264 _UNINTERPRETEDOPTION._serialized_start=5278 _UNINTERPRETEDOPTION._serialized_end=5564 _UNINTERPRETEDOPTION_NAMEPART._serialized_start=5513 _UNINTERPRETEDOPTION_NAMEPART._serialized_end=5564 _SOURCECODEINFO._serialized_start=5567 _SOURCECODEINFO._serialized_end=5780 _SOURCECODEINFO_LOCATION._serialized_start=5646 _SOURCECODEINFO_LOCATION._serialized_end=5780 _GENERATEDCODEINFO._serialized_start=5783 _GENERATEDCODEINFO._serialized_end=5950 _GENERATEDCODEINFO_ANNOTATION._serialized_start=5871 _GENERATEDCODEINFO_ANNOTATION._serialized_end=5950 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/descriptor_pool.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Provides DescriptorPool to use as a container for proto2 descriptors. The DescriptorPool is used in conjection with a DescriptorDatabase to maintain a collection of protocol buffer descriptors for use when dynamically creating message types at runtime. For most applications protocol buffers should be used via modules generated by the protocol buffer compiler tool. This should only be used when the type of protocol buffers used in an application or library cannot be predetermined. Below is a straightforward example on how to use this class:: pool = DescriptorPool() file_descriptor_protos = [ ... ] for file_descriptor_proto in file_descriptor_protos: pool.Add(file_descriptor_proto) my_message_descriptor = pool.FindMessageTypeByName('some.package.MessageType') The message descriptor can be used in conjunction with the message_factory module in order to create a protocol buffer class that can be encoded and decoded. If you want to get a Python class for the specified proto, use the helper functions inside google.protobuf.message_factory directly instead of this class. """ __author__ = 'matthewtoia@google.com (Matt Toia)' import collections import warnings from google.protobuf import descriptor from google.protobuf import descriptor_database from google.protobuf import text_encoding _USE_C_DESCRIPTORS = descriptor._USE_C_DESCRIPTORS # pylint: disable=protected-access def _Deprecated(func): """Mark functions as deprecated.""" def NewFunc(*args, **kwargs): warnings.warn( 'Call to deprecated function %s(). Note: Do add unlinked descriptors ' 'to descriptor_pool is wrong. Use Add() or AddSerializedFile() ' 'instead.' % func.__name__, category=DeprecationWarning) return func(*args, **kwargs) NewFunc.__name__ = func.__name__ NewFunc.__doc__ = func.__doc__ NewFunc.__dict__.update(func.__dict__) return NewFunc def _NormalizeFullyQualifiedName(name): """Remove leading period from fully-qualified type name. Due to b/13860351 in descriptor_database.py, types in the root namespace are generated with a leading period. This function removes that prefix. Args: name (str): The fully-qualified symbol name. Returns: str: The normalized fully-qualified symbol name. """ return name.lstrip('.') def _OptionsOrNone(descriptor_proto): """Returns the value of the field `options`, or None if it is not set.""" if descriptor_proto.HasField('options'): return descriptor_proto.options else: return None def _IsMessageSetExtension(field): return (field.is_extension and field.containing_type.has_options and field.containing_type.GetOptions().message_set_wire_format and field.type == descriptor.FieldDescriptor.TYPE_MESSAGE and field.label == descriptor.FieldDescriptor.LABEL_OPTIONAL) class DescriptorPool(object): """A collection of protobufs dynamically constructed by descriptor protos.""" if _USE_C_DESCRIPTORS: def __new__(cls, descriptor_db=None): # pylint: disable=protected-access return descriptor._message.DescriptorPool(descriptor_db) def __init__(self, descriptor_db=None): """Initializes a Pool of proto buffs. The descriptor_db argument to the constructor is provided to allow specialized file descriptor proto lookup code to be triggered on demand. An example would be an implementation which will read and compile a file specified in a call to FindFileByName() and not require the call to Add() at all. Results from this database will be cached internally here as well. Args: descriptor_db: A secondary source of file descriptors. """ self._internal_db = descriptor_database.DescriptorDatabase() self._descriptor_db = descriptor_db self._descriptors = {} self._enum_descriptors = {} self._service_descriptors = {} self._file_descriptors = {} self._toplevel_extensions = {} # TODO(jieluo): Remove _file_desc_by_toplevel_extension after # maybe year 2020 for compatibility issue (with 3.4.1 only). self._file_desc_by_toplevel_extension = {} self._top_enum_values = {} # We store extensions in two two-level mappings: The first key is the # descriptor of the message being extended, the second key is the extension # full name or its tag number. self._extensions_by_name = collections.defaultdict(dict) self._extensions_by_number = collections.defaultdict(dict) def _CheckConflictRegister(self, desc, desc_name, file_name): """Check if the descriptor name conflicts with another of the same name. Args: desc: Descriptor of a message, enum, service, extension or enum value. desc_name (str): the full name of desc. file_name (str): The file name of descriptor. """ for register, descriptor_type in [ (self._descriptors, descriptor.Descriptor), (self._enum_descriptors, descriptor.EnumDescriptor), (self._service_descriptors, descriptor.ServiceDescriptor), (self._toplevel_extensions, descriptor.FieldDescriptor), (self._top_enum_values, descriptor.EnumValueDescriptor)]: if desc_name in register: old_desc = register[desc_name] if isinstance(old_desc, descriptor.EnumValueDescriptor): old_file = old_desc.type.file.name else: old_file = old_desc.file.name if not isinstance(desc, descriptor_type) or ( old_file != file_name): error_msg = ('Conflict register for file "' + file_name + '": ' + desc_name + ' is already defined in file "' + old_file + '". Please fix the conflict by adding ' 'package name on the proto file, or use different ' 'name for the duplication.') if isinstance(desc, descriptor.EnumValueDescriptor): error_msg += ('\nNote: enum values appear as ' 'siblings of the enum type instead of ' 'children of it.') raise TypeError(error_msg) return def Add(self, file_desc_proto): """Adds the FileDescriptorProto and its types to this pool. Args: file_desc_proto (FileDescriptorProto): The file descriptor to add. """ self._internal_db.Add(file_desc_proto) def AddSerializedFile(self, serialized_file_desc_proto): """Adds the FileDescriptorProto and its types to this pool. Args: serialized_file_desc_proto (bytes): A bytes string, serialization of the :class:`FileDescriptorProto` to add. Returns: FileDescriptor: Descriptor for the added file. """ # pylint: disable=g-import-not-at-top from google.protobuf import descriptor_pb2 file_desc_proto = descriptor_pb2.FileDescriptorProto.FromString( serialized_file_desc_proto) file_desc = self._ConvertFileProtoToFileDescriptor(file_desc_proto) file_desc.serialized_pb = serialized_file_desc_proto return file_desc # Add Descriptor to descriptor pool is dreprecated. Please use Add() # or AddSerializedFile() to add a FileDescriptorProto instead. @_Deprecated def AddDescriptor(self, desc): self._AddDescriptor(desc) # Never call this method. It is for internal usage only. def _AddDescriptor(self, desc): """Adds a Descriptor to the pool, non-recursively. If the Descriptor contains nested messages or enums, the caller must explicitly register them. This method also registers the FileDescriptor associated with the message. Args: desc: A Descriptor. """ if not isinstance(desc, descriptor.Descriptor): raise TypeError('Expected instance of descriptor.Descriptor.') self._CheckConflictRegister(desc, desc.full_name, desc.file.name) self._descriptors[desc.full_name] = desc self._AddFileDescriptor(desc.file) # Add EnumDescriptor to descriptor pool is dreprecated. Please use Add() # or AddSerializedFile() to add a FileDescriptorProto instead. @_Deprecated def AddEnumDescriptor(self, enum_desc): self._AddEnumDescriptor(enum_desc) # Never call this method. It is for internal usage only. def _AddEnumDescriptor(self, enum_desc): """Adds an EnumDescriptor to the pool. This method also registers the FileDescriptor associated with the enum. Args: enum_desc: An EnumDescriptor. """ if not isinstance(enum_desc, descriptor.EnumDescriptor): raise TypeError('Expected instance of descriptor.EnumDescriptor.') file_name = enum_desc.file.name self._CheckConflictRegister(enum_desc, enum_desc.full_name, file_name) self._enum_descriptors[enum_desc.full_name] = enum_desc # Top enum values need to be indexed. # Count the number of dots to see whether the enum is toplevel or nested # in a message. We cannot use enum_desc.containing_type at this stage. if enum_desc.file.package: top_level = (enum_desc.full_name.count('.') - enum_desc.file.package.count('.') == 1) else: top_level = enum_desc.full_name.count('.') == 0 if top_level: file_name = enum_desc.file.name package = enum_desc.file.package for enum_value in enum_desc.values: full_name = _NormalizeFullyQualifiedName( '.'.join((package, enum_value.name))) self._CheckConflictRegister(enum_value, full_name, file_name) self._top_enum_values[full_name] = enum_value self._AddFileDescriptor(enum_desc.file) # Add ServiceDescriptor to descriptor pool is dreprecated. Please use Add() # or AddSerializedFile() to add a FileDescriptorProto instead. @_Deprecated def AddServiceDescriptor(self, service_desc): self._AddServiceDescriptor(service_desc) # Never call this method. It is for internal usage only. def _AddServiceDescriptor(self, service_desc): """Adds a ServiceDescriptor to the pool. Args: service_desc: A ServiceDescriptor. """ if not isinstance(service_desc, descriptor.ServiceDescriptor): raise TypeError('Expected instance of descriptor.ServiceDescriptor.') self._CheckConflictRegister(service_desc, service_desc.full_name, service_desc.file.name) self._service_descriptors[service_desc.full_name] = service_desc # Add ExtensionDescriptor to descriptor pool is dreprecated. Please use Add() # or AddSerializedFile() to add a FileDescriptorProto instead. @_Deprecated def AddExtensionDescriptor(self, extension): self._AddExtensionDescriptor(extension) # Never call this method. It is for internal usage only. def _AddExtensionDescriptor(self, extension): """Adds a FieldDescriptor describing an extension to the pool. Args: extension: A FieldDescriptor. Raises: AssertionError: when another extension with the same number extends the same message. TypeError: when the specified extension is not a descriptor.FieldDescriptor. """ if not (isinstance(extension, descriptor.FieldDescriptor) and extension.is_extension): raise TypeError('Expected an extension descriptor.') if extension.extension_scope is None: self._toplevel_extensions[extension.full_name] = extension try: existing_desc = self._extensions_by_number[ extension.containing_type][extension.number] except KeyError: pass else: if extension is not existing_desc: raise AssertionError( 'Extensions "%s" and "%s" both try to extend message type "%s" ' 'with field number %d.' % (extension.full_name, existing_desc.full_name, extension.containing_type.full_name, extension.number)) self._extensions_by_number[extension.containing_type][ extension.number] = extension self._extensions_by_name[extension.containing_type][ extension.full_name] = extension # Also register MessageSet extensions with the type name. if _IsMessageSetExtension(extension): self._extensions_by_name[extension.containing_type][ extension.message_type.full_name] = extension @_Deprecated def AddFileDescriptor(self, file_desc): self._InternalAddFileDescriptor(file_desc) # Never call this method. It is for internal usage only. def _InternalAddFileDescriptor(self, file_desc): """Adds a FileDescriptor to the pool, non-recursively. If the FileDescriptor contains messages or enums, the caller must explicitly register them. Args: file_desc: A FileDescriptor. """ self._AddFileDescriptor(file_desc) # TODO(jieluo): This is a temporary solution for FieldDescriptor.file. # FieldDescriptor.file is added in code gen. Remove this solution after # maybe 2020 for compatibility reason (with 3.4.1 only). for extension in file_desc.extensions_by_name.values(): self._file_desc_by_toplevel_extension[ extension.full_name] = file_desc def _AddFileDescriptor(self, file_desc): """Adds a FileDescriptor to the pool, non-recursively. If the FileDescriptor contains messages or enums, the caller must explicitly register them. Args: file_desc: A FileDescriptor. """ if not isinstance(file_desc, descriptor.FileDescriptor): raise TypeError('Expected instance of descriptor.FileDescriptor.') self._file_descriptors[file_desc.name] = file_desc def FindFileByName(self, file_name): """Gets a FileDescriptor by file name. Args: file_name (str): The path to the file to get a descriptor for. Returns: FileDescriptor: The descriptor for the named file. Raises: KeyError: if the file cannot be found in the pool. """ try: return self._file_descriptors[file_name] except KeyError: pass try: file_proto = self._internal_db.FindFileByName(file_name) except KeyError as error: if self._descriptor_db: file_proto = self._descriptor_db.FindFileByName(file_name) else: raise error if not file_proto: raise KeyError('Cannot find a file named %s' % file_name) return self._ConvertFileProtoToFileDescriptor(file_proto) def FindFileContainingSymbol(self, symbol): """Gets the FileDescriptor for the file containing the specified symbol. Args: symbol (str): The name of the symbol to search for. Returns: FileDescriptor: Descriptor for the file that contains the specified symbol. Raises: KeyError: if the file cannot be found in the pool. """ symbol = _NormalizeFullyQualifiedName(symbol) try: return self._InternalFindFileContainingSymbol(symbol) except KeyError: pass try: # Try fallback database. Build and find again if possible. self._FindFileContainingSymbolInDb(symbol) return self._InternalFindFileContainingSymbol(symbol) except KeyError: raise KeyError('Cannot find a file containing %s' % symbol) def _InternalFindFileContainingSymbol(self, symbol): """Gets the already built FileDescriptor containing the specified symbol. Args: symbol (str): The name of the symbol to search for. Returns: FileDescriptor: Descriptor for the file that contains the specified symbol. Raises: KeyError: if the file cannot be found in the pool. """ try: return self._descriptors[symbol].file except KeyError: pass try: return self._enum_descriptors[symbol].file except KeyError: pass try: return self._service_descriptors[symbol].file except KeyError: pass try: return self._top_enum_values[symbol].type.file except KeyError: pass try: return self._file_desc_by_toplevel_extension[symbol] except KeyError: pass # Try fields, enum values and nested extensions inside a message. top_name, _, sub_name = symbol.rpartition('.') try: message = self.FindMessageTypeByName(top_name) assert (sub_name in message.extensions_by_name or sub_name in message.fields_by_name or sub_name in message.enum_values_by_name) return message.file except (KeyError, AssertionError): raise KeyError('Cannot find a file containing %s' % symbol) def FindMessageTypeByName(self, full_name): """Loads the named descriptor from the pool. Args: full_name (str): The full name of the descriptor to load. Returns: Descriptor: The descriptor for the named type. Raises: KeyError: if the message cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) if full_name not in self._descriptors: self._FindFileContainingSymbolInDb(full_name) return self._descriptors[full_name] def FindEnumTypeByName(self, full_name): """Loads the named enum descriptor from the pool. Args: full_name (str): The full name of the enum descriptor to load. Returns: EnumDescriptor: The enum descriptor for the named type. Raises: KeyError: if the enum cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) if full_name not in self._enum_descriptors: self._FindFileContainingSymbolInDb(full_name) return self._enum_descriptors[full_name] def FindFieldByName(self, full_name): """Loads the named field descriptor from the pool. Args: full_name (str): The full name of the field descriptor to load. Returns: FieldDescriptor: The field descriptor for the named field. Raises: KeyError: if the field cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) message_name, _, field_name = full_name.rpartition('.') message_descriptor = self.FindMessageTypeByName(message_name) return message_descriptor.fields_by_name[field_name] def FindOneofByName(self, full_name): """Loads the named oneof descriptor from the pool. Args: full_name (str): The full name of the oneof descriptor to load. Returns: OneofDescriptor: The oneof descriptor for the named oneof. Raises: KeyError: if the oneof cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) message_name, _, oneof_name = full_name.rpartition('.') message_descriptor = self.FindMessageTypeByName(message_name) return message_descriptor.oneofs_by_name[oneof_name] def FindExtensionByName(self, full_name): """Loads the named extension descriptor from the pool. Args: full_name (str): The full name of the extension descriptor to load. Returns: FieldDescriptor: The field descriptor for the named extension. Raises: KeyError: if the extension cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) try: # The proto compiler does not give any link between the FileDescriptor # and top-level extensions unless the FileDescriptorProto is added to # the DescriptorDatabase, but this can impact memory usage. # So we registered these extensions by name explicitly. return self._toplevel_extensions[full_name] except KeyError: pass message_name, _, extension_name = full_name.rpartition('.') try: # Most extensions are nested inside a message. scope = self.FindMessageTypeByName(message_name) except KeyError: # Some extensions are defined at file scope. scope = self._FindFileContainingSymbolInDb(full_name) return scope.extensions_by_name[extension_name] def FindExtensionByNumber(self, message_descriptor, number): """Gets the extension of the specified message with the specified number. Extensions have to be registered to this pool by calling :func:`Add` or :func:`AddExtensionDescriptor`. Args: message_descriptor (Descriptor): descriptor of the extended message. number (int): Number of the extension field. Returns: FieldDescriptor: The descriptor for the extension. Raises: KeyError: when no extension with the given number is known for the specified message. """ try: return self._extensions_by_number[message_descriptor][number] except KeyError: self._TryLoadExtensionFromDB(message_descriptor, number) return self._extensions_by_number[message_descriptor][number] def FindAllExtensions(self, message_descriptor): """Gets all the known extensions of a given message. Extensions have to be registered to this pool by build related :func:`Add` or :func:`AddExtensionDescriptor`. Args: message_descriptor (Descriptor): Descriptor of the extended message. Returns: list[FieldDescriptor]: Field descriptors describing the extensions. """ # Fallback to descriptor db if FindAllExtensionNumbers is provided. if self._descriptor_db and hasattr( self._descriptor_db, 'FindAllExtensionNumbers'): full_name = message_descriptor.full_name all_numbers = self._descriptor_db.FindAllExtensionNumbers(full_name) for number in all_numbers: if number in self._extensions_by_number[message_descriptor]: continue self._TryLoadExtensionFromDB(message_descriptor, number) return list(self._extensions_by_number[message_descriptor].values()) def _TryLoadExtensionFromDB(self, message_descriptor, number): """Try to Load extensions from descriptor db. Args: message_descriptor: descriptor of the extended message. number: the extension number that needs to be loaded. """ if not self._descriptor_db: return # Only supported when FindFileContainingExtension is provided. if not hasattr( self._descriptor_db, 'FindFileContainingExtension'): return full_name = message_descriptor.full_name file_proto = self._descriptor_db.FindFileContainingExtension( full_name, number) if file_proto is None: return try: self._ConvertFileProtoToFileDescriptor(file_proto) except: warn_msg = ('Unable to load proto file %s for extension number %d.' % (file_proto.name, number)) warnings.warn(warn_msg, RuntimeWarning) def FindServiceByName(self, full_name): """Loads the named service descriptor from the pool. Args: full_name (str): The full name of the service descriptor to load. Returns: ServiceDescriptor: The service descriptor for the named service. Raises: KeyError: if the service cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) if full_name not in self._service_descriptors: self._FindFileContainingSymbolInDb(full_name) return self._service_descriptors[full_name] def FindMethodByName(self, full_name): """Loads the named service method descriptor from the pool. Args: full_name (str): The full name of the method descriptor to load. Returns: MethodDescriptor: The method descriptor for the service method. Raises: KeyError: if the method cannot be found in the pool. """ full_name = _NormalizeFullyQualifiedName(full_name) service_name, _, method_name = full_name.rpartition('.') service_descriptor = self.FindServiceByName(service_name) return service_descriptor.methods_by_name[method_name] def _FindFileContainingSymbolInDb(self, symbol): """Finds the file in descriptor DB containing the specified symbol. Args: symbol (str): The name of the symbol to search for. Returns: FileDescriptor: The file that contains the specified symbol. Raises: KeyError: if the file cannot be found in the descriptor database. """ try: file_proto = self._internal_db.FindFileContainingSymbol(symbol) except KeyError as error: if self._descriptor_db: file_proto = self._descriptor_db.FindFileContainingSymbol(symbol) else: raise error if not file_proto: raise KeyError('Cannot find a file containing %s' % symbol) return self._ConvertFileProtoToFileDescriptor(file_proto) def _ConvertFileProtoToFileDescriptor(self, file_proto): """Creates a FileDescriptor from a proto or returns a cached copy. This method also has the side effect of loading all the symbols found in the file into the appropriate dictionaries in the pool. Args: file_proto: The proto to convert. Returns: A FileDescriptor matching the passed in proto. """ if file_proto.name not in self._file_descriptors: built_deps = list(self._GetDeps(file_proto.dependency)) direct_deps = [self.FindFileByName(n) for n in file_proto.dependency] public_deps = [direct_deps[i] for i in file_proto.public_dependency] file_descriptor = descriptor.FileDescriptor( pool=self, name=file_proto.name, package=file_proto.package, syntax=file_proto.syntax, options=_OptionsOrNone(file_proto), serialized_pb=file_proto.SerializeToString(), dependencies=direct_deps, public_dependencies=public_deps, # pylint: disable=protected-access create_key=descriptor._internal_create_key) scope = {} # This loop extracts all the message and enum types from all the # dependencies of the file_proto. This is necessary to create the # scope of available message types when defining the passed in # file proto. for dependency in built_deps: scope.update(self._ExtractSymbols( dependency.message_types_by_name.values())) scope.update((_PrefixWithDot(enum.full_name), enum) for enum in dependency.enum_types_by_name.values()) for message_type in file_proto.message_type: message_desc = self._ConvertMessageDescriptor( message_type, file_proto.package, file_descriptor, scope, file_proto.syntax) file_descriptor.message_types_by_name[message_desc.name] = ( message_desc) for enum_type in file_proto.enum_type: file_descriptor.enum_types_by_name[enum_type.name] = ( self._ConvertEnumDescriptor(enum_type, file_proto.package, file_descriptor, None, scope, True)) for index, extension_proto in enumerate(file_proto.extension): extension_desc = self._MakeFieldDescriptor( extension_proto, file_proto.package, index, file_descriptor, is_extension=True) extension_desc.containing_type = self._GetTypeFromScope( file_descriptor.package, extension_proto.extendee, scope) self._SetFieldType(extension_proto, extension_desc, file_descriptor.package, scope) file_descriptor.extensions_by_name[extension_desc.name] = ( extension_desc) self._file_desc_by_toplevel_extension[extension_desc.full_name] = ( file_descriptor) for desc_proto in file_proto.message_type: self._SetAllFieldTypes(file_proto.package, desc_proto, scope) if file_proto.package: desc_proto_prefix = _PrefixWithDot(file_proto.package) else: desc_proto_prefix = '' for desc_proto in file_proto.message_type: desc = self._GetTypeFromScope( desc_proto_prefix, desc_proto.name, scope) file_descriptor.message_types_by_name[desc_proto.name] = desc for index, service_proto in enumerate(file_proto.service): file_descriptor.services_by_name[service_proto.name] = ( self._MakeServiceDescriptor(service_proto, index, scope, file_proto.package, file_descriptor)) self._file_descriptors[file_proto.name] = file_descriptor # Add extensions to the pool file_desc = self._file_descriptors[file_proto.name] for extension in file_desc.extensions_by_name.values(): self._AddExtensionDescriptor(extension) for message_type in file_desc.message_types_by_name.values(): for extension in message_type.extensions: self._AddExtensionDescriptor(extension) return file_desc def _ConvertMessageDescriptor(self, desc_proto, package=None, file_desc=None, scope=None, syntax=None): """Adds the proto to the pool in the specified package. Args: desc_proto: The descriptor_pb2.DescriptorProto protobuf message. package: The package the proto should be located in. file_desc: The file containing this message. scope: Dict mapping short and full symbols to message and enum types. syntax: string indicating syntax of the file ("proto2" or "proto3") Returns: The added descriptor. """ if package: desc_name = '.'.join((package, desc_proto.name)) else: desc_name = desc_proto.name if file_desc is None: file_name = None else: file_name = file_desc.name if scope is None: scope = {} nested = [ self._ConvertMessageDescriptor( nested, desc_name, file_desc, scope, syntax) for nested in desc_proto.nested_type] enums = [ self._ConvertEnumDescriptor(enum, desc_name, file_desc, None, scope, False) for enum in desc_proto.enum_type] fields = [self._MakeFieldDescriptor(field, desc_name, index, file_desc) for index, field in enumerate(desc_proto.field)] extensions = [ self._MakeFieldDescriptor(extension, desc_name, index, file_desc, is_extension=True) for index, extension in enumerate(desc_proto.extension)] oneofs = [ # pylint: disable=g-complex-comprehension descriptor.OneofDescriptor( desc.name, '.'.join((desc_name, desc.name)), index, None, [], _OptionsOrNone(desc), # pylint: disable=protected-access create_key=descriptor._internal_create_key) for index, desc in enumerate(desc_proto.oneof_decl) ] extension_ranges = [(r.start, r.end) for r in desc_proto.extension_range] if extension_ranges: is_extendable = True else: is_extendable = False desc = descriptor.Descriptor( name=desc_proto.name, full_name=desc_name, filename=file_name, containing_type=None, fields=fields, oneofs=oneofs, nested_types=nested, enum_types=enums, extensions=extensions, options=_OptionsOrNone(desc_proto), is_extendable=is_extendable, extension_ranges=extension_ranges, file=file_desc, serialized_start=None, serialized_end=None, syntax=syntax, # pylint: disable=protected-access create_key=descriptor._internal_create_key) for nested in desc.nested_types: nested.containing_type = desc for enum in desc.enum_types: enum.containing_type = desc for field_index, field_desc in enumerate(desc_proto.field): if field_desc.HasField('oneof_index'): oneof_index = field_desc.oneof_index oneofs[oneof_index].fields.append(fields[field_index]) fields[field_index].containing_oneof = oneofs[oneof_index] scope[_PrefixWithDot(desc_name)] = desc self._CheckConflictRegister(desc, desc.full_name, desc.file.name) self._descriptors[desc_name] = desc return desc def _ConvertEnumDescriptor(self, enum_proto, package=None, file_desc=None, containing_type=None, scope=None, top_level=False): """Make a protobuf EnumDescriptor given an EnumDescriptorProto protobuf. Args: enum_proto: The descriptor_pb2.EnumDescriptorProto protobuf message. package: Optional package name for the new message EnumDescriptor. file_desc: The file containing the enum descriptor. containing_type: The type containing this enum. scope: Scope containing available types. top_level: If True, the enum is a top level symbol. If False, the enum is defined inside a message. Returns: The added descriptor """ if package: enum_name = '.'.join((package, enum_proto.name)) else: enum_name = enum_proto.name if file_desc is None: file_name = None else: file_name = file_desc.name values = [self._MakeEnumValueDescriptor(value, index) for index, value in enumerate(enum_proto.value)] desc = descriptor.EnumDescriptor(name=enum_proto.name, full_name=enum_name, filename=file_name, file=file_desc, values=values, containing_type=containing_type, options=_OptionsOrNone(enum_proto), # pylint: disable=protected-access create_key=descriptor._internal_create_key) scope['.%s' % enum_name] = desc self._CheckConflictRegister(desc, desc.full_name, desc.file.name) self._enum_descriptors[enum_name] = desc # Add top level enum values. if top_level: for value in values: full_name = _NormalizeFullyQualifiedName( '.'.join((package, value.name))) self._CheckConflictRegister(value, full_name, file_name) self._top_enum_values[full_name] = value return desc def _MakeFieldDescriptor(self, field_proto, message_name, index, file_desc, is_extension=False): """Creates a field descriptor from a FieldDescriptorProto. For message and enum type fields, this method will do a look up in the pool for the appropriate descriptor for that type. If it is unavailable, it will fall back to the _source function to create it. If this type is still unavailable, construction will fail. Args: field_proto: The proto describing the field. message_name: The name of the containing message. index: Index of the field file_desc: The file containing the field descriptor. is_extension: Indication that this field is for an extension. Returns: An initialized FieldDescriptor object """ if message_name: full_name = '.'.join((message_name, field_proto.name)) else: full_name = field_proto.name if field_proto.json_name: json_name = field_proto.json_name else: json_name = None return descriptor.FieldDescriptor( name=field_proto.name, full_name=full_name, index=index, number=field_proto.number, type=field_proto.type, cpp_type=None, message_type=None, enum_type=None, containing_type=None, label=field_proto.label, has_default_value=False, default_value=None, is_extension=is_extension, extension_scope=None, options=_OptionsOrNone(field_proto), json_name=json_name, file=file_desc, # pylint: disable=protected-access create_key=descriptor._internal_create_key) def _SetAllFieldTypes(self, package, desc_proto, scope): """Sets all the descriptor's fields's types. This method also sets the containing types on any extensions. Args: package: The current package of desc_proto. desc_proto: The message descriptor to update. scope: Enclosing scope of available types. """ package = _PrefixWithDot(package) main_desc = self._GetTypeFromScope(package, desc_proto.name, scope) if package == '.': nested_package = _PrefixWithDot(desc_proto.name) else: nested_package = '.'.join([package, desc_proto.name]) for field_proto, field_desc in zip(desc_proto.field, main_desc.fields): self._SetFieldType(field_proto, field_desc, nested_package, scope) for extension_proto, extension_desc in ( zip(desc_proto.extension, main_desc.extensions)): extension_desc.containing_type = self._GetTypeFromScope( nested_package, extension_proto.extendee, scope) self._SetFieldType(extension_proto, extension_desc, nested_package, scope) for nested_type in desc_proto.nested_type: self._SetAllFieldTypes(nested_package, nested_type, scope) def _SetFieldType(self, field_proto, field_desc, package, scope): """Sets the field's type, cpp_type, message_type and enum_type. Args: field_proto: Data about the field in proto format. field_desc: The descriptor to modify. package: The package the field's container is in. scope: Enclosing scope of available types. """ if field_proto.type_name: desc = self._GetTypeFromScope(package, field_proto.type_name, scope) else: desc = None if not field_proto.HasField('type'): if isinstance(desc, descriptor.Descriptor): field_proto.type = descriptor.FieldDescriptor.TYPE_MESSAGE else: field_proto.type = descriptor.FieldDescriptor.TYPE_ENUM field_desc.cpp_type = descriptor.FieldDescriptor.ProtoTypeToCppProtoType( field_proto.type) if (field_proto.type == descriptor.FieldDescriptor.TYPE_MESSAGE or field_proto.type == descriptor.FieldDescriptor.TYPE_GROUP): field_desc.message_type = desc if field_proto.type == descriptor.FieldDescriptor.TYPE_ENUM: field_desc.enum_type = desc if field_proto.label == descriptor.FieldDescriptor.LABEL_REPEATED: field_desc.has_default_value = False field_desc.default_value = [] elif field_proto.HasField('default_value'): field_desc.has_default_value = True if (field_proto.type == descriptor.FieldDescriptor.TYPE_DOUBLE or field_proto.type == descriptor.FieldDescriptor.TYPE_FLOAT): field_desc.default_value = float(field_proto.default_value) elif field_proto.type == descriptor.FieldDescriptor.TYPE_STRING: field_desc.default_value = field_proto.default_value elif field_proto.type == descriptor.FieldDescriptor.TYPE_BOOL: field_desc.default_value = field_proto.default_value.lower() == 'true' elif field_proto.type == descriptor.FieldDescriptor.TYPE_ENUM: field_desc.default_value = field_desc.enum_type.values_by_name[ field_proto.default_value].number elif field_proto.type == descriptor.FieldDescriptor.TYPE_BYTES: field_desc.default_value = text_encoding.CUnescape( field_proto.default_value) elif field_proto.type == descriptor.FieldDescriptor.TYPE_MESSAGE: field_desc.default_value = None else: # All other types are of the "int" type. field_desc.default_value = int(field_proto.default_value) else: field_desc.has_default_value = False if (field_proto.type == descriptor.FieldDescriptor.TYPE_DOUBLE or field_proto.type == descriptor.FieldDescriptor.TYPE_FLOAT): field_desc.default_value = 0.0 elif field_proto.type == descriptor.FieldDescriptor.TYPE_STRING: field_desc.default_value = u'' elif field_proto.type == descriptor.FieldDescriptor.TYPE_BOOL: field_desc.default_value = False elif field_proto.type == descriptor.FieldDescriptor.TYPE_ENUM: field_desc.default_value = field_desc.enum_type.values[0].number elif field_proto.type == descriptor.FieldDescriptor.TYPE_BYTES: field_desc.default_value = b'' elif field_proto.type == descriptor.FieldDescriptor.TYPE_MESSAGE: field_desc.default_value = None elif field_proto.type == descriptor.FieldDescriptor.TYPE_GROUP: field_desc.default_value = None else: # All other types are of the "int" type. field_desc.default_value = 0 field_desc.type = field_proto.type def _MakeEnumValueDescriptor(self, value_proto, index): """Creates a enum value descriptor object from a enum value proto. Args: value_proto: The proto describing the enum value. index: The index of the enum value. Returns: An initialized EnumValueDescriptor object. """ return descriptor.EnumValueDescriptor( name=value_proto.name, index=index, number=value_proto.number, options=_OptionsOrNone(value_proto), type=None, # pylint: disable=protected-access create_key=descriptor._internal_create_key) def _MakeServiceDescriptor(self, service_proto, service_index, scope, package, file_desc): """Make a protobuf ServiceDescriptor given a ServiceDescriptorProto. Args: service_proto: The descriptor_pb2.ServiceDescriptorProto protobuf message. service_index: The index of the service in the File. scope: Dict mapping short and full symbols to message and enum types. package: Optional package name for the new message EnumDescriptor. file_desc: The file containing the service descriptor. Returns: The added descriptor. """ if package: service_name = '.'.join((package, service_proto.name)) else: service_name = service_proto.name methods = [self._MakeMethodDescriptor(method_proto, service_name, package, scope, index) for index, method_proto in enumerate(service_proto.method)] desc = descriptor.ServiceDescriptor( name=service_proto.name, full_name=service_name, index=service_index, methods=methods, options=_OptionsOrNone(service_proto), file=file_desc, # pylint: disable=protected-access create_key=descriptor._internal_create_key) self._CheckConflictRegister(desc, desc.full_name, desc.file.name) self._service_descriptors[service_name] = desc return desc def _MakeMethodDescriptor(self, method_proto, service_name, package, scope, index): """Creates a method descriptor from a MethodDescriptorProto. Args: method_proto: The proto describing the method. service_name: The name of the containing service. package: Optional package name to look up for types. scope: Scope containing available types. index: Index of the method in the service. Returns: An initialized MethodDescriptor object. """ full_name = '.'.join((service_name, method_proto.name)) input_type = self._GetTypeFromScope( package, method_proto.input_type, scope) output_type = self._GetTypeFromScope( package, method_proto.output_type, scope) return descriptor.MethodDescriptor( name=method_proto.name, full_name=full_name, index=index, containing_service=None, input_type=input_type, output_type=output_type, client_streaming=method_proto.client_streaming, server_streaming=method_proto.server_streaming, options=_OptionsOrNone(method_proto), # pylint: disable=protected-access create_key=descriptor._internal_create_key) def _ExtractSymbols(self, descriptors): """Pulls out all the symbols from descriptor protos. Args: descriptors: The messages to extract descriptors from. Yields: A two element tuple of the type name and descriptor object. """ for desc in descriptors: yield (_PrefixWithDot(desc.full_name), desc) for symbol in self._ExtractSymbols(desc.nested_types): yield symbol for enum in desc.enum_types: yield (_PrefixWithDot(enum.full_name), enum) def _GetDeps(self, dependencies, visited=None): """Recursively finds dependencies for file protos. Args: dependencies: The names of the files being depended on. visited: The names of files already found. Yields: Each direct and indirect dependency. """ visited = visited or set() for dependency in dependencies: if dependency not in visited: visited.add(dependency) dep_desc = self.FindFileByName(dependency) yield dep_desc public_files = [d.name for d in dep_desc.public_dependencies] yield from self._GetDeps(public_files, visited) def _GetTypeFromScope(self, package, type_name, scope): """Finds a given type name in the current scope. Args: package: The package the proto should be located in. type_name: The name of the type to be found in the scope. scope: Dict mapping short and full symbols to message and enum types. Returns: The descriptor for the requested type. """ if type_name not in scope: components = _PrefixWithDot(package).split('.') while components: possible_match = '.'.join(components + [type_name]) if possible_match in scope: type_name = possible_match break else: components.pop(-1) return scope[type_name] def _PrefixWithDot(name): return name if name.startswith('.') else '.%s' % name if _USE_C_DESCRIPTORS: # TODO(amauryfa): This pool could be constructed from Python code, when we # support a flag like 'use_cpp_generated_pool=True'. # pylint: disable=protected-access _DEFAULT = descriptor._message.default_pool else: _DEFAULT = DescriptorPool() def Default(): return _DEFAULT ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/duration_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/duration.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1egoogle/protobuf/duration.proto\x12\x0fgoogle.protobuf\"*\n\x08\x44uration\x12\x0f\n\x07seconds\x18\x01 \x01(\x03\x12\r\n\x05nanos\x18\x02 \x01(\x05\x42\x83\x01\n\x13\x63om.google.protobufB\rDurationProtoP\x01Z1google.golang.org/protobuf/types/known/durationpb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.duration_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\rDurationProtoP\001Z1google.golang.org/protobuf/types/known/durationpb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _DURATION._serialized_start=51 _DURATION._serialized_end=93 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/empty_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/empty.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bgoogle/protobuf/empty.proto\x12\x0fgoogle.protobuf\"\x07\n\x05\x45mptyB}\n\x13\x63om.google.protobufB\nEmptyProtoP\x01Z.google.golang.org/protobuf/types/known/emptypb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.empty_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\nEmptyProtoP\001Z.google.golang.org/protobuf/types/known/emptypb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _EMPTY._serialized_start=48 _EMPTY._serialized_end=55 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/field_mask_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/field_mask.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n google/protobuf/field_mask.proto\x12\x0fgoogle.protobuf\"\x1a\n\tFieldMask\x12\r\n\x05paths\x18\x01 \x03(\tB\x85\x01\n\x13\x63om.google.protobufB\x0e\x46ieldMaskProtoP\x01Z2google.golang.org/protobuf/types/known/fieldmaskpb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.field_mask_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\016FieldMaskProtoP\001Z2google.golang.org/protobuf/types/known/fieldmaskpb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _FIELDMASK._serialized_start=53 _FIELDMASK._serialized_end=79 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/__init__.py ================================================ ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/_parameterized.py ================================================ #! /usr/bin/env python # # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Adds support for parameterized tests to Python's unittest TestCase class. A parameterized test is a method in a test case that is invoked with different argument tuples. A simple example: class AdditionExample(parameterized.TestCase): @parameterized.parameters( (1, 2, 3), (4, 5, 9), (1, 1, 3)) def testAddition(self, op1, op2, result): self.assertEqual(result, op1 + op2) Each invocation is a separate test case and properly isolated just like a normal test method, with its own setUp/tearDown cycle. In the example above, there are three separate testcases, one of which will fail due to an assertion error (1 + 1 != 3). Parameters for individual test cases can be tuples (with positional parameters) or dictionaries (with named parameters): class AdditionExample(parameterized.TestCase): @parameterized.parameters( {'op1': 1, 'op2': 2, 'result': 3}, {'op1': 4, 'op2': 5, 'result': 9}, ) def testAddition(self, op1, op2, result): self.assertEqual(result, op1 + op2) If a parameterized test fails, the error message will show the original test name (which is modified internally) and the arguments for the specific invocation, which are part of the string returned by the shortDescription() method on test cases. The id method of the test, used internally by the unittest framework, is also modified to show the arguments. To make sure that test names stay the same across several invocations, object representations like >>> class Foo(object): ... pass >>> repr(Foo()) '<__main__.Foo object at 0x23d8610>' are turned into '<__main__.Foo>'. For even more descriptive names, especially in test logs, you can use the named_parameters decorator. In this case, only tuples are supported, and the first parameters has to be a string (or an object that returns an apt name when converted via str()): class NamedExample(parameterized.TestCase): @parameterized.named_parameters( ('Normal', 'aa', 'aaa', True), ('EmptyPrefix', '', 'abc', True), ('BothEmpty', '', '', True)) def testStartsWith(self, prefix, string, result): self.assertEqual(result, strings.startswith(prefix)) Named tests also have the benefit that they can be run individually from the command line: $ testmodule.py NamedExample.testStartsWithNormal . -------------------------------------------------------------------- Ran 1 test in 0.000s OK Parameterized Classes ===================== If invocation arguments are shared across test methods in a single TestCase class, instead of decorating all test methods individually, the class itself can be decorated: @parameterized.parameters( (1, 2, 3) (4, 5, 9)) class ArithmeticTest(parameterized.TestCase): def testAdd(self, arg1, arg2, result): self.assertEqual(arg1 + arg2, result) def testSubtract(self, arg2, arg2, result): self.assertEqual(result - arg1, arg2) Inputs from Iterables ===================== If parameters should be shared across several test cases, or are dynamically created from other sources, a single non-tuple iterable can be passed into the decorator. This iterable will be used to obtain the test cases: class AdditionExample(parameterized.TestCase): @parameterized.parameters( c.op1, c.op2, c.result for c in testcases ) def testAddition(self, op1, op2, result): self.assertEqual(result, op1 + op2) Single-Argument Test Methods ============================ If a test method takes only one argument, the single argument does not need to be wrapped into a tuple: class NegativeNumberExample(parameterized.TestCase): @parameterized.parameters( -1, -3, -4, -5 ) def testIsNegative(self, arg): self.assertTrue(IsNegative(arg)) """ __author__ = 'tmarek@google.com (Torsten Marek)' import functools import re import types import unittest import uuid try: # Since python 3 import collections.abc as collections_abc except ImportError: # Won't work after python 3.8 import collections as collections_abc ADDR_RE = re.compile(r'\<([a-zA-Z0-9_\-\.]+) object at 0x[a-fA-F0-9]+\>') _SEPARATOR = uuid.uuid1().hex _FIRST_ARG = object() _ARGUMENT_REPR = object() def _CleanRepr(obj): return ADDR_RE.sub(r'<\1>', repr(obj)) # Helper function formerly from the unittest module, removed from it in # Python 2.7. def _StrClass(cls): return '%s.%s' % (cls.__module__, cls.__name__) def _NonStringIterable(obj): return (isinstance(obj, collections_abc.Iterable) and not isinstance(obj, str)) def _FormatParameterList(testcase_params): if isinstance(testcase_params, collections_abc.Mapping): return ', '.join('%s=%s' % (argname, _CleanRepr(value)) for argname, value in testcase_params.items()) elif _NonStringIterable(testcase_params): return ', '.join(map(_CleanRepr, testcase_params)) else: return _FormatParameterList((testcase_params,)) class _ParameterizedTestIter(object): """Callable and iterable class for producing new test cases.""" def __init__(self, test_method, testcases, naming_type): """Returns concrete test functions for a test and a list of parameters. The naming_type is used to determine the name of the concrete functions as reported by the unittest framework. If naming_type is _FIRST_ARG, the testcases must be tuples, and the first element must have a string representation that is a valid Python identifier. Args: test_method: The decorated test method. testcases: (list of tuple/dict) A list of parameter tuples/dicts for individual test invocations. naming_type: The test naming type, either _NAMED or _ARGUMENT_REPR. """ self._test_method = test_method self.testcases = testcases self._naming_type = naming_type def __call__(self, *args, **kwargs): raise RuntimeError('You appear to be running a parameterized test case ' 'without having inherited from parameterized.' 'TestCase. This is bad because none of ' 'your test cases are actually being run.') def __iter__(self): test_method = self._test_method naming_type = self._naming_type def MakeBoundParamTest(testcase_params): @functools.wraps(test_method) def BoundParamTest(self): if isinstance(testcase_params, collections_abc.Mapping): test_method(self, **testcase_params) elif _NonStringIterable(testcase_params): test_method(self, *testcase_params) else: test_method(self, testcase_params) if naming_type is _FIRST_ARG: # Signal the metaclass that the name of the test function is unique # and descriptive. BoundParamTest.__x_use_name__ = True BoundParamTest.__name__ += str(testcase_params[0]) testcase_params = testcase_params[1:] elif naming_type is _ARGUMENT_REPR: # __x_extra_id__ is used to pass naming information to the __new__ # method of TestGeneratorMetaclass. # The metaclass will make sure to create a unique, but nondescriptive # name for this test. BoundParamTest.__x_extra_id__ = '(%s)' % ( _FormatParameterList(testcase_params),) else: raise RuntimeError('%s is not a valid naming type.' % (naming_type,)) BoundParamTest.__doc__ = '%s(%s)' % ( BoundParamTest.__name__, _FormatParameterList(testcase_params)) if test_method.__doc__: BoundParamTest.__doc__ += '\n%s' % (test_method.__doc__,) return BoundParamTest return (MakeBoundParamTest(c) for c in self.testcases) def _IsSingletonList(testcases): """True iff testcases contains only a single non-tuple element.""" return len(testcases) == 1 and not isinstance(testcases[0], tuple) def _ModifyClass(class_object, testcases, naming_type): assert not getattr(class_object, '_id_suffix', None), ( 'Cannot add parameters to %s,' ' which already has parameterized methods.' % (class_object,)) class_object._id_suffix = id_suffix = {} # We change the size of __dict__ while we iterate over it, # which Python 3.x will complain about, so use copy(). for name, obj in class_object.__dict__.copy().items(): if (name.startswith(unittest.TestLoader.testMethodPrefix) and isinstance(obj, types.FunctionType)): delattr(class_object, name) methods = {} _UpdateClassDictForParamTestCase( methods, id_suffix, name, _ParameterizedTestIter(obj, testcases, naming_type)) for name, meth in methods.items(): setattr(class_object, name, meth) def _ParameterDecorator(naming_type, testcases): """Implementation of the parameterization decorators. Args: naming_type: The naming type. testcases: Testcase parameters. Returns: A function for modifying the decorated object. """ def _Apply(obj): if isinstance(obj, type): _ModifyClass( obj, list(testcases) if not isinstance(testcases, collections_abc.Sequence) else testcases, naming_type) return obj else: return _ParameterizedTestIter(obj, testcases, naming_type) if _IsSingletonList(testcases): assert _NonStringIterable(testcases[0]), ( 'Single parameter argument must be a non-string iterable') testcases = testcases[0] return _Apply def parameters(*testcases): # pylint: disable=invalid-name """A decorator for creating parameterized tests. See the module docstring for a usage example. Args: *testcases: Parameters for the decorated method, either a single iterable, or a list of tuples/dicts/objects (for tests with only one argument). Returns: A test generator to be handled by TestGeneratorMetaclass. """ return _ParameterDecorator(_ARGUMENT_REPR, testcases) def named_parameters(*testcases): # pylint: disable=invalid-name """A decorator for creating parameterized tests. See the module docstring for a usage example. The first element of each parameter tuple should be a string and will be appended to the name of the test method. Args: *testcases: Parameters for the decorated method, either a single iterable, or a list of tuples. Returns: A test generator to be handled by TestGeneratorMetaclass. """ return _ParameterDecorator(_FIRST_ARG, testcases) class TestGeneratorMetaclass(type): """Metaclass for test cases with test generators. A test generator is an iterable in a testcase that produces callables. These callables must be single-argument methods. These methods are injected into the class namespace and the original iterable is removed. If the name of the iterable conforms to the test pattern, the injected methods will be picked up as tests by the unittest framework. In general, it is supposed to be used in conjunction with the parameters decorator. """ def __new__(mcs, class_name, bases, dct): dct['_id_suffix'] = id_suffix = {} for name, obj in dct.copy().items(): if (name.startswith(unittest.TestLoader.testMethodPrefix) and _NonStringIterable(obj)): iterator = iter(obj) dct.pop(name) _UpdateClassDictForParamTestCase(dct, id_suffix, name, iterator) return type.__new__(mcs, class_name, bases, dct) def _UpdateClassDictForParamTestCase(dct, id_suffix, name, iterator): """Adds individual test cases to a dictionary. Args: dct: The target dictionary. id_suffix: The dictionary for mapping names to test IDs. name: The original name of the test case. iterator: The iterator generating the individual test cases. """ for idx, func in enumerate(iterator): assert callable(func), 'Test generators must yield callables, got %r' % ( func,) if getattr(func, '__x_use_name__', False): new_name = func.__name__ else: new_name = '%s%s%d' % (name, _SEPARATOR, idx) assert new_name not in dct, ( 'Name of parameterized test case "%s" not unique' % (new_name,)) dct[new_name] = func id_suffix[new_name] = getattr(func, '__x_extra_id__', '') class TestCase(unittest.TestCase, metaclass=TestGeneratorMetaclass): """Base class for test cases using the parameters decorator.""" def _OriginalName(self): return self._testMethodName.split(_SEPARATOR)[0] def __str__(self): return '%s (%s)' % (self._OriginalName(), _StrClass(self.__class__)) def id(self): # pylint: disable=invalid-name """Returns the descriptive ID of the test. This is used internally by the unittesting framework to get a name for the test to be used in reports. Returns: The test id. """ return '%s.%s%s' % (_StrClass(self.__class__), self._OriginalName(), self._id_suffix.get(self._testMethodName, '')) def CoopTestCase(other_base_class): """Returns a new base class with a cooperative metaclass base. This enables the TestCase to be used in combination with other base classes that have custom metaclasses, such as mox.MoxTestBase. Only works with metaclasses that do not override type.__new__. Example: import google3 import mox from google3.testing.pybase import parameterized class ExampleTest(parameterized.CoopTestCase(mox.MoxTestBase)): ... Args: other_base_class: (class) A test case base class. Returns: A new class object. """ metaclass = type( 'CoopMetaclass', (other_base_class.__metaclass__, TestGeneratorMetaclass), {}) return metaclass( 'CoopTestCase', (other_base_class, TestCase), {}) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/api_implementation.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Determine which implementation of the protobuf API is used in this process. """ import os import sys import warnings try: # pylint: disable=g-import-not-at-top from google.protobuf.internal import _api_implementation # The compile-time constants in the _api_implementation module can be used to # switch to a certain implementation of the Python API at build time. _api_version = _api_implementation.api_version except ImportError: _api_version = -1 # Unspecified by compiler flags. if _api_version == 1: raise ValueError('api_version=1 is no longer supported.') _default_implementation_type = ('cpp' if _api_version > 0 else 'python') # This environment variable can be used to switch to a certain implementation # of the Python API, overriding the compile-time constants in the # _api_implementation module. Right now only 'python' and 'cpp' are valid # values. Any other value will be ignored. _implementation_type = os.getenv('PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION', _default_implementation_type) if _implementation_type != 'python': _implementation_type = 'cpp' if 'PyPy' in sys.version and _implementation_type == 'cpp': warnings.warn('PyPy does not work yet with cpp protocol buffers. ' 'Falling back to the python implementation.') _implementation_type = 'python' # Detect if serialization should be deterministic by default try: # The presence of this module in a build allows the proto implementation to # be upgraded merely via build deps. # # NOTE: Merely importing this automatically enables deterministic proto # serialization for C++ code, but we still need to export it as a boolean so # that we can do the same for `_implementation_type == 'python'`. # # NOTE2: It is possible for C++ code to enable deterministic serialization by # default _without_ affecting Python code, if the C++ implementation is not in # use by this module. That is intended behavior, so we don't actually expose # this boolean outside of this module. # # pylint: disable=g-import-not-at-top,unused-import from google.protobuf import enable_deterministic_proto_serialization _python_deterministic_proto_serialization = True except ImportError: _python_deterministic_proto_serialization = False # Usage of this function is discouraged. Clients shouldn't care which # implementation of the API is in use. Note that there is no guarantee # that differences between APIs will be maintained. # Please don't use this function if possible. def Type(): return _implementation_type def _SetType(implementation_type): """Never use! Only for protobuf benchmark.""" global _implementation_type _implementation_type = implementation_type # See comment on 'Type' above. def Version(): return 2 # For internal use only def IsPythonDefaultSerializationDeterministic(): return _python_deterministic_proto_serialization ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/builder.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Builds descriptors, message classes and services for generated _pb2.py. This file is only called in python generated _pb2.py files. It builds descriptors, message classes and services that users can directly use in generated code. """ __author__ = 'jieluo@google.com (Jie Luo)' from google.protobuf.internal import enum_type_wrapper from google.protobuf import message as _message from google.protobuf import reflection as _reflection from google.protobuf import symbol_database as _symbol_database _sym_db = _symbol_database.Default() def BuildMessageAndEnumDescriptors(file_des, module): """Builds message and enum descriptors. Args: file_des: FileDescriptor of the .proto file module: Generated _pb2 module """ def BuildNestedDescriptors(msg_des, prefix): for (name, nested_msg) in msg_des.nested_types_by_name.items(): module_name = prefix + name.upper() module[module_name] = nested_msg BuildNestedDescriptors(nested_msg, module_name + '_') for enum_des in msg_des.enum_types: module[prefix + enum_des.name.upper()] = enum_des for (name, msg_des) in file_des.message_types_by_name.items(): module_name = '_' + name.upper() module[module_name] = msg_des BuildNestedDescriptors(msg_des, module_name + '_') def BuildTopDescriptorsAndMessages(file_des, module_name, module): """Builds top level descriptors and message classes. Args: file_des: FileDescriptor of the .proto file module_name: str, the name of generated _pb2 module module: Generated _pb2 module """ def BuildMessage(msg_des): create_dict = {} for (name, nested_msg) in msg_des.nested_types_by_name.items(): create_dict[name] = BuildMessage(nested_msg) create_dict['DESCRIPTOR'] = msg_des create_dict['__module__'] = module_name message_class = _reflection.GeneratedProtocolMessageType( msg_des.name, (_message.Message,), create_dict) _sym_db.RegisterMessage(message_class) return message_class # top level enums for (name, enum_des) in file_des.enum_types_by_name.items(): module['_' + name.upper()] = enum_des module[name] = enum_type_wrapper.EnumTypeWrapper(enum_des) for enum_value in enum_des.values: module[enum_value.name] = enum_value.number # top level extensions for (name, extension_des) in file_des.extensions_by_name.items(): module[name.upper() + '_FIELD_NUMBER'] = extension_des.number module[name] = extension_des # services for (name, service) in file_des.services_by_name.items(): module['_' + name.upper()] = service # Build messages. for (name, msg_des) in file_des.message_types_by_name.items(): module[name] = BuildMessage(msg_des) def BuildServices(file_des, module_name, module): """Builds services classes and services stub class. Args: file_des: FileDescriptor of the .proto file module_name: str, the name of generated _pb2 module module: Generated _pb2 module """ # pylint: disable=g-import-not-at-top from google.protobuf import service as _service from google.protobuf import service_reflection # pylint: enable=g-import-not-at-top for (name, service) in file_des.services_by_name.items(): module[name] = service_reflection.GeneratedServiceType( name, (_service.Service,), dict(DESCRIPTOR=service, __module__=module_name)) stub_name = name + '_Stub' module[stub_name] = service_reflection.GeneratedServiceStubType( stub_name, (module[name],), dict(DESCRIPTOR=service, __module__=module_name)) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/containers.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Contains container classes to represent different protocol buffer types. This file defines container classes which represent categories of protocol buffer field types which need extra maintenance. Currently these categories are: - Repeated scalar fields - These are all repeated fields which aren't composite (e.g. they are of simple types like int32, string, etc). - Repeated composite fields - Repeated fields which are composite. This includes groups and nested messages. """ import collections.abc import copy import pickle from typing import ( Any, Iterable, Iterator, List, MutableMapping, MutableSequence, NoReturn, Optional, Sequence, TypeVar, Union, overload, ) _T = TypeVar('_T') _K = TypeVar('_K') _V = TypeVar('_V') class BaseContainer(Sequence[_T]): """Base container class.""" # Minimizes memory usage and disallows assignment to other attributes. __slots__ = ['_message_listener', '_values'] def __init__(self, message_listener: Any) -> None: """ Args: message_listener: A MessageListener implementation. The RepeatedScalarFieldContainer will call this object's Modified() method when it is modified. """ self._message_listener = message_listener self._values = [] @overload def __getitem__(self, key: int) -> _T: ... @overload def __getitem__(self, key: slice) -> List[_T]: ... def __getitem__(self, key): """Retrieves item by the specified key.""" return self._values[key] def __len__(self) -> int: """Returns the number of elements in the container.""" return len(self._values) def __ne__(self, other: Any) -> bool: """Checks if another instance isn't equal to this one.""" # The concrete classes should define __eq__. return not self == other __hash__ = None def __repr__(self) -> str: return repr(self._values) def sort(self, *args, **kwargs) -> None: # Continue to support the old sort_function keyword argument. # This is expected to be a rare occurrence, so use LBYL to avoid # the overhead of actually catching KeyError. if 'sort_function' in kwargs: kwargs['cmp'] = kwargs.pop('sort_function') self._values.sort(*args, **kwargs) def reverse(self) -> None: self._values.reverse() # TODO(slebedev): Remove this. BaseContainer does *not* conform to # MutableSequence, only its subclasses do. collections.abc.MutableSequence.register(BaseContainer) class RepeatedScalarFieldContainer(BaseContainer[_T], MutableSequence[_T]): """Simple, type-checked, list-like container for holding repeated scalars.""" # Disallows assignment to other attributes. __slots__ = ['_type_checker'] def __init__( self, message_listener: Any, type_checker: Any, ) -> None: """Args: message_listener: A MessageListener implementation. The RepeatedScalarFieldContainer will call this object's Modified() method when it is modified. type_checker: A type_checkers.ValueChecker instance to run on elements inserted into this container. """ super().__init__(message_listener) self._type_checker = type_checker def append(self, value: _T) -> None: """Appends an item to the list. Similar to list.append().""" self._values.append(self._type_checker.CheckValue(value)) if not self._message_listener.dirty: self._message_listener.Modified() def insert(self, key: int, value: _T) -> None: """Inserts the item at the specified position. Similar to list.insert().""" self._values.insert(key, self._type_checker.CheckValue(value)) if not self._message_listener.dirty: self._message_listener.Modified() def extend(self, elem_seq: Iterable[_T]) -> None: """Extends by appending the given iterable. Similar to list.extend().""" if elem_seq is None: return try: elem_seq_iter = iter(elem_seq) except TypeError: if not elem_seq: # silently ignore falsy inputs :-/. # TODO(ptucker): Deprecate this behavior. b/18413862 return raise new_values = [self._type_checker.CheckValue(elem) for elem in elem_seq_iter] if new_values: self._values.extend(new_values) self._message_listener.Modified() def MergeFrom( self, other: Union['RepeatedScalarFieldContainer[_T]', Iterable[_T]], ) -> None: """Appends the contents of another repeated field of the same type to this one. We do not check the types of the individual fields. """ self._values.extend(other) self._message_listener.Modified() def remove(self, elem: _T): """Removes an item from the list. Similar to list.remove().""" self._values.remove(elem) self._message_listener.Modified() def pop(self, key: Optional[int] = -1) -> _T: """Removes and returns an item at a given index. Similar to list.pop().""" value = self._values[key] self.__delitem__(key) return value @overload def __setitem__(self, key: int, value: _T) -> None: ... @overload def __setitem__(self, key: slice, value: Iterable[_T]) -> None: ... def __setitem__(self, key, value) -> None: """Sets the item on the specified position.""" if isinstance(key, slice): if key.step is not None: raise ValueError('Extended slices not supported') self._values[key] = map(self._type_checker.CheckValue, value) self._message_listener.Modified() else: self._values[key] = self._type_checker.CheckValue(value) self._message_listener.Modified() def __delitem__(self, key: Union[int, slice]) -> None: """Deletes the item at the specified position.""" del self._values[key] self._message_listener.Modified() def __eq__(self, other: Any) -> bool: """Compares the current instance with another one.""" if self is other: return True # Special case for the same type which should be common and fast. if isinstance(other, self.__class__): return other._values == self._values # We are presumably comparing against some other sequence type. return other == self._values def __deepcopy__( self, unused_memo: Any = None, ) -> 'RepeatedScalarFieldContainer[_T]': clone = RepeatedScalarFieldContainer( copy.deepcopy(self._message_listener), self._type_checker) clone.MergeFrom(self) return clone def __reduce__(self, **kwargs) -> NoReturn: raise pickle.PickleError( "Can't pickle repeated scalar fields, convert to list first") # TODO(slebedev): Constrain T to be a subtype of Message. class RepeatedCompositeFieldContainer(BaseContainer[_T], MutableSequence[_T]): """Simple, list-like container for holding repeated composite fields.""" # Disallows assignment to other attributes. __slots__ = ['_message_descriptor'] def __init__(self, message_listener: Any, message_descriptor: Any) -> None: """ Note that we pass in a descriptor instead of the generated directly, since at the time we construct a _RepeatedCompositeFieldContainer we haven't yet necessarily initialized the type that will be contained in the container. Args: message_listener: A MessageListener implementation. The RepeatedCompositeFieldContainer will call this object's Modified() method when it is modified. message_descriptor: A Descriptor instance describing the protocol type that should be present in this container. We'll use the _concrete_class field of this descriptor when the client calls add(). """ super().__init__(message_listener) self._message_descriptor = message_descriptor def add(self, **kwargs: Any) -> _T: """Adds a new element at the end of the list and returns it. Keyword arguments may be used to initialize the element. """ new_element = self._message_descriptor._concrete_class(**kwargs) new_element._SetListener(self._message_listener) self._values.append(new_element) if not self._message_listener.dirty: self._message_listener.Modified() return new_element def append(self, value: _T) -> None: """Appends one element by copying the message.""" new_element = self._message_descriptor._concrete_class() new_element._SetListener(self._message_listener) new_element.CopyFrom(value) self._values.append(new_element) if not self._message_listener.dirty: self._message_listener.Modified() def insert(self, key: int, value: _T) -> None: """Inserts the item at the specified position by copying.""" new_element = self._message_descriptor._concrete_class() new_element._SetListener(self._message_listener) new_element.CopyFrom(value) self._values.insert(key, new_element) if not self._message_listener.dirty: self._message_listener.Modified() def extend(self, elem_seq: Iterable[_T]) -> None: """Extends by appending the given sequence of elements of the same type as this one, copying each individual message. """ message_class = self._message_descriptor._concrete_class listener = self._message_listener values = self._values for message in elem_seq: new_element = message_class() new_element._SetListener(listener) new_element.MergeFrom(message) values.append(new_element) listener.Modified() def MergeFrom( self, other: Union['RepeatedCompositeFieldContainer[_T]', Iterable[_T]], ) -> None: """Appends the contents of another repeated field of the same type to this one, copying each individual message. """ self.extend(other) def remove(self, elem: _T) -> None: """Removes an item from the list. Similar to list.remove().""" self._values.remove(elem) self._message_listener.Modified() def pop(self, key: Optional[int] = -1) -> _T: """Removes and returns an item at a given index. Similar to list.pop().""" value = self._values[key] self.__delitem__(key) return value @overload def __setitem__(self, key: int, value: _T) -> None: ... @overload def __setitem__(self, key: slice, value: Iterable[_T]) -> None: ... def __setitem__(self, key, value): # This method is implemented to make RepeatedCompositeFieldContainer # structurally compatible with typing.MutableSequence. It is # otherwise unsupported and will always raise an error. raise TypeError( f'{self.__class__.__name__} object does not support item assignment') def __delitem__(self, key: Union[int, slice]) -> None: """Deletes the item at the specified position.""" del self._values[key] self._message_listener.Modified() def __eq__(self, other: Any) -> bool: """Compares the current instance with another one.""" if self is other: return True if not isinstance(other, self.__class__): raise TypeError('Can only compare repeated composite fields against ' 'other repeated composite fields.') return self._values == other._values class ScalarMap(MutableMapping[_K, _V]): """Simple, type-checked, dict-like container for holding repeated scalars.""" # Disallows assignment to other attributes. __slots__ = ['_key_checker', '_value_checker', '_values', '_message_listener', '_entry_descriptor'] def __init__( self, message_listener: Any, key_checker: Any, value_checker: Any, entry_descriptor: Any, ) -> None: """ Args: message_listener: A MessageListener implementation. The ScalarMap will call this object's Modified() method when it is modified. key_checker: A type_checkers.ValueChecker instance to run on keys inserted into this container. value_checker: A type_checkers.ValueChecker instance to run on values inserted into this container. entry_descriptor: The MessageDescriptor of a map entry: key and value. """ self._message_listener = message_listener self._key_checker = key_checker self._value_checker = value_checker self._entry_descriptor = entry_descriptor self._values = {} def __getitem__(self, key: _K) -> _V: try: return self._values[key] except KeyError: key = self._key_checker.CheckValue(key) val = self._value_checker.DefaultValue() self._values[key] = val return val def __contains__(self, item: _K) -> bool: # We check the key's type to match the strong-typing flavor of the API. # Also this makes it easier to match the behavior of the C++ implementation. self._key_checker.CheckValue(item) return item in self._values @overload def get(self, key: _K) -> Optional[_V]: ... @overload def get(self, key: _K, default: _T) -> Union[_V, _T]: ... # We need to override this explicitly, because our defaultdict-like behavior # will make the default implementation (from our base class) always insert # the key. def get(self, key, default=None): if key in self: return self[key] else: return default def __setitem__(self, key: _K, value: _V) -> _T: checked_key = self._key_checker.CheckValue(key) checked_value = self._value_checker.CheckValue(value) self._values[checked_key] = checked_value self._message_listener.Modified() def __delitem__(self, key: _K) -> None: del self._values[key] self._message_listener.Modified() def __len__(self) -> int: return len(self._values) def __iter__(self) -> Iterator[_K]: return iter(self._values) def __repr__(self) -> str: return repr(self._values) def MergeFrom(self, other: 'ScalarMap[_K, _V]') -> None: self._values.update(other._values) self._message_listener.Modified() def InvalidateIterators(self) -> None: # It appears that the only way to reliably invalidate iterators to # self._values is to ensure that its size changes. original = self._values self._values = original.copy() original[None] = None # This is defined in the abstract base, but we can do it much more cheaply. def clear(self) -> None: self._values.clear() self._message_listener.Modified() def GetEntryClass(self) -> Any: return self._entry_descriptor._concrete_class class MessageMap(MutableMapping[_K, _V]): """Simple, type-checked, dict-like container for with submessage values.""" # Disallows assignment to other attributes. __slots__ = ['_key_checker', '_values', '_message_listener', '_message_descriptor', '_entry_descriptor'] def __init__( self, message_listener: Any, message_descriptor: Any, key_checker: Any, entry_descriptor: Any, ) -> None: """ Args: message_listener: A MessageListener implementation. The ScalarMap will call this object's Modified() method when it is modified. key_checker: A type_checkers.ValueChecker instance to run on keys inserted into this container. value_checker: A type_checkers.ValueChecker instance to run on values inserted into this container. entry_descriptor: The MessageDescriptor of a map entry: key and value. """ self._message_listener = message_listener self._message_descriptor = message_descriptor self._key_checker = key_checker self._entry_descriptor = entry_descriptor self._values = {} def __getitem__(self, key: _K) -> _V: key = self._key_checker.CheckValue(key) try: return self._values[key] except KeyError: new_element = self._message_descriptor._concrete_class() new_element._SetListener(self._message_listener) self._values[key] = new_element self._message_listener.Modified() return new_element def get_or_create(self, key: _K) -> _V: """get_or_create() is an alias for getitem (ie. map[key]). Args: key: The key to get or create in the map. This is useful in cases where you want to be explicit that the call is mutating the map. This can avoid lint errors for statements like this that otherwise would appear to be pointless statements: msg.my_map[key] """ return self[key] @overload def get(self, key: _K) -> Optional[_V]: ... @overload def get(self, key: _K, default: _T) -> Union[_V, _T]: ... # We need to override this explicitly, because our defaultdict-like behavior # will make the default implementation (from our base class) always insert # the key. def get(self, key, default=None): if key in self: return self[key] else: return default def __contains__(self, item: _K) -> bool: item = self._key_checker.CheckValue(item) return item in self._values def __setitem__(self, key: _K, value: _V) -> NoReturn: raise ValueError('May not set values directly, call my_map[key].foo = 5') def __delitem__(self, key: _K) -> None: key = self._key_checker.CheckValue(key) del self._values[key] self._message_listener.Modified() def __len__(self) -> int: return len(self._values) def __iter__(self) -> Iterator[_K]: return iter(self._values) def __repr__(self) -> str: return repr(self._values) def MergeFrom(self, other: 'MessageMap[_K, _V]') -> None: # pylint: disable=protected-access for key in other._values: # According to documentation: "When parsing from the wire or when merging, # if there are duplicate map keys the last key seen is used". if key in self: del self[key] self[key].CopyFrom(other[key]) # self._message_listener.Modified() not required here, because # mutations to submessages already propagate. def InvalidateIterators(self) -> None: # It appears that the only way to reliably invalidate iterators to # self._values is to ensure that its size changes. original = self._values self._values = original.copy() original[None] = None # This is defined in the abstract base, but we can do it much more cheaply. def clear(self) -> None: self._values.clear() self._message_listener.Modified() def GetEntryClass(self) -> Any: return self._entry_descriptor._concrete_class class _UnknownField: """A parsed unknown field.""" # Disallows assignment to other attributes. __slots__ = ['_field_number', '_wire_type', '_data'] def __init__(self, field_number, wire_type, data): self._field_number = field_number self._wire_type = wire_type self._data = data return def __lt__(self, other): # pylint: disable=protected-access return self._field_number < other._field_number def __eq__(self, other): if self is other: return True # pylint: disable=protected-access return (self._field_number == other._field_number and self._wire_type == other._wire_type and self._data == other._data) class UnknownFieldRef: # pylint: disable=missing-class-docstring def __init__(self, parent, index): self._parent = parent self._index = index def _check_valid(self): if not self._parent: raise ValueError('UnknownField does not exist. ' 'The parent message might be cleared.') if self._index >= len(self._parent): raise ValueError('UnknownField does not exist. ' 'The parent message might be cleared.') @property def field_number(self): self._check_valid() # pylint: disable=protected-access return self._parent._internal_get(self._index)._field_number @property def wire_type(self): self._check_valid() # pylint: disable=protected-access return self._parent._internal_get(self._index)._wire_type @property def data(self): self._check_valid() # pylint: disable=protected-access return self._parent._internal_get(self._index)._data class UnknownFieldSet: """UnknownField container""" # Disallows assignment to other attributes. __slots__ = ['_values'] def __init__(self): self._values = [] def __getitem__(self, index): if self._values is None: raise ValueError('UnknownFields does not exist. ' 'The parent message might be cleared.') size = len(self._values) if index < 0: index += size if index < 0 or index >= size: raise IndexError('index %d out of range'.index) return UnknownFieldRef(self, index) def _internal_get(self, index): return self._values[index] def __len__(self): if self._values is None: raise ValueError('UnknownFields does not exist. ' 'The parent message might be cleared.') return len(self._values) def _add(self, field_number, wire_type, data): unknown_field = _UnknownField(field_number, wire_type, data) self._values.append(unknown_field) return unknown_field def __iter__(self): for i in range(len(self)): yield UnknownFieldRef(self, i) def _extend(self, other): if other is None: return # pylint: disable=protected-access self._values.extend(other._values) def __eq__(self, other): if self is other: return True # Sort unknown fields because their order shouldn't # affect equality test. values = list(self._values) if other is None: return not values values.sort() # pylint: disable=protected-access other_values = sorted(other._values) return values == other_values def _clear(self): for value in self._values: # pylint: disable=protected-access if isinstance(value._data, UnknownFieldSet): value._data._clear() # pylint: disable=protected-access self._values = None ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/decoder.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Code for decoding protocol buffer primitives. This code is very similar to encoder.py -- read the docs for that module first. A "decoder" is a function with the signature: Decode(buffer, pos, end, message, field_dict) The arguments are: buffer: The string containing the encoded message. pos: The current position in the string. end: The position in the string where the current message ends. May be less than len(buffer) if we're reading a sub-message. message: The message object into which we're parsing. field_dict: message._fields (avoids a hashtable lookup). The decoder reads the field and stores it into field_dict, returning the new buffer position. A decoder for a repeated field may proactively decode all of the elements of that field, if they appear consecutively. Note that decoders may throw any of the following: IndexError: Indicates a truncated message. struct.error: Unpacking of a fixed-width field failed. message.DecodeError: Other errors. Decoders are expected to raise an exception if they are called with pos > end. This allows callers to be lax about bounds checking: it's fineto read past "end" as long as you are sure that someone else will notice and throw an exception later on. Something up the call stack is expected to catch IndexError and struct.error and convert them to message.DecodeError. Decoders are constructed using decoder constructors with the signature: MakeDecoder(field_number, is_repeated, is_packed, key, new_default) The arguments are: field_number: The field number of the field we want to decode. is_repeated: Is the field a repeated field? (bool) is_packed: Is the field a packed field? (bool) key: The key to use when looking up the field within field_dict. (This is actually the FieldDescriptor but nothing in this file should depend on that.) new_default: A function which takes a message object as a parameter and returns a new instance of the default value for this field. (This is called for repeated fields and sub-messages, when an instance does not already exist.) As with encoders, we define a decoder constructor for every type of field. Then, for every field of every message class we construct an actual decoder. That decoder goes into a dict indexed by tag, so when we decode a message we repeatedly read a tag, look up the corresponding decoder, and invoke it. """ __author__ = 'kenton@google.com (Kenton Varda)' import math import struct from google.protobuf.internal import containers from google.protobuf.internal import encoder from google.protobuf.internal import wire_format from google.protobuf import message # This is not for optimization, but rather to avoid conflicts with local # variables named "message". _DecodeError = message.DecodeError def _VarintDecoder(mask, result_type): """Return an encoder for a basic varint value (does not include tag). Decoded values will be bitwise-anded with the given mask before being returned, e.g. to limit them to 32 bits. The returned decoder does not take the usual "end" parameter -- the caller is expected to do bounds checking after the fact (often the caller can defer such checking until later). The decoder returns a (value, new_pos) pair. """ def DecodeVarint(buffer, pos): result = 0 shift = 0 while 1: b = buffer[pos] result |= ((b & 0x7f) << shift) pos += 1 if not (b & 0x80): result &= mask result = result_type(result) return (result, pos) shift += 7 if shift >= 64: raise _DecodeError('Too many bytes when decoding varint.') return DecodeVarint def _SignedVarintDecoder(bits, result_type): """Like _VarintDecoder() but decodes signed values.""" signbit = 1 << (bits - 1) mask = (1 << bits) - 1 def DecodeVarint(buffer, pos): result = 0 shift = 0 while 1: b = buffer[pos] result |= ((b & 0x7f) << shift) pos += 1 if not (b & 0x80): result &= mask result = (result ^ signbit) - signbit result = result_type(result) return (result, pos) shift += 7 if shift >= 64: raise _DecodeError('Too many bytes when decoding varint.') return DecodeVarint # All 32-bit and 64-bit values are represented as int. _DecodeVarint = _VarintDecoder((1 << 64) - 1, int) _DecodeSignedVarint = _SignedVarintDecoder(64, int) # Use these versions for values which must be limited to 32 bits. _DecodeVarint32 = _VarintDecoder((1 << 32) - 1, int) _DecodeSignedVarint32 = _SignedVarintDecoder(32, int) def ReadTag(buffer, pos): """Read a tag from the memoryview, and return a (tag_bytes, new_pos) tuple. We return the raw bytes of the tag rather than decoding them. The raw bytes can then be used to look up the proper decoder. This effectively allows us to trade some work that would be done in pure-python (decoding a varint) for work that is done in C (searching for a byte string in a hash table). In a low-level language it would be much cheaper to decode the varint and use that, but not in Python. Args: buffer: memoryview object of the encoded bytes pos: int of the current position to start from Returns: Tuple[bytes, int] of the tag data and new position. """ start = pos while buffer[pos] & 0x80: pos += 1 pos += 1 tag_bytes = buffer[start:pos].tobytes() return tag_bytes, pos # -------------------------------------------------------------------- def _SimpleDecoder(wire_type, decode_value): """Return a constructor for a decoder for fields of a particular type. Args: wire_type: The field's wire type. decode_value: A function which decodes an individual value, e.g. _DecodeVarint() """ def SpecificDecoder(field_number, is_repeated, is_packed, key, new_default, clear_if_default=False): if is_packed: local_DecodeVarint = _DecodeVarint def DecodePackedField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) (endpoint, pos) = local_DecodeVarint(buffer, pos) endpoint += pos if endpoint > end: raise _DecodeError('Truncated message.') while pos < endpoint: (element, pos) = decode_value(buffer, pos) value.append(element) if pos > endpoint: del value[-1] # Discard corrupt value. raise _DecodeError('Packed element was truncated.') return pos return DecodePackedField elif is_repeated: tag_bytes = encoder.TagBytes(field_number, wire_type) tag_len = len(tag_bytes) def DecodeRepeatedField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) while 1: (element, new_pos) = decode_value(buffer, pos) value.append(element) # Predict that the next tag is another copy of the same repeated # field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos >= end: # Prediction failed. Return. if new_pos > end: raise _DecodeError('Truncated message.') return new_pos return DecodeRepeatedField else: def DecodeField(buffer, pos, end, message, field_dict): (new_value, pos) = decode_value(buffer, pos) if pos > end: raise _DecodeError('Truncated message.') if clear_if_default and not new_value: field_dict.pop(key, None) else: field_dict[key] = new_value return pos return DecodeField return SpecificDecoder def _ModifiedDecoder(wire_type, decode_value, modify_value): """Like SimpleDecoder but additionally invokes modify_value on every value before storing it. Usually modify_value is ZigZagDecode. """ # Reusing _SimpleDecoder is slightly slower than copying a bunch of code, but # not enough to make a significant difference. def InnerDecode(buffer, pos): (result, new_pos) = decode_value(buffer, pos) return (modify_value(result), new_pos) return _SimpleDecoder(wire_type, InnerDecode) def _StructPackDecoder(wire_type, format): """Return a constructor for a decoder for a fixed-width field. Args: wire_type: The field's wire type. format: The format string to pass to struct.unpack(). """ value_size = struct.calcsize(format) local_unpack = struct.unpack # Reusing _SimpleDecoder is slightly slower than copying a bunch of code, but # not enough to make a significant difference. # Note that we expect someone up-stack to catch struct.error and convert # it to _DecodeError -- this way we don't have to set up exception- # handling blocks every time we parse one value. def InnerDecode(buffer, pos): new_pos = pos + value_size result = local_unpack(format, buffer[pos:new_pos])[0] return (result, new_pos) return _SimpleDecoder(wire_type, InnerDecode) def _FloatDecoder(): """Returns a decoder for a float field. This code works around a bug in struct.unpack for non-finite 32-bit floating-point values. """ local_unpack = struct.unpack def InnerDecode(buffer, pos): """Decode serialized float to a float and new position. Args: buffer: memoryview of the serialized bytes pos: int, position in the memory view to start at. Returns: Tuple[float, int] of the deserialized float value and new position in the serialized data. """ # We expect a 32-bit value in little-endian byte order. Bit 1 is the sign # bit, bits 2-9 represent the exponent, and bits 10-32 are the significand. new_pos = pos + 4 float_bytes = buffer[pos:new_pos].tobytes() # If this value has all its exponent bits set, then it's non-finite. # In Python 2.4, struct.unpack will convert it to a finite 64-bit value. # To avoid that, we parse it specially. if (float_bytes[3:4] in b'\x7F\xFF' and float_bytes[2:3] >= b'\x80'): # If at least one significand bit is set... if float_bytes[0:3] != b'\x00\x00\x80': return (math.nan, new_pos) # If sign bit is set... if float_bytes[3:4] == b'\xFF': return (-math.inf, new_pos) return (math.inf, new_pos) # Note that we expect someone up-stack to catch struct.error and convert # it to _DecodeError -- this way we don't have to set up exception- # handling blocks every time we parse one value. result = local_unpack('= b'\xF0') and (double_bytes[0:7] != b'\x00\x00\x00\x00\x00\x00\xF0')): return (math.nan, new_pos) # Note that we expect someone up-stack to catch struct.error and convert # it to _DecodeError -- this way we don't have to set up exception- # handling blocks every time we parse one value. result = local_unpack(' end: raise _DecodeError('Truncated message.') while pos < endpoint: value_start_pos = pos (element, pos) = _DecodeSignedVarint32(buffer, pos) # pylint: disable=protected-access if element in enum_type.values_by_number: value.append(element) else: if not message._unknown_fields: message._unknown_fields = [] tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_VARINT) message._unknown_fields.append( (tag_bytes, buffer[value_start_pos:pos].tobytes())) if message._unknown_field_set is None: message._unknown_field_set = containers.UnknownFieldSet() message._unknown_field_set._add( field_number, wire_format.WIRETYPE_VARINT, element) # pylint: enable=protected-access if pos > endpoint: if element in enum_type.values_by_number: del value[-1] # Discard corrupt value. else: del message._unknown_fields[-1] # pylint: disable=protected-access del message._unknown_field_set._values[-1] # pylint: enable=protected-access raise _DecodeError('Packed element was truncated.') return pos return DecodePackedField elif is_repeated: tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_VARINT) tag_len = len(tag_bytes) def DecodeRepeatedField(buffer, pos, end, message, field_dict): """Decode serialized repeated enum to its value and a new position. Args: buffer: memoryview of the serialized bytes. pos: int, position in the memory view to start at. end: int, end position of serialized data message: Message object to store unknown fields in field_dict: Map[Descriptor, Any] to store decoded values in. Returns: int, new position in serialized data. """ value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) while 1: (element, new_pos) = _DecodeSignedVarint32(buffer, pos) # pylint: disable=protected-access if element in enum_type.values_by_number: value.append(element) else: if not message._unknown_fields: message._unknown_fields = [] message._unknown_fields.append( (tag_bytes, buffer[pos:new_pos].tobytes())) if message._unknown_field_set is None: message._unknown_field_set = containers.UnknownFieldSet() message._unknown_field_set._add( field_number, wire_format.WIRETYPE_VARINT, element) # pylint: enable=protected-access # Predict that the next tag is another copy of the same repeated # field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos >= end: # Prediction failed. Return. if new_pos > end: raise _DecodeError('Truncated message.') return new_pos return DecodeRepeatedField else: def DecodeField(buffer, pos, end, message, field_dict): """Decode serialized repeated enum to its value and a new position. Args: buffer: memoryview of the serialized bytes. pos: int, position in the memory view to start at. end: int, end position of serialized data message: Message object to store unknown fields in field_dict: Map[Descriptor, Any] to store decoded values in. Returns: int, new position in serialized data. """ value_start_pos = pos (enum_value, pos) = _DecodeSignedVarint32(buffer, pos) if pos > end: raise _DecodeError('Truncated message.') if clear_if_default and not enum_value: field_dict.pop(key, None) return pos # pylint: disable=protected-access if enum_value in enum_type.values_by_number: field_dict[key] = enum_value else: if not message._unknown_fields: message._unknown_fields = [] tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_VARINT) message._unknown_fields.append( (tag_bytes, buffer[value_start_pos:pos].tobytes())) if message._unknown_field_set is None: message._unknown_field_set = containers.UnknownFieldSet() message._unknown_field_set._add( field_number, wire_format.WIRETYPE_VARINT, enum_value) # pylint: enable=protected-access return pos return DecodeField # -------------------------------------------------------------------- Int32Decoder = _SimpleDecoder( wire_format.WIRETYPE_VARINT, _DecodeSignedVarint32) Int64Decoder = _SimpleDecoder( wire_format.WIRETYPE_VARINT, _DecodeSignedVarint) UInt32Decoder = _SimpleDecoder(wire_format.WIRETYPE_VARINT, _DecodeVarint32) UInt64Decoder = _SimpleDecoder(wire_format.WIRETYPE_VARINT, _DecodeVarint) SInt32Decoder = _ModifiedDecoder( wire_format.WIRETYPE_VARINT, _DecodeVarint32, wire_format.ZigZagDecode) SInt64Decoder = _ModifiedDecoder( wire_format.WIRETYPE_VARINT, _DecodeVarint, wire_format.ZigZagDecode) # Note that Python conveniently guarantees that when using the '<' prefix on # formats, they will also have the same size across all platforms (as opposed # to without the prefix, where their sizes depend on the C compiler's basic # type sizes). Fixed32Decoder = _StructPackDecoder(wire_format.WIRETYPE_FIXED32, ' end: raise _DecodeError('Truncated string.') value.append(_ConvertToUnicode(buffer[pos:new_pos])) # Predict that the next tag is another copy of the same repeated field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos == end: # Prediction failed. Return. return new_pos return DecodeRepeatedField else: def DecodeField(buffer, pos, end, message, field_dict): (size, pos) = local_DecodeVarint(buffer, pos) new_pos = pos + size if new_pos > end: raise _DecodeError('Truncated string.') if clear_if_default and not size: field_dict.pop(key, None) else: field_dict[key] = _ConvertToUnicode(buffer[pos:new_pos]) return new_pos return DecodeField def BytesDecoder(field_number, is_repeated, is_packed, key, new_default, clear_if_default=False): """Returns a decoder for a bytes field.""" local_DecodeVarint = _DecodeVarint assert not is_packed if is_repeated: tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_LENGTH_DELIMITED) tag_len = len(tag_bytes) def DecodeRepeatedField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) while 1: (size, pos) = local_DecodeVarint(buffer, pos) new_pos = pos + size if new_pos > end: raise _DecodeError('Truncated string.') value.append(buffer[pos:new_pos].tobytes()) # Predict that the next tag is another copy of the same repeated field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos == end: # Prediction failed. Return. return new_pos return DecodeRepeatedField else: def DecodeField(buffer, pos, end, message, field_dict): (size, pos) = local_DecodeVarint(buffer, pos) new_pos = pos + size if new_pos > end: raise _DecodeError('Truncated string.') if clear_if_default and not size: field_dict.pop(key, None) else: field_dict[key] = buffer[pos:new_pos].tobytes() return new_pos return DecodeField def GroupDecoder(field_number, is_repeated, is_packed, key, new_default): """Returns a decoder for a group field.""" end_tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_END_GROUP) end_tag_len = len(end_tag_bytes) assert not is_packed if is_repeated: tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_START_GROUP) tag_len = len(tag_bytes) def DecodeRepeatedField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) while 1: value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) # Read sub-message. pos = value.add()._InternalParse(buffer, pos, end) # Read end tag. new_pos = pos+end_tag_len if buffer[pos:new_pos] != end_tag_bytes or new_pos > end: raise _DecodeError('Missing group end tag.') # Predict that the next tag is another copy of the same repeated field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos == end: # Prediction failed. Return. return new_pos return DecodeRepeatedField else: def DecodeField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) # Read sub-message. pos = value._InternalParse(buffer, pos, end) # Read end tag. new_pos = pos+end_tag_len if buffer[pos:new_pos] != end_tag_bytes or new_pos > end: raise _DecodeError('Missing group end tag.') return new_pos return DecodeField def MessageDecoder(field_number, is_repeated, is_packed, key, new_default): """Returns a decoder for a message field.""" local_DecodeVarint = _DecodeVarint assert not is_packed if is_repeated: tag_bytes = encoder.TagBytes(field_number, wire_format.WIRETYPE_LENGTH_DELIMITED) tag_len = len(tag_bytes) def DecodeRepeatedField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) while 1: # Read length. (size, pos) = local_DecodeVarint(buffer, pos) new_pos = pos + size if new_pos > end: raise _DecodeError('Truncated message.') # Read sub-message. if value.add()._InternalParse(buffer, pos, new_pos) != new_pos: # The only reason _InternalParse would return early is if it # encountered an end-group tag. raise _DecodeError('Unexpected end-group tag.') # Predict that the next tag is another copy of the same repeated field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos == end: # Prediction failed. Return. return new_pos return DecodeRepeatedField else: def DecodeField(buffer, pos, end, message, field_dict): value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) # Read length. (size, pos) = local_DecodeVarint(buffer, pos) new_pos = pos + size if new_pos > end: raise _DecodeError('Truncated message.') # Read sub-message. if value._InternalParse(buffer, pos, new_pos) != new_pos: # The only reason _InternalParse would return early is if it encountered # an end-group tag. raise _DecodeError('Unexpected end-group tag.') return new_pos return DecodeField # -------------------------------------------------------------------- MESSAGE_SET_ITEM_TAG = encoder.TagBytes(1, wire_format.WIRETYPE_START_GROUP) def MessageSetItemDecoder(descriptor): """Returns a decoder for a MessageSet item. The parameter is the message Descriptor. The message set message looks like this: message MessageSet { repeated group Item = 1 { required int32 type_id = 2; required string message = 3; } } """ type_id_tag_bytes = encoder.TagBytes(2, wire_format.WIRETYPE_VARINT) message_tag_bytes = encoder.TagBytes(3, wire_format.WIRETYPE_LENGTH_DELIMITED) item_end_tag_bytes = encoder.TagBytes(1, wire_format.WIRETYPE_END_GROUP) local_ReadTag = ReadTag local_DecodeVarint = _DecodeVarint local_SkipField = SkipField def DecodeItem(buffer, pos, end, message, field_dict): """Decode serialized message set to its value and new position. Args: buffer: memoryview of the serialized bytes. pos: int, position in the memory view to start at. end: int, end position of serialized data message: Message object to store unknown fields in field_dict: Map[Descriptor, Any] to store decoded values in. Returns: int, new position in serialized data. """ message_set_item_start = pos type_id = -1 message_start = -1 message_end = -1 # Technically, type_id and message can appear in any order, so we need # a little loop here. while 1: (tag_bytes, pos) = local_ReadTag(buffer, pos) if tag_bytes == type_id_tag_bytes: (type_id, pos) = local_DecodeVarint(buffer, pos) elif tag_bytes == message_tag_bytes: (size, message_start) = local_DecodeVarint(buffer, pos) pos = message_end = message_start + size elif tag_bytes == item_end_tag_bytes: break else: pos = SkipField(buffer, pos, end, tag_bytes) if pos == -1: raise _DecodeError('Missing group end tag.') if pos > end: raise _DecodeError('Truncated message.') if type_id == -1: raise _DecodeError('MessageSet item missing type_id.') if message_start == -1: raise _DecodeError('MessageSet item missing message.') extension = message.Extensions._FindExtensionByNumber(type_id) # pylint: disable=protected-access if extension is not None: value = field_dict.get(extension) if value is None: message_type = extension.message_type if not hasattr(message_type, '_concrete_class'): # pylint: disable=protected-access message._FACTORY.GetPrototype(message_type) value = field_dict.setdefault( extension, message_type._concrete_class()) if value._InternalParse(buffer, message_start,message_end) != message_end: # The only reason _InternalParse would return early is if it encountered # an end-group tag. raise _DecodeError('Unexpected end-group tag.') else: if not message._unknown_fields: message._unknown_fields = [] message._unknown_fields.append( (MESSAGE_SET_ITEM_TAG, buffer[message_set_item_start:pos].tobytes())) if message._unknown_field_set is None: message._unknown_field_set = containers.UnknownFieldSet() message._unknown_field_set._add( type_id, wire_format.WIRETYPE_LENGTH_DELIMITED, buffer[message_start:message_end].tobytes()) # pylint: enable=protected-access return pos return DecodeItem # -------------------------------------------------------------------- def MapDecoder(field_descriptor, new_default, is_message_map): """Returns a decoder for a map field.""" key = field_descriptor tag_bytes = encoder.TagBytes(field_descriptor.number, wire_format.WIRETYPE_LENGTH_DELIMITED) tag_len = len(tag_bytes) local_DecodeVarint = _DecodeVarint # Can't read _concrete_class yet; might not be initialized. message_type = field_descriptor.message_type def DecodeMap(buffer, pos, end, message, field_dict): submsg = message_type._concrete_class() value = field_dict.get(key) if value is None: value = field_dict.setdefault(key, new_default(message)) while 1: # Read length. (size, pos) = local_DecodeVarint(buffer, pos) new_pos = pos + size if new_pos > end: raise _DecodeError('Truncated message.') # Read sub-message. submsg.Clear() if submsg._InternalParse(buffer, pos, new_pos) != new_pos: # The only reason _InternalParse would return early is if it # encountered an end-group tag. raise _DecodeError('Unexpected end-group tag.') if is_message_map: value[submsg.key].CopyFrom(submsg.value) else: value[submsg.key] = submsg.value # Predict that the next tag is another copy of the same repeated field. pos = new_pos + tag_len if buffer[new_pos:pos] != tag_bytes or new_pos == end: # Prediction failed. Return. return new_pos return DecodeMap # -------------------------------------------------------------------- # Optimization is not as heavy here because calls to SkipField() are rare, # except for handling end-group tags. def _SkipVarint(buffer, pos, end): """Skip a varint value. Returns the new position.""" # Previously ord(buffer[pos]) raised IndexError when pos is out of range. # With this code, ord(b'') raises TypeError. Both are handled in # python_message.py to generate a 'Truncated message' error. while ord(buffer[pos:pos+1].tobytes()) & 0x80: pos += 1 pos += 1 if pos > end: raise _DecodeError('Truncated message.') return pos def _SkipFixed64(buffer, pos, end): """Skip a fixed64 value. Returns the new position.""" pos += 8 if pos > end: raise _DecodeError('Truncated message.') return pos def _DecodeFixed64(buffer, pos): """Decode a fixed64.""" new_pos = pos + 8 return (struct.unpack(' end: raise _DecodeError('Truncated message.') return pos def _SkipGroup(buffer, pos, end): """Skip sub-group. Returns the new position.""" while 1: (tag_bytes, pos) = ReadTag(buffer, pos) new_pos = SkipField(buffer, pos, end, tag_bytes) if new_pos == -1: return pos pos = new_pos def _DecodeUnknownFieldSet(buffer, pos, end_pos=None): """Decode UnknownFieldSet. Returns the UnknownFieldSet and new position.""" unknown_field_set = containers.UnknownFieldSet() while end_pos is None or pos < end_pos: (tag_bytes, pos) = ReadTag(buffer, pos) (tag, _) = _DecodeVarint(tag_bytes, 0) field_number, wire_type = wire_format.UnpackTag(tag) if wire_type == wire_format.WIRETYPE_END_GROUP: break (data, pos) = _DecodeUnknownField(buffer, pos, wire_type) # pylint: disable=protected-access unknown_field_set._add(field_number, wire_type, data) return (unknown_field_set, pos) def _DecodeUnknownField(buffer, pos, wire_type): """Decode a unknown field. Returns the UnknownField and new position.""" if wire_type == wire_format.WIRETYPE_VARINT: (data, pos) = _DecodeVarint(buffer, pos) elif wire_type == wire_format.WIRETYPE_FIXED64: (data, pos) = _DecodeFixed64(buffer, pos) elif wire_type == wire_format.WIRETYPE_FIXED32: (data, pos) = _DecodeFixed32(buffer, pos) elif wire_type == wire_format.WIRETYPE_LENGTH_DELIMITED: (size, pos) = _DecodeVarint(buffer, pos) data = buffer[pos:pos+size].tobytes() pos += size elif wire_type == wire_format.WIRETYPE_START_GROUP: (data, pos) = _DecodeUnknownFieldSet(buffer, pos) elif wire_type == wire_format.WIRETYPE_END_GROUP: return (0, -1) else: raise _DecodeError('Wrong wire type in tag.') return (data, pos) def _EndGroup(buffer, pos, end): """Skipping an END_GROUP tag returns -1 to tell the parent loop to break.""" return -1 def _SkipFixed32(buffer, pos, end): """Skip a fixed32 value. Returns the new position.""" pos += 4 if pos > end: raise _DecodeError('Truncated message.') return pos def _DecodeFixed32(buffer, pos): """Decode a fixed32.""" new_pos = pos + 4 return (struct.unpack('B').pack def EncodeVarint(write, value, unused_deterministic=None): bits = value & 0x7f value >>= 7 while value: write(local_int2byte(0x80|bits)) bits = value & 0x7f value >>= 7 return write(local_int2byte(bits)) return EncodeVarint def _SignedVarintEncoder(): """Return an encoder for a basic signed varint value (does not include tag).""" local_int2byte = struct.Struct('>B').pack def EncodeSignedVarint(write, value, unused_deterministic=None): if value < 0: value += (1 << 64) bits = value & 0x7f value >>= 7 while value: write(local_int2byte(0x80|bits)) bits = value & 0x7f value >>= 7 return write(local_int2byte(bits)) return EncodeSignedVarint _EncodeVarint = _VarintEncoder() _EncodeSignedVarint = _SignedVarintEncoder() def _VarintBytes(value): """Encode the given integer as a varint and return the bytes. This is only called at startup time so it doesn't need to be fast.""" pieces = [] _EncodeVarint(pieces.append, value, True) return b"".join(pieces) def TagBytes(field_number, wire_type): """Encode the given tag and return the bytes. Only called at startup.""" return bytes(_VarintBytes(wire_format.PackTag(field_number, wire_type))) # -------------------------------------------------------------------- # As with sizers (see above), we have a number of common encoder # implementations. def _SimpleEncoder(wire_type, encode_value, compute_value_size): """Return a constructor for an encoder for fields of a particular type. Args: wire_type: The field's wire type, for encoding tags. encode_value: A function which encodes an individual value, e.g. _EncodeVarint(). compute_value_size: A function which computes the size of an individual value, e.g. _VarintSize(). """ def SpecificEncoder(field_number, is_repeated, is_packed): if is_packed: tag_bytes = TagBytes(field_number, wire_format.WIRETYPE_LENGTH_DELIMITED) local_EncodeVarint = _EncodeVarint def EncodePackedField(write, value, deterministic): write(tag_bytes) size = 0 for element in value: size += compute_value_size(element) local_EncodeVarint(write, size, deterministic) for element in value: encode_value(write, element, deterministic) return EncodePackedField elif is_repeated: tag_bytes = TagBytes(field_number, wire_type) def EncodeRepeatedField(write, value, deterministic): for element in value: write(tag_bytes) encode_value(write, element, deterministic) return EncodeRepeatedField else: tag_bytes = TagBytes(field_number, wire_type) def EncodeField(write, value, deterministic): write(tag_bytes) return encode_value(write, value, deterministic) return EncodeField return SpecificEncoder def _ModifiedEncoder(wire_type, encode_value, compute_value_size, modify_value): """Like SimpleEncoder but additionally invokes modify_value on every value before passing it to encode_value. Usually modify_value is ZigZagEncode.""" def SpecificEncoder(field_number, is_repeated, is_packed): if is_packed: tag_bytes = TagBytes(field_number, wire_format.WIRETYPE_LENGTH_DELIMITED) local_EncodeVarint = _EncodeVarint def EncodePackedField(write, value, deterministic): write(tag_bytes) size = 0 for element in value: size += compute_value_size(modify_value(element)) local_EncodeVarint(write, size, deterministic) for element in value: encode_value(write, modify_value(element), deterministic) return EncodePackedField elif is_repeated: tag_bytes = TagBytes(field_number, wire_type) def EncodeRepeatedField(write, value, deterministic): for element in value: write(tag_bytes) encode_value(write, modify_value(element), deterministic) return EncodeRepeatedField else: tag_bytes = TagBytes(field_number, wire_type) def EncodeField(write, value, deterministic): write(tag_bytes) return encode_value(write, modify_value(value), deterministic) return EncodeField return SpecificEncoder def _StructPackEncoder(wire_type, format): """Return a constructor for an encoder for a fixed-width field. Args: wire_type: The field's wire type, for encoding tags. format: The format string to pass to struct.pack(). """ value_size = struct.calcsize(format) def SpecificEncoder(field_number, is_repeated, is_packed): local_struct_pack = struct.pack if is_packed: tag_bytes = TagBytes(field_number, wire_format.WIRETYPE_LENGTH_DELIMITED) local_EncodeVarint = _EncodeVarint def EncodePackedField(write, value, deterministic): write(tag_bytes) local_EncodeVarint(write, len(value) * value_size, deterministic) for element in value: write(local_struct_pack(format, element)) return EncodePackedField elif is_repeated: tag_bytes = TagBytes(field_number, wire_type) def EncodeRepeatedField(write, value, unused_deterministic=None): for element in value: write(tag_bytes) write(local_struct_pack(format, element)) return EncodeRepeatedField else: tag_bytes = TagBytes(field_number, wire_type) def EncodeField(write, value, unused_deterministic=None): write(tag_bytes) return write(local_struct_pack(format, value)) return EncodeField return SpecificEncoder def _FloatingPointEncoder(wire_type, format): """Return a constructor for an encoder for float fields. This is like StructPackEncoder, but catches errors that may be due to passing non-finite floating-point values to struct.pack, and makes a second attempt to encode those values. Args: wire_type: The field's wire type, for encoding tags. format: The format string to pass to struct.pack(). """ value_size = struct.calcsize(format) if value_size == 4: def EncodeNonFiniteOrRaise(write, value): # Remember that the serialized form uses little-endian byte order. if value == _POS_INF: write(b'\x00\x00\x80\x7F') elif value == _NEG_INF: write(b'\x00\x00\x80\xFF') elif value != value: # NaN write(b'\x00\x00\xC0\x7F') else: raise elif value_size == 8: def EncodeNonFiniteOrRaise(write, value): if value == _POS_INF: write(b'\x00\x00\x00\x00\x00\x00\xF0\x7F') elif value == _NEG_INF: write(b'\x00\x00\x00\x00\x00\x00\xF0\xFF') elif value != value: # NaN write(b'\x00\x00\x00\x00\x00\x00\xF8\x7F') else: raise else: raise ValueError('Can\'t encode floating-point values that are ' '%d bytes long (only 4 or 8)' % value_size) def SpecificEncoder(field_number, is_repeated, is_packed): local_struct_pack = struct.pack if is_packed: tag_bytes = TagBytes(field_number, wire_format.WIRETYPE_LENGTH_DELIMITED) local_EncodeVarint = _EncodeVarint def EncodePackedField(write, value, deterministic): write(tag_bytes) local_EncodeVarint(write, len(value) * value_size, deterministic) for element in value: # This try/except block is going to be faster than any code that # we could write to check whether element is finite. try: write(local_struct_pack(format, element)) except SystemError: EncodeNonFiniteOrRaise(write, element) return EncodePackedField elif is_repeated: tag_bytes = TagBytes(field_number, wire_type) def EncodeRepeatedField(write, value, unused_deterministic=None): for element in value: write(tag_bytes) try: write(local_struct_pack(format, element)) except SystemError: EncodeNonFiniteOrRaise(write, element) return EncodeRepeatedField else: tag_bytes = TagBytes(field_number, wire_type) def EncodeField(write, value, unused_deterministic=None): write(tag_bytes) try: write(local_struct_pack(format, value)) except SystemError: EncodeNonFiniteOrRaise(write, value) return EncodeField return SpecificEncoder # ==================================================================== # Here we declare an encoder constructor for each field type. These work # very similarly to sizer constructors, described earlier. Int32Encoder = Int64Encoder = EnumEncoder = _SimpleEncoder( wire_format.WIRETYPE_VARINT, _EncodeSignedVarint, _SignedVarintSize) UInt32Encoder = UInt64Encoder = _SimpleEncoder( wire_format.WIRETYPE_VARINT, _EncodeVarint, _VarintSize) SInt32Encoder = SInt64Encoder = _ModifiedEncoder( wire_format.WIRETYPE_VARINT, _EncodeVarint, _VarintSize, wire_format.ZigZagEncode) # Note that Python conveniently guarantees that when using the '<' prefix on # formats, they will also have the same size across all platforms (as opposed # to without the prefix, where their sizes depend on the C compiler's basic # type sizes). Fixed32Encoder = _StructPackEncoder(wire_format.WIRETYPE_FIXED32, ' str ValueType = int def __init__(self, enum_type): """Inits EnumTypeWrapper with an EnumDescriptor.""" self._enum_type = enum_type self.DESCRIPTOR = enum_type # pylint: disable=invalid-name def Name(self, number): # pylint: disable=invalid-name """Returns a string containing the name of an enum value.""" try: return self._enum_type.values_by_number[number].name except KeyError: pass # fall out to break exception chaining if not isinstance(number, int): raise TypeError( 'Enum value for {} must be an int, but got {} {!r}.'.format( self._enum_type.name, type(number), number)) else: # repr here to handle the odd case when you pass in a boolean. raise ValueError('Enum {} has no name defined for value {!r}'.format( self._enum_type.name, number)) def Value(self, name): # pylint: disable=invalid-name """Returns the value corresponding to the given enum name.""" try: return self._enum_type.values_by_name[name].number except KeyError: pass # fall out to break exception chaining raise ValueError('Enum {} has no value defined for name {!r}'.format( self._enum_type.name, name)) def keys(self): """Return a list of the string names in the enum. Returns: A list of strs, in the order they were defined in the .proto file. """ return [value_descriptor.name for value_descriptor in self._enum_type.values] def values(self): """Return a list of the integer values in the enum. Returns: A list of ints, in the order they were defined in the .proto file. """ return [value_descriptor.number for value_descriptor in self._enum_type.values] def items(self): """Return a list of the (name, value) pairs of the enum. Returns: A list of (str, int) pairs, in the order they were defined in the .proto file. """ return [(value_descriptor.name, value_descriptor.number) for value_descriptor in self._enum_type.values] def __getattr__(self, name): """Returns the value corresponding to the given enum name.""" try: return super( EnumTypeWrapper, self).__getattribute__('_enum_type').values_by_name[name].number except KeyError: pass # fall out to break exception chaining raise AttributeError('Enum {} has no value defined for name {!r}'.format( self._enum_type.name, name)) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/extension_dict.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Contains _ExtensionDict class to represent extensions. """ from google.protobuf.internal import type_checkers from google.protobuf.descriptor import FieldDescriptor def _VerifyExtensionHandle(message, extension_handle): """Verify that the given extension handle is valid.""" if not isinstance(extension_handle, FieldDescriptor): raise KeyError('HasExtension() expects an extension handle, got: %s' % extension_handle) if not extension_handle.is_extension: raise KeyError('"%s" is not an extension.' % extension_handle.full_name) if not extension_handle.containing_type: raise KeyError('"%s" is missing a containing_type.' % extension_handle.full_name) if extension_handle.containing_type is not message.DESCRIPTOR: raise KeyError('Extension "%s" extends message type "%s", but this ' 'message is of type "%s".' % (extension_handle.full_name, extension_handle.containing_type.full_name, message.DESCRIPTOR.full_name)) # TODO(robinson): Unify error handling of "unknown extension" crap. # TODO(robinson): Support iteritems()-style iteration over all # extensions with the "has" bits turned on? class _ExtensionDict(object): """Dict-like container for Extension fields on proto instances. Note that in all cases we expect extension handles to be FieldDescriptors. """ def __init__(self, extended_message): """ Args: extended_message: Message instance for which we are the Extensions dict. """ self._extended_message = extended_message def __getitem__(self, extension_handle): """Returns the current value of the given extension handle.""" _VerifyExtensionHandle(self._extended_message, extension_handle) result = self._extended_message._fields.get(extension_handle) if result is not None: return result if extension_handle.label == FieldDescriptor.LABEL_REPEATED: result = extension_handle._default_constructor(self._extended_message) elif extension_handle.cpp_type == FieldDescriptor.CPPTYPE_MESSAGE: message_type = extension_handle.message_type if not hasattr(message_type, '_concrete_class'): # pylint: disable=protected-access self._extended_message._FACTORY.GetPrototype(message_type) assert getattr(extension_handle.message_type, '_concrete_class', None), ( 'Uninitialized concrete class found for field %r (message type %r)' % (extension_handle.full_name, extension_handle.message_type.full_name)) result = extension_handle.message_type._concrete_class() try: result._SetListener(self._extended_message._listener_for_children) except ReferenceError: pass else: # Singular scalar -- just return the default without inserting into the # dict. return extension_handle.default_value # Atomically check if another thread has preempted us and, if not, swap # in the new object we just created. If someone has preempted us, we # take that object and discard ours. # WARNING: We are relying on setdefault() being atomic. This is true # in CPython but we haven't investigated others. This warning appears # in several other locations in this file. result = self._extended_message._fields.setdefault( extension_handle, result) return result def __eq__(self, other): if not isinstance(other, self.__class__): return False my_fields = self._extended_message.ListFields() other_fields = other._extended_message.ListFields() # Get rid of non-extension fields. my_fields = [field for field in my_fields if field.is_extension] other_fields = [field for field in other_fields if field.is_extension] return my_fields == other_fields def __ne__(self, other): return not self == other def __len__(self): fields = self._extended_message.ListFields() # Get rid of non-extension fields. extension_fields = [field for field in fields if field[0].is_extension] return len(extension_fields) def __hash__(self): raise TypeError('unhashable object') # Note that this is only meaningful for non-repeated, scalar extension # fields. Note also that we may have to call _Modified() when we do # successfully set a field this way, to set any necessary "has" bits in the # ancestors of the extended message. def __setitem__(self, extension_handle, value): """If extension_handle specifies a non-repeated, scalar extension field, sets the value of that field. """ _VerifyExtensionHandle(self._extended_message, extension_handle) if (extension_handle.label == FieldDescriptor.LABEL_REPEATED or extension_handle.cpp_type == FieldDescriptor.CPPTYPE_MESSAGE): raise TypeError( 'Cannot assign to extension "%s" because it is a repeated or ' 'composite type.' % extension_handle.full_name) # It's slightly wasteful to lookup the type checker each time, # but we expect this to be a vanishingly uncommon case anyway. type_checker = type_checkers.GetTypeChecker(extension_handle) # pylint: disable=protected-access self._extended_message._fields[extension_handle] = ( type_checker.CheckValue(value)) self._extended_message._Modified() def __delitem__(self, extension_handle): self._extended_message.ClearExtension(extension_handle) def _FindExtensionByName(self, name): """Tries to find a known extension with the specified name. Args: name: Extension full name. Returns: Extension field descriptor. """ return self._extended_message._extensions_by_name.get(name, None) def _FindExtensionByNumber(self, number): """Tries to find a known extension with the field number. Args: number: Extension field number. Returns: Extension field descriptor. """ return self._extended_message._extensions_by_number.get(number, None) def __iter__(self): # Return a generator over the populated extension fields return (f[0] for f in self._extended_message.ListFields() if f[0].is_extension) def __contains__(self, extension_handle): _VerifyExtensionHandle(self._extended_message, extension_handle) if extension_handle not in self._extended_message._fields: return False if extension_handle.label == FieldDescriptor.LABEL_REPEATED: return bool(self._extended_message._fields.get(extension_handle)) if extension_handle.cpp_type == FieldDescriptor.CPPTYPE_MESSAGE: value = self._extended_message._fields.get(extension_handle) # pylint: disable=protected-access return value is not None and value._is_present_in_parent return True ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/message_listener.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Defines a listener interface for observing certain state transitions on Message objects. Also defines a null implementation of this interface. """ __author__ = 'robinson@google.com (Will Robinson)' class MessageListener(object): """Listens for modifications made to a message. Meant to be registered via Message._SetListener(). Attributes: dirty: If True, then calling Modified() would be a no-op. This can be used to avoid these calls entirely in the common case. """ def Modified(self): """Called every time the message is modified in such a way that the parent message may need to be updated. This currently means either: (a) The message was modified for the first time, so the parent message should henceforth mark the message as present. (b) The message's cached byte size became dirty -- i.e. the message was modified for the first time after a previous call to ByteSize(). Therefore the parent should also mark its byte size as dirty. Note that (a) implies (b), since new objects start out with a client cached size (zero). However, we document (a) explicitly because it is important. Modified() will *only* be called in response to one of these two events -- not every time the sub-message is modified. Note that if the listener's |dirty| attribute is true, then calling Modified at the moment would be a no-op, so it can be skipped. Performance- sensitive callers should check this attribute directly before calling since it will be true most of the time. """ raise NotImplementedError class NullMessageListener(object): """No-op MessageListener implementation.""" def Modified(self): pass ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/message_set_extensions_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/internal/message_set_extensions.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n5google/protobuf/internal/message_set_extensions.proto\x12\x18google.protobuf.internal\"\x1e\n\x0eTestMessageSet*\x08\x08\x04\x10\xff\xff\xff\xff\x07:\x02\x08\x01\"\xa5\x01\n\x18TestMessageSetExtension1\x12\t\n\x01i\x18\x0f \x01(\x05\x32~\n\x15message_set_extension\x12(.google.protobuf.internal.TestMessageSet\x18\xab\xff\xf6. \x01(\x0b\x32\x32.google.protobuf.internal.TestMessageSetExtension1\"\xa7\x01\n\x18TestMessageSetExtension2\x12\x0b\n\x03str\x18\x19 \x01(\t2~\n\x15message_set_extension\x12(.google.protobuf.internal.TestMessageSet\x18\xca\xff\xf6. \x01(\x0b\x32\x32.google.protobuf.internal.TestMessageSetExtension2\"(\n\x18TestMessageSetExtension3\x12\x0c\n\x04text\x18# \x01(\t:\x7f\n\x16message_set_extension3\x12(.google.protobuf.internal.TestMessageSet\x18\xdf\xff\xf6. \x01(\x0b\x32\x32.google.protobuf.internal.TestMessageSetExtension3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.internal.message_set_extensions_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: TestMessageSet.RegisterExtension(message_set_extension3) TestMessageSet.RegisterExtension(_TESTMESSAGESETEXTENSION1.extensions_by_name['message_set_extension']) TestMessageSet.RegisterExtension(_TESTMESSAGESETEXTENSION2.extensions_by_name['message_set_extension']) DESCRIPTOR._options = None _TESTMESSAGESET._options = None _TESTMESSAGESET._serialized_options = b'\010\001' _TESTMESSAGESET._serialized_start=83 _TESTMESSAGESET._serialized_end=113 _TESTMESSAGESETEXTENSION1._serialized_start=116 _TESTMESSAGESETEXTENSION1._serialized_end=281 _TESTMESSAGESETEXTENSION2._serialized_start=284 _TESTMESSAGESETEXTENSION2._serialized_end=451 _TESTMESSAGESETEXTENSION3._serialized_start=453 _TESTMESSAGESETEXTENSION3._serialized_end=493 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/missing_enum_values_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/internal/missing_enum_values.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n2google/protobuf/internal/missing_enum_values.proto\x12\x1fgoogle.protobuf.python.internal\"\xc1\x02\n\x0eTestEnumValues\x12X\n\x14optional_nested_enum\x18\x01 \x01(\x0e\x32:.google.protobuf.python.internal.TestEnumValues.NestedEnum\x12X\n\x14repeated_nested_enum\x18\x02 \x03(\x0e\x32:.google.protobuf.python.internal.TestEnumValues.NestedEnum\x12Z\n\x12packed_nested_enum\x18\x03 \x03(\x0e\x32:.google.protobuf.python.internal.TestEnumValues.NestedEnumB\x02\x10\x01\"\x1f\n\nNestedEnum\x12\x08\n\x04ZERO\x10\x00\x12\x07\n\x03ONE\x10\x01\"\xd3\x02\n\x15TestMissingEnumValues\x12_\n\x14optional_nested_enum\x18\x01 \x01(\x0e\x32\x41.google.protobuf.python.internal.TestMissingEnumValues.NestedEnum\x12_\n\x14repeated_nested_enum\x18\x02 \x03(\x0e\x32\x41.google.protobuf.python.internal.TestMissingEnumValues.NestedEnum\x12\x61\n\x12packed_nested_enum\x18\x03 \x03(\x0e\x32\x41.google.protobuf.python.internal.TestMissingEnumValues.NestedEnumB\x02\x10\x01\"\x15\n\nNestedEnum\x12\x07\n\x03TWO\x10\x02\"\x1b\n\nJustString\x12\r\n\x05\x64ummy\x18\x01 \x02(\t') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.internal.missing_enum_values_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None _TESTENUMVALUES.fields_by_name['packed_nested_enum']._options = None _TESTENUMVALUES.fields_by_name['packed_nested_enum']._serialized_options = b'\020\001' _TESTMISSINGENUMVALUES.fields_by_name['packed_nested_enum']._options = None _TESTMISSINGENUMVALUES.fields_by_name['packed_nested_enum']._serialized_options = b'\020\001' _TESTENUMVALUES._serialized_start=88 _TESTENUMVALUES._serialized_end=409 _TESTENUMVALUES_NESTEDENUM._serialized_start=378 _TESTENUMVALUES_NESTEDENUM._serialized_end=409 _TESTMISSINGENUMVALUES._serialized_start=412 _TESTMISSINGENUMVALUES._serialized_end=751 _TESTMISSINGENUMVALUES_NESTEDENUM._serialized_start=730 _TESTMISSINGENUMVALUES_NESTEDENUM._serialized_end=751 _JUSTSTRING._serialized_start=753 _JUSTSTRING._serialized_end=780 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/more_extensions_dynamic_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/internal/more_extensions_dynamic.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() from google.protobuf.internal import more_extensions_pb2 as google_dot_protobuf_dot_internal_dot_more__extensions__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n6google/protobuf/internal/more_extensions_dynamic.proto\x12\x18google.protobuf.internal\x1a.google/protobuf/internal/more_extensions.proto\"\x1f\n\x12\x44ynamicMessageType\x12\t\n\x01\x61\x18\x01 \x01(\x05:J\n\x17\x64ynamic_int32_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x64 \x01(\x05:z\n\x19\x64ynamic_message_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x65 \x01(\x0b\x32,.google.protobuf.internal.DynamicMessageType:\x83\x01\n\"repeated_dynamic_message_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x66 \x03(\x0b\x32,.google.protobuf.internal.DynamicMessageType') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.internal.more_extensions_dynamic_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: google_dot_protobuf_dot_internal_dot_more__extensions__pb2.ExtendedMessage.RegisterExtension(dynamic_int32_extension) google_dot_protobuf_dot_internal_dot_more__extensions__pb2.ExtendedMessage.RegisterExtension(dynamic_message_extension) google_dot_protobuf_dot_internal_dot_more__extensions__pb2.ExtendedMessage.RegisterExtension(repeated_dynamic_message_extension) DESCRIPTOR._options = None _DYNAMICMESSAGETYPE._serialized_start=132 _DYNAMICMESSAGETYPE._serialized_end=163 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/more_extensions_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/internal/more_extensions.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n.google/protobuf/internal/more_extensions.proto\x12\x18google.protobuf.internal\"\x99\x01\n\x0fTopLevelMessage\x12\x41\n\nsubmessage\x18\x01 \x01(\x0b\x32).google.protobuf.internal.ExtendedMessageB\x02(\x01\x12\x43\n\x0enested_message\x18\x02 \x01(\x0b\x32\'.google.protobuf.internal.NestedMessageB\x02(\x01\"R\n\rNestedMessage\x12\x41\n\nsubmessage\x18\x01 \x01(\x0b\x32).google.protobuf.internal.ExtendedMessageB\x02(\x01\"K\n\x0f\x45xtendedMessage\x12\x17\n\x0eoptional_int32\x18\xe9\x07 \x01(\x05\x12\x18\n\x0frepeated_string\x18\xea\x07 \x03(\t*\x05\x08\x01\x10\xe8\x07\"-\n\x0e\x46oreignMessage\x12\x1b\n\x13\x66oreign_message_int\x18\x01 \x01(\x05:I\n\x16optional_int_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x01 \x01(\x05:w\n\x1aoptional_message_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x02 \x01(\x0b\x32(.google.protobuf.internal.ForeignMessage:I\n\x16repeated_int_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x03 \x03(\x05:w\n\x1arepeated_message_extension\x12).google.protobuf.internal.ExtendedMessage\x18\x04 \x03(\x0b\x32(.google.protobuf.internal.ForeignMessage') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.internal.more_extensions_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: ExtendedMessage.RegisterExtension(optional_int_extension) ExtendedMessage.RegisterExtension(optional_message_extension) ExtendedMessage.RegisterExtension(repeated_int_extension) ExtendedMessage.RegisterExtension(repeated_message_extension) DESCRIPTOR._options = None _TOPLEVELMESSAGE.fields_by_name['submessage']._options = None _TOPLEVELMESSAGE.fields_by_name['submessage']._serialized_options = b'(\001' _TOPLEVELMESSAGE.fields_by_name['nested_message']._options = None _TOPLEVELMESSAGE.fields_by_name['nested_message']._serialized_options = b'(\001' _NESTEDMESSAGE.fields_by_name['submessage']._options = None _NESTEDMESSAGE.fields_by_name['submessage']._serialized_options = b'(\001' _TOPLEVELMESSAGE._serialized_start=77 _TOPLEVELMESSAGE._serialized_end=230 _NESTEDMESSAGE._serialized_start=232 _NESTEDMESSAGE._serialized_end=314 _EXTENDEDMESSAGE._serialized_start=316 _EXTENDEDMESSAGE._serialized_end=391 _FOREIGNMESSAGE._serialized_start=393 _FOREIGNMESSAGE._serialized_end=438 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/more_messages_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/internal/more_messages.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n,google/protobuf/internal/more_messages.proto\x12\x18google.protobuf.internal\"h\n\x10OutOfOrderFields\x12\x17\n\x0foptional_sint32\x18\x05 \x01(\x11\x12\x17\n\x0foptional_uint32\x18\x03 \x01(\r\x12\x16\n\x0eoptional_int32\x18\x01 \x01(\x05*\x04\x08\x04\x10\x05*\x04\x08\x02\x10\x03\"\xcd\x02\n\x05\x63lass\x12\x1b\n\tint_field\x18\x01 \x01(\x05R\x08json_int\x12\n\n\x02if\x18\x02 \x01(\x05\x12(\n\x02\x61s\x18\x03 \x01(\x0e\x32\x1c.google.protobuf.internal.is\x12\x30\n\nenum_field\x18\x04 \x01(\x0e\x32\x1c.google.protobuf.internal.is\x12>\n\x11nested_enum_field\x18\x05 \x01(\x0e\x32#.google.protobuf.internal.class.for\x12;\n\x0enested_message\x18\x06 \x01(\x0b\x32#.google.protobuf.internal.class.try\x1a\x1c\n\x03try\x12\r\n\x05\x66ield\x18\x01 \x01(\x05*\x06\x08\xe7\x07\x10\x90N\"\x1c\n\x03\x66or\x12\x0b\n\x07\x64\x65\x66\x61ult\x10\x00\x12\x08\n\x04True\x10\x01*\x06\x08\xe7\x07\x10\x90N\"?\n\x0b\x45xtendClass20\n\x06return\x12\x1f.google.protobuf.internal.class\x18\xea\x07 \x01(\x05\"~\n\x0fTestFullKeyword\x12:\n\x06\x66ield1\x18\x01 \x01(\x0b\x32*.google.protobuf.internal.OutOfOrderFields\x12/\n\x06\x66ield2\x18\x02 \x01(\x0b\x32\x1f.google.protobuf.internal.class\"\xa5\x0f\n\x11LotsNestedMessage\x1a\x04\n\x02\x42\x30\x1a\x04\n\x02\x42\x31\x1a\x04\n\x02\x42\x32\x1a\x04\n\x02\x42\x33\x1a\x04\n\x02\x42\x34\x1a\x04\n\x02\x42\x35\x1a\x04\n\x02\x42\x36\x1a\x04\n\x02\x42\x37\x1a\x04\n\x02\x42\x38\x1a\x04\n\x02\x42\x39\x1a\x05\n\x03\x42\x31\x30\x1a\x05\n\x03\x42\x31\x31\x1a\x05\n\x03\x42\x31\x32\x1a\x05\n\x03\x42\x31\x33\x1a\x05\n\x03\x42\x31\x34\x1a\x05\n\x03\x42\x31\x35\x1a\x05\n\x03\x42\x31\x36\x1a\x05\n\x03\x42\x31\x37\x1a\x05\n\x03\x42\x31\x38\x1a\x05\n\x03\x42\x31\x39\x1a\x05\n\x03\x42\x32\x30\x1a\x05\n\x03\x42\x32\x31\x1a\x05\n\x03\x42\x32\x32\x1a\x05\n\x03\x42\x32\x33\x1a\x05\n\x03\x42\x32\x34\x1a\x05\n\x03\x42\x32\x35\x1a\x05\n\x03\x42\x32\x36\x1a\x05\n\x03\x42\x32\x37\x1a\x05\n\x03\x42\x32\x38\x1a\x05\n\x03\x42\x32\x39\x1a\x05\n\x03\x42\x33\x30\x1a\x05\n\x03\x42\x33\x31\x1a\x05\n\x03\x42\x33\x32\x1a\x05\n\x03\x42\x33\x33\x1a\x05\n\x03\x42\x33\x34\x1a\x05\n\x03\x42\x33\x35\x1a\x05\n\x03\x42\x33\x36\x1a\x05\n\x03\x42\x33\x37\x1a\x05\n\x03\x42\x33\x38\x1a\x05\n\x03\x42\x33\x39\x1a\x05\n\x03\x42\x34\x30\x1a\x05\n\x03\x42\x34\x31\x1a\x05\n\x03\x42\x34\x32\x1a\x05\n\x03\x42\x34\x33\x1a\x05\n\x03\x42\x34\x34\x1a\x05\n\x03\x42\x34\x35\x1a\x05\n\x03\x42\x34\x36\x1a\x05\n\x03\x42\x34\x37\x1a\x05\n\x03\x42\x34\x38\x1a\x05\n\x03\x42\x34\x39\x1a\x05\n\x03\x42\x35\x30\x1a\x05\n\x03\x42\x35\x31\x1a\x05\n\x03\x42\x35\x32\x1a\x05\n\x03\x42\x35\x33\x1a\x05\n\x03\x42\x35\x34\x1a\x05\n\x03\x42\x35\x35\x1a\x05\n\x03\x42\x35\x36\x1a\x05\n\x03\x42\x35\x37\x1a\x05\n\x03\x42\x35\x38\x1a\x05\n\x03\x42\x35\x39\x1a\x05\n\x03\x42\x36\x30\x1a\x05\n\x03\x42\x36\x31\x1a\x05\n\x03\x42\x36\x32\x1a\x05\n\x03\x42\x36\x33\x1a\x05\n\x03\x42\x36\x34\x1a\x05\n\x03\x42\x36\x35\x1a\x05\n\x03\x42\x36\x36\x1a\x05\n\x03\x42\x36\x37\x1a\x05\n\x03\x42\x36\x38\x1a\x05\n\x03\x42\x36\x39\x1a\x05\n\x03\x42\x37\x30\x1a\x05\n\x03\x42\x37\x31\x1a\x05\n\x03\x42\x37\x32\x1a\x05\n\x03\x42\x37\x33\x1a\x05\n\x03\x42\x37\x34\x1a\x05\n\x03\x42\x37\x35\x1a\x05\n\x03\x42\x37\x36\x1a\x05\n\x03\x42\x37\x37\x1a\x05\n\x03\x42\x37\x38\x1a\x05\n\x03\x42\x37\x39\x1a\x05\n\x03\x42\x38\x30\x1a\x05\n\x03\x42\x38\x31\x1a\x05\n\x03\x42\x38\x32\x1a\x05\n\x03\x42\x38\x33\x1a\x05\n\x03\x42\x38\x34\x1a\x05\n\x03\x42\x38\x35\x1a\x05\n\x03\x42\x38\x36\x1a\x05\n\x03\x42\x38\x37\x1a\x05\n\x03\x42\x38\x38\x1a\x05\n\x03\x42\x38\x39\x1a\x05\n\x03\x42\x39\x30\x1a\x05\n\x03\x42\x39\x31\x1a\x05\n\x03\x42\x39\x32\x1a\x05\n\x03\x42\x39\x33\x1a\x05\n\x03\x42\x39\x34\x1a\x05\n\x03\x42\x39\x35\x1a\x05\n\x03\x42\x39\x36\x1a\x05\n\x03\x42\x39\x37\x1a\x05\n\x03\x42\x39\x38\x1a\x05\n\x03\x42\x39\x39\x1a\x06\n\x04\x42\x31\x30\x30\x1a\x06\n\x04\x42\x31\x30\x31\x1a\x06\n\x04\x42\x31\x30\x32\x1a\x06\n\x04\x42\x31\x30\x33\x1a\x06\n\x04\x42\x31\x30\x34\x1a\x06\n\x04\x42\x31\x30\x35\x1a\x06\n\x04\x42\x31\x30\x36\x1a\x06\n\x04\x42\x31\x30\x37\x1a\x06\n\x04\x42\x31\x30\x38\x1a\x06\n\x04\x42\x31\x30\x39\x1a\x06\n\x04\x42\x31\x31\x30\x1a\x06\n\x04\x42\x31\x31\x31\x1a\x06\n\x04\x42\x31\x31\x32\x1a\x06\n\x04\x42\x31\x31\x33\x1a\x06\n\x04\x42\x31\x31\x34\x1a\x06\n\x04\x42\x31\x31\x35\x1a\x06\n\x04\x42\x31\x31\x36\x1a\x06\n\x04\x42\x31\x31\x37\x1a\x06\n\x04\x42\x31\x31\x38\x1a\x06\n\x04\x42\x31\x31\x39\x1a\x06\n\x04\x42\x31\x32\x30\x1a\x06\n\x04\x42\x31\x32\x31\x1a\x06\n\x04\x42\x31\x32\x32\x1a\x06\n\x04\x42\x31\x32\x33\x1a\x06\n\x04\x42\x31\x32\x34\x1a\x06\n\x04\x42\x31\x32\x35\x1a\x06\n\x04\x42\x31\x32\x36\x1a\x06\n\x04\x42\x31\x32\x37\x1a\x06\n\x04\x42\x31\x32\x38\x1a\x06\n\x04\x42\x31\x32\x39\x1a\x06\n\x04\x42\x31\x33\x30\x1a\x06\n\x04\x42\x31\x33\x31\x1a\x06\n\x04\x42\x31\x33\x32\x1a\x06\n\x04\x42\x31\x33\x33\x1a\x06\n\x04\x42\x31\x33\x34\x1a\x06\n\x04\x42\x31\x33\x35\x1a\x06\n\x04\x42\x31\x33\x36\x1a\x06\n\x04\x42\x31\x33\x37\x1a\x06\n\x04\x42\x31\x33\x38\x1a\x06\n\x04\x42\x31\x33\x39\x1a\x06\n\x04\x42\x31\x34\x30\x1a\x06\n\x04\x42\x31\x34\x31\x1a\x06\n\x04\x42\x31\x34\x32\x1a\x06\n\x04\x42\x31\x34\x33\x1a\x06\n\x04\x42\x31\x34\x34\x1a\x06\n\x04\x42\x31\x34\x35\x1a\x06\n\x04\x42\x31\x34\x36\x1a\x06\n\x04\x42\x31\x34\x37\x1a\x06\n\x04\x42\x31\x34\x38\x1a\x06\n\x04\x42\x31\x34\x39\x1a\x06\n\x04\x42\x31\x35\x30\x1a\x06\n\x04\x42\x31\x35\x31\x1a\x06\n\x04\x42\x31\x35\x32\x1a\x06\n\x04\x42\x31\x35\x33\x1a\x06\n\x04\x42\x31\x35\x34\x1a\x06\n\x04\x42\x31\x35\x35\x1a\x06\n\x04\x42\x31\x35\x36\x1a\x06\n\x04\x42\x31\x35\x37\x1a\x06\n\x04\x42\x31\x35\x38\x1a\x06\n\x04\x42\x31\x35\x39\x1a\x06\n\x04\x42\x31\x36\x30\x1a\x06\n\x04\x42\x31\x36\x31\x1a\x06\n\x04\x42\x31\x36\x32\x1a\x06\n\x04\x42\x31\x36\x33\x1a\x06\n\x04\x42\x31\x36\x34\x1a\x06\n\x04\x42\x31\x36\x35\x1a\x06\n\x04\x42\x31\x36\x36\x1a\x06\n\x04\x42\x31\x36\x37\x1a\x06\n\x04\x42\x31\x36\x38\x1a\x06\n\x04\x42\x31\x36\x39\x1a\x06\n\x04\x42\x31\x37\x30\x1a\x06\n\x04\x42\x31\x37\x31\x1a\x06\n\x04\x42\x31\x37\x32\x1a\x06\n\x04\x42\x31\x37\x33\x1a\x06\n\x04\x42\x31\x37\x34\x1a\x06\n\x04\x42\x31\x37\x35\x1a\x06\n\x04\x42\x31\x37\x36\x1a\x06\n\x04\x42\x31\x37\x37\x1a\x06\n\x04\x42\x31\x37\x38\x1a\x06\n\x04\x42\x31\x37\x39\x1a\x06\n\x04\x42\x31\x38\x30\x1a\x06\n\x04\x42\x31\x38\x31\x1a\x06\n\x04\x42\x31\x38\x32\x1a\x06\n\x04\x42\x31\x38\x33\x1a\x06\n\x04\x42\x31\x38\x34\x1a\x06\n\x04\x42\x31\x38\x35\x1a\x06\n\x04\x42\x31\x38\x36\x1a\x06\n\x04\x42\x31\x38\x37\x1a\x06\n\x04\x42\x31\x38\x38\x1a\x06\n\x04\x42\x31\x38\x39\x1a\x06\n\x04\x42\x31\x39\x30\x1a\x06\n\x04\x42\x31\x39\x31\x1a\x06\n\x04\x42\x31\x39\x32\x1a\x06\n\x04\x42\x31\x39\x33\x1a\x06\n\x04\x42\x31\x39\x34\x1a\x06\n\x04\x42\x31\x39\x35\x1a\x06\n\x04\x42\x31\x39\x36\x1a\x06\n\x04\x42\x31\x39\x37\x1a\x06\n\x04\x42\x31\x39\x38\x1a\x06\n\x04\x42\x31\x39\x39\x1a\x06\n\x04\x42\x32\x30\x30\x1a\x06\n\x04\x42\x32\x30\x31\x1a\x06\n\x04\x42\x32\x30\x32\x1a\x06\n\x04\x42\x32\x30\x33\x1a\x06\n\x04\x42\x32\x30\x34\x1a\x06\n\x04\x42\x32\x30\x35\x1a\x06\n\x04\x42\x32\x30\x36\x1a\x06\n\x04\x42\x32\x30\x37\x1a\x06\n\x04\x42\x32\x30\x38\x1a\x06\n\x04\x42\x32\x30\x39\x1a\x06\n\x04\x42\x32\x31\x30\x1a\x06\n\x04\x42\x32\x31\x31\x1a\x06\n\x04\x42\x32\x31\x32\x1a\x06\n\x04\x42\x32\x31\x33\x1a\x06\n\x04\x42\x32\x31\x34\x1a\x06\n\x04\x42\x32\x31\x35\x1a\x06\n\x04\x42\x32\x31\x36\x1a\x06\n\x04\x42\x32\x31\x37\x1a\x06\n\x04\x42\x32\x31\x38\x1a\x06\n\x04\x42\x32\x31\x39\x1a\x06\n\x04\x42\x32\x32\x30\x1a\x06\n\x04\x42\x32\x32\x31\x1a\x06\n\x04\x42\x32\x32\x32\x1a\x06\n\x04\x42\x32\x32\x33\x1a\x06\n\x04\x42\x32\x32\x34\x1a\x06\n\x04\x42\x32\x32\x35\x1a\x06\n\x04\x42\x32\x32\x36\x1a\x06\n\x04\x42\x32\x32\x37\x1a\x06\n\x04\x42\x32\x32\x38\x1a\x06\n\x04\x42\x32\x32\x39\x1a\x06\n\x04\x42\x32\x33\x30\x1a\x06\n\x04\x42\x32\x33\x31\x1a\x06\n\x04\x42\x32\x33\x32\x1a\x06\n\x04\x42\x32\x33\x33\x1a\x06\n\x04\x42\x32\x33\x34\x1a\x06\n\x04\x42\x32\x33\x35\x1a\x06\n\x04\x42\x32\x33\x36\x1a\x06\n\x04\x42\x32\x33\x37\x1a\x06\n\x04\x42\x32\x33\x38\x1a\x06\n\x04\x42\x32\x33\x39\x1a\x06\n\x04\x42\x32\x34\x30\x1a\x06\n\x04\x42\x32\x34\x31\x1a\x06\n\x04\x42\x32\x34\x32\x1a\x06\n\x04\x42\x32\x34\x33\x1a\x06\n\x04\x42\x32\x34\x34\x1a\x06\n\x04\x42\x32\x34\x35\x1a\x06\n\x04\x42\x32\x34\x36\x1a\x06\n\x04\x42\x32\x34\x37\x1a\x06\n\x04\x42\x32\x34\x38\x1a\x06\n\x04\x42\x32\x34\x39\x1a\x06\n\x04\x42\x32\x35\x30\x1a\x06\n\x04\x42\x32\x35\x31\x1a\x06\n\x04\x42\x32\x35\x32\x1a\x06\n\x04\x42\x32\x35\x33\x1a\x06\n\x04\x42\x32\x35\x34\x1a\x06\n\x04\x42\x32\x35\x35*\x1b\n\x02is\x12\x0b\n\x07\x64\x65\x66\x61ult\x10\x00\x12\x08\n\x04\x65lse\x10\x01:C\n\x0foptional_uint64\x12*.google.protobuf.internal.OutOfOrderFields\x18\x04 \x01(\x04:B\n\x0eoptional_int64\x12*.google.protobuf.internal.OutOfOrderFields\x18\x02 \x01(\x03:2\n\x08\x63ontinue\x12\x1f.google.protobuf.internal.class\x18\xe9\x07 \x01(\x05:2\n\x04with\x12#.google.protobuf.internal.class.try\x18\xe9\x07 \x01(\x05') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.internal.more_messages_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: OutOfOrderFields.RegisterExtension(optional_uint64) OutOfOrderFields.RegisterExtension(optional_int64) globals()['class'].RegisterExtension(globals()['continue']) getattr(globals()['class'], 'try').RegisterExtension(globals()['with']) globals()['class'].RegisterExtension(_EXTENDCLASS.extensions_by_name['return']) DESCRIPTOR._options = None _IS._serialized_start=2669 _IS._serialized_end=2696 _OUTOFORDERFIELDS._serialized_start=74 _OUTOFORDERFIELDS._serialized_end=178 _CLASS._serialized_start=181 _CLASS._serialized_end=514 _CLASS_TRY._serialized_start=448 _CLASS_TRY._serialized_end=476 _CLASS_FOR._serialized_start=478 _CLASS_FOR._serialized_end=506 _EXTENDCLASS._serialized_start=516 _EXTENDCLASS._serialized_end=579 _TESTFULLKEYWORD._serialized_start=581 _TESTFULLKEYWORD._serialized_end=707 _LOTSNESTEDMESSAGE._serialized_start=710 _LOTSNESTEDMESSAGE._serialized_end=2667 _LOTSNESTEDMESSAGE_B0._serialized_start=731 _LOTSNESTEDMESSAGE_B0._serialized_end=735 _LOTSNESTEDMESSAGE_B1._serialized_start=737 _LOTSNESTEDMESSAGE_B1._serialized_end=741 _LOTSNESTEDMESSAGE_B2._serialized_start=743 _LOTSNESTEDMESSAGE_B2._serialized_end=747 _LOTSNESTEDMESSAGE_B3._serialized_start=749 _LOTSNESTEDMESSAGE_B3._serialized_end=753 _LOTSNESTEDMESSAGE_B4._serialized_start=755 _LOTSNESTEDMESSAGE_B4._serialized_end=759 _LOTSNESTEDMESSAGE_B5._serialized_start=761 _LOTSNESTEDMESSAGE_B5._serialized_end=765 _LOTSNESTEDMESSAGE_B6._serialized_start=767 _LOTSNESTEDMESSAGE_B6._serialized_end=771 _LOTSNESTEDMESSAGE_B7._serialized_start=773 _LOTSNESTEDMESSAGE_B7._serialized_end=777 _LOTSNESTEDMESSAGE_B8._serialized_start=779 _LOTSNESTEDMESSAGE_B8._serialized_end=783 _LOTSNESTEDMESSAGE_B9._serialized_start=785 _LOTSNESTEDMESSAGE_B9._serialized_end=789 _LOTSNESTEDMESSAGE_B10._serialized_start=791 _LOTSNESTEDMESSAGE_B10._serialized_end=796 _LOTSNESTEDMESSAGE_B11._serialized_start=798 _LOTSNESTEDMESSAGE_B11._serialized_end=803 _LOTSNESTEDMESSAGE_B12._serialized_start=805 _LOTSNESTEDMESSAGE_B12._serialized_end=810 _LOTSNESTEDMESSAGE_B13._serialized_start=812 _LOTSNESTEDMESSAGE_B13._serialized_end=817 _LOTSNESTEDMESSAGE_B14._serialized_start=819 _LOTSNESTEDMESSAGE_B14._serialized_end=824 _LOTSNESTEDMESSAGE_B15._serialized_start=826 _LOTSNESTEDMESSAGE_B15._serialized_end=831 _LOTSNESTEDMESSAGE_B16._serialized_start=833 _LOTSNESTEDMESSAGE_B16._serialized_end=838 _LOTSNESTEDMESSAGE_B17._serialized_start=840 _LOTSNESTEDMESSAGE_B17._serialized_end=845 _LOTSNESTEDMESSAGE_B18._serialized_start=847 _LOTSNESTEDMESSAGE_B18._serialized_end=852 _LOTSNESTEDMESSAGE_B19._serialized_start=854 _LOTSNESTEDMESSAGE_B19._serialized_end=859 _LOTSNESTEDMESSAGE_B20._serialized_start=861 _LOTSNESTEDMESSAGE_B20._serialized_end=866 _LOTSNESTEDMESSAGE_B21._serialized_start=868 _LOTSNESTEDMESSAGE_B21._serialized_end=873 _LOTSNESTEDMESSAGE_B22._serialized_start=875 _LOTSNESTEDMESSAGE_B22._serialized_end=880 _LOTSNESTEDMESSAGE_B23._serialized_start=882 _LOTSNESTEDMESSAGE_B23._serialized_end=887 _LOTSNESTEDMESSAGE_B24._serialized_start=889 _LOTSNESTEDMESSAGE_B24._serialized_end=894 _LOTSNESTEDMESSAGE_B25._serialized_start=896 _LOTSNESTEDMESSAGE_B25._serialized_end=901 _LOTSNESTEDMESSAGE_B26._serialized_start=903 _LOTSNESTEDMESSAGE_B26._serialized_end=908 _LOTSNESTEDMESSAGE_B27._serialized_start=910 _LOTSNESTEDMESSAGE_B27._serialized_end=915 _LOTSNESTEDMESSAGE_B28._serialized_start=917 _LOTSNESTEDMESSAGE_B28._serialized_end=922 _LOTSNESTEDMESSAGE_B29._serialized_start=924 _LOTSNESTEDMESSAGE_B29._serialized_end=929 _LOTSNESTEDMESSAGE_B30._serialized_start=931 _LOTSNESTEDMESSAGE_B30._serialized_end=936 _LOTSNESTEDMESSAGE_B31._serialized_start=938 _LOTSNESTEDMESSAGE_B31._serialized_end=943 _LOTSNESTEDMESSAGE_B32._serialized_start=945 _LOTSNESTEDMESSAGE_B32._serialized_end=950 _LOTSNESTEDMESSAGE_B33._serialized_start=952 _LOTSNESTEDMESSAGE_B33._serialized_end=957 _LOTSNESTEDMESSAGE_B34._serialized_start=959 _LOTSNESTEDMESSAGE_B34._serialized_end=964 _LOTSNESTEDMESSAGE_B35._serialized_start=966 _LOTSNESTEDMESSAGE_B35._serialized_end=971 _LOTSNESTEDMESSAGE_B36._serialized_start=973 _LOTSNESTEDMESSAGE_B36._serialized_end=978 _LOTSNESTEDMESSAGE_B37._serialized_start=980 _LOTSNESTEDMESSAGE_B37._serialized_end=985 _LOTSNESTEDMESSAGE_B38._serialized_start=987 _LOTSNESTEDMESSAGE_B38._serialized_end=992 _LOTSNESTEDMESSAGE_B39._serialized_start=994 _LOTSNESTEDMESSAGE_B39._serialized_end=999 _LOTSNESTEDMESSAGE_B40._serialized_start=1001 _LOTSNESTEDMESSAGE_B40._serialized_end=1006 _LOTSNESTEDMESSAGE_B41._serialized_start=1008 _LOTSNESTEDMESSAGE_B41._serialized_end=1013 _LOTSNESTEDMESSAGE_B42._serialized_start=1015 _LOTSNESTEDMESSAGE_B42._serialized_end=1020 _LOTSNESTEDMESSAGE_B43._serialized_start=1022 _LOTSNESTEDMESSAGE_B43._serialized_end=1027 _LOTSNESTEDMESSAGE_B44._serialized_start=1029 _LOTSNESTEDMESSAGE_B44._serialized_end=1034 _LOTSNESTEDMESSAGE_B45._serialized_start=1036 _LOTSNESTEDMESSAGE_B45._serialized_end=1041 _LOTSNESTEDMESSAGE_B46._serialized_start=1043 _LOTSNESTEDMESSAGE_B46._serialized_end=1048 _LOTSNESTEDMESSAGE_B47._serialized_start=1050 _LOTSNESTEDMESSAGE_B47._serialized_end=1055 _LOTSNESTEDMESSAGE_B48._serialized_start=1057 _LOTSNESTEDMESSAGE_B48._serialized_end=1062 _LOTSNESTEDMESSAGE_B49._serialized_start=1064 _LOTSNESTEDMESSAGE_B49._serialized_end=1069 _LOTSNESTEDMESSAGE_B50._serialized_start=1071 _LOTSNESTEDMESSAGE_B50._serialized_end=1076 _LOTSNESTEDMESSAGE_B51._serialized_start=1078 _LOTSNESTEDMESSAGE_B51._serialized_end=1083 _LOTSNESTEDMESSAGE_B52._serialized_start=1085 _LOTSNESTEDMESSAGE_B52._serialized_end=1090 _LOTSNESTEDMESSAGE_B53._serialized_start=1092 _LOTSNESTEDMESSAGE_B53._serialized_end=1097 _LOTSNESTEDMESSAGE_B54._serialized_start=1099 _LOTSNESTEDMESSAGE_B54._serialized_end=1104 _LOTSNESTEDMESSAGE_B55._serialized_start=1106 _LOTSNESTEDMESSAGE_B55._serialized_end=1111 _LOTSNESTEDMESSAGE_B56._serialized_start=1113 _LOTSNESTEDMESSAGE_B56._serialized_end=1118 _LOTSNESTEDMESSAGE_B57._serialized_start=1120 _LOTSNESTEDMESSAGE_B57._serialized_end=1125 _LOTSNESTEDMESSAGE_B58._serialized_start=1127 _LOTSNESTEDMESSAGE_B58._serialized_end=1132 _LOTSNESTEDMESSAGE_B59._serialized_start=1134 _LOTSNESTEDMESSAGE_B59._serialized_end=1139 _LOTSNESTEDMESSAGE_B60._serialized_start=1141 _LOTSNESTEDMESSAGE_B60._serialized_end=1146 _LOTSNESTEDMESSAGE_B61._serialized_start=1148 _LOTSNESTEDMESSAGE_B61._serialized_end=1153 _LOTSNESTEDMESSAGE_B62._serialized_start=1155 _LOTSNESTEDMESSAGE_B62._serialized_end=1160 _LOTSNESTEDMESSAGE_B63._serialized_start=1162 _LOTSNESTEDMESSAGE_B63._serialized_end=1167 _LOTSNESTEDMESSAGE_B64._serialized_start=1169 _LOTSNESTEDMESSAGE_B64._serialized_end=1174 _LOTSNESTEDMESSAGE_B65._serialized_start=1176 _LOTSNESTEDMESSAGE_B65._serialized_end=1181 _LOTSNESTEDMESSAGE_B66._serialized_start=1183 _LOTSNESTEDMESSAGE_B66._serialized_end=1188 _LOTSNESTEDMESSAGE_B67._serialized_start=1190 _LOTSNESTEDMESSAGE_B67._serialized_end=1195 _LOTSNESTEDMESSAGE_B68._serialized_start=1197 _LOTSNESTEDMESSAGE_B68._serialized_end=1202 _LOTSNESTEDMESSAGE_B69._serialized_start=1204 _LOTSNESTEDMESSAGE_B69._serialized_end=1209 _LOTSNESTEDMESSAGE_B70._serialized_start=1211 _LOTSNESTEDMESSAGE_B70._serialized_end=1216 _LOTSNESTEDMESSAGE_B71._serialized_start=1218 _LOTSNESTEDMESSAGE_B71._serialized_end=1223 _LOTSNESTEDMESSAGE_B72._serialized_start=1225 _LOTSNESTEDMESSAGE_B72._serialized_end=1230 _LOTSNESTEDMESSAGE_B73._serialized_start=1232 _LOTSNESTEDMESSAGE_B73._serialized_end=1237 _LOTSNESTEDMESSAGE_B74._serialized_start=1239 _LOTSNESTEDMESSAGE_B74._serialized_end=1244 _LOTSNESTEDMESSAGE_B75._serialized_start=1246 _LOTSNESTEDMESSAGE_B75._serialized_end=1251 _LOTSNESTEDMESSAGE_B76._serialized_start=1253 _LOTSNESTEDMESSAGE_B76._serialized_end=1258 _LOTSNESTEDMESSAGE_B77._serialized_start=1260 _LOTSNESTEDMESSAGE_B77._serialized_end=1265 _LOTSNESTEDMESSAGE_B78._serialized_start=1267 _LOTSNESTEDMESSAGE_B78._serialized_end=1272 _LOTSNESTEDMESSAGE_B79._serialized_start=1274 _LOTSNESTEDMESSAGE_B79._serialized_end=1279 _LOTSNESTEDMESSAGE_B80._serialized_start=1281 _LOTSNESTEDMESSAGE_B80._serialized_end=1286 _LOTSNESTEDMESSAGE_B81._serialized_start=1288 _LOTSNESTEDMESSAGE_B81._serialized_end=1293 _LOTSNESTEDMESSAGE_B82._serialized_start=1295 _LOTSNESTEDMESSAGE_B82._serialized_end=1300 _LOTSNESTEDMESSAGE_B83._serialized_start=1302 _LOTSNESTEDMESSAGE_B83._serialized_end=1307 _LOTSNESTEDMESSAGE_B84._serialized_start=1309 _LOTSNESTEDMESSAGE_B84._serialized_end=1314 _LOTSNESTEDMESSAGE_B85._serialized_start=1316 _LOTSNESTEDMESSAGE_B85._serialized_end=1321 _LOTSNESTEDMESSAGE_B86._serialized_start=1323 _LOTSNESTEDMESSAGE_B86._serialized_end=1328 _LOTSNESTEDMESSAGE_B87._serialized_start=1330 _LOTSNESTEDMESSAGE_B87._serialized_end=1335 _LOTSNESTEDMESSAGE_B88._serialized_start=1337 _LOTSNESTEDMESSAGE_B88._serialized_end=1342 _LOTSNESTEDMESSAGE_B89._serialized_start=1344 _LOTSNESTEDMESSAGE_B89._serialized_end=1349 _LOTSNESTEDMESSAGE_B90._serialized_start=1351 _LOTSNESTEDMESSAGE_B90._serialized_end=1356 _LOTSNESTEDMESSAGE_B91._serialized_start=1358 _LOTSNESTEDMESSAGE_B91._serialized_end=1363 _LOTSNESTEDMESSAGE_B92._serialized_start=1365 _LOTSNESTEDMESSAGE_B92._serialized_end=1370 _LOTSNESTEDMESSAGE_B93._serialized_start=1372 _LOTSNESTEDMESSAGE_B93._serialized_end=1377 _LOTSNESTEDMESSAGE_B94._serialized_start=1379 _LOTSNESTEDMESSAGE_B94._serialized_end=1384 _LOTSNESTEDMESSAGE_B95._serialized_start=1386 _LOTSNESTEDMESSAGE_B95._serialized_end=1391 _LOTSNESTEDMESSAGE_B96._serialized_start=1393 _LOTSNESTEDMESSAGE_B96._serialized_end=1398 _LOTSNESTEDMESSAGE_B97._serialized_start=1400 _LOTSNESTEDMESSAGE_B97._serialized_end=1405 _LOTSNESTEDMESSAGE_B98._serialized_start=1407 _LOTSNESTEDMESSAGE_B98._serialized_end=1412 _LOTSNESTEDMESSAGE_B99._serialized_start=1414 _LOTSNESTEDMESSAGE_B99._serialized_end=1419 _LOTSNESTEDMESSAGE_B100._serialized_start=1421 _LOTSNESTEDMESSAGE_B100._serialized_end=1427 _LOTSNESTEDMESSAGE_B101._serialized_start=1429 _LOTSNESTEDMESSAGE_B101._serialized_end=1435 _LOTSNESTEDMESSAGE_B102._serialized_start=1437 _LOTSNESTEDMESSAGE_B102._serialized_end=1443 _LOTSNESTEDMESSAGE_B103._serialized_start=1445 _LOTSNESTEDMESSAGE_B103._serialized_end=1451 _LOTSNESTEDMESSAGE_B104._serialized_start=1453 _LOTSNESTEDMESSAGE_B104._serialized_end=1459 _LOTSNESTEDMESSAGE_B105._serialized_start=1461 _LOTSNESTEDMESSAGE_B105._serialized_end=1467 _LOTSNESTEDMESSAGE_B106._serialized_start=1469 _LOTSNESTEDMESSAGE_B106._serialized_end=1475 _LOTSNESTEDMESSAGE_B107._serialized_start=1477 _LOTSNESTEDMESSAGE_B107._serialized_end=1483 _LOTSNESTEDMESSAGE_B108._serialized_start=1485 _LOTSNESTEDMESSAGE_B108._serialized_end=1491 _LOTSNESTEDMESSAGE_B109._serialized_start=1493 _LOTSNESTEDMESSAGE_B109._serialized_end=1499 _LOTSNESTEDMESSAGE_B110._serialized_start=1501 _LOTSNESTEDMESSAGE_B110._serialized_end=1507 _LOTSNESTEDMESSAGE_B111._serialized_start=1509 _LOTSNESTEDMESSAGE_B111._serialized_end=1515 _LOTSNESTEDMESSAGE_B112._serialized_start=1517 _LOTSNESTEDMESSAGE_B112._serialized_end=1523 _LOTSNESTEDMESSAGE_B113._serialized_start=1525 _LOTSNESTEDMESSAGE_B113._serialized_end=1531 _LOTSNESTEDMESSAGE_B114._serialized_start=1533 _LOTSNESTEDMESSAGE_B114._serialized_end=1539 _LOTSNESTEDMESSAGE_B115._serialized_start=1541 _LOTSNESTEDMESSAGE_B115._serialized_end=1547 _LOTSNESTEDMESSAGE_B116._serialized_start=1549 _LOTSNESTEDMESSAGE_B116._serialized_end=1555 _LOTSNESTEDMESSAGE_B117._serialized_start=1557 _LOTSNESTEDMESSAGE_B117._serialized_end=1563 _LOTSNESTEDMESSAGE_B118._serialized_start=1565 _LOTSNESTEDMESSAGE_B118._serialized_end=1571 _LOTSNESTEDMESSAGE_B119._serialized_start=1573 _LOTSNESTEDMESSAGE_B119._serialized_end=1579 _LOTSNESTEDMESSAGE_B120._serialized_start=1581 _LOTSNESTEDMESSAGE_B120._serialized_end=1587 _LOTSNESTEDMESSAGE_B121._serialized_start=1589 _LOTSNESTEDMESSAGE_B121._serialized_end=1595 _LOTSNESTEDMESSAGE_B122._serialized_start=1597 _LOTSNESTEDMESSAGE_B122._serialized_end=1603 _LOTSNESTEDMESSAGE_B123._serialized_start=1605 _LOTSNESTEDMESSAGE_B123._serialized_end=1611 _LOTSNESTEDMESSAGE_B124._serialized_start=1613 _LOTSNESTEDMESSAGE_B124._serialized_end=1619 _LOTSNESTEDMESSAGE_B125._serialized_start=1621 _LOTSNESTEDMESSAGE_B125._serialized_end=1627 _LOTSNESTEDMESSAGE_B126._serialized_start=1629 _LOTSNESTEDMESSAGE_B126._serialized_end=1635 _LOTSNESTEDMESSAGE_B127._serialized_start=1637 _LOTSNESTEDMESSAGE_B127._serialized_end=1643 _LOTSNESTEDMESSAGE_B128._serialized_start=1645 _LOTSNESTEDMESSAGE_B128._serialized_end=1651 _LOTSNESTEDMESSAGE_B129._serialized_start=1653 _LOTSNESTEDMESSAGE_B129._serialized_end=1659 _LOTSNESTEDMESSAGE_B130._serialized_start=1661 _LOTSNESTEDMESSAGE_B130._serialized_end=1667 _LOTSNESTEDMESSAGE_B131._serialized_start=1669 _LOTSNESTEDMESSAGE_B131._serialized_end=1675 _LOTSNESTEDMESSAGE_B132._serialized_start=1677 _LOTSNESTEDMESSAGE_B132._serialized_end=1683 _LOTSNESTEDMESSAGE_B133._serialized_start=1685 _LOTSNESTEDMESSAGE_B133._serialized_end=1691 _LOTSNESTEDMESSAGE_B134._serialized_start=1693 _LOTSNESTEDMESSAGE_B134._serialized_end=1699 _LOTSNESTEDMESSAGE_B135._serialized_start=1701 _LOTSNESTEDMESSAGE_B135._serialized_end=1707 _LOTSNESTEDMESSAGE_B136._serialized_start=1709 _LOTSNESTEDMESSAGE_B136._serialized_end=1715 _LOTSNESTEDMESSAGE_B137._serialized_start=1717 _LOTSNESTEDMESSAGE_B137._serialized_end=1723 _LOTSNESTEDMESSAGE_B138._serialized_start=1725 _LOTSNESTEDMESSAGE_B138._serialized_end=1731 _LOTSNESTEDMESSAGE_B139._serialized_start=1733 _LOTSNESTEDMESSAGE_B139._serialized_end=1739 _LOTSNESTEDMESSAGE_B140._serialized_start=1741 _LOTSNESTEDMESSAGE_B140._serialized_end=1747 _LOTSNESTEDMESSAGE_B141._serialized_start=1749 _LOTSNESTEDMESSAGE_B141._serialized_end=1755 _LOTSNESTEDMESSAGE_B142._serialized_start=1757 _LOTSNESTEDMESSAGE_B142._serialized_end=1763 _LOTSNESTEDMESSAGE_B143._serialized_start=1765 _LOTSNESTEDMESSAGE_B143._serialized_end=1771 _LOTSNESTEDMESSAGE_B144._serialized_start=1773 _LOTSNESTEDMESSAGE_B144._serialized_end=1779 _LOTSNESTEDMESSAGE_B145._serialized_start=1781 _LOTSNESTEDMESSAGE_B145._serialized_end=1787 _LOTSNESTEDMESSAGE_B146._serialized_start=1789 _LOTSNESTEDMESSAGE_B146._serialized_end=1795 _LOTSNESTEDMESSAGE_B147._serialized_start=1797 _LOTSNESTEDMESSAGE_B147._serialized_end=1803 _LOTSNESTEDMESSAGE_B148._serialized_start=1805 _LOTSNESTEDMESSAGE_B148._serialized_end=1811 _LOTSNESTEDMESSAGE_B149._serialized_start=1813 _LOTSNESTEDMESSAGE_B149._serialized_end=1819 _LOTSNESTEDMESSAGE_B150._serialized_start=1821 _LOTSNESTEDMESSAGE_B150._serialized_end=1827 _LOTSNESTEDMESSAGE_B151._serialized_start=1829 _LOTSNESTEDMESSAGE_B151._serialized_end=1835 _LOTSNESTEDMESSAGE_B152._serialized_start=1837 _LOTSNESTEDMESSAGE_B152._serialized_end=1843 _LOTSNESTEDMESSAGE_B153._serialized_start=1845 _LOTSNESTEDMESSAGE_B153._serialized_end=1851 _LOTSNESTEDMESSAGE_B154._serialized_start=1853 _LOTSNESTEDMESSAGE_B154._serialized_end=1859 _LOTSNESTEDMESSAGE_B155._serialized_start=1861 _LOTSNESTEDMESSAGE_B155._serialized_end=1867 _LOTSNESTEDMESSAGE_B156._serialized_start=1869 _LOTSNESTEDMESSAGE_B156._serialized_end=1875 _LOTSNESTEDMESSAGE_B157._serialized_start=1877 _LOTSNESTEDMESSAGE_B157._serialized_end=1883 _LOTSNESTEDMESSAGE_B158._serialized_start=1885 _LOTSNESTEDMESSAGE_B158._serialized_end=1891 _LOTSNESTEDMESSAGE_B159._serialized_start=1893 _LOTSNESTEDMESSAGE_B159._serialized_end=1899 _LOTSNESTEDMESSAGE_B160._serialized_start=1901 _LOTSNESTEDMESSAGE_B160._serialized_end=1907 _LOTSNESTEDMESSAGE_B161._serialized_start=1909 _LOTSNESTEDMESSAGE_B161._serialized_end=1915 _LOTSNESTEDMESSAGE_B162._serialized_start=1917 _LOTSNESTEDMESSAGE_B162._serialized_end=1923 _LOTSNESTEDMESSAGE_B163._serialized_start=1925 _LOTSNESTEDMESSAGE_B163._serialized_end=1931 _LOTSNESTEDMESSAGE_B164._serialized_start=1933 _LOTSNESTEDMESSAGE_B164._serialized_end=1939 _LOTSNESTEDMESSAGE_B165._serialized_start=1941 _LOTSNESTEDMESSAGE_B165._serialized_end=1947 _LOTSNESTEDMESSAGE_B166._serialized_start=1949 _LOTSNESTEDMESSAGE_B166._serialized_end=1955 _LOTSNESTEDMESSAGE_B167._serialized_start=1957 _LOTSNESTEDMESSAGE_B167._serialized_end=1963 _LOTSNESTEDMESSAGE_B168._serialized_start=1965 _LOTSNESTEDMESSAGE_B168._serialized_end=1971 _LOTSNESTEDMESSAGE_B169._serialized_start=1973 _LOTSNESTEDMESSAGE_B169._serialized_end=1979 _LOTSNESTEDMESSAGE_B170._serialized_start=1981 _LOTSNESTEDMESSAGE_B170._serialized_end=1987 _LOTSNESTEDMESSAGE_B171._serialized_start=1989 _LOTSNESTEDMESSAGE_B171._serialized_end=1995 _LOTSNESTEDMESSAGE_B172._serialized_start=1997 _LOTSNESTEDMESSAGE_B172._serialized_end=2003 _LOTSNESTEDMESSAGE_B173._serialized_start=2005 _LOTSNESTEDMESSAGE_B173._serialized_end=2011 _LOTSNESTEDMESSAGE_B174._serialized_start=2013 _LOTSNESTEDMESSAGE_B174._serialized_end=2019 _LOTSNESTEDMESSAGE_B175._serialized_start=2021 _LOTSNESTEDMESSAGE_B175._serialized_end=2027 _LOTSNESTEDMESSAGE_B176._serialized_start=2029 _LOTSNESTEDMESSAGE_B176._serialized_end=2035 _LOTSNESTEDMESSAGE_B177._serialized_start=2037 _LOTSNESTEDMESSAGE_B177._serialized_end=2043 _LOTSNESTEDMESSAGE_B178._serialized_start=2045 _LOTSNESTEDMESSAGE_B178._serialized_end=2051 _LOTSNESTEDMESSAGE_B179._serialized_start=2053 _LOTSNESTEDMESSAGE_B179._serialized_end=2059 _LOTSNESTEDMESSAGE_B180._serialized_start=2061 _LOTSNESTEDMESSAGE_B180._serialized_end=2067 _LOTSNESTEDMESSAGE_B181._serialized_start=2069 _LOTSNESTEDMESSAGE_B181._serialized_end=2075 _LOTSNESTEDMESSAGE_B182._serialized_start=2077 _LOTSNESTEDMESSAGE_B182._serialized_end=2083 _LOTSNESTEDMESSAGE_B183._serialized_start=2085 _LOTSNESTEDMESSAGE_B183._serialized_end=2091 _LOTSNESTEDMESSAGE_B184._serialized_start=2093 _LOTSNESTEDMESSAGE_B184._serialized_end=2099 _LOTSNESTEDMESSAGE_B185._serialized_start=2101 _LOTSNESTEDMESSAGE_B185._serialized_end=2107 _LOTSNESTEDMESSAGE_B186._serialized_start=2109 _LOTSNESTEDMESSAGE_B186._serialized_end=2115 _LOTSNESTEDMESSAGE_B187._serialized_start=2117 _LOTSNESTEDMESSAGE_B187._serialized_end=2123 _LOTSNESTEDMESSAGE_B188._serialized_start=2125 _LOTSNESTEDMESSAGE_B188._serialized_end=2131 _LOTSNESTEDMESSAGE_B189._serialized_start=2133 _LOTSNESTEDMESSAGE_B189._serialized_end=2139 _LOTSNESTEDMESSAGE_B190._serialized_start=2141 _LOTSNESTEDMESSAGE_B190._serialized_end=2147 _LOTSNESTEDMESSAGE_B191._serialized_start=2149 _LOTSNESTEDMESSAGE_B191._serialized_end=2155 _LOTSNESTEDMESSAGE_B192._serialized_start=2157 _LOTSNESTEDMESSAGE_B192._serialized_end=2163 _LOTSNESTEDMESSAGE_B193._serialized_start=2165 _LOTSNESTEDMESSAGE_B193._serialized_end=2171 _LOTSNESTEDMESSAGE_B194._serialized_start=2173 _LOTSNESTEDMESSAGE_B194._serialized_end=2179 _LOTSNESTEDMESSAGE_B195._serialized_start=2181 _LOTSNESTEDMESSAGE_B195._serialized_end=2187 _LOTSNESTEDMESSAGE_B196._serialized_start=2189 _LOTSNESTEDMESSAGE_B196._serialized_end=2195 _LOTSNESTEDMESSAGE_B197._serialized_start=2197 _LOTSNESTEDMESSAGE_B197._serialized_end=2203 _LOTSNESTEDMESSAGE_B198._serialized_start=2205 _LOTSNESTEDMESSAGE_B198._serialized_end=2211 _LOTSNESTEDMESSAGE_B199._serialized_start=2213 _LOTSNESTEDMESSAGE_B199._serialized_end=2219 _LOTSNESTEDMESSAGE_B200._serialized_start=2221 _LOTSNESTEDMESSAGE_B200._serialized_end=2227 _LOTSNESTEDMESSAGE_B201._serialized_start=2229 _LOTSNESTEDMESSAGE_B201._serialized_end=2235 _LOTSNESTEDMESSAGE_B202._serialized_start=2237 _LOTSNESTEDMESSAGE_B202._serialized_end=2243 _LOTSNESTEDMESSAGE_B203._serialized_start=2245 _LOTSNESTEDMESSAGE_B203._serialized_end=2251 _LOTSNESTEDMESSAGE_B204._serialized_start=2253 _LOTSNESTEDMESSAGE_B204._serialized_end=2259 _LOTSNESTEDMESSAGE_B205._serialized_start=2261 _LOTSNESTEDMESSAGE_B205._serialized_end=2267 _LOTSNESTEDMESSAGE_B206._serialized_start=2269 _LOTSNESTEDMESSAGE_B206._serialized_end=2275 _LOTSNESTEDMESSAGE_B207._serialized_start=2277 _LOTSNESTEDMESSAGE_B207._serialized_end=2283 _LOTSNESTEDMESSAGE_B208._serialized_start=2285 _LOTSNESTEDMESSAGE_B208._serialized_end=2291 _LOTSNESTEDMESSAGE_B209._serialized_start=2293 _LOTSNESTEDMESSAGE_B209._serialized_end=2299 _LOTSNESTEDMESSAGE_B210._serialized_start=2301 _LOTSNESTEDMESSAGE_B210._serialized_end=2307 _LOTSNESTEDMESSAGE_B211._serialized_start=2309 _LOTSNESTEDMESSAGE_B211._serialized_end=2315 _LOTSNESTEDMESSAGE_B212._serialized_start=2317 _LOTSNESTEDMESSAGE_B212._serialized_end=2323 _LOTSNESTEDMESSAGE_B213._serialized_start=2325 _LOTSNESTEDMESSAGE_B213._serialized_end=2331 _LOTSNESTEDMESSAGE_B214._serialized_start=2333 _LOTSNESTEDMESSAGE_B214._serialized_end=2339 _LOTSNESTEDMESSAGE_B215._serialized_start=2341 _LOTSNESTEDMESSAGE_B215._serialized_end=2347 _LOTSNESTEDMESSAGE_B216._serialized_start=2349 _LOTSNESTEDMESSAGE_B216._serialized_end=2355 _LOTSNESTEDMESSAGE_B217._serialized_start=2357 _LOTSNESTEDMESSAGE_B217._serialized_end=2363 _LOTSNESTEDMESSAGE_B218._serialized_start=2365 _LOTSNESTEDMESSAGE_B218._serialized_end=2371 _LOTSNESTEDMESSAGE_B219._serialized_start=2373 _LOTSNESTEDMESSAGE_B219._serialized_end=2379 _LOTSNESTEDMESSAGE_B220._serialized_start=2381 _LOTSNESTEDMESSAGE_B220._serialized_end=2387 _LOTSNESTEDMESSAGE_B221._serialized_start=2389 _LOTSNESTEDMESSAGE_B221._serialized_end=2395 _LOTSNESTEDMESSAGE_B222._serialized_start=2397 _LOTSNESTEDMESSAGE_B222._serialized_end=2403 _LOTSNESTEDMESSAGE_B223._serialized_start=2405 _LOTSNESTEDMESSAGE_B223._serialized_end=2411 _LOTSNESTEDMESSAGE_B224._serialized_start=2413 _LOTSNESTEDMESSAGE_B224._serialized_end=2419 _LOTSNESTEDMESSAGE_B225._serialized_start=2421 _LOTSNESTEDMESSAGE_B225._serialized_end=2427 _LOTSNESTEDMESSAGE_B226._serialized_start=2429 _LOTSNESTEDMESSAGE_B226._serialized_end=2435 _LOTSNESTEDMESSAGE_B227._serialized_start=2437 _LOTSNESTEDMESSAGE_B227._serialized_end=2443 _LOTSNESTEDMESSAGE_B228._serialized_start=2445 _LOTSNESTEDMESSAGE_B228._serialized_end=2451 _LOTSNESTEDMESSAGE_B229._serialized_start=2453 _LOTSNESTEDMESSAGE_B229._serialized_end=2459 _LOTSNESTEDMESSAGE_B230._serialized_start=2461 _LOTSNESTEDMESSAGE_B230._serialized_end=2467 _LOTSNESTEDMESSAGE_B231._serialized_start=2469 _LOTSNESTEDMESSAGE_B231._serialized_end=2475 _LOTSNESTEDMESSAGE_B232._serialized_start=2477 _LOTSNESTEDMESSAGE_B232._serialized_end=2483 _LOTSNESTEDMESSAGE_B233._serialized_start=2485 _LOTSNESTEDMESSAGE_B233._serialized_end=2491 _LOTSNESTEDMESSAGE_B234._serialized_start=2493 _LOTSNESTEDMESSAGE_B234._serialized_end=2499 _LOTSNESTEDMESSAGE_B235._serialized_start=2501 _LOTSNESTEDMESSAGE_B235._serialized_end=2507 _LOTSNESTEDMESSAGE_B236._serialized_start=2509 _LOTSNESTEDMESSAGE_B236._serialized_end=2515 _LOTSNESTEDMESSAGE_B237._serialized_start=2517 _LOTSNESTEDMESSAGE_B237._serialized_end=2523 _LOTSNESTEDMESSAGE_B238._serialized_start=2525 _LOTSNESTEDMESSAGE_B238._serialized_end=2531 _LOTSNESTEDMESSAGE_B239._serialized_start=2533 _LOTSNESTEDMESSAGE_B239._serialized_end=2539 _LOTSNESTEDMESSAGE_B240._serialized_start=2541 _LOTSNESTEDMESSAGE_B240._serialized_end=2547 _LOTSNESTEDMESSAGE_B241._serialized_start=2549 _LOTSNESTEDMESSAGE_B241._serialized_end=2555 _LOTSNESTEDMESSAGE_B242._serialized_start=2557 _LOTSNESTEDMESSAGE_B242._serialized_end=2563 _LOTSNESTEDMESSAGE_B243._serialized_start=2565 _LOTSNESTEDMESSAGE_B243._serialized_end=2571 _LOTSNESTEDMESSAGE_B244._serialized_start=2573 _LOTSNESTEDMESSAGE_B244._serialized_end=2579 _LOTSNESTEDMESSAGE_B245._serialized_start=2581 _LOTSNESTEDMESSAGE_B245._serialized_end=2587 _LOTSNESTEDMESSAGE_B246._serialized_start=2589 _LOTSNESTEDMESSAGE_B246._serialized_end=2595 _LOTSNESTEDMESSAGE_B247._serialized_start=2597 _LOTSNESTEDMESSAGE_B247._serialized_end=2603 _LOTSNESTEDMESSAGE_B248._serialized_start=2605 _LOTSNESTEDMESSAGE_B248._serialized_end=2611 _LOTSNESTEDMESSAGE_B249._serialized_start=2613 _LOTSNESTEDMESSAGE_B249._serialized_end=2619 _LOTSNESTEDMESSAGE_B250._serialized_start=2621 _LOTSNESTEDMESSAGE_B250._serialized_end=2627 _LOTSNESTEDMESSAGE_B251._serialized_start=2629 _LOTSNESTEDMESSAGE_B251._serialized_end=2635 _LOTSNESTEDMESSAGE_B252._serialized_start=2637 _LOTSNESTEDMESSAGE_B252._serialized_end=2643 _LOTSNESTEDMESSAGE_B253._serialized_start=2645 _LOTSNESTEDMESSAGE_B253._serialized_end=2651 _LOTSNESTEDMESSAGE_B254._serialized_start=2653 _LOTSNESTEDMESSAGE_B254._serialized_end=2659 _LOTSNESTEDMESSAGE_B255._serialized_start=2661 _LOTSNESTEDMESSAGE_B255._serialized_end=2667 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/no_package_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/internal/no_package.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)google/protobuf/internal/no_package.proto\";\n\x10NoPackageMessage\x12\'\n\x0fno_package_enum\x18\x01 \x01(\x0e\x32\x0e.NoPackageEnum*?\n\rNoPackageEnum\x12\x16\n\x12NO_PACKAGE_VALUE_0\x10\x00\x12\x16\n\x12NO_PACKAGE_VALUE_1\x10\x01') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.internal.no_package_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None _NOPACKAGEENUM._serialized_start=106 _NOPACKAGEENUM._serialized_end=169 _NOPACKAGEMESSAGE._serialized_start=45 _NOPACKAGEMESSAGE._serialized_end=104 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/python_message.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # This code is meant to work on Python 2.4 and above only. # # TODO(robinson): Helpers for verbose, common checks like seeing if a # descriptor's cpp_type is CPPTYPE_MESSAGE. """Contains a metaclass and helper functions used to create protocol message classes from Descriptor objects at runtime. Recall that a metaclass is the "type" of a class. (A class is to a metaclass what an instance is to a class.) In this case, we use the GeneratedProtocolMessageType metaclass to inject all the useful functionality into the classes output by the protocol compiler at compile-time. The upshot of all this is that the real implementation details for ALL pure-Python protocol buffers are *here in this file*. """ __author__ = 'robinson@google.com (Will Robinson)' from io import BytesIO import struct import sys import weakref # We use "as" to avoid name collisions with variables. from google.protobuf.internal import api_implementation from google.protobuf.internal import containers from google.protobuf.internal import decoder from google.protobuf.internal import encoder from google.protobuf.internal import enum_type_wrapper from google.protobuf.internal import extension_dict from google.protobuf.internal import message_listener as message_listener_mod from google.protobuf.internal import type_checkers from google.protobuf.internal import well_known_types from google.protobuf.internal import wire_format from google.protobuf import descriptor as descriptor_mod from google.protobuf import message as message_mod from google.protobuf import text_format _FieldDescriptor = descriptor_mod.FieldDescriptor _AnyFullTypeName = 'google.protobuf.Any' _ExtensionDict = extension_dict._ExtensionDict class GeneratedProtocolMessageType(type): """Metaclass for protocol message classes created at runtime from Descriptors. We add implementations for all methods described in the Message class. We also create properties to allow getting/setting all fields in the protocol message. Finally, we create slots to prevent users from accidentally "setting" nonexistent fields in the protocol message, which then wouldn't get serialized / deserialized properly. The protocol compiler currently uses this metaclass to create protocol message classes at runtime. Clients can also manually create their own classes at runtime, as in this example: mydescriptor = Descriptor(.....) factory = symbol_database.Default() factory.pool.AddDescriptor(mydescriptor) MyProtoClass = factory.GetPrototype(mydescriptor) myproto_instance = MyProtoClass() myproto.foo_field = 23 ... """ # Must be consistent with the protocol-compiler code in # proto2/compiler/internal/generator.*. _DESCRIPTOR_KEY = 'DESCRIPTOR' def __new__(cls, name, bases, dictionary): """Custom allocation for runtime-generated class types. We override __new__ because this is apparently the only place where we can meaningfully set __slots__ on the class we're creating(?). (The interplay between metaclasses and slots is not very well-documented). Args: name: Name of the class (ignored, but required by the metaclass protocol). bases: Base classes of the class we're constructing. (Should be message.Message). We ignore this field, but it's required by the metaclass protocol dictionary: The class dictionary of the class we're constructing. dictionary[_DESCRIPTOR_KEY] must contain a Descriptor object describing this protocol message type. Returns: Newly-allocated class. Raises: RuntimeError: Generated code only work with python cpp extension. """ descriptor = dictionary[GeneratedProtocolMessageType._DESCRIPTOR_KEY] if isinstance(descriptor, str): raise RuntimeError('The generated code only work with python cpp ' 'extension, but it is using pure python runtime.') # If a concrete class already exists for this descriptor, don't try to # create another. Doing so will break any messages that already exist with # the existing class. # # The C++ implementation appears to have its own internal `PyMessageFactory` # to achieve similar results. # # This most commonly happens in `text_format.py` when using descriptors from # a custom pool; it calls symbol_database.Global().getPrototype() on a # descriptor which already has an existing concrete class. new_class = getattr(descriptor, '_concrete_class', None) if new_class: return new_class if descriptor.full_name in well_known_types.WKTBASES: bases += (well_known_types.WKTBASES[descriptor.full_name],) _AddClassAttributesForNestedExtensions(descriptor, dictionary) _AddSlots(descriptor, dictionary) superclass = super(GeneratedProtocolMessageType, cls) new_class = superclass.__new__(cls, name, bases, dictionary) return new_class def __init__(cls, name, bases, dictionary): """Here we perform the majority of our work on the class. We add enum getters, an __init__ method, implementations of all Message methods, and properties for all fields in the protocol type. Args: name: Name of the class (ignored, but required by the metaclass protocol). bases: Base classes of the class we're constructing. (Should be message.Message). We ignore this field, but it's required by the metaclass protocol dictionary: The class dictionary of the class we're constructing. dictionary[_DESCRIPTOR_KEY] must contain a Descriptor object describing this protocol message type. """ descriptor = dictionary[GeneratedProtocolMessageType._DESCRIPTOR_KEY] # If this is an _existing_ class looked up via `_concrete_class` in the # __new__ method above, then we don't need to re-initialize anything. existing_class = getattr(descriptor, '_concrete_class', None) if existing_class: assert existing_class is cls, ( 'Duplicate `GeneratedProtocolMessageType` created for descriptor %r' % (descriptor.full_name)) return cls._decoders_by_tag = {} if (descriptor.has_options and descriptor.GetOptions().message_set_wire_format): cls._decoders_by_tag[decoder.MESSAGE_SET_ITEM_TAG] = ( decoder.MessageSetItemDecoder(descriptor), None) # Attach stuff to each FieldDescriptor for quick lookup later on. for field in descriptor.fields: _AttachFieldHelpers(cls, field) descriptor._concrete_class = cls # pylint: disable=protected-access _AddEnumValues(descriptor, cls) _AddInitMethod(descriptor, cls) _AddPropertiesForFields(descriptor, cls) _AddPropertiesForExtensions(descriptor, cls) _AddStaticMethods(cls) _AddMessageMethods(descriptor, cls) _AddPrivateHelperMethods(descriptor, cls) superclass = super(GeneratedProtocolMessageType, cls) superclass.__init__(name, bases, dictionary) # Stateless helpers for GeneratedProtocolMessageType below. # Outside clients should not access these directly. # # I opted not to make any of these methods on the metaclass, to make it more # clear that I'm not really using any state there and to keep clients from # thinking that they have direct access to these construction helpers. def _PropertyName(proto_field_name): """Returns the name of the public property attribute which clients can use to get and (in some cases) set the value of a protocol message field. Args: proto_field_name: The protocol message field name, exactly as it appears (or would appear) in a .proto file. """ # TODO(robinson): Escape Python keywords (e.g., yield), and test this support. # nnorwitz makes my day by writing: # """ # FYI. See the keyword module in the stdlib. This could be as simple as: # # if keyword.iskeyword(proto_field_name): # return proto_field_name + "_" # return proto_field_name # """ # Kenton says: The above is a BAD IDEA. People rely on being able to use # getattr() and setattr() to reflectively manipulate field values. If we # rename the properties, then every such user has to also make sure to apply # the same transformation. Note that currently if you name a field "yield", # you can still access it just fine using getattr/setattr -- it's not even # that cumbersome to do so. # TODO(kenton): Remove this method entirely if/when everyone agrees with my # position. return proto_field_name def _AddSlots(message_descriptor, dictionary): """Adds a __slots__ entry to dictionary, containing the names of all valid attributes for this message type. Args: message_descriptor: A Descriptor instance describing this message type. dictionary: Class dictionary to which we'll add a '__slots__' entry. """ dictionary['__slots__'] = ['_cached_byte_size', '_cached_byte_size_dirty', '_fields', '_unknown_fields', '_unknown_field_set', '_is_present_in_parent', '_listener', '_listener_for_children', '__weakref__', '_oneofs'] def _IsMessageSetExtension(field): return (field.is_extension and field.containing_type.has_options and field.containing_type.GetOptions().message_set_wire_format and field.type == _FieldDescriptor.TYPE_MESSAGE and field.label == _FieldDescriptor.LABEL_OPTIONAL) def _IsMapField(field): return (field.type == _FieldDescriptor.TYPE_MESSAGE and field.message_type.has_options and field.message_type.GetOptions().map_entry) def _IsMessageMapField(field): value_type = field.message_type.fields_by_name['value'] return value_type.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE def _AttachFieldHelpers(cls, field_descriptor): is_repeated = (field_descriptor.label == _FieldDescriptor.LABEL_REPEATED) is_packable = (is_repeated and wire_format.IsTypePackable(field_descriptor.type)) is_proto3 = field_descriptor.containing_type.syntax == 'proto3' if not is_packable: is_packed = False elif field_descriptor.containing_type.syntax == 'proto2': is_packed = (field_descriptor.has_options and field_descriptor.GetOptions().packed) else: has_packed_false = (field_descriptor.has_options and field_descriptor.GetOptions().HasField('packed') and field_descriptor.GetOptions().packed == False) is_packed = not has_packed_false is_map_entry = _IsMapField(field_descriptor) if is_map_entry: field_encoder = encoder.MapEncoder(field_descriptor) sizer = encoder.MapSizer(field_descriptor, _IsMessageMapField(field_descriptor)) elif _IsMessageSetExtension(field_descriptor): field_encoder = encoder.MessageSetItemEncoder(field_descriptor.number) sizer = encoder.MessageSetItemSizer(field_descriptor.number) else: field_encoder = type_checkers.TYPE_TO_ENCODER[field_descriptor.type]( field_descriptor.number, is_repeated, is_packed) sizer = type_checkers.TYPE_TO_SIZER[field_descriptor.type]( field_descriptor.number, is_repeated, is_packed) field_descriptor._encoder = field_encoder field_descriptor._sizer = sizer field_descriptor._default_constructor = _DefaultValueConstructorForField( field_descriptor) def AddDecoder(wiretype, is_packed): tag_bytes = encoder.TagBytes(field_descriptor.number, wiretype) decode_type = field_descriptor.type if (decode_type == _FieldDescriptor.TYPE_ENUM and type_checkers.SupportsOpenEnums(field_descriptor)): decode_type = _FieldDescriptor.TYPE_INT32 oneof_descriptor = None clear_if_default = False if field_descriptor.containing_oneof is not None: oneof_descriptor = field_descriptor elif (is_proto3 and not is_repeated and field_descriptor.cpp_type != _FieldDescriptor.CPPTYPE_MESSAGE): clear_if_default = True if is_map_entry: is_message_map = _IsMessageMapField(field_descriptor) field_decoder = decoder.MapDecoder( field_descriptor, _GetInitializeDefaultForMap(field_descriptor), is_message_map) elif decode_type == _FieldDescriptor.TYPE_STRING: field_decoder = decoder.StringDecoder( field_descriptor.number, is_repeated, is_packed, field_descriptor, field_descriptor._default_constructor, clear_if_default) elif field_descriptor.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: field_decoder = type_checkers.TYPE_TO_DECODER[decode_type]( field_descriptor.number, is_repeated, is_packed, field_descriptor, field_descriptor._default_constructor) else: field_decoder = type_checkers.TYPE_TO_DECODER[decode_type]( field_descriptor.number, is_repeated, is_packed, # pylint: disable=protected-access field_descriptor, field_descriptor._default_constructor, clear_if_default) cls._decoders_by_tag[tag_bytes] = (field_decoder, oneof_descriptor) AddDecoder(type_checkers.FIELD_TYPE_TO_WIRE_TYPE[field_descriptor.type], False) if is_repeated and wire_format.IsTypePackable(field_descriptor.type): # To support wire compatibility of adding packed = true, add a decoder for # packed values regardless of the field's options. AddDecoder(wire_format.WIRETYPE_LENGTH_DELIMITED, True) def _AddClassAttributesForNestedExtensions(descriptor, dictionary): extensions = descriptor.extensions_by_name for extension_name, extension_field in extensions.items(): assert extension_name not in dictionary dictionary[extension_name] = extension_field def _AddEnumValues(descriptor, cls): """Sets class-level attributes for all enum fields defined in this message. Also exporting a class-level object that can name enum values. Args: descriptor: Descriptor object for this message type. cls: Class we're constructing for this message type. """ for enum_type in descriptor.enum_types: setattr(cls, enum_type.name, enum_type_wrapper.EnumTypeWrapper(enum_type)) for enum_value in enum_type.values: setattr(cls, enum_value.name, enum_value.number) def _GetInitializeDefaultForMap(field): if field.label != _FieldDescriptor.LABEL_REPEATED: raise ValueError('map_entry set on non-repeated field %s' % ( field.name)) fields_by_name = field.message_type.fields_by_name key_checker = type_checkers.GetTypeChecker(fields_by_name['key']) value_field = fields_by_name['value'] if _IsMessageMapField(field): def MakeMessageMapDefault(message): return containers.MessageMap( message._listener_for_children, value_field.message_type, key_checker, field.message_type) return MakeMessageMapDefault else: value_checker = type_checkers.GetTypeChecker(value_field) def MakePrimitiveMapDefault(message): return containers.ScalarMap( message._listener_for_children, key_checker, value_checker, field.message_type) return MakePrimitiveMapDefault def _DefaultValueConstructorForField(field): """Returns a function which returns a default value for a field. Args: field: FieldDescriptor object for this field. The returned function has one argument: message: Message instance containing this field, or a weakref proxy of same. That function in turn returns a default value for this field. The default value may refer back to |message| via a weak reference. """ if _IsMapField(field): return _GetInitializeDefaultForMap(field) if field.label == _FieldDescriptor.LABEL_REPEATED: if field.has_default_value and field.default_value != []: raise ValueError('Repeated field default value not empty list: %s' % ( field.default_value)) if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: # We can't look at _concrete_class yet since it might not have # been set. (Depends on order in which we initialize the classes). message_type = field.message_type def MakeRepeatedMessageDefault(message): return containers.RepeatedCompositeFieldContainer( message._listener_for_children, field.message_type) return MakeRepeatedMessageDefault else: type_checker = type_checkers.GetTypeChecker(field) def MakeRepeatedScalarDefault(message): return containers.RepeatedScalarFieldContainer( message._listener_for_children, type_checker) return MakeRepeatedScalarDefault if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: # _concrete_class may not yet be initialized. message_type = field.message_type def MakeSubMessageDefault(message): assert getattr(message_type, '_concrete_class', None), ( 'Uninitialized concrete class found for field %r (message type %r)' % (field.full_name, message_type.full_name)) result = message_type._concrete_class() result._SetListener( _OneofListener(message, field) if field.containing_oneof is not None else message._listener_for_children) return result return MakeSubMessageDefault def MakeScalarDefault(message): # TODO(protobuf-team): This may be broken since there may not be # default_value. Combine with has_default_value somehow. return field.default_value return MakeScalarDefault def _ReraiseTypeErrorWithFieldName(message_name, field_name): """Re-raise the currently-handled TypeError with the field name added.""" exc = sys.exc_info()[1] if len(exc.args) == 1 and type(exc) is TypeError: # simple TypeError; add field name to exception message exc = TypeError('%s for field %s.%s' % (str(exc), message_name, field_name)) # re-raise possibly-amended exception with original traceback: raise exc.with_traceback(sys.exc_info()[2]) def _AddInitMethod(message_descriptor, cls): """Adds an __init__ method to cls.""" def _GetIntegerEnumValue(enum_type, value): """Convert a string or integer enum value to an integer. If the value is a string, it is converted to the enum value in enum_type with the same name. If the value is not a string, it's returned as-is. (No conversion or bounds-checking is done.) """ if isinstance(value, str): try: return enum_type.values_by_name[value].number except KeyError: raise ValueError('Enum type %s: unknown label "%s"' % ( enum_type.full_name, value)) return value def init(self, **kwargs): self._cached_byte_size = 0 self._cached_byte_size_dirty = len(kwargs) > 0 self._fields = {} # Contains a mapping from oneof field descriptors to the descriptor # of the currently set field in that oneof field. self._oneofs = {} # _unknown_fields is () when empty for efficiency, and will be turned into # a list if fields are added. self._unknown_fields = () # _unknown_field_set is None when empty for efficiency, and will be # turned into UnknownFieldSet struct if fields are added. self._unknown_field_set = None # pylint: disable=protected-access self._is_present_in_parent = False self._listener = message_listener_mod.NullMessageListener() self._listener_for_children = _Listener(self) for field_name, field_value in kwargs.items(): field = _GetFieldByName(message_descriptor, field_name) if field is None: raise TypeError('%s() got an unexpected keyword argument "%s"' % (message_descriptor.name, field_name)) if field_value is None: # field=None is the same as no field at all. continue if field.label == _FieldDescriptor.LABEL_REPEATED: copy = field._default_constructor(self) if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: # Composite if _IsMapField(field): if _IsMessageMapField(field): for key in field_value: copy[key].MergeFrom(field_value[key]) else: copy.update(field_value) else: for val in field_value: if isinstance(val, dict): copy.add(**val) else: copy.add().MergeFrom(val) else: # Scalar if field.cpp_type == _FieldDescriptor.CPPTYPE_ENUM: field_value = [_GetIntegerEnumValue(field.enum_type, val) for val in field_value] copy.extend(field_value) self._fields[field] = copy elif field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: copy = field._default_constructor(self) new_val = field_value if isinstance(field_value, dict): new_val = field.message_type._concrete_class(**field_value) try: copy.MergeFrom(new_val) except TypeError: _ReraiseTypeErrorWithFieldName(message_descriptor.name, field_name) self._fields[field] = copy else: if field.cpp_type == _FieldDescriptor.CPPTYPE_ENUM: field_value = _GetIntegerEnumValue(field.enum_type, field_value) try: setattr(self, field_name, field_value) except TypeError: _ReraiseTypeErrorWithFieldName(message_descriptor.name, field_name) init.__module__ = None init.__doc__ = None cls.__init__ = init def _GetFieldByName(message_descriptor, field_name): """Returns a field descriptor by field name. Args: message_descriptor: A Descriptor describing all fields in message. field_name: The name of the field to retrieve. Returns: The field descriptor associated with the field name. """ try: return message_descriptor.fields_by_name[field_name] except KeyError: raise ValueError('Protocol message %s has no "%s" field.' % (message_descriptor.name, field_name)) def _AddPropertiesForFields(descriptor, cls): """Adds properties for all fields in this protocol message type.""" for field in descriptor.fields: _AddPropertiesForField(field, cls) if descriptor.is_extendable: # _ExtensionDict is just an adaptor with no state so we allocate a new one # every time it is accessed. cls.Extensions = property(lambda self: _ExtensionDict(self)) def _AddPropertiesForField(field, cls): """Adds a public property for a protocol message field. Clients can use this property to get and (in the case of non-repeated scalar fields) directly set the value of a protocol message field. Args: field: A FieldDescriptor for this field. cls: The class we're constructing. """ # Catch it if we add other types that we should # handle specially here. assert _FieldDescriptor.MAX_CPPTYPE == 10 constant_name = field.name.upper() + '_FIELD_NUMBER' setattr(cls, constant_name, field.number) if field.label == _FieldDescriptor.LABEL_REPEATED: _AddPropertiesForRepeatedField(field, cls) elif field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: _AddPropertiesForNonRepeatedCompositeField(field, cls) else: _AddPropertiesForNonRepeatedScalarField(field, cls) class _FieldProperty(property): __slots__ = ('DESCRIPTOR',) def __init__(self, descriptor, getter, setter, doc): property.__init__(self, getter, setter, doc=doc) self.DESCRIPTOR = descriptor def _AddPropertiesForRepeatedField(field, cls): """Adds a public property for a "repeated" protocol message field. Clients can use this property to get the value of the field, which will be either a RepeatedScalarFieldContainer or RepeatedCompositeFieldContainer (see below). Note that when clients add values to these containers, we perform type-checking in the case of repeated scalar fields, and we also set any necessary "has" bits as a side-effect. Args: field: A FieldDescriptor for this field. cls: The class we're constructing. """ proto_field_name = field.name property_name = _PropertyName(proto_field_name) def getter(self): field_value = self._fields.get(field) if field_value is None: # Construct a new object to represent this field. field_value = field._default_constructor(self) # Atomically check if another thread has preempted us and, if not, swap # in the new object we just created. If someone has preempted us, we # take that object and discard ours. # WARNING: We are relying on setdefault() being atomic. This is true # in CPython but we haven't investigated others. This warning appears # in several other locations in this file. field_value = self._fields.setdefault(field, field_value) return field_value getter.__module__ = None getter.__doc__ = 'Getter for %s.' % proto_field_name # We define a setter just so we can throw an exception with a more # helpful error message. def setter(self, new_value): raise AttributeError('Assignment not allowed to repeated field ' '"%s" in protocol message object.' % proto_field_name) doc = 'Magic attribute generated for "%s" proto field.' % proto_field_name setattr(cls, property_name, _FieldProperty(field, getter, setter, doc=doc)) def _AddPropertiesForNonRepeatedScalarField(field, cls): """Adds a public property for a nonrepeated, scalar protocol message field. Clients can use this property to get and directly set the value of the field. Note that when the client sets the value of a field by using this property, all necessary "has" bits are set as a side-effect, and we also perform type-checking. Args: field: A FieldDescriptor for this field. cls: The class we're constructing. """ proto_field_name = field.name property_name = _PropertyName(proto_field_name) type_checker = type_checkers.GetTypeChecker(field) default_value = field.default_value is_proto3 = field.containing_type.syntax == 'proto3' def getter(self): # TODO(protobuf-team): This may be broken since there may not be # default_value. Combine with has_default_value somehow. return self._fields.get(field, default_value) getter.__module__ = None getter.__doc__ = 'Getter for %s.' % proto_field_name clear_when_set_to_default = is_proto3 and not field.containing_oneof def field_setter(self, new_value): # pylint: disable=protected-access # Testing the value for truthiness captures all of the proto3 defaults # (0, 0.0, enum 0, and False). try: new_value = type_checker.CheckValue(new_value) except TypeError as e: raise TypeError( 'Cannot set %s to %.1024r: %s' % (field.full_name, new_value, e)) if clear_when_set_to_default and not new_value: self._fields.pop(field, None) else: self._fields[field] = new_value # Check _cached_byte_size_dirty inline to improve performance, since scalar # setters are called frequently. if not self._cached_byte_size_dirty: self._Modified() if field.containing_oneof: def setter(self, new_value): field_setter(self, new_value) self._UpdateOneofState(field) else: setter = field_setter setter.__module__ = None setter.__doc__ = 'Setter for %s.' % proto_field_name # Add a property to encapsulate the getter/setter. doc = 'Magic attribute generated for "%s" proto field.' % proto_field_name setattr(cls, property_name, _FieldProperty(field, getter, setter, doc=doc)) def _AddPropertiesForNonRepeatedCompositeField(field, cls): """Adds a public property for a nonrepeated, composite protocol message field. A composite field is a "group" or "message" field. Clients can use this property to get the value of the field, but cannot assign to the property directly. Args: field: A FieldDescriptor for this field. cls: The class we're constructing. """ # TODO(robinson): Remove duplication with similar method # for non-repeated scalars. proto_field_name = field.name property_name = _PropertyName(proto_field_name) def getter(self): field_value = self._fields.get(field) if field_value is None: # Construct a new object to represent this field. field_value = field._default_constructor(self) # Atomically check if another thread has preempted us and, if not, swap # in the new object we just created. If someone has preempted us, we # take that object and discard ours. # WARNING: We are relying on setdefault() being atomic. This is true # in CPython but we haven't investigated others. This warning appears # in several other locations in this file. field_value = self._fields.setdefault(field, field_value) return field_value getter.__module__ = None getter.__doc__ = 'Getter for %s.' % proto_field_name # We define a setter just so we can throw an exception with a more # helpful error message. def setter(self, new_value): raise AttributeError('Assignment not allowed to composite field ' '"%s" in protocol message object.' % proto_field_name) # Add a property to encapsulate the getter. doc = 'Magic attribute generated for "%s" proto field.' % proto_field_name setattr(cls, property_name, _FieldProperty(field, getter, setter, doc=doc)) def _AddPropertiesForExtensions(descriptor, cls): """Adds properties for all fields in this protocol message type.""" extensions = descriptor.extensions_by_name for extension_name, extension_field in extensions.items(): constant_name = extension_name.upper() + '_FIELD_NUMBER' setattr(cls, constant_name, extension_field.number) # TODO(amauryfa): Migrate all users of these attributes to functions like # pool.FindExtensionByNumber(descriptor). if descriptor.file is not None: # TODO(amauryfa): Use cls.MESSAGE_FACTORY.pool when available. pool = descriptor.file.pool cls._extensions_by_number = pool._extensions_by_number[descriptor] cls._extensions_by_name = pool._extensions_by_name[descriptor] def _AddStaticMethods(cls): # TODO(robinson): This probably needs to be thread-safe(?) def RegisterExtension(extension_handle): extension_handle.containing_type = cls.DESCRIPTOR # TODO(amauryfa): Use cls.MESSAGE_FACTORY.pool when available. # pylint: disable=protected-access cls.DESCRIPTOR.file.pool._AddExtensionDescriptor(extension_handle) _AttachFieldHelpers(cls, extension_handle) cls.RegisterExtension = staticmethod(RegisterExtension) def FromString(s): message = cls() message.MergeFromString(s) return message cls.FromString = staticmethod(FromString) def _IsPresent(item): """Given a (FieldDescriptor, value) tuple from _fields, return true if the value should be included in the list returned by ListFields().""" if item[0].label == _FieldDescriptor.LABEL_REPEATED: return bool(item[1]) elif item[0].cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: return item[1]._is_present_in_parent else: return True def _AddListFieldsMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def ListFields(self): all_fields = [item for item in self._fields.items() if _IsPresent(item)] all_fields.sort(key = lambda item: item[0].number) return all_fields cls.ListFields = ListFields _PROTO3_ERROR_TEMPLATE = \ ('Protocol message %s has no non-repeated submessage field "%s" ' 'nor marked as optional') _PROTO2_ERROR_TEMPLATE = 'Protocol message %s has no non-repeated field "%s"' def _AddHasFieldMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" is_proto3 = (message_descriptor.syntax == "proto3") error_msg = _PROTO3_ERROR_TEMPLATE if is_proto3 else _PROTO2_ERROR_TEMPLATE hassable_fields = {} for field in message_descriptor.fields: if field.label == _FieldDescriptor.LABEL_REPEATED: continue # For proto3, only submessages and fields inside a oneof have presence. if (is_proto3 and field.cpp_type != _FieldDescriptor.CPPTYPE_MESSAGE and not field.containing_oneof): continue hassable_fields[field.name] = field # Has methods are supported for oneof descriptors. for oneof in message_descriptor.oneofs: hassable_fields[oneof.name] = oneof def HasField(self, field_name): try: field = hassable_fields[field_name] except KeyError: raise ValueError(error_msg % (message_descriptor.full_name, field_name)) if isinstance(field, descriptor_mod.OneofDescriptor): try: return HasField(self, self._oneofs[field].name) except KeyError: return False else: if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: value = self._fields.get(field) return value is not None and value._is_present_in_parent else: return field in self._fields cls.HasField = HasField def _AddClearFieldMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def ClearField(self, field_name): try: field = message_descriptor.fields_by_name[field_name] except KeyError: try: field = message_descriptor.oneofs_by_name[field_name] if field in self._oneofs: field = self._oneofs[field] else: return except KeyError: raise ValueError('Protocol message %s has no "%s" field.' % (message_descriptor.name, field_name)) if field in self._fields: # To match the C++ implementation, we need to invalidate iterators # for map fields when ClearField() happens. if hasattr(self._fields[field], 'InvalidateIterators'): self._fields[field].InvalidateIterators() # Note: If the field is a sub-message, its listener will still point # at us. That's fine, because the worst than can happen is that it # will call _Modified() and invalidate our byte size. Big deal. del self._fields[field] if self._oneofs.get(field.containing_oneof, None) is field: del self._oneofs[field.containing_oneof] # Always call _Modified() -- even if nothing was changed, this is # a mutating method, and thus calling it should cause the field to become # present in the parent message. self._Modified() cls.ClearField = ClearField def _AddClearExtensionMethod(cls): """Helper for _AddMessageMethods().""" def ClearExtension(self, extension_handle): extension_dict._VerifyExtensionHandle(self, extension_handle) # Similar to ClearField(), above. if extension_handle in self._fields: del self._fields[extension_handle] self._Modified() cls.ClearExtension = ClearExtension def _AddHasExtensionMethod(cls): """Helper for _AddMessageMethods().""" def HasExtension(self, extension_handle): extension_dict._VerifyExtensionHandle(self, extension_handle) if extension_handle.label == _FieldDescriptor.LABEL_REPEATED: raise KeyError('"%s" is repeated.' % extension_handle.full_name) if extension_handle.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: value = self._fields.get(extension_handle) return value is not None and value._is_present_in_parent else: return extension_handle in self._fields cls.HasExtension = HasExtension def _InternalUnpackAny(msg): """Unpacks Any message and returns the unpacked message. This internal method is different from public Any Unpack method which takes the target message as argument. _InternalUnpackAny method does not have target message type and need to find the message type in descriptor pool. Args: msg: An Any message to be unpacked. Returns: The unpacked message. """ # TODO(amauryfa): Don't use the factory of generated messages. # To make Any work with custom factories, use the message factory of the # parent message. # pylint: disable=g-import-not-at-top from google.protobuf import symbol_database factory = symbol_database.Default() type_url = msg.type_url if not type_url: return None # TODO(haberman): For now we just strip the hostname. Better logic will be # required. type_name = type_url.split('/')[-1] descriptor = factory.pool.FindMessageTypeByName(type_name) if descriptor is None: return None message_class = factory.GetPrototype(descriptor) message = message_class() message.ParseFromString(msg.value) return message def _AddEqualsMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def __eq__(self, other): if (not isinstance(other, message_mod.Message) or other.DESCRIPTOR != self.DESCRIPTOR): return False if self is other: return True if self.DESCRIPTOR.full_name == _AnyFullTypeName: any_a = _InternalUnpackAny(self) any_b = _InternalUnpackAny(other) if any_a and any_b: return any_a == any_b if not self.ListFields() == other.ListFields(): return False # TODO(jieluo): Fix UnknownFieldSet to consider MessageSet extensions, # then use it for the comparison. unknown_fields = list(self._unknown_fields) unknown_fields.sort() other_unknown_fields = list(other._unknown_fields) other_unknown_fields.sort() return unknown_fields == other_unknown_fields cls.__eq__ = __eq__ def _AddStrMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def __str__(self): return text_format.MessageToString(self) cls.__str__ = __str__ def _AddReprMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def __repr__(self): return text_format.MessageToString(self) cls.__repr__ = __repr__ def _AddUnicodeMethod(unused_message_descriptor, cls): """Helper for _AddMessageMethods().""" def __unicode__(self): return text_format.MessageToString(self, as_utf8=True).decode('utf-8') cls.__unicode__ = __unicode__ def _BytesForNonRepeatedElement(value, field_number, field_type): """Returns the number of bytes needed to serialize a non-repeated element. The returned byte count includes space for tag information and any other additional space associated with serializing value. Args: value: Value we're serializing. field_number: Field number of this value. (Since the field number is stored as part of a varint-encoded tag, this has an impact on the total bytes required to serialize the value). field_type: The type of the field. One of the TYPE_* constants within FieldDescriptor. """ try: fn = type_checkers.TYPE_TO_BYTE_SIZE_FN[field_type] return fn(field_number, value) except KeyError: raise message_mod.EncodeError('Unrecognized field type: %d' % field_type) def _AddByteSizeMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def ByteSize(self): if not self._cached_byte_size_dirty: return self._cached_byte_size size = 0 descriptor = self.DESCRIPTOR if descriptor.GetOptions().map_entry: # Fields of map entry should always be serialized. size = descriptor.fields_by_name['key']._sizer(self.key) size += descriptor.fields_by_name['value']._sizer(self.value) else: for field_descriptor, field_value in self.ListFields(): size += field_descriptor._sizer(field_value) for tag_bytes, value_bytes in self._unknown_fields: size += len(tag_bytes) + len(value_bytes) self._cached_byte_size = size self._cached_byte_size_dirty = False self._listener_for_children.dirty = False return size cls.ByteSize = ByteSize def _AddSerializeToStringMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def SerializeToString(self, **kwargs): # Check if the message has all of its required fields set. if not self.IsInitialized(): raise message_mod.EncodeError( 'Message %s is missing required fields: %s' % ( self.DESCRIPTOR.full_name, ','.join(self.FindInitializationErrors()))) return self.SerializePartialToString(**kwargs) cls.SerializeToString = SerializeToString def _AddSerializePartialToStringMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def SerializePartialToString(self, **kwargs): out = BytesIO() self._InternalSerialize(out.write, **kwargs) return out.getvalue() cls.SerializePartialToString = SerializePartialToString def InternalSerialize(self, write_bytes, deterministic=None): if deterministic is None: deterministic = ( api_implementation.IsPythonDefaultSerializationDeterministic()) else: deterministic = bool(deterministic) descriptor = self.DESCRIPTOR if descriptor.GetOptions().map_entry: # Fields of map entry should always be serialized. descriptor.fields_by_name['key']._encoder( write_bytes, self.key, deterministic) descriptor.fields_by_name['value']._encoder( write_bytes, self.value, deterministic) else: for field_descriptor, field_value in self.ListFields(): field_descriptor._encoder(write_bytes, field_value, deterministic) for tag_bytes, value_bytes in self._unknown_fields: write_bytes(tag_bytes) write_bytes(value_bytes) cls._InternalSerialize = InternalSerialize def _AddMergeFromStringMethod(message_descriptor, cls): """Helper for _AddMessageMethods().""" def MergeFromString(self, serialized): serialized = memoryview(serialized) length = len(serialized) try: if self._InternalParse(serialized, 0, length) != length: # The only reason _InternalParse would return early is if it # encountered an end-group tag. raise message_mod.DecodeError('Unexpected end-group tag.') except (IndexError, TypeError): # Now ord(buf[p:p+1]) == ord('') gets TypeError. raise message_mod.DecodeError('Truncated message.') except struct.error as e: raise message_mod.DecodeError(e) return length # Return this for legacy reasons. cls.MergeFromString = MergeFromString local_ReadTag = decoder.ReadTag local_SkipField = decoder.SkipField decoders_by_tag = cls._decoders_by_tag def InternalParse(self, buffer, pos, end): """Create a message from serialized bytes. Args: self: Message, instance of the proto message object. buffer: memoryview of the serialized data. pos: int, position to start in the serialized data. end: int, end position of the serialized data. Returns: Message object. """ # Guard against internal misuse, since this function is called internally # quite extensively, and its easy to accidentally pass bytes. assert isinstance(buffer, memoryview) self._Modified() field_dict = self._fields # pylint: disable=protected-access unknown_field_set = self._unknown_field_set while pos != end: (tag_bytes, new_pos) = local_ReadTag(buffer, pos) field_decoder, field_desc = decoders_by_tag.get(tag_bytes, (None, None)) if field_decoder is None: if not self._unknown_fields: # pylint: disable=protected-access self._unknown_fields = [] # pylint: disable=protected-access if unknown_field_set is None: # pylint: disable=protected-access self._unknown_field_set = containers.UnknownFieldSet() # pylint: disable=protected-access unknown_field_set = self._unknown_field_set # pylint: disable=protected-access (tag, _) = decoder._DecodeVarint(tag_bytes, 0) field_number, wire_type = wire_format.UnpackTag(tag) if field_number == 0: raise message_mod.DecodeError('Field number 0 is illegal.') # TODO(jieluo): remove old_pos. old_pos = new_pos (data, new_pos) = decoder._DecodeUnknownField( buffer, new_pos, wire_type) # pylint: disable=protected-access if new_pos == -1: return pos # pylint: disable=protected-access unknown_field_set._add(field_number, wire_type, data) # TODO(jieluo): remove _unknown_fields. new_pos = local_SkipField(buffer, old_pos, end, tag_bytes) if new_pos == -1: return pos self._unknown_fields.append( (tag_bytes, buffer[old_pos:new_pos].tobytes())) pos = new_pos else: pos = field_decoder(buffer, new_pos, end, self, field_dict) if field_desc: self._UpdateOneofState(field_desc) return pos cls._InternalParse = InternalParse def _AddIsInitializedMethod(message_descriptor, cls): """Adds the IsInitialized and FindInitializationError methods to the protocol message class.""" required_fields = [field for field in message_descriptor.fields if field.label == _FieldDescriptor.LABEL_REQUIRED] def IsInitialized(self, errors=None): """Checks if all required fields of a message are set. Args: errors: A list which, if provided, will be populated with the field paths of all missing required fields. Returns: True iff the specified message has all required fields set. """ # Performance is critical so we avoid HasField() and ListFields(). for field in required_fields: if (field not in self._fields or (field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE and not self._fields[field]._is_present_in_parent)): if errors is not None: errors.extend(self.FindInitializationErrors()) return False for field, value in list(self._fields.items()): # dict can change size! if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: if field.label == _FieldDescriptor.LABEL_REPEATED: if (field.message_type.has_options and field.message_type.GetOptions().map_entry): continue for element in value: if not element.IsInitialized(): if errors is not None: errors.extend(self.FindInitializationErrors()) return False elif value._is_present_in_parent and not value.IsInitialized(): if errors is not None: errors.extend(self.FindInitializationErrors()) return False return True cls.IsInitialized = IsInitialized def FindInitializationErrors(self): """Finds required fields which are not initialized. Returns: A list of strings. Each string is a path to an uninitialized field from the top-level message, e.g. "foo.bar[5].baz". """ errors = [] # simplify things for field in required_fields: if not self.HasField(field.name): errors.append(field.name) for field, value in self.ListFields(): if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: if field.is_extension: name = '(%s)' % field.full_name else: name = field.name if _IsMapField(field): if _IsMessageMapField(field): for key in value: element = value[key] prefix = '%s[%s].' % (name, key) sub_errors = element.FindInitializationErrors() errors += [prefix + error for error in sub_errors] else: # ScalarMaps can't have any initialization errors. pass elif field.label == _FieldDescriptor.LABEL_REPEATED: for i in range(len(value)): element = value[i] prefix = '%s[%d].' % (name, i) sub_errors = element.FindInitializationErrors() errors += [prefix + error for error in sub_errors] else: prefix = name + '.' sub_errors = value.FindInitializationErrors() errors += [prefix + error for error in sub_errors] return errors cls.FindInitializationErrors = FindInitializationErrors def _FullyQualifiedClassName(klass): module = klass.__module__ name = getattr(klass, '__qualname__', klass.__name__) if module in (None, 'builtins', '__builtin__'): return name return module + '.' + name def _AddMergeFromMethod(cls): LABEL_REPEATED = _FieldDescriptor.LABEL_REPEATED CPPTYPE_MESSAGE = _FieldDescriptor.CPPTYPE_MESSAGE def MergeFrom(self, msg): if not isinstance(msg, cls): raise TypeError( 'Parameter to MergeFrom() must be instance of same class: ' 'expected %s got %s.' % (_FullyQualifiedClassName(cls), _FullyQualifiedClassName(msg.__class__))) assert msg is not self self._Modified() fields = self._fields for field, value in msg._fields.items(): if field.label == LABEL_REPEATED: field_value = fields.get(field) if field_value is None: # Construct a new object to represent this field. field_value = field._default_constructor(self) fields[field] = field_value field_value.MergeFrom(value) elif field.cpp_type == CPPTYPE_MESSAGE: if value._is_present_in_parent: field_value = fields.get(field) if field_value is None: # Construct a new object to represent this field. field_value = field._default_constructor(self) fields[field] = field_value field_value.MergeFrom(value) else: self._fields[field] = value if field.containing_oneof: self._UpdateOneofState(field) if msg._unknown_fields: if not self._unknown_fields: self._unknown_fields = [] self._unknown_fields.extend(msg._unknown_fields) # pylint: disable=protected-access if self._unknown_field_set is None: self._unknown_field_set = containers.UnknownFieldSet() self._unknown_field_set._extend(msg._unknown_field_set) cls.MergeFrom = MergeFrom def _AddWhichOneofMethod(message_descriptor, cls): def WhichOneof(self, oneof_name): """Returns the name of the currently set field inside a oneof, or None.""" try: field = message_descriptor.oneofs_by_name[oneof_name] except KeyError: raise ValueError( 'Protocol message has no oneof "%s" field.' % oneof_name) nested_field = self._oneofs.get(field, None) if nested_field is not None and self.HasField(nested_field.name): return nested_field.name else: return None cls.WhichOneof = WhichOneof def _Clear(self): # Clear fields. self._fields = {} self._unknown_fields = () # pylint: disable=protected-access if self._unknown_field_set is not None: self._unknown_field_set._clear() self._unknown_field_set = None self._oneofs = {} self._Modified() def _UnknownFields(self): if self._unknown_field_set is None: # pylint: disable=protected-access # pylint: disable=protected-access self._unknown_field_set = containers.UnknownFieldSet() return self._unknown_field_set # pylint: disable=protected-access def _DiscardUnknownFields(self): self._unknown_fields = [] self._unknown_field_set = None # pylint: disable=protected-access for field, value in self.ListFields(): if field.cpp_type == _FieldDescriptor.CPPTYPE_MESSAGE: if _IsMapField(field): if _IsMessageMapField(field): for key in value: value[key].DiscardUnknownFields() elif field.label == _FieldDescriptor.LABEL_REPEATED: for sub_message in value: sub_message.DiscardUnknownFields() else: value.DiscardUnknownFields() def _SetListener(self, listener): if listener is None: self._listener = message_listener_mod.NullMessageListener() else: self._listener = listener def _AddMessageMethods(message_descriptor, cls): """Adds implementations of all Message methods to cls.""" _AddListFieldsMethod(message_descriptor, cls) _AddHasFieldMethod(message_descriptor, cls) _AddClearFieldMethod(message_descriptor, cls) if message_descriptor.is_extendable: _AddClearExtensionMethod(cls) _AddHasExtensionMethod(cls) _AddEqualsMethod(message_descriptor, cls) _AddStrMethod(message_descriptor, cls) _AddReprMethod(message_descriptor, cls) _AddUnicodeMethod(message_descriptor, cls) _AddByteSizeMethod(message_descriptor, cls) _AddSerializeToStringMethod(message_descriptor, cls) _AddSerializePartialToStringMethod(message_descriptor, cls) _AddMergeFromStringMethod(message_descriptor, cls) _AddIsInitializedMethod(message_descriptor, cls) _AddMergeFromMethod(cls) _AddWhichOneofMethod(message_descriptor, cls) # Adds methods which do not depend on cls. cls.Clear = _Clear cls.UnknownFields = _UnknownFields cls.DiscardUnknownFields = _DiscardUnknownFields cls._SetListener = _SetListener def _AddPrivateHelperMethods(message_descriptor, cls): """Adds implementation of private helper methods to cls.""" def Modified(self): """Sets the _cached_byte_size_dirty bit to true, and propagates this to our listener iff this was a state change. """ # Note: Some callers check _cached_byte_size_dirty before calling # _Modified() as an extra optimization. So, if this method is ever # changed such that it does stuff even when _cached_byte_size_dirty is # already true, the callers need to be updated. if not self._cached_byte_size_dirty: self._cached_byte_size_dirty = True self._listener_for_children.dirty = True self._is_present_in_parent = True self._listener.Modified() def _UpdateOneofState(self, field): """Sets field as the active field in its containing oneof. Will also delete currently active field in the oneof, if it is different from the argument. Does not mark the message as modified. """ other_field = self._oneofs.setdefault(field.containing_oneof, field) if other_field is not field: del self._fields[other_field] self._oneofs[field.containing_oneof] = field cls._Modified = Modified cls.SetInParent = Modified cls._UpdateOneofState = _UpdateOneofState class _Listener(object): """MessageListener implementation that a parent message registers with its child message. In order to support semantics like: foo.bar.baz.qux = 23 assert foo.HasField('bar') ...child objects must have back references to their parents. This helper class is at the heart of this support. """ def __init__(self, parent_message): """Args: parent_message: The message whose _Modified() method we should call when we receive Modified() messages. """ # This listener establishes a back reference from a child (contained) object # to its parent (containing) object. We make this a weak reference to avoid # creating cyclic garbage when the client finishes with the 'parent' object # in the tree. if isinstance(parent_message, weakref.ProxyType): self._parent_message_weakref = parent_message else: self._parent_message_weakref = weakref.proxy(parent_message) # As an optimization, we also indicate directly on the listener whether # or not the parent message is dirty. This way we can avoid traversing # up the tree in the common case. self.dirty = False def Modified(self): if self.dirty: return try: # Propagate the signal to our parents iff this is the first field set. self._parent_message_weakref._Modified() except ReferenceError: # We can get here if a client has kept a reference to a child object, # and is now setting a field on it, but the child's parent has been # garbage-collected. This is not an error. pass class _OneofListener(_Listener): """Special listener implementation for setting composite oneof fields.""" def __init__(self, parent_message, field): """Args: parent_message: The message whose _Modified() method we should call when we receive Modified() messages. field: The descriptor of the field being set in the parent message. """ super(_OneofListener, self).__init__(parent_message) self._field = field def Modified(self): """Also updates the state of the containing oneof in the parent message.""" try: self._parent_message_weakref._UpdateOneofState(self._field) super(_OneofListener, self).Modified() except ReferenceError: pass ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/type_checkers.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Provides type checking routines. This module defines type checking utilities in the forms of dictionaries: VALUE_CHECKERS: A dictionary of field types and a value validation object. TYPE_TO_BYTE_SIZE_FN: A dictionary with field types and a size computing function. TYPE_TO_SERIALIZE_METHOD: A dictionary with field types and serialization function. FIELD_TYPE_TO_WIRE_TYPE: A dictionary with field typed and their corresponding wire types. TYPE_TO_DESERIALIZE_METHOD: A dictionary with field types and deserialization function. """ __author__ = 'robinson@google.com (Will Robinson)' import ctypes import numbers from google.protobuf.internal import decoder from google.protobuf.internal import encoder from google.protobuf.internal import wire_format from google.protobuf import descriptor _FieldDescriptor = descriptor.FieldDescriptor def TruncateToFourByteFloat(original): return ctypes.c_float(original).value def ToShortestFloat(original): """Returns the shortest float that has same value in wire.""" # All 4 byte floats have between 6 and 9 significant digits, so we # start with 6 as the lower bound. # It has to be iterative because use '.9g' directly can not get rid # of the noises for most values. For example if set a float_field=0.9 # use '.9g' will print 0.899999976. precision = 6 rounded = float('{0:.{1}g}'.format(original, precision)) while TruncateToFourByteFloat(rounded) != original: precision += 1 rounded = float('{0:.{1}g}'.format(original, precision)) return rounded def SupportsOpenEnums(field_descriptor): return field_descriptor.containing_type.syntax == 'proto3' def GetTypeChecker(field): """Returns a type checker for a message field of the specified types. Args: field: FieldDescriptor object for this field. Returns: An instance of TypeChecker which can be used to verify the types of values assigned to a field of the specified type. """ if (field.cpp_type == _FieldDescriptor.CPPTYPE_STRING and field.type == _FieldDescriptor.TYPE_STRING): return UnicodeValueChecker() if field.cpp_type == _FieldDescriptor.CPPTYPE_ENUM: if SupportsOpenEnums(field): # When open enums are supported, any int32 can be assigned. return _VALUE_CHECKERS[_FieldDescriptor.CPPTYPE_INT32] else: return EnumValueChecker(field.enum_type) return _VALUE_CHECKERS[field.cpp_type] # None of the typecheckers below make any attempt to guard against people # subclassing builtin types and doing weird things. We're not trying to # protect against malicious clients here, just people accidentally shooting # themselves in the foot in obvious ways. class TypeChecker(object): """Type checker used to catch type errors as early as possible when the client is setting scalar fields in protocol messages. """ def __init__(self, *acceptable_types): self._acceptable_types = acceptable_types def CheckValue(self, proposed_value): """Type check the provided value and return it. The returned value might have been normalized to another type. """ if not isinstance(proposed_value, self._acceptable_types): message = ('%.1024r has type %s, but expected one of: %s' % (proposed_value, type(proposed_value), self._acceptable_types)) raise TypeError(message) return proposed_value class TypeCheckerWithDefault(TypeChecker): def __init__(self, default_value, *acceptable_types): TypeChecker.__init__(self, *acceptable_types) self._default_value = default_value def DefaultValue(self): return self._default_value class BoolValueChecker(object): """Type checker used for bool fields.""" def CheckValue(self, proposed_value): if not hasattr(proposed_value, '__index__') or ( type(proposed_value).__module__ == 'numpy' and type(proposed_value).__name__ == 'ndarray'): message = ('%.1024r has type %s, but expected one of: %s' % (proposed_value, type(proposed_value), (bool, int))) raise TypeError(message) return bool(proposed_value) def DefaultValue(self): return False # IntValueChecker and its subclasses perform integer type-checks # and bounds-checks. class IntValueChecker(object): """Checker used for integer fields. Performs type-check and range check.""" def CheckValue(self, proposed_value): if not hasattr(proposed_value, '__index__') or ( type(proposed_value).__module__ == 'numpy' and type(proposed_value).__name__ == 'ndarray'): message = ('%.1024r has type %s, but expected one of: %s' % (proposed_value, type(proposed_value), (int,))) raise TypeError(message) if not self._MIN <= int(proposed_value) <= self._MAX: raise ValueError('Value out of range: %d' % proposed_value) # We force all values to int to make alternate implementations where the # distinction is more significant (e.g. the C++ implementation) simpler. proposed_value = int(proposed_value) return proposed_value def DefaultValue(self): return 0 class EnumValueChecker(object): """Checker used for enum fields. Performs type-check and range check.""" def __init__(self, enum_type): self._enum_type = enum_type def CheckValue(self, proposed_value): if not isinstance(proposed_value, numbers.Integral): message = ('%.1024r has type %s, but expected one of: %s' % (proposed_value, type(proposed_value), (int,))) raise TypeError(message) if int(proposed_value) not in self._enum_type.values_by_number: raise ValueError('Unknown enum value: %d' % proposed_value) return proposed_value def DefaultValue(self): return self._enum_type.values[0].number class UnicodeValueChecker(object): """Checker used for string fields. Always returns a unicode value, even if the input is of type str. """ def CheckValue(self, proposed_value): if not isinstance(proposed_value, (bytes, str)): message = ('%.1024r has type %s, but expected one of: %s' % (proposed_value, type(proposed_value), (bytes, str))) raise TypeError(message) # If the value is of type 'bytes' make sure that it is valid UTF-8 data. if isinstance(proposed_value, bytes): try: proposed_value = proposed_value.decode('utf-8') except UnicodeDecodeError: raise ValueError('%.1024r has type bytes, but isn\'t valid UTF-8 ' 'encoding. Non-UTF-8 strings must be converted to ' 'unicode objects before being added.' % (proposed_value)) else: try: proposed_value.encode('utf8') except UnicodeEncodeError: raise ValueError('%.1024r isn\'t a valid unicode string and ' 'can\'t be encoded in UTF-8.'% (proposed_value)) return proposed_value def DefaultValue(self): return u"" class Int32ValueChecker(IntValueChecker): # We're sure to use ints instead of longs here since comparison may be more # efficient. _MIN = -2147483648 _MAX = 2147483647 class Uint32ValueChecker(IntValueChecker): _MIN = 0 _MAX = (1 << 32) - 1 class Int64ValueChecker(IntValueChecker): _MIN = -(1 << 63) _MAX = (1 << 63) - 1 class Uint64ValueChecker(IntValueChecker): _MIN = 0 _MAX = (1 << 64) - 1 # The max 4 bytes float is about 3.4028234663852886e+38 _FLOAT_MAX = float.fromhex('0x1.fffffep+127') _FLOAT_MIN = -_FLOAT_MAX _INF = float('inf') _NEG_INF = float('-inf') class DoubleValueChecker(object): """Checker used for double fields. Performs type-check and range check. """ def CheckValue(self, proposed_value): """Check and convert proposed_value to float.""" if (not hasattr(proposed_value, '__float__') and not hasattr(proposed_value, '__index__')) or ( type(proposed_value).__module__ == 'numpy' and type(proposed_value).__name__ == 'ndarray'): message = ('%.1024r has type %s, but expected one of: int, float' % (proposed_value, type(proposed_value))) raise TypeError(message) return float(proposed_value) def DefaultValue(self): return 0.0 class FloatValueChecker(DoubleValueChecker): """Checker used for float fields. Performs type-check and range check. Values exceeding a 32-bit float will be converted to inf/-inf. """ def CheckValue(self, proposed_value): """Check and convert proposed_value to float.""" converted_value = super().CheckValue(proposed_value) # This inf rounding matches the C++ proto SafeDoubleToFloat logic. if converted_value > _FLOAT_MAX: return _INF if converted_value < _FLOAT_MIN: return _NEG_INF return TruncateToFourByteFloat(converted_value) # Type-checkers for all scalar CPPTYPEs. _VALUE_CHECKERS = { _FieldDescriptor.CPPTYPE_INT32: Int32ValueChecker(), _FieldDescriptor.CPPTYPE_INT64: Int64ValueChecker(), _FieldDescriptor.CPPTYPE_UINT32: Uint32ValueChecker(), _FieldDescriptor.CPPTYPE_UINT64: Uint64ValueChecker(), _FieldDescriptor.CPPTYPE_DOUBLE: DoubleValueChecker(), _FieldDescriptor.CPPTYPE_FLOAT: FloatValueChecker(), _FieldDescriptor.CPPTYPE_BOOL: BoolValueChecker(), _FieldDescriptor.CPPTYPE_STRING: TypeCheckerWithDefault(b'', bytes), } # Map from field type to a function F, such that F(field_num, value) # gives the total byte size for a value of the given type. This # byte size includes tag information and any other additional space # associated with serializing "value". TYPE_TO_BYTE_SIZE_FN = { _FieldDescriptor.TYPE_DOUBLE: wire_format.DoubleByteSize, _FieldDescriptor.TYPE_FLOAT: wire_format.FloatByteSize, _FieldDescriptor.TYPE_INT64: wire_format.Int64ByteSize, _FieldDescriptor.TYPE_UINT64: wire_format.UInt64ByteSize, _FieldDescriptor.TYPE_INT32: wire_format.Int32ByteSize, _FieldDescriptor.TYPE_FIXED64: wire_format.Fixed64ByteSize, _FieldDescriptor.TYPE_FIXED32: wire_format.Fixed32ByteSize, _FieldDescriptor.TYPE_BOOL: wire_format.BoolByteSize, _FieldDescriptor.TYPE_STRING: wire_format.StringByteSize, _FieldDescriptor.TYPE_GROUP: wire_format.GroupByteSize, _FieldDescriptor.TYPE_MESSAGE: wire_format.MessageByteSize, _FieldDescriptor.TYPE_BYTES: wire_format.BytesByteSize, _FieldDescriptor.TYPE_UINT32: wire_format.UInt32ByteSize, _FieldDescriptor.TYPE_ENUM: wire_format.EnumByteSize, _FieldDescriptor.TYPE_SFIXED32: wire_format.SFixed32ByteSize, _FieldDescriptor.TYPE_SFIXED64: wire_format.SFixed64ByteSize, _FieldDescriptor.TYPE_SINT32: wire_format.SInt32ByteSize, _FieldDescriptor.TYPE_SINT64: wire_format.SInt64ByteSize } # Maps from field types to encoder constructors. TYPE_TO_ENCODER = { _FieldDescriptor.TYPE_DOUBLE: encoder.DoubleEncoder, _FieldDescriptor.TYPE_FLOAT: encoder.FloatEncoder, _FieldDescriptor.TYPE_INT64: encoder.Int64Encoder, _FieldDescriptor.TYPE_UINT64: encoder.UInt64Encoder, _FieldDescriptor.TYPE_INT32: encoder.Int32Encoder, _FieldDescriptor.TYPE_FIXED64: encoder.Fixed64Encoder, _FieldDescriptor.TYPE_FIXED32: encoder.Fixed32Encoder, _FieldDescriptor.TYPE_BOOL: encoder.BoolEncoder, _FieldDescriptor.TYPE_STRING: encoder.StringEncoder, _FieldDescriptor.TYPE_GROUP: encoder.GroupEncoder, _FieldDescriptor.TYPE_MESSAGE: encoder.MessageEncoder, _FieldDescriptor.TYPE_BYTES: encoder.BytesEncoder, _FieldDescriptor.TYPE_UINT32: encoder.UInt32Encoder, _FieldDescriptor.TYPE_ENUM: encoder.EnumEncoder, _FieldDescriptor.TYPE_SFIXED32: encoder.SFixed32Encoder, _FieldDescriptor.TYPE_SFIXED64: encoder.SFixed64Encoder, _FieldDescriptor.TYPE_SINT32: encoder.SInt32Encoder, _FieldDescriptor.TYPE_SINT64: encoder.SInt64Encoder, } # Maps from field types to sizer constructors. TYPE_TO_SIZER = { _FieldDescriptor.TYPE_DOUBLE: encoder.DoubleSizer, _FieldDescriptor.TYPE_FLOAT: encoder.FloatSizer, _FieldDescriptor.TYPE_INT64: encoder.Int64Sizer, _FieldDescriptor.TYPE_UINT64: encoder.UInt64Sizer, _FieldDescriptor.TYPE_INT32: encoder.Int32Sizer, _FieldDescriptor.TYPE_FIXED64: encoder.Fixed64Sizer, _FieldDescriptor.TYPE_FIXED32: encoder.Fixed32Sizer, _FieldDescriptor.TYPE_BOOL: encoder.BoolSizer, _FieldDescriptor.TYPE_STRING: encoder.StringSizer, _FieldDescriptor.TYPE_GROUP: encoder.GroupSizer, _FieldDescriptor.TYPE_MESSAGE: encoder.MessageSizer, _FieldDescriptor.TYPE_BYTES: encoder.BytesSizer, _FieldDescriptor.TYPE_UINT32: encoder.UInt32Sizer, _FieldDescriptor.TYPE_ENUM: encoder.EnumSizer, _FieldDescriptor.TYPE_SFIXED32: encoder.SFixed32Sizer, _FieldDescriptor.TYPE_SFIXED64: encoder.SFixed64Sizer, _FieldDescriptor.TYPE_SINT32: encoder.SInt32Sizer, _FieldDescriptor.TYPE_SINT64: encoder.SInt64Sizer, } # Maps from field type to a decoder constructor. TYPE_TO_DECODER = { _FieldDescriptor.TYPE_DOUBLE: decoder.DoubleDecoder, _FieldDescriptor.TYPE_FLOAT: decoder.FloatDecoder, _FieldDescriptor.TYPE_INT64: decoder.Int64Decoder, _FieldDescriptor.TYPE_UINT64: decoder.UInt64Decoder, _FieldDescriptor.TYPE_INT32: decoder.Int32Decoder, _FieldDescriptor.TYPE_FIXED64: decoder.Fixed64Decoder, _FieldDescriptor.TYPE_FIXED32: decoder.Fixed32Decoder, _FieldDescriptor.TYPE_BOOL: decoder.BoolDecoder, _FieldDescriptor.TYPE_STRING: decoder.StringDecoder, _FieldDescriptor.TYPE_GROUP: decoder.GroupDecoder, _FieldDescriptor.TYPE_MESSAGE: decoder.MessageDecoder, _FieldDescriptor.TYPE_BYTES: decoder.BytesDecoder, _FieldDescriptor.TYPE_UINT32: decoder.UInt32Decoder, _FieldDescriptor.TYPE_ENUM: decoder.EnumDecoder, _FieldDescriptor.TYPE_SFIXED32: decoder.SFixed32Decoder, _FieldDescriptor.TYPE_SFIXED64: decoder.SFixed64Decoder, _FieldDescriptor.TYPE_SINT32: decoder.SInt32Decoder, _FieldDescriptor.TYPE_SINT64: decoder.SInt64Decoder, } # Maps from field type to expected wiretype. FIELD_TYPE_TO_WIRE_TYPE = { _FieldDescriptor.TYPE_DOUBLE: wire_format.WIRETYPE_FIXED64, _FieldDescriptor.TYPE_FLOAT: wire_format.WIRETYPE_FIXED32, _FieldDescriptor.TYPE_INT64: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_UINT64: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_INT32: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_FIXED64: wire_format.WIRETYPE_FIXED64, _FieldDescriptor.TYPE_FIXED32: wire_format.WIRETYPE_FIXED32, _FieldDescriptor.TYPE_BOOL: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_STRING: wire_format.WIRETYPE_LENGTH_DELIMITED, _FieldDescriptor.TYPE_GROUP: wire_format.WIRETYPE_START_GROUP, _FieldDescriptor.TYPE_MESSAGE: wire_format.WIRETYPE_LENGTH_DELIMITED, _FieldDescriptor.TYPE_BYTES: wire_format.WIRETYPE_LENGTH_DELIMITED, _FieldDescriptor.TYPE_UINT32: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_ENUM: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_SFIXED32: wire_format.WIRETYPE_FIXED32, _FieldDescriptor.TYPE_SFIXED64: wire_format.WIRETYPE_FIXED64, _FieldDescriptor.TYPE_SINT32: wire_format.WIRETYPE_VARINT, _FieldDescriptor.TYPE_SINT64: wire_format.WIRETYPE_VARINT, } ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/well_known_types.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Contains well known classes. This files defines well known classes which need extra maintenance including: - Any - Duration - FieldMask - Struct - Timestamp """ __author__ = 'jieluo@google.com (Jie Luo)' import calendar import collections.abc import datetime from google.protobuf.descriptor import FieldDescriptor _TIMESTAMPFOMAT = '%Y-%m-%dT%H:%M:%S' _NANOS_PER_SECOND = 1000000000 _NANOS_PER_MILLISECOND = 1000000 _NANOS_PER_MICROSECOND = 1000 _MILLIS_PER_SECOND = 1000 _MICROS_PER_SECOND = 1000000 _SECONDS_PER_DAY = 24 * 3600 _DURATION_SECONDS_MAX = 315576000000 class Any(object): """Class for Any Message type.""" __slots__ = () def Pack(self, msg, type_url_prefix='type.googleapis.com/', deterministic=None): """Packs the specified message into current Any message.""" if len(type_url_prefix) < 1 or type_url_prefix[-1] != '/': self.type_url = '%s/%s' % (type_url_prefix, msg.DESCRIPTOR.full_name) else: self.type_url = '%s%s' % (type_url_prefix, msg.DESCRIPTOR.full_name) self.value = msg.SerializeToString(deterministic=deterministic) def Unpack(self, msg): """Unpacks the current Any message into specified message.""" descriptor = msg.DESCRIPTOR if not self.Is(descriptor): return False msg.ParseFromString(self.value) return True def TypeName(self): """Returns the protobuf type name of the inner message.""" # Only last part is to be used: b/25630112 return self.type_url.split('/')[-1] def Is(self, descriptor): """Checks if this Any represents the given protobuf type.""" return '/' in self.type_url and self.TypeName() == descriptor.full_name _EPOCH_DATETIME_NAIVE = datetime.datetime.utcfromtimestamp(0) _EPOCH_DATETIME_AWARE = datetime.datetime.fromtimestamp( 0, tz=datetime.timezone.utc) class Timestamp(object): """Class for Timestamp message type.""" __slots__ = () def ToJsonString(self): """Converts Timestamp to RFC 3339 date string format. Returns: A string converted from timestamp. The string is always Z-normalized and uses 3, 6 or 9 fractional digits as required to represent the exact time. Example of the return format: '1972-01-01T10:00:20.021Z' """ nanos = self.nanos % _NANOS_PER_SECOND total_sec = self.seconds + (self.nanos - nanos) // _NANOS_PER_SECOND seconds = total_sec % _SECONDS_PER_DAY days = (total_sec - seconds) // _SECONDS_PER_DAY dt = datetime.datetime(1970, 1, 1) + datetime.timedelta(days, seconds) result = dt.isoformat() if (nanos % 1e9) == 0: # If there are 0 fractional digits, the fractional # point '.' should be omitted when serializing. return result + 'Z' if (nanos % 1e6) == 0: # Serialize 3 fractional digits. return result + '.%03dZ' % (nanos / 1e6) if (nanos % 1e3) == 0: # Serialize 6 fractional digits. return result + '.%06dZ' % (nanos / 1e3) # Serialize 9 fractional digits. return result + '.%09dZ' % nanos def FromJsonString(self, value): """Parse a RFC 3339 date string format to Timestamp. Args: value: A date string. Any fractional digits (or none) and any offset are accepted as long as they fit into nano-seconds precision. Example of accepted format: '1972-01-01T10:00:20.021-05:00' Raises: ValueError: On parsing problems. """ if not isinstance(value, str): raise ValueError('Timestamp JSON value not a string: {!r}'.format(value)) timezone_offset = value.find('Z') if timezone_offset == -1: timezone_offset = value.find('+') if timezone_offset == -1: timezone_offset = value.rfind('-') if timezone_offset == -1: raise ValueError( 'Failed to parse timestamp: missing valid timezone offset.') time_value = value[0:timezone_offset] # Parse datetime and nanos. point_position = time_value.find('.') if point_position == -1: second_value = time_value nano_value = '' else: second_value = time_value[:point_position] nano_value = time_value[point_position + 1:] if 't' in second_value: raise ValueError( 'time data \'{0}\' does not match format \'%Y-%m-%dT%H:%M:%S\', ' 'lowercase \'t\' is not accepted'.format(second_value)) date_object = datetime.datetime.strptime(second_value, _TIMESTAMPFOMAT) td = date_object - datetime.datetime(1970, 1, 1) seconds = td.seconds + td.days * _SECONDS_PER_DAY if len(nano_value) > 9: raise ValueError( 'Failed to parse Timestamp: nanos {0} more than ' '9 fractional digits.'.format(nano_value)) if nano_value: nanos = round(float('0.' + nano_value) * 1e9) else: nanos = 0 # Parse timezone offsets. if value[timezone_offset] == 'Z': if len(value) != timezone_offset + 1: raise ValueError('Failed to parse timestamp: invalid trailing' ' data {0}.'.format(value)) else: timezone = value[timezone_offset:] pos = timezone.find(':') if pos == -1: raise ValueError( 'Invalid timezone offset value: {0}.'.format(timezone)) if timezone[0] == '+': seconds -= (int(timezone[1:pos])*60+int(timezone[pos+1:]))*60 else: seconds += (int(timezone[1:pos])*60+int(timezone[pos+1:]))*60 # Set seconds and nanos self.seconds = int(seconds) self.nanos = int(nanos) def GetCurrentTime(self): """Get the current UTC into Timestamp.""" self.FromDatetime(datetime.datetime.utcnow()) def ToNanoseconds(self): """Converts Timestamp to nanoseconds since epoch.""" return self.seconds * _NANOS_PER_SECOND + self.nanos def ToMicroseconds(self): """Converts Timestamp to microseconds since epoch.""" return (self.seconds * _MICROS_PER_SECOND + self.nanos // _NANOS_PER_MICROSECOND) def ToMilliseconds(self): """Converts Timestamp to milliseconds since epoch.""" return (self.seconds * _MILLIS_PER_SECOND + self.nanos // _NANOS_PER_MILLISECOND) def ToSeconds(self): """Converts Timestamp to seconds since epoch.""" return self.seconds def FromNanoseconds(self, nanos): """Converts nanoseconds since epoch to Timestamp.""" self.seconds = nanos // _NANOS_PER_SECOND self.nanos = nanos % _NANOS_PER_SECOND def FromMicroseconds(self, micros): """Converts microseconds since epoch to Timestamp.""" self.seconds = micros // _MICROS_PER_SECOND self.nanos = (micros % _MICROS_PER_SECOND) * _NANOS_PER_MICROSECOND def FromMilliseconds(self, millis): """Converts milliseconds since epoch to Timestamp.""" self.seconds = millis // _MILLIS_PER_SECOND self.nanos = (millis % _MILLIS_PER_SECOND) * _NANOS_PER_MILLISECOND def FromSeconds(self, seconds): """Converts seconds since epoch to Timestamp.""" self.seconds = seconds self.nanos = 0 def ToDatetime(self, tzinfo=None): """Converts Timestamp to a datetime. Args: tzinfo: A datetime.tzinfo subclass; defaults to None. Returns: If tzinfo is None, returns a timezone-naive UTC datetime (with no timezone information, i.e. not aware that it's UTC). Otherwise, returns a timezone-aware datetime in the input timezone. """ delta = datetime.timedelta( seconds=self.seconds, microseconds=_RoundTowardZero(self.nanos, _NANOS_PER_MICROSECOND)) if tzinfo is None: return _EPOCH_DATETIME_NAIVE + delta else: return _EPOCH_DATETIME_AWARE.astimezone(tzinfo) + delta def FromDatetime(self, dt): """Converts datetime to Timestamp. Args: dt: A datetime. If it's timezone-naive, it's assumed to be in UTC. """ # Using this guide: http://wiki.python.org/moin/WorkingWithTime # And this conversion guide: http://docs.python.org/library/time.html # Turn the date parameter into a tuple (struct_time) that can then be # manipulated into a long value of seconds. During the conversion from # struct_time to long, the source date in UTC, and so it follows that the # correct transformation is calendar.timegm() self.seconds = calendar.timegm(dt.utctimetuple()) self.nanos = dt.microsecond * _NANOS_PER_MICROSECOND class Duration(object): """Class for Duration message type.""" __slots__ = () def ToJsonString(self): """Converts Duration to string format. Returns: A string converted from self. The string format will contains 3, 6, or 9 fractional digits depending on the precision required to represent the exact Duration value. For example: "1s", "1.010s", "1.000000100s", "-3.100s" """ _CheckDurationValid(self.seconds, self.nanos) if self.seconds < 0 or self.nanos < 0: result = '-' seconds = - self.seconds + int((0 - self.nanos) // 1e9) nanos = (0 - self.nanos) % 1e9 else: result = '' seconds = self.seconds + int(self.nanos // 1e9) nanos = self.nanos % 1e9 result += '%d' % seconds if (nanos % 1e9) == 0: # If there are 0 fractional digits, the fractional # point '.' should be omitted when serializing. return result + 's' if (nanos % 1e6) == 0: # Serialize 3 fractional digits. return result + '.%03ds' % (nanos / 1e6) if (nanos % 1e3) == 0: # Serialize 6 fractional digits. return result + '.%06ds' % (nanos / 1e3) # Serialize 9 fractional digits. return result + '.%09ds' % nanos def FromJsonString(self, value): """Converts a string to Duration. Args: value: A string to be converted. The string must end with 's'. Any fractional digits (or none) are accepted as long as they fit into precision. For example: "1s", "1.01s", "1.0000001s", "-3.100s Raises: ValueError: On parsing problems. """ if not isinstance(value, str): raise ValueError('Duration JSON value not a string: {!r}'.format(value)) if len(value) < 1 or value[-1] != 's': raise ValueError( 'Duration must end with letter "s": {0}.'.format(value)) try: pos = value.find('.') if pos == -1: seconds = int(value[:-1]) nanos = 0 else: seconds = int(value[:pos]) if value[0] == '-': nanos = int(round(float('-0{0}'.format(value[pos: -1])) *1e9)) else: nanos = int(round(float('0{0}'.format(value[pos: -1])) *1e9)) _CheckDurationValid(seconds, nanos) self.seconds = seconds self.nanos = nanos except ValueError as e: raise ValueError( 'Couldn\'t parse duration: {0} : {1}.'.format(value, e)) def ToNanoseconds(self): """Converts a Duration to nanoseconds.""" return self.seconds * _NANOS_PER_SECOND + self.nanos def ToMicroseconds(self): """Converts a Duration to microseconds.""" micros = _RoundTowardZero(self.nanos, _NANOS_PER_MICROSECOND) return self.seconds * _MICROS_PER_SECOND + micros def ToMilliseconds(self): """Converts a Duration to milliseconds.""" millis = _RoundTowardZero(self.nanos, _NANOS_PER_MILLISECOND) return self.seconds * _MILLIS_PER_SECOND + millis def ToSeconds(self): """Converts a Duration to seconds.""" return self.seconds def FromNanoseconds(self, nanos): """Converts nanoseconds to Duration.""" self._NormalizeDuration(nanos // _NANOS_PER_SECOND, nanos % _NANOS_PER_SECOND) def FromMicroseconds(self, micros): """Converts microseconds to Duration.""" self._NormalizeDuration( micros // _MICROS_PER_SECOND, (micros % _MICROS_PER_SECOND) * _NANOS_PER_MICROSECOND) def FromMilliseconds(self, millis): """Converts milliseconds to Duration.""" self._NormalizeDuration( millis // _MILLIS_PER_SECOND, (millis % _MILLIS_PER_SECOND) * _NANOS_PER_MILLISECOND) def FromSeconds(self, seconds): """Converts seconds to Duration.""" self.seconds = seconds self.nanos = 0 def ToTimedelta(self): """Converts Duration to timedelta.""" return datetime.timedelta( seconds=self.seconds, microseconds=_RoundTowardZero( self.nanos, _NANOS_PER_MICROSECOND)) def FromTimedelta(self, td): """Converts timedelta to Duration.""" self._NormalizeDuration(td.seconds + td.days * _SECONDS_PER_DAY, td.microseconds * _NANOS_PER_MICROSECOND) def _NormalizeDuration(self, seconds, nanos): """Set Duration by seconds and nanos.""" # Force nanos to be negative if the duration is negative. if seconds < 0 and nanos > 0: seconds += 1 nanos -= _NANOS_PER_SECOND self.seconds = seconds self.nanos = nanos def _CheckDurationValid(seconds, nanos): if seconds < -_DURATION_SECONDS_MAX or seconds > _DURATION_SECONDS_MAX: raise ValueError( 'Duration is not valid: Seconds {0} must be in range ' '[-315576000000, 315576000000].'.format(seconds)) if nanos <= -_NANOS_PER_SECOND or nanos >= _NANOS_PER_SECOND: raise ValueError( 'Duration is not valid: Nanos {0} must be in range ' '[-999999999, 999999999].'.format(nanos)) if (nanos < 0 and seconds > 0) or (nanos > 0 and seconds < 0): raise ValueError( 'Duration is not valid: Sign mismatch.') def _RoundTowardZero(value, divider): """Truncates the remainder part after division.""" # For some languages, the sign of the remainder is implementation # dependent if any of the operands is negative. Here we enforce # "rounded toward zero" semantics. For example, for (-5) / 2 an # implementation may give -3 as the result with the remainder being # 1. This function ensures we always return -2 (closer to zero). result = value // divider remainder = value % divider if result < 0 and remainder > 0: return result + 1 else: return result class FieldMask(object): """Class for FieldMask message type.""" __slots__ = () def ToJsonString(self): """Converts FieldMask to string according to proto3 JSON spec.""" camelcase_paths = [] for path in self.paths: camelcase_paths.append(_SnakeCaseToCamelCase(path)) return ','.join(camelcase_paths) def FromJsonString(self, value): """Converts string to FieldMask according to proto3 JSON spec.""" if not isinstance(value, str): raise ValueError('FieldMask JSON value not a string: {!r}'.format(value)) self.Clear() if value: for path in value.split(','): self.paths.append(_CamelCaseToSnakeCase(path)) def IsValidForDescriptor(self, message_descriptor): """Checks whether the FieldMask is valid for Message Descriptor.""" for path in self.paths: if not _IsValidPath(message_descriptor, path): return False return True def AllFieldsFromDescriptor(self, message_descriptor): """Gets all direct fields of Message Descriptor to FieldMask.""" self.Clear() for field in message_descriptor.fields: self.paths.append(field.name) def CanonicalFormFromMask(self, mask): """Converts a FieldMask to the canonical form. Removes paths that are covered by another path. For example, "foo.bar" is covered by "foo" and will be removed if "foo" is also in the FieldMask. Then sorts all paths in alphabetical order. Args: mask: The original FieldMask to be converted. """ tree = _FieldMaskTree(mask) tree.ToFieldMask(self) def Union(self, mask1, mask2): """Merges mask1 and mask2 into this FieldMask.""" _CheckFieldMaskMessage(mask1) _CheckFieldMaskMessage(mask2) tree = _FieldMaskTree(mask1) tree.MergeFromFieldMask(mask2) tree.ToFieldMask(self) def Intersect(self, mask1, mask2): """Intersects mask1 and mask2 into this FieldMask.""" _CheckFieldMaskMessage(mask1) _CheckFieldMaskMessage(mask2) tree = _FieldMaskTree(mask1) intersection = _FieldMaskTree() for path in mask2.paths: tree.IntersectPath(path, intersection) intersection.ToFieldMask(self) def MergeMessage( self, source, destination, replace_message_field=False, replace_repeated_field=False): """Merges fields specified in FieldMask from source to destination. Args: source: Source message. destination: The destination message to be merged into. replace_message_field: Replace message field if True. Merge message field if False. replace_repeated_field: Replace repeated field if True. Append elements of repeated field if False. """ tree = _FieldMaskTree(self) tree.MergeMessage( source, destination, replace_message_field, replace_repeated_field) def _IsValidPath(message_descriptor, path): """Checks whether the path is valid for Message Descriptor.""" parts = path.split('.') last = parts.pop() for name in parts: field = message_descriptor.fields_by_name.get(name) if (field is None or field.label == FieldDescriptor.LABEL_REPEATED or field.type != FieldDescriptor.TYPE_MESSAGE): return False message_descriptor = field.message_type return last in message_descriptor.fields_by_name def _CheckFieldMaskMessage(message): """Raises ValueError if message is not a FieldMask.""" message_descriptor = message.DESCRIPTOR if (message_descriptor.name != 'FieldMask' or message_descriptor.file.name != 'google/protobuf/field_mask.proto'): raise ValueError('Message {0} is not a FieldMask.'.format( message_descriptor.full_name)) def _SnakeCaseToCamelCase(path_name): """Converts a path name from snake_case to camelCase.""" result = [] after_underscore = False for c in path_name: if c.isupper(): raise ValueError( 'Fail to print FieldMask to Json string: Path name ' '{0} must not contain uppercase letters.'.format(path_name)) if after_underscore: if c.islower(): result.append(c.upper()) after_underscore = False else: raise ValueError( 'Fail to print FieldMask to Json string: The ' 'character after a "_" must be a lowercase letter ' 'in path name {0}.'.format(path_name)) elif c == '_': after_underscore = True else: result += c if after_underscore: raise ValueError('Fail to print FieldMask to Json string: Trailing "_" ' 'in path name {0}.'.format(path_name)) return ''.join(result) def _CamelCaseToSnakeCase(path_name): """Converts a field name from camelCase to snake_case.""" result = [] for c in path_name: if c == '_': raise ValueError('Fail to parse FieldMask: Path name ' '{0} must not contain "_"s.'.format(path_name)) if c.isupper(): result += '_' result += c.lower() else: result += c return ''.join(result) class _FieldMaskTree(object): """Represents a FieldMask in a tree structure. For example, given a FieldMask "foo.bar,foo.baz,bar.baz", the FieldMaskTree will be: [_root] -+- foo -+- bar | | | +- baz | +- bar --- baz In the tree, each leaf node represents a field path. """ __slots__ = ('_root',) def __init__(self, field_mask=None): """Initializes the tree by FieldMask.""" self._root = {} if field_mask: self.MergeFromFieldMask(field_mask) def MergeFromFieldMask(self, field_mask): """Merges a FieldMask to the tree.""" for path in field_mask.paths: self.AddPath(path) def AddPath(self, path): """Adds a field path into the tree. If the field path to add is a sub-path of an existing field path in the tree (i.e., a leaf node), it means the tree already matches the given path so nothing will be added to the tree. If the path matches an existing non-leaf node in the tree, that non-leaf node will be turned into a leaf node with all its children removed because the path matches all the node's children. Otherwise, a new path will be added. Args: path: The field path to add. """ node = self._root for name in path.split('.'): if name not in node: node[name] = {} elif not node[name]: # Pre-existing empty node implies we already have this entire tree. return node = node[name] # Remove any sub-trees we might have had. node.clear() def ToFieldMask(self, field_mask): """Converts the tree to a FieldMask.""" field_mask.Clear() _AddFieldPaths(self._root, '', field_mask) def IntersectPath(self, path, intersection): """Calculates the intersection part of a field path with this tree. Args: path: The field path to calculates. intersection: The out tree to record the intersection part. """ node = self._root for name in path.split('.'): if name not in node: return elif not node[name]: intersection.AddPath(path) return node = node[name] intersection.AddLeafNodes(path, node) def AddLeafNodes(self, prefix, node): """Adds leaf nodes begin with prefix to this tree.""" if not node: self.AddPath(prefix) for name in node: child_path = prefix + '.' + name self.AddLeafNodes(child_path, node[name]) def MergeMessage( self, source, destination, replace_message, replace_repeated): """Merge all fields specified by this tree from source to destination.""" _MergeMessage( self._root, source, destination, replace_message, replace_repeated) def _StrConvert(value): """Converts value to str if it is not.""" # This file is imported by c extension and some methods like ClearField # requires string for the field name. py2/py3 has different text # type and may use unicode. if not isinstance(value, str): return value.encode('utf-8') return value def _MergeMessage( node, source, destination, replace_message, replace_repeated): """Merge all fields specified by a sub-tree from source to destination.""" source_descriptor = source.DESCRIPTOR for name in node: child = node[name] field = source_descriptor.fields_by_name[name] if field is None: raise ValueError('Error: Can\'t find field {0} in message {1}.'.format( name, source_descriptor.full_name)) if child: # Sub-paths are only allowed for singular message fields. if (field.label == FieldDescriptor.LABEL_REPEATED or field.cpp_type != FieldDescriptor.CPPTYPE_MESSAGE): raise ValueError('Error: Field {0} in message {1} is not a singular ' 'message field and cannot have sub-fields.'.format( name, source_descriptor.full_name)) if source.HasField(name): _MergeMessage( child, getattr(source, name), getattr(destination, name), replace_message, replace_repeated) continue if field.label == FieldDescriptor.LABEL_REPEATED: if replace_repeated: destination.ClearField(_StrConvert(name)) repeated_source = getattr(source, name) repeated_destination = getattr(destination, name) repeated_destination.MergeFrom(repeated_source) else: if field.cpp_type == FieldDescriptor.CPPTYPE_MESSAGE: if replace_message: destination.ClearField(_StrConvert(name)) if source.HasField(name): getattr(destination, name).MergeFrom(getattr(source, name)) else: setattr(destination, name, getattr(source, name)) def _AddFieldPaths(node, prefix, field_mask): """Adds the field paths descended from node to field_mask.""" if not node and prefix: field_mask.paths.append(prefix) return for name in sorted(node): if prefix: child_path = prefix + '.' + name else: child_path = name _AddFieldPaths(node[name], child_path, field_mask) def _SetStructValue(struct_value, value): if value is None: struct_value.null_value = 0 elif isinstance(value, bool): # Note: this check must come before the number check because in Python # True and False are also considered numbers. struct_value.bool_value = value elif isinstance(value, str): struct_value.string_value = value elif isinstance(value, (int, float)): struct_value.number_value = value elif isinstance(value, (dict, Struct)): struct_value.struct_value.Clear() struct_value.struct_value.update(value) elif isinstance(value, (list, ListValue)): struct_value.list_value.Clear() struct_value.list_value.extend(value) else: raise ValueError('Unexpected type') def _GetStructValue(struct_value): which = struct_value.WhichOneof('kind') if which == 'struct_value': return struct_value.struct_value elif which == 'null_value': return None elif which == 'number_value': return struct_value.number_value elif which == 'string_value': return struct_value.string_value elif which == 'bool_value': return struct_value.bool_value elif which == 'list_value': return struct_value.list_value elif which is None: raise ValueError('Value not set') class Struct(object): """Class for Struct message type.""" __slots__ = () def __getitem__(self, key): return _GetStructValue(self.fields[key]) def __contains__(self, item): return item in self.fields def __setitem__(self, key, value): _SetStructValue(self.fields[key], value) def __delitem__(self, key): del self.fields[key] def __len__(self): return len(self.fields) def __iter__(self): return iter(self.fields) def keys(self): # pylint: disable=invalid-name return self.fields.keys() def values(self): # pylint: disable=invalid-name return [self[key] for key in self] def items(self): # pylint: disable=invalid-name return [(key, self[key]) for key in self] def get_or_create_list(self, key): """Returns a list for this key, creating if it didn't exist already.""" if not self.fields[key].HasField('list_value'): # Clear will mark list_value modified which will indeed create a list. self.fields[key].list_value.Clear() return self.fields[key].list_value def get_or_create_struct(self, key): """Returns a struct for this key, creating if it didn't exist already.""" if not self.fields[key].HasField('struct_value'): # Clear will mark struct_value modified which will indeed create a struct. self.fields[key].struct_value.Clear() return self.fields[key].struct_value def update(self, dictionary): # pylint: disable=invalid-name for key, value in dictionary.items(): _SetStructValue(self.fields[key], value) collections.abc.MutableMapping.register(Struct) class ListValue(object): """Class for ListValue message type.""" __slots__ = () def __len__(self): return len(self.values) def append(self, value): _SetStructValue(self.values.add(), value) def extend(self, elem_seq): for value in elem_seq: self.append(value) def __getitem__(self, index): """Retrieves item by the specified index.""" return _GetStructValue(self.values.__getitem__(index)) def __setitem__(self, index, value): _SetStructValue(self.values.__getitem__(index), value) def __delitem__(self, key): del self.values[key] def items(self): for i in range(len(self)): yield self[i] def add_struct(self): """Appends and returns a struct value as the next value in the list.""" struct_value = self.values.add().struct_value # Clear will mark struct_value modified which will indeed create a struct. struct_value.Clear() return struct_value def add_list(self): """Appends and returns a list value as the next value in the list.""" list_value = self.values.add().list_value # Clear will mark list_value modified which will indeed create a list. list_value.Clear() return list_value collections.abc.MutableSequence.register(ListValue) WKTBASES = { 'google.protobuf.Any': Any, 'google.protobuf.Duration': Duration, 'google.protobuf.FieldMask': FieldMask, 'google.protobuf.ListValue': ListValue, 'google.protobuf.Struct': Struct, 'google.protobuf.Timestamp': Timestamp, } ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/internal/wire_format.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Constants and static functions to support protocol buffer wire format.""" __author__ = 'robinson@google.com (Will Robinson)' import struct from google.protobuf import descriptor from google.protobuf import message TAG_TYPE_BITS = 3 # Number of bits used to hold type info in a proto tag. TAG_TYPE_MASK = (1 << TAG_TYPE_BITS) - 1 # 0x7 # These numbers identify the wire type of a protocol buffer value. # We use the least-significant TAG_TYPE_BITS bits of the varint-encoded # tag-and-type to store one of these WIRETYPE_* constants. # These values must match WireType enum in google/protobuf/wire_format.h. WIRETYPE_VARINT = 0 WIRETYPE_FIXED64 = 1 WIRETYPE_LENGTH_DELIMITED = 2 WIRETYPE_START_GROUP = 3 WIRETYPE_END_GROUP = 4 WIRETYPE_FIXED32 = 5 _WIRETYPE_MAX = 5 # Bounds for various integer types. INT32_MAX = int((1 << 31) - 1) INT32_MIN = int(-(1 << 31)) UINT32_MAX = (1 << 32) - 1 INT64_MAX = (1 << 63) - 1 INT64_MIN = -(1 << 63) UINT64_MAX = (1 << 64) - 1 # "struct" format strings that will encode/decode the specified formats. FORMAT_UINT32_LITTLE_ENDIAN = '> TAG_TYPE_BITS), (tag & TAG_TYPE_MASK) def ZigZagEncode(value): """ZigZag Transform: Encodes signed integers so that they can be effectively used with varint encoding. See wire_format.h for more details. """ if value >= 0: return value << 1 return (value << 1) ^ (~0) def ZigZagDecode(value): """Inverse of ZigZagEncode().""" if not value & 0x1: return value >> 1 return (value >> 1) ^ (~0) # The *ByteSize() functions below return the number of bytes required to # serialize "field number + type" information and then serialize the value. def Int32ByteSize(field_number, int32): return Int64ByteSize(field_number, int32) def Int32ByteSizeNoTag(int32): return _VarUInt64ByteSizeNoTag(0xffffffffffffffff & int32) def Int64ByteSize(field_number, int64): # Have to convert to uint before calling UInt64ByteSize(). return UInt64ByteSize(field_number, 0xffffffffffffffff & int64) def UInt32ByteSize(field_number, uint32): return UInt64ByteSize(field_number, uint32) def UInt64ByteSize(field_number, uint64): return TagByteSize(field_number) + _VarUInt64ByteSizeNoTag(uint64) def SInt32ByteSize(field_number, int32): return UInt32ByteSize(field_number, ZigZagEncode(int32)) def SInt64ByteSize(field_number, int64): return UInt64ByteSize(field_number, ZigZagEncode(int64)) def Fixed32ByteSize(field_number, fixed32): return TagByteSize(field_number) + 4 def Fixed64ByteSize(field_number, fixed64): return TagByteSize(field_number) + 8 def SFixed32ByteSize(field_number, sfixed32): return TagByteSize(field_number) + 4 def SFixed64ByteSize(field_number, sfixed64): return TagByteSize(field_number) + 8 def FloatByteSize(field_number, flt): return TagByteSize(field_number) + 4 def DoubleByteSize(field_number, double): return TagByteSize(field_number) + 8 def BoolByteSize(field_number, b): return TagByteSize(field_number) + 1 def EnumByteSize(field_number, enum): return UInt32ByteSize(field_number, enum) def StringByteSize(field_number, string): return BytesByteSize(field_number, string.encode('utf-8')) def BytesByteSize(field_number, b): return (TagByteSize(field_number) + _VarUInt64ByteSizeNoTag(len(b)) + len(b)) def GroupByteSize(field_number, message): return (2 * TagByteSize(field_number) # START and END group. + message.ByteSize()) def MessageByteSize(field_number, message): return (TagByteSize(field_number) + _VarUInt64ByteSizeNoTag(message.ByteSize()) + message.ByteSize()) def MessageSetItemByteSize(field_number, msg): # First compute the sizes of the tags. # There are 2 tags for the beginning and ending of the repeated group, that # is field number 1, one with field number 2 (type_id) and one with field # number 3 (message). total_size = (2 * TagByteSize(1) + TagByteSize(2) + TagByteSize(3)) # Add the number of bytes for type_id. total_size += _VarUInt64ByteSizeNoTag(field_number) message_size = msg.ByteSize() # The number of bytes for encoding the length of the message. total_size += _VarUInt64ByteSizeNoTag(message_size) # The size of the message. total_size += message_size return total_size def TagByteSize(field_number): """Returns the bytes required to serialize a tag with this field number.""" # Just pass in type 0, since the type won't affect the tag+type size. return _VarUInt64ByteSizeNoTag(PackTag(field_number, 0)) # Private helper function for the *ByteSize() functions above. def _VarUInt64ByteSizeNoTag(uint64): """Returns the number of bytes required to serialize a single varint using boundary value comparisons. (unrolled loop optimization -WPierce) uint64 must be unsigned. """ if uint64 <= 0x7f: return 1 if uint64 <= 0x3fff: return 2 if uint64 <= 0x1fffff: return 3 if uint64 <= 0xfffffff: return 4 if uint64 <= 0x7ffffffff: return 5 if uint64 <= 0x3ffffffffff: return 6 if uint64 <= 0x1ffffffffffff: return 7 if uint64 <= 0xffffffffffffff: return 8 if uint64 <= 0x7fffffffffffffff: return 9 if uint64 > UINT64_MAX: raise message.EncodeError('Value out of range: %d' % uint64) return 10 NON_PACKABLE_TYPES = ( descriptor.FieldDescriptor.TYPE_STRING, descriptor.FieldDescriptor.TYPE_GROUP, descriptor.FieldDescriptor.TYPE_MESSAGE, descriptor.FieldDescriptor.TYPE_BYTES ) def IsTypePackable(field_type): """Return true iff packable = true is valid for fields of this type. Args: field_type: a FieldDescriptor::Type value. Returns: True iff fields of this type are packable. """ return field_type not in NON_PACKABLE_TYPES ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/json_format.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Contains routines for printing protocol messages in JSON format. Simple usage example: # Create a proto object and serialize it to a json format string. message = my_proto_pb2.MyMessage(foo='bar') json_string = json_format.MessageToJson(message) # Parse a json format string to proto object. message = json_format.Parse(json_string, my_proto_pb2.MyMessage()) """ __author__ = 'jieluo@google.com (Jie Luo)' import base64 from collections import OrderedDict import json import math from operator import methodcaller import re import sys from google.protobuf.internal import type_checkers from google.protobuf import descriptor from google.protobuf import symbol_database _TIMESTAMPFOMAT = '%Y-%m-%dT%H:%M:%S' _INT_TYPES = frozenset([descriptor.FieldDescriptor.CPPTYPE_INT32, descriptor.FieldDescriptor.CPPTYPE_UINT32, descriptor.FieldDescriptor.CPPTYPE_INT64, descriptor.FieldDescriptor.CPPTYPE_UINT64]) _INT64_TYPES = frozenset([descriptor.FieldDescriptor.CPPTYPE_INT64, descriptor.FieldDescriptor.CPPTYPE_UINT64]) _FLOAT_TYPES = frozenset([descriptor.FieldDescriptor.CPPTYPE_FLOAT, descriptor.FieldDescriptor.CPPTYPE_DOUBLE]) _INFINITY = 'Infinity' _NEG_INFINITY = '-Infinity' _NAN = 'NaN' _UNPAIRED_SURROGATE_PATTERN = re.compile( u'[\ud800-\udbff](?![\udc00-\udfff])|(? self.max_recursion_depth: raise ParseError('Message too deep. Max recursion depth is {0}'.format( self.max_recursion_depth)) message_descriptor = message.DESCRIPTOR full_name = message_descriptor.full_name if not path: path = message_descriptor.name if _IsWrapperMessage(message_descriptor): self._ConvertWrapperMessage(value, message, path) elif full_name in _WKTJSONMETHODS: methodcaller(_WKTJSONMETHODS[full_name][1], value, message, path)(self) else: self._ConvertFieldValuePair(value, message, path) self.recursion_depth -= 1 def _ConvertFieldValuePair(self, js, message, path): """Convert field value pairs into regular message. Args: js: A JSON object to convert the field value pairs. message: A regular protocol message to record the data. path: parent path to log parse error info. Raises: ParseError: In case of problems converting. """ names = [] message_descriptor = message.DESCRIPTOR fields_by_json_name = dict((f.json_name, f) for f in message_descriptor.fields) for name in js: try: field = fields_by_json_name.get(name, None) if not field: field = message_descriptor.fields_by_name.get(name, None) if not field and _VALID_EXTENSION_NAME.match(name): if not message_descriptor.is_extendable: raise ParseError( 'Message type {0} does not have extensions at {1}'.format( message_descriptor.full_name, path)) identifier = name[1:-1] # strip [] brackets # pylint: disable=protected-access field = message.Extensions._FindExtensionByName(identifier) # pylint: enable=protected-access if not field: # Try looking for extension by the message type name, dropping the # field name following the final . separator in full_name. identifier = '.'.join(identifier.split('.')[:-1]) # pylint: disable=protected-access field = message.Extensions._FindExtensionByName(identifier) # pylint: enable=protected-access if not field: if self.ignore_unknown_fields: continue raise ParseError( ('Message type "{0}" has no field named "{1}" at "{2}".\n' ' Available Fields(except extensions): "{3}"').format( message_descriptor.full_name, name, path, [f.json_name for f in message_descriptor.fields])) if name in names: raise ParseError('Message type "{0}" should not have multiple ' '"{1}" fields at "{2}".'.format( message.DESCRIPTOR.full_name, name, path)) names.append(name) value = js[name] # Check no other oneof field is parsed. if field.containing_oneof is not None and value is not None: oneof_name = field.containing_oneof.name if oneof_name in names: raise ParseError('Message type "{0}" should not have multiple ' '"{1}" oneof fields at "{2}".'.format( message.DESCRIPTOR.full_name, oneof_name, path)) names.append(oneof_name) if value is None: if (field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_MESSAGE and field.message_type.full_name == 'google.protobuf.Value'): sub_message = getattr(message, field.name) sub_message.null_value = 0 elif (field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_ENUM and field.enum_type.full_name == 'google.protobuf.NullValue'): setattr(message, field.name, 0) else: message.ClearField(field.name) continue # Parse field value. if _IsMapEntry(field): message.ClearField(field.name) self._ConvertMapFieldValue(value, message, field, '{0}.{1}'.format(path, name)) elif field.label == descriptor.FieldDescriptor.LABEL_REPEATED: message.ClearField(field.name) if not isinstance(value, list): raise ParseError('repeated field {0} must be in [] which is ' '{1} at {2}'.format(name, value, path)) if field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_MESSAGE: # Repeated message field. for index, item in enumerate(value): sub_message = getattr(message, field.name).add() # None is a null_value in Value. if (item is None and sub_message.DESCRIPTOR.full_name != 'google.protobuf.Value'): raise ParseError('null is not allowed to be used as an element' ' in a repeated field at {0}.{1}[{2}]'.format( path, name, index)) self.ConvertMessage(item, sub_message, '{0}.{1}[{2}]'.format(path, name, index)) else: # Repeated scalar field. for index, item in enumerate(value): if item is None: raise ParseError('null is not allowed to be used as an element' ' in a repeated field at {0}.{1}[{2}]'.format( path, name, index)) getattr(message, field.name).append( _ConvertScalarFieldValue( item, field, '{0}.{1}[{2}]'.format(path, name, index))) elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_MESSAGE: if field.is_extension: sub_message = message.Extensions[field] else: sub_message = getattr(message, field.name) sub_message.SetInParent() self.ConvertMessage(value, sub_message, '{0}.{1}'.format(path, name)) else: if field.is_extension: message.Extensions[field] = _ConvertScalarFieldValue( value, field, '{0}.{1}'.format(path, name)) else: setattr( message, field.name, _ConvertScalarFieldValue(value, field, '{0}.{1}'.format(path, name))) except ParseError as e: if field and field.containing_oneof is None: raise ParseError('Failed to parse {0} field: {1}.'.format(name, e)) else: raise ParseError(str(e)) except ValueError as e: raise ParseError('Failed to parse {0} field: {1}.'.format(name, e)) except TypeError as e: raise ParseError('Failed to parse {0} field: {1}.'.format(name, e)) def _ConvertAnyMessage(self, value, message, path): """Convert a JSON representation into Any message.""" if isinstance(value, dict) and not value: return try: type_url = value['@type'] except KeyError: raise ParseError( '@type is missing when parsing any message at {0}'.format(path)) try: sub_message = _CreateMessageFromTypeUrl(type_url, self.descriptor_pool) except TypeError as e: raise ParseError('{0} at {1}'.format(e, path)) message_descriptor = sub_message.DESCRIPTOR full_name = message_descriptor.full_name if _IsWrapperMessage(message_descriptor): self._ConvertWrapperMessage(value['value'], sub_message, '{0}.value'.format(path)) elif full_name in _WKTJSONMETHODS: methodcaller(_WKTJSONMETHODS[full_name][1], value['value'], sub_message, '{0}.value'.format(path))( self) else: del value['@type'] self._ConvertFieldValuePair(value, sub_message, path) value['@type'] = type_url # Sets Any message message.value = sub_message.SerializeToString() message.type_url = type_url def _ConvertGenericMessage(self, value, message, path): """Convert a JSON representation into message with FromJsonString.""" # Duration, Timestamp, FieldMask have a FromJsonString method to do the # conversion. Users can also call the method directly. try: message.FromJsonString(value) except ValueError as e: raise ParseError('{0} at {1}'.format(e, path)) def _ConvertValueMessage(self, value, message, path): """Convert a JSON representation into Value message.""" if isinstance(value, dict): self._ConvertStructMessage(value, message.struct_value, path) elif isinstance(value, list): self._ConvertListValueMessage(value, message.list_value, path) elif value is None: message.null_value = 0 elif isinstance(value, bool): message.bool_value = value elif isinstance(value, str): message.string_value = value elif isinstance(value, _INT_OR_FLOAT): message.number_value = value else: raise ParseError('Value {0} has unexpected type {1} at {2}'.format( value, type(value), path)) def _ConvertListValueMessage(self, value, message, path): """Convert a JSON representation into ListValue message.""" if not isinstance(value, list): raise ParseError('ListValue must be in [] which is {0} at {1}'.format( value, path)) message.ClearField('values') for index, item in enumerate(value): self._ConvertValueMessage(item, message.values.add(), '{0}[{1}]'.format(path, index)) def _ConvertStructMessage(self, value, message, path): """Convert a JSON representation into Struct message.""" if not isinstance(value, dict): raise ParseError('Struct must be in a dict which is {0} at {1}'.format( value, path)) # Clear will mark the struct as modified so it will be created even if # there are no values. message.Clear() for key in value: self._ConvertValueMessage(value[key], message.fields[key], '{0}.{1}'.format(path, key)) return def _ConvertWrapperMessage(self, value, message, path): """Convert a JSON representation into Wrapper message.""" field = message.DESCRIPTOR.fields_by_name['value'] setattr( message, 'value', _ConvertScalarFieldValue(value, field, path='{0}.value'.format(path))) def _ConvertMapFieldValue(self, value, message, field, path): """Convert map field value for a message map field. Args: value: A JSON object to convert the map field value. message: A protocol message to record the converted data. field: The descriptor of the map field to be converted. path: parent path to log parse error info. Raises: ParseError: In case of convert problems. """ if not isinstance(value, dict): raise ParseError( 'Map field {0} must be in a dict which is {1} at {2}'.format( field.name, value, path)) key_field = field.message_type.fields_by_name['key'] value_field = field.message_type.fields_by_name['value'] for key in value: key_value = _ConvertScalarFieldValue(key, key_field, '{0}.key'.format(path), True) if value_field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_MESSAGE: self.ConvertMessage(value[key], getattr(message, field.name)[key_value], '{0}[{1}]'.format(path, key_value)) else: getattr(message, field.name)[key_value] = _ConvertScalarFieldValue( value[key], value_field, path='{0}[{1}]'.format(path, key_value)) def _ConvertScalarFieldValue(value, field, path, require_str=False): """Convert a single scalar field value. Args: value: A scalar value to convert the scalar field value. field: The descriptor of the field to convert. path: parent path to log parse error info. require_str: If True, the field value must be a str. Returns: The converted scalar field value Raises: ParseError: In case of convert problems. """ try: if field.cpp_type in _INT_TYPES: return _ConvertInteger(value) elif field.cpp_type in _FLOAT_TYPES: return _ConvertFloat(value, field) elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_BOOL: return _ConvertBool(value, require_str) elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_STRING: if field.type == descriptor.FieldDescriptor.TYPE_BYTES: if isinstance(value, str): encoded = value.encode('utf-8') else: encoded = value # Add extra padding '=' padded_value = encoded + b'=' * (4 - len(encoded) % 4) return base64.urlsafe_b64decode(padded_value) else: # Checking for unpaired surrogates appears to be unreliable, # depending on the specific Python version, so we check manually. if _UNPAIRED_SURROGATE_PATTERN.search(value): raise ParseError('Unpaired surrogate') return value elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_ENUM: # Convert an enum value. enum_value = field.enum_type.values_by_name.get(value, None) if enum_value is None: try: number = int(value) enum_value = field.enum_type.values_by_number.get(number, None) except ValueError: raise ParseError('Invalid enum value {0} for enum type {1}'.format( value, field.enum_type.full_name)) if enum_value is None: if field.file.syntax == 'proto3': # Proto3 accepts unknown enums. return number raise ParseError('Invalid enum value {0} for enum type {1}'.format( value, field.enum_type.full_name)) return enum_value.number except ParseError as e: raise ParseError('{0} at {1}'.format(e, path)) def _ConvertInteger(value): """Convert an integer. Args: value: A scalar value to convert. Returns: The integer value. Raises: ParseError: If an integer couldn't be consumed. """ if isinstance(value, float) and not value.is_integer(): raise ParseError('Couldn\'t parse integer: {0}'.format(value)) if isinstance(value, str) and value.find(' ') != -1: raise ParseError('Couldn\'t parse integer: "{0}"'.format(value)) if isinstance(value, bool): raise ParseError('Bool value {0} is not acceptable for ' 'integer field'.format(value)) return int(value) def _ConvertFloat(value, field): """Convert an floating point number.""" if isinstance(value, float): if math.isnan(value): raise ParseError('Couldn\'t parse NaN, use quoted "NaN" instead') if math.isinf(value): if value > 0: raise ParseError('Couldn\'t parse Infinity or value too large, ' 'use quoted "Infinity" instead') else: raise ParseError('Couldn\'t parse -Infinity or value too small, ' 'use quoted "-Infinity" instead') if field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_FLOAT: # pylint: disable=protected-access if value > type_checkers._FLOAT_MAX: raise ParseError('Float value too large') # pylint: disable=protected-access if value < type_checkers._FLOAT_MIN: raise ParseError('Float value too small') if value == 'nan': raise ParseError('Couldn\'t parse float "nan", use "NaN" instead') try: # Assume Python compatible syntax. return float(value) except ValueError: # Check alternative spellings. if value == _NEG_INFINITY: return float('-inf') elif value == _INFINITY: return float('inf') elif value == _NAN: return float('nan') else: raise ParseError('Couldn\'t parse float: {0}'.format(value)) def _ConvertBool(value, require_str): """Convert a boolean value. Args: value: A scalar value to convert. require_str: If True, value must be a str. Returns: The bool parsed. Raises: ParseError: If a boolean value couldn't be consumed. """ if require_str: if value == 'true': return True elif value == 'false': return False else: raise ParseError('Expected "true" or "false", not {0}'.format(value)) if not isinstance(value, bool): raise ParseError('Expected true or false without quotes') return value _WKTJSONMETHODS = { 'google.protobuf.Any': ['_AnyMessageToJsonObject', '_ConvertAnyMessage'], 'google.protobuf.Duration': ['_GenericMessageToJsonObject', '_ConvertGenericMessage'], 'google.protobuf.FieldMask': ['_GenericMessageToJsonObject', '_ConvertGenericMessage'], 'google.protobuf.ListValue': ['_ListValueMessageToJsonObject', '_ConvertListValueMessage'], 'google.protobuf.Struct': ['_StructMessageToJsonObject', '_ConvertStructMessage'], 'google.protobuf.Timestamp': ['_GenericMessageToJsonObject', '_ConvertGenericMessage'], 'google.protobuf.Value': ['_ValueMessageToJsonObject', '_ConvertValueMessage'] } ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/message.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # TODO(robinson): We should just make these methods all "pure-virtual" and move # all implementation out, into reflection.py for now. """Contains an abstract base class for protocol messages.""" __author__ = 'robinson@google.com (Will Robinson)' class Error(Exception): """Base error type for this module.""" pass class DecodeError(Error): """Exception raised when deserializing messages.""" pass class EncodeError(Error): """Exception raised when serializing messages.""" pass class Message(object): """Abstract base class for protocol messages. Protocol message classes are almost always generated by the protocol compiler. These generated types subclass Message and implement the methods shown below. """ # TODO(robinson): Link to an HTML document here. # TODO(robinson): Document that instances of this class will also # have an Extensions attribute with __getitem__ and __setitem__. # Again, not sure how to best convey this. # TODO(robinson): Document that the class must also have a static # RegisterExtension(extension_field) method. # Not sure how to best express at this point. # TODO(robinson): Document these fields and methods. __slots__ = [] #: The :class:`google.protobuf.descriptor.Descriptor` for this message type. DESCRIPTOR = None def __deepcopy__(self, memo=None): clone = type(self)() clone.MergeFrom(self) return clone def __eq__(self, other_msg): """Recursively compares two messages by value and structure.""" raise NotImplementedError def __ne__(self, other_msg): # Can't just say self != other_msg, since that would infinitely recurse. :) return not self == other_msg def __hash__(self): raise TypeError('unhashable object') def __str__(self): """Outputs a human-readable representation of the message.""" raise NotImplementedError def __unicode__(self): """Outputs a human-readable representation of the message.""" raise NotImplementedError def MergeFrom(self, other_msg): """Merges the contents of the specified message into current message. This method merges the contents of the specified message into the current message. Singular fields that are set in the specified message overwrite the corresponding fields in the current message. Repeated fields are appended. Singular sub-messages and groups are recursively merged. Args: other_msg (Message): A message to merge into the current message. """ raise NotImplementedError def CopyFrom(self, other_msg): """Copies the content of the specified message into the current message. The method clears the current message and then merges the specified message using MergeFrom. Args: other_msg (Message): A message to copy into the current one. """ if self is other_msg: return self.Clear() self.MergeFrom(other_msg) def Clear(self): """Clears all data that was set in the message.""" raise NotImplementedError def SetInParent(self): """Mark this as present in the parent. This normally happens automatically when you assign a field of a sub-message, but sometimes you want to make the sub-message present while keeping it empty. If you find yourself using this, you may want to reconsider your design. """ raise NotImplementedError def IsInitialized(self): """Checks if the message is initialized. Returns: bool: The method returns True if the message is initialized (i.e. all of its required fields are set). """ raise NotImplementedError # TODO(robinson): MergeFromString() should probably return None and be # implemented in terms of a helper that returns the # of bytes read. Our # deserialization routines would use the helper when recursively # deserializing, but the end user would almost always just want the no-return # MergeFromString(). def MergeFromString(self, serialized): """Merges serialized protocol buffer data into this message. When we find a field in `serialized` that is already present in this message: - If it's a "repeated" field, we append to the end of our list. - Else, if it's a scalar, we overwrite our field. - Else, (it's a nonrepeated composite), we recursively merge into the existing composite. Args: serialized (bytes): Any object that allows us to call ``memoryview(serialized)`` to access a string of bytes using the buffer interface. Returns: int: The number of bytes read from `serialized`. For non-group messages, this will always be `len(serialized)`, but for messages which are actually groups, this will generally be less than `len(serialized)`, since we must stop when we reach an ``END_GROUP`` tag. Note that if we *do* stop because of an ``END_GROUP`` tag, the number of bytes returned does not include the bytes for the ``END_GROUP`` tag information. Raises: DecodeError: if the input cannot be parsed. """ # TODO(robinson): Document handling of unknown fields. # TODO(robinson): When we switch to a helper, this will return None. raise NotImplementedError def ParseFromString(self, serialized): """Parse serialized protocol buffer data into this message. Like :func:`MergeFromString()`, except we clear the object first. Raises: message.DecodeError if the input cannot be parsed. """ self.Clear() return self.MergeFromString(serialized) def SerializeToString(self, **kwargs): """Serializes the protocol message to a binary string. Keyword Args: deterministic (bool): If true, requests deterministic serialization of the protobuf, with predictable ordering of map keys. Returns: A binary string representation of the message if all of the required fields in the message are set (i.e. the message is initialized). Raises: EncodeError: if the message isn't initialized (see :func:`IsInitialized`). """ raise NotImplementedError def SerializePartialToString(self, **kwargs): """Serializes the protocol message to a binary string. This method is similar to SerializeToString but doesn't check if the message is initialized. Keyword Args: deterministic (bool): If true, requests deterministic serialization of the protobuf, with predictable ordering of map keys. Returns: bytes: A serialized representation of the partial message. """ raise NotImplementedError # TODO(robinson): Decide whether we like these better # than auto-generated has_foo() and clear_foo() methods # on the instances themselves. This way is less consistent # with C++, but it makes reflection-type access easier and # reduces the number of magically autogenerated things. # # TODO(robinson): Be sure to document (and test) exactly # which field names are accepted here. Are we case-sensitive? # What do we do with fields that share names with Python keywords # like 'lambda' and 'yield'? # # nnorwitz says: # """ # Typically (in python), an underscore is appended to names that are # keywords. So they would become lambda_ or yield_. # """ def ListFields(self): """Returns a list of (FieldDescriptor, value) tuples for present fields. A message field is non-empty if HasField() would return true. A singular primitive field is non-empty if HasField() would return true in proto2 or it is non zero in proto3. A repeated field is non-empty if it contains at least one element. The fields are ordered by field number. Returns: list[tuple(FieldDescriptor, value)]: field descriptors and values for all fields in the message which are not empty. The values vary by field type. """ raise NotImplementedError def HasField(self, field_name): """Checks if a certain field is set for the message. For a oneof group, checks if any field inside is set. Note that if the field_name is not defined in the message descriptor, :exc:`ValueError` will be raised. Args: field_name (str): The name of the field to check for presence. Returns: bool: Whether a value has been set for the named field. Raises: ValueError: if the `field_name` is not a member of this message. """ raise NotImplementedError def ClearField(self, field_name): """Clears the contents of a given field. Inside a oneof group, clears the field set. If the name neither refers to a defined field or oneof group, :exc:`ValueError` is raised. Args: field_name (str): The name of the field to check for presence. Raises: ValueError: if the `field_name` is not a member of this message. """ raise NotImplementedError def WhichOneof(self, oneof_group): """Returns the name of the field that is set inside a oneof group. If no field is set, returns None. Args: oneof_group (str): the name of the oneof group to check. Returns: str or None: The name of the group that is set, or None. Raises: ValueError: no group with the given name exists """ raise NotImplementedError def HasExtension(self, extension_handle): """Checks if a certain extension is present for this message. Extensions are retrieved using the :attr:`Extensions` mapping (if present). Args: extension_handle: The handle for the extension to check. Returns: bool: Whether the extension is present for this message. Raises: KeyError: if the extension is repeated. Similar to repeated fields, there is no separate notion of presence: a "not present" repeated extension is an empty list. """ raise NotImplementedError def ClearExtension(self, extension_handle): """Clears the contents of a given extension. Args: extension_handle: The handle for the extension to clear. """ raise NotImplementedError def UnknownFields(self): """Returns the UnknownFieldSet. Returns: UnknownFieldSet: The unknown fields stored in this message. """ raise NotImplementedError def DiscardUnknownFields(self): """Clears all fields in the :class:`UnknownFieldSet`. This operation is recursive for nested message. """ raise NotImplementedError def ByteSize(self): """Returns the serialized size of this message. Recursively calls ByteSize() on all contained messages. Returns: int: The number of bytes required to serialize this message. """ raise NotImplementedError @classmethod def FromString(cls, s): raise NotImplementedError @staticmethod def RegisterExtension(extension_handle): raise NotImplementedError def _SetListener(self, message_listener): """Internal method used by the protocol message implementation. Clients should not call this directly. Sets a listener that this message will call on certain state transitions. The purpose of this method is to register back-edges from children to parents at runtime, for the purpose of setting "has" bits and byte-size-dirty bits in the parent and ancestor objects whenever a child or descendant object is modified. If the client wants to disconnect this Message from the object tree, she explicitly sets callback to None. If message_listener is None, unregisters any existing listener. Otherwise, message_listener must implement the MessageListener interface in internal/message_listener.py, and we discard any listener registered via a previous _SetListener() call. """ raise NotImplementedError def __getstate__(self): """Support the pickle protocol.""" return dict(serialized=self.SerializePartialToString()) def __setstate__(self, state): """Support the pickle protocol.""" self.__init__() serialized = state['serialized'] # On Python 3, using encoding='latin1' is required for unpickling # protos pickled by Python 2. if not isinstance(serialized, bytes): serialized = serialized.encode('latin1') self.ParseFromString(serialized) def __reduce__(self): message_descriptor = self.DESCRIPTOR if message_descriptor.containing_type is None: return type(self), (), self.__getstate__() # the message type must be nested. # Python does not pickle nested classes; use the symbol_database on the # receiving end. container = message_descriptor return (_InternalConstructMessage, (container.full_name,), self.__getstate__()) def _InternalConstructMessage(full_name): """Constructs a nested message.""" from google.protobuf import symbol_database # pylint:disable=g-import-not-at-top return symbol_database.Default().GetSymbol(full_name)() ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/message_factory.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Provides a factory class for generating dynamic messages. The easiest way to use this class is if you have access to the FileDescriptor protos containing the messages you want to create you can just do the following: message_classes = message_factory.GetMessages(iterable_of_file_descriptors) my_proto_instance = message_classes['some.proto.package.MessageName']() """ __author__ = 'matthewtoia@google.com (Matt Toia)' from google.protobuf.internal import api_implementation from google.protobuf import descriptor_pool from google.protobuf import message if api_implementation.Type() == 'cpp': from google.protobuf.pyext import cpp_message as message_impl else: from google.protobuf.internal import python_message as message_impl # The type of all Message classes. _GENERATED_PROTOCOL_MESSAGE_TYPE = message_impl.GeneratedProtocolMessageType class MessageFactory(object): """Factory for creating Proto2 messages from descriptors in a pool.""" def __init__(self, pool=None): """Initializes a new factory.""" self.pool = pool or descriptor_pool.DescriptorPool() # local cache of all classes built from protobuf descriptors self._classes = {} def GetPrototype(self, descriptor): """Obtains a proto2 message class based on the passed in descriptor. Passing a descriptor with a fully qualified name matching a previous invocation will cause the same class to be returned. Args: descriptor: The descriptor to build from. Returns: A class describing the passed in descriptor. """ if descriptor not in self._classes: result_class = self.CreatePrototype(descriptor) # The assignment to _classes is redundant for the base implementation, but # might avoid confusion in cases where CreatePrototype gets overridden and # does not call the base implementation. self._classes[descriptor] = result_class return result_class return self._classes[descriptor] def CreatePrototype(self, descriptor): """Builds a proto2 message class based on the passed in descriptor. Don't call this function directly, it always creates a new class. Call GetPrototype() instead. This method is meant to be overridden in subblasses to perform additional operations on the newly constructed class. Args: descriptor: The descriptor to build from. Returns: A class describing the passed in descriptor. """ descriptor_name = descriptor.name result_class = _GENERATED_PROTOCOL_MESSAGE_TYPE( descriptor_name, (message.Message,), { 'DESCRIPTOR': descriptor, # If module not set, it wrongly points to message_factory module. '__module__': None, }) result_class._FACTORY = self # pylint: disable=protected-access # Assign in _classes before doing recursive calls to avoid infinite # recursion. self._classes[descriptor] = result_class for field in descriptor.fields: if field.message_type: self.GetPrototype(field.message_type) for extension in result_class.DESCRIPTOR.extensions: if extension.containing_type not in self._classes: self.GetPrototype(extension.containing_type) extended_class = self._classes[extension.containing_type] extended_class.RegisterExtension(extension) return result_class def GetMessages(self, files): """Gets all the messages from a specified file. This will find and resolve dependencies, failing if the descriptor pool cannot satisfy them. Args: files: The file names to extract messages from. Returns: A dictionary mapping proto names to the message classes. This will include any dependent messages as well as any messages defined in the same file as a specified message. """ result = {} for file_name in files: file_desc = self.pool.FindFileByName(file_name) for desc in file_desc.message_types_by_name.values(): result[desc.full_name] = self.GetPrototype(desc) # While the extension FieldDescriptors are created by the descriptor pool, # the python classes created in the factory need them to be registered # explicitly, which is done below. # # The call to RegisterExtension will specifically check if the # extension was already registered on the object and either # ignore the registration if the original was the same, or raise # an error if they were different. for extension in file_desc.extensions_by_name.values(): if extension.containing_type not in self._classes: self.GetPrototype(extension.containing_type) extended_class = self._classes[extension.containing_type] extended_class.RegisterExtension(extension) return result _FACTORY = MessageFactory() def GetMessages(file_protos): """Builds a dictionary of all the messages available in a set of files. Args: file_protos: Iterable of FileDescriptorProto to build messages out of. Returns: A dictionary mapping proto names to the message classes. This will include any dependent messages as well as any messages defined in the same file as a specified message. """ # The cpp implementation of the protocol buffer library requires to add the # message in topological order of the dependency graph. file_by_name = {file_proto.name: file_proto for file_proto in file_protos} def _AddFile(file_proto): for dependency in file_proto.dependency: if dependency in file_by_name: # Remove from elements to be visited, in order to cut cycles. _AddFile(file_by_name.pop(dependency)) _FACTORY.pool.Add(file_proto) while file_by_name: _AddFile(file_by_name.popitem()[1]) return _FACTORY.GetMessages([file_proto.name for file_proto in file_protos]) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/proto_builder.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Dynamic Protobuf class creator.""" from collections import OrderedDict import hashlib import os from google.protobuf import descriptor_pb2 from google.protobuf import descriptor from google.protobuf import message_factory def _GetMessageFromFactory(factory, full_name): """Get a proto class from the MessageFactory by name. Args: factory: a MessageFactory instance. full_name: str, the fully qualified name of the proto type. Returns: A class, for the type identified by full_name. Raises: KeyError, if the proto is not found in the factory's descriptor pool. """ proto_descriptor = factory.pool.FindMessageTypeByName(full_name) proto_cls = factory.GetPrototype(proto_descriptor) return proto_cls def MakeSimpleProtoClass(fields, full_name=None, pool=None): """Create a Protobuf class whose fields are basic types. Note: this doesn't validate field names! Args: fields: dict of {name: field_type} mappings for each field in the proto. If this is an OrderedDict the order will be maintained, otherwise the fields will be sorted by name. full_name: optional str, the fully-qualified name of the proto type. pool: optional DescriptorPool instance. Returns: a class, the new protobuf class with a FileDescriptor. """ factory = message_factory.MessageFactory(pool=pool) if full_name is not None: try: proto_cls = _GetMessageFromFactory(factory, full_name) return proto_cls except KeyError: # The factory's DescriptorPool doesn't know about this class yet. pass # Get a list of (name, field_type) tuples from the fields dict. If fields was # an OrderedDict we keep the order, but otherwise we sort the field to ensure # consistent ordering. field_items = fields.items() if not isinstance(fields, OrderedDict): field_items = sorted(field_items) # Use a consistent file name that is unlikely to conflict with any imported # proto files. fields_hash = hashlib.sha1() for f_name, f_type in field_items: fields_hash.update(f_name.encode('utf-8')) fields_hash.update(str(f_type).encode('utf-8')) proto_file_name = fields_hash.hexdigest() + '.proto' # If the proto is anonymous, use the same hash to name it. if full_name is None: full_name = ('net.proto2.python.public.proto_builder.AnonymousProto_' + fields_hash.hexdigest()) try: proto_cls = _GetMessageFromFactory(factory, full_name) return proto_cls except KeyError: # The factory's DescriptorPool doesn't know about this class yet. pass # This is the first time we see this proto: add a new descriptor to the pool. factory.pool.Add( _MakeFileDescriptorProto(proto_file_name, full_name, field_items)) return _GetMessageFromFactory(factory, full_name) def _MakeFileDescriptorProto(proto_file_name, full_name, field_items): """Populate FileDescriptorProto for MessageFactory's DescriptorPool.""" package, name = full_name.rsplit('.', 1) file_proto = descriptor_pb2.FileDescriptorProto() file_proto.name = os.path.join(package.replace('.', '/'), proto_file_name) file_proto.package = package desc_proto = file_proto.message_type.add() desc_proto.name = name for f_number, (f_name, f_type) in enumerate(field_items, 1): field_proto = desc_proto.field.add() field_proto.name = f_name # # If the number falls in the reserved range, reassign it to the correct # # number after the range. if f_number >= descriptor.FieldDescriptor.FIRST_RESERVED_FIELD_NUMBER: f_number += ( descriptor.FieldDescriptor.LAST_RESERVED_FIELD_NUMBER - descriptor.FieldDescriptor.FIRST_RESERVED_FIELD_NUMBER + 1) field_proto.number = f_number field_proto.label = descriptor_pb2.FieldDescriptorProto.LABEL_OPTIONAL field_proto.type = f_type return file_proto ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/pyext/__init__.py ================================================ ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/pyext/cpp_message.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Protocol message implementation hooks for C++ implementation. Contains helper functions used to create protocol message classes from Descriptor objects at runtime backed by the protocol buffer C++ API. """ __author__ = 'tibell@google.com (Johan Tibell)' from google.protobuf.pyext import _message class GeneratedProtocolMessageType(_message.MessageMeta): """Metaclass for protocol message classes created at runtime from Descriptors. The protocol compiler currently uses this metaclass to create protocol message classes at runtime. Clients can also manually create their own classes at runtime, as in this example: mydescriptor = Descriptor(.....) factory = symbol_database.Default() factory.pool.AddDescriptor(mydescriptor) MyProtoClass = factory.GetPrototype(mydescriptor) myproto_instance = MyProtoClass() myproto.foo_field = 23 ... The above example will not work for nested types. If you wish to include them, use reflection.MakeClass() instead of manually instantiating the class in order to create the appropriate class structure. """ # Must be consistent with the protocol-compiler code in # proto2/compiler/internal/generator.*. _DESCRIPTOR_KEY = 'DESCRIPTOR' ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/pyext/python_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/pyext/python.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"google/protobuf/pyext/python.proto\x12\x1fgoogle.protobuf.python.internal\"\xbc\x02\n\x0cTestAllTypes\x12\\\n\x17repeated_nested_message\x18\x01 \x03(\x0b\x32;.google.protobuf.python.internal.TestAllTypes.NestedMessage\x12\\\n\x17optional_nested_message\x18\x02 \x01(\x0b\x32;.google.protobuf.python.internal.TestAllTypes.NestedMessage\x12\x16\n\x0eoptional_int32\x18\x03 \x01(\x05\x1aX\n\rNestedMessage\x12\n\n\x02\x62\x62\x18\x01 \x01(\x05\x12;\n\x02\x63\x63\x18\x02 \x01(\x0b\x32/.google.protobuf.python.internal.ForeignMessage\"&\n\x0e\x46oreignMessage\x12\t\n\x01\x63\x18\x01 \x01(\x05\x12\t\n\x01\x64\x18\x02 \x03(\x05\"\x1d\n\x11TestAllExtensions*\x08\x08\x01\x10\x80\x80\x80\x80\x02:\x9a\x01\n!optional_nested_message_extension\x12\x32.google.protobuf.python.internal.TestAllExtensions\x18\x01 \x01(\x0b\x32;.google.protobuf.python.internal.TestAllTypes.NestedMessage:\x9a\x01\n!repeated_nested_message_extension\x12\x32.google.protobuf.python.internal.TestAllExtensions\x18\x02 \x03(\x0b\x32;.google.protobuf.python.internal.TestAllTypes.NestedMessageB\x02H\x01') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.pyext.python_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: TestAllExtensions.RegisterExtension(optional_nested_message_extension) TestAllExtensions.RegisterExtension(repeated_nested_message_extension) DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'H\001' _TESTALLTYPES._serialized_start=72 _TESTALLTYPES._serialized_end=388 _TESTALLTYPES_NESTEDMESSAGE._serialized_start=300 _TESTALLTYPES_NESTEDMESSAGE._serialized_end=388 _FOREIGNMESSAGE._serialized_start=390 _FOREIGNMESSAGE._serialized_end=428 _TESTALLEXTENSIONS._serialized_start=430 _TESTALLEXTENSIONS._serialized_end=459 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/reflection.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # This code is meant to work on Python 2.4 and above only. """Contains a metaclass and helper functions used to create protocol message classes from Descriptor objects at runtime. Recall that a metaclass is the "type" of a class. (A class is to a metaclass what an instance is to a class.) In this case, we use the GeneratedProtocolMessageType metaclass to inject all the useful functionality into the classes output by the protocol compiler at compile-time. The upshot of all this is that the real implementation details for ALL pure-Python protocol buffers are *here in this file*. """ __author__ = 'robinson@google.com (Will Robinson)' from google.protobuf import message_factory from google.protobuf import symbol_database # The type of all Message classes. # Part of the public interface, but normally only used by message factories. GeneratedProtocolMessageType = message_factory._GENERATED_PROTOCOL_MESSAGE_TYPE MESSAGE_CLASS_CACHE = {} # Deprecated. Please NEVER use reflection.ParseMessage(). def ParseMessage(descriptor, byte_str): """Generate a new Message instance from this Descriptor and a byte string. DEPRECATED: ParseMessage is deprecated because it is using MakeClass(). Please use MessageFactory.GetPrototype() instead. Args: descriptor: Protobuf Descriptor object byte_str: Serialized protocol buffer byte string Returns: Newly created protobuf Message object. """ result_class = MakeClass(descriptor) new_msg = result_class() new_msg.ParseFromString(byte_str) return new_msg # Deprecated. Please NEVER use reflection.MakeClass(). def MakeClass(descriptor): """Construct a class object for a protobuf described by descriptor. DEPRECATED: use MessageFactory.GetPrototype() instead. Args: descriptor: A descriptor.Descriptor object describing the protobuf. Returns: The Message class object described by the descriptor. """ # Original implementation leads to duplicate message classes, which won't play # well with extensions. Message factory info is also missing. # Redirect to message_factory. return symbol_database.Default().GetPrototype(descriptor) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/service.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """DEPRECATED: Declares the RPC service interfaces. This module declares the abstract interfaces underlying proto2 RPC services. These are intended to be independent of any particular RPC implementation, so that proto2 services can be used on top of a variety of implementations. Starting with version 2.3.0, RPC implementations should not try to build on these, but should instead provide code generator plugins which generate code specific to the particular RPC implementation. This way the generated code can be more appropriate for the implementation in use and can avoid unnecessary layers of indirection. """ __author__ = 'petar@google.com (Petar Petrov)' class RpcException(Exception): """Exception raised on failed blocking RPC method call.""" pass class Service(object): """Abstract base interface for protocol-buffer-based RPC services. Services themselves are abstract classes (implemented either by servers or as stubs), but they subclass this base interface. The methods of this interface can be used to call the methods of the service without knowing its exact type at compile time (analogous to the Message interface). """ def GetDescriptor(): """Retrieves this service's descriptor.""" raise NotImplementedError def CallMethod(self, method_descriptor, rpc_controller, request, done): """Calls a method of the service specified by method_descriptor. If "done" is None then the call is blocking and the response message will be returned directly. Otherwise the call is asynchronous and "done" will later be called with the response value. In the blocking case, RpcException will be raised on error. Preconditions: * method_descriptor.service == GetDescriptor * request is of the exact same classes as returned by GetRequestClass(method). * After the call has started, the request must not be modified. * "rpc_controller" is of the correct type for the RPC implementation being used by this Service. For stubs, the "correct type" depends on the RpcChannel which the stub is using. Postconditions: * "done" will be called when the method is complete. This may be before CallMethod() returns or it may be at some point in the future. * If the RPC failed, the response value passed to "done" will be None. Further details about the failure can be found by querying the RpcController. """ raise NotImplementedError def GetRequestClass(self, method_descriptor): """Returns the class of the request message for the specified method. CallMethod() requires that the request is of a particular subclass of Message. GetRequestClass() gets the default instance of this required type. Example: method = service.GetDescriptor().FindMethodByName("Foo") request = stub.GetRequestClass(method)() request.ParseFromString(input) service.CallMethod(method, request, callback) """ raise NotImplementedError def GetResponseClass(self, method_descriptor): """Returns the class of the response message for the specified method. This method isn't really needed, as the RpcChannel's CallMethod constructs the response protocol message. It's provided anyway in case it is useful for the caller to know the response type in advance. """ raise NotImplementedError class RpcController(object): """An RpcController mediates a single method call. The primary purpose of the controller is to provide a way to manipulate settings specific to the RPC implementation and to find out about RPC-level errors. The methods provided by the RpcController interface are intended to be a "least common denominator" set of features which we expect all implementations to support. Specific implementations may provide more advanced features (e.g. deadline propagation). """ # Client-side methods below def Reset(self): """Resets the RpcController to its initial state. After the RpcController has been reset, it may be reused in a new call. Must not be called while an RPC is in progress. """ raise NotImplementedError def Failed(self): """Returns true if the call failed. After a call has finished, returns true if the call failed. The possible reasons for failure depend on the RPC implementation. Failed() must not be called before a call has finished. If Failed() returns true, the contents of the response message are undefined. """ raise NotImplementedError def ErrorText(self): """If Failed is true, returns a human-readable description of the error.""" raise NotImplementedError def StartCancel(self): """Initiate cancellation. Advises the RPC system that the caller desires that the RPC call be canceled. The RPC system may cancel it immediately, may wait awhile and then cancel it, or may not even cancel the call at all. If the call is canceled, the "done" callback will still be called and the RpcController will indicate that the call failed at that time. """ raise NotImplementedError # Server-side methods below def SetFailed(self, reason): """Sets a failure reason. Causes Failed() to return true on the client side. "reason" will be incorporated into the message returned by ErrorText(). If you find you need to return machine-readable information about failures, you should incorporate it into your response protocol buffer and should NOT call SetFailed(). """ raise NotImplementedError def IsCanceled(self): """Checks if the client cancelled the RPC. If true, indicates that the client canceled the RPC, so the server may as well give up on replying to it. The server should still call the final "done" callback. """ raise NotImplementedError def NotifyOnCancel(self, callback): """Sets a callback to invoke on cancel. Asks that the given callback be called when the RPC is canceled. The callback will always be called exactly once. If the RPC completes without being canceled, the callback will be called after completion. If the RPC has already been canceled when NotifyOnCancel() is called, the callback will be called immediately. NotifyOnCancel() must be called no more than once per request. """ raise NotImplementedError class RpcChannel(object): """Abstract interface for an RPC channel. An RpcChannel represents a communication line to a service which can be used to call that service's methods. The service may be running on another machine. Normally, you should not use an RpcChannel directly, but instead construct a stub {@link Service} wrapping it. Example: Example: RpcChannel channel = rpcImpl.Channel("remotehost.example.com:1234") RpcController controller = rpcImpl.Controller() MyService service = MyService_Stub(channel) service.MyMethod(controller, request, callback) """ def CallMethod(self, method_descriptor, rpc_controller, request, response_class, done): """Calls the method identified by the descriptor. Call the given method of the remote service. The signature of this procedure looks the same as Service.CallMethod(), but the requirements are less strict in one important way: the request object doesn't have to be of any specific class as long as its descriptor is method.input_type. """ raise NotImplementedError ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/service_reflection.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Contains metaclasses used to create protocol service and service stub classes from ServiceDescriptor objects at runtime. The GeneratedServiceType and GeneratedServiceStubType metaclasses are used to inject all useful functionality into the classes output by the protocol compiler at compile-time. """ __author__ = 'petar@google.com (Petar Petrov)' class GeneratedServiceType(type): """Metaclass for service classes created at runtime from ServiceDescriptors. Implementations for all methods described in the Service class are added here by this class. We also create properties to allow getting/setting all fields in the protocol message. The protocol compiler currently uses this metaclass to create protocol service classes at runtime. Clients can also manually create their own classes at runtime, as in this example:: mydescriptor = ServiceDescriptor(.....) class MyProtoService(service.Service): __metaclass__ = GeneratedServiceType DESCRIPTOR = mydescriptor myservice_instance = MyProtoService() # ... """ _DESCRIPTOR_KEY = 'DESCRIPTOR' def __init__(cls, name, bases, dictionary): """Creates a message service class. Args: name: Name of the class (ignored, but required by the metaclass protocol). bases: Base classes of the class being constructed. dictionary: The class dictionary of the class being constructed. dictionary[_DESCRIPTOR_KEY] must contain a ServiceDescriptor object describing this protocol service type. """ # Don't do anything if this class doesn't have a descriptor. This happens # when a service class is subclassed. if GeneratedServiceType._DESCRIPTOR_KEY not in dictionary: return descriptor = dictionary[GeneratedServiceType._DESCRIPTOR_KEY] service_builder = _ServiceBuilder(descriptor) service_builder.BuildService(cls) cls.DESCRIPTOR = descriptor class GeneratedServiceStubType(GeneratedServiceType): """Metaclass for service stubs created at runtime from ServiceDescriptors. This class has similar responsibilities as GeneratedServiceType, except that it creates the service stub classes. """ _DESCRIPTOR_KEY = 'DESCRIPTOR' def __init__(cls, name, bases, dictionary): """Creates a message service stub class. Args: name: Name of the class (ignored, here). bases: Base classes of the class being constructed. dictionary: The class dictionary of the class being constructed. dictionary[_DESCRIPTOR_KEY] must contain a ServiceDescriptor object describing this protocol service type. """ super(GeneratedServiceStubType, cls).__init__(name, bases, dictionary) # Don't do anything if this class doesn't have a descriptor. This happens # when a service stub is subclassed. if GeneratedServiceStubType._DESCRIPTOR_KEY not in dictionary: return descriptor = dictionary[GeneratedServiceStubType._DESCRIPTOR_KEY] service_stub_builder = _ServiceStubBuilder(descriptor) service_stub_builder.BuildServiceStub(cls) class _ServiceBuilder(object): """This class constructs a protocol service class using a service descriptor. Given a service descriptor, this class constructs a class that represents the specified service descriptor. One service builder instance constructs exactly one service class. That means all instances of that class share the same builder. """ def __init__(self, service_descriptor): """Initializes an instance of the service class builder. Args: service_descriptor: ServiceDescriptor to use when constructing the service class. """ self.descriptor = service_descriptor def BuildService(builder, cls): """Constructs the service class. Args: cls: The class that will be constructed. """ # CallMethod needs to operate with an instance of the Service class. This # internal wrapper function exists only to be able to pass the service # instance to the method that does the real CallMethod work. # Making sure to use exact argument names from the abstract interface in # service.py to match the type signature def _WrapCallMethod(self, method_descriptor, rpc_controller, request, done): return builder._CallMethod(self, method_descriptor, rpc_controller, request, done) def _WrapGetRequestClass(self, method_descriptor): return builder._GetRequestClass(method_descriptor) def _WrapGetResponseClass(self, method_descriptor): return builder._GetResponseClass(method_descriptor) builder.cls = cls cls.CallMethod = _WrapCallMethod cls.GetDescriptor = staticmethod(lambda: builder.descriptor) cls.GetDescriptor.__doc__ = 'Returns the service descriptor.' cls.GetRequestClass = _WrapGetRequestClass cls.GetResponseClass = _WrapGetResponseClass for method in builder.descriptor.methods: setattr(cls, method.name, builder._GenerateNonImplementedMethod(method)) def _CallMethod(self, srvc, method_descriptor, rpc_controller, request, callback): """Calls the method described by a given method descriptor. Args: srvc: Instance of the service for which this method is called. method_descriptor: Descriptor that represent the method to call. rpc_controller: RPC controller to use for this method's execution. request: Request protocol message. callback: A callback to invoke after the method has completed. """ if method_descriptor.containing_service != self.descriptor: raise RuntimeError( 'CallMethod() given method descriptor for wrong service type.') method = getattr(srvc, method_descriptor.name) return method(rpc_controller, request, callback) def _GetRequestClass(self, method_descriptor): """Returns the class of the request protocol message. Args: method_descriptor: Descriptor of the method for which to return the request protocol message class. Returns: A class that represents the input protocol message of the specified method. """ if method_descriptor.containing_service != self.descriptor: raise RuntimeError( 'GetRequestClass() given method descriptor for wrong service type.') return method_descriptor.input_type._concrete_class def _GetResponseClass(self, method_descriptor): """Returns the class of the response protocol message. Args: method_descriptor: Descriptor of the method for which to return the response protocol message class. Returns: A class that represents the output protocol message of the specified method. """ if method_descriptor.containing_service != self.descriptor: raise RuntimeError( 'GetResponseClass() given method descriptor for wrong service type.') return method_descriptor.output_type._concrete_class def _GenerateNonImplementedMethod(self, method): """Generates and returns a method that can be set for a service methods. Args: method: Descriptor of the service method for which a method is to be generated. Returns: A method that can be added to the service class. """ return lambda inst, rpc_controller, request, callback: ( self._NonImplementedMethod(method.name, rpc_controller, callback)) def _NonImplementedMethod(self, method_name, rpc_controller, callback): """The body of all methods in the generated service class. Args: method_name: Name of the method being executed. rpc_controller: RPC controller used to execute this method. callback: A callback which will be invoked when the method finishes. """ rpc_controller.SetFailed('Method %s not implemented.' % method_name) callback(None) class _ServiceStubBuilder(object): """Constructs a protocol service stub class using a service descriptor. Given a service descriptor, this class constructs a suitable stub class. A stub is just a type-safe wrapper around an RpcChannel which emulates a local implementation of the service. One service stub builder instance constructs exactly one class. It means all instances of that class share the same service stub builder. """ def __init__(self, service_descriptor): """Initializes an instance of the service stub class builder. Args: service_descriptor: ServiceDescriptor to use when constructing the stub class. """ self.descriptor = service_descriptor def BuildServiceStub(self, cls): """Constructs the stub class. Args: cls: The class that will be constructed. """ def _ServiceStubInit(stub, rpc_channel): stub.rpc_channel = rpc_channel self.cls = cls cls.__init__ = _ServiceStubInit for method in self.descriptor.methods: setattr(cls, method.name, self._GenerateStubMethod(method)) def _GenerateStubMethod(self, method): return (lambda inst, rpc_controller, request, callback=None: self._StubMethod(inst, method, rpc_controller, request, callback)) def _StubMethod(self, stub, method_descriptor, rpc_controller, request, callback): """The body of all service methods in the generated stub class. Args: stub: Stub instance. method_descriptor: Descriptor of the invoked method. rpc_controller: Rpc controller to execute the method. request: Request protocol message. callback: A callback to execute when the method finishes. Returns: Response message (in case of blocking call). """ return stub.rpc_channel.CallMethod( method_descriptor, rpc_controller, request, method_descriptor.output_type._concrete_class, callback) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/source_context_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/source_context.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$google/protobuf/source_context.proto\x12\x0fgoogle.protobuf\"\"\n\rSourceContext\x12\x11\n\tfile_name\x18\x01 \x01(\tB\x8a\x01\n\x13\x63om.google.protobufB\x12SourceContextProtoP\x01Z6google.golang.org/protobuf/types/known/sourcecontextpb\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.source_context_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\022SourceContextProtoP\001Z6google.golang.org/protobuf/types/known/sourcecontextpb\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _SOURCECONTEXT._serialized_start=57 _SOURCECONTEXT._serialized_end=91 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/struct_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/struct.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cgoogle/protobuf/struct.proto\x12\x0fgoogle.protobuf\"\x84\x01\n\x06Struct\x12\x33\n\x06\x66ields\x18\x01 \x03(\x0b\x32#.google.protobuf.Struct.FieldsEntry\x1a\x45\n\x0b\x46ieldsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12%\n\x05value\x18\x02 \x01(\x0b\x32\x16.google.protobuf.Value:\x02\x38\x01\"\xea\x01\n\x05Value\x12\x30\n\nnull_value\x18\x01 \x01(\x0e\x32\x1a.google.protobuf.NullValueH\x00\x12\x16\n\x0cnumber_value\x18\x02 \x01(\x01H\x00\x12\x16\n\x0cstring_value\x18\x03 \x01(\tH\x00\x12\x14\n\nbool_value\x18\x04 \x01(\x08H\x00\x12/\n\x0cstruct_value\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x12\x30\n\nlist_value\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.ListValueH\x00\x42\x06\n\x04kind\"3\n\tListValue\x12&\n\x06values\x18\x01 \x03(\x0b\x32\x16.google.protobuf.Value*\x1b\n\tNullValue\x12\x0e\n\nNULL_VALUE\x10\x00\x42\x7f\n\x13\x63om.google.protobufB\x0bStructProtoP\x01Z/google.golang.org/protobuf/types/known/structpb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.struct_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\013StructProtoP\001Z/google.golang.org/protobuf/types/known/structpb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _STRUCT_FIELDSENTRY._options = None _STRUCT_FIELDSENTRY._serialized_options = b'8\001' _NULLVALUE._serialized_start=474 _NULLVALUE._serialized_end=501 _STRUCT._serialized_start=50 _STRUCT._serialized_end=182 _STRUCT_FIELDSENTRY._serialized_start=113 _STRUCT_FIELDSENTRY._serialized_end=182 _VALUE._serialized_start=185 _VALUE._serialized_end=419 _LISTVALUE._serialized_start=421 _LISTVALUE._serialized_end=472 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/symbol_database.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """A database of Python protocol buffer generated symbols. SymbolDatabase is the MessageFactory for messages generated at compile time, and makes it easy to create new instances of a registered type, given only the type's protocol buffer symbol name. Example usage:: db = symbol_database.SymbolDatabase() # Register symbols of interest, from one or multiple files. db.RegisterFileDescriptor(my_proto_pb2.DESCRIPTOR) db.RegisterMessage(my_proto_pb2.MyMessage) db.RegisterEnumDescriptor(my_proto_pb2.MyEnum.DESCRIPTOR) # The database can be used as a MessageFactory, to generate types based on # their name: types = db.GetMessages(['my_proto.proto']) my_message_instance = types['MyMessage']() # The database's underlying descriptor pool can be queried, so it's not # necessary to know a type's filename to be able to generate it: filename = db.pool.FindFileContainingSymbol('MyMessage') my_message_instance = db.GetMessages([filename])['MyMessage']() # This functionality is also provided directly via a convenience method: my_message_instance = db.GetSymbol('MyMessage')() """ from google.protobuf.internal import api_implementation from google.protobuf import descriptor_pool from google.protobuf import message_factory class SymbolDatabase(message_factory.MessageFactory): """A database of Python generated symbols.""" def RegisterMessage(self, message): """Registers the given message type in the local database. Calls to GetSymbol() and GetMessages() will return messages registered here. Args: message: A :class:`google.protobuf.message.Message` subclass (or instance); its descriptor will be registered. Returns: The provided message. """ desc = message.DESCRIPTOR self._classes[desc] = message self.RegisterMessageDescriptor(desc) return message def RegisterMessageDescriptor(self, message_descriptor): """Registers the given message descriptor in the local database. Args: message_descriptor (Descriptor): the message descriptor to add. """ if api_implementation.Type() == 'python': # pylint: disable=protected-access self.pool._AddDescriptor(message_descriptor) def RegisterEnumDescriptor(self, enum_descriptor): """Registers the given enum descriptor in the local database. Args: enum_descriptor (EnumDescriptor): The enum descriptor to register. Returns: EnumDescriptor: The provided descriptor. """ if api_implementation.Type() == 'python': # pylint: disable=protected-access self.pool._AddEnumDescriptor(enum_descriptor) return enum_descriptor def RegisterServiceDescriptor(self, service_descriptor): """Registers the given service descriptor in the local database. Args: service_descriptor (ServiceDescriptor): the service descriptor to register. """ if api_implementation.Type() == 'python': # pylint: disable=protected-access self.pool._AddServiceDescriptor(service_descriptor) def RegisterFileDescriptor(self, file_descriptor): """Registers the given file descriptor in the local database. Args: file_descriptor (FileDescriptor): The file descriptor to register. """ if api_implementation.Type() == 'python': # pylint: disable=protected-access self.pool._InternalAddFileDescriptor(file_descriptor) def GetSymbol(self, symbol): """Tries to find a symbol in the local database. Currently, this method only returns message.Message instances, however, if may be extended in future to support other symbol types. Args: symbol (str): a protocol buffer symbol. Returns: A Python class corresponding to the symbol. Raises: KeyError: if the symbol could not be found. """ return self._classes[self.pool.FindMessageTypeByName(symbol)] def GetMessages(self, files): # TODO(amauryfa): Fix the differences with MessageFactory. """Gets all registered messages from a specified file. Only messages already created and registered will be returned; (this is the case for imported _pb2 modules) But unlike MessageFactory, this version also returns already defined nested messages, but does not register any message extensions. Args: files (list[str]): The file names to extract messages from. Returns: A dictionary mapping proto names to the message classes. Raises: KeyError: if a file could not be found. """ def _GetAllMessages(desc): """Walk a message Descriptor and recursively yields all message names.""" yield desc for msg_desc in desc.nested_types: for nested_desc in _GetAllMessages(msg_desc): yield nested_desc result = {} for file_name in files: file_desc = self.pool.FindFileByName(file_name) for msg_desc in file_desc.message_types_by_name.values(): for desc in _GetAllMessages(msg_desc): try: result[desc.full_name] = self._classes[desc] except KeyError: # This descriptor has no registered class, skip it. pass return result _DEFAULT = SymbolDatabase(pool=descriptor_pool.Default()) def Default(): """Returns the default SymbolDatabase.""" return _DEFAULT ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/text_encoding.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Encoding related utilities.""" import re _cescape_chr_to_symbol_map = {} _cescape_chr_to_symbol_map[9] = r'\t' # optional escape _cescape_chr_to_symbol_map[10] = r'\n' # optional escape _cescape_chr_to_symbol_map[13] = r'\r' # optional escape _cescape_chr_to_symbol_map[34] = r'\"' # necessary escape _cescape_chr_to_symbol_map[39] = r"\'" # optional escape _cescape_chr_to_symbol_map[92] = r'\\' # necessary escape # Lookup table for unicode _cescape_unicode_to_str = [chr(i) for i in range(0, 256)] for byte, string in _cescape_chr_to_symbol_map.items(): _cescape_unicode_to_str[byte] = string # Lookup table for non-utf8, with necessary escapes at (o >= 127 or o < 32) _cescape_byte_to_str = ([r'\%03o' % i for i in range(0, 32)] + [chr(i) for i in range(32, 127)] + [r'\%03o' % i for i in range(127, 256)]) for byte, string in _cescape_chr_to_symbol_map.items(): _cescape_byte_to_str[byte] = string del byte, string def CEscape(text, as_utf8): # type: (...) -> str """Escape a bytes string for use in an text protocol buffer. Args: text: A byte string to be escaped. as_utf8: Specifies if result may contain non-ASCII characters. In Python 3 this allows unescaped non-ASCII Unicode characters. In Python 2 the return value will be valid UTF-8 rather than only ASCII. Returns: Escaped string (str). """ # Python's text.encode() 'string_escape' or 'unicode_escape' codecs do not # satisfy our needs; they encodes unprintable characters using two-digit hex # escapes whereas our C++ unescaping function allows hex escapes to be any # length. So, "\0011".encode('string_escape') ends up being "\\x011", which # will be decoded in C++ as a single-character string with char code 0x11. text_is_unicode = isinstance(text, str) if as_utf8 and text_is_unicode: # We're already unicode, no processing beyond control char escapes. return text.translate(_cescape_chr_to_symbol_map) ord_ = ord if text_is_unicode else lambda x: x # bytes iterate as ints. if as_utf8: return ''.join(_cescape_unicode_to_str[ord_(c)] for c in text) return ''.join(_cescape_byte_to_str[ord_(c)] for c in text) _CUNESCAPE_HEX = re.compile(r'(\\+)x([0-9a-fA-F])(?![0-9a-fA-F])') def CUnescape(text): # type: (str) -> bytes """Unescape a text string with C-style escape sequences to UTF-8 bytes. Args: text: The data to parse in a str. Returns: A byte string. """ def ReplaceHex(m): # Only replace the match if the number of leading back slashes is odd. i.e. # the slash itself is not escaped. if len(m.group(1)) & 1: return m.group(1) + 'x0' + m.group(2) return m.group(0) # This is required because the 'string_escape' encoding doesn't # allow single-digit hex escapes (like '\xf'). result = _CUNESCAPE_HEX.sub(ReplaceHex, text) return (result.encode('utf-8') # Make it bytes to allow decode. .decode('unicode_escape') # Make it bytes again to return the proper type. .encode('raw_unicode_escape')) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/text_format.py ================================================ # Protocol Buffers - Google's data interchange format # Copyright 2008 Google Inc. All rights reserved. # https://developers.google.com/protocol-buffers/ # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Contains routines for printing protocol messages in text format. Simple usage example:: # Create a proto object and serialize it to a text proto string. message = my_proto_pb2.MyMessage(foo='bar') text_proto = text_format.MessageToString(message) # Parse a text proto string. message = text_format.Parse(text_proto, my_proto_pb2.MyMessage()) """ __author__ = 'kenton@google.com (Kenton Varda)' # TODO(b/129989314) Import thread contention leads to test failures. import encodings.raw_unicode_escape # pylint: disable=unused-import import encodings.unicode_escape # pylint: disable=unused-import import io import math import re from google.protobuf.internal import decoder from google.protobuf.internal import type_checkers from google.protobuf import descriptor from google.protobuf import text_encoding # pylint: disable=g-import-not-at-top __all__ = ['MessageToString', 'Parse', 'PrintMessage', 'PrintField', 'PrintFieldValue', 'Merge', 'MessageToBytes'] _INTEGER_CHECKERS = (type_checkers.Uint32ValueChecker(), type_checkers.Int32ValueChecker(), type_checkers.Uint64ValueChecker(), type_checkers.Int64ValueChecker()) _FLOAT_INFINITY = re.compile('-?inf(?:inity)?f?$', re.IGNORECASE) _FLOAT_NAN = re.compile('nanf?$', re.IGNORECASE) _QUOTES = frozenset(("'", '"')) _ANY_FULL_TYPE_NAME = 'google.protobuf.Any' class Error(Exception): """Top-level module error for text_format.""" class ParseError(Error): """Thrown in case of text parsing or tokenizing error.""" def __init__(self, message=None, line=None, column=None): if message is not None and line is not None: loc = str(line) if column is not None: loc += ':{0}'.format(column) message = '{0} : {1}'.format(loc, message) if message is not None: super(ParseError, self).__init__(message) else: super(ParseError, self).__init__() self._line = line self._column = column def GetLine(self): return self._line def GetColumn(self): return self._column class TextWriter(object): def __init__(self, as_utf8): self._writer = io.StringIO() def write(self, val): return self._writer.write(val) def close(self): return self._writer.close() def getvalue(self): return self._writer.getvalue() def MessageToString( message, as_utf8=False, as_one_line=False, use_short_repeated_primitives=False, pointy_brackets=False, use_index_order=False, float_format=None, double_format=None, use_field_number=False, descriptor_pool=None, indent=0, message_formatter=None, print_unknown_fields=False, force_colon=False): # type: (...) -> str """Convert protobuf message to text format. Double values can be formatted compactly with 15 digits of precision (which is the most that IEEE 754 "double" can guarantee) using double_format='.15g'. To ensure that converting to text and back to a proto will result in an identical value, double_format='.17g' should be used. Args: message: The protocol buffers message. as_utf8: Return unescaped Unicode for non-ASCII characters. In Python 3 actual Unicode characters may appear as is in strings. In Python 2 the return value will be valid UTF-8 rather than only ASCII. as_one_line: Don't introduce newlines between fields. use_short_repeated_primitives: Use short repeated format for primitives. pointy_brackets: If True, use angle brackets instead of curly braces for nesting. use_index_order: If True, fields of a proto message will be printed using the order defined in source code instead of the field number, extensions will be printed at the end of the message and their relative order is determined by the extension number. By default, use the field number order. float_format (str): If set, use this to specify float field formatting (per the "Format Specification Mini-Language"); otherwise, shortest float that has same value in wire will be printed. Also affect double field if double_format is not set but float_format is set. double_format (str): If set, use this to specify double field formatting (per the "Format Specification Mini-Language"); if it is not set but float_format is set, use float_format. Otherwise, use ``str()`` use_field_number: If True, print field numbers instead of names. descriptor_pool (DescriptorPool): Descriptor pool used to resolve Any types. indent (int): The initial indent level, in terms of spaces, for pretty print. message_formatter (function(message, indent, as_one_line) -> unicode|None): Custom formatter for selected sub-messages (usually based on message type). Use to pretty print parts of the protobuf for easier diffing. print_unknown_fields: If True, unknown fields will be printed. force_colon: If set, a colon will be added after the field name even if the field is a proto message. Returns: str: A string of the text formatted protocol buffer message. """ out = TextWriter(as_utf8) printer = _Printer( out, indent, as_utf8, as_one_line, use_short_repeated_primitives, pointy_brackets, use_index_order, float_format, double_format, use_field_number, descriptor_pool, message_formatter, print_unknown_fields=print_unknown_fields, force_colon=force_colon) printer.PrintMessage(message) result = out.getvalue() out.close() if as_one_line: return result.rstrip() return result def MessageToBytes(message, **kwargs): # type: (...) -> bytes """Convert protobuf message to encoded text format. See MessageToString.""" text = MessageToString(message, **kwargs) if isinstance(text, bytes): return text codec = 'utf-8' if kwargs.get('as_utf8') else 'ascii' return text.encode(codec) def _IsMapEntry(field): return (field.type == descriptor.FieldDescriptor.TYPE_MESSAGE and field.message_type.has_options and field.message_type.GetOptions().map_entry) def PrintMessage(message, out, indent=0, as_utf8=False, as_one_line=False, use_short_repeated_primitives=False, pointy_brackets=False, use_index_order=False, float_format=None, double_format=None, use_field_number=False, descriptor_pool=None, message_formatter=None, print_unknown_fields=False, force_colon=False): printer = _Printer( out=out, indent=indent, as_utf8=as_utf8, as_one_line=as_one_line, use_short_repeated_primitives=use_short_repeated_primitives, pointy_brackets=pointy_brackets, use_index_order=use_index_order, float_format=float_format, double_format=double_format, use_field_number=use_field_number, descriptor_pool=descriptor_pool, message_formatter=message_formatter, print_unknown_fields=print_unknown_fields, force_colon=force_colon) printer.PrintMessage(message) def PrintField(field, value, out, indent=0, as_utf8=False, as_one_line=False, use_short_repeated_primitives=False, pointy_brackets=False, use_index_order=False, float_format=None, double_format=None, message_formatter=None, print_unknown_fields=False, force_colon=False): """Print a single field name/value pair.""" printer = _Printer(out, indent, as_utf8, as_one_line, use_short_repeated_primitives, pointy_brackets, use_index_order, float_format, double_format, message_formatter=message_formatter, print_unknown_fields=print_unknown_fields, force_colon=force_colon) printer.PrintField(field, value) def PrintFieldValue(field, value, out, indent=0, as_utf8=False, as_one_line=False, use_short_repeated_primitives=False, pointy_brackets=False, use_index_order=False, float_format=None, double_format=None, message_formatter=None, print_unknown_fields=False, force_colon=False): """Print a single field value (not including name).""" printer = _Printer(out, indent, as_utf8, as_one_line, use_short_repeated_primitives, pointy_brackets, use_index_order, float_format, double_format, message_formatter=message_formatter, print_unknown_fields=print_unknown_fields, force_colon=force_colon) printer.PrintFieldValue(field, value) def _BuildMessageFromTypeName(type_name, descriptor_pool): """Returns a protobuf message instance. Args: type_name: Fully-qualified protobuf message type name string. descriptor_pool: DescriptorPool instance. Returns: A Message instance of type matching type_name, or None if the a Descriptor wasn't found matching type_name. """ # pylint: disable=g-import-not-at-top if descriptor_pool is None: from google.protobuf import descriptor_pool as pool_mod descriptor_pool = pool_mod.Default() from google.protobuf import symbol_database database = symbol_database.Default() try: message_descriptor = descriptor_pool.FindMessageTypeByName(type_name) except KeyError: return None message_type = database.GetPrototype(message_descriptor) return message_type() # These values must match WireType enum in google/protobuf/wire_format.h. WIRETYPE_LENGTH_DELIMITED = 2 WIRETYPE_START_GROUP = 3 class _Printer(object): """Text format printer for protocol message.""" def __init__( self, out, indent=0, as_utf8=False, as_one_line=False, use_short_repeated_primitives=False, pointy_brackets=False, use_index_order=False, float_format=None, double_format=None, use_field_number=False, descriptor_pool=None, message_formatter=None, print_unknown_fields=False, force_colon=False): """Initialize the Printer. Double values can be formatted compactly with 15 digits of precision (which is the most that IEEE 754 "double" can guarantee) using double_format='.15g'. To ensure that converting to text and back to a proto will result in an identical value, double_format='.17g' should be used. Args: out: To record the text format result. indent: The initial indent level for pretty print. as_utf8: Return unescaped Unicode for non-ASCII characters. In Python 3 actual Unicode characters may appear as is in strings. In Python 2 the return value will be valid UTF-8 rather than ASCII. as_one_line: Don't introduce newlines between fields. use_short_repeated_primitives: Use short repeated format for primitives. pointy_brackets: If True, use angle brackets instead of curly braces for nesting. use_index_order: If True, print fields of a proto message using the order defined in source code instead of the field number. By default, use the field number order. float_format: If set, use this to specify float field formatting (per the "Format Specification Mini-Language"); otherwise, shortest float that has same value in wire will be printed. Also affect double field if double_format is not set but float_format is set. double_format: If set, use this to specify double field formatting (per the "Format Specification Mini-Language"); if it is not set but float_format is set, use float_format. Otherwise, str() is used. use_field_number: If True, print field numbers instead of names. descriptor_pool: A DescriptorPool used to resolve Any types. message_formatter: A function(message, indent, as_one_line): unicode|None to custom format selected sub-messages (usually based on message type). Use to pretty print parts of the protobuf for easier diffing. print_unknown_fields: If True, unknown fields will be printed. force_colon: If set, a colon will be added after the field name even if the field is a proto message. """ self.out = out self.indent = indent self.as_utf8 = as_utf8 self.as_one_line = as_one_line self.use_short_repeated_primitives = use_short_repeated_primitives self.pointy_brackets = pointy_brackets self.use_index_order = use_index_order self.float_format = float_format if double_format is not None: self.double_format = double_format else: self.double_format = float_format self.use_field_number = use_field_number self.descriptor_pool = descriptor_pool self.message_formatter = message_formatter self.print_unknown_fields = print_unknown_fields self.force_colon = force_colon def _TryPrintAsAnyMessage(self, message): """Serializes if message is a google.protobuf.Any field.""" if '/' not in message.type_url: return False packed_message = _BuildMessageFromTypeName(message.TypeName(), self.descriptor_pool) if packed_message: packed_message.MergeFromString(message.value) colon = ':' if self.force_colon else '' self.out.write('%s[%s]%s ' % (self.indent * ' ', message.type_url, colon)) self._PrintMessageFieldValue(packed_message) self.out.write(' ' if self.as_one_line else '\n') return True else: return False def _TryCustomFormatMessage(self, message): formatted = self.message_formatter(message, self.indent, self.as_one_line) if formatted is None: return False out = self.out out.write(' ' * self.indent) out.write(formatted) out.write(' ' if self.as_one_line else '\n') return True def PrintMessage(self, message): """Convert protobuf message to text format. Args: message: The protocol buffers message. """ if self.message_formatter and self._TryCustomFormatMessage(message): return if (message.DESCRIPTOR.full_name == _ANY_FULL_TYPE_NAME and self._TryPrintAsAnyMessage(message)): return fields = message.ListFields() if self.use_index_order: fields.sort( key=lambda x: x[0].number if x[0].is_extension else x[0].index) for field, value in fields: if _IsMapEntry(field): for key in sorted(value): # This is slow for maps with submessage entries because it copies the # entire tree. Unfortunately this would take significant refactoring # of this file to work around. # # TODO(haberman): refactor and optimize if this becomes an issue. entry_submsg = value.GetEntryClass()(key=key, value=value[key]) self.PrintField(field, entry_submsg) elif field.label == descriptor.FieldDescriptor.LABEL_REPEATED: if (self.use_short_repeated_primitives and field.cpp_type != descriptor.FieldDescriptor.CPPTYPE_MESSAGE and field.cpp_type != descriptor.FieldDescriptor.CPPTYPE_STRING): self._PrintShortRepeatedPrimitivesValue(field, value) else: for element in value: self.PrintField(field, element) else: self.PrintField(field, value) if self.print_unknown_fields: self._PrintUnknownFields(message.UnknownFields()) def _PrintUnknownFields(self, unknown_fields): """Print unknown fields.""" out = self.out for field in unknown_fields: out.write(' ' * self.indent) out.write(str(field.field_number)) if field.wire_type == WIRETYPE_START_GROUP: if self.as_one_line: out.write(' { ') else: out.write(' {\n') self.indent += 2 self._PrintUnknownFields(field.data) if self.as_one_line: out.write('} ') else: self.indent -= 2 out.write(' ' * self.indent + '}\n') elif field.wire_type == WIRETYPE_LENGTH_DELIMITED: try: # If this field is parseable as a Message, it is probably # an embedded message. # pylint: disable=protected-access (embedded_unknown_message, pos) = decoder._DecodeUnknownFieldSet( memoryview(field.data), 0, len(field.data)) except Exception: # pylint: disable=broad-except pos = 0 if pos == len(field.data): if self.as_one_line: out.write(' { ') else: out.write(' {\n') self.indent += 2 self._PrintUnknownFields(embedded_unknown_message) if self.as_one_line: out.write('} ') else: self.indent -= 2 out.write(' ' * self.indent + '}\n') else: # A string or bytes field. self.as_utf8 may not work. out.write(': \"') out.write(text_encoding.CEscape(field.data, False)) out.write('\" ' if self.as_one_line else '\"\n') else: # varint, fixed32, fixed64 out.write(': ') out.write(str(field.data)) out.write(' ' if self.as_one_line else '\n') def _PrintFieldName(self, field): """Print field name.""" out = self.out out.write(' ' * self.indent) if self.use_field_number: out.write(str(field.number)) else: if field.is_extension: out.write('[') if (field.containing_type.GetOptions().message_set_wire_format and field.type == descriptor.FieldDescriptor.TYPE_MESSAGE and field.label == descriptor.FieldDescriptor.LABEL_OPTIONAL): out.write(field.message_type.full_name) else: out.write(field.full_name) out.write(']') elif field.type == descriptor.FieldDescriptor.TYPE_GROUP: # For groups, use the capitalized name. out.write(field.message_type.name) else: out.write(field.name) if (self.force_colon or field.cpp_type != descriptor.FieldDescriptor.CPPTYPE_MESSAGE): # The colon is optional in this case, but our cross-language golden files # don't include it. Here, the colon is only included if force_colon is # set to True out.write(':') def PrintField(self, field, value): """Print a single field name/value pair.""" self._PrintFieldName(field) self.out.write(' ') self.PrintFieldValue(field, value) self.out.write(' ' if self.as_one_line else '\n') def _PrintShortRepeatedPrimitivesValue(self, field, value): """"Prints short repeated primitives value.""" # Note: this is called only when value has at least one element. self._PrintFieldName(field) self.out.write(' [') for i in range(len(value) - 1): self.PrintFieldValue(field, value[i]) self.out.write(', ') self.PrintFieldValue(field, value[-1]) self.out.write(']') self.out.write(' ' if self.as_one_line else '\n') def _PrintMessageFieldValue(self, value): if self.pointy_brackets: openb = '<' closeb = '>' else: openb = '{' closeb = '}' if self.as_one_line: self.out.write('%s ' % openb) self.PrintMessage(value) self.out.write(closeb) else: self.out.write('%s\n' % openb) self.indent += 2 self.PrintMessage(value) self.indent -= 2 self.out.write(' ' * self.indent + closeb) def PrintFieldValue(self, field, value): """Print a single field value (not including name). For repeated fields, the value should be a single element. Args: field: The descriptor of the field to be printed. value: The value of the field. """ out = self.out if field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_MESSAGE: self._PrintMessageFieldValue(value) elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_ENUM: enum_value = field.enum_type.values_by_number.get(value, None) if enum_value is not None: out.write(enum_value.name) else: out.write(str(value)) elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_STRING: out.write('\"') if isinstance(value, str) and not self.as_utf8: out_value = value.encode('utf-8') else: out_value = value if field.type == descriptor.FieldDescriptor.TYPE_BYTES: # We always need to escape all binary data in TYPE_BYTES fields. out_as_utf8 = False else: out_as_utf8 = self.as_utf8 out.write(text_encoding.CEscape(out_value, out_as_utf8)) out.write('\"') elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_BOOL: if value: out.write('true') else: out.write('false') elif field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_FLOAT: if self.float_format is not None: out.write('{1:{0}}'.format(self.float_format, value)) else: if math.isnan(value): out.write(str(value)) else: out.write(str(type_checkers.ToShortestFloat(value))) elif (field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_DOUBLE and self.double_format is not None): out.write('{1:{0}}'.format(self.double_format, value)) else: out.write(str(value)) def Parse(text, message, allow_unknown_extension=False, allow_field_number=False, descriptor_pool=None, allow_unknown_field=False): """Parses a text representation of a protocol message into a message. NOTE: for historical reasons this function does not clear the input message. This is different from what the binary msg.ParseFrom(...) does. If text contains a field already set in message, the value is appended if the field is repeated. Otherwise, an error is raised. Example:: a = MyProto() a.repeated_field.append('test') b = MyProto() # Repeated fields are combined text_format.Parse(repr(a), b) text_format.Parse(repr(a), b) # repeated_field contains ["test", "test"] # Non-repeated fields cannot be overwritten a.singular_field = 1 b.singular_field = 2 text_format.Parse(repr(a), b) # ParseError # Binary version: b.ParseFromString(a.SerializeToString()) # repeated_field is now "test" Caller is responsible for clearing the message as needed. Args: text (str): Message text representation. message (Message): A protocol buffer message to merge into. allow_unknown_extension: if True, skip over missing extensions and keep parsing allow_field_number: if True, both field number and field name are allowed. descriptor_pool (DescriptorPool): Descriptor pool used to resolve Any types. allow_unknown_field: if True, skip over unknown field and keep parsing. Avoid to use this option if possible. It may hide some errors (e.g. spelling error on field name) Returns: Message: The same message passed as argument. Raises: ParseError: On text parsing problems. """ return ParseLines(text.split(b'\n' if isinstance(text, bytes) else u'\n'), message, allow_unknown_extension, allow_field_number, descriptor_pool=descriptor_pool, allow_unknown_field=allow_unknown_field) def Merge(text, message, allow_unknown_extension=False, allow_field_number=False, descriptor_pool=None, allow_unknown_field=False): """Parses a text representation of a protocol message into a message. Like Parse(), but allows repeated values for a non-repeated field, and uses the last one. This means any non-repeated, top-level fields specified in text replace those in the message. Args: text (str): Message text representation. message (Message): A protocol buffer message to merge into. allow_unknown_extension: if True, skip over missing extensions and keep parsing allow_field_number: if True, both field number and field name are allowed. descriptor_pool (DescriptorPool): Descriptor pool used to resolve Any types. allow_unknown_field: if True, skip over unknown field and keep parsing. Avoid to use this option if possible. It may hide some errors (e.g. spelling error on field name) Returns: Message: The same message passed as argument. Raises: ParseError: On text parsing problems. """ return MergeLines( text.split(b'\n' if isinstance(text, bytes) else u'\n'), message, allow_unknown_extension, allow_field_number, descriptor_pool=descriptor_pool, allow_unknown_field=allow_unknown_field) def ParseLines(lines, message, allow_unknown_extension=False, allow_field_number=False, descriptor_pool=None, allow_unknown_field=False): """Parses a text representation of a protocol message into a message. See Parse() for caveats. Args: lines: An iterable of lines of a message's text representation. message: A protocol buffer message to merge into. allow_unknown_extension: if True, skip over missing extensions and keep parsing allow_field_number: if True, both field number and field name are allowed. descriptor_pool: A DescriptorPool used to resolve Any types. allow_unknown_field: if True, skip over unknown field and keep parsing. Avoid to use this option if possible. It may hide some errors (e.g. spelling error on field name) Returns: The same message passed as argument. Raises: ParseError: On text parsing problems. """ parser = _Parser(allow_unknown_extension, allow_field_number, descriptor_pool=descriptor_pool, allow_unknown_field=allow_unknown_field) return parser.ParseLines(lines, message) def MergeLines(lines, message, allow_unknown_extension=False, allow_field_number=False, descriptor_pool=None, allow_unknown_field=False): """Parses a text representation of a protocol message into a message. See Merge() for more details. Args: lines: An iterable of lines of a message's text representation. message: A protocol buffer message to merge into. allow_unknown_extension: if True, skip over missing extensions and keep parsing allow_field_number: if True, both field number and field name are allowed. descriptor_pool: A DescriptorPool used to resolve Any types. allow_unknown_field: if True, skip over unknown field and keep parsing. Avoid to use this option if possible. It may hide some errors (e.g. spelling error on field name) Returns: The same message passed as argument. Raises: ParseError: On text parsing problems. """ parser = _Parser(allow_unknown_extension, allow_field_number, descriptor_pool=descriptor_pool, allow_unknown_field=allow_unknown_field) return parser.MergeLines(lines, message) class _Parser(object): """Text format parser for protocol message.""" def __init__(self, allow_unknown_extension=False, allow_field_number=False, descriptor_pool=None, allow_unknown_field=False): self.allow_unknown_extension = allow_unknown_extension self.allow_field_number = allow_field_number self.descriptor_pool = descriptor_pool self.allow_unknown_field = allow_unknown_field def ParseLines(self, lines, message): """Parses a text representation of a protocol message into a message.""" self._allow_multiple_scalars = False self._ParseOrMerge(lines, message) return message def MergeLines(self, lines, message): """Merges a text representation of a protocol message into a message.""" self._allow_multiple_scalars = True self._ParseOrMerge(lines, message) return message def _ParseOrMerge(self, lines, message): """Converts a text representation of a protocol message into a message. Args: lines: Lines of a message's text representation. message: A protocol buffer message to merge into. Raises: ParseError: On text parsing problems. """ # Tokenize expects native str lines. str_lines = ( line if isinstance(line, str) else line.decode('utf-8') for line in lines) tokenizer = Tokenizer(str_lines) while not tokenizer.AtEnd(): self._MergeField(tokenizer, message) def _MergeField(self, tokenizer, message): """Merges a single protocol message field into a message. Args: tokenizer: A tokenizer to parse the field name and values. message: A protocol message to record the data. Raises: ParseError: In case of text parsing problems. """ message_descriptor = message.DESCRIPTOR if (message_descriptor.full_name == _ANY_FULL_TYPE_NAME and tokenizer.TryConsume('[')): type_url_prefix, packed_type_name = self._ConsumeAnyTypeUrl(tokenizer) tokenizer.Consume(']') tokenizer.TryConsume(':') if tokenizer.TryConsume('<'): expanded_any_end_token = '>' else: tokenizer.Consume('{') expanded_any_end_token = '}' expanded_any_sub_message = _BuildMessageFromTypeName(packed_type_name, self.descriptor_pool) if not expanded_any_sub_message: raise ParseError('Type %s not found in descriptor pool' % packed_type_name) while not tokenizer.TryConsume(expanded_any_end_token): if tokenizer.AtEnd(): raise tokenizer.ParseErrorPreviousToken('Expected "%s".' % (expanded_any_end_token,)) self._MergeField(tokenizer, expanded_any_sub_message) deterministic = False message.Pack(expanded_any_sub_message, type_url_prefix=type_url_prefix, deterministic=deterministic) return if tokenizer.TryConsume('['): name = [tokenizer.ConsumeIdentifier()] while tokenizer.TryConsume('.'): name.append(tokenizer.ConsumeIdentifier()) name = '.'.join(name) if not message_descriptor.is_extendable: raise tokenizer.ParseErrorPreviousToken( 'Message type "%s" does not have extensions.' % message_descriptor.full_name) # pylint: disable=protected-access field = message.Extensions._FindExtensionByName(name) # pylint: enable=protected-access if not field: if self.allow_unknown_extension: field = None else: raise tokenizer.ParseErrorPreviousToken( 'Extension "%s" not registered. ' 'Did you import the _pb2 module which defines it? ' 'If you are trying to place the extension in the MessageSet ' 'field of another message that is in an Any or MessageSet field, ' 'that message\'s _pb2 module must be imported as well' % name) elif message_descriptor != field.containing_type: raise tokenizer.ParseErrorPreviousToken( 'Extension "%s" does not extend message type "%s".' % (name, message_descriptor.full_name)) tokenizer.Consume(']') else: name = tokenizer.ConsumeIdentifierOrNumber() if self.allow_field_number and name.isdigit(): number = ParseInteger(name, True, True) field = message_descriptor.fields_by_number.get(number, None) if not field and message_descriptor.is_extendable: field = message.Extensions._FindExtensionByNumber(number) else: field = message_descriptor.fields_by_name.get(name, None) # Group names are expected to be capitalized as they appear in the # .proto file, which actually matches their type names, not their field # names. if not field: field = message_descriptor.fields_by_name.get(name.lower(), None) if field and field.type != descriptor.FieldDescriptor.TYPE_GROUP: field = None if (field and field.type == descriptor.FieldDescriptor.TYPE_GROUP and field.message_type.name != name): field = None if not field and not self.allow_unknown_field: raise tokenizer.ParseErrorPreviousToken( 'Message type "%s" has no field named "%s".' % (message_descriptor.full_name, name)) if field: if not self._allow_multiple_scalars and field.containing_oneof: # Check if there's a different field set in this oneof. # Note that we ignore the case if the same field was set before, and we # apply _allow_multiple_scalars to non-scalar fields as well. which_oneof = message.WhichOneof(field.containing_oneof.name) if which_oneof is not None and which_oneof != field.name: raise tokenizer.ParseErrorPreviousToken( 'Field "%s" is specified along with field "%s", another member ' 'of oneof "%s" for message type "%s".' % (field.name, which_oneof, field.containing_oneof.name, message_descriptor.full_name)) if field.cpp_type == descriptor.FieldDescriptor.CPPTYPE_MESSAGE: tokenizer.TryConsume(':') merger = self._MergeMessageField else: tokenizer.Consume(':') merger = self._MergeScalarField if (field.label == descriptor.FieldDescriptor.LABEL_REPEATED and tokenizer.TryConsume('[')): # Short repeated format, e.g. "foo: [1, 2, 3]" if not tokenizer.TryConsume(']'): while True: merger(tokenizer, message, field) if tokenizer.TryConsume(']'): break tokenizer.Consume(',') else: merger(tokenizer, message, field) else: # Proto field is unknown. assert (self.allow_unknown_extension or self.allow_unknown_field) _SkipFieldContents(tokenizer) # For historical reasons, fields may optionally be separated by commas or # semicolons. if not tokenizer.TryConsume(','): tokenizer.TryConsume(';') def _ConsumeAnyTypeUrl(self, tokenizer): """Consumes a google.protobuf.Any type URL and returns the type name.""" # Consume "type.googleapis.com/". prefix = [tokenizer.ConsumeIdentifier()] tokenizer.Consume('.') prefix.append(tokenizer.ConsumeIdentifier()) tokenizer.Consume('.') prefix.append(tokenizer.ConsumeIdentifier()) tokenizer.Consume('/') # Consume the fully-qualified type name. name = [tokenizer.ConsumeIdentifier()] while tokenizer.TryConsume('.'): name.append(tokenizer.ConsumeIdentifier()) return '.'.join(prefix), '.'.join(name) def _MergeMessageField(self, tokenizer, message, field): """Merges a single scalar field into a message. Args: tokenizer: A tokenizer to parse the field value. message: The message of which field is a member. field: The descriptor of the field to be merged. Raises: ParseError: In case of text parsing problems. """ is_map_entry = _IsMapEntry(field) if tokenizer.TryConsume('<'): end_token = '>' else: tokenizer.Consume('{') end_token = '}' if field.label == descriptor.FieldDescriptor.LABEL_REPEATED: if field.is_extension: sub_message = message.Extensions[field].add() elif is_map_entry: sub_message = getattr(message, field.name).GetEntryClass()() else: sub_message = getattr(message, field.name).add() else: if field.is_extension: if (not self._allow_multiple_scalars and message.HasExtension(field)): raise tokenizer.ParseErrorPreviousToken( 'Message type "%s" should not have multiple "%s" extensions.' % (message.DESCRIPTOR.full_name, field.full_name)) sub_message = message.Extensions[field] else: # Also apply _allow_multiple_scalars to message field. # TODO(jieluo): Change to _allow_singular_overwrites. if (not self._allow_multiple_scalars and message.HasField(field.name)): raise tokenizer.ParseErrorPreviousToken( 'Message type "%s" should not have multiple "%s" fields.' % (message.DESCRIPTOR.full_name, field.name)) sub_message = getattr(message, field.name) sub_message.SetInParent() while not tokenizer.TryConsume(end_token): if tokenizer.AtEnd(): raise tokenizer.ParseErrorPreviousToken('Expected "%s".' % (end_token,)) self._MergeField(tokenizer, sub_message) if is_map_entry: value_cpptype = field.message_type.fields_by_name['value'].cpp_type if value_cpptype == descriptor.FieldDescriptor.CPPTYPE_MESSAGE: value = getattr(message, field.name)[sub_message.key] value.CopyFrom(sub_message.value) else: getattr(message, field.name)[sub_message.key] = sub_message.value @staticmethod def _IsProto3Syntax(message): message_descriptor = message.DESCRIPTOR return (hasattr(message_descriptor, 'syntax') and message_descriptor.syntax == 'proto3') def _MergeScalarField(self, tokenizer, message, field): """Merges a single scalar field into a message. Args: tokenizer: A tokenizer to parse the field value. message: A protocol message to record the data. field: The descriptor of the field to be merged. Raises: ParseError: In case of text parsing problems. RuntimeError: On runtime errors. """ _ = self.allow_unknown_extension value = None if field.type in (descriptor.FieldDescriptor.TYPE_INT32, descriptor.FieldDescriptor.TYPE_SINT32, descriptor.FieldDescriptor.TYPE_SFIXED32): value = _ConsumeInt32(tokenizer) elif field.type in (descriptor.FieldDescriptor.TYPE_INT64, descriptor.FieldDescriptor.TYPE_SINT64, descriptor.FieldDescriptor.TYPE_SFIXED64): value = _ConsumeInt64(tokenizer) elif field.type in (descriptor.FieldDescriptor.TYPE_UINT32, descriptor.FieldDescriptor.TYPE_FIXED32): value = _ConsumeUint32(tokenizer) elif field.type in (descriptor.FieldDescriptor.TYPE_UINT64, descriptor.FieldDescriptor.TYPE_FIXED64): value = _ConsumeUint64(tokenizer) elif field.type in (descriptor.FieldDescriptor.TYPE_FLOAT, descriptor.FieldDescriptor.TYPE_DOUBLE): value = tokenizer.ConsumeFloat() elif field.type == descriptor.FieldDescriptor.TYPE_BOOL: value = tokenizer.ConsumeBool() elif field.type == descriptor.FieldDescriptor.TYPE_STRING: value = tokenizer.ConsumeString() elif field.type == descriptor.FieldDescriptor.TYPE_BYTES: value = tokenizer.ConsumeByteString() elif field.type == descriptor.FieldDescriptor.TYPE_ENUM: value = tokenizer.ConsumeEnum(field) else: raise RuntimeError('Unknown field type %d' % field.type) if field.label == descriptor.FieldDescriptor.LABEL_REPEATED: if field.is_extension: message.Extensions[field].append(value) else: getattr(message, field.name).append(value) else: if field.is_extension: if (not self._allow_multiple_scalars and not self._IsProto3Syntax(message) and message.HasExtension(field)): raise tokenizer.ParseErrorPreviousToken( 'Message type "%s" should not have multiple "%s" extensions.' % (message.DESCRIPTOR.full_name, field.full_name)) else: message.Extensions[field] = value else: duplicate_error = False if not self._allow_multiple_scalars: if self._IsProto3Syntax(message): # Proto3 doesn't represent presence so we try best effort to check # multiple scalars by compare to default values. duplicate_error = bool(getattr(message, field.name)) else: duplicate_error = message.HasField(field.name) if duplicate_error: raise tokenizer.ParseErrorPreviousToken( 'Message type "%s" should not have multiple "%s" fields.' % (message.DESCRIPTOR.full_name, field.name)) else: setattr(message, field.name, value) def _SkipFieldContents(tokenizer): """Skips over contents (value or message) of a field. Args: tokenizer: A tokenizer to parse the field name and values. """ # Try to guess the type of this field. # If this field is not a message, there should be a ":" between the # field name and the field value and also the field value should not # start with "{" or "<" which indicates the beginning of a message body. # If there is no ":" or there is a "{" or "<" after ":", this field has # to be a message or the input is ill-formed. if tokenizer.TryConsume(':') and not tokenizer.LookingAt( '{') and not tokenizer.LookingAt('<'): _SkipFieldValue(tokenizer) else: _SkipFieldMessage(tokenizer) def _SkipField(tokenizer): """Skips over a complete field (name and value/message). Args: tokenizer: A tokenizer to parse the field name and values. """ if tokenizer.TryConsume('['): # Consume extension name. tokenizer.ConsumeIdentifier() while tokenizer.TryConsume('.'): tokenizer.ConsumeIdentifier() tokenizer.Consume(']') else: tokenizer.ConsumeIdentifierOrNumber() _SkipFieldContents(tokenizer) # For historical reasons, fields may optionally be separated by commas or # semicolons. if not tokenizer.TryConsume(','): tokenizer.TryConsume(';') def _SkipFieldMessage(tokenizer): """Skips over a field message. Args: tokenizer: A tokenizer to parse the field name and values. """ if tokenizer.TryConsume('<'): delimiter = '>' else: tokenizer.Consume('{') delimiter = '}' while not tokenizer.LookingAt('>') and not tokenizer.LookingAt('}'): _SkipField(tokenizer) tokenizer.Consume(delimiter) def _SkipFieldValue(tokenizer): """Skips over a field value. Args: tokenizer: A tokenizer to parse the field name and values. Raises: ParseError: In case an invalid field value is found. """ # String/bytes tokens can come in multiple adjacent string literals. # If we can consume one, consume as many as we can. if tokenizer.TryConsumeByteString(): while tokenizer.TryConsumeByteString(): pass return if (not tokenizer.TryConsumeIdentifier() and not _TryConsumeInt64(tokenizer) and not _TryConsumeUint64(tokenizer) and not tokenizer.TryConsumeFloat()): raise ParseError('Invalid field value: ' + tokenizer.token) class Tokenizer(object): """Protocol buffer text representation tokenizer. This class handles the lower level string parsing by splitting it into meaningful tokens. It was directly ported from the Java protocol buffer API. """ _WHITESPACE = re.compile(r'\s+') _COMMENT = re.compile(r'(\s*#.*$)', re.MULTILINE) _WHITESPACE_OR_COMMENT = re.compile(r'(\s|(#.*$))+', re.MULTILINE) _TOKEN = re.compile('|'.join([ r'[a-zA-Z_][0-9a-zA-Z_+-]*', # an identifier r'([0-9+-]|(\.[0-9]))[0-9a-zA-Z_.+-]*', # a number ] + [ # quoted str for each quote mark # Avoid backtracking! https://stackoverflow.com/a/844267 r'{qt}[^{qt}\n\\]*((\\.)+[^{qt}\n\\]*)*({qt}|\\?$)'.format(qt=mark) for mark in _QUOTES ])) _IDENTIFIER = re.compile(r'[^\d\W]\w*') _IDENTIFIER_OR_NUMBER = re.compile(r'\w+') def __init__(self, lines, skip_comments=True): self._position = 0 self._line = -1 self._column = 0 self._token_start = None self.token = '' self._lines = iter(lines) self._current_line = '' self._previous_line = 0 self._previous_column = 0 self._more_lines = True self._skip_comments = skip_comments self._whitespace_pattern = (skip_comments and self._WHITESPACE_OR_COMMENT or self._WHITESPACE) self._SkipWhitespace() self.NextToken() def LookingAt(self, token): return self.token == token def AtEnd(self): """Checks the end of the text was reached. Returns: True iff the end was reached. """ return not self.token def _PopLine(self): while len(self._current_line) <= self._column: try: self._current_line = next(self._lines) except StopIteration: self._current_line = '' self._more_lines = False return else: self._line += 1 self._column = 0 def _SkipWhitespace(self): while True: self._PopLine() match = self._whitespace_pattern.match(self._current_line, self._column) if not match: break length = len(match.group(0)) self._column += length def TryConsume(self, token): """Tries to consume a given piece of text. Args: token: Text to consume. Returns: True iff the text was consumed. """ if self.token == token: self.NextToken() return True return False def Consume(self, token): """Consumes a piece of text. Args: token: Text to consume. Raises: ParseError: If the text couldn't be consumed. """ if not self.TryConsume(token): raise self.ParseError('Expected "%s".' % token) def ConsumeComment(self): result = self.token if not self._COMMENT.match(result): raise self.ParseError('Expected comment.') self.NextToken() return result def ConsumeCommentOrTrailingComment(self): """Consumes a comment, returns a 2-tuple (trailing bool, comment str).""" # Tokenizer initializes _previous_line and _previous_column to 0. As the # tokenizer starts, it looks like there is a previous token on the line. just_started = self._line == 0 and self._column == 0 before_parsing = self._previous_line comment = self.ConsumeComment() # A trailing comment is a comment on the same line than the previous token. trailing = (self._previous_line == before_parsing and not just_started) return trailing, comment def TryConsumeIdentifier(self): try: self.ConsumeIdentifier() return True except ParseError: return False def ConsumeIdentifier(self): """Consumes protocol message field identifier. Returns: Identifier string. Raises: ParseError: If an identifier couldn't be consumed. """ result = self.token if not self._IDENTIFIER.match(result): raise self.ParseError('Expected identifier.') self.NextToken() return result def TryConsumeIdentifierOrNumber(self): try: self.ConsumeIdentifierOrNumber() return True except ParseError: return False def ConsumeIdentifierOrNumber(self): """Consumes protocol message field identifier. Returns: Identifier string. Raises: ParseError: If an identifier couldn't be consumed. """ result = self.token if not self._IDENTIFIER_OR_NUMBER.match(result): raise self.ParseError('Expected identifier or number, got %s.' % result) self.NextToken() return result def TryConsumeInteger(self): try: self.ConsumeInteger() return True except ParseError: return False def ConsumeInteger(self): """Consumes an integer number. Returns: The integer parsed. Raises: ParseError: If an integer couldn't be consumed. """ try: result = _ParseAbstractInteger(self.token) except ValueError as e: raise self.ParseError(str(e)) self.NextToken() return result def TryConsumeFloat(self): try: self.ConsumeFloat() return True except ParseError: return False def ConsumeFloat(self): """Consumes an floating point number. Returns: The number parsed. Raises: ParseError: If a floating point number couldn't be consumed. """ try: result = ParseFloat(self.token) except ValueError as e: raise self.ParseError(str(e)) self.NextToken() return result def ConsumeBool(self): """Consumes a boolean value. Returns: The bool parsed. Raises: ParseError: If a boolean value couldn't be consumed. """ try: result = ParseBool(self.token) except ValueError as e: raise self.ParseError(str(e)) self.NextToken() return result def TryConsumeByteString(self): try: self.ConsumeByteString() return True except ParseError: return False def ConsumeString(self): """Consumes a string value. Returns: The string parsed. Raises: ParseError: If a string value couldn't be consumed. """ the_bytes = self.ConsumeByteString() try: return str(the_bytes, 'utf-8') except UnicodeDecodeError as e: raise self._StringParseError(e) def ConsumeByteString(self): """Consumes a byte array value. Returns: The array parsed (as a string). Raises: ParseError: If a byte array value couldn't be consumed. """ the_list = [self._ConsumeSingleByteString()] while self.token and self.token[0] in _QUOTES: the_list.append(self._ConsumeSingleByteString()) return b''.join(the_list) def _ConsumeSingleByteString(self): """Consume one token of a string literal. String literals (whether bytes or text) can come in multiple adjacent tokens which are automatically concatenated, like in C or Python. This method only consumes one token. Returns: The token parsed. Raises: ParseError: When the wrong format data is found. """ text = self.token if len(text) < 1 or text[0] not in _QUOTES: raise self.ParseError('Expected string but found: %r' % (text,)) if len(text) < 2 or text[-1] != text[0]: raise self.ParseError('String missing ending quote: %r' % (text,)) try: result = text_encoding.CUnescape(text[1:-1]) except ValueError as e: raise self.ParseError(str(e)) self.NextToken() return result def ConsumeEnum(self, field): try: result = ParseEnum(field, self.token) except ValueError as e: raise self.ParseError(str(e)) self.NextToken() return result def ParseErrorPreviousToken(self, message): """Creates and *returns* a ParseError for the previously read token. Args: message: A message to set for the exception. Returns: A ParseError instance. """ return ParseError(message, self._previous_line + 1, self._previous_column + 1) def ParseError(self, message): """Creates and *returns* a ParseError for the current token.""" return ParseError('\'' + self._current_line + '\': ' + message, self._line + 1, self._column + 1) def _StringParseError(self, e): return self.ParseError('Couldn\'t parse string: ' + str(e)) def NextToken(self): """Reads the next meaningful token.""" self._previous_line = self._line self._previous_column = self._column self._column += len(self.token) self._SkipWhitespace() if not self._more_lines: self.token = '' return match = self._TOKEN.match(self._current_line, self._column) if not match and not self._skip_comments: match = self._COMMENT.match(self._current_line, self._column) if match: token = match.group(0) self.token = token else: self.token = self._current_line[self._column] # Aliased so it can still be accessed by current visibility violators. # TODO(dbarnett): Migrate violators to textformat_tokenizer. _Tokenizer = Tokenizer # pylint: disable=invalid-name def _ConsumeInt32(tokenizer): """Consumes a signed 32bit integer number from tokenizer. Args: tokenizer: A tokenizer used to parse the number. Returns: The integer parsed. Raises: ParseError: If a signed 32bit integer couldn't be consumed. """ return _ConsumeInteger(tokenizer, is_signed=True, is_long=False) def _ConsumeUint32(tokenizer): """Consumes an unsigned 32bit integer number from tokenizer. Args: tokenizer: A tokenizer used to parse the number. Returns: The integer parsed. Raises: ParseError: If an unsigned 32bit integer couldn't be consumed. """ return _ConsumeInteger(tokenizer, is_signed=False, is_long=False) def _TryConsumeInt64(tokenizer): try: _ConsumeInt64(tokenizer) return True except ParseError: return False def _ConsumeInt64(tokenizer): """Consumes a signed 32bit integer number from tokenizer. Args: tokenizer: A tokenizer used to parse the number. Returns: The integer parsed. Raises: ParseError: If a signed 32bit integer couldn't be consumed. """ return _ConsumeInteger(tokenizer, is_signed=True, is_long=True) def _TryConsumeUint64(tokenizer): try: _ConsumeUint64(tokenizer) return True except ParseError: return False def _ConsumeUint64(tokenizer): """Consumes an unsigned 64bit integer number from tokenizer. Args: tokenizer: A tokenizer used to parse the number. Returns: The integer parsed. Raises: ParseError: If an unsigned 64bit integer couldn't be consumed. """ return _ConsumeInteger(tokenizer, is_signed=False, is_long=True) def _ConsumeInteger(tokenizer, is_signed=False, is_long=False): """Consumes an integer number from tokenizer. Args: tokenizer: A tokenizer used to parse the number. is_signed: True if a signed integer must be parsed. is_long: True if a long integer must be parsed. Returns: The integer parsed. Raises: ParseError: If an integer with given characteristics couldn't be consumed. """ try: result = ParseInteger(tokenizer.token, is_signed=is_signed, is_long=is_long) except ValueError as e: raise tokenizer.ParseError(str(e)) tokenizer.NextToken() return result def ParseInteger(text, is_signed=False, is_long=False): """Parses an integer. Args: text: The text to parse. is_signed: True if a signed integer must be parsed. is_long: True if a long integer must be parsed. Returns: The integer value. Raises: ValueError: Thrown Iff the text is not a valid integer. """ # Do the actual parsing. Exception handling is propagated to caller. result = _ParseAbstractInteger(text) # Check if the integer is sane. Exceptions handled by callers. checker = _INTEGER_CHECKERS[2 * int(is_long) + int(is_signed)] checker.CheckValue(result) return result def _ParseAbstractInteger(text): """Parses an integer without checking size/signedness. Args: text: The text to parse. Returns: The integer value. Raises: ValueError: Thrown Iff the text is not a valid integer. """ # Do the actual parsing. Exception handling is propagated to caller. orig_text = text c_octal_match = re.match(r'(-?)0(\d+)$', text) if c_octal_match: # Python 3 no longer supports 0755 octal syntax without the 'o', so # we always use the '0o' prefix for multi-digit numbers starting with 0. text = c_octal_match.group(1) + '0o' + c_octal_match.group(2) try: return int(text, 0) except ValueError: raise ValueError('Couldn\'t parse integer: %s' % orig_text) def ParseFloat(text): """Parse a floating point number. Args: text: Text to parse. Returns: The number parsed. Raises: ValueError: If a floating point number couldn't be parsed. """ try: # Assume Python compatible syntax. return float(text) except ValueError: # Check alternative spellings. if _FLOAT_INFINITY.match(text): if text[0] == '-': return float('-inf') else: return float('inf') elif _FLOAT_NAN.match(text): return float('nan') else: # assume '1.0f' format try: return float(text.rstrip('f')) except ValueError: raise ValueError('Couldn\'t parse float: %s' % text) def ParseBool(text): """Parse a boolean value. Args: text: Text to parse. Returns: Boolean values parsed Raises: ValueError: If text is not a valid boolean. """ if text in ('true', 't', '1', 'True'): return True elif text in ('false', 'f', '0', 'False'): return False else: raise ValueError('Expected "true" or "false".') def ParseEnum(field, value): """Parse an enum value. The value can be specified by a number (the enum value), or by a string literal (the enum name). Args: field: Enum field descriptor. value: String value. Returns: Enum value number. Raises: ValueError: If the enum value could not be parsed. """ enum_descriptor = field.enum_type try: number = int(value, 0) except ValueError: # Identifier. enum_value = enum_descriptor.values_by_name.get(value, None) if enum_value is None: raise ValueError('Enum type "%s" has no value named %s.' % (enum_descriptor.full_name, value)) else: # Numeric value. if hasattr(field.file, 'syntax'): # Attribute is checked for compatibility. if field.file.syntax == 'proto3': # Proto3 accept numeric unknown enums. return number enum_value = enum_descriptor.values_by_number.get(number, None) if enum_value is None: raise ValueError('Enum type "%s" has no value with number %d.' % (enum_descriptor.full_name, number)) return enum_value.number ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/timestamp_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/timestamp.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1fgoogle/protobuf/timestamp.proto\x12\x0fgoogle.protobuf\"+\n\tTimestamp\x12\x0f\n\x07seconds\x18\x01 \x01(\x03\x12\r\n\x05nanos\x18\x02 \x01(\x05\x42\x85\x01\n\x13\x63om.google.protobufB\x0eTimestampProtoP\x01Z2google.golang.org/protobuf/types/known/timestamppb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.timestamp_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\016TimestampProtoP\001Z2google.golang.org/protobuf/types/known/timestamppb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _TIMESTAMP._serialized_start=52 _TIMESTAMP._serialized_end=95 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/type_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/type.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2 from google.protobuf import source_context_pb2 as google_dot_protobuf_dot_source__context__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1agoogle/protobuf/type.proto\x12\x0fgoogle.protobuf\x1a\x19google/protobuf/any.proto\x1a$google/protobuf/source_context.proto\"\xd7\x01\n\x04Type\x12\x0c\n\x04name\x18\x01 \x01(\t\x12&\n\x06\x66ields\x18\x02 \x03(\x0b\x32\x16.google.protobuf.Field\x12\x0e\n\x06oneofs\x18\x03 \x03(\t\x12(\n\x07options\x18\x04 \x03(\x0b\x32\x17.google.protobuf.Option\x12\x36\n\x0esource_context\x18\x05 \x01(\x0b\x32\x1e.google.protobuf.SourceContext\x12\'\n\x06syntax\x18\x06 \x01(\x0e\x32\x17.google.protobuf.Syntax\"\xd5\x05\n\x05\x46ield\x12)\n\x04kind\x18\x01 \x01(\x0e\x32\x1b.google.protobuf.Field.Kind\x12\x37\n\x0b\x63\x61rdinality\x18\x02 \x01(\x0e\x32\".google.protobuf.Field.Cardinality\x12\x0e\n\x06number\x18\x03 \x01(\x05\x12\x0c\n\x04name\x18\x04 \x01(\t\x12\x10\n\x08type_url\x18\x06 \x01(\t\x12\x13\n\x0boneof_index\x18\x07 \x01(\x05\x12\x0e\n\x06packed\x18\x08 \x01(\x08\x12(\n\x07options\x18\t \x03(\x0b\x32\x17.google.protobuf.Option\x12\x11\n\tjson_name\x18\n \x01(\t\x12\x15\n\rdefault_value\x18\x0b \x01(\t\"\xc8\x02\n\x04Kind\x12\x10\n\x0cTYPE_UNKNOWN\x10\x00\x12\x0f\n\x0bTYPE_DOUBLE\x10\x01\x12\x0e\n\nTYPE_FLOAT\x10\x02\x12\x0e\n\nTYPE_INT64\x10\x03\x12\x0f\n\x0bTYPE_UINT64\x10\x04\x12\x0e\n\nTYPE_INT32\x10\x05\x12\x10\n\x0cTYPE_FIXED64\x10\x06\x12\x10\n\x0cTYPE_FIXED32\x10\x07\x12\r\n\tTYPE_BOOL\x10\x08\x12\x0f\n\x0bTYPE_STRING\x10\t\x12\x0e\n\nTYPE_GROUP\x10\n\x12\x10\n\x0cTYPE_MESSAGE\x10\x0b\x12\x0e\n\nTYPE_BYTES\x10\x0c\x12\x0f\n\x0bTYPE_UINT32\x10\r\x12\r\n\tTYPE_ENUM\x10\x0e\x12\x11\n\rTYPE_SFIXED32\x10\x0f\x12\x11\n\rTYPE_SFIXED64\x10\x10\x12\x0f\n\x0bTYPE_SINT32\x10\x11\x12\x0f\n\x0bTYPE_SINT64\x10\x12\"t\n\x0b\x43\x61rdinality\x12\x17\n\x13\x43\x41RDINALITY_UNKNOWN\x10\x00\x12\x18\n\x14\x43\x41RDINALITY_OPTIONAL\x10\x01\x12\x18\n\x14\x43\x41RDINALITY_REQUIRED\x10\x02\x12\x18\n\x14\x43\x41RDINALITY_REPEATED\x10\x03\"\xce\x01\n\x04\x45num\x12\x0c\n\x04name\x18\x01 \x01(\t\x12-\n\tenumvalue\x18\x02 \x03(\x0b\x32\x1a.google.protobuf.EnumValue\x12(\n\x07options\x18\x03 \x03(\x0b\x32\x17.google.protobuf.Option\x12\x36\n\x0esource_context\x18\x04 \x01(\x0b\x32\x1e.google.protobuf.SourceContext\x12\'\n\x06syntax\x18\x05 \x01(\x0e\x32\x17.google.protobuf.Syntax\"S\n\tEnumValue\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06number\x18\x02 \x01(\x05\x12(\n\x07options\x18\x03 \x03(\x0b\x32\x17.google.protobuf.Option\";\n\x06Option\x12\x0c\n\x04name\x18\x01 \x01(\t\x12#\n\x05value\x18\x02 \x01(\x0b\x32\x14.google.protobuf.Any*.\n\x06Syntax\x12\x11\n\rSYNTAX_PROTO2\x10\x00\x12\x11\n\rSYNTAX_PROTO3\x10\x01\x42{\n\x13\x63om.google.protobufB\tTypeProtoP\x01Z-google.golang.org/protobuf/types/known/typepb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.type_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\tTypeProtoP\001Z-google.golang.org/protobuf/types/known/typepb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _SYNTAX._serialized_start=1413 _SYNTAX._serialized_end=1459 _TYPE._serialized_start=113 _TYPE._serialized_end=328 _FIELD._serialized_start=331 _FIELD._serialized_end=1056 _FIELD_KIND._serialized_start=610 _FIELD_KIND._serialized_end=938 _FIELD_CARDINALITY._serialized_start=940 _FIELD_CARDINALITY._serialized_end=1056 _ENUM._serialized_start=1059 _ENUM._serialized_end=1265 _ENUMVALUE._serialized_start=1267 _ENUMVALUE._serialized_end=1350 _OPTION._serialized_start=1352 _OPTION._serialized_end=1411 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/util/__init__.py ================================================ ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/util/json_format_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/util/json_format.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n&google/protobuf/util/json_format.proto\x12\x11protobuf_unittest\"\x89\x01\n\x13TestFlagsAndStrings\x12\t\n\x01\x41\x18\x01 \x02(\x05\x12K\n\rrepeatedgroup\x18\x02 \x03(\n24.protobuf_unittest.TestFlagsAndStrings.RepeatedGroup\x1a\x1a\n\rRepeatedGroup\x12\t\n\x01\x66\x18\x03 \x02(\t\"!\n\x14TestBase64ByteArrays\x12\t\n\x01\x61\x18\x01 \x02(\x0c\"G\n\x12TestJavaScriptJSON\x12\t\n\x01\x61\x18\x01 \x01(\x05\x12\r\n\x05\x66inal\x18\x02 \x01(\x02\x12\n\n\x02in\x18\x03 \x01(\t\x12\x0b\n\x03Var\x18\x04 \x01(\t\"Q\n\x18TestJavaScriptOrderJSON1\x12\t\n\x01\x64\x18\x01 \x01(\x05\x12\t\n\x01\x63\x18\x02 \x01(\x05\x12\t\n\x01x\x18\x03 \x01(\x08\x12\t\n\x01\x62\x18\x04 \x01(\x05\x12\t\n\x01\x61\x18\x05 \x01(\x05\"\x89\x01\n\x18TestJavaScriptOrderJSON2\x12\t\n\x01\x64\x18\x01 \x01(\x05\x12\t\n\x01\x63\x18\x02 \x01(\x05\x12\t\n\x01x\x18\x03 \x01(\x08\x12\t\n\x01\x62\x18\x04 \x01(\x05\x12\t\n\x01\x61\x18\x05 \x01(\x05\x12\x36\n\x01z\x18\x06 \x03(\x0b\x32+.protobuf_unittest.TestJavaScriptOrderJSON1\"$\n\x0cTestLargeInt\x12\t\n\x01\x61\x18\x01 \x02(\x03\x12\t\n\x01\x62\x18\x02 \x02(\x04\"\xa0\x01\n\x0bTestNumbers\x12\x30\n\x01\x61\x18\x01 \x01(\x0e\x32%.protobuf_unittest.TestNumbers.MyType\x12\t\n\x01\x62\x18\x02 \x01(\x05\x12\t\n\x01\x63\x18\x03 \x01(\x02\x12\t\n\x01\x64\x18\x04 \x01(\x08\x12\t\n\x01\x65\x18\x05 \x01(\x01\x12\t\n\x01\x66\x18\x06 \x01(\r\"(\n\x06MyType\x12\x06\n\x02OK\x10\x00\x12\x0b\n\x07WARNING\x10\x01\x12\t\n\x05\x45RROR\x10\x02\"T\n\rTestCamelCase\x12\x14\n\x0cnormal_field\x18\x01 \x01(\t\x12\x15\n\rCAPITAL_FIELD\x18\x02 \x01(\x05\x12\x16\n\x0e\x43\x61melCaseField\x18\x03 \x01(\x05\"|\n\x0bTestBoolMap\x12=\n\x08\x62ool_map\x18\x01 \x03(\x0b\x32+.protobuf_unittest.TestBoolMap.BoolMapEntry\x1a.\n\x0c\x42oolMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x08\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\"O\n\rTestRecursion\x12\r\n\x05value\x18\x01 \x01(\x05\x12/\n\x05\x63hild\x18\x02 \x01(\x0b\x32 .protobuf_unittest.TestRecursion\"\x86\x01\n\rTestStringMap\x12\x43\n\nstring_map\x18\x01 \x03(\x0b\x32/.protobuf_unittest.TestStringMap.StringMapEntry\x1a\x30\n\x0eStringMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xc4\x01\n\x14TestStringSerializer\x12\x15\n\rscalar_string\x18\x01 \x01(\t\x12\x17\n\x0frepeated_string\x18\x02 \x03(\t\x12J\n\nstring_map\x18\x03 \x03(\x0b\x32\x36.protobuf_unittest.TestStringSerializer.StringMapEntry\x1a\x30\n\x0eStringMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"$\n\x18TestMessageWithExtension*\x08\x08\x64\x10\x80\x80\x80\x80\x02\"z\n\rTestExtension\x12\r\n\x05value\x18\x01 \x01(\t2Z\n\x03\x65xt\x12+.protobuf_unittest.TestMessageWithExtension\x18\x64 \x01(\x0b\x32 .protobuf_unittest.TestExtension\"Q\n\x14TestDefaultEnumValue\x12\x39\n\nenum_value\x18\x01 \x01(\x0e\x32\x1c.protobuf_unittest.EnumValue:\x07\x44\x45\x46\x41ULT*2\n\tEnumValue\x12\x0c\n\x08PROTOCOL\x10\x00\x12\n\n\x06\x42UFFER\x10\x01\x12\x0b\n\x07\x44\x45\x46\x41ULT\x10\x02') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.util.json_format_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: TestMessageWithExtension.RegisterExtension(_TESTEXTENSION.extensions_by_name['ext']) DESCRIPTOR._options = None _TESTBOOLMAP_BOOLMAPENTRY._options = None _TESTBOOLMAP_BOOLMAPENTRY._serialized_options = b'8\001' _TESTSTRINGMAP_STRINGMAPENTRY._options = None _TESTSTRINGMAP_STRINGMAPENTRY._serialized_options = b'8\001' _TESTSTRINGSERIALIZER_STRINGMAPENTRY._options = None _TESTSTRINGSERIALIZER_STRINGMAPENTRY._serialized_options = b'8\001' _ENUMVALUE._serialized_start=1607 _ENUMVALUE._serialized_end=1657 _TESTFLAGSANDSTRINGS._serialized_start=62 _TESTFLAGSANDSTRINGS._serialized_end=199 _TESTFLAGSANDSTRINGS_REPEATEDGROUP._serialized_start=173 _TESTFLAGSANDSTRINGS_REPEATEDGROUP._serialized_end=199 _TESTBASE64BYTEARRAYS._serialized_start=201 _TESTBASE64BYTEARRAYS._serialized_end=234 _TESTJAVASCRIPTJSON._serialized_start=236 _TESTJAVASCRIPTJSON._serialized_end=307 _TESTJAVASCRIPTORDERJSON1._serialized_start=309 _TESTJAVASCRIPTORDERJSON1._serialized_end=390 _TESTJAVASCRIPTORDERJSON2._serialized_start=393 _TESTJAVASCRIPTORDERJSON2._serialized_end=530 _TESTLARGEINT._serialized_start=532 _TESTLARGEINT._serialized_end=568 _TESTNUMBERS._serialized_start=571 _TESTNUMBERS._serialized_end=731 _TESTNUMBERS_MYTYPE._serialized_start=691 _TESTNUMBERS_MYTYPE._serialized_end=731 _TESTCAMELCASE._serialized_start=733 _TESTCAMELCASE._serialized_end=817 _TESTBOOLMAP._serialized_start=819 _TESTBOOLMAP._serialized_end=943 _TESTBOOLMAP_BOOLMAPENTRY._serialized_start=897 _TESTBOOLMAP_BOOLMAPENTRY._serialized_end=943 _TESTRECURSION._serialized_start=945 _TESTRECURSION._serialized_end=1024 _TESTSTRINGMAP._serialized_start=1027 _TESTSTRINGMAP._serialized_end=1161 _TESTSTRINGMAP_STRINGMAPENTRY._serialized_start=1113 _TESTSTRINGMAP_STRINGMAPENTRY._serialized_end=1161 _TESTSTRINGSERIALIZER._serialized_start=1164 _TESTSTRINGSERIALIZER._serialized_end=1360 _TESTSTRINGSERIALIZER_STRINGMAPENTRY._serialized_start=1113 _TESTSTRINGSERIALIZER_STRINGMAPENTRY._serialized_end=1161 _TESTMESSAGEWITHEXTENSION._serialized_start=1362 _TESTMESSAGEWITHEXTENSION._serialized_end=1398 _TESTEXTENSION._serialized_start=1400 _TESTEXTENSION._serialized_end=1522 _TESTDEFAULTENUMVALUE._serialized_start=1524 _TESTDEFAULTENUMVALUE._serialized_end=1605 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/util/json_format_proto3_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/util/json_format_proto3.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2 from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb2 from google.protobuf import field_mask_pb2 as google_dot_protobuf_dot_field__mask__pb2 from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2 from google.protobuf import unittest_pb2 as google_dot_protobuf_dot_unittest__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n-google/protobuf/util/json_format_proto3.proto\x12\x06proto3\x1a\x19google/protobuf/any.proto\x1a\x1egoogle/protobuf/duration.proto\x1a google/protobuf/field_mask.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\x1a\x1egoogle/protobuf/unittest.proto\"\x1c\n\x0bMessageType\x12\r\n\x05value\x18\x01 \x01(\x05\"\x94\x05\n\x0bTestMessage\x12\x12\n\nbool_value\x18\x01 \x01(\x08\x12\x13\n\x0bint32_value\x18\x02 \x01(\x05\x12\x13\n\x0bint64_value\x18\x03 \x01(\x03\x12\x14\n\x0cuint32_value\x18\x04 \x01(\r\x12\x14\n\x0cuint64_value\x18\x05 \x01(\x04\x12\x13\n\x0b\x66loat_value\x18\x06 \x01(\x02\x12\x14\n\x0c\x64ouble_value\x18\x07 \x01(\x01\x12\x14\n\x0cstring_value\x18\x08 \x01(\t\x12\x13\n\x0b\x62ytes_value\x18\t \x01(\x0c\x12$\n\nenum_value\x18\n \x01(\x0e\x32\x10.proto3.EnumType\x12*\n\rmessage_value\x18\x0b \x01(\x0b\x32\x13.proto3.MessageType\x12\x1b\n\x13repeated_bool_value\x18\x15 \x03(\x08\x12\x1c\n\x14repeated_int32_value\x18\x16 \x03(\x05\x12\x1c\n\x14repeated_int64_value\x18\x17 \x03(\x03\x12\x1d\n\x15repeated_uint32_value\x18\x18 \x03(\r\x12\x1d\n\x15repeated_uint64_value\x18\x19 \x03(\x04\x12\x1c\n\x14repeated_float_value\x18\x1a \x03(\x02\x12\x1d\n\x15repeated_double_value\x18\x1b \x03(\x01\x12\x1d\n\x15repeated_string_value\x18\x1c \x03(\t\x12\x1c\n\x14repeated_bytes_value\x18\x1d \x03(\x0c\x12-\n\x13repeated_enum_value\x18\x1e \x03(\x0e\x32\x10.proto3.EnumType\x12\x33\n\x16repeated_message_value\x18\x1f \x03(\x0b\x32\x13.proto3.MessageType\"\x8c\x02\n\tTestOneof\x12\x1b\n\x11oneof_int32_value\x18\x01 \x01(\x05H\x00\x12\x1c\n\x12oneof_string_value\x18\x02 \x01(\tH\x00\x12\x1b\n\x11oneof_bytes_value\x18\x03 \x01(\x0cH\x00\x12,\n\x10oneof_enum_value\x18\x04 \x01(\x0e\x32\x10.proto3.EnumTypeH\x00\x12\x32\n\x13oneof_message_value\x18\x05 \x01(\x0b\x32\x13.proto3.MessageTypeH\x00\x12\x36\n\x10oneof_null_value\x18\x06 \x01(\x0e\x32\x1a.google.protobuf.NullValueH\x00\x42\r\n\x0boneof_value\"\xe1\x04\n\x07TestMap\x12.\n\x08\x62ool_map\x18\x01 \x03(\x0b\x32\x1c.proto3.TestMap.BoolMapEntry\x12\x30\n\tint32_map\x18\x02 \x03(\x0b\x32\x1d.proto3.TestMap.Int32MapEntry\x12\x30\n\tint64_map\x18\x03 \x03(\x0b\x32\x1d.proto3.TestMap.Int64MapEntry\x12\x32\n\nuint32_map\x18\x04 \x03(\x0b\x32\x1e.proto3.TestMap.Uint32MapEntry\x12\x32\n\nuint64_map\x18\x05 \x03(\x0b\x32\x1e.proto3.TestMap.Uint64MapEntry\x12\x32\n\nstring_map\x18\x06 \x03(\x0b\x32\x1e.proto3.TestMap.StringMapEntry\x1a.\n\x0c\x42oolMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x08\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a/\n\rInt32MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a/\n\rInt64MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x03\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x30\n\x0eUint32MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x30\n\x0eUint64MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x04\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x30\n\x0eStringMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\"\x85\x06\n\rTestNestedMap\x12\x34\n\x08\x62ool_map\x18\x01 \x03(\x0b\x32\".proto3.TestNestedMap.BoolMapEntry\x12\x36\n\tint32_map\x18\x02 \x03(\x0b\x32#.proto3.TestNestedMap.Int32MapEntry\x12\x36\n\tint64_map\x18\x03 \x03(\x0b\x32#.proto3.TestNestedMap.Int64MapEntry\x12\x38\n\nuint32_map\x18\x04 \x03(\x0b\x32$.proto3.TestNestedMap.Uint32MapEntry\x12\x38\n\nuint64_map\x18\x05 \x03(\x0b\x32$.proto3.TestNestedMap.Uint64MapEntry\x12\x38\n\nstring_map\x18\x06 \x03(\x0b\x32$.proto3.TestNestedMap.StringMapEntry\x12\x32\n\x07map_map\x18\x07 \x03(\x0b\x32!.proto3.TestNestedMap.MapMapEntry\x1a.\n\x0c\x42oolMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x08\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a/\n\rInt32MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a/\n\rInt64MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x03\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x30\n\x0eUint32MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x30\n\x0eUint64MapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x04\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x30\n\x0eStringMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1a\x44\n\x0bMapMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12$\n\x05value\x18\x02 \x01(\x0b\x32\x15.proto3.TestNestedMap:\x02\x38\x01\"{\n\rTestStringMap\x12\x38\n\nstring_map\x18\x01 \x03(\x0b\x32$.proto3.TestStringMap.StringMapEntry\x1a\x30\n\x0eStringMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xee\x07\n\x0bTestWrapper\x12.\n\nbool_value\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.BoolValue\x12\x30\n\x0bint32_value\x18\x02 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x30\n\x0bint64_value\x18\x03 \x01(\x0b\x32\x1b.google.protobuf.Int64Value\x12\x32\n\x0cuint32_value\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x32\n\x0cuint64_value\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.UInt64Value\x12\x30\n\x0b\x66loat_value\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.FloatValue\x12\x32\n\x0c\x64ouble_value\x18\x07 \x01(\x0b\x32\x1c.google.protobuf.DoubleValue\x12\x32\n\x0cstring_value\x18\x08 \x01(\x0b\x32\x1c.google.protobuf.StringValue\x12\x30\n\x0b\x62ytes_value\x18\t \x01(\x0b\x32\x1b.google.protobuf.BytesValue\x12\x37\n\x13repeated_bool_value\x18\x0b \x03(\x0b\x32\x1a.google.protobuf.BoolValue\x12\x39\n\x14repeated_int32_value\x18\x0c \x03(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x39\n\x14repeated_int64_value\x18\r \x03(\x0b\x32\x1b.google.protobuf.Int64Value\x12;\n\x15repeated_uint32_value\x18\x0e \x03(\x0b\x32\x1c.google.protobuf.UInt32Value\x12;\n\x15repeated_uint64_value\x18\x0f \x03(\x0b\x32\x1c.google.protobuf.UInt64Value\x12\x39\n\x14repeated_float_value\x18\x10 \x03(\x0b\x32\x1b.google.protobuf.FloatValue\x12;\n\x15repeated_double_value\x18\x11 \x03(\x0b\x32\x1c.google.protobuf.DoubleValue\x12;\n\x15repeated_string_value\x18\x12 \x03(\x0b\x32\x1c.google.protobuf.StringValue\x12\x39\n\x14repeated_bytes_value\x18\x13 \x03(\x0b\x32\x1b.google.protobuf.BytesValue\"n\n\rTestTimestamp\x12)\n\x05value\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x32\n\x0erepeated_value\x18\x02 \x03(\x0b\x32\x1a.google.protobuf.Timestamp\"k\n\x0cTestDuration\x12(\n\x05value\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x31\n\x0erepeated_value\x18\x02 \x03(\x0b\x32\x19.google.protobuf.Duration\":\n\rTestFieldMask\x12)\n\x05value\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.FieldMask\"e\n\nTestStruct\x12&\n\x05value\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12/\n\x0erepeated_value\x18\x02 \x03(\x0b\x32\x17.google.protobuf.Struct\"\\\n\x07TestAny\x12#\n\x05value\x18\x01 \x01(\x0b\x32\x14.google.protobuf.Any\x12,\n\x0erepeated_value\x18\x02 \x03(\x0b\x32\x14.google.protobuf.Any\"b\n\tTestValue\x12%\n\x05value\x18\x01 \x01(\x0b\x32\x16.google.protobuf.Value\x12.\n\x0erepeated_value\x18\x02 \x03(\x0b\x32\x16.google.protobuf.Value\"n\n\rTestListValue\x12)\n\x05value\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.ListValue\x12\x32\n\x0erepeated_value\x18\x02 \x03(\x0b\x32\x1a.google.protobuf.ListValue\"\x89\x01\n\rTestBoolValue\x12\x12\n\nbool_value\x18\x01 \x01(\x08\x12\x34\n\x08\x62ool_map\x18\x02 \x03(\x0b\x32\".proto3.TestBoolValue.BoolMapEntry\x1a.\n\x0c\x42oolMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\x08\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\"+\n\x12TestCustomJsonName\x12\x15\n\x05value\x18\x01 \x01(\x05R\x06@value\"J\n\x0eTestExtensions\x12\x38\n\nextensions\x18\x01 \x01(\x0b\x32$.protobuf_unittest.TestAllExtensions\"\x84\x01\n\rTestEnumValue\x12%\n\x0b\x65num_value1\x18\x01 \x01(\x0e\x32\x10.proto3.EnumType\x12%\n\x0b\x65num_value2\x18\x02 \x01(\x0e\x32\x10.proto3.EnumType\x12%\n\x0b\x65num_value3\x18\x03 \x01(\x0e\x32\x10.proto3.EnumType*\x1c\n\x08\x45numType\x12\x07\n\x03\x46OO\x10\x00\x12\x07\n\x03\x42\x41R\x10\x01\x42,\n\x18\x63om.google.protobuf.utilB\x10JsonFormatProto3b\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.util.json_format_proto3_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\030com.google.protobuf.utilB\020JsonFormatProto3' _TESTMAP_BOOLMAPENTRY._options = None _TESTMAP_BOOLMAPENTRY._serialized_options = b'8\001' _TESTMAP_INT32MAPENTRY._options = None _TESTMAP_INT32MAPENTRY._serialized_options = b'8\001' _TESTMAP_INT64MAPENTRY._options = None _TESTMAP_INT64MAPENTRY._serialized_options = b'8\001' _TESTMAP_UINT32MAPENTRY._options = None _TESTMAP_UINT32MAPENTRY._serialized_options = b'8\001' _TESTMAP_UINT64MAPENTRY._options = None _TESTMAP_UINT64MAPENTRY._serialized_options = b'8\001' _TESTMAP_STRINGMAPENTRY._options = None _TESTMAP_STRINGMAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_BOOLMAPENTRY._options = None _TESTNESTEDMAP_BOOLMAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_INT32MAPENTRY._options = None _TESTNESTEDMAP_INT32MAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_INT64MAPENTRY._options = None _TESTNESTEDMAP_INT64MAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_UINT32MAPENTRY._options = None _TESTNESTEDMAP_UINT32MAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_UINT64MAPENTRY._options = None _TESTNESTEDMAP_UINT64MAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_STRINGMAPENTRY._options = None _TESTNESTEDMAP_STRINGMAPENTRY._serialized_options = b'8\001' _TESTNESTEDMAP_MAPMAPENTRY._options = None _TESTNESTEDMAP_MAPMAPENTRY._serialized_options = b'8\001' _TESTSTRINGMAP_STRINGMAPENTRY._options = None _TESTSTRINGMAP_STRINGMAPENTRY._serialized_options = b'8\001' _TESTBOOLVALUE_BOOLMAPENTRY._options = None _TESTBOOLVALUE_BOOLMAPENTRY._serialized_options = b'8\001' _ENUMTYPE._serialized_start=4849 _ENUMTYPE._serialized_end=4877 _MESSAGETYPE._serialized_start=277 _MESSAGETYPE._serialized_end=305 _TESTMESSAGE._serialized_start=308 _TESTMESSAGE._serialized_end=968 _TESTONEOF._serialized_start=971 _TESTONEOF._serialized_end=1239 _TESTMAP._serialized_start=1242 _TESTMAP._serialized_end=1851 _TESTMAP_BOOLMAPENTRY._serialized_start=1557 _TESTMAP_BOOLMAPENTRY._serialized_end=1603 _TESTMAP_INT32MAPENTRY._serialized_start=1605 _TESTMAP_INT32MAPENTRY._serialized_end=1652 _TESTMAP_INT64MAPENTRY._serialized_start=1654 _TESTMAP_INT64MAPENTRY._serialized_end=1701 _TESTMAP_UINT32MAPENTRY._serialized_start=1703 _TESTMAP_UINT32MAPENTRY._serialized_end=1751 _TESTMAP_UINT64MAPENTRY._serialized_start=1753 _TESTMAP_UINT64MAPENTRY._serialized_end=1801 _TESTMAP_STRINGMAPENTRY._serialized_start=1803 _TESTMAP_STRINGMAPENTRY._serialized_end=1851 _TESTNESTEDMAP._serialized_start=1854 _TESTNESTEDMAP._serialized_end=2627 _TESTNESTEDMAP_BOOLMAPENTRY._serialized_start=1557 _TESTNESTEDMAP_BOOLMAPENTRY._serialized_end=1603 _TESTNESTEDMAP_INT32MAPENTRY._serialized_start=1605 _TESTNESTEDMAP_INT32MAPENTRY._serialized_end=1652 _TESTNESTEDMAP_INT64MAPENTRY._serialized_start=1654 _TESTNESTEDMAP_INT64MAPENTRY._serialized_end=1701 _TESTNESTEDMAP_UINT32MAPENTRY._serialized_start=1703 _TESTNESTEDMAP_UINT32MAPENTRY._serialized_end=1751 _TESTNESTEDMAP_UINT64MAPENTRY._serialized_start=1753 _TESTNESTEDMAP_UINT64MAPENTRY._serialized_end=1801 _TESTNESTEDMAP_STRINGMAPENTRY._serialized_start=1803 _TESTNESTEDMAP_STRINGMAPENTRY._serialized_end=1851 _TESTNESTEDMAP_MAPMAPENTRY._serialized_start=2559 _TESTNESTEDMAP_MAPMAPENTRY._serialized_end=2627 _TESTSTRINGMAP._serialized_start=2629 _TESTSTRINGMAP._serialized_end=2752 _TESTSTRINGMAP_STRINGMAPENTRY._serialized_start=2704 _TESTSTRINGMAP_STRINGMAPENTRY._serialized_end=2752 _TESTWRAPPER._serialized_start=2755 _TESTWRAPPER._serialized_end=3761 _TESTTIMESTAMP._serialized_start=3763 _TESTTIMESTAMP._serialized_end=3873 _TESTDURATION._serialized_start=3875 _TESTDURATION._serialized_end=3982 _TESTFIELDMASK._serialized_start=3984 _TESTFIELDMASK._serialized_end=4042 _TESTSTRUCT._serialized_start=4044 _TESTSTRUCT._serialized_end=4145 _TESTANY._serialized_start=4147 _TESTANY._serialized_end=4239 _TESTVALUE._serialized_start=4241 _TESTVALUE._serialized_end=4339 _TESTLISTVALUE._serialized_start=4341 _TESTLISTVALUE._serialized_end=4451 _TESTBOOLVALUE._serialized_start=4454 _TESTBOOLVALUE._serialized_end=4591 _TESTBOOLVALUE_BOOLMAPENTRY._serialized_start=1557 _TESTBOOLVALUE_BOOLMAPENTRY._serialized_end=1603 _TESTCUSTOMJSONNAME._serialized_start=4593 _TESTCUSTOMJSONNAME._serialized_end=4636 _TESTEXTENSIONS._serialized_start=4638 _TESTEXTENSIONS._serialized_end=4712 _TESTENUMVALUE._serialized_start=4715 _TESTENUMVALUE._serialized_end=4847 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/nuke/vendor/google/protobuf/wrappers_pb2.py ================================================ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: google/protobuf/wrappers.proto """Generated protocol buffer code.""" from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1egoogle/protobuf/wrappers.proto\x12\x0fgoogle.protobuf\"\x1c\n\x0b\x44oubleValue\x12\r\n\x05value\x18\x01 \x01(\x01\"\x1b\n\nFloatValue\x12\r\n\x05value\x18\x01 \x01(\x02\"\x1b\n\nInt64Value\x12\r\n\x05value\x18\x01 \x01(\x03\"\x1c\n\x0bUInt64Value\x12\r\n\x05value\x18\x01 \x01(\x04\"\x1b\n\nInt32Value\x12\r\n\x05value\x18\x01 \x01(\x05\"\x1c\n\x0bUInt32Value\x12\r\n\x05value\x18\x01 \x01(\r\"\x1a\n\tBoolValue\x12\r\n\x05value\x18\x01 \x01(\x08\"\x1c\n\x0bStringValue\x12\r\n\x05value\x18\x01 \x01(\t\"\x1b\n\nBytesValue\x12\r\n\x05value\x18\x01 \x01(\x0c\x42\x83\x01\n\x13\x63om.google.protobufB\rWrappersProtoP\x01Z1google.golang.org/protobuf/types/known/wrapperspb\xf8\x01\x01\xa2\x02\x03GPB\xaa\x02\x1eGoogle.Protobuf.WellKnownTypesb\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google.protobuf.wrappers_pb2', globals()) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\rWrappersProtoP\001Z1google.golang.org/protobuf/types/known/wrapperspb\370\001\001\242\002\003GPB\252\002\036Google.Protobuf.WellKnownTypes' _DOUBLEVALUE._serialized_start=51 _DOUBLEVALUE._serialized_end=79 _FLOATVALUE._serialized_start=81 _FLOATVALUE._serialized_end=108 _INT64VALUE._serialized_start=110 _INT64VALUE._serialized_end=137 _UINT64VALUE._serialized_start=139 _UINT64VALUE._serialized_end=167 _INT32VALUE._serialized_start=169 _INT32VALUE._serialized_end=196 _UINT32VALUE._serialized_start=198 _UINT32VALUE._serialized_end=226 _BOOLVALUE._serialized_start=228 _BOOLVALUE._serialized_end=254 _STRINGVALUE._serialized_start=256 _STRINGVALUE._serialized_end=284 _BYTESVALUE._serialized_start=286 _BYTESVALUE._serialized_end=313 # @@protoc_insertion_point(module_scope) ================================================ FILE: openpype/hosts/photoshop/__init__.py ================================================ from .addon import ( PhotoshopAddon, PHOTOSHOP_HOST_DIR, ) __all__ = ( "PhotoshopAddon", "PHOTOSHOP_HOST_DIR", ) ================================================ FILE: openpype/hosts/photoshop/addon.py ================================================ import os from openpype.modules import OpenPypeModule, IHostAddon PHOTOSHOP_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) class PhotoshopAddon(OpenPypeModule, IHostAddon): name = "photoshop" host_name = "photoshop" def initialize(self, module_settings): self.enabled = True def add_implementation_envs(self, env, _app): """Modify environments to contain all required for implementation.""" defaults = { "OPENPYPE_LOG_NO_COLORS": "True", "WEBSOCKET_URL": "ws://localhost:8099/ws/" } for key, value in defaults.items(): if not env.get(key): env[key] = value def get_workfile_extensions(self): return [".psd", ".psb"] ================================================ FILE: openpype/hosts/photoshop/api/README.md ================================================ # Photoshop Integration ## Setup The Photoshop integration requires two components to work; `extension` and `server`. ### Extension To install the extension download [Extension Manager Command Line tool (ExManCmd)](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#option-2---exmancmd). ``` ExManCmd /install {path to addon}/api/extension.zxp ``` ### Server The easiest way to get the server and Photoshop launch is with: ``` python -c ^"import openpype.hosts.photoshop;openpype.hosts.photoshop.launch(""C:\Program Files\Adobe\Adobe Photoshop 2020\Photoshop.exe"")^" ``` `avalon.photoshop.launch` launches the application and server, and also closes the server when Photoshop exists. ## Usage The Photoshop extension can be found under `Window > Extensions > Ayon`. Once launched you should be presented with a panel like this: ![Ayon Panel](panel.png "AYON Panel") ## Developing ### Extension When developing the extension you can load it [unsigned](https://github.com/Adobe-CEP/CEP-Resources/blob/master/CEP_9.x/Documentation/CEP%209.0%20HTML%20Extension%20Cookbook.md#debugging-unsigned-extensions). When signing the extension you can use this [guide](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#package-distribute-install-guide). ``` ZXPSignCmd -selfSignedCert NA NA Ayon Ayon-Photoshop Ayon extension.p12 ZXPSignCmd -sign {path to avalon-core}\avalon\photoshop\extension {path to avalon-core}\avalon\photoshop\extension.zxp extension.p12 avalon ``` ### Plugin Examples These plugins were made with the [polly config](https://github.com/mindbender-studio/config). To fully integrate and load, you will have to use this config and add `image` to the [integration plugin](https://github.com/mindbender-studio/config/blob/master/polly/plugins/publish/integrate_asset.py). #### Creator Plugin ```python from avalon import photoshop class CreateImage(photoshop.Creator): """Image folder for publish.""" name = "imageDefault" label = "Image" family = "image" def __init__(self, *args, **kwargs): super(CreateImage, self).__init__(*args, **kwargs) ``` #### Collector Plugin ```python import pythoncom import pyblish.api class CollectInstances(pyblish.api.ContextPlugin): """Gather instances by LayerSet and file metadata This collector takes into account assets that are associated with an LayerSet and marked with a unique identifier; Identifier: id (str): "pyblish.avalon.instance" """ label = "Instances" order = pyblish.api.CollectorOrder hosts = ["photoshop"] families_mapping = { "image": [] } def process(self, context): # Necessary call when running in a different thread which pyblish-qml # can be. pythoncom.CoInitialize() photoshop_client = PhotoshopClientStub() layers = photoshop_client.get_layers() layers_meta = photoshop_client.get_layers_metadata() for layer in layers: layer_data = photoshop_client.read(layer, layers_meta) # Skip layers without metadata. if layer_data is None: continue # Skip containers. if "container" in layer_data["id"]: continue # child_layers = [*layer.Layers] # self.log.debug("child_layers {}".format(child_layers)) # if not child_layers: # self.log.info("%s skipped, it was empty." % layer.Name) # continue instance = context.create_instance(layer.name) instance.append(layer) instance.data.update(layer_data) instance.data["families"] = self.families_mapping[ layer_data["family"] ] instance.data["publish"] = layer.visible # Produce diagnostic message for any graphical # user interface interested in visualising it. self.log.info("Found: \"%s\" " % instance.data["name"]) ``` #### Extractor Plugin ```python import os from openpype.pipeline import publish from openpype.hosts.photoshop import api as photoshop class ExtractImage(publish.Extractor): """Produce a flattened image file from instance This plug-in takes into account only the layers in the group. """ label = "Extract Image" hosts = ["photoshop"] families = ["image"] formats = ["png", "jpg"] def process(self, instance): staging_dir = self.staging_dir(instance) self.log.info("Outputting image to {}".format(staging_dir)) # Perform extraction stub = photoshop.stub() files = {} with photoshop.maintained_selection(): self.log.info("Extracting %s" % str(list(instance))) with photoshop.maintained_visibility(): # Hide all other layers. extract_ids = set([ll.id for ll in stub. get_layers_in_layers([instance[0]])]) for layer in stub.get_layers(): # limit unnecessary calls to client if layer.visible and layer.id not in extract_ids: stub.set_visible(layer.id, False) save_options = [] if "png" in self.formats: save_options.append('png') if "jpg" in self.formats: save_options.append('jpg') file_basename = os.path.splitext( stub.get_active_document_name() )[0] for extension in save_options: _filename = "{}.{}".format(file_basename, extension) files[extension] = _filename full_filename = os.path.join(staging_dir, _filename) stub.saveAs(full_filename, extension, True) representations = [] for extension, filename in files.items(): representations.append({ "name": extension, "ext": extension, "files": filename, "stagingDir": staging_dir }) instance.data["representations"] = representations instance.data["stagingDir"] = staging_dir self.log.info(f"Extracted {instance} to {staging_dir}") ``` #### Loader Plugin ```python from avalon import api, photoshop from openpype.pipeline import load, get_representation_path stub = photoshop.stub() class ImageLoader(load.LoaderPlugin): """Load images Stores the imported asset in a container named after the asset. """ families = ["image"] representations = ["*"] def load(self, context, name=None, namespace=None, data=None): path = self.filepath_from_context(context) with photoshop.maintained_selection(): layer = stub.import_smart_object(path) self[:] = [layer] return photoshop.containerise( name, namespace, layer, context, self.__class__.__name__ ) def update(self, container, representation): layer = container.pop("layer") with photoshop.maintained_selection(): stub.replace_smart_object( layer, get_representation_path(representation) ) stub.imprint( layer, {"representation": str(representation["_id"])} ) def remove(self, container): container["layer"].Delete() def switch(self, container, representation): self.update(container, representation) ``` For easier debugging of Javascript: https://community.adobe.com/t5/download-install/adobe-extension-debuger-problem/td-p/10911704?page=1 Add --enable-blink-features=ShadowDOMV0,CustomElementsV0 when starting Chrome then localhost:8078 (port set in `photoshop\extension\.debug`) Or use Visual Studio Code https://medium.com/adobetech/extendscript-debugger-for-visual-studio-code-public-release-a2ff6161fa01 Or install CEF client from https://github.com/Adobe-CEP/CEP-Resources/tree/master/CEP_9.x ## Resources - https://github.com/lohriialo/photoshop-scripting-python - https://www.adobe.com/devnet/photoshop/scripting.html - https://github.com/Adobe-CEP/Getting-Started-guides - https://github.com/Adobe-CEP/CEP-Resources ================================================ FILE: openpype/hosts/photoshop/api/__init__.py ================================================ """Public API Anything that isn't defined here is INTERNAL and unreliable for external use. """ from .launch_logic import stub from .pipeline import ( PhotoshopHost, ls, containerise ) from .plugin import ( PhotoshopLoader, get_unique_layer_name ) from .lib import ( maintained_selection, maintained_visibility ) __all__ = [ # launch_logic "stub", # pipeline "PhotoshopHost", "ls", "containerise", # Plugin "PhotoshopLoader", "get_unique_layer_name", # lib "maintained_selection", "maintained_visibility", ] ================================================ FILE: openpype/hosts/photoshop/api/extension/.debug ================================================ ================================================ FILE: openpype/hosts/photoshop/api/extension/CSXS/manifest.xml ================================================ ./index.html true applicationActivate com.adobe.csxs.events.ApplicationInitialized Panel AYON 300 140 400 200 ./icons/ayon_logo.png ================================================ FILE: openpype/hosts/photoshop/api/extension/client/CSInterface.js ================================================ /************************************************************************************************** * * ADOBE SYSTEMS INCORPORATED * Copyright 2013 Adobe Systems Incorporated * All Rights Reserved. * * NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the * terms of the Adobe license agreement accompanying it. If you have received this file from a * source other than Adobe, then your use, modification, or distribution of it requires the prior * written permission of Adobe. * **************************************************************************************************/ /** CSInterface - v8.0.0 */ /** * Stores constants for the window types supported by the CSXS infrastructure. */ function CSXSWindowType() { } /** Constant for the CSXS window type Panel. */ CSXSWindowType._PANEL = "Panel"; /** Constant for the CSXS window type Modeless. */ CSXSWindowType._MODELESS = "Modeless"; /** Constant for the CSXS window type ModalDialog. */ CSXSWindowType._MODAL_DIALOG = "ModalDialog"; /** EvalScript error message */ EvalScript_ErrMessage = "EvalScript error."; /** * @class Version * Defines a version number with major, minor, micro, and special * components. The major, minor and micro values are numeric; the special * value can be any string. * * @param major The major version component, a positive integer up to nine digits long. * @param minor The minor version component, a positive integer up to nine digits long. * @param micro The micro version component, a positive integer up to nine digits long. * @param special The special version component, an arbitrary string. * * @return A new \c Version object. */ function Version(major, minor, micro, special) { this.major = major; this.minor = minor; this.micro = micro; this.special = special; } /** * The maximum value allowed for a numeric version component. * This reflects the maximum value allowed in PlugPlug and the manifest schema. */ Version.MAX_NUM = 999999999; /** * @class VersionBound * Defines a boundary for a version range, which associates a \c Version object * with a flag for whether it is an inclusive or exclusive boundary. * * @param version The \c #Version object. * @param inclusive True if this boundary is inclusive, false if it is exclusive. * * @return A new \c VersionBound object. */ function VersionBound(version, inclusive) { this.version = version; this.inclusive = inclusive; } /** * @class VersionRange * Defines a range of versions using a lower boundary and optional upper boundary. * * @param lowerBound The \c #VersionBound object. * @param upperBound The \c #VersionBound object, or null for a range with no upper boundary. * * @return A new \c VersionRange object. */ function VersionRange(lowerBound, upperBound) { this.lowerBound = lowerBound; this.upperBound = upperBound; } /** * @class Runtime * Represents a runtime related to the CEP infrastructure. * Extensions can declare dependencies on particular * CEP runtime versions in the extension manifest. * * @param name The runtime name. * @param version A \c #VersionRange object that defines a range of valid versions. * * @return A new \c Runtime object. */ function Runtime(name, versionRange) { this.name = name; this.versionRange = versionRange; } /** * @class Extension * Encapsulates a CEP-based extension to an Adobe application. * * @param id The unique identifier of this extension. * @param name The localizable display name of this extension. * @param mainPath The path of the "index.html" file. * @param basePath The base path of this extension. * @param windowType The window type of the main window of this extension. Valid values are defined by \c #CSXSWindowType. * @param width The default width in pixels of the main window of this extension. * @param height The default height in pixels of the main window of this extension. * @param minWidth The minimum width in pixels of the main window of this extension. * @param minHeight The minimum height in pixels of the main window of this extension. * @param maxWidth The maximum width in pixels of the main window of this extension. * @param maxHeight The maximum height in pixels of the main window of this extension. * @param defaultExtensionDataXml The extension data contained in the default \c ExtensionDispatchInfo section of the extension manifest. * @param specialExtensionDataXml The extension data contained in the application-specific \c ExtensionDispatchInfo section of the extension manifest. * @param requiredRuntimeList An array of \c Runtime objects for runtimes required by this extension. * @param isAutoVisible True if this extension is visible on loading. * @param isPluginExtension True if this extension has been deployed in the Plugins folder of the host application. * * @return A new \c Extension object. */ function Extension(id, name, mainPath, basePath, windowType, width, height, minWidth, minHeight, maxWidth, maxHeight, defaultExtensionDataXml, specialExtensionDataXml, requiredRuntimeList, isAutoVisible, isPluginExtension) { this.id = id; this.name = name; this.mainPath = mainPath; this.basePath = basePath; this.windowType = windowType; this.width = width; this.height = height; this.minWidth = minWidth; this.minHeight = minHeight; this.maxWidth = maxWidth; this.maxHeight = maxHeight; this.defaultExtensionDataXml = defaultExtensionDataXml; this.specialExtensionDataXml = specialExtensionDataXml; this.requiredRuntimeList = requiredRuntimeList; this.isAutoVisible = isAutoVisible; this.isPluginExtension = isPluginExtension; } /** * @class CSEvent * A standard JavaScript event, the base class for CEP events. * * @param type The name of the event type. * @param scope The scope of event, can be "GLOBAL" or "APPLICATION". * @param appId The unique identifier of the application that generated the event. * @param extensionId The unique identifier of the extension that generated the event. * * @return A new \c CSEvent object */ function CSEvent(type, scope, appId, extensionId) { this.type = type; this.scope = scope; this.appId = appId; this.extensionId = extensionId; } /** Event-specific data. */ CSEvent.prototype.data = ""; /** * @class SystemPath * Stores operating-system-specific location constants for use in the * \c #CSInterface.getSystemPath() method. * @return A new \c SystemPath object. */ function SystemPath() { } /** The path to user data. */ SystemPath.USER_DATA = "userData"; /** The path to common files for Adobe applications. */ SystemPath.COMMON_FILES = "commonFiles"; /** The path to the user's default document folder. */ SystemPath.MY_DOCUMENTS = "myDocuments"; /** @deprecated. Use \c #SystemPath.Extension. */ SystemPath.APPLICATION = "application"; /** The path to current extension. */ SystemPath.EXTENSION = "extension"; /** The path to hosting application's executable. */ SystemPath.HOST_APPLICATION = "hostApplication"; /** * @class ColorType * Stores color-type constants. */ function ColorType() { } /** RGB color type. */ ColorType.RGB = "rgb"; /** Gradient color type. */ ColorType.GRADIENT = "gradient"; /** Null color type. */ ColorType.NONE = "none"; /** * @class RGBColor * Stores an RGB color with red, green, blue, and alpha values. * All values are in the range [0.0 to 255.0]. Invalid numeric values are * converted to numbers within this range. * * @param red The red value, in the range [0.0 to 255.0]. * @param green The green value, in the range [0.0 to 255.0]. * @param blue The blue value, in the range [0.0 to 255.0]. * @param alpha The alpha (transparency) value, in the range [0.0 to 255.0]. * The default, 255.0, means that the color is fully opaque. * * @return A new RGBColor object. */ function RGBColor(red, green, blue, alpha) { this.red = red; this.green = green; this.blue = blue; this.alpha = alpha; } /** * @class Direction * A point value in which the y component is 0 and the x component * is positive or negative for a right or left direction, * or the x component is 0 and the y component is positive or negative for * an up or down direction. * * @param x The horizontal component of the point. * @param y The vertical component of the point. * * @return A new \c Direction object. */ function Direction(x, y) { this.x = x; this.y = y; } /** * @class GradientStop * Stores gradient stop information. * * @param offset The offset of the gradient stop, in the range [0.0 to 1.0]. * @param rgbColor The color of the gradient at this point, an \c #RGBColor object. * * @return GradientStop object. */ function GradientStop(offset, rgbColor) { this.offset = offset; this.rgbColor = rgbColor; } /** * @class GradientColor * Stores gradient color information. * * @param type The gradient type, must be "linear". * @param direction A \c #Direction object for the direction of the gradient (up, down, right, or left). * @param numStops The number of stops in the gradient. * @param gradientStopList An array of \c #GradientStop objects. * * @return A new \c GradientColor object. */ function GradientColor(type, direction, numStops, arrGradientStop) { this.type = type; this.direction = direction; this.numStops = numStops; this.arrGradientStop = arrGradientStop; } /** * @class UIColor * Stores color information, including the type, anti-alias level, and specific color * values in a color object of an appropriate type. * * @param type The color type, 1 for "rgb" and 2 for "gradient". The supplied color object must correspond to this type. * @param antialiasLevel The anti-alias level constant. * @param color A \c #RGBColor or \c #GradientColor object containing specific color information. * * @return A new \c UIColor object. */ function UIColor(type, antialiasLevel, color) { this.type = type; this.antialiasLevel = antialiasLevel; this.color = color; } /** * @class AppSkinInfo * Stores window-skin properties, such as color and font. All color parameter values are \c #UIColor objects except that systemHighlightColor is \c #RGBColor object. * * @param baseFontFamily The base font family of the application. * @param baseFontSize The base font size of the application. * @param appBarBackgroundColor The application bar background color. * @param panelBackgroundColor The background color of the extension panel. * @param appBarBackgroundColorSRGB The application bar background color, as sRGB. * @param panelBackgroundColorSRGB The background color of the extension panel, as sRGB. * @param systemHighlightColor The highlight color of the extension panel, if provided by the host application. Otherwise, the operating-system highlight color. * * @return AppSkinInfo object. */ function AppSkinInfo(baseFontFamily, baseFontSize, appBarBackgroundColor, panelBackgroundColor, appBarBackgroundColorSRGB, panelBackgroundColorSRGB, systemHighlightColor) { this.baseFontFamily = baseFontFamily; this.baseFontSize = baseFontSize; this.appBarBackgroundColor = appBarBackgroundColor; this.panelBackgroundColor = panelBackgroundColor; this.appBarBackgroundColorSRGB = appBarBackgroundColorSRGB; this.panelBackgroundColorSRGB = panelBackgroundColorSRGB; this.systemHighlightColor = systemHighlightColor; } /** * @class HostEnvironment * Stores information about the environment in which the extension is loaded. * * @param appName The application's name. * @param appVersion The application's version. * @param appLocale The application's current license locale. * @param appUILocale The application's current UI locale. * @param appId The application's unique identifier. * @param isAppOnline True if the application is currently online. * @param appSkinInfo An \c #AppSkinInfo object containing the application's default color and font styles. * * @return A new \c HostEnvironment object. */ function HostEnvironment(appName, appVersion, appLocale, appUILocale, appId, isAppOnline, appSkinInfo) { this.appName = appName; this.appVersion = appVersion; this.appLocale = appLocale; this.appUILocale = appUILocale; this.appId = appId; this.isAppOnline = isAppOnline; this.appSkinInfo = appSkinInfo; } /** * @class HostCapabilities * Stores information about the host capabilities. * * @param EXTENDED_PANEL_MENU True if the application supports panel menu. * @param EXTENDED_PANEL_ICONS True if the application supports panel icon. * @param DELEGATE_APE_ENGINE True if the application supports delegated APE engine. * @param SUPPORT_HTML_EXTENSIONS True if the application supports HTML extensions. * @param DISABLE_FLASH_EXTENSIONS True if the application disables FLASH extensions. * * @return A new \c HostCapabilities object. */ function HostCapabilities(EXTENDED_PANEL_MENU, EXTENDED_PANEL_ICONS, DELEGATE_APE_ENGINE, SUPPORT_HTML_EXTENSIONS, DISABLE_FLASH_EXTENSIONS) { this.EXTENDED_PANEL_MENU = EXTENDED_PANEL_MENU; this.EXTENDED_PANEL_ICONS = EXTENDED_PANEL_ICONS; this.DELEGATE_APE_ENGINE = DELEGATE_APE_ENGINE; this.SUPPORT_HTML_EXTENSIONS = SUPPORT_HTML_EXTENSIONS; this.DISABLE_FLASH_EXTENSIONS = DISABLE_FLASH_EXTENSIONS; // Since 5.0.0 } /** * @class ApiVersion * Stores current api version. * * Since 4.2.0 * * @param major The major version * @param minor The minor version. * @param micro The micro version. * * @return ApiVersion object. */ function ApiVersion(major, minor, micro) { this.major = major; this.minor = minor; this.micro = micro; } /** * @class MenuItemStatus * Stores flyout menu item status * * Since 5.2.0 * * @param menuItemLabel The menu item label. * @param enabled True if user wants to enable the menu item. * @param checked True if user wants to check the menu item. * * @return MenuItemStatus object. */ function MenuItemStatus(menuItemLabel, enabled, checked) { this.menuItemLabel = menuItemLabel; this.enabled = enabled; this.checked = checked; } /** * @class ContextMenuItemStatus * Stores the status of the context menu item. * * Since 5.2.0 * * @param menuItemID The menu item id. * @param enabled True if user wants to enable the menu item. * @param checked True if user wants to check the menu item. * * @return MenuItemStatus object. */ function ContextMenuItemStatus(menuItemID, enabled, checked) { this.menuItemID = menuItemID; this.enabled = enabled; this.checked = checked; } //------------------------------ CSInterface ---------------------------------- /** * @class CSInterface * This is the entry point to the CEP extensibility infrastructure. * Instantiate this object and use it to: *
    *
  • Access information about the host application in which an extension is running
  • *
  • Launch an extension
  • *
  • Register interest in event notifications, and dispatch events
  • *
* * @return A new \c CSInterface object */ function CSInterface() { } /** * User can add this event listener to handle native application theme color changes. * Callback function gives extensions ability to fine-tune their theme color after the * global theme color has been changed. * The callback function should be like below: * * @example * // event is a CSEvent object, but user can ignore it. * function OnAppThemeColorChanged(event) * { * // Should get a latest HostEnvironment object from application. * var skinInfo = JSON.parse(window.__adobe_cep__.getHostEnvironment()).appSkinInfo; * // Gets the style information such as color info from the skinInfo, * // and redraw all UI controls of your extension according to the style info. * } */ CSInterface.THEME_COLOR_CHANGED_EVENT = "com.adobe.csxs.events.ThemeColorChanged"; /** The host environment data object. */ CSInterface.prototype.hostEnvironment = window.__adobe_cep__ ? JSON.parse(window.__adobe_cep__.getHostEnvironment()) : null; /** Retrieves information about the host environment in which the * extension is currently running. * * @return A \c #HostEnvironment object. */ CSInterface.prototype.getHostEnvironment = function() { this.hostEnvironment = JSON.parse(window.__adobe_cep__.getHostEnvironment()); return this.hostEnvironment; }; /** Closes this extension. */ CSInterface.prototype.closeExtension = function() { window.__adobe_cep__.closeExtension(); }; /** * Retrieves a path for which a constant is defined in the system. * * @param pathType The path-type constant defined in \c #SystemPath , * * @return The platform-specific system path string. */ CSInterface.prototype.getSystemPath = function(pathType) { var path = decodeURI(window.__adobe_cep__.getSystemPath(pathType)); var OSVersion = this.getOSInformation(); if (OSVersion.indexOf("Windows") >= 0) { path = path.replace("file:///", ""); } else if (OSVersion.indexOf("Mac") >= 0) { path = path.replace("file://", ""); } return path; }; /** * Evaluates a JavaScript script, which can use the JavaScript DOM * of the host application. * * @param script The JavaScript script. * @param callback Optional. A callback function that receives the result of execution. * If execution fails, the callback function receives the error message \c EvalScript_ErrMessage. */ CSInterface.prototype.evalScript = function(script, callback) { if(callback === null || callback === undefined) { callback = function(result){}; } window.__adobe_cep__.evalScript(script, callback); }; /** * Retrieves the unique identifier of the application. * in which the extension is currently running. * * @return The unique ID string. */ CSInterface.prototype.getApplicationID = function() { var appId = this.hostEnvironment.appId; return appId; }; /** * Retrieves host capability information for the application * in which the extension is currently running. * * @return A \c #HostCapabilities object. */ CSInterface.prototype.getHostCapabilities = function() { var hostCapabilities = JSON.parse(window.__adobe_cep__.getHostCapabilities() ); return hostCapabilities; }; /** * Triggers a CEP event programmatically. Yoy can use it to dispatch * an event of a predefined type, or of a type you have defined. * * @param event A \c CSEvent object. */ CSInterface.prototype.dispatchEvent = function(event) { if (typeof event.data == "object") { event.data = JSON.stringify(event.data); } window.__adobe_cep__.dispatchEvent(event); }; /** * Registers an interest in a CEP event of a particular type, and * assigns an event handler. * The event infrastructure notifies your extension when events of this type occur, * passing the event object to the registered handler function. * * @param type The name of the event type of interest. * @param listener The JavaScript handler function or method. * @param obj Optional, the object containing the handler method, if any. * Default is null. */ CSInterface.prototype.addEventListener = function(type, listener, obj) { window.__adobe_cep__.addEventListener(type, listener, obj); }; /** * Removes a registered event listener. * * @param type The name of the event type of interest. * @param listener The JavaScript handler function or method that was registered. * @param obj Optional, the object containing the handler method, if any. * Default is null. */ CSInterface.prototype.removeEventListener = function(type, listener, obj) { window.__adobe_cep__.removeEventListener(type, listener, obj); }; /** * Loads and launches another extension, or activates the extension if it is already loaded. * * @param extensionId The extension's unique identifier. * @param startupParams Not currently used, pass "". * * @example * To launch the extension "help" with ID "HLP" from this extension, call: * requestOpenExtension("HLP", ""); * */ CSInterface.prototype.requestOpenExtension = function(extensionId, params) { window.__adobe_cep__.requestOpenExtension(extensionId, params); }; /** * Retrieves the list of extensions currently loaded in the current host application. * The extension list is initialized once, and remains the same during the lifetime * of the CEP session. * * @param extensionIds Optional, an array of unique identifiers for extensions of interest. * If omitted, retrieves data for all extensions. * * @return Zero or more \c #Extension objects. */ CSInterface.prototype.getExtensions = function(extensionIds) { var extensionIdsStr = JSON.stringify(extensionIds); var extensionsStr = window.__adobe_cep__.getExtensions(extensionIdsStr); var extensions = JSON.parse(extensionsStr); return extensions; }; /** * Retrieves network-related preferences. * * @return A JavaScript object containing network preferences. */ CSInterface.prototype.getNetworkPreferences = function() { var result = window.__adobe_cep__.getNetworkPreferences(); var networkPre = JSON.parse(result); return networkPre; }; /** * Initializes the resource bundle for this extension with property values * for the current application and locale. * To support multiple locales, you must define a property file for each locale, * containing keyed display-string values for that locale. * See localization documentation for Extension Builder and related products. * * Keys can be in the * form key.value="localized string", for use in HTML text elements. * For example, in this input element, the localized \c key.value string is displayed * instead of the empty \c value string: * * * * @return An object containing the resource bundle information. */ CSInterface.prototype.initResourceBundle = function() { var resourceBundle = JSON.parse(window.__adobe_cep__.initResourceBundle()); var resElms = document.querySelectorAll('[data-locale]'); for (var n = 0; n < resElms.length; n++) { var resEl = resElms[n]; // Get the resource key from the element. var resKey = resEl.getAttribute('data-locale'); if (resKey) { // Get all the resources that start with the key. for (var key in resourceBundle) { if (key.indexOf(resKey) === 0) { var resValue = resourceBundle[key]; if (key.length == resKey.length) { resEl.innerHTML = resValue; } else if ('.' == key.charAt(resKey.length)) { var attrKey = key.substring(resKey.length + 1); resEl[attrKey] = resValue; } } } } } return resourceBundle; }; /** * Writes installation information to a file. * * @return The file path. */ CSInterface.prototype.dumpInstallationInfo = function() { return window.__adobe_cep__.dumpInstallationInfo(); }; /** * Retrieves version information for the current Operating System, * See http://www.useragentstring.com/pages/Chrome/ for Chrome \c navigator.userAgent values. * * @return A string containing the OS version, or "unknown Operation System". * If user customizes the User Agent by setting CEF command parameter "--user-agent", only * "Mac OS X" or "Windows" will be returned. */ CSInterface.prototype.getOSInformation = function() { var userAgent = navigator.userAgent; if ((navigator.platform == "Win32") || (navigator.platform == "Windows")) { var winVersion = "Windows"; var winBit = ""; if (userAgent.indexOf("Windows") > -1) { if (userAgent.indexOf("Windows NT 5.0") > -1) { winVersion = "Windows 2000"; } else if (userAgent.indexOf("Windows NT 5.1") > -1) { winVersion = "Windows XP"; } else if (userAgent.indexOf("Windows NT 5.2") > -1) { winVersion = "Windows Server 2003"; } else if (userAgent.indexOf("Windows NT 6.0") > -1) { winVersion = "Windows Vista"; } else if (userAgent.indexOf("Windows NT 6.1") > -1) { winVersion = "Windows 7"; } else if (userAgent.indexOf("Windows NT 6.2") > -1) { winVersion = "Windows 8"; } else if (userAgent.indexOf("Windows NT 6.3") > -1) { winVersion = "Windows 8.1"; } else if (userAgent.indexOf("Windows NT 10") > -1) { winVersion = "Windows 10"; } if (userAgent.indexOf("WOW64") > -1 || userAgent.indexOf("Win64") > -1) { winBit = " 64-bit"; } else { winBit = " 32-bit"; } } return winVersion + winBit; } else if ((navigator.platform == "MacIntel") || (navigator.platform == "Macintosh")) { var result = "Mac OS X"; if (userAgent.indexOf("Mac OS X") > -1) { result = userAgent.substring(userAgent.indexOf("Mac OS X"), userAgent.indexOf(")")); result = result.replace(/_/g, "."); } return result; } return "Unknown Operation System"; }; /** * Opens a page in the default system browser. * * Since 4.2.0 * * @param url The URL of the page/file to open, or the email address. * Must use HTTP/HTTPS/file/mailto protocol. For example: * "http://www.adobe.com" * "https://github.com" * "file:///C:/log.txt" * "mailto:test@adobe.com" * * @return One of these error codes:\n *
    \n *
  • NO_ERROR - 0
  • \n *
  • ERR_UNKNOWN - 1
  • \n *
  • ERR_INVALID_PARAMS - 2
  • \n *
  • ERR_INVALID_URL - 201
  • \n *
\n */ CSInterface.prototype.openURLInDefaultBrowser = function(url) { return cep.util.openURLInDefaultBrowser(url); }; /** * Retrieves extension ID. * * Since 4.2.0 * * @return extension ID. */ CSInterface.prototype.getExtensionID = function() { return window.__adobe_cep__.getExtensionId(); }; /** * Retrieves the scale factor of screen. * On Windows platform, the value of scale factor might be different from operating system's scale factor, * since host application may use its self-defined scale factor. * * Since 4.2.0 * * @return One of the following float number. *
    \n *
  • -1.0 when error occurs
  • \n *
  • 1.0 means normal screen
  • \n *
  • >1.0 means HiDPI screen
  • \n *
\n */ CSInterface.prototype.getScaleFactor = function() { return window.__adobe_cep__.getScaleFactor(); }; /** * Set a handler to detect any changes of scale factor. This only works on Mac. * * Since 4.2.0 * * @param handler The function to be called when scale factor is changed. * */ CSInterface.prototype.setScaleFactorChangedHandler = function(handler) { window.__adobe_cep__.setScaleFactorChangedHandler(handler); }; /** * Retrieves current API version. * * Since 4.2.0 * * @return ApiVersion object. * */ CSInterface.prototype.getCurrentApiVersion = function() { var apiVersion = JSON.parse(window.__adobe_cep__.getCurrentApiVersion()); return apiVersion; }; /** * Set panel flyout menu by an XML. * * Since 5.2.0 * * Register a callback function for "com.adobe.csxs.events.flyoutMenuClicked" to get notified when a * menu item is clicked. * The "data" attribute of event is an object which contains "menuId" and "menuName" attributes. * * Register callback functions for "com.adobe.csxs.events.flyoutMenuOpened" and "com.adobe.csxs.events.flyoutMenuClosed" * respectively to get notified when flyout menu is opened or closed. * * @param menu A XML string which describes menu structure. * An example menu XML: * * * * * * * * * * * * */ CSInterface.prototype.setPanelFlyoutMenu = function(menu) { if ("string" != typeof menu) { return; } window.__adobe_cep__.invokeSync("setPanelFlyoutMenu", menu); }; /** * Updates a menu item in the extension window's flyout menu, by setting the enabled * and selection status. * * Since 5.2.0 * * @param menuItemLabel The menu item label. * @param enabled True to enable the item, false to disable it (gray it out). * @param checked True to select the item, false to deselect it. * * @return false when the host application does not support this functionality (HostCapabilities.EXTENDED_PANEL_MENU is false). * Fails silently if menu label is invalid. * * @see HostCapabilities.EXTENDED_PANEL_MENU */ CSInterface.prototype.updatePanelMenuItem = function(menuItemLabel, enabled, checked) { var ret = false; if (this.getHostCapabilities().EXTENDED_PANEL_MENU) { var itemStatus = new MenuItemStatus(menuItemLabel, enabled, checked); ret = window.__adobe_cep__.invokeSync("updatePanelMenuItem", JSON.stringify(itemStatus)); } return ret; }; /** * Set context menu by XML string. * * Since 5.2.0 * * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. * - an item without menu ID or menu name is disabled and is not shown. * - if the item name is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. * - Checkable attribute takes precedence over Checked attribute. * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. The Chrome extension contextMenus API was taken as a reference. https://developer.chrome.com/extensions/contextMenus * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. * * @param menu A XML string which describes menu structure. * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. * * @description An example menu XML: * * * * * * * * * * * */ CSInterface.prototype.setContextMenu = function(menu, callback) { if ("string" != typeof menu) { return; } window.__adobe_cep__.invokeAsync("setContextMenu", menu, callback); }; /** * Set context menu by JSON string. * * Since 6.0.0 * * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. * - an item without menu ID or menu name is disabled and is not shown. * - if the item label is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. * - Checkable attribute takes precedence over Checked attribute. * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. The Chrome extension contextMenus API was taken as a reference. * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. https://developer.chrome.com/extensions/contextMenus * * @param menu A JSON string which describes menu structure. * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. * * @description An example menu JSON: * * { * "menu": [ * { * "id": "menuItemId1", * "label": "testExample1", * "enabled": true, * "checkable": true, * "checked": false, * "icon": "./image/small_16X16.png" * }, * { * "id": "menuItemId2", * "label": "testExample2", * "menu": [ * { * "id": "menuItemId2-1", * "label": "testExample2-1", * "menu": [ * { * "id": "menuItemId2-1-1", * "label": "testExample2-1-1", * "enabled": false, * "checkable": true, * "checked": true * } * ] * }, * { * "id": "menuItemId2-2", * "label": "testExample2-2", * "enabled": true, * "checkable": true, * "checked": true * } * ] * }, * { * "label": "---" * }, * { * "id": "menuItemId3", * "label": "testExample3", * "enabled": false, * "checkable": true, * "checked": false * } * ] * } * */ CSInterface.prototype.setContextMenuByJSON = function(menu, callback) { if ("string" != typeof menu) { return; } window.__adobe_cep__.invokeAsync("setContextMenuByJSON", menu, callback); }; /** * Updates a context menu item by setting the enabled and selection status. * * Since 5.2.0 * * @param menuItemID The menu item ID. * @param enabled True to enable the item, false to disable it (gray it out). * @param checked True to select the item, false to deselect it. */ CSInterface.prototype.updateContextMenuItem = function(menuItemID, enabled, checked) { var itemStatus = new ContextMenuItemStatus(menuItemID, enabled, checked); ret = window.__adobe_cep__.invokeSync("updateContextMenuItem", JSON.stringify(itemStatus)); }; /** * Get the visibility status of an extension window. * * Since 6.0.0 * * @return true if the extension window is visible; false if the extension window is hidden. */ CSInterface.prototype.isWindowVisible = function() { return window.__adobe_cep__.invokeSync("isWindowVisible", ""); }; /** * Resize extension's content to the specified dimensions. * 1. Works with modal and modeless extensions in all Adobe products. * 2. Extension's manifest min/max size constraints apply and take precedence. * 3. For panel extensions * 3.1 This works in all Adobe products except: * * Premiere Pro * * Prelude * * After Effects * 3.2 When the panel is in certain states (especially when being docked), * it will not change to the desired dimensions even when the * specified size satisfies min/max constraints. * * Since 6.0.0 * * @param width The new width * @param height The new height */ CSInterface.prototype.resizeContent = function(width, height) { window.__adobe_cep__.resizeContent(width, height); }; /** * Register the invalid certificate callback for an extension. * This callback will be triggered when the extension tries to access the web site that contains the invalid certificate on the main frame. * But if the extension does not call this function and tries to access the web site containing the invalid certificate, a default error page will be shown. * * Since 6.1.0 * * @param callback the callback function */ CSInterface.prototype.registerInvalidCertificateCallback = function(callback) { return window.__adobe_cep__.registerInvalidCertificateCallback(callback); }; /** * Register an interest in some key events to prevent them from being sent to the host application. * * This function works with modeless extensions and panel extensions. * Generally all the key events will be sent to the host application for these two extensions if the current focused element * is not text input or dropdown, * If you want to intercept some key events and want them to be handled in the extension, please call this function * in advance to prevent them being sent to the host application. * * Since 6.1.0 * * @param keyEventsInterest A JSON string describing those key events you are interested in. A null object or an empty string will lead to removing the interest * * This JSON string should be an array, each object has following keys: * * keyCode: [Required] represents an OS system dependent virtual key code identifying * the unmodified value of the pressed key. * ctrlKey: [optional] a Boolean that indicates if the control key was pressed (true) or not (false) when the event occurred. * altKey: [optional] a Boolean that indicates if the alt key was pressed (true) or not (false) when the event occurred. * shiftKey: [optional] a Boolean that indicates if the shift key was pressed (true) or not (false) when the event occurred. * metaKey: [optional] (Mac Only) a Boolean that indicates if the Meta key was pressed (true) or not (false) when the event occurred. * On Macintosh keyboards, this is the command key. To detect Windows key on Windows, please use keyCode instead. * An example JSON string: * * [ * { * "keyCode": 48 * }, * { * "keyCode": 123, * "ctrlKey": true * }, * { * "keyCode": 123, * "ctrlKey": true, * "metaKey": true * } * ] * */ CSInterface.prototype.registerKeyEventsInterest = function(keyEventsInterest) { return window.__adobe_cep__.registerKeyEventsInterest(keyEventsInterest); }; /** * Set the title of the extension window. * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. * * Since 6.1.0 * * @param title The window title. */ CSInterface.prototype.setWindowTitle = function(title) { window.__adobe_cep__.invokeSync("setWindowTitle", title); }; /** * Get the title of the extension window. * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. * * Since 6.1.0 * * @return The window title. */ CSInterface.prototype.getWindowTitle = function() { return window.__adobe_cep__.invokeSync("getWindowTitle", ""); }; ================================================ FILE: openpype/hosts/photoshop/api/extension/client/client.js ================================================ // client facing part of extension, creates WSRPC client (jsx cannot // do that) // consumes RPC calls from server (OpenPype) calls ./host/index.jsx and // returns values back (in json format) var logReturn = function(result){ log.warn('Result: ' + result);}; var csInterface = new CSInterface(); log.warn("script start"); WSRPC.DEBUG = false; WSRPC.TRACE = false; function myCallBack(){ log.warn("Triggered index.jsx"); } // importing through manifest.xml isn't working because relative paths // possibly TODO jsx.evalFile('./host/index.jsx', myCallBack); function runEvalScript(script) { // because of asynchronous nature of functions in jsx // this waits for response return new Promise(function(resolve, reject){ csInterface.evalScript(script, resolve); }); } /** main entry point **/ startUp("WEBSOCKET_URL"); // get websocket server url from environment value async function startUp(url){ log.warn("url", url); promis = runEvalScript("getEnv('" + url + "')"); var res = await promis; // run rest only after resolved promise main(res); } function get_extension_version(){ /** Returns version number from extension manifest.xml **/ log.debug("get_extension_version") var path = csInterface.getSystemPath(SystemPath.EXTENSION); log.debug("extension path " + path); var result = window.cep.fs.readFile(path + "/CSXS/manifest.xml"); var version = undefined; if(result.err === 0){ if (window.DOMParser) { const parser = new DOMParser(); const xmlDoc = parser.parseFromString(result.data.toString(), 'text/xml'); const children = xmlDoc.children; for (let i = 0; i <= children.length; i++) { if (children[i] && children[i].getAttribute('ExtensionBundleVersion')) { version = children[i].getAttribute('ExtensionBundleVersion'); } } } } return version } function main(websocket_url){ // creates connection to 'websocket_url', registers routes log.warn("websocket_url", websocket_url); var default_url = 'ws://localhost:8099/ws/'; if (websocket_url == ''){ websocket_url = default_url; } log.warn("connecting to:", websocket_url); RPC = new WSRPC(websocket_url, 5000); // spin connection RPC.connect(); log.warn("connected"); function EscapeStringForJSX(str){ // Replaces: // \ with \\ // ' with \' // " with \" // See: https://stackoverflow.com/a/3967927/5285364 return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); } RPC.addRoute('Photoshop.open', function (data) { log.warn('Server called client route "open":', data); var escapedPath = EscapeStringForJSX(data.path); return runEvalScript("fileOpen('" + escapedPath +"')") .then(function(result){ log.warn("open: " + result); return result; }); }); RPC.addRoute('Photoshop.read', function (data) { log.warn('Server called client route "read":', data); return runEvalScript("getHeadline()") .then(function(result){ log.warn("getHeadline: " + result); return result; }); }); RPC.addRoute('Photoshop.get_layers', function (data) { log.warn('Server called client route "get_layers":', data); return runEvalScript("getLayers()") .then(function(result){ log.warn("getLayers: " + result); return result; }); }); RPC.addRoute('Photoshop.set_visible', function (data) { log.warn('Server called client route "set_visible":', data); return runEvalScript("setVisible(" + data.layer_id + ", " + data.visibility + ")") .then(function(result){ log.warn("setVisible: " + result); return result; }); }); RPC.addRoute('Photoshop.get_active_document_name', function (data) { log.warn('Server called client route "get_active_document_name":', data); return runEvalScript("getActiveDocumentName()") .then(function(result){ log.warn("save: " + result); return result; }); }); RPC.addRoute('Photoshop.get_active_document_full_name', function (data) { log.warn('Server called client route ' + '"get_active_document_full_name":', data); return runEvalScript("getActiveDocumentFullName()") .then(function(result){ log.warn("save: " + result); return result; }); }); RPC.addRoute('Photoshop.save', function (data) { log.warn('Server called client route "save":', data); return runEvalScript("save()") .then(function(result){ log.warn("save: " + result); return result; }); }); RPC.addRoute('Photoshop.get_selected_layers', function (data) { log.warn('Server called client route "get_selected_layers":', data); return runEvalScript("getSelectedLayers()") .then(function(result){ log.warn("get_selected_layers: " + result); return result; }); }); RPC.addRoute('Photoshop.create_group', function (data) { log.warn('Server called client route "create_group":', data); return runEvalScript("createGroup('" + data.name + "')") .then(function(result){ log.warn("createGroup: " + result); return result; }); }); RPC.addRoute('Photoshop.group_selected_layers', function (data) { log.warn('Server called client route "group_selected_layers":', data); return runEvalScript("groupSelectedLayers(null, "+ "'" + data.name +"')") .then(function(result){ log.warn("group_selected_layers: " + result); return result; }); }); RPC.addRoute('Photoshop.import_smart_object', function (data) { log.warn('Server called client "import_smart_object":', data); var escapedPath = EscapeStringForJSX(data.path); return runEvalScript("importSmartObject('" + escapedPath +"', " + "'"+ data.name +"',"+ + data.as_reference +")") .then(function(result){ log.warn("import_smart_object: " + result); return result; }); }); RPC.addRoute('Photoshop.replace_smart_object', function (data) { log.warn('Server called route "replace_smart_object":', data); var escapedPath = EscapeStringForJSX(data.path); return runEvalScript("replaceSmartObjects("+data.layer_id+"," + "'" + escapedPath +"',"+ "'"+ data.name +"')") .then(function(result){ log.warn("replaceSmartObjects: " + result); return result; }); }); RPC.addRoute('Photoshop.delete_layer', function (data) { log.warn('Server called route "delete_layer":', data); return runEvalScript("deleteLayer("+data.layer_id+")") .then(function(result){ log.warn("delete_layer: " + result); return result; }); }); RPC.addRoute('Photoshop.rename_layer', function (data) { log.warn('Server called route "rename_layer":', data); return runEvalScript("renameLayer("+data.layer_id+", " + "'"+ data.name +"')") .then(function(result){ log.warn("rename_layer: " + result); return result; }); }); RPC.addRoute('Photoshop.select_layers', function (data) { log.warn('Server called client route "select_layers":', data); return runEvalScript("selectLayers('" + data.layers +"')") .then(function(result){ log.warn("select_layers: " + result); return result; }); }); RPC.addRoute('Photoshop.is_saved', function (data) { log.warn('Server called client route "is_saved":', data); return runEvalScript("isSaved()") .then(function(result){ log.warn("is_saved: " + result); return result; }); }); RPC.addRoute('Photoshop.saveAs', function (data) { log.warn('Server called client route "saveAsJPEG":', data); var escapedPath = EscapeStringForJSX(data.image_path); return runEvalScript("saveAs('" + escapedPath + "', " + "'" + data.ext + "', " + data.as_copy + ")") .then(function(result){ log.warn("save: " + result); return result; }); }); RPC.addRoute('Photoshop.imprint', function (data) { log.warn('Server called client route "imprint":', data); var escaped = data.payload.replace(/\n/g, "\\n"); return runEvalScript("imprint('" + escaped + "')") .then(function(result){ log.warn("imprint: " + result); return result; }); }); RPC.addRoute('Photoshop.get_extension_version', function (data) { log.warn('Server called client route "get_extension_version":', data); return get_extension_version(); }); RPC.addRoute('Photoshop.close', function (data) { log.warn('Server called client route "close":', data); return runEvalScript("close()"); }); RPC.call('Photoshop.ping').then(function (data) { log.warn('Result for calling server route "ping": ', data); return runEvalScript("ping()") .then(function(result){ log.warn("ping: " + result); return result; }); }, function (error) { log.warn(error); }); } log.warn("end script"); ================================================ FILE: openpype/hosts/photoshop/api/extension/client/wsrpc.js ================================================ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = global || self, global.WSRPC = factory()); }(this, function () { 'use strict'; function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } var Deferred = function Deferred() { _classCallCheck(this, Deferred); var self = this; self.resolve = null; self.reject = null; self.done = false; function wrapper(func) { return function () { if (self.done) throw new Error('Promise already done'); self.done = true; return func.apply(this, arguments); }; } self.promise = new Promise(function (resolve, reject) { self.resolve = wrapper(resolve); self.reject = wrapper(reject); }); self.promise.isPending = function () { return !self.done; }; return self; }; function logGroup(group, level, args) { console.group(group); console[level].apply(this, args); console.groupEnd(); } function log() { if (!WSRPC.DEBUG) return; logGroup('WSRPC.DEBUG', 'trace', arguments); } function trace(msg) { if (!WSRPC.TRACE) return; var payload = msg; if ('data' in msg) payload = JSON.parse(msg.data); logGroup("WSRPC.TRACE", 'trace', [payload]); } function getAbsoluteWsUrl(url) { if (/^\w+:\/\//.test(url)) return url; if (typeof window == 'undefined' && window.location.host.length < 1) throw new Error("Can not construct absolute URL from ".concat(window.location)); var scheme = window.location.protocol === "https:" ? "wss:" : "ws:"; var port = window.location.port === '' ? ":".concat(window.location.port) : ''; var host = window.location.host; var path = url.replace(/^\/+/gm, ''); return "".concat(scheme, "//").concat(host).concat(port, "/").concat(path); } var readyState = Object.freeze({ 0: 'CONNECTING', 1: 'OPEN', 2: 'CLOSING', 3: 'CLOSED' }); var WSRPC = function WSRPC(URL) { var reconnectTimeout = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1000; _classCallCheck(this, WSRPC); var self = this; URL = getAbsoluteWsUrl(URL); self.id = 1; self.eventId = 0; self.socketStarted = false; self.eventStore = { onconnect: {}, onerror: {}, onclose: {}, onchange: {} }; self.connectionNumber = 0; self.oneTimeEventStore = { onconnect: [], onerror: [], onclose: [], onchange: [] }; self.callQueue = []; function createSocket() { var ws = new WebSocket(URL); var rejectQueue = function rejectQueue() { self.connectionNumber++; // rejects incoming calls var deferred; //reject all pending calls while (0 < self.callQueue.length) { var callObj = self.callQueue.shift(); deferred = self.store[callObj.id]; delete self.store[callObj.id]; if (deferred && deferred.promise.isPending()) { deferred.reject('WebSocket error occurred'); } } // reject all from the store for (var key in self.store) { if (!self.store.hasOwnProperty(key)) continue; deferred = self.store[key]; if (deferred && deferred.promise.isPending()) { deferred.reject('WebSocket error occurred'); } } }; function reconnect(callEvents) { setTimeout(function () { try { self.socket = createSocket(); self.id = 1; } catch (exc) { callEvents('onerror', exc); delete self.socket; console.error(exc); } }, reconnectTimeout); } ws.onclose = function (err) { log('ONCLOSE CALLED', 'STATE', self.public.state()); trace(err); for (var serial in self.store) { if (!self.store.hasOwnProperty(serial)) continue; if (self.store[serial].hasOwnProperty('reject')) { self.store[serial].reject('Connection closed'); } } rejectQueue(); callEvents('onclose', err); callEvents('onchange', err); reconnect(callEvents); }; ws.onerror = function (err) { log('ONERROR CALLED', 'STATE', self.public.state()); trace(err); rejectQueue(); callEvents('onerror', err); callEvents('onchange', err); log('WebSocket has been closed by error: ', err); }; function tryCallEvent(func, event) { try { return func(event); } catch (e) { if (e.hasOwnProperty('stack')) { log(e.stack); } else { log('Event function', func, 'raised unknown error:', e); } console.error(e); } } function callEvents(evName, event) { while (0 < self.oneTimeEventStore[evName].length) { var deferred = self.oneTimeEventStore[evName].shift(); if (deferred.hasOwnProperty('resolve') && deferred.promise.isPending()) deferred.resolve(); } for (var i in self.eventStore[evName]) { if (!self.eventStore[evName].hasOwnProperty(i)) continue; var cur = self.eventStore[evName][i]; tryCallEvent(cur, event); } } ws.onopen = function (ev) { log('ONOPEN CALLED', 'STATE', self.public.state()); trace(ev); while (0 < self.callQueue.length) { // noinspection JSUnresolvedFunction self.socket.send(JSON.stringify(self.callQueue.shift(), 0, 1)); } callEvents('onconnect', ev); callEvents('onchange', ev); }; function handleCall(self, data) { if (!self.routes.hasOwnProperty(data.method)) throw new Error('Route not found'); var connectionNumber = self.connectionNumber; var deferred = new Deferred(); deferred.promise.then(function (result) { if (connectionNumber !== self.connectionNumber) return; self.socket.send(JSON.stringify({ id: data.id, result: result })); }, function (error) { if (connectionNumber !== self.connectionNumber) return; self.socket.send(JSON.stringify({ id: data.id, error: error })); }); var func = self.routes[data.method]; if (self.asyncRoutes[data.method]) return func.apply(deferred, [data.params]); function badPromise() { throw new Error("You should register route with async flag."); } var promiseMock = { resolve: badPromise, reject: badPromise }; try { deferred.resolve(func.apply(promiseMock, [data.params])); } catch (e) { deferred.reject(e); console.error(e); } } function handleError(self, data) { if (!self.store.hasOwnProperty(data.id)) return log('Unknown callback'); var deferred = self.store[data.id]; if (typeof deferred === 'undefined') return log('Confirmation without handler'); delete self.store[data.id]; log('REJECTING', data.error); deferred.reject(data.error); } function handleResult(self, data) { var deferred = self.store[data.id]; if (typeof deferred === 'undefined') return log('Confirmation without handler'); delete self.store[data.id]; if (data.hasOwnProperty('result')) { return deferred.resolve(data.result); } return deferred.reject(data.error); } ws.onmessage = function (message) { log('ONMESSAGE CALLED', 'STATE', self.public.state()); trace(message); if (message.type !== 'message') return; var data; try { data = JSON.parse(message.data); log(data); if (data.hasOwnProperty('method')) { return handleCall(self, data); } else if (data.hasOwnProperty('error') && data.error === null) { return handleError(self, data); } else { return handleResult(self, data); } } catch (exception) { var err = { error: exception.message, result: null, id: data ? data.id : null }; self.socket.send(JSON.stringify(err)); console.error(exception); } }; return ws; } function makeCall(func, args, params) { self.id += 2; var deferred = new Deferred(); var callObj = Object.freeze({ id: self.id, method: func, params: args }); var state = self.public.state(); if (state === 'OPEN') { self.store[self.id] = deferred; self.socket.send(JSON.stringify(callObj)); } else if (state === 'CONNECTING') { log('SOCKET IS', state); self.store[self.id] = deferred; self.callQueue.push(callObj); } else { log('SOCKET IS', state); if (params && params['noWait']) { deferred.reject("Socket is: ".concat(state)); } else { self.store[self.id] = deferred; self.callQueue.push(callObj); } } return deferred.promise; } self.asyncRoutes = {}; self.routes = {}; self.store = {}; self.public = Object.freeze({ call: function call(func, args, params) { return makeCall(func, args, params); }, addRoute: function addRoute(route, callback, isAsync) { self.asyncRoutes[route] = isAsync || false; self.routes[route] = callback; }, deleteRoute: function deleteRoute(route) { delete self.asyncRoutes[route]; return delete self.routes[route]; }, addEventListener: function addEventListener(event, func) { var eventId = self.eventId++; self.eventStore[event][eventId] = func; return eventId; }, removeEventListener: function removeEventListener(event, index) { if (self.eventStore[event].hasOwnProperty(index)) { delete self.eventStore[event][index]; return true; } else { return false; } }, onEvent: function onEvent(event) { var deferred = new Deferred(); self.oneTimeEventStore[event].push(deferred); return deferred.promise; }, destroy: function destroy() { return self.socket.close(); }, state: function state() { return readyState[this.stateCode()]; }, stateCode: function stateCode() { if (self.socketStarted && self.socket) return self.socket.readyState; return 3; }, connect: function connect() { self.socketStarted = true; self.socket = createSocket(); } }); self.public.addRoute('log', function (argsObj) { //console.info("Websocket sent: ".concat(argsObj)); }); self.public.addRoute('ping', function (data) { return data; }); return self.public; }; WSRPC.DEBUG = false; WSRPC.TRACE = false; return WSRPC; })); //# sourceMappingURL=wsrpc.js.map ================================================ FILE: openpype/hosts/photoshop/api/extension/host/JSX.js ================================================ /* _ ______ __ _ | / ___\ \/ / (_)___ _ | \___ \\ / | / __| | |_| |___) / \ _ | \__ \ \___/|____/_/\_(_)/ |___/ |__/ _ ____ /\ /\___ _ __ ___(_) ___ _ __ |___ \ \ \ / / _ \ '__/ __| |/ _ \| '_ \ __) | \ V / __/ | \__ \ | (_) | | | | / __/ \_/ \___|_| |___/_|\___/|_| |_| |_____| */ ////////////////////////////////////////////////////////////////////////////////// // JSX.js and writtent by Trevor https://creative-scripts.com/jsx-js // // If you turn over is less the $50,000,000 then you don't have to pay anything // // License MIT, don't complain, don't sue NO MATTER WHAT // // If you turn over is more the $50,000,000 then you DO have to pay // // Contact me https://creative-scripts.com/contact for pricing and licensing // // Don't remove these commented lines // // For simple and effective calling of jsx from the js engine // // Version 2 last modified April 18 2018 // ////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////// // Change log: // // JSX.js V2 is now independent of NodeJS and CSInterface.js // // forceEval is now by default true // // It wraps the scripts in a try catch and an eval providing useful error handling // // One can set in the jsx engine $.includeStack = true to return the call stack in the event of an error // /////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////// // JSX.js for calling jsx code from the js engine // // 2 methods included // // 1) jsx.evalScript AKA jsx.eval // // 2) jsx.evalFile AKA jsx.file // // Special features // // 1) Allows all changes in your jsx code to be reloaded into your extension at the click of a button // // 2) Can enable the $.fileName property to work and provides a $.__fileName() method as an alternative // // 3) Can force a callBack result from InDesign // // 4) No more csInterface.evalScript('alert("hello "' + title + " " + name + '");') // // use jsx.evalScript('alert("hello __title__ __name__");', {title: title, name: name}); // // 5) execute jsx files from your jsx folder like this jsx.evalFile('myFabJsxScript.jsx'); // // or from a relative path jsx.evalFile('../myFabScripts/myFabJsxScript.jsx'); // // or from an absolute url jsx.evalFile('/Path/to/my/FabJsxScript.jsx'); (mac) // // or from an absolute url jsx.evalFile('C:Path/to/my/FabJsxScript.jsx'); (windows) // // 6) Parameter can be entered in the from of a parameter list which can be in any order or as an object // // 7) Not camelCase sensitive (very useful for the illiterate) // // Dead easy to use BUT SPEND THE 3 TO 5 MINUTES IT SHOULD TAKE TO READ THE INSTRUCTIONS // /////////////////////////////////////////////////////////////////////////////////////////////////////////// /* jshint undef:true, unused:true, esversion:6 */ ////////////////////////////////////// // jsx is the interface for the API // ////////////////////////////////////// var jsx; // Wrap everything in an anonymous function to prevent leeks (function() { ///////////////////////////////////////////////////////////////////// // Substitute some CSInterface functions to avoid dependency on it // ///////////////////////////////////////////////////////////////////// var __dirname = (function() { var path, isMac; path = decodeURI(window.__adobe_cep__.getSystemPath('extension')); isMac = navigator.platform[0] === 'M'; // [M]ac path = path.replace('file://' + (isMac ? '' : '/'), ''); return path; })(); var evalScript = function(script, callback) { callback = callback || function() {}; window.__adobe_cep__.evalScript(script, callback); }; //////////////////////////////////////////// // In place of using the node path module // //////////////////////////////////////////// // jshint undef: true, unused: true // A very minified version of the NodeJs Path module!! // For use outside of NodeJs // Majorly nicked by Trevor from Joyent var path = (function() { var isString = function(arg) { return typeof arg === 'string'; }; // var isObject = function(arg) { // return typeof arg === 'object' && arg !== null; // }; var basename = function(path) { if (!isString(path)) { throw new TypeError('Argument to path.basename must be a string'); } var bits = path.split(/[\/\\]/g); return bits[bits.length - 1]; }; // jshint undef: true // Regex to split a windows path into three parts: [*, device, slash, // tail] windows-only var splitDeviceRe = /^([a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/]+[^\\\/]+)?([\\\/])?([\s\S]*?)$/; // Regex to split the tail part of the above into [*, dir, basename, ext] // var splitTailRe = // /^([\s\S]*?)((?:\.{1,2}|[^\\\/]+?|)(\.[^.\/\\]*|))(?:[\\\/]*)$/; var win32 = {}; // Function to split a filename into [root, dir, basename, ext] // var win32SplitPath = function(filename) { // // Separate device+slash from tail // var result = splitDeviceRe.exec(filename), // device = (result[1] || '') + (result[2] || ''), // tail = result[3] || ''; // // Split the tail into dir, basename and extension // var result2 = splitTailRe.exec(tail), // dir = result2[1], // basename = result2[2], // ext = result2[3]; // return [device, dir, basename, ext]; // }; var win32StatPath = function(path) { var result = splitDeviceRe.exec(path), device = result[1] || '', isUnc = !!device && device[1] !== ':'; return { device: device, isUnc: isUnc, isAbsolute: isUnc || !!result[2], // UNC paths are always absolute tail: result[3] }; }; var normalizeUNCRoot = function(device) { return '\\\\' + device.replace(/^[\\\/]+/, '').replace(/[\\\/]+/g, '\\'); }; var normalizeArray = function(parts, allowAboveRoot) { var res = []; for (var i = 0; i < parts.length; i++) { var p = parts[i]; // ignore empty parts if (!p || p === '.') continue; if (p === '..') { if (res.length && res[res.length - 1] !== '..') { res.pop(); } else if (allowAboveRoot) { res.push('..'); } } else { res.push(p); } } return res; }; win32.normalize = function(path) { var result = win32StatPath(path), device = result.device, isUnc = result.isUnc, isAbsolute = result.isAbsolute, tail = result.tail, trailingSlash = /[\\\/]$/.test(tail); // Normalize the tail path tail = normalizeArray(tail.split(/[\\\/]+/), !isAbsolute).join('\\'); if (!tail && !isAbsolute) { tail = '.'; } if (tail && trailingSlash) { tail += '\\'; } // Convert slashes to backslashes when `device` points to an UNC root. // Also squash multiple slashes into a single one where appropriate. if (isUnc) { device = normalizeUNCRoot(device); } return device + (isAbsolute ? '\\' : '') + tail; }; win32.join = function() { var paths = []; for (var i = 0; i < arguments.length; i++) { var arg = arguments[i]; if (!isString(arg)) { throw new TypeError('Arguments to path.join must be strings'); } if (arg) { paths.push(arg); } } var joined = paths.join('\\'); // Make sure that the joined path doesn't start with two slashes, because // normalize() will mistake it for an UNC path then. // // This step is skipped when it is very clear that the user actually // intended to point at an UNC path. This is assumed when the first // non-empty string arguments starts with exactly two slashes followed by // at least one more non-slash character. // // Note that for normalize() to treat a path as an UNC path it needs to // have at least 2 components, so we don't filter for that here. // This means that the user can use join to construct UNC paths from // a server name and a share name; for example: // path.join('//server', 'share') -> '\\\\server\\share\') if (!/^[\\\/]{2}[^\\\/]/.test(paths[0])) { joined = joined.replace(/^[\\\/]{2,}/, '\\'); } return win32.normalize(joined); }; var posix = {}; // posix version posix.join = function() { var path = ''; for (var i = 0; i < arguments.length; i++) { var segment = arguments[i]; if (!isString(segment)) { throw new TypeError('Arguments to path.join must be strings'); } if (segment) { if (!path) { path += segment; } else { path += '/' + segment; } } } return posix.normalize(path); }; // path.normalize(path) // posix version posix.normalize = function(path) { var isAbsolute = path.charAt(0) === '/', trailingSlash = path && path[path.length - 1] === '/'; // Normalize the path path = normalizeArray(path.split('/'), !isAbsolute).join('/'); if (!path && !isAbsolute) { path = '.'; } if (path && trailingSlash) { path += '/'; } return (isAbsolute ? '/' : '') + path; }; win32.basename = posix.basename = basename; this.win32 = win32; this.posix = posix; return (navigator.platform[0] === 'M') ? posix : win32; })(); //////////////////////////////////////////////////////////////////////////////////////////////////////// // The is the "main" function which is to be prototyped // // It run a small snippet in the jsx engine that // // 1) Assigns $.__dirname with the value of the extensions __dirname base path // // 2) Sets up a method $.__fileName() for retrieving from within the jsx script it's $.fileName value // // more on that method later // // At the end of the script the global declaration jsx = new Jsx(); has been made. // // If you like you can remove that and include in your relevant functions // // var jsx = new Jsx(); You would never call the Jsx function without the "new" declaration // //////////////////////////////////////////////////////////////////////////////////////////////////////// var Jsx = function() { var jsxScript; // Setup jsx function to enable the jsx scripts to easily retrieve their file location jsxScript = [ '$.level = 0;', 'if(!$.__fileNames){', ' $.__fileNames = {};', ' $.__dirname = "__dirname__";'.replace('__dirname__', __dirname), ' $.__fileName = function(name){', ' name = name || $.fileName;', ' return ($.__fileNames && $.__fileNames[name]) || $.fileName;', ' };', '}' ].join(''); evalScript(jsxScript); return this; }; /** * [evalScript] For calling jsx scripts from the js engine * * The jsx.evalScript method is used for calling jsx scripts directly from the js engine * Allows for easy replacement i.e. variable insertions and for forcing eval. * For convenience jsx.eval or jsx.script or jsx.evalscript can be used instead of calling jsx.evalScript * * @param {String} jsxScript * The string that makes up the jsx script * it can contain a simple template like syntax for replacements * 'alert("__foo__");' * the __foo__ will be replaced as per the replacements parameter * * @param {Function} callback * The callback function you want the jsx script to trigger on completion * The result of the jsx script is passed as the argument to that function * The function can exist in some other file. * Note that InDesign does not automatically pass the callBack as a string. * Either write your InDesign in a way that it returns a sting the form of * return 'this is my result surrounded by quotes' * or use the force eval option * [Optional DEFAULT no callBack] * * @param {Object} replacements * The replacements to make on the jsx script * given the following script (template) * 'alert("__message__: " + __val__);' * and we want to change the script to * 'alert("I was born in the year: " + 1234);' * we would pass the following object * {"message": 'I was born in the year', "val": 1234} * or if not using reserved words like do we can leave out the key quotes * {message: 'I was born in the year', val: 1234} * [Optional DEFAULT no replacements] * * @param {Bolean} forceEval * If the script should be wrapped in an eval and try catch * This will 1) provide useful error feedback if heaven forbid it is needed * 2) The result will be a string which is required for callback results in InDesign * [Optional DEFAULT true] * * Note 1) The order of the parameters is irrelevant * Note 2) One can pass the arguments as an object if desired * jsx.evalScript(myCallBackFunction, 'alert("__myMessage__");', true); * is the same as * jsx.evalScript({ * script: 'alert("__myMessage__");', * replacements: {myMessage: 'Hi there'}, * callBack: myCallBackFunction, * eval: true * }); * note that either lower or camelCase key names are valid * i.e. both callback or callBack will work * * The following keys are the same jsx || script || jsxScript || jsxscript || file * The following keys are the same callBack || callback * The following keys are the same replacements || replace * The following keys are the same eval || forceEval || forceeval * The following keys are the same forceEvalScript || forceevalscript || evalScript || evalscript; * * @return {Boolean} if the jsxScript was executed or not */ Jsx.prototype.evalScript = function() { var arg, i, key, replaceThis, withThis, args, callback, forceEval, replacements, jsxScript, isBin; ////////////////////////////////////////////////////////////////////////////////////// // sort out order which arguments into jsxScript, callback, replacements, forceEval // ////////////////////////////////////////////////////////////////////////////////////// args = arguments; // Detect if the parameters were passed as an object and if so allow for various keys if (args.length === 1 && (arg = args[0]) instanceof Object) { jsxScript = arg.jsxScript || arg.jsx || arg.script || arg.file || arg.jsxscript; callback = arg.callBack || arg.callback; replacements = arg.replacements || arg.replace; forceEval = arg.eval || arg.forceEval || arg.forceeval; } else { for (i = 0; i < 4; i++) { arg = args[i]; if (arg === undefined) { continue; } if (arg.constructor === String) { jsxScript = arg; continue; } if (arg.constructor === Object) { replacements = arg; continue; } if (arg.constructor === Function) { callback = arg; continue; } if (arg === false) { forceEval = false; } } } // If no script provide then not too much to do! if (!jsxScript) { return false; } // Have changed the forceEval default to be true as I prefer the error handling if (forceEval !== false) { forceEval = true; } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // On Illustrator and other apps the result of the jsx script is automatically passed as a string // // if you have a "script" containing the single number 1 and nothing else then the callBack will register as "1" // // On InDesign that same script will provide a blank callBack // // Let's say we have a callBack function var callBack = function(result){alert(result);} // // On Ai your see the 1 in the alert // // On ID your just see a blank alert // // To see the 1 in the alert you need to convert the result to a string and then it will show // // So if we rewrite out 1 byte script to '1' i.e. surround the 1 in quotes then the call back alert will show 1 // // If the scripts planed one can make sure that the results always passed as a string (including errors) // // otherwise one can wrap the script in an eval and then have the result passed as a string // // I have not gone through all the apps but can say // // for Ai you never need to set the forceEval to true // // for ID you if you have not coded your script appropriately and your want to send a result to the callBack then set forceEval to true // // I changed this that even on Illustrator it applies the try catch, Note the try catch will fail if $.level is set to 1 // ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// if (forceEval) { isBin = (jsxScript.substring(0, 10) === '@JSXBIN@ES') ? '' : '\n'; jsxScript = ( // "\n''') + '';} catch(e){(function(e){var n, a=[]; for (n in e){a.push(n + ': ' + e[n])}; return a.join('\n')})(e)}"); // "\n''') + '';} catch(e){e + (e.line ? ('\\nLine ' + (+e.line - 1)) : '')}"); [ "$.level = 0;", "try{eval('''" + isBin, // need to add an extra line otherwise #targetengine doesn't work ;-] jsxScript.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"') + "\n''') + '';", "} catch (e) {", " (function(e) {", " var line, sourceLine, name, description, ErrorMessage, fileName, start, end, bug;", " line = +e.line" + (isBin === '' ? ';' : ' - 1;'), // To take into account the extra line added " fileName = File(e.fileName).fsName;", " sourceLine = line && e.source.split(/[\\r\\n]/)[line];", " name = e.name;", " description = e.description;", " ErrorMessage = name + ' ' + e.number + ': ' + description;", " if (fileName.length && !(/[\\/\\\\]\\d+$/.test(fileName))) {", " ErrorMessage += '\\nFile: ' + fileName;", " line++;", " }", " if (line){", " ErrorMessage += '\\nLine: ' + line +", " '-> ' + ((sourceLine.length < 300) ? sourceLine : sourceLine.substring(0,300) + '...');", " }", " if (e.start) {ErrorMessage += '\\nBug: ' + e.source.substring(e.start - 1, e.end)}", " if ($.includeStack) {ErrorMessage += '\\nStack:' + $.stack;}", " return ErrorMessage;", " })(e);", "}" ].join('') ); } ///////////////////////////////////////////////////////////// // deal with the replacements // // Note it's probably better to use ${template} `literals` // ///////////////////////////////////////////////////////////// if (replacements) { for (key in replacements) { if (replacements.hasOwnProperty(key)) { replaceThis = new RegExp('__' + key + '__', 'g'); withThis = replacements[key]; jsxScript = jsxScript.replace(replaceThis, withThis + ''); } } } try { evalScript(jsxScript, callback); return true; } catch (err) { //////////////////////////////////////////////// // Do whatever error handling you want here ! // //////////////////////////////////////////////// var newErr; newErr = new Error(err); alert('Error Eek: ' + newErr.stack); return false; } }; /** * [evalFile] For calling jsx scripts from the js engine * * The jsx.evalFiles method is used for executing saved jsx scripts * where the jsxScript parameter is a string of the jsx scripts file location. * For convenience jsx.file or jsx.evalfile can be used instead of jsx.evalFile * * @param {String} file * The path to jsx script * If only the base name is provided then the path will be presumed to be the * To execute files stored in the jsx folder located in the __dirname folder use * jsx.evalFile('myFabJsxScript.jsx'); * To execute files stored in the a folder myFabScripts located in the __dirname folder use * jsx.evalFile('./myFabScripts/myFabJsxScript.jsx'); * To execute files stored in the a folder myFabScripts located at an absolute url use * jsx.evalFile('/Path/to/my/FabJsxScript.jsx'); (mac) * or jsx.evalFile('C:Path/to/my/FabJsxScript.jsx'); (windows) * * @param {Function} callback * The callback function you want the jsx script to trigger on completion * The result of the jsx script is passed as the argument to that function * The function can exist in some other file. * Note that InDesign does not automatically pass the callBack as a string. * Either write your InDesign in a way that it returns a sting the form of * return 'this is my result surrounded by quotes' * or use the force eval option * [Optional DEFAULT no callBack] * * @param {Object} replacements * The replacements to make on the jsx script * give the following script (template) * 'alert("__message__: " + __val__);' * and we want to change the script to * 'alert("I was born in the year: " + 1234);' * we would pass the following object * {"message": 'I was born in the year', "val": 1234} * or if not using reserved words like do we can leave out the key quotes * {message: 'I was born in the year', val: 1234} * By default when possible the forceEvalScript will be set to true * The forceEvalScript option cannot be true when there are replacements * To force the forceEvalScript to be false you can send a blank set of replacements * jsx.evalFile('myFabScript.jsx', {}); Will NOT be executed using the $.evalScript method * jsx.evalFile('myFabScript.jsx'); Will YES be executed using the $.evalScript method * see the forceEvalScript parameter for details on this * [Optional DEFAULT no replacements] * * @param {Bolean} forceEval * If the script should be wrapped in an eval and try catch * This will 1) provide useful error feedback if heaven forbid it is needed * 2) The result will be a string which is required for callback results in InDesign * [Optional DEFAULT true] * * If no replacements are needed then the jsx script is be executed by using the $.evalFile method * This exposes the true value of the $.fileName property * In such a case it's best to avoid using the $.__fileName() with no base name as it won't work * BUT one can still use the $.__fileName('baseName') method which is more accurate than the standard $.fileName property * Let's say you have a Drive called "Graphics" AND YOU HAVE a root folder on your "main" drive called "Graphics" * You call a script jsx.evalFile('/Volumes/Graphics/myFabScript.jsx'); * $.fileName will give you '/Graphics/myFabScript.jsx' which is wrong * $.__fileName('myFabScript.jsx') will give you '/Volumes/Graphics/myFabScript.jsx' which is correct * $.__fileName() will not give you a reliable result * Note that if your calling multiple versions of myFabScript.jsx stored in multiple folders then you can get stuffed! * i.e. if the fileName is important to you then don't do that. * It also will force the result of the jsx file as a string which is particularly useful for InDesign callBacks * * Note 1) The order of the parameters is irrelevant * Note 2) One can pass the arguments as an object if desired * jsx.evalScript(myCallBackFunction, 'alert("__myMessage__");', true); * is the same as * jsx.evalScript({ * script: 'alert("__myMessage__");', * replacements: {myMessage: 'Hi there'}, * callBack: myCallBackFunction, * eval: false, * }); * note that either lower or camelCase key names or valid * i.e. both callback or callBack will work * * The following keys are the same file || jsx || script || jsxScript || jsxscript * The following keys are the same callBack || callback * The following keys are the same replacements || replace * The following keys are the same eval || forceEval || forceeval * * @return {Boolean} if the jsxScript was executed or not */ Jsx.prototype.evalFile = function() { var arg, args, callback, fileName, fileNameScript, forceEval, forceEvalScript, i, jsxFolder, jsxScript, newLine, replacements, success; success = true; // optimistic args = arguments; jsxFolder = path.join(__dirname, 'jsx'); ////////////////////////////////////////////////////////////////////////////////////////////////////////// // $.fileName does not return it's correct path in the jsx engine for files called from the js engine // // In Illustrator it returns an integer in InDesign it returns an empty string // // This script injection allows for the script to know it's path by calling // // $.__fileName(); // // on Illustrator this works pretty well // // on InDesign it's best to use with a bit of care // // If the a second script has been called the InDesing will "forget" the path to the first script // // 2 work-arounds for this // // 1) at the beginning of your script add var thePathToMeIs = $.fileName(); // // thePathToMeIs will not be forgotten after running the second script // // 2) $.__fileName('myBaseName.jsx'); // // for example you have file with the following path // // /path/to/me.jsx // // Call $.__fileName('me.jsx') and you will get /path/to/me.jsx even after executing a second script // // Note When the forceEvalScript option is used then you just use the regular $.fileName property // ////////////////////////////////////////////////////////////////////////////////////////////////////////// fileNameScript = [ // The if statement should not normally be executed 'if(!$.__fileNames){', ' $.__fileNames = {};', ' $.__dirname = "__dirname__";'.replace('__dirname__', __dirname), ' $.__fileName = function(name){', ' name = name || $.fileName;', ' return ($.__fileNames && $.__fileNames[name]) || $.fileName;', ' };', '}', '$.__fileNames["__basename__"] = $.__fileNames["" + $.fileName] = "__fileName__";' ].join(''); ////////////////////////////////////////////////////////////////////////////////////// // sort out order which arguments into jsxScript, callback, replacements, forceEval // ////////////////////////////////////////////////////////////////////////////////////// // Detect if the parameters were passed as an object and if so allow for various keys if (args.length === 1 && (arg = args[0]) instanceof Object) { jsxScript = arg.jsxScript || arg.jsx || arg.script || arg.file || arg.jsxscript; callback = arg.callBack || arg.callback; replacements = arg.replacements || arg.replace; forceEval = arg.eval || arg.forceEval || arg.forceeval; } else { for (i = 0; i < 5; i++) { arg = args[i]; if (arg === undefined) { continue; } if (arg.constructor.name === 'String') { jsxScript = arg; continue; } if (arg.constructor.name === 'Object') { ////////////////////////////////////////////////////////////////////////////////////////////////////////////// // If no replacements are provided then the $.evalScript method will be used // // This will allow directly for the $.fileName property to be used // // If one does not want the $.evalScript method to be used then // // either send a blank object as the replacements {} // // or explicitly set the forceEvalScript option to false // // This can only be done if the parameters are passed as an object // // i.e. jsx.evalFile({file:'myFabScript.jsx', forceEvalScript: false}); // // if the file was called using // // i.e. jsx.evalFile('myFabScript.jsx'); // // then the following jsx code is called $.evalFile(new File('Path/to/myFabScript.jsx', 10000000000)) + ''; // // forceEval is never needed if the forceEvalScript is triggered // ////////////////////////////////////////////////////////////////////////////////////////////////////////////// replacements = arg; continue; } if (arg.constructor === Function) { callback = arg; continue; } if (arg === false) { forceEval = false; } } } // If no script provide then not too much to do! if (!jsxScript) { return false; } forceEvalScript = !replacements; ////////////////////////////////////////////////////// // Get path of script // // Check if it's literal, relative or in jsx folder // ////////////////////////////////////////////////////// if (/^\/|[a-zA-Z]+:/.test(jsxScript)) { // absolute path Mac | Windows jsxScript = path.normalize(jsxScript); } else if (/^\.+\//.test(jsxScript)) { jsxScript = path.join(__dirname, jsxScript); // relative path } else { jsxScript = path.join(jsxFolder, jsxScript); // files in the jsxFolder } if (forceEvalScript) { jsxScript = jsxScript.replace(/"/g, '\\"'); // Check that the path exist, should change this to asynchronous at some point if (!window.cep.fs.stat(jsxScript).err) { jsxScript = fileNameScript.replace(/__fileName__/, jsxScript).replace(/__basename__/, path.basename(jsxScript)) + '$.evalFile(new File("' + jsxScript.replace(/\\/g, '\\\\') + '")) + "";'; return this.evalScript(jsxScript, callback, forceEval); } else { throw new Error(`The file: {jsxScript} could not be found / read`); } } //////////////////////////////////////////////////////////////////////////////////////////////// // Replacements made so we can't use $.evalFile and need to read the jsx script for ourselves // //////////////////////////////////////////////////////////////////////////////////////////////// fileName = jsxScript.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); try { jsxScript = window.cep.fs.readFile(jsxScript).data; } catch (er) { throw new Error(`The file: ${fileName} could not be read`); } // It is desirable that the injected fileNameScript is on the same line as the 1st line of the script // This is so that the $.line or error.line returns the same value as the actual file // However if the 1st line contains a # directive then we need to insert a new line and stuff the above problem // When possible i.e. when there's no replacements then $.evalFile will be used and then the whole issue is avoided newLine = /^\s*#/.test(jsxScript) ? '\n' : ''; jsxScript = fileNameScript.replace(/__fileName__/, fileName).replace(/__basename__/, path.basename(fileName)) + newLine + jsxScript; try { // evalScript(jsxScript, callback); return this.evalScript(jsxScript, callback, replacements, forceEval); } catch (err) { //////////////////////////////////////////////// // Do whatever error handling you want here ! // //////////////////////////////////////////////// var newErr; newErr = new Error(err); alert('Error Eek: ' + newErr.stack); return false; } return success; // success should be an array but for now it's a Boolean }; //////////////////////////////////// // Setup alternative method names // //////////////////////////////////// Jsx.prototype.eval = Jsx.prototype.script = Jsx.prototype.evalscript = Jsx.prototype.evalScript; Jsx.prototype.file = Jsx.prototype.evalfile = Jsx.prototype.evalFile; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Examples // // jsx.evalScript('alert("foo");'); // // jsx.evalFile('foo.jsx'); // where foo.jsx is stored in the jsx folder at the base of the extensions directory // // jsx.evalFile('../myFolder/foo.jsx'); // where a relative or absolute file path is given // // // // using conventional methods one would use in the case were the values to swap were supplied by variables // // csInterface.evalScript('var q = "' + name + '"; alert("' + myString + '" ' + myOp + ' q);q;', callback); // // Using all the '' + foo + '' is very error prone // // jsx.evalScript('var q = "__name__"; alert(__string__ __opp__ q);q;',{'name':'Fred', 'string':'Hello ', 'opp':'+'}, callBack); // // is much simpler and less error prone // // // // more readable to use object // // jsx.evalFile({ // // file: 'yetAnotherFabScript.jsx', // // replacements: {"this": foo, That: bar, and: "&&", the: foo2, other: bar2}, // // eval: true // // }) // // Enjoy // /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// jsx = new Jsx(); })(); ================================================ FILE: openpype/hosts/photoshop/api/extension/host/index.jsx ================================================ #include "json.js"; #target photoshop var LogFactory=function(file,write,store,level,defaultStatus,continuing){if(file&&(file.constructor===String||file.constructor===File)){file={file:file};}else if(!file)file={file:{}};write=(file.write!==undefined)?file.write:write;if(write===undefined){write=true;}store=(file.store!==undefined)?file.store||false:store||false;level=(file.level!==undefined)?file.level:level;defaultStatus=(file.defaultStatus!==undefined)?file.defaultStatus:defaultStatus;if(defaultStatus===undefined){defaultStatus='LOG';}continuing=(file.continuing!==undefined)?file.continuing:continuing||false;file=file.file||{};var stack,times,logTime,logPoint,icons,statuses,LOG_LEVEL,LOG_STATUS;stack=[];times=[];logTime=new Date();logPoint='Log Factory Start';icons={"1":"\ud83d\udd50","130":"\ud83d\udd5c","2":"\ud83d\udd51","230":"\ud83d\udd5d","3":"\ud83d\udd52","330":"\ud83d\udd5e","4":"\ud83d\udd53","430":"\ud83d\udd5f","5":"\ud83d\udd54","530":"\ud83d\udd60","6":"\ud83d\udd55","630":"\ud83d\udd61","7":"\ud83d\udd56","730":"\ud83d\udd62","8":"\ud83d\udd57","830":"\ud83d\udd63","9":"\ud83d\udd58","930":"\ud83d\udd64","10":"\ud83d\udd59","1030":"\ud83d\udd65","11":"\ud83d\udd5a","1130":"\ud83d\udd66","12":"\ud83d\udd5b","1230":"\ud83d\udd67","AIRPLANE":"\ud83d\udee9","ALARM":"\u23f0","AMBULANCE":"\ud83d\ude91","ANCHOR":"\u2693","ANGRY":"\ud83d\ude20","ANGUISHED":"\ud83d\ude27","ANT":"\ud83d\udc1c","ANTENNA":"\ud83d\udce1","APPLE":"\ud83c\udf4f","APPLE2":"\ud83c\udf4e","ATM":"\ud83c\udfe7","ATOM":"\u269b","BABYBOTTLE":"\ud83c\udf7c","BAD:":"\ud83d\udc4e","BANANA":"\ud83c\udf4c","BANDAGE":"\ud83e\udd15","BANK":"\ud83c\udfe6","BATTERY":"\ud83d\udd0b","BED":"\ud83d\udecf","BEE":"\ud83d\udc1d","BEER":"\ud83c\udf7a","BELL":"\ud83d\udd14","BELLOFF":"\ud83d\udd15","BIRD":"\ud83d\udc26","BLACKFLAG":"\ud83c\udff4","BLUSH":"\ud83d\ude0a","BOMB":"\ud83d\udca3","BOOK":"\ud83d\udcd5","BOOKMARK":"\ud83d\udd16","BOOKS":"\ud83d\udcda","BOW":"\ud83c\udff9","BOWLING":"\ud83c\udfb3","BRIEFCASE":"\ud83d\udcbc","BROKEN":"\ud83d\udc94","BUG":"\ud83d\udc1b","BUILDING":"\ud83c\udfdb","BUILDINGS":"\ud83c\udfd8","BULB":"\ud83d\udca1","BUS":"\ud83d\ude8c","CACTUS":"\ud83c\udf35","CALENDAR":"\ud83d\udcc5","CAMEL":"\ud83d\udc2a","CAMERA":"\ud83d\udcf7","CANDLE":"\ud83d\udd6f","CAR":"\ud83d\ude98","CAROUSEL":"\ud83c\udfa0","CASTLE":"\ud83c\udff0","CATEYES":"\ud83d\ude3b","CATJOY":"\ud83d\ude39","CATMOUTH":"\ud83d\ude3a","CATSMILE":"\ud83d\ude3c","CD":"\ud83d\udcbf","CHECK":"\u2714","CHEQFLAG":"\ud83c\udfc1","CHICK":"\ud83d\udc25","CHICKEN":"\ud83d\udc14","CHICKHEAD":"\ud83d\udc24","CIRCLEBLACK":"\u26ab","CIRCLEBLUE":"\ud83d\udd35","CIRCLERED":"\ud83d\udd34","CIRCLEWHITE":"\u26aa","CIRCUS":"\ud83c\udfaa","CLAPPER":"\ud83c\udfac","CLAPPING":"\ud83d\udc4f","CLIP":"\ud83d\udcce","CLIPBOARD":"\ud83d\udccb","CLOUD":"\ud83c\udf28","CLOVER":"\ud83c\udf40","CLOWN":"\ud83e\udd21","COLDSWEAT":"\ud83d\ude13","COLDSWEAT2":"\ud83d\ude30","COMPRESS":"\ud83d\udddc","CONFOUNDED":"\ud83d\ude16","CONFUSED":"\ud83d\ude15","CONSTRUCTION":"\ud83d\udea7","CONTROL":"\ud83c\udf9b","COOKIE":"\ud83c\udf6a","COOKING":"\ud83c\udf73","COOL":"\ud83d\ude0e","COOLBOX":"\ud83c\udd92","COPYRIGHT":"\u00a9","CRANE":"\ud83c\udfd7","CRAYON":"\ud83d\udd8d","CREDITCARD":"\ud83d\udcb3","CROSS":"\u2716","CROSSBOX:":"\u274e","CRY":"\ud83d\ude22","CRYCAT":"\ud83d\ude3f","CRYSTALBALL":"\ud83d\udd2e","CUSTOMS":"\ud83d\udec3","DELICIOUS":"\ud83d\ude0b","DERELICT":"\ud83c\udfda","DESKTOP":"\ud83d\udda5","DIAMONDLB":"\ud83d\udd37","DIAMONDLO":"\ud83d\udd36","DIAMONDSB":"\ud83d\udd39","DIAMONDSO":"\ud83d\udd38","DICE":"\ud83c\udfb2","DISAPPOINTED":"\ud83d\ude1e","CRY2":"\ud83d\ude25","DIVISION":"\u2797","DIZZY":"\ud83d\ude35","DOLLAR":"\ud83d\udcb5","DOLLAR2":"\ud83d\udcb2","DOWNARROW":"\u2b07","DVD":"\ud83d\udcc0","EJECT":"\u23cf","ELEPHANT":"\ud83d\udc18","EMAIL":"\ud83d\udce7","ENVELOPE":"\ud83d\udce8","ENVELOPE2":"\u2709","ENVELOPE_DOWN":"\ud83d\udce9","EURO":"\ud83d\udcb6","EVIL":"\ud83d\ude08","EXPRESSIONLESS":"\ud83d\ude11","EYES":"\ud83d\udc40","FACTORY":"\ud83c\udfed","FAX":"\ud83d\udce0","FEARFUL":"\ud83d\ude28","FILEBOX":"\ud83d\uddc3","FILECABINET":"\ud83d\uddc4","FIRE":"\ud83d\udd25","FIREENGINE":"\ud83d\ude92","FIST":"\ud83d\udc4a","FLOWER":"\ud83c\udf37","FLOWER2":"\ud83c\udf38","FLUSHED":"\ud83d\ude33","FOLDER":"\ud83d\udcc1","FOLDER2":"\ud83d\udcc2","FREE":"\ud83c\udd93","FROG":"\ud83d\udc38","FROWN":"\ud83d\ude41","GEAR":"\u2699","GLOBE":"\ud83c\udf0d","GLOWINGSTAR":"\ud83c\udf1f","GOOD:":"\ud83d\udc4d","GRIMACING":"\ud83d\ude2c","GRIN":"\ud83d\ude00","GRINNINGCAT":"\ud83d\ude38","HALO":"\ud83d\ude07","HAMMER":"\ud83d\udd28","HAMSTER":"\ud83d\udc39","HAND":"\u270b","HANDDOWN":"\ud83d\udc47","HANDLEFT":"\ud83d\udc48","HANDRIGHT":"\ud83d\udc49","HANDUP":"\ud83d\udc46","HATCHING":"\ud83d\udc23","HAZARD":"\u2623","HEADPHONE":"\ud83c\udfa7","HEARNOEVIL":"\ud83d\ude49","HEARTBLUE":"\ud83d\udc99","HEARTEYES":"\ud83d\ude0d","HEARTGREEN":"\ud83d\udc9a","HEARTYELLOW":"\ud83d\udc9b","HELICOPTER":"\ud83d\ude81","HERB":"\ud83c\udf3f","HIGH_BRIGHTNESS":"\ud83d\udd06","HIGHVOLTAGE":"\u26a1","HIT":"\ud83c\udfaf","HONEY":"\ud83c\udf6f","HOT":"\ud83c\udf36","HOURGLASS":"\u23f3","HOUSE":"\ud83c\udfe0","HUGGINGFACE":"\ud83e\udd17","HUNDRED":"\ud83d\udcaf","HUSHED":"\ud83d\ude2f","ID":"\ud83c\udd94","INBOX":"\ud83d\udce5","INDEX":"\ud83d\uddc2","JOY":"\ud83d\ude02","KEY":"\ud83d\udd11","KISS":"\ud83d\ude18","KISS2":"\ud83d\ude17","KISS3":"\ud83d\ude19","KISS4":"\ud83d\ude1a","KISSINGCAT":"\ud83d\ude3d","KNIFE":"\ud83d\udd2a","LABEL":"\ud83c\udff7","LADYBIRD":"\ud83d\udc1e","LANDING":"\ud83d\udeec","LAPTOP":"\ud83d\udcbb","LEFTARROW":"\u2b05","LEMON":"\ud83c\udf4b","LIGHTNINGCLOUD":"\ud83c\udf29","LINK":"\ud83d\udd17","LITTER":"\ud83d\udeae","LOCK":"\ud83d\udd12","LOLLIPOP":"\ud83c\udf6d","LOUDSPEAKER":"\ud83d\udce2","LOW_BRIGHTNESS":"\ud83d\udd05","MAD":"\ud83d\ude1c","MAGNIFYING_GLASS":"\ud83d\udd0d","MASK":"\ud83d\ude37","MEDAL":"\ud83c\udf96","MEMO":"\ud83d\udcdd","MIC":"\ud83c\udfa4","MICROSCOPE":"\ud83d\udd2c","MINUS":"\u2796","MOBILE":"\ud83d\udcf1","MONEY":"\ud83d\udcb0","MONEYMOUTH":"\ud83e\udd11","MONKEY":"\ud83d\udc35","MOUSE":"\ud83d\udc2d","MOUSE2":"\ud83d\udc01","MOUTHLESS":"\ud83d\ude36","MOVIE":"\ud83c\udfa5","MUGS":"\ud83c\udf7b","NERD":"\ud83e\udd13","NEUTRAL":"\ud83d\ude10","NEW":"\ud83c\udd95","NOENTRY":"\ud83d\udeab","NOTEBOOK":"\ud83d\udcd4","NOTEPAD":"\ud83d\uddd2","NUTANDBOLT":"\ud83d\udd29","O":"\u2b55","OFFICE":"\ud83c\udfe2","OK":"\ud83c\udd97","OKHAND":"\ud83d\udc4c","OLDKEY":"\ud83d\udddd","OPENLOCK":"\ud83d\udd13","OPENMOUTH":"\ud83d\ude2e","OUTBOX":"\ud83d\udce4","PACKAGE":"\ud83d\udce6","PAGE":"\ud83d\udcc4","PAINTBRUSH":"\ud83d\udd8c","PALETTE":"\ud83c\udfa8","PANDA":"\ud83d\udc3c","PASSPORT":"\ud83d\udec2","PAWS":"\ud83d\udc3e","PEN":"\ud83d\udd8a","PEN2":"\ud83d\udd8b","PENSIVE":"\ud83d\ude14","PERFORMING":"\ud83c\udfad","PHONE":"\ud83d\udcde","PILL":"\ud83d\udc8a","PING":"\u2757","PLATE":"\ud83c\udf7d","PLUG":"\ud83d\udd0c","PLUS":"\u2795","POLICE":"\ud83d\ude93","POLICELIGHT":"\ud83d\udea8","POSTOFFICE":"\ud83c\udfe4","POUND":"\ud83d\udcb7","POUTING":"\ud83d\ude21","POUTINGCAT":"\ud83d\ude3e","PRESENT":"\ud83c\udf81","PRINTER":"\ud83d\udda8","PROJECTOR":"\ud83d\udcfd","PUSHPIN":"\ud83d\udccc","QUESTION":"\u2753","RABBIT":"\ud83d\udc30","RADIOACTIVE":"\u2622","RADIOBUTTON":"\ud83d\udd18","RAINCLOUD":"\ud83c\udf27","RAT":"\ud83d\udc00","RECYCLE":"\u267b","REGISTERED":"\u00ae","RELIEVED":"\ud83d\ude0c","ROBOT":"\ud83e\udd16","ROCKET":"\ud83d\ude80","ROLLING":"\ud83d\ude44","ROOSTER":"\ud83d\udc13","RULER":"\ud83d\udccf","SATELLITE":"\ud83d\udef0","SAVE":"\ud83d\udcbe","SCHOOL":"\ud83c\udfeb","SCISSORS":"\u2702","SCREAMING":"\ud83d\ude31","SCROLL":"\ud83d\udcdc","SEAT":"\ud83d\udcba","SEEDLING":"\ud83c\udf31","SEENOEVIL":"\ud83d\ude48","SHIELD":"\ud83d\udee1","SHIP":"\ud83d\udea2","SHOCKED":"\ud83d\ude32","SHOWER":"\ud83d\udebf","SLEEPING":"\ud83d\ude34","SLEEPY":"\ud83d\ude2a","SLIDER":"\ud83c\udf9a","SLOT":"\ud83c\udfb0","SMILE":"\ud83d\ude42","SMILING":"\ud83d\ude03","SMILINGCLOSEDEYES":"\ud83d\ude06","SMILINGEYES":"\ud83d\ude04","SMILINGSWEAT":"\ud83d\ude05","SMIRK":"\ud83d\ude0f","SNAIL":"\ud83d\udc0c","SNAKE":"\ud83d\udc0d","SOCCER":"\u26bd","SOS":"\ud83c\udd98","SPEAKER":"\ud83d\udd08","SPEAKEROFF":"\ud83d\udd07","SPEAKNOEVIL":"\ud83d\ude4a","SPIDER":"\ud83d\udd77","SPIDERWEB":"\ud83d\udd78","STAR":"\u2b50","STOP":"\u26d4","STOPWATCH":"\u23f1","SULK":"\ud83d\ude26","SUNFLOWER":"\ud83c\udf3b","SUNGLASSES":"\ud83d\udd76","SYRINGE":"\ud83d\udc89","TAKEOFF":"\ud83d\udeeb","TAXI":"\ud83d\ude95","TELESCOPE":"\ud83d\udd2d","TEMPORATURE":"\ud83e\udd12","TENNIS":"\ud83c\udfbe","THERMOMETER":"\ud83c\udf21","THINKING":"\ud83e\udd14","THUNDERCLOUD":"\u26c8","TICKBOX":"\u2705","TICKET":"\ud83c\udf9f","TIRED":"\ud83d\ude2b","TOILET":"\ud83d\udebd","TOMATO":"\ud83c\udf45","TONGUE":"\ud83d\ude1b","TOOLS":"\ud83d\udee0","TORCH":"\ud83d\udd26","TORNADO":"\ud83c\udf2a","TOUNG2":"\ud83d\ude1d","TRADEMARK":"\u2122","TRAFFICLIGHT":"\ud83d\udea6","TRASH":"\ud83d\uddd1","TREE":"\ud83c\udf32","TRIANGLE_LEFT":"\u25c0","TRIANGLE_RIGHT":"\u25b6","TRIANGLEDOWN":"\ud83d\udd3b","TRIANGLEUP":"\ud83d\udd3a","TRIANGULARFLAG":"\ud83d\udea9","TROPHY":"\ud83c\udfc6","TRUCK":"\ud83d\ude9a","TRUMPET":"\ud83c\udfba","TURKEY":"\ud83e\udd83","TURTLE":"\ud83d\udc22","UMBRELLA":"\u26f1","UNAMUSED":"\ud83d\ude12","UPARROW":"\u2b06","UPSIDEDOWN":"\ud83d\ude43","WARNING":"\u26a0","WATCH":"\u231a","WAVING":"\ud83d\udc4b","WEARY":"\ud83d\ude29","WEARYCAT":"\ud83d\ude40","WHITEFLAG":"\ud83c\udff3","WINEGLASS":"\ud83c\udf77","WINK":"\ud83d\ude09","WORRIED":"\ud83d\ude1f","WRENCH":"\ud83d\udd27","X":"\u274c","YEN":"\ud83d\udcb4","ZIPPERFACE":"\ud83e\udd10","UNDEFINED":"","":""};statuses={F:'FATAL',B:'BUG',C:'CRITICAL',E:'ERROR',W:'WARNING',I:'INFO',IM:'IMPORTANT',D:'DEBUG',L:'LOG',CO:'CONSTANT',FU:'FUNCTION',R:'RETURN',V:'VARIABLE',S:'STACK',RE:'RESULT',ST:'STOPPER',TI:'TIMER',T:'TRACE'};LOG_LEVEL={NONE:7,OFF:7,FATAL:6,ERROR:5,WARN:4,INFO:3,UNDEFINED:2,'':2,DEFAULT:2,DEBUG:2,TRACE:1,ON:0,ALL:0,};LOG_STATUS={OFF:LOG_LEVEL.OFF,NONE:LOG_LEVEL.OFF,NO:LOG_LEVEL.OFF,NOPE:LOG_LEVEL.OFF,FALSE:LOG_LEVEL.OFF,FATAL:LOG_LEVEL.FATAL,BUG:LOG_LEVEL.ERROR,CRITICAL:LOG_LEVEL.ERROR,ERROR:LOG_LEVEL.ERROR,WARNING:LOG_LEVEL.WARN,INFO:LOG_LEVEL.INFO,IMPORTANT:LOG_LEVEL.INFO,DEBUG:LOG_LEVEL.DEBUG,LOG:LOG_LEVEL.DEBUG,STACK:LOG_LEVEL.DEBUG,CONSTANT:LOG_LEVEL.DEBUG,FUNCTION:LOG_LEVEL.DEBUG,VARIABLE:LOG_LEVEL.DEBUG,RETURN:LOG_LEVEL.DEBUG,RESULT:LOG_LEVEL.TRACE,STOPPER:LOG_LEVEL.TRACE,TIMER:LOG_LEVEL.TRACE,TRACE:LOG_LEVEL.TRACE,ALL:LOG_LEVEL.ALL,YES:LOG_LEVEL.ALL,YEP:LOG_LEVEL.ALL,TRUE:LOG_LEVEL.ALL};var logFile,logFolder;var LOG=function(message,status,icon){if(LOG.level!==LOG_LEVEL.OFF&&(LOG.write||LOG.store)&&LOG.arguments.length)return LOG.addMessage(message,status,icon);};LOG.logDecodeLevel=function(level){if(level==~~level)return Math.abs(level);var lev;level+='';level=level.toUpperCase();if(level in statuses){level=statuses[level];}lev=LOG_LEVEL[level];if(lev!==undefined)return lev;lev=LOG_STATUS[level];if(lev!==undefined)return lev;return LOG_LEVEL.DEFAULT;};LOG.write=write;LOG.store=store;LOG.level=LOG.logDecodeLevel(level);LOG.status=defaultStatus;LOG.addMessage=function(message,status,icon){var date=new Date(),count,bool,logStatus;if(status&&status.constructor.name==='String'){status=status.toUpperCase();status=statuses[status]||status;}else status=LOG.status;logStatus=LOG_STATUS[status]||LOG_STATUS.ALL;if(logStatus999)?'['+LOG.count+'] ':(' ['+LOG.count+'] ').slice(-7);message=count+status+icon+(message instanceof Object?message.toSource():message)+date;if(LOG.store){stack.push(message);}if(LOG.write){bool=file&&file.writable&&logFile.writeln(message);if(!bool){file.writable=true;LOG.setFile(logFile);logFile.writeln(message);}}LOG.count++;return true;};var logNewFile=function(file,isCookie,overwrite){file.encoding='UTF-8';file.lineFeed=($.os[0]=='M')?'Macintosh':' Windows';if(isCookie)return file.open(overwrite?'w':'e')&&file;file.writable=LOG.write;logFile=file;logFolder=file.parent;if(continuing){LOG.count=LOG.setCount(file);}return(!LOG.write&&file||(file.open('a')&&file));};LOG.setFile=function(file,isCookie,overwrite){var bool,folder,fileName,suffix,newFileName,f,d,safeFileName;d=new Date();f=$.stack.split("\n")[0].replace(/^\[\(?/,'').replace(/\)?\]$/,'');if(f==~~f){f=$.fileName.replace(/[^\/]+\//g,'');}safeFileName=File.encode((isCookie?'/COOKIE_':'/LOG_')+f.replace(/^\//,'')+'_'+(1900+d.getYear())+(''+d).replace(/...(...)(..).+/,'_$1_$2')+(isCookie?'.txt':'.log'));if(file&&file.constructor.name=='String'){file=(file.match('/'))?new File(file):new File((logFolder||Folder.temp)+'/'+file);}if(file instanceof File){folder=file.parent;bool=folder.exists||folder.create();if(!bool)folder=Folder.temp;fileName=File.decode(file.name);suffix=fileName.match(/\.[^.]+$/);suffix=suffix?suffix[0]:'';fileName='/'+fileName;newFileName=fileName.replace(/\.[^.]+$/,'')+'_'+(+(new Date())+suffix);f=logNewFile(file,isCookie,overwrite);if(f)return f;f=logNewFile(new File(folder+newFileName),isCookie,overwrite);if(f)return f;f=logNewFile(new File(folder+safeFileName),isCookie,overwrite);if(f)return f;if(folder!=Folder.temp){f=logNewFile(new File(Folder.temp+fileName),isCookie,overwrite);if(f)return f;f=logNewFile(new File(Folder.temp+safeFileName),isCookie,overwrite);return f||new File(Folder.temp+safeFileName);}}return LOG.setFile(((logFile&&!isCookie)?new File(logFile):new File(Folder.temp+safeFileName)),isCookie,overwrite );};LOG.setCount=function(file){if(~~file===file){LOG.count=file;return LOG.count;}if(file===undefined){file=logFile;}if(file&&file.constructor===String){file=new File(file);}var logNumbers,contents;if(!file.length||!file.exists){LOG.count=1;return 1;}file.open('r');file.encoding='utf-8';file.seek(10000,2);contents='\n'+file.read();logNumbers=contents.match(/\n{0,3}\[\d+\] \[\w+\]+/g);if(logNumbers){logNumbers=+logNumbers[logNumbers.length-1].match(/\d+/)+1;file.close();LOG.count=logNumbers;return logNumbers;}if(file.length<10001){file.close();LOG.count=1;return 1;}file.seek(10000000,2);contents='\n'+file.read();logNumbers=contents.match(/\n{0,3}\[\d+\] \[\w+\]+/g);if(logNumbers){logNumbers=+logNumbers[logNumbers.length-1].match(/\d+/)+1;file.close();LOG.count=logNumbers;return logNumbers;}file.close();LOG.count=1;return 1;};LOG.setLevel=function(level){LOG.level=LOG.logDecodeLevel(level);return LOG.level;};LOG.setStatus=function(status){status=(''+status).toUpperCase();LOG.status=statuses[status]||status;return LOG.status;};LOG.cookie=function(file,level,overwrite,setLevel){var log,cookie;if(!file){file={file:file};}if(file&&(file.constructor===String||file.constructor===File)){file={file:file};}log=file;if(log.level===undefined){log.level=(level!==undefined)?level:'NONE';}if(log.overwrite===undefined){log.overwrite=(overwrite!==undefined)?overwrite:false;}if(log.setLevel===undefined){log.setLevel=(setLevel!==undefined)?setLevel:true;}setLevel=log.setLevel;overwrite=log.overwrite;level=log.level;file=log.file;file=LOG.setFile(file,true,overwrite);if(overwrite){file.write(level);}else{cookie=file.read();if(cookie.length){level=cookie;}else{file.write(level);}}file.close();if(setLevel){LOG.setLevel(level);}return{path:file,level:level};};LOG.args=function(args,funct,line){if(LOG.level>LOG_STATUS.FUNCTION)return;if(!(args&&(''+args.constructor).replace(/\s+/g,'')==='functionObject(){[nativecode]}'))return;if(!LOG.args.STRIP_COMMENTS){LOG.args.STRIP_COMMENTS=/((\/.*$)|(\/\*[\s\S]*?\*\/))/mg;}if(!LOG.args.ARGUMENT_NAMES){LOG.args.ARGUMENT_NAMES=/([^\s,]+)/g;}if(!LOG.args.OUTER_BRACKETS){LOG.args.OUTER_BRACKETS=/^\((.+)?\)$/;}if(!LOG.args.NEW_SOMETHING){LOG.args.NEW_SOMETHING=/^new \w+\((.+)?\)$/;}var functionString,argumentNames,stackInfo,report,functionName,arg,argsL,n,argName,argValue,argsTotal;if(funct===~~funct){line=funct;}if(!(funct instanceof Function)){funct=args.callee;}if(!(funct instanceof Function))return;functionName=funct.name;functionString=(''+funct).replace(LOG.args.STRIP_COMMENTS,'');argumentNames=functionString.slice(functionString.indexOf('(')+1,functionString.indexOf(')')).match(LOG.args.ARGUMENT_NAMES);argumentNames=argumentNames||[];report=[];report.push('--------------');report.push('Function Data:');report.push('--------------');report.push('Function Name:'+functionName);argsL=args.length;stackInfo=$.stack.split(/[\n\r]/);stackInfo.pop();stackInfo=stackInfo.join('\n ');report.push('Call stack:'+stackInfo);if(line){report.push('Function Line around:'+line);}report.push('Arguments Provided:'+argsL);report.push('Named Arguments:'+argumentNames.length);if(argumentNames.length){report.push('Arguments Names:'+argumentNames.join(','));}if(argsL){report.push('----------------');report.push('Argument Values:');report.push('----------------');}argsTotal=Math.max(argsL,argumentNames.length);for(n=0;n=argsL){argValue='NO VALUE PROVIDED';}else if(arg===undefined){argValue='undefined';}else if(arg===null){argValue='null';}else{argValue=arg.toSource().replace(LOG.args.OUTER_BRACKETS,'$1').replace(LOG.args.NEW_SOMETHING,'$1');}report.push((argName?argName:'arguments['+n+']')+':'+argValue);}report.push('');report=report.join('\n ');LOG(report,'f');return report;};LOG.stack=function(reverse){var st=$.stack.split('\n');st.pop();st.pop();if(reverse){st.reverse();}return LOG(st.join('\n '),'s');};LOG.values=function(values){var n,value,map=[];if(!(values instanceof Object||values instanceof Array)){return;}if(!LOG.values.OUTER_BRACKETS){LOG.values.OUTER_BRACKETS=/^\((.+)?\)$/;}if(!LOG.values.NEW_SOMETHING){LOG.values.NEW_SOMETHING=/^new \w+\((.+)?\)$/;}for(n in values){try{value=values[n];if(value===undefined){value='undefined';}else if(value===null){value='null';}else{value=value.toSource().replace(LOG.values.OUTER_BRACKETS,'$1').replace(LOG.values.NEW_SOMETHING,'$1');}}catch(e){value='\uD83D\uDEAB '+e;}map.push(n+':'+value);}if(map.length){map=map.join('\n ')+'\n ';return LOG(map,'v');}};LOG.reset=function(all){stack.length=0;LOG.count=1;if(all!==false){if(logFile instanceof File){logFile.close();}logFile=LOG.store=LOG.writeToFile=undefined;LOG.write=true;logFolder=Folder.temp;logTime=new Date();logPoint='After Log Reset';}};LOG.stopper=function(message){var newLogTime,t,m,newLogPoint;newLogTime=new Date();newLogPoint=(LOG.count!==undefined)?'LOG#'+LOG.count:'BEFORE LOG#1';LOG.time=t=newLogTime-logTime;if(message===false){return;}message=message||'Stopper start point';t=LOG.prettyTime(t);m=message+'\n '+'From '+logPoint+' to '+newLogPoint+' took '+t+' Starting '+logTime+' '+logTime.getMilliseconds()+'ms'+' Ending '+newLogTime+' '+newLogTime.getMilliseconds()+'ms';LOG(m,'st');logPoint=newLogPoint;logTime=newLogTime;return m;};LOG.start=function(message){var t=new Date();times.push([t,(message!==undefined)?message+'':'']);};LOG.stop=function(message){if(!times.length)return;message=(message)?message+' ':'';var nt,startLog,ot,om,td,m;nt=new Date();startLog=times.pop();ot=startLog[0];om=startLog[1];td=nt-ot;if(om.length){om+=' ';}m=om+'STARTED ['+ot+' '+ot.getMilliseconds()+'ms]\n '+message+'FINISHED ['+nt+' '+nt.getMilliseconds()+'ms]\n TOTAL TIME ['+LOG.prettyTime(td)+']';LOG(m,'ti');return m;};LOG.prettyTime=function(t){var h,m,s,ms;h=Math.floor(t / 3600000);m=Math.floor((t % 3600000)/ 60000);s=Math.floor((t % 60000)/ 1000);ms=t % 1000;t=(!t)?'<1ms':((h)?h+' hours ':'')+((m)?m+' minutes ':'')+((s)?s+' seconds ':'')+((ms&&(h||m||s))?'&':'')+((ms)?ms+'ms':'');return t;};LOG.get=function(){if(!stack.length)return 'THE LOG IS NOT SET TO STORE';var a=fetchLogLines(arguments);return a?'\n'+a.join('\n'):'NO LOGS AVAILABLE';};var fetchLogLines=function(){var args=arguments[0];if(!args.length)return stack;var c,n,l,a=[],ln,start,end,j,sl;l=args.length;sl=stack.length-1;n=0;for(c=0;cln)?sl+ln+1:ln-1;if(ln>=0&&ln<=sl)a[n++]=stack[ln];}else if(ln instanceof Array&&ln.length===2){start=ln[0];end=ln[1];if(!(~~start===start&&~~end===end))continue;start=(0>start)?sl+start+1:start-1;end=(0>end)?sl+end+1:end-1;start=Math.max(Math.min(sl,start),0);end=Math.min(Math.max(end,0),sl);if(start<=end)for(j=start;j<=end;j++)a[n++]=stack[j];else for(j=start;j>=end;j--)a[n++]=stack[j];}}return(n)?a:false;};LOG.file=function(){return logFile;};LOG.openFolder=function(){if(logFolder)return logFolder.execute();};LOG.show=LOG.execute=function(){if(logFile)return logFile.execute();};LOG.close=function(){if(logFile)return logFile.close();};LOG.setFile(file);if(!$.summary.difference){$.summary.difference=function(){return $.summary().replace(/ *([0-9]+)([^ ]+)(\n?)/g,$.summary.updateSnapshot );};}if(!$.summary.updateSnapshot){$.summary.updateSnapshot=function(full,count,name,lf){var snapshot=$.summary.snapshot;count=Number(count);var prev=snapshot[name]?snapshot[name]:0;snapshot[name]=count;var diff=count-prev;if(diff===0)return "";return " ".substring(String(diff).length)+diff+" "+name+lf;};}if(!$.summary.snapshot){$.summary.snapshot=[];$.summary.difference();}$.gc();$.gc();$.summary.difference();LOG.sumDiff=function(message){$.gc();$.gc();var diff=$.summary.difference();if(diff.length<8){diff=' - NONE -';}if(message===undefined){message='';}message+=diff;return LOG('$.summary.difference():'+message,'v');};return LOG;}; var log = new LogFactory('myLog.log'); // =>; creates the new log factory - put full path where function getEnv(variable){ return $.getenv(variable); } function fileOpen(path){ return app.open(new File(path)); } function getLayerTypeWithName(layerName) { var type = 'NA'; var nameParts = layerName.split('_'); var namePrefix = nameParts[0]; namePrefix = namePrefix.toLowerCase(); switch (namePrefix) { case 'guide': case 'tl': case 'tr': case 'bl': case 'br': type = 'GUIDE'; break; case 'fg': type = 'FG'; break; case 'bg': type = 'BG'; break; case 'obj': default: type = 'OBJ'; break; } return type; } function getLayers() { /** * Get json representation of list of layers. * Much faster this way than in DOM traversal (2s vs 45s on same file) * * Format of single layer info: * id : number * name: string * group: boolean - true if layer is a group * parents:array - list of ids of parent groups, useful for selection * all children layers from parent layerSet (eg. group) * type: string - type of layer guessed from its name * visible:boolean - true if visible **/ if (documents.length == 0){ return '[]'; } var ref1 = new ActionReference(); ref1.putEnumerated(charIDToTypeID('Dcmn'), charIDToTypeID('Ordn'), charIDToTypeID('Trgt')); var count = executeActionGet(ref1).getInteger(charIDToTypeID('NmbL')); // get all layer names var layers = []; var layer = {}; var parents = []; for (var i = count; i >= 1; i--) { var layer = {}; var ref2 = new ActionReference(); ref2.putIndex(charIDToTypeID('Lyr '), i); var desc = executeActionGet(ref2); // Access layer index #i var layerSection = typeIDToStringID(desc.getEnumerationValue( stringIDToTypeID('layerSection'))); layer.id = desc.getInteger(stringIDToTypeID("layerID")); layer.name = desc.getString(stringIDToTypeID("name")); layer.color_code = typeIDToStringID(desc.getEnumerationValue(stringIDToTypeID('color'))); layer.group = false; layer.parents = parents.slice(); layer.type = getLayerTypeWithName(layer.name); layer.visible = desc.getBoolean(stringIDToTypeID("visible")); //log(" name: " + layer.name + " groupId " + layer.groupId + //" group " + layer.group); if (layerSection == 'layerSectionStart') { // Group start and end parents.push(layer.id); layer.group = true; } if (layerSection == 'layerSectionEnd') { parents.pop(); continue; } layers.push(JSON.stringify(layer)); } try{ var bck = activeDocument.backgroundLayer; layer.id = bck.id; layer.name = bck.name; layer.group = false; layer.parents = []; layer.type = 'background'; layer.visible = bck.visible; layers.push(JSON.stringify(layer)); }catch(e){ // do nothing, no background layer }; //log("layers " + layers); return '[' + layers + ']'; } function setVisible(layer_id, visibility){ /** * Sets particular 'layer_id' to 'visibility' if true > show **/ var desc = new ActionDescriptor(); var ref = new ActionReference(); ref.putIdentifier(stringIDToTypeID("layer"), layer_id); desc.putReference(stringIDToTypeID("null"), ref); executeAction(visibility?stringIDToTypeID("show"):stringIDToTypeID("hide"), desc, DialogModes.NO); } function getHeadline(){ /** * Returns headline of current document with metadata * **/ if (documents.length == 0){ return ''; } var headline = app.activeDocument.info.headline; return headline; } function isSaved(){ return app.activeDocument.saved; } function save(){ /** Saves active document **/ return app.activeDocument.save(); } function saveAs(output_path, ext, as_copy){ /** Exports scene to various formats * * Currently implemented: 'jpg', 'png', 'psd' * * output_path - escaped file path on local system * ext - extension for export * as_copy - create copy, do not overwrite * * */ var saveName = output_path; var saveOptions; if (ext == 'jpg'){ saveOptions = new JPEGSaveOptions(); saveOptions.quality = 12; saveOptions.embedColorProfile = true; saveOptions.formatOptions = FormatOptions.PROGRESSIVE; if(saveOptions.formatOptions == FormatOptions.PROGRESSIVE){ saveOptions.scans = 5}; saveOptions.matte = MatteType.NONE; } if (ext == 'png'){ saveOptions = new PNGSaveOptions(); saveOptions.interlaced = true; saveOptions.transparency = true; } if (ext == 'psd'){ saveOptions = null; return app.activeDocument.saveAs(new File(saveName)); } if (ext == 'psb'){ return savePSB(output_path); } return app.activeDocument.saveAs(new File(saveName), saveOptions, as_copy); } function getActiveDocumentName(){ /** * Returns file name of active document * */ if (documents.length == 0){ return null; } return app.activeDocument.name; } function getActiveDocumentFullName(){ /** * Returns file name of active document with file path. * activeDocument.fullName returns path in URI (eg /c/.. instead of c:/) * */ if (documents.length == 0){ return null; } var f = new File(app.activeDocument.fullName); var path = f.fsName; f.close(); return path; } function imprint(payload){ /** * Sets headline content of current document with metadata. Stores * information about assets created through Avalon. * Content accessible in PS through File > File Info * **/ app.activeDocument.info.headline = payload; } function getSelectedLayers(doc) { /** * Returns json representation of currently selected layers. * Works in three steps - 1) creates new group with selected layers * 2) traverses this group * 3) deletes newly created group, not needed * Bit weird, but Adobe.. **/ if (doc == null){ doc = app.activeDocument; } var selLayers = []; _grp = groupSelectedLayers(doc); var group = doc.activeLayer; var layers = group.layers; // // group is fake at this point // var itself_name = ''; // if (layers){ // itself_name = layers[0].name; // } for (var i = 0; i < layers.length; i++) { var layer = {}; layer.id = layers[i].id; layer.name = layers[i].name; long_names =_get_parents_names(group.parent, layers[i].name); var t = layers[i].kind; if ((typeof t !== 'undefined') && (layers[i].kind.toString() == 'LayerKind.NORMAL')){ layer.group = false; }else{ layer.group = true; } layer.long_name = long_names; selLayers.push(layer); } _undo(); return JSON.stringify(selLayers); }; function selectLayers(selectedLayers){ /** * Selects layers from list of ids **/ selectedLayers = JSON.parse(selectedLayers); var layers = new Array(); var id54 = charIDToTypeID( "slct" ); var desc12 = new ActionDescriptor(); var id55 = charIDToTypeID( "null" ); var ref9 = new ActionReference(); var existing_layers = JSON.parse(getLayers()); var existing_ids = []; for (var y = 0; y < existing_layers.length; y++){ existing_ids.push(existing_layers[y]["id"]); } for (var i = 0; i < selectedLayers.length; i++) { // a check to see if the id still exists var id = selectedLayers[i]; if(existing_ids.toString().indexOf(id)>=0){ layers[i] = charIDToTypeID( "Lyr " ); ref9.putIdentifier(layers[i], id); } } desc12.putReference( id55, ref9 ); var id58 = charIDToTypeID( "MkVs" ); desc12.putBoolean( id58, false ); executeAction( id54, desc12, DialogModes.NO ); } function groupSelectedLayers(doc, name) { /** * Groups selected layers into new group. * Returns json representation of Layer for server to consume * * Args: * doc(activeDocument) * name (str): new name of created group **/ if (doc == null){ doc = app.activeDocument; } var desc = new ActionDescriptor(); var ref = new ActionReference(); ref.putClass( stringIDToTypeID('layerSection') ); desc.putReference( charIDToTypeID('null'), ref ); var lref = new ActionReference(); lref.putEnumerated( charIDToTypeID('Lyr '), charIDToTypeID('Ordn'), charIDToTypeID('Trgt') ); desc.putReference( charIDToTypeID('From'), lref); executeAction( charIDToTypeID('Mk '), desc, DialogModes.NO ); var group = doc.activeLayer; if (name){ // Add special character to highlight group that will be published group.name = name; } var layer = {}; layer.id = group.id; layer.name = name; // keep name clean layer.group = true; layer.long_name = _get_parents_names(group, name); return JSON.stringify(layer); }; function importSmartObject(path, name, link){ /** * Creates new layer with an image from 'path' * * path: absolute path to loaded file * name: sets name of newly created laye * **/ var desc1 = new ActionDescriptor(); desc1.putPath( app.charIDToTypeID("null"), new File(path) ); link = link || false; if (link) { desc1.putBoolean( app.charIDToTypeID('Lnkd'), true ); } desc1.putEnumerated(app.charIDToTypeID("FTcs"), app.charIDToTypeID("QCSt"), app.charIDToTypeID("Qcsa")); var desc2 = new ActionDescriptor(); desc2.putUnitDouble(app.charIDToTypeID("Hrzn"), app.charIDToTypeID("#Pxl"), 0.0); desc2.putUnitDouble(app.charIDToTypeID("Vrtc"), app.charIDToTypeID("#Pxl"), 0.0); desc1.putObject(charIDToTypeID("Ofst"), charIDToTypeID("Ofst"), desc2); executeAction(charIDToTypeID("Plc " ), desc1, DialogModes.NO); var docRef = app.activeDocument var currentActivelayer = app.activeDocument.activeLayer; if (name){ currentActivelayer.name = name; } var layer = {} layer.id = currentActivelayer.id; layer.name = currentActivelayer.name; return JSON.stringify(layer); } function replaceSmartObjects(layer_id, path, name){ /** * Updates content of 'layer' with an image from 'path' * **/ var desc = new ActionDescriptor(); var ref = new ActionReference(); ref.putIdentifier(stringIDToTypeID("layer"), layer_id); desc.putReference(stringIDToTypeID("null"), ref); desc.putPath(charIDToTypeID('null'), new File(path) ); desc.putInteger(charIDToTypeID("PgNm"), 1); executeAction(stringIDToTypeID('placedLayerReplaceContents'), desc, DialogModes.NO ); var currentActivelayer = app.activeDocument.activeLayer; if (name){ currentActivelayer.name = name; } } function createGroup(name){ /** * Creates new group with a 'name' * Because of asynchronous nature, only group.id is available **/ group = app.activeDocument.layerSets.add(); // Add special character to highlight group that will be published group.name = name; return group.id; // only id available at this time :| } function deleteLayer(layer_id){ /*** * Deletes layer by its layer_id * * layer_id (int) **/ var d = new ActionDescriptor(); var r = new ActionReference(); r.putIdentifier(stringIDToTypeID("layer"), layer_id); d.putReference(stringIDToTypeID("null"), r); executeAction(stringIDToTypeID("delete"), d, DialogModes.NO); } function _undo() { executeAction(charIDToTypeID("undo", undefined, DialogModes.NO)); }; function savePSB(output_path){ /*** * Saves file as .psb to 'output_path' * * output_path (str) **/ var desc1 = new ActionDescriptor(); var desc2 = new ActionDescriptor(); desc2.putBoolean( stringIDToTypeID('maximizeCompatibility'), true ); desc1.putObject( charIDToTypeID('As '), charIDToTypeID('Pht8'), desc2 ); desc1.putPath( charIDToTypeID('In '), new File(output_path) ); desc1.putBoolean( charIDToTypeID('LwCs'), true ); executeAction( charIDToTypeID('save'), desc1, DialogModes.NO ); } function close(){ executeAction(stringIDToTypeID("quit"), undefined, DialogModes.NO ); } function renameLayer(layer_id, new_name){ /*** * Renames 'layer_id' to 'new_name' * * Via Action (fast) * * Args: * layer_id(int) * new_name(str) * * output_path (str) **/ doc = app.activeDocument; selectLayers('['+layer_id+']'); doc.activeLayer.name = new_name; } function _get_parents_names(layer, itself_name){ var long_names = [itself_name]; while (layer.parent){ if (layer.typename != "LayerSet"){ break; } long_names.push(layer.name); layer = layer.parent; } return long_names; } // triggers when panel is opened, good for debugging //log(getActiveDocumentName()); // log.show(); // var a = app.activeDocument.activeLayer; // log(a); //getSelectedLayers(); // importSmartObject("c:/projects/test.jpg", "a aaNewLayer", true); // log("dpc"); // replaceSmartObjects(153, "▼Jungle_imageTest_001", "c:/projects/test_project_test_asset_TestTask_v001.png"); ================================================ FILE: openpype/hosts/photoshop/api/extension/host/json.js ================================================ // json2.js // 2017-06-12 // Public Domain. // NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK. // USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO // NOT CONTROL. // This file creates a global JSON object containing two methods: stringify // and parse. This file provides the ES5 JSON capability to ES3 systems. // If a project might run on IE8 or earlier, then this file should be included. // This file does nothing on ES5 systems. // JSON.stringify(value, replacer, space) // value any JavaScript value, usually an object or array. // replacer an optional parameter that determines how object // values are stringified for objects. It can be a // function or an array of strings. // space an optional parameter that specifies the indentation // of nested structures. If it is omitted, the text will // be packed without extra whitespace. If it is a number, // it will specify the number of spaces to indent at each // level. If it is a string (such as "\t" or " "), // it contains the characters used to indent at each level. // This method produces a JSON text from a JavaScript value. // When an object value is found, if the object contains a toJSON // method, its toJSON method will be called and the result will be // stringified. A toJSON method does not serialize: it returns the // value represented by the name/value pair that should be serialized, // or undefined if nothing should be serialized. The toJSON method // will be passed the key associated with the value, and this will be // bound to the value. // For example, this would serialize Dates as ISO strings. // Date.prototype.toJSON = function (key) { // function f(n) { // // Format integers to have at least two digits. // return (n < 10) // ? "0" + n // : n; // } // return this.getUTCFullYear() + "-" + // f(this.getUTCMonth() + 1) + "-" + // f(this.getUTCDate()) + "T" + // f(this.getUTCHours()) + ":" + // f(this.getUTCMinutes()) + ":" + // f(this.getUTCSeconds()) + "Z"; // }; // You can provide an optional replacer method. It will be passed the // key and value of each member, with this bound to the containing // object. The value that is returned from your method will be // serialized. If your method returns undefined, then the member will // be excluded from the serialization. // If the replacer parameter is an array of strings, then it will be // used to select the members to be serialized. It filters the results // such that only members with keys listed in the replacer array are // stringified. // Values that do not have JSON representations, such as undefined or // functions, will not be serialized. Such values in objects will be // dropped; in arrays they will be replaced with null. You can use // a replacer function to replace those with JSON values. // JSON.stringify(undefined) returns undefined. // The optional space parameter produces a stringification of the // value that is filled with line breaks and indentation to make it // easier to read. // If the space parameter is a non-empty string, then that string will // be used for indentation. If the space parameter is a number, then // the indentation will be that many spaces. // Example: // text = JSON.stringify(["e", {pluribus: "unum"}]); // // text is '["e",{"pluribus":"unum"}]' // text = JSON.stringify(["e", {pluribus: "unum"}], null, "\t"); // // text is '[\n\t"e",\n\t{\n\t\t"pluribus": "unum"\n\t}\n]' // text = JSON.stringify([new Date()], function (key, value) { // return this[key] instanceof Date // ? "Date(" + this[key] + ")" // : value; // }); // // text is '["Date(---current time---)"]' // JSON.parse(text, reviver) // This method parses a JSON text to produce an object or array. // It can throw a SyntaxError exception. // The optional reviver parameter is a function that can filter and // transform the results. It receives each of the keys and values, // and its return value is used instead of the original value. // If it returns what it received, then the structure is not modified. // If it returns undefined then the member is deleted. // Example: // // Parse the text. Values that look like ISO date strings will // // be converted to Date objects. // myData = JSON.parse(text, function (key, value) { // var a; // if (typeof value === "string") { // a = // /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value); // if (a) { // return new Date(Date.UTC( // +a[1], +a[2] - 1, +a[3], +a[4], +a[5], +a[6] // )); // } // return value; // } // }); // myData = JSON.parse( // "[\"Date(09/09/2001)\"]", // function (key, value) { // var d; // if ( // typeof value === "string" // && value.slice(0, 5) === "Date(" // && value.slice(-1) === ")" // ) { // d = new Date(value.slice(5, -1)); // if (d) { // return d; // } // } // return value; // } // ); // This is a reference implementation. You are free to copy, modify, or // redistribute. /*jslint eval, for, this */ /*property JSON, apply, call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours, getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join, lastIndex, length, parse, prototype, push, replace, slice, stringify, test, toJSON, toString, valueOf */ // Create a JSON object only if one does not already exist. We create the // methods in a closure to avoid creating global variables. if (typeof JSON !== "object") { JSON = {}; } (function () { "use strict"; var rx_one = /^[\],:{}\s]*$/; var rx_two = /\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g; var rx_three = /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g; var rx_four = /(?:^|:|,)(?:\s*\[)+/g; var rx_escapable = /[\\"\u0000-\u001f\u007f-\u009f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; var rx_dangerous = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; function f(n) { // Format integers to have at least two digits. return (n < 10) ? "0" + n : n; } function this_value() { return this.valueOf(); } if (typeof Date.prototype.toJSON !== "function") { Date.prototype.toJSON = function () { return isFinite(this.valueOf()) ? ( this.getUTCFullYear() + "-" + f(this.getUTCMonth() + 1) + "-" + f(this.getUTCDate()) + "T" + f(this.getUTCHours()) + ":" + f(this.getUTCMinutes()) + ":" + f(this.getUTCSeconds()) + "Z" ) : null; }; Boolean.prototype.toJSON = this_value; Number.prototype.toJSON = this_value; String.prototype.toJSON = this_value; } var gap; var indent; var meta; var rep; function quote(string) { // If the string contains no control characters, no quote characters, and no // backslash characters, then we can safely slap some quotes around it. // Otherwise we must also replace the offending characters with safe escape // sequences. rx_escapable.lastIndex = 0; return rx_escapable.test(string) ? "\"" + string.replace(rx_escapable, function (a) { var c = meta[a]; return typeof c === "string" ? c : "\\u" + ("0000" + a.charCodeAt(0).toString(16)).slice(-4); }) + "\"" : "\"" + string + "\""; } function str(key, holder) { // Produce a string from holder[key]. var i; // The loop counter. var k; // The member key. var v; // The member value. var length; var mind = gap; var partial; var value = holder[key]; // If the value has a toJSON method, call it to obtain a replacement value. if ( value && typeof value === "object" && typeof value.toJSON === "function" ) { value = value.toJSON(key); } // If we were called with a replacer function, then call the replacer to // obtain a replacement value. if (typeof rep === "function") { value = rep.call(holder, key, value); } // What happens next depends on the value's type. switch (typeof value) { case "string": return quote(value); case "number": // JSON numbers must be finite. Encode non-finite numbers as null. return (isFinite(value)) ? String(value) : "null"; case "boolean": case "null": // If the value is a boolean or null, convert it to a string. Note: // typeof null does not produce "null". The case is included here in // the remote chance that this gets fixed someday. return String(value); // If the type is "object", we might be dealing with an object or an array or // null. case "object": // Due to a specification blunder in ECMAScript, typeof null is "object", // so watch out for that case. if (!value) { return "null"; } // Make an array to hold the partial results of stringifying this object value. gap += indent; partial = []; // Is the value an array? if (Object.prototype.toString.apply(value) === "[object Array]") { // The value is an array. Stringify every element. Use null as a placeholder // for non-JSON values. length = value.length; for (i = 0; i < length; i += 1) { partial[i] = str(i, value) || "null"; } // Join all of the elements together, separated with commas, and wrap them in // brackets. v = partial.length === 0 ? "[]" : gap ? ( "[\n" + gap + partial.join(",\n" + gap) + "\n" + mind + "]" ) : "[" + partial.join(",") + "]"; gap = mind; return v; } // If the replacer is an array, use it to select the members to be stringified. if (rep && typeof rep === "object") { length = rep.length; for (i = 0; i < length; i += 1) { if (typeof rep[i] === "string") { k = rep[i]; v = str(k, value); if (v) { partial.push(quote(k) + ( (gap) ? ": " : ":" ) + v); } } } } else { // Otherwise, iterate through all of the keys in the object. for (k in value) { if (Object.prototype.hasOwnProperty.call(value, k)) { v = str(k, value); if (v) { partial.push(quote(k) + ( (gap) ? ": " : ":" ) + v); } } } } // Join all of the member texts together, separated with commas, // and wrap them in braces. v = partial.length === 0 ? "{}" : gap ? "{\n" + gap + partial.join(",\n" + gap) + "\n" + mind + "}" : "{" + partial.join(",") + "}"; gap = mind; return v; } } // If the JSON object does not yet have a stringify method, give it one. if (typeof JSON.stringify !== "function") { meta = { // table of character substitutions "\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", "\r": "\\r", "\"": "\\\"", "\\": "\\\\" }; JSON.stringify = function (value, replacer, space) { // The stringify method takes a value and an optional replacer, and an optional // space parameter, and returns a JSON text. The replacer can be a function // that can replace values, or an array of strings that will select the keys. // A default replacer method can be provided. Use of the space parameter can // produce text that is more easily readable. var i; gap = ""; indent = ""; // If the space parameter is a number, make an indent string containing that // many spaces. if (typeof space === "number") { for (i = 0; i < space; i += 1) { indent += " "; } // If the space parameter is a string, it will be used as the indent string. } else if (typeof space === "string") { indent = space; } // If there is a replacer, it must be a function or an array. // Otherwise, throw an error. rep = replacer; if (replacer && typeof replacer !== "function" && ( typeof replacer !== "object" || typeof replacer.length !== "number" )) { throw new Error("JSON.stringify"); } // Make a fake root object containing our value under the key of "". // Return the result of stringifying the value. return str("", {"": value}); }; } // If the JSON object does not yet have a parse method, give it one. if (typeof JSON.parse !== "function") { JSON.parse = function (text, reviver) { // The parse method takes a text and an optional reviver function, and returns // a JavaScript value if the text is a valid JSON text. var j; function walk(holder, key) { // The walk method is used to recursively walk the resulting structure so // that modifications can be made. var k; var v; var value = holder[key]; if (value && typeof value === "object") { for (k in value) { if (Object.prototype.hasOwnProperty.call(value, k)) { v = walk(value, k); if (v !== undefined) { value[k] = v; } else { delete value[k]; } } } } return reviver.call(holder, key, value); } // Parsing happens in four stages. In the first stage, we replace certain // Unicode characters with escape sequences. JavaScript handles many characters // incorrectly, either silently deleting them, or treating them as line endings. text = String(text); rx_dangerous.lastIndex = 0; if (rx_dangerous.test(text)) { text = text.replace(rx_dangerous, function (a) { return ( "\\u" + ("0000" + a.charCodeAt(0).toString(16)).slice(-4) ); }); } // In the second stage, we run the text against regular expressions that look // for non-JSON patterns. We are especially concerned with "()" and "new" // because they can cause invocation, and "=" because it can cause mutation. // But just to be safe, we want to reject all unexpected forms. // We split the second stage into 4 regexp operations in order to work around // crippling inefficiencies in IE's and Safari's regexp engines. First we // replace the JSON backslash pairs with "@" (a non-JSON character). Second, we // replace all simple value tokens with "]" characters. Third, we delete all // open brackets that follow a colon or comma or that begin the text. Finally, // we look to see that the remaining characters are only whitespace or "]" or // "," or ":" or "{" or "}". If that is so, then the text is safe for eval. if ( rx_one.test( text .replace(rx_two, "@") .replace(rx_three, "]") .replace(rx_four, "") ) ) { // In the third stage we use the eval function to compile the text into a // JavaScript structure. The "{" operator is subject to a syntactic ambiguity // in JavaScript: it can begin a block or an object literal. We wrap the text // in parens to eliminate the ambiguity. j = eval("(" + text + ")"); // In the optional fourth stage, we recursively walk the new structure, passing // each name/value pair to a reviver function for possible transformation. return (typeof reviver === "function") ? walk({"": j}, "") : j; } // If the text is not JSON parseable, then a SyntaxError is thrown. throw new SyntaxError("JSON.parse"); }; } }()); ================================================ FILE: openpype/hosts/photoshop/api/extension/index.html ================================================ ================================================ FILE: openpype/hosts/photoshop/api/launch_logic.py ================================================ import os import subprocess import collections import asyncio from wsrpc_aiohttp import ( WebSocketRoute, WebSocketAsync ) from qtpy import QtCore from openpype.lib import Logger, StringTemplate from openpype.pipeline import ( registered_host, Anatomy, ) from openpype.pipeline.workfile import ( get_workfile_template_key_from_context, get_last_workfile, ) from openpype.pipeline.template_data import get_template_data_with_names from openpype.tools.utils import host_tools from openpype.tools.adobe_webserver.app import WebServerTool from openpype.pipeline.context_tools import change_current_context from openpype.client import get_asset_by_name from .ws_stub import PhotoshopServerStub log = Logger.get_logger(__name__) class ConnectionNotEstablishedYet(Exception): pass class MainThreadItem: """Structure to store information about callback in main thread. Item should be used to execute callback in main thread which may be needed for execution of Qt objects. Item store callback (callable variable), arguments and keyword arguments for the callback. Item hold information about it's process. """ not_set = object() def __init__(self, callback, *args, **kwargs): self._done = False self._exception = self.not_set self._result = self.not_set self._callback = callback self._args = args self._kwargs = kwargs @property def done(self): return self._done @property def exception(self): return self._exception @property def result(self): return self._result def execute(self): """Execute callback and store its result. Method must be called from main thread. Item is marked as `done` when callback execution finished. Store output of callback of exception information when callback raises one. """ log.debug("Executing process in main thread") if self.done: log.warning("- item is already processed") return log.info("Running callback: {}".format(str(self._callback))) try: result = self._callback(*self._args, **self._kwargs) self._result = result except Exception as exc: self._exception = exc finally: self._done = True def stub(): """ Convenience function to get server RPC stub to call methods directed for host (Photoshop). It expects already created connection, started from client. Currently created when panel is opened (PS: Window>Extensions>Avalon) :return: where functions could be called from """ ps_stub = PhotoshopServerStub() if not ps_stub.client: raise ConnectionNotEstablishedYet("Connection is not created yet") return ps_stub def show_tool_by_name(tool_name): kwargs = {} if tool_name == "loader": kwargs["use_context"] = True host_tools.show_tool_by_name(tool_name, **kwargs) class ProcessLauncher(QtCore.QObject): route_name = "Photoshop" _main_thread_callbacks = collections.deque() def __init__(self, subprocess_args): self._subprocess_args = subprocess_args self._log = None super(ProcessLauncher, self).__init__() # Keep track if launcher was already started self._started = False self._process = None self._websocket_server = None start_process_timer = QtCore.QTimer() start_process_timer.setInterval(100) loop_timer = QtCore.QTimer() loop_timer.setInterval(200) start_process_timer.timeout.connect(self._on_start_process_timer) loop_timer.timeout.connect(self._on_loop_timer) self._start_process_timer = start_process_timer self._loop_timer = loop_timer @property def log(self): if self._log is None: self._log = Logger.get_logger( "{}-launcher".format(self.route_name) ) return self._log @property def websocket_server_is_running(self): if self._websocket_server is not None: return self._websocket_server.is_running return False @property def is_process_running(self): if self._process is not None: return self._process.poll() is None return False @property def is_host_connected(self): """Returns True if connected, False if app is not running at all.""" if not self.is_process_running: return False try: _stub = stub() if _stub: return True except Exception: pass return None @classmethod def execute_in_main_thread(cls, callback, *args, **kwargs): item = MainThreadItem(callback, *args, **kwargs) cls._main_thread_callbacks.append(item) return item def start(self): if self._started: return self.log.info("Started launch logic of Photoshop") self._started = True self._start_process_timer.start() def exit(self): """ Exit whole application. """ if self._start_process_timer.isActive(): self._start_process_timer.stop() if self._loop_timer.isActive(): self._loop_timer.stop() if self._websocket_server is not None: self._websocket_server.stop() if self._process: self._process.kill() self._process.wait() QtCore.QCoreApplication.exit() def _on_loop_timer(self): # TODO find better way and catch errors # Run only callbacks that are in queue at the moment cls = self.__class__ for _ in range(len(cls._main_thread_callbacks)): if cls._main_thread_callbacks: item = cls._main_thread_callbacks.popleft() item.execute() if not self.is_process_running: self.log.info("Host process is not running. Closing") self.exit() elif not self.websocket_server_is_running: self.log.info("Websocket server is not running. Closing") self.exit() def _on_start_process_timer(self): # TODO add try except validations for each part in this method # Start server as first thing if self._websocket_server is None: self._init_server() return # TODO add waiting time # Wait for webserver if not self.websocket_server_is_running: return # Start application process if self._process is None: self._start_process() self.log.info("Waiting for host to connect") return # TODO add waiting time # Wait until host is connected if self.is_host_connected: self._start_process_timer.stop() self._loop_timer.start() elif ( not self.is_process_running or not self.websocket_server_is_running ): self.exit() def _init_server(self): if self._websocket_server is not None: return self.log.debug( "Initialization of websocket server for host communication" ) self._websocket_server = websocket_server = WebServerTool() if websocket_server.port_occupied( websocket_server.host_name, websocket_server.port ): self.log.info( "Server already running, sending actual context and exit." ) asyncio.run(websocket_server.send_context_change(self.route_name)) self.exit() return # Add Websocket route websocket_server.add_route("*", "/ws/", WebSocketAsync) # Add after effects route to websocket handler print("Adding {} route".format(self.route_name)) WebSocketAsync.add_route( self.route_name, PhotoshopRoute ) self.log.info("Starting websocket server for host communication") websocket_server.start_server() def _start_process(self): if self._process is not None: return self.log.info("Starting host process") try: self._process = subprocess.Popen( self._subprocess_args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) except Exception: self.log.info("exce", exc_info=True) self.exit() class PhotoshopRoute(WebSocketRoute): """ One route, mimicking external application (like Harmony, etc). All functions could be called from client. 'do_notify' function calls function on the client - mimicking notification after long running job on the server or similar """ instance = None def init(self, **kwargs): # Python __init__ must be return "self". # This method might return anything. log.debug("someone called Photoshop route") self.instance = self return kwargs # server functions async def ping(self): log.debug("someone called Photoshop route ping") # This method calls function on the client side # client functions async def set_context(self, project, asset, task): """ Sets 'project' and 'asset' to envs, eg. setting context. Opens last workile from that context if exists. Args: project (str) asset (str) task (str """ log.info("Setting context change") log.info(f"project {project} asset {asset} task {task}") asset_doc = get_asset_by_name(project, asset) change_current_context(asset_doc, task) last_workfile_path = self._get_last_workfile_path(project, asset, task) if last_workfile_path and os.path.exists(last_workfile_path): ProcessLauncher.execute_in_main_thread( lambda: stub().open(last_workfile_path)) async def read(self): log.debug("photoshop.read client calls server server calls " "photoshop client") return await self.socket.call('photoshop.read') # panel routes for tools async def workfiles_route(self): self._tool_route("workfiles") async def loader_route(self): self._tool_route("loader") async def publish_route(self): self._tool_route("publisher") async def sceneinventory_route(self): self._tool_route("sceneinventory") async def experimental_tools_route(self): self._tool_route("experimental_tools") def _tool_route(self, _tool_name): """The address accessed when clicking on the buttons.""" ProcessLauncher.execute_in_main_thread(show_tool_by_name, _tool_name) # Required return statement. return "nothing" def _get_last_workfile_path(self, project_name, asset_name, task_name): """Returns last workfile path if exists""" host = registered_host() host_name = "photoshop" template_key = get_workfile_template_key_from_context( asset_name, task_name, host_name, project_name=project_name ) anatomy = Anatomy(project_name) data = get_template_data_with_names( project_name, asset_name, task_name, host_name ) data["root"] = anatomy.roots file_template = anatomy.templates[template_key]["file"] # Define saving file extension extensions = host.get_workfile_extensions() folder_template = anatomy.templates[template_key]["folder"] work_root = StringTemplate.format_strict_template( folder_template, data ) last_workfile_path = get_last_workfile( work_root, file_template, data, extensions, True ) return last_workfile_path ================================================ FILE: openpype/hosts/photoshop/api/lib.py ================================================ import os import sys import contextlib import traceback from openpype.lib import env_value_to_bool, Logger from openpype.modules import ModulesManager from openpype.pipeline import install_host from openpype.tools.utils import host_tools from openpype.tools.utils import get_openpype_qt_app from openpype.tests.lib import is_in_tests from .launch_logic import ProcessLauncher, stub log = Logger.get_logger(__name__) def safe_excepthook(*args): traceback.print_exception(*args) def main(*subprocess_args): from openpype.hosts.photoshop.api import PhotoshopHost host = PhotoshopHost() install_host(host) sys.excepthook = safe_excepthook # coloring in StdOutBroker os.environ["OPENPYPE_LOG_NO_COLORS"] = "False" app = get_openpype_qt_app() app.setQuitOnLastWindowClosed(False) launcher = ProcessLauncher(subprocess_args) launcher.start() if env_value_to_bool("HEADLESS_PUBLISH"): manager = ModulesManager() webpublisher_addon = manager["webpublisher"] launcher.execute_in_main_thread( webpublisher_addon.headless_publish, log, "ClosePS", is_in_tests() ) elif env_value_to_bool("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", default=True): launcher.execute_in_main_thread( host_tools.show_workfiles, save=env_value_to_bool("WORKFILES_SAVE_AS") ) sys.exit(app.exec_()) @contextlib.contextmanager def maintained_selection(): """Maintain selection during context.""" selection = stub().get_selected_layers() try: yield selection finally: stub().select_layers(selection) @contextlib.contextmanager def maintained_visibility(layers=None): """Maintain visibility during context. Args: layers (list) of PSItem (used for caching) """ visibility = {} if not layers: layers = stub().get_layers() for layer in layers: visibility[layer.id] = layer.visible try: yield finally: for layer in layers: stub().set_visible(layer.id, visibility[layer.id]) pass ================================================ FILE: openpype/hosts/photoshop/api/pipeline.py ================================================ import os from qtpy import QtWidgets import pyblish.api from openpype.lib import register_event_callback, Logger from openpype.pipeline import ( register_loader_plugin_path, register_creator_plugin_path, AVALON_CONTAINER_ID, ) from openpype.host import ( HostBase, IWorkfileHost, ILoadHost, IPublishHost ) from openpype.pipeline.load import any_outdated_containers from openpype.hosts.photoshop import PHOTOSHOP_HOST_DIR from openpype.tools.utils import get_openpype_qt_app from . import lib log = Logger.get_logger(__name__) PLUGINS_DIR = os.path.join(PHOTOSHOP_HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") class PhotoshopHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): name = "photoshop" def install(self): """Install Photoshop-specific functionality needed for integration. This function is called automatically on calling `api.install(photoshop)`. """ log.info("Installing OpenPype Photoshop...") pyblish.api.register_host("photoshop") pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) register_event_callback("application.launched", on_application_launch) def current_file(self): try: full_name = lib.stub().get_active_document_full_name() if full_name and full_name != "null": return os.path.normpath(full_name).replace("\\", "/") except Exception: pass return None def work_root(self, session): return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") def open_workfile(self, filepath): lib.stub().open(filepath) return True def save_workfile(self, filepath=None): _, ext = os.path.splitext(filepath) lib.stub().saveAs(filepath, ext[1:], True) def get_current_workfile(self): return self.current_file() def workfile_has_unsaved_changes(self): if self.current_file(): return not lib.stub().is_saved() return False def get_workfile_extensions(self): return [".psd", ".psb"] def get_containers(self): return ls() def get_context_data(self): """Get stored values for context (validation enable/disable etc)""" meta = _get_stub().get_layers_metadata() for item in meta: if item.get("id") == "publish_context": item.pop("id") return item return {} def update_context_data(self, data, changes): """Store value needed for context""" item = data item["id"] = "publish_context" _get_stub().imprint(item["id"], item) def list_instances(self): """List all created instances to publish from current workfile. Pulls from File > File Info Returns: (list) of dictionaries matching instances format """ stub = _get_stub() if not stub: return [] instances = [] layers_meta = stub.get_layers_metadata() if layers_meta: for instance in layers_meta: if instance.get("id") == "pyblish.avalon.instance": instances.append(instance) return instances def remove_instance(self, instance): """Remove instance from current workfile metadata. Updates metadata of current file in File > File Info and removes icon highlight on group layer. Args: instance (dict): instance representation from subsetmanager model """ stub = _get_stub() if not stub: return inst_id = instance.get("instance_id") or instance.get("uuid") # legacy if not inst_id: log.warning("No instance identifier for {}".format(instance)) return stub.remove_instance(inst_id) if instance.get("members"): item = stub.get_layer(instance["members"][0]) if item: stub.rename_layer(item.id, item.name.replace(stub.PUBLISH_ICON, '')) def check_inventory(): if not any_outdated_containers(): return # Warn about outdated containers. _app = get_openpype_qt_app() message_box = QtWidgets.QMessageBox() message_box.setIcon(QtWidgets.QMessageBox.Warning) msg = "There are outdated containers in the scene." message_box.setText(msg) message_box.exec_() def on_application_launch(): check_inventory() def ls(): """Yields containers from active Photoshop document This is the host-equivalent of api.ls(), but instead of listing assets on disk, it lists assets already loaded in Photoshop; once loaded they are called 'containers' Yields: dict: container """ try: stub = lib.stub() # only after Photoshop is up except lib.ConnectionNotEstablishedYet: print("Not connected yet, ignoring") return if not stub.get_active_document_name(): return layers_meta = stub.get_layers_metadata() # minimalize calls to PS for layer in stub.get_layers(): data = stub.read(layer, layers_meta) # Skip non-tagged layers. if not data: continue # Filter to only containers. if "container" not in data["id"]: continue # Append transient data data["objectName"] = layer.name.replace(stub.LOADED_ICON, '') data["layer"] = layer yield data def _get_stub(): """Handle pulling stub from PS to run operations on host Returns: (PhotoshopServerStub) or None """ try: stub = lib.stub() # only after Photoshop is up except lib.ConnectionNotEstablishedYet: print("Not connected yet, ignoring") return if not stub.get_active_document_name(): return return stub def containerise( name, namespace, layer, context, loader=None, suffix="_CON" ): """Imprint layer with metadata Containerisation enables a tracking of version, author and origin for loaded assets. Arguments: name (str): Name of resulting assembly namespace (str): Namespace under which to host container layer (PSItem): Layer to containerise context (dict): Asset information loader (str, optional): Name of loader used to produce this container. suffix (str, optional): Suffix of container, defaults to `_CON`. Returns: container (str): Name of container assembly """ layer.name = name + suffix data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace, "loader": str(loader), "representation": str(context["representation"]["_id"]), "members": [str(layer.id)] } stub = lib.stub() stub.imprint(layer.id, data) return layer def cache_and_get_instances(creator): """Cache instances in shared data. Storing all instances as a list as legacy instances might be still present. Args: creator (Creator): Plugin which would like to get instances from host. Returns: List[]: list of all instances stored in metadata """ shared_key = "openpype.photoshop.instances" if shared_key not in creator.collection_shared_data: creator.collection_shared_data[shared_key] = \ creator.host.list_instances() return creator.collection_shared_data[shared_key] ================================================ FILE: openpype/hosts/photoshop/api/plugin.py ================================================ import re from openpype.pipeline import LoaderPlugin from .launch_logic import stub def get_unique_layer_name(layers, asset_name, subset_name): """ Gets all layer names and if 'asset_name_subset_name' is present, it increases suffix by 1 (eg. creates unique layer name - for Loader) Args: layers (list) of dict with layers info (name, id etc.) asset_name (string): subset_name (string): Returns: (string): name_00X (without version) """ name = "{}_{}".format(asset_name, subset_name) names = {} for layer in layers: layer_name = re.sub(r'_\d{3}$', '', layer.name) if layer_name in names.keys(): names[layer_name] = names[layer_name] + 1 else: names[layer_name] = 1 occurrences = names.get(name, 0) return "{}_{:0>3d}".format(name, occurrences + 1) class PhotoshopLoader(LoaderPlugin): @staticmethod def get_stub(): return stub() ================================================ FILE: openpype/hosts/photoshop/api/ws_stub.py ================================================ """ Stub handling connection from server to client. Used anywhere solution is calling client methods. """ import json import attr from wsrpc_aiohttp import WebSocketAsync from openpype.tools.adobe_webserver.app import WebServerTool @attr.s class PSItem(object): """ Object denoting layer or group item in PS. Each item is created in PS by any Loader, but contains same fields, which are being used in later processing. """ # metadata id = attr.ib() # id created by AE, could be used for querying name = attr.ib() # name of item group = attr.ib(default=None) # item type (footage, folder, comp) parents = attr.ib(factory=list) visible = attr.ib(default=True) type = attr.ib(default=None) # all imported elements, single for members = attr.ib(factory=list) long_name = attr.ib(default=None) color_code = attr.ib(default=None) # color code of layer instance_id = attr.ib(default=None) @property def clean_name(self): """Returns layer name without publish icon highlight Returns: (str) """ return (self.name.replace(PhotoshopServerStub.PUBLISH_ICON, '') .replace(PhotoshopServerStub.LOADED_ICON, '')) class PhotoshopServerStub: """ Stub for calling function on client (Photoshop js) side. Expects that client is already connected (started when avalon menu is opened). 'self.websocketserver.call' is used as async wrapper """ PUBLISH_ICON = '\u2117 ' LOADED_ICON = '\u25bc' def __init__(self): self.websocketserver = WebServerTool.get_instance() self.client = self.get_client() @staticmethod def get_client(): """ Return first connected client to WebSocket TODO implement selection by Route :return: client """ clients = WebSocketAsync.get_clients() client = None if len(clients) > 0: key = list(clients.keys())[0] client = clients.get(key) return client def open(self, path): """Open file located at 'path' (local). Args: path(string): file path locally Returns: None """ self.websocketserver.call( self.client.call('Photoshop.open', path=path) ) def read(self, layer, layers_meta=None): """Parses layer metadata from Headline field of active document. Args: layer: (PSItem) layers_meta: full list from Headline (for performance in loops) Returns: (dict) of layer metadata stored in PS file Example: { 'id': 'pyblish.avalon.container', 'loader': 'ImageLoader', 'members': ['64'], 'name': 'imageMainMiddle', 'namespace': 'Hero_imageMainMiddle_001', 'representation': '6203dc91e80934d9f6ee7d96', 'schema': 'openpype:container-2.0' } """ if layers_meta is None: layers_meta = self.get_layers_metadata() for layer_meta in layers_meta: layer_id = layer_meta.get("uuid") # legacy if layer_meta.get("members"): layer_id = layer_meta["members"][0] if str(layer.id) == str(layer_id): return layer_meta print("Unable to find layer metadata for {}".format(layer.id)) def imprint(self, item_id, data, all_layers=None, items_meta=None): """Save layer metadata to Headline field of active document Stores metadata in format: [{ "active":true, "subset":"imageBG", "family":"image", "id":"pyblish.avalon.instance", "asset":"Town", "uuid": "8" }] - for created instances OR [{ "schema": "openpype:container-2.0", "id": "pyblish.avalon.instance", "name": "imageMG", "namespace": "Jungle_imageMG_001", "loader": "ImageLoader", "representation": "5fbfc0ee30a946093c6ff18a", "members": [ "40" ] }] - for loaded instances Args: item_id (str): data(string): json representation for single layer all_layers (list of PSItem): for performance, could be injected for usage in loop, if not, single call will be triggered items_meta(string): json representation from Headline (for performance - provide only if imprint is in loop - value should be same) Returns: None """ if not items_meta: items_meta = self.get_layers_metadata() # json.dumps writes integer values in a dictionary to string, so # anticipating it here. item_id = str(item_id) is_new = True result_meta = [] for item_meta in items_meta: if ((item_meta.get('members') and item_id == str(item_meta.get('members')[0])) or item_meta.get("instance_id") == item_id): is_new = False if data: item_meta.update(data) result_meta.append(item_meta) else: result_meta.append(item_meta) if is_new: result_meta.append(data) # Ensure only valid ids are stored. if not all_layers: all_layers = self.get_layers() layer_ids = [layer.id for layer in all_layers] cleaned_data = [] for item in result_meta: if item.get("members"): if int(item["members"][0]) not in layer_ids: continue cleaned_data.append(item) payload = json.dumps(cleaned_data, indent=4) self.websocketserver.call( self.client.call('Photoshop.imprint', payload=payload) ) def get_layers(self): """Returns JSON document with all(?) layers in active document. Returns: Format of tuple: { 'id':'123', 'name': 'My Layer 1', 'type': 'GUIDE'|'FG'|'BG'|'OBJ' 'visible': 'true'|'false' """ res = self.websocketserver.call( self.client.call('Photoshop.get_layers') ) return self._to_records(res) def get_layer(self, layer_id): """ Returns PSItem for specific 'layer_id' or None if not found Args: layer_id (string): unique layer id, stored in 'uuid' field Returns: (PSItem) or None """ layers = self.get_layers() for layer in layers: if str(layer.id) == str(layer_id): return layer def get_layers_in_layers(self, layers): """Return all layers that belong to layers (might be groups). Args: layers : Returns: """ parent_ids = set([lay.id for lay in layers]) return self._get_layers_in_layers(parent_ids) def get_layers_in_layers_ids(self, layers_ids, layers=None): """Return all layers that belong to layers (might be groups). Args: layers_ids layers : Returns: """ parent_ids = set(layers_ids) return self._get_layers_in_layers(parent_ids, layers) def _get_layers_in_layers(self, parent_ids, layers=None): if not layers: layers = self.get_layers() all_layers = layers ret = [] for layer in all_layers: parents = set(layer.parents) if len(parent_ids & parents) > 0: ret.append(layer) if layer.id in parent_ids: ret.append(layer) return ret def create_group(self, name): """Create new group (eg. LayerSet) Returns: """ enhanced_name = self.PUBLISH_ICON + name ret = self.websocketserver.call( self.client.call('Photoshop.create_group', name=enhanced_name) ) # create group on PS is asynchronous, returns only id return PSItem(id=ret, name=name, group=True) def group_selected_layers(self, name): """Group selected layers into new LayerSet (eg. group) Returns: (Layer) """ enhanced_name = self.PUBLISH_ICON + name res = self.websocketserver.call( self.client.call( 'Photoshop.group_selected_layers', name=enhanced_name ) ) res = self._to_records(res) if res: rec = res.pop() rec.name = rec.name.replace(self.PUBLISH_ICON, '') return rec raise ValueError("No group record returned") def get_selected_layers(self): """Get a list of actually selected layers. Returns: """ res = self.websocketserver.call( self.client.call('Photoshop.get_selected_layers') ) return self._to_records(res) def select_layers(self, layers): """Selects specified layers in Photoshop by its ids. Args: layers: """ layers_id = [str(lay.id) for lay in layers] self.websocketserver.call( self.client.call( 'Photoshop.select_layers', layers=json.dumps(layers_id) ) ) def get_active_document_full_name(self): """Returns full name with path of active document via ws call Returns(string): full path with name """ res = self.websocketserver.call( self.client.call('Photoshop.get_active_document_full_name') ) return res def get_active_document_name(self): """Returns just a name of active document via ws call Returns(string): file name """ return self.websocketserver.call( self.client.call('Photoshop.get_active_document_name') ) def is_saved(self): """Returns true if no changes in active document Returns: """ return self.websocketserver.call( self.client.call('Photoshop.is_saved') ) def save(self): """Saves active document""" self.websocketserver.call( self.client.call('Photoshop.save') ) def saveAs(self, image_path, ext, as_copy): """Saves active document to psd (copy) or png or jpg Args: image_path(string): full local path ext: as_copy: Returns: None """ self.websocketserver.call( self.client.call( 'Photoshop.saveAs', image_path=image_path, ext=ext, as_copy=as_copy ) ) def set_visible(self, layer_id, visibility): """Set layer with 'layer_id' to 'visibility' Args: layer_id: visibility: Returns: None """ self.websocketserver.call( self.client.call( 'Photoshop.set_visible', layer_id=layer_id, visibility=visibility ) ) def hide_all_others_layers(self, layers): """hides all layers that are not part of the list or that are not children of this list Args: layers (list): list of PSItem - highest hierarchy """ extract_ids = set([ll.id for ll in self.get_layers_in_layers(layers)]) self.hide_all_others_layers_ids(extract_ids) def hide_all_others_layers_ids(self, extract_ids, layers=None): """hides all layers that are not part of the list or that are not children of this list Args: extract_ids (list): list of integer that should be visible layers (list) of PSItem (used for caching) """ if not layers: layers = self.get_layers() for layer in layers: if layer.visible and layer.id not in extract_ids: self.set_visible(layer.id, False) def get_layers_metadata(self): """Reads layers metadata from Headline from active document in PS. (Headline accessible by File > File Info) Returns: (list) example: {"8":{"active":true,"subset":"imageBG", "family":"image","id":"pyblish.avalon.instance", "asset":"Town"}} 8 is layer(group) id - used for deletion, update etc. """ res = self.websocketserver.call(self.client.call('Photoshop.read')) layers_data = [] try: if res: layers_data = json.loads(res) except json.decoder.JSONDecodeError: raise ValueError("{} cannot be parsed, recreate meta".format(res)) # format of metadata changed from {} to [] because of standardization # keep current implementation logic as its working if isinstance(layers_data, dict): for layer_id, layer_meta in layers_data.items(): if layer_meta.get("schema") != "openpype:container-2.0": layer_meta["members"] = [str(layer_id)] layers_data = list(layers_data.values()) return layers_data def import_smart_object(self, path, layer_name, as_reference=False): """Import the file at `path` as a smart object to active document. Args: path (str): File path to import. layer_name (str): Unique layer name to differentiate how many times same smart object was loaded as_reference (bool): pull in content or reference """ enhanced_name = self.LOADED_ICON + layer_name res = self.websocketserver.call( self.client.call( 'Photoshop.import_smart_object', path=path, name=enhanced_name, as_reference=as_reference ) ) rec = self._to_records(res).pop() if rec: rec.name = rec.name.replace(self.LOADED_ICON, '') return rec def replace_smart_object(self, layer, path, layer_name): """Replace the smart object `layer` with file at `path` Args: layer (PSItem): path (str): File to import. layer_name (str): Unique layer name to differentiate how many times same smart object was loaded """ enhanced_name = self.LOADED_ICON + layer_name self.websocketserver.call( self.client.call( 'Photoshop.replace_smart_object', layer_id=layer.id, path=path, name=enhanced_name ) ) def delete_layer(self, layer_id): """Deletes specific layer by it's id. Args: layer_id (int): id of layer to delete """ self.websocketserver.call( self.client.call('Photoshop.delete_layer', layer_id=layer_id) ) def rename_layer(self, layer_id, name): """Renames specific layer by it's id. Args: layer_id (int): id of layer to delete name (str): new name """ self.websocketserver.call( self.client.call( 'Photoshop.rename_layer', layer_id=layer_id, name=name ) ) def remove_instance(self, instance_id): cleaned_data = [] for item in self.get_layers_metadata(): inst_id = item.get("instance_id") or item.get("uuid") if inst_id != instance_id: cleaned_data.append(item) payload = json.dumps(cleaned_data, indent=4) self.websocketserver.call( self.client.call('Photoshop.imprint', payload=payload) ) def get_extension_version(self): """Returns version number of installed extension.""" return self.websocketserver.call( self.client.call('Photoshop.get_extension_version') ) def close(self): """Shutting down PS and process too. For webpublishing only. """ # TODO change client.call to method with checks for client self.websocketserver.call(self.client.call('Photoshop.close')) def _to_records(self, res): """Converts string json representation into list of PSItem for dot notation access to work. Args: res (string): valid json Returns: """ try: layers_data = json.loads(res) except json.decoder.JSONDecodeError: raise ValueError("Received broken JSON {}".format(res)) ret = [] # convert to AEItem to use dot donation if isinstance(layers_data, dict): layers_data = [layers_data] for d in layers_data: # currently implemented and expected fields ret.append(PSItem( d.get('id'), d.get('name'), d.get('group'), d.get('parents'), d.get('visible'), d.get('type'), d.get('members'), d.get('long_name'), d.get("color_code"), d.get("instance_id") )) return ret ================================================ FILE: openpype/hosts/photoshop/lib.py ================================================ import re from openpype import AYON_SERVER_ENABLED import openpype.hosts.photoshop.api as api from openpype.client import get_asset_by_name from openpype.lib import prepare_template_data from openpype.pipeline import ( AutoCreator, CreatedInstance ) from openpype.hosts.photoshop.api.pipeline import cache_and_get_instances class PSAutoCreator(AutoCreator): """Generic autocreator to extend.""" def get_instance_attr_defs(self): return [] def collect_instances(self): for instance_data in cache_and_get_instances(self): creator_id = instance_data.get("creator_identifier") if creator_id == self.identifier: instance = CreatedInstance.from_existing( instance_data, self ) self._add_instance_to_context(instance) def update_instances(self, update_list): self.log.debug("update_list:: {}".format(update_list)) for created_inst, _changes in update_list: api.stub().imprint(created_inst.get("instance_id"), created_inst.data_to_store()) def create(self, options=None): existing_instance = None for instance in self.create_context.instances: if instance.family == self.family: existing_instance = instance break context = self.create_context project_name = context.get_current_project_name() asset_name = context.get_current_asset_name() task_name = context.get_current_task_name() host_name = context.host_name if existing_instance is None: existing_instance_asset = None elif AYON_SERVER_ENABLED: existing_instance_asset = existing_instance["folderPath"] else: existing_instance_asset = existing_instance["asset"] if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, project_name, host_name ) data = { "task": task_name, "variant": self.default_variant } if AYON_SERVER_ENABLED: data["folderPath"] = asset_name else: data["asset"] = asset_name data.update(self.get_dynamic_data( self.default_variant, task_name, asset_doc, project_name, host_name, None )) if not self.active_on_create: data["active"] = False new_instance = CreatedInstance( self.family, subset_name, data, self ) self._add_instance_to_context(new_instance) api.stub().imprint(new_instance.get("instance_id"), new_instance.data_to_store()) elif ( existing_instance_asset != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, project_name, host_name ) if AYON_SERVER_ENABLED: existing_instance["folderPath"] = asset_name else: existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name def clean_subset_name(subset_name): """Clean all variants leftover {layer} from subset name.""" dynamic_data = prepare_template_data({"layer": "{layer}"}) for value in dynamic_data.values(): if value in subset_name: subset_name = (subset_name.replace(value, "") .replace("__", "_") .replace("..", ".")) # clean trailing separator as Main_ pattern = r'[\W_]+$' replacement = '' return re.sub(pattern, replacement, subset_name) ================================================ FILE: openpype/hosts/photoshop/plugins/create/create_flatten_image.py ================================================ from openpype.pipeline import CreatedInstance from openpype import AYON_SERVER_ENABLED from openpype.lib import BoolDef import openpype.hosts.photoshop.api as api from openpype.hosts.photoshop.lib import PSAutoCreator, clean_subset_name from openpype.pipeline.create import get_subset_name from openpype.lib import prepare_template_data from openpype.client import get_asset_by_name class AutoImageCreator(PSAutoCreator): """Creates flatten image from all visible layers. Used in simplified publishing as auto created instance. Must be enabled in Setting and template for subset name provided """ identifier = "auto_image" family = "image" # Settings default_variant = "" # - Mark by default instance for review mark_for_review = True active_on_create = True def create(self, options=None): existing_instance = None for instance in self.create_context.instances: if instance.creator_identifier == self.identifier: existing_instance = instance break context = self.create_context project_name = context.get_current_project_name() asset_name = context.get_current_asset_name() task_name = context.get_current_task_name() host_name = context.host_name asset_doc = get_asset_by_name(project_name, asset_name) if existing_instance is None: existing_instance_asset = None elif AYON_SERVER_ENABLED: existing_instance_asset = existing_instance["folderPath"] else: existing_instance_asset = existing_instance["asset"] if existing_instance is None: subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, project_name, host_name ) data = { "task": task_name, } if AYON_SERVER_ENABLED: data["folderPath"] = asset_name else: data["asset"] = asset_name if not self.active_on_create: data["active"] = False creator_attributes = {"mark_for_review": self.mark_for_review} data.update({"creator_attributes": creator_attributes}) new_instance = CreatedInstance( self.family, subset_name, data, self ) self._add_instance_to_context(new_instance) api.stub().imprint(new_instance.get("instance_id"), new_instance.data_to_store()) elif ( # existing instance from different context existing_instance_asset != asset_name or existing_instance["task"] != task_name ): subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, project_name, host_name ) if AYON_SERVER_ENABLED: existing_instance["folderPath"] = asset_name else: existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name api.stub().imprint(existing_instance.get("instance_id"), existing_instance.data_to_store()) def get_pre_create_attr_defs(self): return [ BoolDef( "mark_for_review", label="Review", default=self.mark_for_review ) ] def get_instance_attr_defs(self): return [ BoolDef( "mark_for_review", label="Review" ) ] def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["AutoImageCreator"] ) self.active_on_create = plugin_settings["active_on_create"] self.default_variant = plugin_settings["default_variant"] self.mark_for_review = plugin_settings["mark_for_review"] self.enabled = plugin_settings["enabled"] def get_detail_description(self): return """Creator for flatten image. Studio might configure simple publishing workflow. In that case `image` instance is automatically created which will publish flat image from all visible layers. Artist might disable this instance from publishing or from creating review for it though. """ def get_subset_name( self, variant, task_name, asset_doc, project_name, host_name=None, instance=None ): dynamic_data = prepare_template_data({"layer": "{layer}"}) subset_name = get_subset_name( self.family, variant, task_name, asset_doc, project_name, host_name, dynamic_data=dynamic_data ) return clean_subset_name(subset_name) ================================================ FILE: openpype/hosts/photoshop/plugins/create/create_image.py ================================================ import re from openpype.hosts.photoshop import api from openpype.lib import BoolDef from openpype.pipeline import ( Creator, CreatedInstance, CreatorError ) from openpype.lib import prepare_template_data from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS from openpype.hosts.photoshop.api.pipeline import cache_and_get_instances from openpype.hosts.photoshop.lib import clean_subset_name class ImageCreator(Creator): """Creates image instance for publishing. Result of 'image' instance is image of all visible layers, or image(s) of selected layers. """ identifier = "image" label = "Image" family = "image" description = "Image creator" # Settings default_variants = "" mark_for_review = False active_on_create = True def create(self, subset_name_from_ui, data, pre_create_data): groups_to_create = [] top_layers_to_wrap = [] create_empty_group = False stub = api.stub() # only after PS is up top_level_selected_items = stub.get_selected_layers() if pre_create_data.get("use_selection"): only_single_item_selected = len(top_level_selected_items) == 1 if ( only_single_item_selected or pre_create_data.get("create_multiple")): for selected_item in top_level_selected_items: if selected_item.group: groups_to_create.append(selected_item) else: top_layers_to_wrap.append(selected_item) else: group = stub.group_selected_layers(subset_name_from_ui) groups_to_create.append(group) else: stub.select_layers(stub.get_layers()) try: group = stub.group_selected_layers(subset_name_from_ui) except: raise CreatorError("Cannot group locked Background layer!") groups_to_create.append(group) # create empty group if nothing selected if not groups_to_create and not top_layers_to_wrap: group = stub.create_group(subset_name_from_ui) groups_to_create.append(group) # wrap each top level layer into separate new group for layer in top_layers_to_wrap: stub.select_layers([layer]) group = stub.group_selected_layers(layer.name) groups_to_create.append(group) layer_name = '' # use artist chosen option OR force layer if more subsets are created # to differentiate them use_layer_name = (pre_create_data.get("use_layer_name") or len(groups_to_create) > 1) for group in groups_to_create: subset_name = subset_name_from_ui # reset to name from creator UI layer_names_in_hierarchy = [] created_group_name = self._clean_highlights(stub, group.name) if use_layer_name: layer_name = re.sub( "[^{}]+".format(SUBSET_NAME_ALLOWED_SYMBOLS), "", group.name ) if "{layer}" not in subset_name.lower(): subset_name += "{Layer}" layer_fill = prepare_template_data({"layer": layer_name}) subset_name = subset_name.format(**layer_fill) subset_name = clean_subset_name(subset_name) if group.long_name: for directory in group.long_name[::-1]: name = self._clean_highlights(stub, directory) layer_names_in_hierarchy.append(name) data_update = { "subset": subset_name, "members": [str(group.id)], "layer_name": layer_name, "long_name": "_".join(layer_names_in_hierarchy) } data.update(data_update) mark_for_review = (pre_create_data.get("mark_for_review") or self.mark_for_review) creator_attributes = {"mark_for_review": mark_for_review} data.update({"creator_attributes": creator_attributes}) if not self.active_on_create: data["active"] = False new_instance = CreatedInstance(self.family, subset_name, data, self) stub.imprint(new_instance.get("instance_id"), new_instance.data_to_store()) self._add_instance_to_context(new_instance) # reusing existing group, need to rename afterwards if not create_empty_group: stub.rename_layer(group.id, stub.PUBLISH_ICON + created_group_name) def collect_instances(self): for instance_data in cache_and_get_instances(self): # legacy instances have family=='image' creator_id = (instance_data.get("creator_identifier") or instance_data.get("family")) if creator_id == self.identifier: instance_data = self._handle_legacy(instance_data) instance = CreatedInstance.from_existing( instance_data, self ) self._add_instance_to_context(instance) def update_instances(self, update_list): self.log.debug("update_list:: {}".format(update_list)) for created_inst, _changes in update_list: if created_inst.get("layer"): # not storing PSItem layer to metadata created_inst.pop("layer") api.stub().imprint(created_inst.get("instance_id"), created_inst.data_to_store()) def remove_instances(self, instances): for instance in instances: self.host.remove_instance(instance) self._remove_instance_from_context(instance) def get_pre_create_attr_defs(self): output = [ BoolDef("use_selection", default=True, label="Create only for selected"), BoolDef("create_multiple", default=True, label="Create separate instance for each selected"), BoolDef("use_layer_name", default=False, label="Use layer name in subset"), BoolDef( "mark_for_review", label="Create separate review", default=False ) ] return output def get_instance_attr_defs(self): return [ BoolDef( "mark_for_review", label="Review" ) ] def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["ImageCreator"] ) self.active_on_create = plugin_settings["active_on_create"] self.default_variants = plugin_settings["default_variants"] self.mark_for_review = plugin_settings["mark_for_review"] self.enabled = plugin_settings["enabled"] def get_detail_description(self): return """Creator for Image instances Main publishable item in Photoshop will be of `image` family. Result of this item (instance) is picture that could be loaded and used in another DCCs (for example as single layer in composition in AfterEffects, reference in Maya etc). There are couple of options what to publish: - separate image per selected layer (or group of layers) - one image for all selected layers - all visible layers (groups) flattened into single image In most cases you would like to keep `Create only for selected` toggled on and select what you would like to publish. Toggling this option off will allow you to create instance for all visible layers without a need to select them explicitly. Use 'Create separate instance for each selected' to create separate images per selected layer (group of layers). 'Use layer name in subset' will explicitly add layer name into subset name. Position of this name is configurable in `project_settings/global/tools/creator/subset_name_profiles`. If layer placeholder ({layer}) is not used in `subset_name_profiles` but layer name should be used (set explicitly in UI or implicitly if multiple images should be created), it is added in capitalized form as a suffix to subset name. Each image could have its separate review created if necessary via `Create separate review` toggle. But more use case is to use separate `review` instance to create review from all published items. """ def _handle_legacy(self, instance_data): """Converts old instances to new format.""" if not instance_data.get("members"): instance_data["members"] = [instance_data.get("uuid")] if instance_data.get("uuid"): # uuid not needed, replaced with unique instance_id api.stub().remove_instance(instance_data.get("uuid")) instance_data.pop("uuid") if not instance_data.get("task"): instance_data["task"] = self.create_context.get_current_task_name() if not instance_data.get("variant"): instance_data["variant"] = '' return instance_data def _clean_highlights(self, stub, item): return item.replace(stub.PUBLISH_ICON, '').replace(stub.LOADED_ICON, '') def get_dynamic_data(self, variant, task_name, asset_doc, project_name, host_name, instance): if instance is not None: layer_name = instance.get("layer_name") if layer_name: return {"layer": layer_name} return {"layer": "{layer}"} ================================================ FILE: openpype/hosts/photoshop/plugins/create/create_review.py ================================================ from openpype.hosts.photoshop.lib import PSAutoCreator class ReviewCreator(PSAutoCreator): """Creates review instance which might be disabled from publishing.""" identifier = "review" family = "review" default_variant = "Main" def get_detail_description(self): return """Auto creator for review. Photoshop review is created from all published images or from all visible layers if no `image` instances got created. Review might be disabled by an artist (instance shouldn't be deleted as it will get recreated in next publish either way). """ def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["ReviewCreator"] ) self.default_variant = plugin_settings["default_variant"] self.active_on_create = plugin_settings["active_on_create"] self.enabled = plugin_settings["enabled"] ================================================ FILE: openpype/hosts/photoshop/plugins/create/create_workfile.py ================================================ from openpype.hosts.photoshop.lib import PSAutoCreator class WorkfileCreator(PSAutoCreator): identifier = "workfile" family = "workfile" default_variant = "Main" def get_detail_description(self): return """Auto creator for workfile. It is expected that each publish will also publish its source workfile for safekeeping. This creator triggers automatically without need for an artist to remember and trigger it explicitly. Workfile instance could be disabled if it is not required to publish workfile. (Instance shouldn't be deleted though as it will be recreated in next publish automatically). """ def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["WorkfileCreator"] ) self.active_on_create = plugin_settings["active_on_create"] self.enabled = plugin_settings["enabled"] ================================================ FILE: openpype/hosts/photoshop/plugins/load/load_image.py ================================================ import re from openpype.pipeline import get_representation_path from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name class ImageLoader(photoshop.PhotoshopLoader): """Load images Stores the imported asset in a container named after the asset. """ families = ["image", "render"] representations = ["*"] def load(self, context, name=None, namespace=None, data=None): stub = self.get_stub() layer_name = get_unique_layer_name( stub.get_layers(), context["asset"]["name"], name ) with photoshop.maintained_selection(): path = self.filepath_from_context(context) layer = self.import_layer(path, layer_name, stub) self[:] = [layer] namespace = namespace or layer_name return photoshop.containerise( name, namespace, layer, context, self.__class__.__name__ ) def update(self, container, representation): """ Switch asset or change version """ stub = self.get_stub() layer = container.pop("layer") context = representation.get("context", {}) namespace_from_container = re.sub(r'_\d{3}$', '', container["namespace"]) layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: layer_name = get_unique_layer_name( stub.get_layers(), context["asset"], context["subset"] ) else: # switching version - keep same name layer_name = container["namespace"] path = get_representation_path(representation) with photoshop.maintained_selection(): stub.replace_smart_object( layer, path, layer_name ) stub.imprint( layer.id, {"representation": str(representation["_id"])} ) def remove(self, container): """ Removes element from scene: deletes layer + removes from Headline Args: container (dict): container to be removed - used to get layer_id """ stub = self.get_stub() layer = container.pop("layer") stub.imprint(layer.id, {}) stub.delete_layer(layer.id) def switch(self, container, representation): self.update(container, representation) def import_layer(self, file_name, layer_name, stub): return stub.import_smart_object(file_name, layer_name) ================================================ FILE: openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py ================================================ import os import qargparse from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name class ImageFromSequenceLoader(photoshop.PhotoshopLoader): """ Load specific image from sequence Used only as quick load of reference file from a sequence. Plain ImageLoader picks first frame from sequence. Loads only existing files - currently not possible to limit loaders to single select - multiselect. If user selects multiple repres, list for all of them is provided, but selection is only single file. This loader will be triggered multiple times, but selected name will match only to proper path. Loader doesnt do containerization as there is currently no data model of 'frame of rendered files' (only rendered sequence), update would be difficult. """ families = ["render"] representations = ["*"] options = [] def load(self, context, name=None, namespace=None, data=None): path = self.filepath_from_context(context) if data.get("frame"): path = os.path.join( os.path.dirname(path), data["frame"] ) if not os.path.exists(path): return stub = self.get_stub() layer_name = get_unique_layer_name( stub.get_layers(), context["asset"]["name"], name ) with photoshop.maintained_selection(): layer = stub.import_smart_object(path, layer_name) self[:] = [layer] namespace = namespace or layer_name return namespace @classmethod def get_options(cls, repre_contexts): """ Returns list of files for selected 'repre_contexts'. It returns only files with same extension as in context as it is expected that context points to sequence of frames. Returns: (list) of qargparse.Choice """ files = [] for context in repre_contexts: fname = cls.filepath_from_context(context) _, file_extension = os.path.splitext(fname) for file_name in os.listdir(os.path.dirname(fname)): if not file_name.endswith(file_extension): continue files.append(file_name) # return selection only if there is something if not files or len(files) <= 1: return [] return [ qargparse.Choice( "frame", label="Select specific file", items=files, default=0, help="Which frame should be loaded?" ) ] def update(self, container, representation): """No update possible, not containerized.""" pass def remove(self, container): """No update possible, not containerized.""" pass ================================================ FILE: openpype/hosts/photoshop/plugins/load/load_reference.py ================================================ import re from openpype.pipeline import get_representation_path from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name class ReferenceLoader(photoshop.PhotoshopLoader): """Load reference images Stores the imported asset in a container named after the asset. Inheriting from 'load_image' didn't work because of "Cannot write to closing transport", possible refactor. """ families = ["image", "render"] representations = ["*"] def load(self, context, name=None, namespace=None, data=None): stub = self.get_stub() layer_name = get_unique_layer_name( stub.get_layers(), context["asset"]["name"], name ) with photoshop.maintained_selection(): path = self.filepath_from_context(context) layer = self.import_layer(path, layer_name, stub) self[:] = [layer] namespace = namespace or layer_name return photoshop.containerise( name, namespace, layer, context, self.__class__.__name__ ) def update(self, container, representation): """ Switch asset or change version """ stub = self.get_stub() layer = container.pop("layer") context = representation.get("context", {}) namespace_from_container = re.sub(r'_\d{3}$', '', container["namespace"]) layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: layer_name = get_unique_layer_name( stub.get_layers(), context["asset"], context["subset"] ) else: # switching version - keep same name layer_name = container["namespace"] path = get_representation_path(representation) with photoshop.maintained_selection(): stub.replace_smart_object( layer, path, layer_name ) stub.imprint( layer.id, {"representation": str(representation["_id"])} ) def remove(self, container): """Removes element from scene: deletes layer + removes from Headline Args: container (dict): container to be removed - used to get layer_id """ stub = self.get_stub() layer = container.pop("layer") stub.imprint(layer.id, {}) stub.delete_layer(layer.id) def switch(self, container, representation): self.update(container, representation) def import_layer(self, file_name, layer_name, stub): return stub.import_smart_object( file_name, layer_name, as_reference=True ) ================================================ FILE: openpype/hosts/photoshop/plugins/publish/closePS.py ================================================ # -*- coding: utf-8 -*- """Close PS after publish. For Webpublishing only.""" import os import pyblish.api from openpype.hosts.photoshop import api as photoshop class ClosePS(pyblish.api.ContextPlugin): """Close PS after publish. For Webpublishing only. """ order = pyblish.api.IntegratorOrder + 14 label = "Close PS" optional = True active = True hosts = ["photoshop"] targets = ["automated"] def process(self, context): self.log.info("ClosePS") stub = photoshop.stub() self.log.info("Shutting down PS") stub.save() stub.close() self.log.info("PS closed") ================================================ FILE: openpype/hosts/photoshop/plugins/publish/collect_auto_image.py ================================================ import pyblish.api from openpype.client import get_asset_name_identifier from openpype.hosts.photoshop import api as photoshop from openpype.pipeline.create import get_subset_name class CollectAutoImage(pyblish.api.ContextPlugin): """Creates auto image in non artist based publishes (Webpublisher). """ label = "Collect Auto Image" order = pyblish.api.CollectorOrder hosts = ["photoshop"] order = pyblish.api.CollectorOrder + 0.2 targets = ["automated"] def process(self, context): for instance in context: creator_identifier = instance.data.get("creator_identifier") if creator_identifier and creator_identifier == "auto_image": self.log.debug("Auto image instance found, won't create new") return project_name = context.data["projectName"] proj_settings = context.data["project_settings"] task_name = context.data["task"] host_name = context.data["hostName"] asset_doc = context.data["assetEntity"] asset_name = get_asset_name_identifier(asset_doc) auto_creator = proj_settings.get( "photoshop", {}).get( "create", {}).get( "AutoImageCreator", {}) if not auto_creator or not auto_creator["enabled"]: self.log.debug("Auto image creator disabled, won't create new") return stub = photoshop.stub() stored_items = stub.get_layers_metadata() for item in stored_items: if item.get("creator_identifier") == "auto_image": if not item.get("active"): self.log.debug("Auto_image instance disabled") return layer_items = stub.get_layers() publishable_ids = [layer.id for layer in layer_items if layer.visible] # collect stored image instances instance_names = [] for layer_item in layer_items: layer_meta_data = stub.read(layer_item, stored_items) # Skip layers without metadata. if layer_meta_data is None: continue # Skip containers. if "container" in layer_meta_data["id"]: continue # active might not be in legacy meta if layer_meta_data.get("active", True) and layer_item.visible: instance_names.append(layer_meta_data["subset"]) if len(instance_names) == 0: variants = proj_settings.get( "photoshop", {}).get( "create", {}).get( "CreateImage", {}).get( "default_variants", ['']) family = "image" variant = context.data.get("variant") or variants[0] subset_name = get_subset_name( family, variant, task_name, asset_doc, project_name, host_name ) instance = context.create_instance(subset_name) instance.data["family"] = family instance.data["asset"] = asset_name instance.data["subset"] = subset_name instance.data["ids"] = publishable_ids instance.data["publish"] = True instance.data["creator_identifier"] = "auto_image" if auto_creator["mark_for_review"]: instance.data["creator_attributes"] = {"mark_for_review": True} instance.data["families"] = ["review"] self.log.info("auto image instance: {} ".format(instance.data)) ================================================ FILE: openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py ================================================ import pyblish.api from openpype.hosts.photoshop import api as photoshop class CollectAutoImageRefresh(pyblish.api.ContextPlugin): """Refreshes auto_image instance with currently visible layers.. """ label = "Collect Auto Image Refresh" order = pyblish.api.CollectorOrder hosts = ["photoshop"] order = pyblish.api.CollectorOrder + 0.2 def process(self, context): for instance in context: creator_identifier = instance.data.get("creator_identifier") if creator_identifier and creator_identifier == "auto_image": self.log.debug("Auto image instance found, won't create new") # refresh existing auto image instance with current visible publishable_ids = [layer.id for layer in photoshop.stub().get_layers() # noqa if layer.visible] instance.data["ids"] = publishable_ids return ================================================ FILE: openpype/hosts/photoshop/plugins/publish/collect_auto_review.py ================================================ """ Requires: None Provides: instance -> family ("review") """ import pyblish.api from openpype.client import get_asset_name_identifier from openpype.hosts.photoshop import api as photoshop from openpype.pipeline.create import get_subset_name class CollectAutoReview(pyblish.api.ContextPlugin): """Create review instance in non artist based workflow. Called only if PS is triggered in Webpublisher or in tests. """ label = "Collect Auto Review" hosts = ["photoshop"] order = pyblish.api.CollectorOrder + 0.2 targets = ["automated"] publish = True def process(self, context): family = "review" has_review = False for instance in context: if instance.data["family"] == family: self.log.debug("Review instance found, won't create new") has_review = True creator_attributes = instance.data.get("creator_attributes", {}) if (creator_attributes.get("mark_for_review") and "review" not in instance.data["families"]): instance.data["families"].append("review") if has_review: return stub = photoshop.stub() stored_items = stub.get_layers_metadata() for item in stored_items: if item.get("creator_identifier") == family: if not item.get("active"): self.log.debug("Review instance disabled") return auto_creator = context.data["project_settings"].get( "photoshop", {}).get( "create", {}).get( "ReviewCreator", {}) if not auto_creator or not auto_creator["enabled"]: self.log.debug("Review creator disabled, won't create new") return variant = (context.data.get("variant") or auto_creator["default_variant"]) project_name = context.data["projectName"] proj_settings = context.data["project_settings"] task_name = context.data["task"] host_name = context.data["hostName"] asset_doc = context.data["assetEntity"] asset_name = get_asset_name_identifier(asset_doc) subset_name = get_subset_name( family, variant, task_name, asset_doc, project_name, host_name=host_name, project_settings=proj_settings ) instance = context.create_instance(subset_name) instance.data.update({ "subset": subset_name, "label": subset_name, "name": subset_name, "family": family, "families": [], "representations": [], "asset": asset_name, "publish": self.publish }) self.log.debug("auto review created::{}".format(instance.data)) ================================================ FILE: openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py ================================================ import os import pyblish.api from openpype.client import get_asset_name_identifier from openpype.hosts.photoshop import api as photoshop from openpype.pipeline.create import get_subset_name class CollectAutoWorkfile(pyblish.api.ContextPlugin): """Collect current script for publish.""" order = pyblish.api.CollectorOrder + 0.2 label = "Collect Workfile" hosts = ["photoshop"] targets = ["automated"] def process(self, context): family = "workfile" file_path = context.data["currentFile"] _, ext = os.path.splitext(file_path) staging_dir = os.path.dirname(file_path) base_name = os.path.basename(file_path) workfile_representation = { "name": ext[1:], "ext": ext[1:], "files": base_name, "stagingDir": staging_dir, } for instance in context: if instance.data["family"] == family: self.log.debug("Workfile instance found, won't create new") instance.data.update({ "label": base_name, "name": base_name, "representations": [], }) # creating representation _, ext = os.path.splitext(file_path) instance.data["representations"].append( workfile_representation) return stub = photoshop.stub() stored_items = stub.get_layers_metadata() for item in stored_items: if item.get("creator_identifier") == family: if not item.get("active"): self.log.debug("Workfile instance disabled") return project_name = context.data["projectName"] proj_settings = context.data["project_settings"] auto_creator = proj_settings.get( "photoshop", {}).get( "create", {}).get( "WorkfileCreator", {}) if not auto_creator or not auto_creator["enabled"]: self.log.debug("Workfile creator disabled, won't create new") return # context.data["variant"] might come only from collect_batch_data variant = (context.data.get("variant") or auto_creator["default_variant"]) task_name = context.data["task"] host_name = context.data["hostName"] asset_doc = context.data["assetEntity"] asset_name = get_asset_name_identifier(asset_doc) subset_name = get_subset_name( family, variant, task_name, asset_doc, project_name, host_name=host_name, project_settings=proj_settings ) # Create instance instance = context.create_instance(subset_name) instance.data.update({ "subset": subset_name, "label": base_name, "name": base_name, "family": family, "families": [], "representations": [], "asset": asset_name }) # creating representation instance.data["representations"].append(workfile_representation) self.log.debug("auto workfile review created:{}".format(instance.data)) ================================================ FILE: openpype/hosts/photoshop/plugins/publish/collect_batch_data.py ================================================ """Parses batch context from json and continues in publish process. Provides: context -> Loaded batch file. - asset - task (task name) - taskType - project_name - variant Code is practically copy of `openype/hosts/webpublish/collect_batch_data` as webpublisher should be eventually ejected as an addon, eg. mentioned plugin shouldn't be pushed into general publish plugins. """ import os import pyblish.api from openpype.pipeline import legacy_io from openpype_modules.webpublisher.lib import ( get_batch_asset_task_info, parse_json ) from openpype.tests.lib import is_in_tests class CollectBatchData(pyblish.api.ContextPlugin): """Collect batch data from json stored in 'OPENPYPE_PUBLISH_DATA' env dir. The directory must contain 'manifest.json' file where batch data should be stored. """ # must be really early, context values are only in json file order = pyblish.api.CollectorOrder - 0.495 label = "Collect batch data" hosts = ["photoshop"] targets = ["webpublish"] def process(self, context): self.log.info("CollectBatchData") batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") if is_in_tests(): self.log.debug("Automatic testing, no batch data, skipping") return assert batch_dir, ( "Missing `OPENPYPE_PUBLISH_DATA`") assert os.path.exists(batch_dir), \ "Folder {} doesn't exist".format(batch_dir) project_name = os.environ.get("AVALON_PROJECT") if project_name is None: raise AssertionError( "Environment `AVALON_PROJECT` was not found." "Could not set project `root` which may cause issues." ) batch_data = parse_json(os.path.join(batch_dir, "manifest.json")) context.data["batchDir"] = batch_dir context.data["batchData"] = batch_data asset_name, task_name, task_type = get_batch_asset_task_info( batch_data["context"] ) os.environ["AVALON_ASSET"] = asset_name os.environ["AVALON_TASK"] = task_name legacy_io.Session["AVALON_ASSET"] = asset_name legacy_io.Session["AVALON_TASK"] = task_name context.data["asset"] = asset_name context.data["task"] = task_name context.data["taskType"] = task_type context.data["project_name"] = project_name context.data["variant"] = batch_data["variant"] ================================================ FILE: openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py ================================================ import os import re import pyblish.api from openpype.lib import prepare_template_data from openpype.hosts.photoshop import api as photoshop from openpype.settings import get_project_settings from openpype.tests.lib import is_in_tests class CollectColorCodedInstances(pyblish.api.ContextPlugin): """Creates instances for layers marked by configurable color. Used in remote publishing when artists marks publishable layers by color- coding. Top level layers (group) must be marked by specific color to be published as an instance of 'image' family. Can add group for all publishable layers to allow creation of flattened image. (Cannot contain special background layer as it cannot be grouped!) Based on value `create_flatten_image` from Settings: - "yes": create flattened 'image' subset of all publishable layers + create 'image' subset per publishable layer - "only": create ONLY flattened 'image' subset of all publishable layers - "no": do not create flattened 'image' subset at all, only separate subsets per marked layer. Identifier: id (str): "pyblish.avalon.instance" """ order = pyblish.api.CollectorOrder + 0.100 label = "Instances" order = pyblish.api.CollectorOrder hosts = ["photoshop"] targets = ["automated"] # configurable by Settings color_code_mapping = [] # TODO check if could be set globally, probably doesn't make sense when # flattened template cannot subset_template_name = "" create_flatten_image = "no" flatten_subset_template = "" def process(self, context): self.log.info("CollectColorCodedInstances") batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") if (is_in_tests() and (not batch_dir or not os.path.exists(batch_dir))): self.log.debug("Automatic testing, no batch data, skipping") return existing_subset_names = self._get_existing_subset_names(context) # from CollectBatchData asset_name = context.data["asset"] task_name = context.data["task"] variant = context.data["variant"] project_name = context.data["projectEntity"]["name"] naming_conventions = get_project_settings(project_name).get( "photoshop", {}).get( "publish", {}).get( "ValidateNaming", {}) stub = photoshop.stub() layers = stub.get_layers() publishable_layers = [] created_instances = [] family_from_settings = None for layer in layers: self.log.debug("Layer:: {}".format(layer)) if layer.parents: self.log.debug("!!! Not a top layer, skip") continue if not layer.visible: self.log.debug("Not visible, skip") continue resolved_family, resolved_subset_template = self._resolve_mapping( layer ) if not resolved_subset_template or not resolved_family: self.log.debug("!!! Not found family or template, skip") continue if not family_from_settings: family_from_settings = resolved_family fill_pairs = { "variant": variant, "family": resolved_family, "task": task_name, "layer": layer.clean_name } subset = resolved_subset_template.format( **prepare_template_data(fill_pairs)) subset = self._clean_subset_name(stub, naming_conventions, subset, layer) if subset in existing_subset_names: self.log.info( "Subset {} already created, skipping.".format(subset)) continue if self.create_flatten_image != "flatten_only": instance = self._create_instance(context, layer, resolved_family, asset_name, subset, task_name) created_instances.append(instance) existing_subset_names.append(subset) publishable_layers.append(layer) if self.create_flatten_image != "no" and publishable_layers: self.log.debug("create_flatten_image") if not self.flatten_subset_template: self.log.warning("No template for flatten image") return fill_pairs.pop("layer") subset = self.flatten_subset_template.format( **prepare_template_data(fill_pairs)) first_layer = publishable_layers[0] # dummy layer first_layer.name = subset family = family_from_settings # inherit family instance = self._create_instance(context, first_layer, family, asset_name, subset, task_name) instance.data["ids"] = [layer.id for layer in publishable_layers] created_instances.append(instance) for instance in created_instances: # Produce diagnostic message for any graphical # user interface interested in visualising it. self.log.info("Found: \"%s\" " % instance.data["name"]) self.log.info("instance: {} ".format(instance.data)) def _get_existing_subset_names(self, context): """Collect manually created instances from workfile. Shouldn't be any as Webpublisher bypass publishing via Openpype, but might be some if workfile published through OP is reused. """ existing_subset_names = [] for instance in context: if instance.data.get('publish'): existing_subset_names.append(instance.data.get('subset')) return existing_subset_names def _create_instance(self, context, layer, family, asset, subset, task_name): instance = context.create_instance(layer.name) instance.data["family"] = family instance.data["publish"] = True instance.data["asset"] = asset instance.data["task"] = task_name instance.data["subset"] = subset instance.data["layer"] = layer instance.data["families"] = [] return instance def _resolve_mapping(self, layer): """Matches 'layer' color code and name to mapping. If both color code AND name regex is configured, BOTH must be valid If layer matches to multiple mappings, only first is used! """ family_list = [] family = None subset_name_list = [] resolved_subset_template = None for mapping in self.color_code_mapping: if mapping["color_code"] and \ layer.color_code not in mapping["color_code"]: continue if mapping["layer_name_regex"] and \ not any(re.search(pattern, layer.name) for pattern in mapping["layer_name_regex"]): continue family_list.append(mapping["family"]) subset_name_list.append(mapping["subset_template_name"]) if len(subset_name_list) > 1: self.log.warning("Multiple mappings found for '{}'". format(layer.name)) self.log.warning("Only first subset name template used!") subset_name_list[:] = subset_name_list[0] if len(family_list) > 1: self.log.warning("Multiple mappings found for '{}'". format(layer.name)) self.log.warning("Only first family used!") family_list[:] = family_list[0] if subset_name_list: resolved_subset_template = subset_name_list.pop() if family_list: family = family_list.pop() self.log.debug("resolved_family {}".format(family)) self.log.debug("resolved_subset_template {}".format( resolved_subset_template)) return family, resolved_subset_template def _clean_subset_name(self, stub, naming_conventions, subset, layer): """Cleans invalid characters from subset name and layer name.""" if re.search(naming_conventions["invalid_chars"], subset): subset = re.sub( naming_conventions["invalid_chars"], naming_conventions["replace_char"], subset ) layer_name = re.sub( naming_conventions["invalid_chars"], naming_conventions["replace_char"], layer.clean_name ) layer.name = layer_name stub.rename_layer(layer.id, layer_name) return subset ================================================ FILE: openpype/hosts/photoshop/plugins/publish/collect_current_file.py ================================================ import os import pyblish.api from openpype.hosts.photoshop import api as photoshop class CollectCurrentFile(pyblish.api.ContextPlugin): """Inject the current working file into context""" order = pyblish.api.CollectorOrder - 0.49 label = "Current File" hosts = ["photoshop"] def process(self, context): context.data["currentFile"] = os.path.normpath( photoshop.stub().get_active_document_full_name() ).replace("\\", "/") ================================================ FILE: openpype/hosts/photoshop/plugins/publish/collect_extension_version.py ================================================ import os import re import pyblish.api from openpype.hosts.photoshop import api as photoshop class CollectExtensionVersion(pyblish.api.ContextPlugin): """ Pulls and compares version of installed extension. It is recommended to use same extension as in provided Openpype code. Please use Anastasiy’s Extension Manager or ZXPInstaller to update extension in case of an error. You can locate extension.zxp in your installed Openpype code in `repos/avalon-core/avalon/photoshop` """ # This technically should be a validator, but other collectors might be # impacted with usage of obsolete extension, so collector that runs first # was chosen order = pyblish.api.CollectorOrder - 0.5 label = "Collect extension version" hosts = ["photoshop"] optional = True active = True def process(self, context): installed_version = photoshop.stub().get_extension_version() if not installed_version: raise ValueError("Unknown version, probably old extension") manifest_url = os.path.join(os.path.dirname(photoshop.__file__), "extension", "CSXS", "manifest.xml") if not os.path.exists(manifest_url): self.log.debug("Unable to locate extension manifest, not checking") return expected_version = None with open(manifest_url) as fp: content = fp.read() found = re.findall(r'(ExtensionBundleVersion=")([0-9\.]+)(")', content) if found: expected_version = found[0][1] if expected_version != installed_version: msg = "Expected version '{}' found '{}'\n".format( expected_version, installed_version) msg += "Please update your installed extension, it might not work " msg += "properly." raise ValueError(msg) ================================================ FILE: openpype/hosts/photoshop/plugins/publish/collect_image.py ================================================ import pyblish.api from openpype.hosts.photoshop import api class CollectImage(pyblish.api.InstancePlugin): """Collect layer metadata into a instance. Used later in validation """ order = pyblish.api.CollectorOrder + 0.200 label = 'Collect Image' hosts = ["photoshop"] families = ["image"] def process(self, instance): if instance.data.get("members"): layer = api.stub().get_layer(instance.data["members"][0]) instance.data["layer"] = layer ================================================ FILE: openpype/hosts/photoshop/plugins/publish/collect_published_version.py ================================================ """Collects published version of workfile and increments it. For synchronization of published image and workfile version it is required to store workfile version from workfile file name in context.data["version"]. In remote publishing this name is unreliable (artist might not follow naming convention etc.), last published workfile version for particular workfile subset is used instead. This plugin runs only in remote publishing (eg. Webpublisher). Requires: context.data["assetEntity"] Provides: context["version"] - incremented latest published workfile version """ import pyblish.api from openpype.client import get_last_version_by_subset_name from openpype.pipeline.version_start import get_versioning_start class CollectPublishedVersion(pyblish.api.ContextPlugin): """Collects published version of workfile and increments it.""" order = pyblish.api.CollectorOrder + 0.190 label = "Collect published version" hosts = ["photoshop"] targets = ["automated"] def process(self, context): workfile_subset_name = None for instance in context: if instance.data["family"] == "workfile": workfile_subset_name = instance.data["subset"] break if not workfile_subset_name: self.log.warning("No workfile instance found, " "synchronization of version will not work.") return project_name = context.data["projectName"] asset_doc = context.data["assetEntity"] asset_id = asset_doc["_id"] version_doc = get_last_version_by_subset_name(project_name, workfile_subset_name, asset_id) if version_doc: version_int = int(version_doc["name"]) + 1 else: version_int = get_versioning_start( project_name, "photoshop", task_name=context.data["task"], task_type=context.data["taskType"], project_settings=context.data["project_settings"] ) self.log.debug(f"Setting {version_int} to context.") context.data["version"] = version_int ================================================ FILE: openpype/hosts/photoshop/plugins/publish/collect_review.py ================================================ """ Requires: None Provides: instance -> family ("review") """ import os import pyblish.api from openpype.pipeline.create import get_subset_name class CollectReview(pyblish.api.ContextPlugin): """Adds review to families for instances marked to be reviewable. """ label = "Collect Review" label = "Review" hosts = ["photoshop"] order = pyblish.api.CollectorOrder + 0.1 publish = True def process(self, context): for instance in context: creator_attributes = instance.data["creator_attributes"] if (creator_attributes.get("mark_for_review") and "review" not in instance.data["families"]): instance.data["families"].append("review") ================================================ FILE: openpype/hosts/photoshop/plugins/publish/collect_version.py ================================================ import pyblish.api class CollectVersion(pyblish.api.InstancePlugin): """Collect version for publishable instances. Used to synchronize version from workfile to all publishable instances: - image (manually created or color coded) - review - workfile Dev comment: Explicit collector created to control this from single place and not from 3 different. Workfile set here explicitly as version might to be forced from latest + 1 because of Webpublisher. (This plugin must run after CollectPublishedVersion!) """ order = pyblish.api.CollectorOrder + 0.200 label = 'Collect Version' hosts = ["photoshop"] families = ["image", "review", "workfile"] def process(self, instance): workfile_version = instance.context.data["version"] self.log.debug(f"Applying version {workfile_version}") instance.data["version"] = workfile_version ================================================ FILE: openpype/hosts/photoshop/plugins/publish/collect_workfile.py ================================================ import os import pyblish.api from openpype.pipeline.create import get_subset_name class CollectWorkfile(pyblish.api.ContextPlugin): """Collect current script for publish.""" order = pyblish.api.CollectorOrder + 0.1 label = "Collect Workfile" hosts = ["photoshop"] default_variant = "Main" def process(self, context): for instance in context: if instance.data["family"] == "workfile": file_path = context.data["currentFile"] _, ext = os.path.splitext(file_path) staging_dir = os.path.dirname(file_path) base_name = os.path.basename(file_path) # creating representation _, ext = os.path.splitext(file_path) instance.data["representations"].append({ "name": ext[1:], "ext": ext[1:], "files": base_name, "stagingDir": staging_dir, }) return ================================================ FILE: openpype/hosts/photoshop/plugins/publish/extract_image.py ================================================ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.photoshop import api as photoshop class ExtractImage(pyblish.api.ContextPlugin): """Extract all layers (groups) marked for publish. Usually publishable instance is created as a wrapper of layer(s). For each publishable instance so many images as there is 'formats' is created. Logic tries to hide/unhide layers minimum times. Called once for all publishable instances. """ order = publish.Extractor.order - 0.48 label = "Extract Image" hosts = ["photoshop"] families = ["image", "background"] formats = ["png", "jpg"] def process(self, context): stub = photoshop.stub() hidden_layer_ids = set() all_layers = stub.get_layers() for layer in all_layers: if not layer.visible: hidden_layer_ids.add(layer.id) stub.hide_all_others_layers_ids([], layers=all_layers) with photoshop.maintained_selection(): with photoshop.maintained_visibility(layers=all_layers): for instance in context: if instance.data["family"] not in self.families: continue staging_dir = self.staging_dir(instance) self.log.info("Outputting image to {}".format(staging_dir)) # Perform extraction files = {} ids = set() # real layers and groups members = instance.data("members") if members: ids.update(set([int(member) for member in members])) # virtual groups collected by color coding or auto_image add_ids = instance.data.pop("ids", None) if add_ids: ids.update(set(add_ids)) extract_ids = set([ll.id for ll in stub. get_layers_in_layers_ids(ids, all_layers) if ll.id not in hidden_layer_ids]) for extracted_id in extract_ids: stub.set_visible(extracted_id, True) file_basename = os.path.splitext( stub.get_active_document_name() )[0] for extension in self.formats: _filename = "{}.{}".format(file_basename, extension) files[extension] = _filename full_filename = os.path.join(staging_dir, _filename) stub.saveAs(full_filename, extension, True) self.log.info(f"Extracted: {extension}") representations = [] for extension, filename in files.items(): representations.append({ "name": extension, "ext": extension, "files": filename, "stagingDir": staging_dir }) instance.data["representations"] = representations instance.data["stagingDir"] = staging_dir self.log.info(f"Extracted {instance} to {staging_dir}") for extracted_id in extract_ids: stub.set_visible(extracted_id, False) def staging_dir(self, instance): """Provide a temporary directory in which to store extracted files Upon calling this method the staging directory is stored inside the instance.data['stagingDir'] """ from openpype.pipeline.publish import get_instance_staging_dir return get_instance_staging_dir(instance) ================================================ FILE: openpype/hosts/photoshop/plugins/publish/extract_review.py ================================================ import os import shutil from PIL import Image from openpype.lib import ( run_subprocess, get_ffmpeg_tool_args, ) from openpype.pipeline import publish from openpype.hosts.photoshop import api as photoshop class ExtractReview(publish.Extractor): """ Produce a flattened or sequence image files from all 'image' instances. If no 'image' instance is created, it produces flattened image from all visible layers. It creates review, thumbnail and mov representations. 'review' family could be used in other steps as a reference, as it contains flattened image by default. (Eg. artist could load this review as a single item and see full image. In most cases 'image' family is separated by layers to better usage in animation or comp.) """ label = "Extract Review" hosts = ["photoshop"] families = ["review"] # Extract Options jpg_options = None mov_options = None make_image_sequence = None max_downscale_size = 8192 def process(self, instance): staging_dir = self.staging_dir(instance) self.log.info("Outputting image to {}".format(staging_dir)) fps = instance.data.get("fps", 25) stub = photoshop.stub() self.output_seq_filename = os.path.splitext( stub.get_active_document_name())[0] + ".%04d.jpg" layers = self._get_layers_from_image_instances(instance) self.log.info("Layers image instance found: {}".format(layers)) repre_name = "jpg" repre_skeleton = { "name": repre_name, "ext": "jpg", "stagingDir": staging_dir, "tags": self.jpg_options['tags'], } if instance.data["family"] != "review": self.log.debug("Existing extracted file from image family used.") # enable creation of review, without this jpg review would clash # with jpg of the image family output_name = repre_name repre_name = "{}_{}".format(repre_name, output_name) repre_skeleton.update({"name": repre_name, "outputName": output_name}) img_file = self.output_seq_filename % 0 self._prepare_file_for_image_family(img_file, instance, staging_dir) repre_skeleton.update({ "files": img_file, }) processed_img_names = [img_file] elif self.make_image_sequence and len(layers) > 1: self.log.debug("Extract layers to image sequence.") img_list = self._save_sequence_images(staging_dir, layers) repre_skeleton.update({ "frameStart": 0, "frameEnd": len(img_list), "fps": fps, "files": img_list, }) processed_img_names = img_list else: self.log.debug("Extract layers to flatten image.") img_file = self._save_flatten_image(staging_dir, layers) repre_skeleton.update({ "files": img_file, }) processed_img_names = [img_file] instance.data["representations"].append(repre_skeleton) ffmpeg_args = get_ffmpeg_tool_args("ffmpeg") instance.data["stagingDir"] = staging_dir source_files_pattern = os.path.join(staging_dir, self.output_seq_filename) source_files_pattern = self._check_and_resize(processed_img_names, source_files_pattern, staging_dir) self._generate_thumbnail( list(ffmpeg_args), instance, source_files_pattern, staging_dir) no_of_frames = len(processed_img_names) if no_of_frames > 1: self._generate_mov( list(ffmpeg_args), instance, fps, no_of_frames, source_files_pattern, staging_dir) self.log.info(f"Extracted {instance} to {staging_dir}") def _prepare_file_for_image_family(self, img_file, instance, staging_dir): """Converts existing file for image family to .jpg Image instance could have its own separate review (instance per layer for example). This uses extracted file instead of extracting again. Args: img_file (str): name of output file (with 0000 value for ffmpeg later) instance: staging_dir (str): temporary folder where extracted file is located """ repre_file = instance.data["representations"][0] source_file_path = os.path.join(repre_file["stagingDir"], repre_file["files"]) if not os.path.exists(source_file_path): raise RuntimeError(f"{source_file_path} doesn't exist for " "review to create from") _, ext = os.path.splitext(repre_file["files"]) if ext != ".jpg": im = Image.open(source_file_path) if (im.mode in ('RGBA', 'LA') or ( im.mode == 'P' and 'transparency' in im.info)): # without this it produces messy low quality jpg rgb_im = Image.new("RGBA", (im.width, im.height), "#ffffff") rgb_im.alpha_composite(im) rgb_im.convert("RGB").save(os.path.join(staging_dir, img_file)) else: im.save(os.path.join(staging_dir, img_file)) else: # handles already .jpg shutil.copy(source_file_path, os.path.join(staging_dir, img_file)) def _generate_mov(self, ffmpeg_path, instance, fps, no_of_frames, source_files_pattern, staging_dir): """Generates .mov to upload to Ftrack. Args: ffmpeg_path (str): path to ffmpeg instance (Pyblish Instance) fps (str) no_of_frames (int): source_files_pattern (str): name of source file staging_dir (str): temporary location to store thumbnail Updates: instance - adds representation portion """ # Generate mov. mov_path = os.path.join(staging_dir, "review.mov") self.log.info(f"Generate mov review: {mov_path}") args = ffmpeg_path + [ "-y", "-i", source_files_pattern, "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-vframes", str(no_of_frames), mov_path ] self.log.debug("mov args:: {}".format(args)) _output = run_subprocess(args) instance.data["representations"].append({ "name": "mov", "ext": "mov", "files": os.path.basename(mov_path), "stagingDir": staging_dir, "frameStart": 1, "frameEnd": no_of_frames, "fps": fps, "tags": self.mov_options['tags'] }) def _generate_thumbnail( self, ffmpeg_args, instance, source_files_pattern, staging_dir ): """Generates scaled down thumbnail and adds it as representation. Args: ffmpeg_path (str): path to ffmpeg instance (Pyblish Instance) source_files_pattern (str): name of source file staging_dir (str): temporary location to store thumbnail Updates: instance - adds representation portion """ # Generate thumbnail thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg") self.log.info(f"Generate thumbnail {thumbnail_path}") args = ffmpeg_args + [ "-y", "-i", source_files_pattern, "-vf", "scale=300:-1", "-vframes", "1", thumbnail_path ] self.log.debug("thumbnail args:: {}".format(args)) _output = run_subprocess(args) instance.data["representations"].append({ "name": "thumbnail", "ext": "jpg", "outputName": "thumb", "files": os.path.basename(thumbnail_path), "stagingDir": staging_dir, "tags": ["thumbnail", "delete"] }) instance.data["thumbnailPath"] = thumbnail_path def _check_and_resize(self, processed_img_names, source_files_pattern, staging_dir): """Check if saved image could be used in ffmpeg. Ffmpeg has max size 16384x16384. Saved image(s) must be resized to be used as a source for thumbnail or review mov. """ Image.MAX_IMAGE_PIXELS = None first_url = os.path.join(staging_dir, processed_img_names[0]) with Image.open(first_url) as im: width, height = im.size if width > self.max_downscale_size or height > self.max_downscale_size: resized_dir = os.path.join(staging_dir, "resized") os.mkdir(resized_dir) source_files_pattern = os.path.join(resized_dir, self.output_seq_filename) for file_name in processed_img_names: source_url = os.path.join(staging_dir, file_name) with Image.open(source_url) as res_img: # 'thumbnail' automatically keeps aspect ratio res_img.thumbnail((self.max_downscale_size, self.max_downscale_size), Image.ANTIALIAS) res_img.save(os.path.join(resized_dir, file_name)) return source_files_pattern def _get_layers_from_image_instances(self, instance): """Collect all layers from 'instance'. Returns: (list) of PSItem """ layers = [] # creating review for existing 'image' instance if instance.data["family"] == "image" and instance.data.get("layer"): layers.append(instance.data["layer"]) return layers for image_instance in instance.context: if image_instance.data["family"] != "image": continue if not image_instance.data.get("layer"): # dummy instance for flatten image continue layers.append(image_instance.data.get("layer")) return sorted(layers) def _save_flatten_image(self, staging_dir, layers): """Creates flat image from 'layers' into 'staging_dir'. Returns: (str): path to new image """ img_filename = self.output_seq_filename % 0 output_image_path = os.path.join(staging_dir, img_filename) stub = photoshop.stub() with photoshop.maintained_visibility(): self.log.info("Extracting {}".format(layers)) if layers: stub.hide_all_others_layers(layers) stub.saveAs(output_image_path, 'jpg', True) return img_filename def _save_sequence_images(self, staging_dir, layers): """Creates separate flat images from 'layers' into 'staging_dir'. Used as source for multi frames .mov to review at once. Returns: (list): paths to new images """ stub = photoshop.stub() list_img_filename = [] with photoshop.maintained_visibility(): for i, layer in enumerate(layers): self.log.info("Extracting {}".format(layer)) img_filename = self.output_seq_filename % i output_image_path = os.path.join(staging_dir, img_filename) list_img_filename.append(img_filename) with photoshop.maintained_visibility(): stub.hide_all_others_layers([layer]) stub.saveAs(output_image_path, 'jpg', True) return list_img_filename ================================================ FILE: openpype/hosts/photoshop/plugins/publish/extract_save_scene.py ================================================ from openpype.pipeline import publish from openpype.hosts.photoshop import api as photoshop class ExtractSaveScene(publish.Extractor): """Save scene before extraction.""" order = publish.Extractor.order - 0.49 label = "Extract Save Scene" hosts = ["photoshop"] families = ["workfile"] def process(self, instance): photoshop.stub().save() ================================================ FILE: openpype/hosts/photoshop/plugins/publish/help/validate_instance_asset.xml ================================================ Asset does not match ## Collected asset name is not same as in context {msg} ### How to repair? {repair_msg} Refresh Publish afterwards (circle arrow at the bottom right). If that's not correct value, close workfile and reopen via Workfiles to get proper context asset name OR disable this validator and publish again if you are publishing to different context deliberately. (Context means combination of project, asset name and task name.) ================================================ FILE: openpype/hosts/photoshop/plugins/publish/help/validate_naming.xml ================================================ Subset name ## Invalid subset or layer name Subset or layer name cannot contain specific characters (spaces etc) which could cause issue when subset name is used in a published file name. {msg} ### How to repair? You can fix this with "repair" button on the right and press Refresh publishing button at the bottom right. ### __Detailed Info__ (optional) Not all characters are available in a file names on all OS. Wrong characters could be configured in Settings. ================================================ FILE: openpype/hosts/photoshop/plugins/publish/increment_workfile.py ================================================ import os import pyblish.api from openpype.pipeline.publish import get_errored_plugins_from_context from openpype.lib import version_up from openpype.hosts.photoshop import api as photoshop class IncrementWorkfile(pyblish.api.InstancePlugin): """Increment the current workfile. Saves the current scene with an increased version number. """ label = "Increment Workfile" order = pyblish.api.IntegratorOrder + 9.0 hosts = ["photoshop"] families = ["workfile"] optional = True def process(self, instance): errored_plugins = get_errored_plugins_from_context(instance.context) if errored_plugins: raise RuntimeError( "Skipping incrementing current file because publishing failed." ) scene_path = version_up(instance.context.data["currentFile"]) _, ext = os.path.splitext(scene_path) photoshop.stub().saveAs(scene_path, ext[1:], True) self.log.info("Incremented workfile to: {}".format(scene_path)) ================================================ FILE: openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py ================================================ import pyblish.api from openpype.pipeline import get_current_asset_name from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, OptionalPyblishPluginMixin ) from openpype.hosts.photoshop import api as photoshop class ValidateInstanceAssetRepair(pyblish.api.Action): """Repair the instance asset.""" label = "Repair" icon = "wrench" on = "failed" def process(self, context, plugin): # Get the errored instances failed = [] for result in context.data["results"]: if (result["error"] is not None and result["instance"] is not None and result["instance"] not in failed): failed.append(result["instance"]) # Apply pyblish.logic to get the instances for the plug-in instances = pyblish.api.instances_by_plugin(failed, plugin) stub = photoshop.stub() current_asset_name = get_current_asset_name() for instance in instances: data = stub.read(instance[0]) data["asset"] = current_asset_name stub.imprint(instance[0], data) class ValidateInstanceAsset(OptionalPyblishPluginMixin, pyblish.api.InstancePlugin): """Validate the instance asset is the current selected context asset. As it might happen that multiple worfiles are opened, switching between them would mess with selected context. In that case outputs might be output under wrong asset! Repair action will use Context asset value (from Workfiles or Launcher) Closing and reopening with Workfiles will refresh Context value. """ label = "Validate Instance Asset" hosts = ["photoshop"] optional = True actions = [ValidateInstanceAssetRepair] order = ValidateContentsOrder def process(self, instance): instance_asset = instance.data["asset"] current_asset = get_current_asset_name() if instance_asset != current_asset: msg = ( f"Instance asset {instance_asset} is not the same " f"as current context {current_asset}." ) repair_msg = ( f"Repair with 'Repair' button to use '{current_asset}'.\n" ) formatting_data = {"msg": msg, "repair_msg": repair_msg} raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) ================================================ FILE: openpype/hosts/photoshop/plugins/publish/validate_naming.py ================================================ import re import pyblish.api from openpype.hosts.photoshop import api as photoshop from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, ) class ValidateNamingRepair(pyblish.api.Action): """Repair the instance asset.""" label = "Repair" icon = "wrench" on = "failed" def process(self, context, plugin): # Get the errored instances failed = [] for result in context.data["results"]: if (result["error"] is not None and result["instance"] is not None and result["instance"] not in failed): failed.append(result["instance"]) invalid_chars, replace_char = plugin.get_replace_chars() self.log.debug("{} --- {}".format(invalid_chars, replace_char)) # Apply pyblish.logic to get the instances for the plug-in instances = pyblish.api.instances_by_plugin(failed, plugin) stub = photoshop.stub() for instance in instances: self.log.debug("validate_naming instance {}".format(instance)) current_layer_state = stub.get_layer(instance.data["layer"].id) self.log.debug("current_layer{}".format(current_layer_state)) layer_meta = stub.read(current_layer_state) instance_id = (layer_meta.get("instance_id") or layer_meta.get("uuid")) if not instance_id: self.log.warning("Unable to repair, cannot find layer") continue layer_name = re.sub(invalid_chars, replace_char, current_layer_state.clean_name) layer_name = stub.PUBLISH_ICON + layer_name stub.rename_layer(current_layer_state.id, layer_name) subset_name = re.sub(invalid_chars, replace_char, instance.data["subset"]) # format from Tool Creator subset_name = re.sub( "[^{}]+".format(SUBSET_NAME_ALLOWED_SYMBOLS), "", subset_name ) layer_meta["subset"] = subset_name stub.imprint(instance_id, layer_meta) return True class ValidateNaming(pyblish.api.InstancePlugin): """Validate the instance name. Spaces in names are not allowed. Will be replace with underscores. """ label = "Validate Naming" hosts = ["photoshop"] order = ValidateContentsOrder families = ["image"] actions = [ValidateNamingRepair] # configured by Settings invalid_chars = '' replace_char = '' def process(self, instance): help_msg = ' Use Repair button to fix it and then refresh publish.' layer = instance.data.get("layer") if layer: msg = "Name \"{}\" is not allowed.{}".format(layer.clean_name, help_msg) formatting_data = {"msg": msg} if re.search(self.invalid_chars, layer.clean_name): raise PublishXmlValidationError(self, msg, formatting_data=formatting_data ) msg = "Subset \"{}\" is not allowed.{}".format(instance.data["subset"], help_msg) formatting_data = {"msg": msg} if re.search(self.invalid_chars, instance.data["subset"]): raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) @classmethod def get_replace_chars(cls): """Pass values configured in Settings for Repair.""" return cls.invalid_chars, cls.replace_char ================================================ FILE: openpype/hosts/resolve/README.markdown ================================================ ## Basic setup - Actually supported version is up to v18 - install Python 3.6.2 (latest tested v17) or up to 3.9.13 (latest tested on v18) - pip install PySide2: - Python 3.9.*: open terminal and go to python.exe directory, then `python -m pip install PySide2` - pip install OpenTimelineIO: - Python 3.9.*: open terminal and go to python.exe directory, then `python -m pip install OpenTimelineIO` - Python 3.6: open terminal and go to python.exe directory, then `python -m pip install git+https://github.com/PixarAnimationStudios/OpenTimelineIO.git@5aa24fbe89d615448876948fe4b4900455c9a3e8` and move built files from `./Lib/site-packages/opentimelineio/cxx-libs/bin and lib` to `./Lib/site-packages/opentimelineio/`. I was building it on Win10 machine with Visual Studio Community 2019 and ![image](https://user-images.githubusercontent.com/40640033/102792588-ffcb1c80-43a8-11eb-9c6b-bf2114ed578e.png) with installed CMake in PATH. - make sure Resolve Fusion (Fusion Tab/menu/Fusion/Fusion Settings) is set to Python 3.6 ![image](https://user-images.githubusercontent.com/40640033/102631545-280b0f00-414e-11eb-89fc-98ac268d209d.png) - Open OpenPype **Tray/Admin/Studio settings** > `applications/resolve/environment` and add Python3 path to `RESOLVE_PYTHON3_HOME` platform related. ## Editorial setup This is how it looks on my testing project timeline ![image](https://user-images.githubusercontent.com/40640033/102637638-96ec6600-4156-11eb-9656-6e8e3ce4baf8.png) Notice I had renamed tracks to `main` (holding metadata markers) and `review` used for generating review data with ffmpeg confersion to jpg sequence. 1. you need to start OpenPype menu from Resolve/EditTab/Menu/Workspace/Scripts/Comp/**__OpenPype_Menu__** 2. then select any clips in `main` track and change their color to `Chocolate` 3. in OpenPype Menu select `Create` 4. in Creator select `Create Publishable Clip [New]` (temporary name) 5. set `Rename clips` to True, Master Track to `main` and Use review track to `review` as in picture ![image](https://user-images.githubusercontent.com/40640033/102643773-0d419600-4160-11eb-919e-9c2be0aecab8.png) 6. after you hit `ok` all clips are colored to `ping` and marked with openpype metadata tag 7. git `Publish` on openpype menu and see that all had been collected correctly. That is the last step for now as rest is Work in progress. Next steps will follow. ================================================ FILE: openpype/hosts/resolve/RESOLVE_API_v18.5.1-build6.txt ================================================ Updated as of 26 May 2023 ---------------------------- In this package, you will find a brief introduction to the Scripting API for DaVinci Resolve Studio. Apart from this README.txt file, this package contains folders containing the basic import modules for scripting access (DaVinciResolve.py) and some representative examples. From v16.2.0 onwards, the nodeIndex parameters accepted by SetLUT() and SetCDL() are 1-based instead of 0-based, i.e. 1 <= nodeIndex <= total number of nodes. Overview -------- As with Blackmagic Design Fusion scripts, user scripts written in Lua and Python programming languages are supported. By default, scripts can be invoked from the Console window in the Fusion page, or via command line. This permission can be changed in Resolve Preferences, to be only from Console, or to be invoked from the local network. Please be aware of the security implications when allowing scripting access from outside of the Resolve application. Prerequisites ------------- DaVinci Resolve scripting requires one of the following to be installed (for all users): Lua 5.1 Python 2.7 64-bit Python >= 3.6 64-bit Using a script -------------- DaVinci Resolve needs to be running for a script to be invoked. For a Resolve script to be executed from an external folder, the script needs to know of the API location. You may need to set the these environment variables to allow for your Python installation to pick up the appropriate dependencies as shown below: Mac OS X: RESOLVE_SCRIPT_API="/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting" RESOLVE_SCRIPT_LIB="/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so" PYTHONPATH="$PYTHONPATH:$RESOLVE_SCRIPT_API/Modules/" Windows: RESOLVE_SCRIPT_API="%PROGRAMDATA%\Blackmagic Design\DaVinci Resolve\Support\Developer\Scripting" RESOLVE_SCRIPT_LIB="C:\Program Files\Blackmagic Design\DaVinci Resolve\fusionscript.dll" PYTHONPATH="%PYTHONPATH%;%RESOLVE_SCRIPT_API%\Modules\" Linux: RESOLVE_SCRIPT_API="/opt/resolve/Developer/Scripting" RESOLVE_SCRIPT_LIB="/opt/resolve/libs/Fusion/fusionscript.so" PYTHONPATH="$PYTHONPATH:$RESOLVE_SCRIPT_API/Modules/" (Note: For standard ISO Linux installations, the path above may need to be modified to refer to /home/resolve instead of /opt/resolve) As with Fusion scripts, Resolve scripts can also be invoked via the menu and the Console. On startup, DaVinci Resolve scans the subfolders in the directories shown below and enumerates the scripts found in the Workspace application menu under Scripts. Place your script under Utility to be listed in all pages, under Comp or Tool to be available in the Fusion page or under folders for individual pages (Edit, Color or Deliver). Scripts under Deliver are additionally listed under render jobs. Placing your script here and invoking it from the menu is the easiest way to use scripts. Mac OS X: - All users: /Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts - Specific user: /Users//Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts Windows: - All users: %PROGRAMDATA%\Blackmagic Design\DaVinci Resolve\Fusion\Scripts - Specific user: %APPDATA%\Roaming\Blackmagic Design\DaVinci Resolve\Support\Fusion\Scripts Linux: - All users: /opt/resolve/Fusion/Scripts (or /home/resolve/Fusion/Scripts/ depending on installation) - Specific user: $HOME/.local/share/DaVinciResolve/Fusion/Scripts The interactive Console window allows for an easy way to execute simple scripting commands, to query or modify properties, and to test scripts. The console accepts commands in Python 2.7, Python 3.6 and Lua and evaluates and executes them immediately. For more information on how to use the Console, please refer to the DaVinci Resolve User Manual. This example Python script creates a simple project: #!/usr/bin/env python import DaVinciResolveScript as dvr_script resolve = dvr_script.scriptapp("Resolve") fusion = resolve.Fusion() projectManager = resolve.GetProjectManager() projectManager.CreateProject("Hello World") The resolve object is the fundamental starting point for scripting via Resolve. As a native object, it can be inspected for further scriptable properties - using table iteration and "getmetatable" in Lua and dir, help etc in Python (among other methods). A notable scriptable object above is fusion - it allows access to all existing Fusion scripting functionality. Running DaVinci Resolve in headless mode ---------------------------------------- DaVinci Resolve can be launched in a headless mode without the user interface using the -nogui command line option. When DaVinci Resolve is launched using this option, the user interface is disabled. However, the various scripting APIs will continue to work as expected. Basic Resolve API ----------------- Some commonly used API functions are described below (*). As with the resolve object, each object is inspectable for properties and functions. Resolve Fusion() --> Fusion # Returns the Fusion object. Starting point for Fusion scripts. GetMediaStorage() --> MediaStorage # Returns the media storage object to query and act on media locations. GetProjectManager() --> ProjectManager # Returns the project manager object for currently open database. OpenPage(pageName) --> Bool # Switches to indicated page in DaVinci Resolve. Input can be one of ("media", "cut", "edit", "fusion", "color", "fairlight", "deliver"). GetCurrentPage() --> String # Returns the page currently displayed in the main window. Returned value can be one of ("media", "cut", "edit", "fusion", "color", "fairlight", "deliver", None). GetProductName() --> string # Returns product name. GetVersion() --> [version fields] # Returns list of product version fields in [major, minor, patch, build, suffix] format. GetVersionString() --> string # Returns product version in "major.minor.patch[suffix].build" format. LoadLayoutPreset(presetName) --> Bool # Loads UI layout from saved preset named 'presetName'. UpdateLayoutPreset(presetName) --> Bool # Overwrites preset named 'presetName' with current UI layout. ExportLayoutPreset(presetName, presetFilePath) --> Bool # Exports preset named 'presetName' to path 'presetFilePath'. DeleteLayoutPreset(presetName) --> Bool # Deletes preset named 'presetName'. SaveLayoutPreset(presetName) --> Bool # Saves current UI layout as a preset named 'presetName'. ImportLayoutPreset(presetFilePath, presetName) --> Bool # Imports preset from path 'presetFilePath'. The optional argument 'presetName' specifies how the preset shall be named. If not specified, the preset is named based on the filename. Quit() --> None # Quits the Resolve App. ProjectManager ArchiveProject(projectName, filePath, isArchiveSrcMedia=True, isArchiveRenderCache=True, isArchiveProxyMedia=False) --> Bool # Archives project to provided file path with the configuration as provided by the optional arguments CreateProject(projectName) --> Project # Creates and returns a project if projectName (string) is unique, and None if it is not. DeleteProject(projectName) --> Bool # Delete project in the current folder if not currently loaded LoadProject(projectName) --> Project # Loads and returns the project with name = projectName (string) if there is a match found, and None if there is no matching Project. GetCurrentProject() --> Project # Returns the currently loaded Resolve project. SaveProject() --> Bool # Saves the currently loaded project with its own name. Returns True if successful. CloseProject(project) --> Bool # Closes the specified project without saving. CreateFolder(folderName) --> Bool # Creates a folder if folderName (string) is unique. DeleteFolder(folderName) --> Bool # Deletes the specified folder if it exists. Returns True in case of success. GetProjectListInCurrentFolder() --> [project names...] # Returns a list of project names in current folder. GetFolderListInCurrentFolder() --> [folder names...] # Returns a list of folder names in current folder. GotoRootFolder() --> Bool # Opens root folder in database. GotoParentFolder() --> Bool # Opens parent folder of current folder in database if current folder has parent. GetCurrentFolder() --> string # Returns the current folder name. OpenFolder(folderName) --> Bool # Opens folder under given name. ImportProject(filePath, projectName=None) --> Bool # Imports a project from the file path provided with given project name, if any. Returns True if successful. ExportProject(projectName, filePath, withStillsAndLUTs=True) --> Bool # Exports project to provided file path, including stills and LUTs if withStillsAndLUTs is True (enabled by default). Returns True in case of success. RestoreProject(filePath, projectName=None) --> Bool # Restores a project from the file path provided with given project name, if any. Returns True if successful. GetCurrentDatabase() --> {dbInfo} # Returns a dictionary (with keys 'DbType', 'DbName' and optional 'IpAddress') corresponding to the current database connection GetDatabaseList() --> [{dbInfo}] # Returns a list of dictionary items (with keys 'DbType', 'DbName' and optional 'IpAddress') corresponding to all the databases added to Resolve SetCurrentDatabase({dbInfo}) --> Bool # Switches current database connection to the database specified by the keys below, and closes any open project. # 'DbType': 'Disk' or 'PostgreSQL' (string) # 'DbName': database name (string) # 'IpAddress': IP address of the PostgreSQL server (string, optional key - defaults to '127.0.0.1') Project GetMediaPool() --> MediaPool # Returns the Media Pool object. GetTimelineCount() --> int # Returns the number of timelines currently present in the project. GetTimelineByIndex(idx) --> Timeline # Returns timeline at the given index, 1 <= idx <= project.GetTimelineCount() GetCurrentTimeline() --> Timeline # Returns the currently loaded timeline. SetCurrentTimeline(timeline) --> Bool # Sets given timeline as current timeline for the project. Returns True if successful. GetGallery() --> Gallery # Returns the Gallery object. GetName() --> string # Returns project name. SetName(projectName) --> Bool # Sets project name if given projectName (string) is unique. GetPresetList() --> [presets...] # Returns a list of presets and their information. SetPreset(presetName) --> Bool # Sets preset by given presetName (string) into project. AddRenderJob() --> string # Adds a render job based on current render settings to the render queue. Returns a unique job id (string) for the new render job. DeleteRenderJob(jobId) --> Bool # Deletes render job for input job id (string). DeleteAllRenderJobs() --> Bool # Deletes all render jobs in the queue. GetRenderJobList() --> [render jobs...] # Returns a list of render jobs and their information. GetRenderPresetList() --> [presets...] # Returns a list of render presets and their information. StartRendering(jobId1, jobId2, ...) --> Bool # Starts rendering jobs indicated by the input job ids. StartRendering([jobIds...], isInteractiveMode=False) --> Bool # Starts rendering jobs indicated by the input job ids. # The optional "isInteractiveMode", when set, enables error feedback in the UI during rendering. StartRendering(isInteractiveMode=False) --> Bool # Starts rendering all queued render jobs. # The optional "isInteractiveMode", when set, enables error feedback in the UI during rendering. StopRendering() --> None # Stops any current render processes. IsRenderingInProgress() --> Bool # Returns True if rendering is in progress. LoadRenderPreset(presetName) --> Bool # Sets a preset as current preset for rendering if presetName (string) exists. SaveAsNewRenderPreset(presetName) --> Bool # Creates new render preset by given name if presetName(string) is unique. SetRenderSettings({settings}) --> Bool # Sets given settings for rendering. Settings is a dict, with support for the keys: # Refer to "Looking up render settings" section for information for supported settings GetRenderJobStatus(jobId) --> {status info} # Returns a dict with job status and completion percentage of the job by given jobId (string). GetSetting(settingName) --> string # Returns value of project setting (indicated by settingName, string). Check the section below for more information. SetSetting(settingName, settingValue) --> Bool # Sets the project setting (indicated by settingName, string) to the value (settingValue, string). Check the section below for more information. GetRenderFormats() --> {render formats..} # Returns a dict (format -> file extension) of available render formats. GetRenderCodecs(renderFormat) --> {render codecs...} # Returns a dict (codec description -> codec name) of available codecs for given render format (string). GetCurrentRenderFormatAndCodec() --> {format, codec} # Returns a dict with currently selected format 'format' and render codec 'codec'. SetCurrentRenderFormatAndCodec(format, codec) --> Bool # Sets given render format (string) and render codec (string) as options for rendering. GetCurrentRenderMode() --> int # Returns the render mode: 0 - Individual clips, 1 - Single clip. SetCurrentRenderMode(renderMode) --> Bool # Sets the render mode. Specify renderMode = 0 for Individual clips, 1 for Single clip. GetRenderResolutions(format, codec) --> [{Resolution}] # Returns list of resolutions applicable for the given render format (string) and render codec (string). Returns full list of resolutions if no argument is provided. Each element in the list is a dictionary with 2 keys "Width" and "Height". RefreshLUTList() --> Bool # Refreshes LUT List GetUniqueId() --> string # Returns a unique ID for the project item InsertAudioToCurrentTrackAtPlayhead(mediaPath, --> Bool # Inserts the media specified by mediaPath (string) with startOffsetInSamples (int) and durationInSamples (int) at the playhead on a selected track on the Fairlight page. Returns True if successful, otherwise False. startOffsetInSamples, durationInSamples) LoadBurnInPreset(presetName) --> Bool # Loads user defined data burn in preset for project when supplied presetName (string). Returns true if successful. ExportCurrentFrameAsStill(filePath) --> Bool # Exports current frame as still to supplied filePath. filePath must end in valid export file format. Returns True if succssful, False otherwise. MediaStorage GetMountedVolumeList() --> [paths...] # Returns list of folder paths corresponding to mounted volumes displayed in Resolve’s Media Storage. GetSubFolderList(folderPath) --> [paths...] # Returns list of folder paths in the given absolute folder path. GetFileList(folderPath) --> [paths...] # Returns list of media and file listings in the given absolute folder path. Note that media listings may be logically consolidated entries. RevealInStorage(path) --> Bool # Expands and displays given file/folder path in Resolve’s Media Storage. AddItemListToMediaPool(item1, item2, ...) --> [clips...] # Adds specified file/folder paths from Media Storage into current Media Pool folder. Input is one or more file/folder paths. Returns a list of the MediaPoolItems created. AddItemListToMediaPool([items...]) --> [clips...] # Adds specified file/folder paths from Media Storage into current Media Pool folder. Input is an array of file/folder paths. Returns a list of the MediaPoolItems created. AddItemListToMediaPool([{itemInfo}, ...]) --> [clips...] # Adds list of itemInfos specified as dict of "media", "startFrame" (int), "endFrame" (int) from Media Storage into current Media Pool folder. Returns a list of the MediaPoolItems created. AddClipMattesToMediaPool(MediaPoolItem, [paths], stereoEye) --> Bool # Adds specified media files as mattes for the specified MediaPoolItem. StereoEye is an optional argument for specifying which eye to add the matte to for stereo clips ("left" or "right"). Returns True if successful. AddTimelineMattesToMediaPool([paths]) --> [MediaPoolItems] # Adds specified media files as timeline mattes in current media pool folder. Returns a list of created MediaPoolItems. MediaPool GetRootFolder() --> Folder # Returns root Folder of Media Pool AddSubFolder(folder, name) --> Folder # Adds new subfolder under specified Folder object with the given name. RefreshFolders() --> Bool # Updates the folders in collaboration mode CreateEmptyTimeline(name) --> Timeline # Adds new timeline with given name. AppendToTimeline(clip1, clip2, ...) --> [TimelineItem] # Appends specified MediaPoolItem objects in the current timeline. Returns the list of appended timelineItems. AppendToTimeline([clips]) --> [TimelineItem] # Appends specified MediaPoolItem objects in the current timeline. Returns the list of appended timelineItems. AppendToTimeline([{clipInfo}, ...]) --> [TimelineItem] # Appends list of clipInfos specified as dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), (optional) "mediaType" (int; 1 - Video only, 2 - Audio only), "trackIndex" (int) and "recordFrame" (int). Returns the list of appended timelineItems. CreateTimelineFromClips(name, clip1, clip2,...) --> Timeline # Creates new timeline with specified name, and appends the specified MediaPoolItem objects. CreateTimelineFromClips(name, [clips]) --> Timeline # Creates new timeline with specified name, and appends the specified MediaPoolItem objects. CreateTimelineFromClips(name, [{clipInfo}]) --> Timeline # Creates new timeline with specified name, appending the list of clipInfos specified as a dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), "recordFrame" (int). ImportTimelineFromFile(filePath, {importOptions}) --> Timeline # Creates timeline based on parameters within given file (AAF/EDL/XML/FCPXML/DRT/ADL) and optional importOptions dict, with support for the keys: # "timelineName": string, specifies the name of the timeline to be created. Not valid for DRT import # "importSourceClips": Bool, specifies whether source clips should be imported, True by default. Not valid for DRT import # "sourceClipsPath": string, specifies a filesystem path to search for source clips if the media is inaccessible in their original path and if "importSourceClips" is True # "sourceClipsFolders": List of Media Pool folder objects to search for source clips if the media is not present in current folder and if "importSourceClips" is False. Not valid for DRT import # "interlaceProcessing": Bool, specifies whether to enable interlace processing on the imported timeline being created. valid only for AAF import DeleteTimelines([timeline]) --> Bool # Deletes specified timelines in the media pool. GetCurrentFolder() --> Folder # Returns currently selected Folder. SetCurrentFolder(Folder) --> Bool # Sets current folder by given Folder. DeleteClips([clips]) --> Bool # Deletes specified clips or timeline mattes in the media pool ImportFolderFromFile(filePath, sourceClipsPath="") --> Bool # Returns true if import from given DRB filePath is successful, false otherwise # sourceClipsPath is a string that specifies a filesystem path to search for source clips if the media is inaccessible in their original path, empty by default DeleteFolders([subfolders]) --> Bool # Deletes specified subfolders in the media pool MoveClips([clips], targetFolder) --> Bool # Moves specified clips to target folder. MoveFolders([folders], targetFolder) --> Bool # Moves specified folders to target folder. GetClipMatteList(MediaPoolItem) --> [paths] # Get mattes for specified MediaPoolItem, as a list of paths to the matte files. GetTimelineMatteList(Folder) --> [MediaPoolItems] # Get mattes in specified Folder, as list of MediaPoolItems. DeleteClipMattes(MediaPoolItem, [paths]) --> Bool # Delete mattes based on their file paths, for specified MediaPoolItem. Returns True on success. RelinkClips([MediaPoolItem], folderPath) --> Bool # Update the folder location of specified media pool clips with the specified folder path. UnlinkClips([MediaPoolItem]) --> Bool # Unlink specified media pool clips. ImportMedia([items...]) --> [MediaPoolItems] # Imports specified file/folder paths into current Media Pool folder. Input is an array of file/folder paths. Returns a list of the MediaPoolItems created. ImportMedia([{clipInfo}]) --> [MediaPoolItems] # Imports file path(s) into current Media Pool folder as specified in list of clipInfo dict. Returns a list of the MediaPoolItems created. # Each clipInfo gets imported as one MediaPoolItem unless 'Show Individual Frames' is turned on. # Example: ImportMedia([{"FilePath":"file_%03d.dpx", "StartIndex":1, "EndIndex":100}]) would import clip "file_[001-100].dpx". ExportMetadata(fileName, [clips]) --> Bool # Exports metadata of specified clips to 'fileName' in CSV format. # If no clips are specified, all clips from media pool will be used. GetUniqueId() --> string # Returns a unique ID for the media pool Folder GetClipList() --> [clips...] # Returns a list of clips (items) within the folder. GetName() --> string # Returns the media folder name. GetSubFolderList() --> [folders...] # Returns a list of subfolders in the folder. GetIsFolderStale() --> bool # Returns true if folder is stale in collaboration mode, false otherwise GetUniqueId() --> string # Returns a unique ID for the media pool folder Export(filePath) --> bool # Returns true if export of DRB folder to filePath is successful, false otherwise MediaPoolItem GetName() --> string # Returns the clip name. GetMetadata(metadataType=None) --> string|dict # Returns the metadata value for the key 'metadataType'. # If no argument is specified, a dict of all set metadata properties is returned. SetMetadata(metadataType, metadataValue) --> Bool # Sets the given metadata to metadataValue (string). Returns True if successful. SetMetadata({metadata}) --> Bool # Sets the item metadata with specified 'metadata' dict. Returns True if successful. GetMediaId() --> string # Returns the unique ID for the MediaPoolItem. AddMarker(frameId, color, name, note, duration, --> Bool # Creates a new marker at given frameId position and with given marker information. 'customData' is optional and helps to attach user specific data to the marker. customData) GetMarkers() --> {markers...} # Returns a dict (frameId -> {information}) of all markers and dicts with their information. # Example of output format: {96.0: {'color': 'Green', 'duration': 1.0, 'note': '', 'name': 'Marker 1', 'customData': ''}, ...} # In the above example - there is one 'Green' marker at offset 96 (position of the marker) GetMarkerByCustomData(customData) --> {markers...} # Returns marker {information} for the first matching marker with specified customData. UpdateMarkerCustomData(frameId, customData) --> Bool # Updates customData (string) for the marker at given frameId position. CustomData is not exposed via UI and is useful for scripting developer to attach any user specific data to markers. GetMarkerCustomData(frameId) --> string # Returns customData string for the marker at given frameId position. DeleteMarkersByColor(color) --> Bool # Delete all markers of the specified color from the media pool item. "All" as argument deletes all color markers. DeleteMarkerAtFrame(frameNum) --> Bool # Delete marker at frame number from the media pool item. DeleteMarkerByCustomData(customData) --> Bool # Delete first matching marker with specified customData. AddFlag(color) --> Bool # Adds a flag with given color (string). GetFlagList() --> [colors...] # Returns a list of flag colors assigned to the item. ClearFlags(color) --> Bool # Clears the flag of the given color if one exists. An "All" argument is supported and clears all flags. GetClipColor() --> string # Returns the item color as a string. SetClipColor(colorName) --> Bool # Sets the item color based on the colorName (string). ClearClipColor() --> Bool # Clears the item color. GetClipProperty(propertyName=None) --> string|dict # Returns the property value for the key 'propertyName'. # If no argument is specified, a dict of all clip properties is returned. Check the section below for more information. SetClipProperty(propertyName, propertyValue) --> Bool # Sets the given property to propertyValue (string). Check the section below for more information. LinkProxyMedia(proxyMediaFilePath) --> Bool # Links proxy media located at path specified by arg 'proxyMediaFilePath' with the current clip. 'proxyMediaFilePath' should be absolute clip path. UnlinkProxyMedia() --> Bool # Unlinks any proxy media associated with clip. ReplaceClip(filePath) --> Bool # Replaces the underlying asset and metadata of MediaPoolItem with the specified absolute clip path. GetUniqueId() --> string # Returns a unique ID for the media pool item TranscribeAudio() --> Bool # Transcribes audio of the MediaPoolItem. Returns True if successful; False otherwise ClearTranscription() --> Bool # Clears audio transcription of the MediaPoolItem. Returns True if successful; False otherwise. Timeline GetName() --> string # Returns the timeline name. SetName(timelineName) --> Bool # Sets the timeline name if timelineName (string) is unique. Returns True if successful. GetStartFrame() --> int # Returns the frame number at the start of timeline. GetEndFrame() --> int # Returns the frame number at the end of timeline. SetStartTimecode(timecode) --> Bool # Set the start timecode of the timeline to the string 'timecode'. Returns true when the change is successful, false otherwise. GetStartTimecode() --> string # Returns the start timecode for the timeline. GetTrackCount(trackType) --> int # Returns the number of tracks for the given track type ("audio", "video" or "subtitle"). AddTrack(trackType, optionalSubTrackType) --> Bool # Adds track of trackType ("video", "subtitle", "audio"). Second argument optionalSubTrackType is required for "audio" # optionalSubTrackType can be one of {"mono", "stereo", "5.1", "5.1film", "7.1", "7.1film", "adaptive1", ... , "adaptive24"} DeleteTrack(trackType, trackIndex) --> Bool # Deletes track of trackType ("video", "subtitle", "audio") and given trackIndex. 1 <= trackIndex <= GetTrackCount(trackType). SetTrackEnable(trackType, trackIndex, Bool) --> Bool # Enables/Disables track with given trackType and trackIndex # trackType is one of {"audio", "video", "subtitle"} # 1 <= trackIndex <= GetTrackCount(trackType). GetIsTrackEnabled(trackType, trackIndex) --> Bool # Returns True if track with given trackType and trackIndex is enabled and False otherwise. # trackType is one of {"audio", "video", "subtitle"} # 1 <= trackIndex <= GetTrackCount(trackType). SetTrackLock(trackType, trackIndex, Bool) --> Bool # Locks/Unlocks track with given trackType and trackIndex # trackType is one of {"audio", "video", "subtitle"} # 1 <= trackIndex <= GetTrackCount(trackType). GetIsTrackLocked(trackType, trackIndex) --> Bool # Returns True if track with given trackType and trackIndex is locked and False otherwise. # trackType is one of {"audio", "video", "subtitle"} # 1 <= trackIndex <= GetTrackCount(trackType). DeleteClips([timelineItems], Bool) --> Bool # Deletes specified TimelineItems from the timeline, performing ripple delete if the second argument is True. Second argument is optional (The default for this is False) SetClipsLinked([timelineItems], Bool) --> Bool # Links or unlinks the specified TimelineItems depending on second argument. GetItemListInTrack(trackType, index) --> [items...] # Returns a list of timeline items on that track (based on trackType and index). 1 <= index <= GetTrackCount(trackType). AddMarker(frameId, color, name, note, duration, --> Bool # Creates a new marker at given frameId position and with given marker information. 'customData' is optional and helps to attach user specific data to the marker. customData) GetMarkers() --> {markers...} # Returns a dict (frameId -> {information}) of all markers and dicts with their information. # Example: a value of {96.0: {'color': 'Green', 'duration': 1.0, 'note': '', 'name': 'Marker 1', 'customData': ''}, ...} indicates a single green marker at timeline offset 96 GetMarkerByCustomData(customData) --> {markers...} # Returns marker {information} for the first matching marker with specified customData. UpdateMarkerCustomData(frameId, customData) --> Bool # Updates customData (string) for the marker at given frameId position. CustomData is not exposed via UI and is useful for scripting developer to attach any user specific data to markers. GetMarkerCustomData(frameId) --> string # Returns customData string for the marker at given frameId position. DeleteMarkersByColor(color) --> Bool # Deletes all timeline markers of the specified color. An "All" argument is supported and deletes all timeline markers. DeleteMarkerAtFrame(frameNum) --> Bool # Deletes the timeline marker at the given frame number. DeleteMarkerByCustomData(customData) --> Bool # Delete first matching marker with specified customData. ApplyGradeFromDRX(path, gradeMode, item1, item2, ...)--> Bool # Loads a still from given file path (string) and applies grade to Timeline Items with gradeMode (int): 0 - "No keyframes", 1 - "Source Timecode aligned", 2 - "Start Frames aligned". ApplyGradeFromDRX(path, gradeMode, [items]) --> Bool # Loads a still from given file path (string) and applies grade to Timeline Items with gradeMode (int): 0 - "No keyframes", 1 - "Source Timecode aligned", 2 - "Start Frames aligned". GetCurrentTimecode() --> string # Returns a string timecode representation for the current playhead position, while on Cut, Edit, Color, Fairlight and Deliver pages. SetCurrentTimecode(timecode) --> Bool # Sets current playhead position from input timecode for Cut, Edit, Color, Fairlight and Deliver pages. GetCurrentVideoItem() --> item # Returns the current video timeline item. GetCurrentClipThumbnailImage() --> {thumbnailData} # Returns a dict (keys "width", "height", "format" and "data") with data containing raw thumbnail image data (RGB 8-bit image data encoded in base64 format) for current media in the Color Page. # An example of how to retrieve and interpret thumbnails is provided in 6_get_current_media_thumbnail.py in the Examples folder. GetTrackName(trackType, trackIndex) --> string # Returns the track name for track indicated by trackType ("audio", "video" or "subtitle") and index. 1 <= trackIndex <= GetTrackCount(trackType). SetTrackName(trackType, trackIndex, name) --> Bool # Sets the track name (string) for track indicated by trackType ("audio", "video" or "subtitle") and index. 1 <= trackIndex <= GetTrackCount(trackType). DuplicateTimeline(timelineName) --> timeline # Duplicates the timeline and returns the created timeline, with the (optional) timelineName, on success. CreateCompoundClip([timelineItems], {clipInfo}) --> timelineItem # Creates a compound clip of input timeline items with an optional clipInfo map: {"startTimecode" : "00:00:00:00", "name" : "Compound Clip 1"}. It returns the created timeline item. CreateFusionClip([timelineItems]) --> timelineItem # Creates a Fusion clip of input timeline items. It returns the created timeline item. ImportIntoTimeline(filePath, {importOptions}) --> Bool # Imports timeline items from an AAF file and optional importOptions dict into the timeline, with support for the keys: # "autoImportSourceClipsIntoMediaPool": Bool, specifies if source clips should be imported into media pool, True by default # "ignoreFileExtensionsWhenMatching": Bool, specifies if file extensions should be ignored when matching, False by default # "linkToSourceCameraFiles": Bool, specifies if link to source camera files should be enabled, False by default # "useSizingInfo": Bool, specifies if sizing information should be used, False by default # "importMultiChannelAudioTracksAsLinkedGroups": Bool, specifies if multi-channel audio tracks should be imported as linked groups, False by default # "insertAdditionalTracks": Bool, specifies if additional tracks should be inserted, True by default # "insertWithOffset": string, specifies insert with offset value in timecode format - defaults to "00:00:00:00", applicable if "insertAdditionalTracks" is False # "sourceClipsPath": string, specifies a filesystem path to search for source clips if the media is inaccessible in their original path and if "ignoreFileExtensionsWhenMatching" is True # "sourceClipsFolders": string, list of Media Pool folder objects to search for source clips if the media is not present in current folder Export(fileName, exportType, exportSubtype) --> Bool # Exports timeline to 'fileName' as per input exportType & exportSubtype format. # Refer to section "Looking up timeline export properties" for information on the parameters. GetSetting(settingName) --> string # Returns value of timeline setting (indicated by settingName : string). Check the section below for more information. SetSetting(settingName, settingValue) --> Bool # Sets timeline setting (indicated by settingName : string) to the value (settingValue : string). Check the section below for more information. InsertGeneratorIntoTimeline(generatorName) --> TimelineItem # Inserts a generator (indicated by generatorName : string) into the timeline. InsertFusionGeneratorIntoTimeline(generatorName) --> TimelineItem # Inserts a Fusion generator (indicated by generatorName : string) into the timeline. InsertFusionCompositionIntoTimeline() --> TimelineItem # Inserts a Fusion composition into the timeline. InsertOFXGeneratorIntoTimeline(generatorName) --> TimelineItem # Inserts an OFX generator (indicated by generatorName : string) into the timeline. InsertTitleIntoTimeline(titleName) --> TimelineItem # Inserts a title (indicated by titleName : string) into the timeline. InsertFusionTitleIntoTimeline(titleName) --> TimelineItem # Inserts a Fusion title (indicated by titleName : string) into the timeline. GrabStill() --> galleryStill # Grabs still from the current video clip. Returns a GalleryStill object. GrabAllStills(stillFrameSource) --> [galleryStill] # Grabs stills from all the clips of the timeline at 'stillFrameSource' (1 - First frame, 2 - Middle frame). Returns the list of GalleryStill objects. GetUniqueId() --> string # Returns a unique ID for the timeline CreateSubtitlesFromAudio() --> Bool # Creates subtitles from audio for the timeline. Returns True on success, False otherwise. DetectSceneCuts() --> Bool # Detects and makes scene cuts along the timeline. Returns True if successful, False otherwise. TimelineItem GetName() --> string # Returns the item name. GetDuration() --> int # Returns the item duration. GetEnd() --> int # Returns the end frame position on the timeline. GetFusionCompCount() --> int # Returns number of Fusion compositions associated with the timeline item. GetFusionCompByIndex(compIndex) --> fusionComp # Returns the Fusion composition object based on given index. 1 <= compIndex <= timelineItem.GetFusionCompCount() GetFusionCompNameList() --> [names...] # Returns a list of Fusion composition names associated with the timeline item. GetFusionCompByName(compName) --> fusionComp # Returns the Fusion composition object based on given name. GetLeftOffset() --> int # Returns the maximum extension by frame for clip from left side. GetRightOffset() --> int # Returns the maximum extension by frame for clip from right side. GetStart() --> int # Returns the start frame position on the timeline. SetProperty(propertyKey, propertyValue) --> Bool # Sets the value of property "propertyKey" to value "propertyValue" # Refer to "Looking up Timeline item properties" for more information GetProperty(propertyKey) --> int/[key:value] # returns the value of the specified key # if no key is specified, the method returns a dictionary(python) or table(lua) for all supported keys AddMarker(frameId, color, name, note, duration, --> Bool # Creates a new marker at given frameId position and with given marker information. 'customData' is optional and helps to attach user specific data to the marker. customData) GetMarkers() --> {markers...} # Returns a dict (frameId -> {information}) of all markers and dicts with their information. # Example: a value of {96.0: {'color': 'Green', 'duration': 1.0, 'note': '', 'name': 'Marker 1', 'customData': ''}, ...} indicates a single green marker at clip offset 96 GetMarkerByCustomData(customData) --> {markers...} # Returns marker {information} for the first matching marker with specified customData. UpdateMarkerCustomData(frameId, customData) --> Bool # Updates customData (string) for the marker at given frameId position. CustomData is not exposed via UI and is useful for scripting developer to attach any user specific data to markers. GetMarkerCustomData(frameId) --> string # Returns customData string for the marker at given frameId position. DeleteMarkersByColor(color) --> Bool # Delete all markers of the specified color from the timeline item. "All" as argument deletes all color markers. DeleteMarkerAtFrame(frameNum) --> Bool # Delete marker at frame number from the timeline item. DeleteMarkerByCustomData(customData) --> Bool # Delete first matching marker with specified customData. AddFlag(color) --> Bool # Adds a flag with given color (string). GetFlagList() --> [colors...] # Returns a list of flag colors assigned to the item. ClearFlags(color) --> Bool # Clear flags of the specified color. An "All" argument is supported to clear all flags. GetClipColor() --> string # Returns the item color as a string. SetClipColor(colorName) --> Bool # Sets the item color based on the colorName (string). ClearClipColor() --> Bool # Clears the item color. AddFusionComp() --> fusionComp # Adds a new Fusion composition associated with the timeline item. ImportFusionComp(path) --> fusionComp # Imports a Fusion composition from given file path by creating and adding a new composition for the item. ExportFusionComp(path, compIndex) --> Bool # Exports the Fusion composition based on given index to the path provided. DeleteFusionCompByName(compName) --> Bool # Deletes the named Fusion composition. LoadFusionCompByName(compName) --> fusionComp # Loads the named Fusion composition as the active composition. RenameFusionCompByName(oldName, newName) --> Bool # Renames the Fusion composition identified by oldName. AddVersion(versionName, versionType) --> Bool # Adds a new color version for a video clip based on versionType (0 - local, 1 - remote). GetCurrentVersion() --> {versionName...} # Returns the current version of the video clip. The returned value will have the keys versionName and versionType(0 - local, 1 - remote). DeleteVersionByName(versionName, versionType) --> Bool # Deletes a color version by name and versionType (0 - local, 1 - remote). LoadVersionByName(versionName, versionType) --> Bool # Loads a named color version as the active version. versionType: 0 - local, 1 - remote. RenameVersionByName(oldName, newName, versionType)--> Bool # Renames the color version identified by oldName and versionType (0 - local, 1 - remote). GetVersionNameList(versionType) --> [names...] # Returns a list of all color versions for the given versionType (0 - local, 1 - remote). GetMediaPoolItem() --> MediaPoolItem # Returns the media pool item corresponding to the timeline item if one exists. GetStereoConvergenceValues() --> {keyframes...} # Returns a dict (offset -> value) of keyframe offsets and respective convergence values. GetStereoLeftFloatingWindowParams() --> {keyframes...} # For the LEFT eye -> returns a dict (offset -> dict) of keyframe offsets and respective floating window params. Value at particular offset includes the left, right, top and bottom floating window values. GetStereoRightFloatingWindowParams() --> {keyframes...} # For the RIGHT eye -> returns a dict (offset -> dict) of keyframe offsets and respective floating window params. Value at particular offset includes the left, right, top and bottom floating window values. GetNumNodes() --> int # Returns the number of nodes in the current graph for the timeline item ApplyArriCdlLut() --> Bool # Applies ARRI CDL and LUT. Returns True if successful, False otherwise. SetLUT(nodeIndex, lutPath) --> Bool # Sets LUT on the node mapping the node index provided, 1 <= nodeIndex <= total number of nodes. # The lutPath can be an absolute path, or a relative path (based off custom LUT paths or the master LUT path). # The operation is successful for valid lut paths that Resolve has already discovered (see Project.RefreshLUTList). GetLUT(nodeIndex) --> String # Gets relative LUT path based on the node index provided, 1 <= nodeIndex <= total number of nodes. SetCDL([CDL map]) --> Bool # Keys of map are: "NodeIndex", "Slope", "Offset", "Power", "Saturation", where 1 <= NodeIndex <= total number of nodes. # Example python code - SetCDL({"NodeIndex" : "1", "Slope" : "0.5 0.4 0.2", "Offset" : "0.4 0.3 0.2", "Power" : "0.6 0.7 0.8", "Saturation" : "0.65"}) AddTake(mediaPoolItem, startFrame, endFrame) --> Bool # Adds mediaPoolItem as a new take. Initializes a take selector for the timeline item if needed. By default, the full clip extents is added. startFrame (int) and endFrame (int) are optional arguments used to specify the extents. GetSelectedTakeIndex() --> int # Returns the index of the currently selected take, or 0 if the clip is not a take selector. GetTakesCount() --> int # Returns the number of takes in take selector, or 0 if the clip is not a take selector. GetTakeByIndex(idx) --> {takeInfo...} # Returns a dict (keys "startFrame", "endFrame" and "mediaPoolItem") with take info for specified index. DeleteTakeByIndex(idx) --> Bool # Deletes a take by index, 1 <= idx <= number of takes. SelectTakeByIndex(idx) --> Bool # Selects a take by index, 1 <= idx <= number of takes. FinalizeTake() --> Bool # Finalizes take selection. CopyGrades([tgtTimelineItems]) --> Bool # Copies the current grade to all the items in tgtTimelineItems list. Returns True on success and False if any error occurred. SetClipEnabled(Bool) --> Bool # Sets clip enabled based on argument. GetClipEnabled() --> Bool # Gets clip enabled status. UpdateSidecar() --> Bool # Updates sidecar file for BRAW clips or RMD file for R3D clips. GetUniqueId() --> string # Returns a unique ID for the timeline item LoadBurnInPreset(presetName) --> Bool # Loads user defined data burn in preset for clip when supplied presetName (string). Returns true if successful. GetNodeLabel(nodeIndex) --> string # Returns the label of the node at nodeIndex. CreateMagicMask(mode) --> Bool # Returns True if magic mask was created successfully, False otherwise. mode can "F" (forward), "B" (backward), or "BI" (bidirection) RegenerateMagicMask() --> Bool # Returns True if magic mask was regenerated successfully, False otherwise. Stabilize() --> Bool # Returns True if stabilization was successful, False otherwise SmartReframe() --> Bool # Performs Smart Reframe. Returns True if successful, False otherwise. Gallery GetAlbumName(galleryStillAlbum) --> string # Returns the name of the GalleryStillAlbum object 'galleryStillAlbum'. SetAlbumName(galleryStillAlbum, albumName) --> Bool # Sets the name of the GalleryStillAlbum object 'galleryStillAlbum' to 'albumName'. GetCurrentStillAlbum() --> galleryStillAlbum # Returns current album as a GalleryStillAlbum object. SetCurrentStillAlbum(galleryStillAlbum) --> Bool # Sets current album to GalleryStillAlbum object 'galleryStillAlbum'. GetGalleryStillAlbums() --> [galleryStillAlbum] # Returns the gallery albums as a list of GalleryStillAlbum objects. GalleryStillAlbum GetStills() --> [galleryStill] # Returns the list of GalleryStill objects in the album. GetLabel(galleryStill) --> string # Returns the label of the galleryStill. SetLabel(galleryStill, label) --> Bool # Sets the new 'label' to GalleryStill object 'galleryStill'. ExportStills([galleryStill], folderPath, filePrefix, format) --> Bool # Exports list of GalleryStill objects '[galleryStill]' to directory 'folderPath', with filename prefix 'filePrefix', using file format 'format' (supported formats: dpx, cin, tif, jpg, png, ppm, bmp, xpm). DeleteStills([galleryStill]) --> Bool # Deletes specified list of GalleryStill objects '[galleryStill]'. GalleryStill # This class does not provide any API functions but the object type is used by functions in other classes. List and Dict Data Structures ----------------------------- Beside primitive data types, Resolve's Python API mainly uses list and dict data structures. Lists are denoted by [ ... ] and dicts are denoted by { ... } above. As Lua does not support list and dict data structures, the Lua API implements "list" as a table with indices, e.g. { [1] = listValue1, [2] = listValue2, ... }. Similarly the Lua API implements "dict" as a table with the dictionary key as first element, e.g. { [dictKey1] = dictValue1, [dictKey2] = dictValue2, ... }. Looking up Project and Clip properties -------------------------------------- This section covers additional notes for the functions "Project:GetSetting", "Project:SetSetting", "Timeline:GetSetting", "Timeline:SetSetting", "MediaPoolItem:GetClipProperty" and "MediaPoolItem:SetClipProperty". These functions are used to get and set properties otherwise available to the user through the Project Settings and the Clip Attributes dialogs. The functions follow a key-value pair format, where each property is identified by a key (the settingName or propertyName parameter) and possesses a value (typically a text value). Keys and values are designed to be easily correlated with parameter names and values in the Resolve UI. Explicitly enumerated values for some parameters are listed below. Some properties may be read only - these include intrinsic clip properties like date created or sample rate, and properties that can be disabled in specific application contexts (e.g. custom colorspaces in an ACES workflow, or output sizing parameters when behavior is set to match timeline) Getting values: Invoke "Project:GetSetting", "Timeline:GetSetting" or "MediaPoolItem:GetClipProperty" with the appropriate property key. To get a snapshot of all queryable properties (keys and values), you can call "Project:GetSetting", "Timeline:GetSetting" or "MediaPoolItem:GetClipProperty" without parameters (or with a NoneType or a blank property key). Using specific keys to query individual properties will be faster. Note that getting a property using an invalid key will return a trivial result. Setting values: Invoke "Project:SetSetting", "Timeline:SetSetting" or "MediaPoolItem:SetClipProperty" with the appropriate property key and a valid value. When setting a parameter, please check the return value to ensure the success of the operation. You can troubleshoot the validity of keys and values by setting the desired result from the UI and checking property snapshots before and after the change. The following Project properties have specifically enumerated values: "superScale" - the property value is an enumerated integer between 0 and 4 with these meanings: 0=Auto, 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. for super scale multiplier '2x Enhanced', exactly 4 arguments must be passed as outlined below. If less than 4 arguments are passed, it will default to 2x. Affects: • x = Project:GetSetting('superScale') and Project:SetSetting('superScale', x) • for '2x Enhanced' --> Project:SetSetting('superScale', 2, sharpnessValue, noiseReductionValue), where sharpnessValue is a float in the range [0.0, 1.0] and noiseReductionValue is a float in the range [0.0, 1.0] "timelineFrameRate" - the property value is one of the frame rates available to the user in project settings under "Timeline frame rate" option. Drop Frame can be configured for supported frame rates by appending the frame rate with "DF", e.g. "29.97 DF" will enable drop frame and "29.97" will disable drop frame Affects: • x = Project:GetSetting('timelineFrameRate') and Project:SetSetting('timelineFrameRate', x) The following Clip properties have specifically enumerated values: "Super Scale" - the property value is an enumerated integer between 1 and 4 with these meanings: 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. for super scale multiplier '2x Enhanced', exactly 4 arguments must be passed as outlined below. If less than 4 arguments are passed, it will default to 2x. Affects: • x = MediaPoolItem:GetClipProperty('Super Scale') and MediaPoolItem:SetClipProperty('Super Scale', x) • for '2x Enhanced' --> MediaPoolItem:SetClipProperty('Super Scale', 2, sharpnessValue, noiseReductionValue), where sharpnessValue is a float in the range [0.0, 1.0] and noiseReductionValue is a float in the range [0.0, 1.0] Looking up Render Settings -------------------------- This section covers the supported settings for the method SetRenderSettings({settings}) The parameter setting is a dictionary containing the following keys: - "SelectAllFrames": Bool (when set True, the settings MarkIn and MarkOut are ignored) - "MarkIn": int - "MarkOut": int - "TargetDir": string - "CustomName": string - "UniqueFilenameStyle": 0 - Prefix, 1 - Suffix. - "ExportVideo": Bool - "ExportAudio": Bool - "FormatWidth": int - "FormatHeight": int - "FrameRate": float (examples: 23.976, 24) - "PixelAspectRatio": string (for SD resolution: "16_9" or "4_3") (other resolutions: "square" or "cinemascope") - "VideoQuality" possible values for current codec (if applicable): - 0 (int) - will set quality to automatic - [1 -> MAX] (int) - will set input bit rate - ["Least", "Low", "Medium", "High", "Best"] (String) - will set input quality level - "AudioCodec": string (example: "aac") - "AudioBitDepth": int - "AudioSampleRate": int - "ColorSpaceTag" : string (example: "Same as Project", "AstroDesign") - "GammaTag" : string (example: "Same as Project", "ACEScct") - "ExportAlpha": Bool - "EncodingProfile": string (example: "Main10"). Can only be set for H.264 and H.265. - "MultiPassEncode": Bool. Can only be set for H.264. - "AlphaMode": 0 - Premultiplied, 1 - Straight. Can only be set if "ExportAlpha" is true. - "NetworkOptimization": Bool. Only supported by QuickTime and MP4 formats. Looking up timeline export properties ------------------------------------- This section covers the parameters for the argument Export(fileName, exportType, exportSubtype). exportType can be one of the following constants: - resolve.EXPORT_AAF - resolve.EXPORT_DRT - resolve.EXPORT_EDL - resolve.EXPORT_FCP_7_XML - resolve.EXPORT_FCPXML_1_8 - resolve.EXPORT_FCPXML_1_9 - resolve.EXPORT_FCPXML_1_10 - resolve.EXPORT_HDR_10_PROFILE_A - resolve.EXPORT_HDR_10_PROFILE_B - resolve.EXPORT_TEXT_CSV - resolve.EXPORT_TEXT_TAB - resolve.EXPORT_DOLBY_VISION_VER_2_9 - resolve.EXPORT_DOLBY_VISION_VER_4_0 - resolve.EXPORT_DOLBY_VISION_VER_5_1 - resolve.EXPORT_OTIO exportSubtype can be one of the following enums: - resolve.EXPORT_NONE - resolve.EXPORT_AAF_NEW - resolve.EXPORT_AAF_EXISTING - resolve.EXPORT_CDL - resolve.EXPORT_SDL - resolve.EXPORT_MISSING_CLIPS Please note that exportSubType is a required parameter for resolve.EXPORT_AAF and resolve.EXPORT_EDL. For rest of the exportType, exportSubtype is ignored. When exportType is resolve.EXPORT_AAF, valid exportSubtype values are resolve.EXPORT_AAF_NEW and resolve.EXPORT_AAF_EXISTING. When exportType is resolve.EXPORT_EDL, valid exportSubtype values are resolve.EXPORT_CDL, resolve.EXPORT_SDL, resolve.EXPORT_MISSING_CLIPS and resolve.EXPORT_NONE. Note: Replace 'resolve.' when using the constants above, if a different Resolve class instance name is used. Unsupported exportType types --------------------------------- Starting with DaVinci Resolve 18.1, the following export types are not supported: - resolve.EXPORT_FCPXML_1_3 - resolve.EXPORT_FCPXML_1_4 - resolve.EXPORT_FCPXML_1_5 - resolve.EXPORT_FCPXML_1_6 - resolve.EXPORT_FCPXML_1_7 Looking up Timeline item properties ----------------------------------- This section covers additional notes for the function "TimelineItem:SetProperty" and "TimelineItem:GetProperty". These functions are used to get and set properties mentioned. The supported keys with their accepted values are: "Pan" : floating point values from -4.0*width to 4.0*width "Tilt" : floating point values from -4.0*height to 4.0*height "ZoomX" : floating point values from 0.0 to 100.0 "ZoomY" : floating point values from 0.0 to 100.0 "ZoomGang" : a boolean value "RotationAngle" : floating point values from -360.0 to 360.0 "AnchorPointX" : floating point values from -4.0*width to 4.0*width "AnchorPointY" : floating point values from -4.0*height to 4.0*height "Pitch" : floating point values from -1.5 to 1.5 "Yaw" : floating point values from -1.5 to 1.5 "FlipX" : boolean value for flipping horizontally "FlipY" : boolean value for flipping vertically "CropLeft" : floating point values from 0.0 to width "CropRight" : floating point values from 0.0 to width "CropTop" : floating point values from 0.0 to height "CropBottom" : floating point values from 0.0 to height "CropSoftness" : floating point values from -100.0 to 100.0 "CropRetain" : boolean value for "Retain Image Position" checkbox "DynamicZoomEase" : A value from the following constants - DYNAMIC_ZOOM_EASE_LINEAR = 0 - DYNAMIC_ZOOM_EASE_IN - DYNAMIC_ZOOM_EASE_OUT - DYNAMIC_ZOOM_EASE_IN_AND_OUT "CompositeMode" : A value from the following constants - COMPOSITE_NORMAL = 0 - COMPOSITE_ADD - COMPOSITE_SUBTRACT - COMPOSITE_DIFF - COMPOSITE_MULTIPLY - COMPOSITE_SCREEN - COMPOSITE_OVERLAY - COMPOSITE_HARDLIGHT - COMPOSITE_SOFTLIGHT - COMPOSITE_DARKEN - COMPOSITE_LIGHTEN - COMPOSITE_COLOR_DODGE - COMPOSITE_COLOR_BURN - COMPOSITE_EXCLUSION - COMPOSITE_HUE - COMPOSITE_SATURATE - COMPOSITE_COLORIZE - COMPOSITE_LUMA_MASK - COMPOSITE_DIVIDE - COMPOSITE_LINEAR_DODGE - COMPOSITE_LINEAR_BURN - COMPOSITE_LINEAR_LIGHT - COMPOSITE_VIVID_LIGHT - COMPOSITE_PIN_LIGHT - COMPOSITE_HARD_MIX - COMPOSITE_LIGHTER_COLOR - COMPOSITE_DARKER_COLOR - COMPOSITE_FOREGROUND - COMPOSITE_ALPHA - COMPOSITE_INVERTED_ALPHA - COMPOSITE_LUM - COMPOSITE_INVERTED_LUM "Opacity" : floating point value from 0.0 to 100.0 "Distortion" : floating point value from -1.0 to 1.0 "RetimeProcess" : A value from the following constants - RETIME_USE_PROJECT = 0 - RETIME_NEAREST - RETIME_FRAME_BLEND - RETIME_OPTICAL_FLOW "MotionEstimation" : A value from the following constants - MOTION_EST_USE_PROJECT = 0 - MOTION_EST_STANDARD_FASTER - MOTION_EST_STANDARD_BETTER - MOTION_EST_ENHANCED_FASTER - MOTION_EST_ENHANCED_BETTER - MOTION_EST_SPEED_WRAP "Scaling" : A value from the following constants - SCALE_USE_PROJECT = 0 - SCALE_CROP - SCALE_FIT - SCALE_FILL - SCALE_STRETCH "ResizeFilter" : A value from the following constants - RESIZE_FILTER_USE_PROJECT = 0 - RESIZE_FILTER_SHARPER - RESIZE_FILTER_SMOOTHER - RESIZE_FILTER_BICUBIC - RESIZE_FILTER_BILINEAR - RESIZE_FILTER_BESSEL - RESIZE_FILTER_BOX - RESIZE_FILTER_CATMULL_ROM - RESIZE_FILTER_CUBIC - RESIZE_FILTER_GAUSSIAN - RESIZE_FILTER_LANCZOS - RESIZE_FILTER_MITCHELL - RESIZE_FILTER_NEAREST_NEIGHBOR - RESIZE_FILTER_QUADRATIC - RESIZE_FILTER_SINC - RESIZE_FILTER_LINEAR Values beyond the range will be clipped width and height are same as the UI max limits The arguments can be passed as a key and value pair or they can be grouped together into a dictionary (for python) or table (for lua) and passed as a single argument. Getting the values for the keys that uses constants will return the number which is in the constant Deprecated Resolve API Functions -------------------------------- The following API functions are deprecated. ProjectManager GetProjectsInCurrentFolder() --> {project names...} # Returns a dict of project names in current folder. GetFoldersInCurrentFolder() --> {folder names...} # Returns a dict of folder names in current folder. Project GetPresets() --> {presets...} # Returns a dict of presets and their information. GetRenderJobs() --> {render jobs...} # Returns a dict of render jobs and their information. GetRenderPresets() --> {presets...} # Returns a dict of render presets and their information. MediaStorage GetMountedVolumes() --> {paths...} # Returns a dict of folder paths corresponding to mounted volumes displayed in Resolve’s Media Storage. GetSubFolders(folderPath) --> {paths...} # Returns a dict of folder paths in the given absolute folder path. GetFiles(folderPath) --> {paths...} # Returns a dict of media and file listings in the given absolute folder path. Note that media listings may be logically consolidated entries. AddItemsToMediaPool(item1, item2, ...) --> {clips...} # Adds specified file/folder paths from Media Storage into current Media Pool folder. Input is one or more file/folder paths. Returns a dict of the MediaPoolItems created. AddItemsToMediaPool([items...]) --> {clips...} # Adds specified file/folder paths from Media Storage into current Media Pool folder. Input is an array of file/folder paths. Returns a dict of the MediaPoolItems created. Folder GetClips() --> {clips...} # Returns a dict of clips (items) within the folder. GetSubFolders() --> {folders...} # Returns a dict of subfolders in the folder. MediaPoolItem GetFlags() --> {colors...} # Returns a dict of flag colors assigned to the item. Timeline GetItemsInTrack(trackType, index) --> {items...} # Returns a dict of Timeline items on the video or audio track (based on trackType) at specified TimelineItem GetFusionCompNames() --> {names...} # Returns a dict of Fusion composition names associated with the timeline item. GetFlags() --> {colors...} # Returns a dict of flag colors assigned to the item. GetVersionNames(versionType) --> {names...} # Returns a dict of version names by provided versionType: 0 - local, 1 - remote. Unsupported Resolve API Functions --------------------------------- The following API (functions and parameters) are no longer supported. Use job IDs instead of indices. Project StartRendering(index1, index2, ...) --> Bool # Please use unique job ids (string) instead of indices. StartRendering([idxs...]) --> Bool # Please use unique job ids (string) instead of indices. DeleteRenderJobByIndex(idx) --> Bool # Please use unique job ids (string) instead of indices. GetRenderJobStatus(idx) --> {status info} # Please use unique job ids (string) instead of indices. GetSetting and SetSetting --> {} # settingName videoMonitorUseRec601For422SDI is now replaced with videoMonitorUseMatrixOverrideFor422SDI and videoMonitorMatrixOverrideFor422SDI. # settingName perfProxyMediaOn is now replaced with perfProxyMediaMode which takes values 0 - disabled, 1 - when available, 2 - when source not available. ================================================ FILE: openpype/hosts/resolve/__init__.py ================================================ from .addon import ResolveAddon __all__ = ( "ResolveAddon", ) ================================================ FILE: openpype/hosts/resolve/addon.py ================================================ import os from openpype.modules import OpenPypeModule, IHostAddon from .utils import RESOLVE_ROOT_DIR class ResolveAddon(OpenPypeModule, IHostAddon): name = "resolve" host_name = "resolve" def initialize(self, module_settings): self.enabled = True def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] return [ os.path.join(RESOLVE_ROOT_DIR, "hooks") ] def get_workfile_extensions(self): return [".drp"] ================================================ FILE: openpype/hosts/resolve/api/__init__.py ================================================ """ resolve api """ from .utils import ( get_resolve_module ) from .pipeline import ( ResolveHost, ls, containerise, update_container, maintained_selection, remove_instance, list_instances ) from .lib import ( maintain_current_timeline, publish_clip_color, get_project_manager, get_current_project, get_current_timeline, get_any_timeline, get_new_timeline, create_bin, get_media_pool_item, create_media_pool_item, create_timeline_item, get_timeline_item, get_video_track_names, get_current_timeline_items, get_pype_timeline_item_by_name, get_timeline_item_pype_tag, set_timeline_item_pype_tag, imprint, set_publish_attribute, get_publish_attribute, create_compound_clip, swap_clips, get_pype_clip_metadata, set_project_manager_to_folder_name, get_otio_clip_instance_data, get_reformated_path ) from .menu import launch_pype_menu from .plugin import ( ClipLoader, TimelineItemLoader, Creator, PublishClip ) from .workio import ( open_file, save_file, current_file, has_unsaved_changes, file_extensions, work_root ) from .testing_utils import TestGUI bmdvr = None bmdvf = None __all__ = [ "bmdvr", "bmdvf", # pipeline "ResolveHost", "ls", "containerise", "update_container", "maintained_selection", "remove_instance", "list_instances", # utils "get_resolve_module", # lib "maintain_current_timeline", "publish_clip_color", "get_project_manager", "get_current_project", "get_current_timeline", "get_any_timeline", "get_new_timeline", "create_bin", "get_media_pool_item", "create_media_pool_item", "create_timeline_item", "get_timeline_item", "get_video_track_names", "get_current_timeline_items", "get_pype_timeline_item_by_name", "get_timeline_item_pype_tag", "set_timeline_item_pype_tag", "imprint", "set_publish_attribute", "get_publish_attribute", "create_compound_clip", "swap_clips", "get_pype_clip_metadata", "set_project_manager_to_folder_name", "get_otio_clip_instance_data", "get_reformated_path", # menu "launch_pype_menu", # plugin "ClipLoader", "TimelineItemLoader", "Creator", "PublishClip", # workio "open_file", "save_file", "current_file", "has_unsaved_changes", "file_extensions", "work_root", "TestGUI" ] ================================================ FILE: openpype/hosts/resolve/api/action.py ================================================ # absolute_import is needed to counter the `module has no cmds error` in Maya from __future__ import absolute_import import pyblish.api from openpype.pipeline.publish import get_errored_instances_from_context class SelectInvalidAction(pyblish.api.Action): """Select invalid clips in Resolve timeline when plug-in failed. To retrieve the invalid nodes this assumes a static `get_invalid()` method is available on the plugin. """ label = "Select invalid" on = "failed" # This action is only available on a failed plug-in icon = "search" # Icon from Awesome Icon def process(self, context, plugin): try: from .lib import get_project_manager pm = get_project_manager() self.log.debug(pm) except ImportError: raise ImportError("Current host is not Resolve") errored_instances = get_errored_instances_from_context(context, plugin=plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid clips..") invalid = list() for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: if isinstance(invalid_nodes, (list, tuple)): invalid.extend(invalid_nodes) else: self.log.warning("Plug-in returned to be invalid, " "but has no selectable nodes.") # Ensure unique (process each node only once) invalid = list(set(invalid)) if invalid: self.log.info("Selecting invalid nodes: %s" % ", ".join(invalid)) # TODO: select resolve timeline track items in current timeline else: self.log.info("No invalid nodes found.") ================================================ FILE: openpype/hosts/resolve/api/lib.py ================================================ import sys import json import re import os import contextlib from opentimelineio import opentime from openpype.lib import Logger from openpype.pipeline.editorial import ( is_overlapping_otio_ranges, frames_to_timecode ) from ..otio import davinci_export as otio_export log = Logger.get_logger(__name__) self = sys.modules[__name__] self.project_manager = None self.media_storage = None # OpenPype sequential rename variables self.rename_index = 0 self.rename_add = 0 self.publish_clip_color = "Pink" self.pype_marker_workflow = True # OpenPype compound clip workflow variable self.pype_tag_name = "VFX Notes" # OpenPype marker workflow variables self.pype_marker_name = "OpenPypeData" self.pype_marker_duration = 1 self.pype_marker_color = "Mint" self.temp_marker_frame = None # OpenPype default timeline self.pype_timeline_name = "OpenPypeTimeline" @contextlib.contextmanager def maintain_current_timeline(to_timeline: object, from_timeline: object = None): """Maintain current timeline selection during context Attributes: from_timeline (resolve.Timeline)[optional]: Example: >>> print(from_timeline.GetName()) timeline1 >>> print(to_timeline.GetName()) timeline2 >>> with maintain_current_timeline(to_timeline): ... print(get_current_timeline().GetName()) timeline2 >>> print(get_current_timeline().GetName()) timeline1 """ project = get_current_project() working_timeline = from_timeline or project.GetCurrentTimeline() # switch to the input timeline project.SetCurrentTimeline(to_timeline) try: # do a work yield finally: # put the original working timeline to context project.SetCurrentTimeline(working_timeline) def get_project_manager(): from . import bmdvr if not self.project_manager: self.project_manager = bmdvr.GetProjectManager() return self.project_manager def get_media_storage(): from . import bmdvr if not self.media_storage: self.media_storage = bmdvr.GetMediaStorage() return self.media_storage def get_current_project(): """Get current project object. """ return get_project_manager().GetCurrentProject() def get_current_timeline(new=False): """Get current timeline object. Args: new (bool)[optional]: [DEPRECATED] if True it will create new timeline if none exists Returns: TODO: will need to reflect future `None` object: resolve.Timeline """ project = get_current_project() timeline = project.GetCurrentTimeline() # return current timeline if any if timeline: return timeline # TODO: [deprecated] and will be removed in future if new: return get_new_timeline() def get_any_timeline(): """Get any timeline object. Returns: object | None: resolve.Timeline """ project = get_current_project() timeline_count = project.GetTimelineCount() if timeline_count > 0: return project.GetTimelineByIndex(1) def get_new_timeline(timeline_name: str = None): """Get new timeline object. Arguments: timeline_name (str): New timeline name. Returns: object: resolve.Timeline """ project = get_current_project() media_pool = project.GetMediaPool() new_timeline = media_pool.CreateEmptyTimeline( timeline_name or self.pype_timeline_name) project.SetCurrentTimeline(new_timeline) return new_timeline def create_bin(name: str, root: object = None) -> object: """ Create media pool's folder. Return folder object and if the name does not exist it will create a new. If the input name is with forward or backward slashes then it will create all parents and return the last child bin object Args: name (str): name of folder / bin, or hierarchycal name "parent/name" root (resolve.Folder)[optional]: root folder / bin object Returns: object: resolve.Folder """ # get all variables media_pool = get_current_project().GetMediaPool() root_bin = root or media_pool.GetRootFolder() # create hierarchy of bins in case there is slash in name if "/" in name.replace("\\", "/"): child_bin = None for bname in name.split("/"): child_bin = create_bin(bname, child_bin or root_bin) if child_bin: return child_bin else: created_bin = None for subfolder in root_bin.GetSubFolderList(): if subfolder.GetName() in name: created_bin = subfolder if not created_bin: new_folder = media_pool.AddSubFolder(root_bin, name) media_pool.SetCurrentFolder(new_folder) else: media_pool.SetCurrentFolder(created_bin) return media_pool.GetCurrentFolder() def remove_media_pool_item(media_pool_item: object) -> bool: media_pool = get_current_project().GetMediaPool() return media_pool.DeleteClips([media_pool_item]) def create_media_pool_item( files: list, root: object = None, ) -> object: """ Create media pool item. Args: files (list[str]): list of absolute paths to files root (resolve.Folder)[optional]: root folder / bin object Returns: object: resolve.MediaPoolItem """ # get all variables media_pool = get_current_project().GetMediaPool() root_bin = root or media_pool.GetRootFolder() # make sure files list is not empty and first available file exists filepath = next((f for f in files if os.path.isfile(f)), None) if not filepath: raise FileNotFoundError("No file found in input files list") # try to search in bin if the clip does not exist existing_mpi = get_media_pool_item(filepath, root_bin) if existing_mpi: return existing_mpi # add all data in folder to media pool media_pool_items = media_pool.ImportMedia(files) return media_pool_items.pop() if media_pool_items else False def get_media_pool_item(filepath, root: object = None) -> object: """ Return clip if found in folder with use of input file path. Args: filepath (str): absolute path to a file root (resolve.Folder)[optional]: root folder / bin object Returns: object: resolve.MediaPoolItem """ media_pool = get_current_project().GetMediaPool() root = root or media_pool.GetRootFolder() fname = os.path.basename(filepath) for _mpi in root.GetClipList(): _mpi_name = _mpi.GetClipProperty("File Name") _mpi_name = get_reformated_path(_mpi_name, first=True) if fname in _mpi_name: return _mpi return None def create_timeline_item( media_pool_item: object, timeline: object = None, timeline_in: int = None, source_start: int = None, source_end: int = None, ) -> object: """ Add media pool item to current or defined timeline. Args: media_pool_item (resolve.MediaPoolItem): resolve's object timeline (Optional[resolve.Timeline]): resolve's object timeline_in (Optional[int]): timeline input frame (sequence frame) source_start (Optional[int]): media source input frame (sequence frame) source_end (Optional[int]): media source output frame (sequence frame) Returns: object: resolve.TimelineItem """ # get all variables project = get_current_project() media_pool = project.GetMediaPool() _clip_property = media_pool_item.GetClipProperty clip_name = _clip_property("File Name") timeline = timeline or get_current_timeline() # timing variables if all([timeline_in, source_start, source_end]): fps = timeline.GetSetting("timelineFrameRate") duration = source_end - source_start timecode_in = frames_to_timecode(timeline_in, fps) timecode_out = frames_to_timecode(timeline_in + duration, fps) else: timecode_in = None timecode_out = None # if timeline was used then switch it to current timeline with maintain_current_timeline(timeline): # Add input mediaPoolItem to clip data clip_data = { "mediaPoolItem": media_pool_item, } if source_start: clip_data["startFrame"] = source_start if source_end: clip_data["endFrame"] = source_end if timecode_in: clip_data["recordFrame"] = timeline_in # add to timeline media_pool.AppendToTimeline([clip_data]) output_timeline_item = get_timeline_item( media_pool_item, timeline) assert output_timeline_item, AssertionError(( "Clip name '{}' was't created on the timeline: '{}' \n\n" "Please check if correct track position is activated, \n" "or if a clip is not already at the timeline in \n" "position: '{}' out: '{}'. \n\n" "Clip data: {}" ).format( clip_name, timeline.GetName(), timecode_in, timecode_out, clip_data )) return output_timeline_item def get_timeline_item(media_pool_item: object, timeline: object = None) -> object: """ Returns clips related to input mediaPoolItem. Args: media_pool_item (resolve.MediaPoolItem): resolve's object timeline (resolve.Timeline)[optional]: resolve's object Returns: object: resolve.TimelineItem """ _clip_property = media_pool_item.GetClipProperty clip_name = _clip_property("File Name") output_timeline_item = None timeline = timeline or get_current_timeline() with maintain_current_timeline(timeline): # search the timeline for the added clip for _ti_data in get_current_timeline_items(): _ti_clip = _ti_data["clip"]["item"] _ti_clip_property = _ti_clip.GetMediaPoolItem().GetClipProperty if clip_name in _ti_clip_property("File Name"): output_timeline_item = _ti_clip return output_timeline_item def get_video_track_names() -> list: tracks = list() track_type = "video" timeline = get_current_timeline() # get all tracks count filtered by track type selected_track_count = timeline.GetTrackCount(track_type) # loop all tracks and get items track_index: int for track_index in range(1, (int(selected_track_count) + 1)): track_name = timeline.GetTrackName("video", track_index) tracks.append(track_name) return tracks def get_current_timeline_items( filter: bool = False, track_type: str = None, track_name: str = None, selecting_color: str = None) -> list: """ Gets all available current timeline track items """ track_type = track_type or "video" selecting_color = selecting_color or "Chocolate" project = get_current_project() # get timeline anyhow timeline = ( get_current_timeline() or get_any_timeline() or get_new_timeline() ) selected_clips = [] # get all tracks count filtered by track type selected_track_count = timeline.GetTrackCount(track_type) # loop all tracks and get items _clips = {} for track_index in range(1, (int(selected_track_count) + 1)): _track_name = timeline.GetTrackName(track_type, track_index) # filter out all unmathed track names if track_name and _track_name not in track_name: continue timeline_items = timeline.GetItemListInTrack( track_type, track_index) _clips[track_index] = timeline_items _data = { "project": project, "timeline": timeline, "track": { "name": _track_name, "index": track_index, "type": track_type} } # get track item object and its color for clip_index, ti in enumerate(_clips[track_index]): data = _data.copy() data["clip"] = { "item": ti, "index": clip_index } ti_color = ti.GetClipColor() if filter and selecting_color in ti_color or not filter: selected_clips.append(data) return selected_clips def get_pype_timeline_item_by_name(name: str) -> object: """Get timeline item by name. Args: name (str): name of timeline item Returns: object: resolve.TimelineItem """ for _ti_data in get_current_timeline_items(): _ti_clip = _ti_data["clip"]["item"] tag_data = get_timeline_item_pype_tag(_ti_clip) tag_name = tag_data.get("namespace") if not tag_name: continue if tag_name in name: return _ti_clip return None def get_timeline_item_pype_tag(timeline_item): """ Get openpype track item tag created by creator or loader plugin. Attributes: trackItem (resolve.TimelineItem): resolve object Returns: dict: openpype tag data """ return_tag = None if self.pype_marker_workflow: return_tag = get_pype_marker(timeline_item) else: media_pool_item = timeline_item.GetMediaPoolItem() # get all tags from track item _tags = media_pool_item.GetMetadata() if not _tags: return None for key, data in _tags.items(): # return only correct tag defined by global name if key in self.pype_tag_name: return_tag = json.loads(data) return return_tag def set_timeline_item_pype_tag(timeline_item, data=None): """ Set openpype track item tag to input timeline_item. Attributes: trackItem (resolve.TimelineItem): resolve api object Returns: dict: json loaded data """ data = data or dict() # get available openpype tag if any tag_data = get_timeline_item_pype_tag(timeline_item) if self.pype_marker_workflow: # delete tag as it is not updatable if tag_data: delete_pype_marker(timeline_item) tag_data.update(data) set_pype_marker(timeline_item, tag_data) else: if tag_data: media_pool_item = timeline_item.GetMediaPoolItem() # it not tag then create one tag_data.update(data) media_pool_item.SetMetadata( self.pype_tag_name, json.dumps(tag_data)) else: tag_data = data # if openpype tag available then update with input data # add it to the input track item timeline_item.SetMetadata(self.pype_tag_name, json.dumps(tag_data)) return tag_data def imprint(timeline_item, data=None): """ Adding `Avalon data` into a hiero track item tag. Also including publish attribute into tag. Arguments: timeline_item (hiero.core.TrackItem): hiero track item object data (dict): Any data which needs to be imprinted Examples: data = { 'asset': 'sq020sh0280', 'family': 'render', 'subset': 'subsetMain' } """ data = data or {} set_timeline_item_pype_tag(timeline_item, data) # add publish attribute set_publish_attribute(timeline_item, True) def set_publish_attribute(timeline_item, value): """ Set Publish attribute in input Tag object Attribute: tag (hiero.core.Tag): a tag object value (bool): True or False """ tag_data = get_timeline_item_pype_tag(timeline_item) tag_data["publish"] = value # set data to the publish attribute set_timeline_item_pype_tag(timeline_item, tag_data) def get_publish_attribute(timeline_item): """ Get Publish attribute from input Tag object Attribute: tag (hiero.core.Tag): a tag object value (bool): True or False """ tag_data = get_timeline_item_pype_tag(timeline_item) return tag_data["publish"] def set_pype_marker(timeline_item, tag_data): source_start = timeline_item.GetLeftOffset() item_duration = timeline_item.GetDuration() frame = int(source_start + (item_duration / 2)) # marker attributes frameId = (frame / 10) * 10 color = self.pype_marker_color name = self.pype_marker_name note = json.dumps(tag_data) duration = (self.pype_marker_duration / 10) * 10 timeline_item.AddMarker( frameId, color, name, note, duration ) def get_pype_marker(timeline_item): timeline_item_markers = timeline_item.GetMarkers() for marker_frame, marker in timeline_item_markers.items(): color = marker["color"] name = marker["name"] if name == self.pype_marker_name and color == self.pype_marker_color: note = marker["note"] self.temp_marker_frame = marker_frame return json.loads(note) return dict() def delete_pype_marker(timeline_item): timeline_item.DeleteMarkerAtFrame(self.temp_marker_frame) self.temp_marker_frame = None def create_compound_clip(clip_data, name, folder): """ Convert timeline object into nested timeline object Args: clip_data (dict): timeline item object packed into dict with project, timeline (sequence) folder (resolve.MediaPool.Folder): media pool folder object, name (str): name for compound clip Returns: resolve.MediaPoolItem: media pool item with compound clip timeline(cct) """ # get basic objects form data project = clip_data["project"] timeline = clip_data["timeline"] clip = clip_data["clip"] # get details of objects clip_item = clip["item"] mp = project.GetMediaPool() # get clip attributes clip_attributes = get_clip_attributes(clip_item) mp_item = clip_item.GetMediaPoolItem() _mp_props = mp_item.GetClipProperty mp_first_frame = int(_mp_props("Start")) mp_last_frame = int(_mp_props("End")) # initialize basic source timing for otio ci_l_offset = clip_item.GetLeftOffset() ci_duration = clip_item.GetDuration() rate = float(_mp_props("FPS")) # source rational times mp_in_rc = opentime.RationalTime((ci_l_offset), rate) mp_out_rc = opentime.RationalTime((ci_l_offset + ci_duration - 1), rate) # get frame in and out for clip swapping in_frame = opentime.to_frames(mp_in_rc) out_frame = opentime.to_frames(mp_out_rc) # keep original sequence tl_origin = timeline # Set current folder to input media_pool_folder: mp.SetCurrentFolder(folder) # check if clip doesn't exist already: clips = folder.GetClipList() cct = next((c for c in clips if c.GetName() in name), None) if cct: print(f"Compound clip exists: {cct}") else: # Create empty timeline in current folder and give name: cct = mp.CreateEmptyTimeline(name) # check if clip doesn't exist already: clips = folder.GetClipList() cct = next((c for c in clips if c.GetName() in name), None) print(f"Compound clip created: {cct}") with maintain_current_timeline(cct, tl_origin): # Add input clip to the current timeline: mp.AppendToTimeline([{ "mediaPoolItem": mp_item, "startFrame": mp_first_frame, "endFrame": mp_last_frame }]) # Add collected metadata and attributes to the comound clip: if mp_item.GetMetadata(self.pype_tag_name): clip_attributes[self.pype_tag_name] = mp_item.GetMetadata( self.pype_tag_name)[self.pype_tag_name] # stringify clip_attributes = json.dumps(clip_attributes) # add attributes to metadata for k, v in mp_item.GetMetadata().items(): cct.SetMetadata(k, v) # add metadata to cct cct.SetMetadata(self.pype_tag_name, clip_attributes) # reset start timecode of the compound clip cct.SetClipProperty("Start TC", _mp_props("Start TC")) # swap clips on timeline swap_clips(clip_item, cct, in_frame, out_frame) cct.SetClipColor("Pink") return cct def swap_clips(from_clip, to_clip, to_in_frame, to_out_frame): """ Swapping clips on timeline in timelineItem It will add take and activate it to the frame range which is inputted Args: from_clip (resolve.TimelineItem) to_clip (resolve.mediaPoolItem) to_clip_name (str): name of to_clip to_in_frame (float): cut in frame, usually `GetLeftOffset()` to_out_frame (float): cut out frame, usually left offset plus duration Returns: bool: True if successfully replaced """ # copy ACES input transform from timeline clip to new media item mediapool_item_from_timeline = from_clip.GetMediaPoolItem() _idt = mediapool_item_from_timeline.GetClipProperty('IDT') to_clip.SetClipProperty('IDT', _idt) _clip_prop = to_clip.GetClipProperty to_clip_name = _clip_prop("File Name") # add clip item as take to timeline take = from_clip.AddTake( to_clip, float(to_in_frame), float(to_out_frame) ) if not take: return False for take_index in range(1, (int(from_clip.GetTakesCount()) + 1)): take_item = from_clip.GetTakeByIndex(take_index) take_mp_item = take_item["mediaPoolItem"] if to_clip_name in take_mp_item.GetName(): from_clip.SelectTakeByIndex(take_index) from_clip.FinalizeTake() return True return False def _validate_tc(x): # Validate and reformat timecode string if len(x) != 11: print('Invalid timecode. Try again.') c = ':' colonized = x[:2] + c + x[3:5] + c + x[6:8] + c + x[9:] if colonized.replace(':', '').isdigit(): print(f"_ colonized: {colonized}") return colonized else: print('Invalid timecode. Try again.') def get_pype_clip_metadata(clip): """ Get openpype metadata created by creator plugin Attributes: clip (resolve.TimelineItem): resolve's object Returns: dict: hierarchy, orig clip attributes """ mp_item = clip.GetMediaPoolItem() metadata = mp_item.GetMetadata() return metadata.get(self.pype_tag_name) def get_clip_attributes(clip): """ Collect basic attributes from resolve timeline item Args: clip (resolve.TimelineItem): timeline item object Returns: dict: all collected attributres as key: values """ mp_item = clip.GetMediaPoolItem() return { "clipIn": clip.GetStart(), "clipOut": clip.GetEnd(), "clipLeftOffset": clip.GetLeftOffset(), "clipRightOffset": clip.GetRightOffset(), "clipMarkers": clip.GetMarkers(), "clipFlags": clip.GetFlagList(), "sourceId": mp_item.GetMediaId(), "sourceProperties": mp_item.GetClipProperty() } def set_project_manager_to_folder_name(folder_name): """ Sets context of Project manager to given folder by name. Searching for folder by given name from root folder to nested. If no existing folder by name it will create one in root folder. Args: folder_name (str): name of searched folder Returns: bool: True if success Raises: Exception: Cannot create folder in root """ # initialize project manager get_project_manager() set_folder = False # go back to root folder if self.project_manager.GotoRootFolder(): log.info(f"Testing existing folder: {folder_name}") folders = _convert_resolve_list_type( self.project_manager.GetFoldersInCurrentFolder()) log.info(f"Testing existing folders: {folders}") # get me first available folder object # with the same name as in `folder_name` else return False if next((f for f in folders if f in folder_name), False): log.info(f"Found existing folder: {folder_name}") set_folder = self.project_manager.OpenFolder(folder_name) if set_folder: return True # if folder by name is not existent then create one # go back to root folder log.info(f"Folder `{folder_name}` not found and will be created") if self.project_manager.GotoRootFolder(): try: # create folder by given name self.project_manager.CreateFolder(folder_name) self.project_manager.OpenFolder(folder_name) return True except NameError as e: log.error((f"Folder with name `{folder_name}` cannot be created!" f"Error: {e}")) return False def _convert_resolve_list_type(resolve_list): """ Resolve is using indexed dictionary as list type. `{1.0: 'vaule'}` This will convert it to normal list class """ assert isinstance(resolve_list, dict), ( "Input argument should be dict() type") return [resolve_list[i] for i in sorted(resolve_list.keys())] def create_otio_time_range_from_timeline_item_data(timeline_item_data): timeline_item = timeline_item_data["clip"]["item"] project = timeline_item_data["project"] timeline = timeline_item_data["timeline"] timeline_start = timeline.GetStartFrame() frame_start = int(timeline_item.GetStart() - timeline_start) frame_duration = int(timeline_item.GetDuration()) fps = project.GetSetting("timelineFrameRate") return otio_export.create_otio_time_range( frame_start, frame_duration, fps) def get_otio_clip_instance_data(otio_timeline, timeline_item_data): """ Return otio objects for timeline, track and clip Args: timeline_item_data (dict): timeline_item_data from list returned by resolve.get_current_timeline_items() otio_timeline (otio.schema.Timeline): otio object Returns: dict: otio clip object """ timeline_item = timeline_item_data["clip"]["item"] track_name = timeline_item_data["track"]["name"] timeline_range = create_otio_time_range_from_timeline_item_data( timeline_item_data) for otio_clip in otio_timeline.each_clip(): track_name = otio_clip.parent().name parent_range = otio_clip.range_in_parent() if track_name not in track_name: continue if otio_clip.name not in timeline_item.GetName(): continue if is_overlapping_otio_ranges( parent_range, timeline_range, strict=True): # add pypedata marker to otio_clip metadata for marker in otio_clip.markers: if self.pype_marker_name in marker.name: otio_clip.metadata.update(marker.metadata) return {"otioClip": otio_clip} return None def get_reformated_path(path, padded=False, first=False): """ Return fixed python expression path Args: path (str): path url or simple file name Returns: type: string with reformated path Example: get_reformated_path("plate.[0001-1008].exr") > plate.%04d.exr """ first_frame_pattern = re.compile(r"\[(\d+)\-\d+\]") if "[" in path: padding_pattern = r"(\d+)(?=-)" padding = len(re.findall(padding_pattern, path).pop()) num_pattern = r"(\[\d+\-\d+\])" if padded: path = re.sub(num_pattern, f"%0{padding}d", path) elif first: first_frame = re.findall(first_frame_pattern, path, flags=0) if len(first_frame) >= 1: first_frame = first_frame[0] path = re.sub(num_pattern, first_frame, path) else: path = re.sub(num_pattern, "%d", path) return path ================================================ FILE: openpype/hosts/resolve/api/menu.py ================================================ import os import sys from qtpy import QtWidgets, QtCore, QtGui from openpype.tools.utils import host_tools from openpype.pipeline import registered_host MENU_LABEL = os.environ["AVALON_LABEL"] def load_stylesheet(): path = os.path.join(os.path.dirname(__file__), "menu_style.qss") if not os.path.exists(path): print("Unable to load stylesheet, file not found in resources") return "" with open(path, "r") as file_stream: stylesheet = file_stream.read() return stylesheet class Spacer(QtWidgets.QWidget): def __init__(self, height, *args, **kwargs): super(Spacer, self).__init__(*args, **kwargs) self.setFixedHeight(height) real_spacer = QtWidgets.QWidget(self) real_spacer.setObjectName("Spacer") real_spacer.setFixedHeight(height) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(real_spacer) self.setLayout(layout) class OpenPypeMenu(QtWidgets.QWidget): def __init__(self, *args, **kwargs): super(OpenPypeMenu, self).__init__(*args, **kwargs) self.setObjectName(f"{MENU_LABEL}Menu") self.setWindowFlags( QtCore.Qt.Window | QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint | QtCore.Qt.WindowCloseButtonHint | QtCore.Qt.WindowStaysOnTopHint ) self.setWindowTitle(f"{MENU_LABEL}") save_current_btn = QtWidgets.QPushButton("Save current file", self) workfiles_btn = QtWidgets.QPushButton("Workfiles ...", self) create_btn = QtWidgets.QPushButton("Create ...", self) publish_btn = QtWidgets.QPushButton("Publish ...", self) load_btn = QtWidgets.QPushButton("Load ...", self) inventory_btn = QtWidgets.QPushButton("Manager ...", self) subsetm_btn = QtWidgets.QPushButton("Subset Manager ...", self) libload_btn = QtWidgets.QPushButton("Library ...", self) experimental_btn = QtWidgets.QPushButton( "Experimental tools ...", self ) # rename_btn = QtWidgets.QPushButton("Rename", self) # set_colorspace_btn = QtWidgets.QPushButton( # "Set colorspace from presets", self # ) # reset_resolution_btn = QtWidgets.QPushButton( # "Set Resolution from presets", self # ) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(10, 20, 10, 20) layout.addWidget(save_current_btn) layout.addWidget(Spacer(15, self)) layout.addWidget(workfiles_btn) layout.addWidget(create_btn) layout.addWidget(publish_btn) layout.addWidget(load_btn) layout.addWidget(inventory_btn) layout.addWidget(subsetm_btn) layout.addWidget(Spacer(15, self)) layout.addWidget(libload_btn) # layout.addWidget(Spacer(15, self)) # layout.addWidget(rename_btn) # layout.addWidget(Spacer(15, self)) # layout.addWidget(set_colorspace_btn) # layout.addWidget(reset_resolution_btn) layout.addWidget(Spacer(15, self)) layout.addWidget(experimental_btn) self.setLayout(layout) save_current_btn.clicked.connect(self.on_save_current_clicked) save_current_btn.setShortcut(QtGui.QKeySequence.Save) workfiles_btn.clicked.connect(self.on_workfile_clicked) create_btn.clicked.connect(self.on_create_clicked) publish_btn.clicked.connect(self.on_publish_clicked) load_btn.clicked.connect(self.on_load_clicked) inventory_btn.clicked.connect(self.on_inventory_clicked) subsetm_btn.clicked.connect(self.on_subsetm_clicked) libload_btn.clicked.connect(self.on_libload_clicked) # rename_btn.clicked.connect(self.on_rename_clicked) # set_colorspace_btn.clicked.connect(self.on_set_colorspace_clicked) # reset_resolution_btn.clicked.connect(self.on_set_resolution_clicked) experimental_btn.clicked.connect(self.on_experimental_clicked) def on_save_current_clicked(self): host = registered_host() current_file = host.get_current_workfile() if not current_file: print("Current project is not saved. " "Please save once first via workfiles tool.") host_tools.show_workfiles() return print(f"Saving current file to: {current_file}") host.save_workfile(current_file) def on_workfile_clicked(self): print("Clicked Workfile") host_tools.show_workfiles() def on_create_clicked(self): print("Clicked Create") host_tools.show_creator() def on_publish_clicked(self): print("Clicked Publish") host_tools.show_publish(parent=None) def on_load_clicked(self): print("Clicked Load") host_tools.show_loader(use_context=True) def on_inventory_clicked(self): print("Clicked Inventory") host_tools.show_scene_inventory() def on_subsetm_clicked(self): print("Clicked Subset Manager") host_tools.show_subset_manager() def on_libload_clicked(self): print("Clicked Library") host_tools.show_library_loader() def on_rename_clicked(self): print("Clicked Rename") def on_set_colorspace_clicked(self): print("Clicked Set Colorspace") def on_set_resolution_clicked(self): print("Clicked Set Resolution") def on_experimental_clicked(self): host_tools.show_experimental_tools_dialog() def launch_pype_menu(): app = QtWidgets.QApplication(sys.argv) pype_menu = OpenPypeMenu() stylesheet = load_stylesheet() pype_menu.setStyleSheet(stylesheet) pype_menu.show() sys.exit(app.exec_()) ================================================ FILE: openpype/hosts/resolve/api/menu_style.qss ================================================ QWidget { background-color: #282828; border-radius: 3; font-size: 13px; } QComboBox { border: 1px solid #090909; background-color: #201f1f; color: #ffffff; } QComboBox QAbstractItemView { color: white; } QPushButton { border: 1px solid #090909; background-color: #201f1f; color: #ffffff; padding: 5; } QPushButton:focus { background-color: "#171717"; color: #d0d0d0; } QPushButton:hover { background-color: "#171717"; color: #e64b3d; } QSpinBox { border: 1px solid #090909; background-color: #201f1f; color: #ffffff; padding: 2; max-width: 8em; qproperty-alignment: AlignCenter; } QLineEdit { border: 1px solid #090909; border-radius: 3px; background-color: #201f1f; color: #ffffff; padding: 2; min-width: 10em; qproperty-alignment: AlignCenter; } #OpenPypeMenu { qproperty-alignment: AlignLeft; min-width: 10em; border: 1px solid #fef9ef; } QVBoxLayout { background-color: #282828; } #Divider { border: 1px solid #090909; background-color: #585858; } QLabel { color: #77776b; } ================================================ FILE: openpype/hosts/resolve/api/pipeline.py ================================================ """ Basic avalon integration """ import os import contextlib from collections import OrderedDict from pyblish import api as pyblish from openpype.lib import Logger from openpype.pipeline import ( schema, register_loader_plugin_path, register_creator_plugin_path, AVALON_CONTAINER_ID, ) from openpype.host import ( HostBase, IWorkfileHost, ILoadHost ) from . import lib from .utils import get_resolve_module from .workio import ( open_file, save_file, file_extensions, has_unsaved_changes, work_root, current_file ) log = Logger.get_logger(__name__) HOST_DIR = os.path.dirname(os.path.abspath(os.path.dirname(__file__))) PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") AVALON_CONTAINERS = ":AVALON_CONTAINERS" class ResolveHost(HostBase, IWorkfileHost, ILoadHost): name = "resolve" def install(self): """Install resolve-specific functionality of avalon-core. This is where you install menus and register families, data and loaders into resolve. It is called automatically when installing via `api.install(resolve)`. See the Maya equivalent for inspiration on how to implement this. """ log.info("openpype.hosts.resolve installed") pyblish.register_host(self.name) pyblish.register_plugin_path(PUBLISH_PATH) print("Registering DaVinci Resolve plug-ins..") register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) # register callback for switching publishable pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) get_resolve_module() def open_workfile(self, filepath): return open_file(filepath) def save_workfile(self, filepath=None): return save_file(filepath) def work_root(self, session): return work_root(session) def get_current_workfile(self): return current_file() def workfile_has_unsaved_changes(self): return has_unsaved_changes() def get_workfile_extensions(self): return file_extensions() def get_containers(self): return ls() def containerise(timeline_item, name, namespace, context, loader=None, data=None): """Bundle Hiero's object into an assembly and imprint it with metadata Containerisation enables a tracking of version, author and origin for loaded assets. Arguments: timeline_item (hiero.core.TrackItem): object to imprint as container name (str): Name of resulting assembly namespace (str): Namespace under which to host container context (dict): Asset information loader (str, optional): Name of node used to produce this container. Returns: timeline_item (hiero.core.TrackItem): containerised object """ data_imprint = OrderedDict({ "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": str(name), "namespace": str(namespace), "loader": str(loader), "representation": str(context["representation"]["_id"]), }) if data: data_imprint.update(data) lib.set_timeline_item_pype_tag(timeline_item, data_imprint) return timeline_item def ls(): """List available containers. This function is used by the Container Manager in Nuke. You'll need to implement a for-loop that then *yields* one Container at a time. See the `container.json` schema for details on how it should look, and the Maya equivalent, which is in `avalon.maya.pipeline` """ # get all track items from current timeline all_timeline_items = lib.get_current_timeline_items(filter=False) for timeline_item_data in all_timeline_items: timeline_item = timeline_item_data["clip"]["item"] container = parse_container(timeline_item) if container: yield container def parse_container(timeline_item, validate=True): """Return container data from timeline_item's openpype tag. Args: timeline_item (hiero.core.TrackItem): A containerised track item. validate (bool)[optional]: validating with avalon scheme Returns: dict: The container schema data for input containerized track item. """ # convert tag metadata to normal keys names data = lib.get_timeline_item_pype_tag(timeline_item) if validate and data and data.get("schema"): schema.validate(data) if not isinstance(data, dict): return # If not all required data return the empty container required = ['schema', 'id', 'name', 'namespace', 'loader', 'representation'] if not all(key in data for key in required): return container = {key: data[key] for key in required} container["objectName"] = timeline_item.GetName() # Store reference to the node object container["_timeline_item"] = timeline_item return container def update_container(timeline_item, data=None): """Update container data to input timeline_item's openpype tag. Args: timeline_item (hiero.core.TrackItem): A containerised track item. data (dict)[optional]: dictionery with data to be updated Returns: bool: True if container was updated correctly """ data = data or dict() container = lib.get_timeline_item_pype_tag(timeline_item) for _key, _value in container.items(): try: container[_key] = data[_key] except KeyError: pass log.info("Updating container: `{}`".format(timeline_item)) return bool(lib.set_timeline_item_pype_tag(timeline_item, container)) @contextlib.contextmanager def maintained_selection(): """Maintain selection during context Example: >>> with maintained_selection(): ... node['selected'].setValue(True) >>> print(node['selected'].value()) False """ try: # do the operation yield finally: pass def reset_selection(): """Deselect all selected nodes """ pass def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle node passthrough states on instance toggles.""" log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( instance, old_value, new_value)) from openpype.hosts.resolve.api import ( set_publish_attribute ) # Whether instances should be passthrough based on new value timeline_item = instance.data["item"] set_publish_attribute(timeline_item, new_value) def remove_instance(instance): """Remove instance marker from track item.""" instance_id = instance.get("uuid") selected_timeline_items = lib.get_current_timeline_items( filter=True, selecting_color=lib.publish_clip_color) found_ti = None for timeline_item_data in selected_timeline_items: timeline_item = timeline_item_data["clip"]["item"] # get openpype tag data tag_data = lib.get_timeline_item_pype_tag(timeline_item) _ti_id = tag_data.get("uuid") if _ti_id == instance_id: found_ti = timeline_item break if found_ti is None: return # removing instance by marker color print(f"Removing instance: {found_ti.GetName()}") found_ti.DeleteMarkersByColor(lib.pype_marker_color) def list_instances(): """List all created instances from current workfile.""" listed_instances = [] selected_timeline_items = lib.get_current_timeline_items( filter=True, selecting_color=lib.publish_clip_color) for timeline_item_data in selected_timeline_items: timeline_item = timeline_item_data["clip"]["item"] ti_name = timeline_item.GetName().split(".")[0] # get openpype tag data tag_data = lib.get_timeline_item_pype_tag(timeline_item) if tag_data: asset = tag_data.get("asset") subset = tag_data.get("subset") tag_data["label"] = f"{ti_name} [{asset}-{subset}]" listed_instances.append(tag_data) return listed_instances ================================================ FILE: openpype/hosts/resolve/api/plugin.py ================================================ import re import uuid import copy import qargparse from qtpy import QtWidgets, QtCore from openpype.settings import get_current_project_settings from openpype.pipeline import ( LegacyCreator, LoaderPlugin, Anatomy ) from . import lib from .menu import load_stylesheet class CreatorWidget(QtWidgets.QDialog): # output items items = {} def __init__(self, name, info, ui_inputs, parent=None): super(CreatorWidget, self).__init__(parent) self.setObjectName(name) self.setWindowFlags( QtCore.Qt.Window | QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint | QtCore.Qt.WindowCloseButtonHint | QtCore.Qt.WindowStaysOnTopHint ) self.setWindowTitle(name or "OpenPype Creator Input") self.resize(500, 700) # Where inputs and labels are set self.content_widget = [QtWidgets.QWidget(self)] top_layout = QtWidgets.QFormLayout(self.content_widget[0]) top_layout.setObjectName("ContentLayout") top_layout.addWidget(Spacer(5, self)) # first add widget tag line top_layout.addWidget(QtWidgets.QLabel(info)) # main dynamic layout self.scroll_area = QtWidgets.QScrollArea(self, widgetResizable=True) self.scroll_area.setVerticalScrollBarPolicy( QtCore.Qt.ScrollBarAsNeeded) self.scroll_area.setVerticalScrollBarPolicy( QtCore.Qt.ScrollBarAlwaysOn) self.scroll_area.setHorizontalScrollBarPolicy( QtCore.Qt.ScrollBarAlwaysOff) self.scroll_area.setWidgetResizable(True) self.content_widget.append(self.scroll_area) scroll_widget = QtWidgets.QWidget(self) in_scroll_area = QtWidgets.QVBoxLayout(scroll_widget) self.content_layout = [in_scroll_area] # add preset data into input widget layout self.items = self.populate_widgets(ui_inputs) self.scroll_area.setWidget(scroll_widget) # Confirmation buttons btns_widget = QtWidgets.QWidget(self) btns_layout = QtWidgets.QHBoxLayout(btns_widget) cancel_btn = QtWidgets.QPushButton("Cancel") btns_layout.addWidget(cancel_btn) ok_btn = QtWidgets.QPushButton("Ok") btns_layout.addWidget(ok_btn) # Main layout of the dialog main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(10, 10, 10, 10) main_layout.setSpacing(0) # adding content widget for w in self.content_widget: main_layout.addWidget(w) main_layout.addWidget(btns_widget) ok_btn.clicked.connect(self._on_ok_clicked) cancel_btn.clicked.connect(self._on_cancel_clicked) stylesheet = load_stylesheet() self.setStyleSheet(stylesheet) def _on_ok_clicked(self): self.result = self.value(self.items) self.close() def _on_cancel_clicked(self): self.result = None self.close() def value(self, data, new_data=None): new_data = new_data or {} for k, v in data.items(): new_data[k] = { "target": None, "value": None } if v["type"] == "dict": new_data[k]["target"] = v["target"] new_data[k]["value"] = self.value(v["value"]) if v["type"] == "section": new_data.pop(k) new_data = self.value(v["value"], new_data) elif getattr(v["value"], "currentText", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].currentText() elif getattr(v["value"], "isChecked", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].isChecked() elif getattr(v["value"], "value", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].value() elif getattr(v["value"], "text", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].text() return new_data def camel_case_split(self, text): matches = re.finditer( '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', text) return " ".join([str(m.group(0)).capitalize() for m in matches]) def create_row(self, layout, type, text, **kwargs): # get type attribute from qwidgets attr = getattr(QtWidgets, type) # convert label text to normal capitalized text with spaces label_text = self.camel_case_split(text) # assign the new text to label widget label = QtWidgets.QLabel(label_text) label.setObjectName("LineLabel") # create attribute name text strip of spaces attr_name = text.replace(" ", "") # create attribute and assign default values setattr( self, attr_name, attr(parent=self)) # assign the created attribute to variable item = getattr(self, attr_name) for func, val in kwargs.items(): if getattr(item, func): func_attr = getattr(item, func) if isinstance(val, tuple): func_attr(*val) else: func_attr(val) # add to layout layout.addRow(label, item) return item def populate_widgets(self, data, content_layout=None): """ Populate widget from input dict. Each plugin has its own set of widget rows defined in dictionary each row values should have following keys: `type`, `target`, `label`, `order`, `value` and optionally also `toolTip`. Args: data (dict): widget rows or organized groups defined by types `dict` or `section` content_layout (QtWidgets.QFormLayout)[optional]: used when nesting Returns: dict: redefined data dict updated with created widgets """ content_layout = content_layout or self.content_layout[-1] # fix order of process by defined order value ordered_keys = list(data.keys()) for k, v in data.items(): try: # try removing a key from index which should # be filled with new ordered_keys.pop(v["order"]) except IndexError: pass # add key into correct order ordered_keys.insert(v["order"], k) # process ordered for k in ordered_keys: v = data[k] tool_tip = v.get("toolTip", "") if v["type"] == "dict": # adding spacer between sections self.content_layout.append(QtWidgets.QWidget(self)) content_layout.addWidget(self.content_layout[-1]) self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) headline.addWidget(Spacer(20, self)) headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label self.content_layout.append(QtWidgets.QWidget(self)) self.content_layout[-1].setObjectName("sectionContent") nested_content_layout = QtWidgets.QFormLayout( self.content_layout[-1]) nested_content_layout.setObjectName("NestedContentLayout") content_layout.addWidget(self.content_layout[-1]) # add nested key as label data[k]["value"] = self.populate_widgets( v["value"], nested_content_layout) if v["type"] == "section": # adding spacer between sections self.content_layout.append(QtWidgets.QWidget(self)) content_layout.addWidget(self.content_layout[-1]) self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) headline.addWidget(Spacer(20, self)) headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label self.content_layout.append(QtWidgets.QWidget(self)) self.content_layout[-1].setObjectName("sectionContent") nested_content_layout = QtWidgets.QFormLayout( self.content_layout[-1]) nested_content_layout.setObjectName("NestedContentLayout") content_layout.addWidget(self.content_layout[-1]) # add nested key as label data[k]["value"] = self.populate_widgets( v["value"], nested_content_layout) elif v["type"] == "QLineEdit": data[k]["value"] = self.create_row( content_layout, "QLineEdit", v["label"], setText=v["value"], setToolTip=tool_tip) elif v["type"] == "QComboBox": data[k]["value"] = self.create_row( content_layout, "QComboBox", v["label"], addItems=v["value"], setToolTip=tool_tip) elif v["type"] == "QCheckBox": data[k]["value"] = self.create_row( content_layout, "QCheckBox", v["label"], setChecked=v["value"], setToolTip=tool_tip) elif v["type"] == "QSpinBox": data[k]["value"] = self.create_row( content_layout, "QSpinBox", v["label"], setRange=(0, 99999), setValue=v["value"], setToolTip=tool_tip) return data class Spacer(QtWidgets.QWidget): def __init__(self, height, *args, **kwargs): super(self.__class__, self).__init__(*args, **kwargs) self.setFixedHeight(height) real_spacer = QtWidgets.QWidget(self) real_spacer.setObjectName("Spacer") real_spacer.setFixedHeight(height) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(real_spacer) self.setLayout(layout) class ClipLoader: active_bin = None data = {} def __init__(self, loader_obj, context, **options): """ Initialize object Arguments: loader_obj (openpype.pipeline.load.LoaderPlugin): plugin object context (dict): loader plugin context options (dict)[optional]: possible keys: projectBinPath: "path/to/binItem" """ self.__dict__.update(loader_obj.__dict__) self.context = context self.active_project = lib.get_current_project() # try to get value from options or evaluate key value for `handles` self.with_handles = options.get("handles") is True # try to get value from options or evaluate key value for `load_to` self.new_timeline = ( options.get("newTimeline") or options.get("load_to") == "New timeline" ) # try to get value from options or evaluate key value for `load_how` self.sequential_load = ( options.get("sequentially") or options.get("load_how") == "Sequentially in order" ) assert self._populate_data(), str( "Cannot Load selected data, look into database " "or call your supervisor") # inject asset data to representation dict self._get_asset_data() # add active components to class if self.new_timeline: loader_cls = loader_obj.__class__ if loader_cls.timeline: # if multiselection is set then use options sequence self.active_timeline = loader_cls.timeline else: # create new sequence self.active_timeline = lib.get_new_timeline( "{}_{}".format( self.data["timeline_basename"], str(uuid.uuid4())[:8] ) ) loader_cls.timeline = self.active_timeline else: self.active_timeline = lib.get_current_timeline() def _populate_data(self): """ Gets context and convert it to self.data data structure: { "name": "assetName_subsetName_representationName" "binPath": "projectBinPath", } """ # create name representation = self.context["representation"] representation_context = representation["context"] asset = str(representation_context["asset"]) subset = str(representation_context["subset"]) representation_name = str(representation_context["representation"]) self.data["clip_name"] = "_".join([ asset, subset, representation_name ]) self.data["versionData"] = self.context["version"]["data"] self.data["timeline_basename"] = "timeline_{}_{}".format( subset, representation_name) # solve project bin structure path hierarchy = str("/".join(( "Loader", representation_context["hierarchy"].replace("\\", "/"), asset ))) self.data["binPath"] = hierarchy return True def _get_asset_data(self): """ Get all available asset data joint `data` key with asset.data dict into the representation """ self.data["assetData"] = copy.deepcopy(self.context["asset"]["data"]) def load(self, files): """Load clip into timeline Arguments: files (list[str]): list of files to load into timeline """ # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) # create mediaItem in active project bin # create clip media media_pool_item = lib.create_media_pool_item( files, self.active_bin ) _clip_property = media_pool_item.GetClipProperty source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) source_duration = int(_clip_property("Frames")) # Trim clip start if slate is present if "slate" in self.data["versionData"]["families"]: source_in += 1 source_duration = source_out - source_in + 1 if not self.with_handles: # Load file without the handles of the source media # We remove the handles from the source in and source out # so that the handles are excluded in the timeline handle_start = 0 handle_end = 0 # get version data frame data from db version_data = self.data["versionData"] frame_start = version_data.get("frameStart") frame_end = version_data.get("frameEnd") # The version data usually stored the frame range + handles of the # media however certain representations may be shorter because they # exclude those handles intentionally. Unfortunately the # representation does not store that in the database currently; # so we should compensate for those cases. If the media is shorter # than the frame range specified in the database we assume it is # without handles and thus we do not need to remove the handles # from source and out if frame_start is not None and frame_end is not None: # Version has frame range data, so we can compare media length handle_start = version_data.get("handleStart", 0) handle_end = version_data.get("handleEnd", 0) frame_start_handle = frame_start - handle_start frame_end_handle = frame_end + handle_end database_frame_duration = int( frame_end_handle - frame_start_handle + 1 ) if source_duration >= database_frame_duration: source_in += handle_start source_out -= handle_end # get timeline in timeline_start = self.active_timeline.GetStartFrame() if self.sequential_load: # set timeline start frame timeline_in = int(timeline_start) else: # set timeline start frame + original clip in frame timeline_in = int( timeline_start + self.data["assetData"]["clipIn"]) # make track item from source in bin as item timeline_item = lib.create_timeline_item( media_pool_item, self.active_timeline, timeline_in, source_in, source_out, ) print("Loading clips: `{}`".format(self.data["clip_name"])) return timeline_item def update(self, timeline_item, files): # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) # create mediaItem in active project bin # create clip media media_pool_item = lib.create_media_pool_item( files, self.active_bin ) _clip_property = media_pool_item.GetClipProperty # Read trimming from timeline item timeline_item_in = timeline_item.GetLeftOffset() timeline_item_len = timeline_item.GetDuration() timeline_item_out = timeline_item_in + timeline_item_len lib.swap_clips( timeline_item, media_pool_item, timeline_item_in, timeline_item_out ) print("Loading clips: `{}`".format(self.data["clip_name"])) return timeline_item class TimelineItemLoader(LoaderPlugin): """A basic SequenceLoader for Resolve This will implement the basic behavior for a loader to inherit from that will containerize the reference and will implement the `remove` and `update` logic. """ options = [ qargparse.Boolean( "handles", label="Include handles", default=0, help="Load with handles or without?" ), qargparse.Choice( "load_to", label="Where to load clips", items=[ "Current timeline", "New timeline" ], default=0, help="Where do you want clips to be loaded?" ), qargparse.Choice( "load_how", label="How to load clips", items=[ "Original timing", "Sequentially in order" ], default="Original timing", help="Would you like to place it at original timing?" ) ] def load( self, context, name=None, namespace=None, options=None ): pass def update(self, container, representation): """Update an existing `container` """ pass def remove(self, container): """Remove an existing `container` """ pass class Creator(LegacyCreator): """Creator class wrapper """ marker_color = "Purple" def __init__(self, *args, **kwargs): super(Creator, self).__init__(*args, **kwargs) resolve_p_settings = get_current_project_settings().get("resolve") self.presets = {} if resolve_p_settings: self.presets = resolve_p_settings["create"].get( self.__class__.__name__, {}) # adding basic current context resolve objects self.project = lib.get_current_project() self.timeline = lib.get_current_timeline() if (self.options or {}).get("useSelection"): self.selected = lib.get_current_timeline_items(filter=True) else: self.selected = lib.get_current_timeline_items(filter=False) self.widget = CreatorWidget class PublishClip: """ Convert a track item to publishable instance Args: timeline_item (hiero.core.TrackItem): hiero track item object kwargs (optional): additional data needed for rename=True (presets) Returns: hiero.core.TrackItem: hiero track item object with openpype tag """ vertical_clip_match = {} tag_data = {} types = { "shot": "shot", "folder": "folder", "episode": "episode", "sequence": "sequence", "track": "sequence", } # parents search pattern parents_search_pattern = r"\{([a-z]*?)\}" # default templates for non-ui use rename_default = False hierarchy_default = "{_folder_}/{_sequence_}/{_track_}" clip_name_default = "shot_{_trackIndex_:0>3}_{_clipIndex_:0>4}" subset_name_default = "" review_track_default = "< none >" subset_family_default = "plate" count_from_default = 10 count_steps_default = 10 vertical_sync_default = False driving_layer_default = "" def __init__(self, cls, timeline_item_data, **kwargs): # populate input cls attribute onto self.[attr] self.__dict__.update(cls.__dict__) # get main parent objects self.timeline_item_data = timeline_item_data self.timeline_item = timeline_item_data["clip"]["item"] timeline_name = timeline_item_data["timeline"].GetName() self.timeline_name = str(timeline_name).replace(" ", "_") # track item (clip) main attributes self.ti_name = self.timeline_item.GetName() self.ti_index = int(timeline_item_data["clip"]["index"]) # get track name and index track_name = timeline_item_data["track"]["name"] self.track_name = str(track_name).replace(" ", "_") self.track_index = int(timeline_item_data["track"]["index"]) # adding tag.family into tag if kwargs.get("avalon"): self.tag_data.update(kwargs["avalon"]) # adding ui inputs if any self.ui_inputs = kwargs.get("ui_inputs", {}) # adding media pool folder if any self.mp_folder = kwargs.get("mp_folder") # populate default data before we get other attributes self._populate_timeline_item_default_data() # use all populated default data to create all important attributes self._populate_attributes() # create parents with correct types self._create_parents() def convert(self): # solve track item data and add them to tag data self._convert_to_tag_data() # if track name is in review track name and also if driving track name # is not in review track name: skip tag creation if (self.track_name in self.review_layer) and ( self.driving_layer not in self.review_layer): return # deal with clip name new_name = self.tag_data.pop("newClipName") if self.rename: self.tag_data["asset_name"] = new_name else: self.tag_data["asset_name"] = self.ti_name # AYON unique identifier folder_path = "/{}/{}".format( self.tag_data["hierarchy"], self.tag_data["asset_name"] ) self.tag_data["folder_path"] = folder_path # create new name for track item if not lib.pype_marker_workflow: # create compound clip workflow lib.create_compound_clip( self.timeline_item_data, self.tag_data["asset_name"], self.mp_folder ) # add timeline_item_data selection to tag self.tag_data.update({ "track_data": self.timeline_item_data["track"] }) # create openpype tag on timeline_item and add data lib.imprint(self.timeline_item, self.tag_data) return self.timeline_item def _populate_timeline_item_default_data(self): """ Populate default formatting data from track item. """ self.timeline_item_default_data = { "_folder_": "shots", "_sequence_": self.timeline_name, "_track_": self.track_name, "_clip_": self.ti_name, "_trackIndex_": self.track_index, "_clipIndex_": self.ti_index } def _populate_attributes(self): """ Populate main object attributes. """ # track item frame range and parent track name for vertical sync check self.clip_in = int(self.timeline_item.GetStart()) self.clip_out = int(self.timeline_item.GetEnd()) # define ui inputs if non gui mode was used self.shot_num = self.ti_index # ui_inputs data or default values if gui was not used self.rename = self.ui_inputs.get( "clipRename", {}).get("value") or self.rename_default self.clip_name = self.ui_inputs.get( "clipName", {}).get("value") or self.clip_name_default self.hierarchy = self.ui_inputs.get( "hierarchy", {}).get("value") or self.hierarchy_default self.hierarchy_data = self.ui_inputs.get( "hierarchyData", {}).get("value") or \ self.timeline_item_default_data.copy() self.count_from = self.ui_inputs.get( "countFrom", {}).get("value") or self.count_from_default self.count_steps = self.ui_inputs.get( "countSteps", {}).get("value") or self.count_steps_default self.subset_name = self.ui_inputs.get( "subsetName", {}).get("value") or self.subset_name_default self.subset_family = self.ui_inputs.get( "subsetFamily", {}).get("value") or self.subset_family_default self.vertical_sync = self.ui_inputs.get( "vSyncOn", {}).get("value") or self.vertical_sync_default self.driving_layer = self.ui_inputs.get( "vSyncTrack", {}).get("value") or self.driving_layer_default self.review_track = self.ui_inputs.get( "reviewTrack", {}).get("value") or self.review_track_default # build subset name from layer name if self.subset_name == "": self.subset_name = self.track_name # create subset for publishing self.subset = self.subset_family + self.subset_name.capitalize() def _replace_hash_to_expression(self, name, text): """ Replace hash with number in correct padding. """ _spl = text.split("#") _len = (len(_spl) - 1) _repl = "{{{0}:0>{1}}}".format(name, _len) new_text = text.replace(("#" * _len), _repl) return new_text def _convert_to_tag_data(self): """ Convert internal data to tag data. Populating the tag data into internal variable self.tag_data """ # define vertical sync attributes hero_track = True self.review_layer = "" if self.vertical_sync: # check if track name is not in driving layer if self.track_name not in self.driving_layer: # if it is not then define vertical sync as None hero_track = False # increasing steps by index of rename iteration self.count_steps *= self.rename_index hierarchy_formatting_data = {} _data = self.timeline_item_default_data.copy() if self.ui_inputs: # adding tag metadata from ui for _k, _v in self.ui_inputs.items(): if _v["target"] == "tag": self.tag_data[_k] = _v["value"] # driving layer is set as positive match if hero_track or self.vertical_sync: # mark review layer if self.review_track and ( self.review_track not in self.review_track_default): # if review layer is defined and not the same as default self.review_layer = self.review_track # shot num calculate if self.rename_index == 0: self.shot_num = self.count_from else: self.shot_num = self.count_from + self.count_steps # clip name sequence number _data.update({"shot": self.shot_num}) # solve # in test to pythonic expression for _k, _v in self.hierarchy_data.items(): if "#" not in _v["value"]: continue self.hierarchy_data[ _k]["value"] = self._replace_hash_to_expression( _k, _v["value"]) # fill up pythonic expresisons in hierarchy data for k, _v in self.hierarchy_data.items(): hierarchy_formatting_data[k] = _v["value"].format(**_data) else: # if no gui mode then just pass default data hierarchy_formatting_data = self.hierarchy_data tag_hierarchy_data = self._solve_tag_hierarchy_data( hierarchy_formatting_data ) tag_hierarchy_data.update({"heroTrack": True}) if hero_track and self.vertical_sync: self.vertical_clip_match.update({ (self.clip_in, self.clip_out): tag_hierarchy_data }) if not hero_track and self.vertical_sync: # driving layer is set as negative match for (_in, _out), hero_data in self.vertical_clip_match.items(): hero_data.update({"heroTrack": False}) if _in == self.clip_in and _out == self.clip_out: data_subset = hero_data["subset"] # add track index in case duplicity of names in hero data if self.subset in data_subset: hero_data["subset"] = self.subset + str( self.track_index) # in case track name and subset name is the same then add if self.subset_name == self.track_name: hero_data["subset"] = self.subset # assign data to return hierarchy data to tag tag_hierarchy_data = hero_data # add data to return data dict self.tag_data.update(tag_hierarchy_data) # add uuid to tag data self.tag_data["uuid"] = str(uuid.uuid4()) # add review track only to hero track if hero_track and self.review_layer: self.tag_data.update({"reviewTrack": self.review_layer}) else: self.tag_data.update({"reviewTrack": None}) def _solve_tag_hierarchy_data(self, hierarchy_formatting_data): """ Solve tag data from hierarchy data and templates. """ # fill up clip name and hierarchy keys hierarchy_filled = self.hierarchy.format(**hierarchy_formatting_data) clip_name_filled = self.clip_name.format(**hierarchy_formatting_data) return { "newClipName": clip_name_filled, "hierarchy": hierarchy_filled, "parents": self.parents, "hierarchyData": hierarchy_formatting_data, "subset": self.subset, "family": self.subset_family } def _convert_to_entity(self, key): """ Converting input key to key with type. """ # convert to entity type entity_type = self.types.get(key) assert entity_type, "Missing entity type for `{}`".format( key ) return { "entity_type": entity_type, "entity_name": self.hierarchy_data[key]["value"].format( **self.timeline_item_default_data ) } def _create_parents(self): """ Create parents and return it in list. """ self.parents = [] pattern = re.compile(self.parents_search_pattern) par_split = [pattern.findall(t).pop() for t in self.hierarchy.split("/")] for key in par_split: parent = self._convert_to_entity(key) self.parents.append(parent) def get_representation_files(representation): anatomy = Anatomy() files = [] for file_data in representation["files"]: path = anatomy.fill_root(file_data["path"]) files.append(path) return files ================================================ FILE: openpype/hosts/resolve/api/testing_utils.py ================================================ #! python3 class TestGUI: def __init__(self): resolve = bmd.scriptapp("Resolve") # noqa self.fu = resolve.Fusion() ui = self.fu.UIManager self.disp = bmd.UIDispatcher(self.fu.UIManager) # noqa self.title_font = ui.Font({"PixelSize": 18}) self._dialogue = self.disp.AddWindow( { "WindowTitle": "Get Testing folder", "ID": "TestingWin", "Geometry": [250, 250, 250, 100], "Spacing": 0, "Margin": 10 }, [ ui.VGroup( { "Spacing": 2 }, [ ui.Button( { "ID": "inputTestSourcesFolder", "Text": "Select folder with testing media", "Weight": 1.25, "ToolTip": ( "Chose folder with videos, sequences, " "single images, nested folders with " "media" ), "Flat": False } ), ui.VGap(), ui.Button( { "ID": "openButton", "Text": "Process Test", "Weight": 2, "ToolTip": "Run the test...", "Flat": False } ) ] ) ] ) self._widgets = self._dialogue.GetItems() self._dialogue.On.TestingWin.Close = self._close_window self._dialogue.On.inputTestSourcesFolder.Clicked = self._open_dir_button_pressed # noqa self._dialogue.On.openButton.Clicked = self.process def _close_window(self, event): self.disp.ExitLoop() def process(self, event): # placeholder function this supposed to be run from child class pass def _open_dir_button_pressed(self, event): # placeholder function this supposed to be run from child class pass def show_gui(self): self._dialogue.Show() self.disp.RunLoop() self._dialogue.Hide() ================================================ FILE: openpype/hosts/resolve/api/todo-rendering.py ================================================ #!/usr/bin/env python # TODO: convert this script to be usable with OpenPype """ Example DaVinci Resolve script: Load a still from DRX file, apply the still to all clips in all timelines. Set render format and codec, add render jobs for all timelines, render to specified path and wait for rendering completion. Once render is complete, delete all jobs """ # clonned from: https://github.com/survos/transcribe/blob/fe3cf51eb95b82dabcf21fbe5f89bfb3d8bb6ce2/python/3_grade_and_render_all_timelines.py # noqa from python_get_resolve import GetResolve import sys import time def AddTimelineToRender(project, timeline, presetName, targetDirectory, renderFormat, renderCodec): project.SetCurrentTimeline(timeline) project.LoadRenderPreset(presetName) if not project.SetCurrentRenderFormatAndCodec(renderFormat, renderCodec): return False project.SetRenderSettings( {"SelectAllFrames": 1, "TargetDir": targetDirectory}) return project.AddRenderJob() def RenderAllTimelines(resolve, presetName, targetDirectory, renderFormat, renderCodec): projectManager = resolve.GetProjectManager() project = projectManager.GetCurrentProject() if not project: return False resolve.OpenPage("Deliver") timelineCount = project.GetTimelineCount() for index in range(0, int(timelineCount)): if not AddTimelineToRender( project, project.GetTimelineByIndex(index + 1), presetName, targetDirectory, renderFormat, renderCodec): return False return project.StartRendering() def IsRenderingInProgress(resolve): projectManager = resolve.GetProjectManager() project = projectManager.GetCurrentProject() if not project: return False return project.IsRenderingInProgress() def WaitForRenderingCompletion(resolve): while IsRenderingInProgress(resolve): time.sleep(1) return def ApplyDRXToAllTimelineClips(timeline, path, gradeMode=0): trackCount = timeline.GetTrackCount("video") clips = {} for index in range(1, int(trackCount) + 1): clips.update(timeline.GetItemsInTrack("video", index)) return timeline.ApplyGradeFromDRX(path, int(gradeMode), clips) def ApplyDRXToAllTimelines(resolve, path, gradeMode=0): projectManager = resolve.GetProjectManager() project = projectManager.GetCurrentProject() if not project: return False timelineCount = project.GetTimelineCount() for index in range(0, int(timelineCount)): timeline = project.GetTimelineByIndex(index + 1) project.SetCurrentTimeline(timeline) if not ApplyDRXToAllTimelineClips(timeline, path, gradeMode): return False return True def DeleteAllRenderJobs(resolve): projectManager = resolve.GetProjectManager() project = projectManager.GetCurrentProject() project.DeleteAllRenderJobs() return # Inputs: # - DRX file to import grade still and apply it for clips # - grade mode (0, 1 or 2) # - preset name for rendering # - render path # - render format # - render codec if len(sys.argv) < 7: print( "input parameters for scripts are [drx file path] [grade mode] " "[render preset name] [render path] [render format] [render codec]") sys.exit() drxPath = sys.argv[1] gradeMode = sys.argv[2] renderPresetName = sys.argv[3] renderPath = sys.argv[4] renderFormat = sys.argv[5] renderCodec = sys.argv[6] # Get currently open project resolve = GetResolve() if not ApplyDRXToAllTimelines(resolve, drxPath, gradeMode): print("Unable to apply a still from drx file to all timelines") sys.exit() if not RenderAllTimelines(resolve, renderPresetName, renderPath, renderFormat, renderCodec): print("Unable to set all timelines for rendering") sys.exit() WaitForRenderingCompletion(resolve) DeleteAllRenderJobs(resolve) print("Rendering is completed.") ================================================ FILE: openpype/hosts/resolve/api/utils.py ================================================ #! python3 """ Resolve's tools for setting environment """ import os import sys from openpype.lib import Logger log = Logger.get_logger(__name__) def get_resolve_module(): from openpype.hosts.resolve import api # dont run if already loaded if api.bmdvr: log.info(("resolve module is assigned to " f"`openpype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) return api.bmdvr try: """ The PYTHONPATH needs to be set correctly for this import statement to work. An alternative is to import the DaVinciResolveScript by specifying absolute path (see ExceptionHandler logic) """ import DaVinciResolveScript as bmd except ImportError: if sys.platform.startswith("darwin"): expected_path = ("/Library/Application Support/Blackmagic Design" "/DaVinci Resolve/Developer/Scripting/Modules") elif sys.platform.startswith("win") \ or sys.platform.startswith("cygwin"): expected_path = os.path.normpath( os.getenv('PROGRAMDATA') + ( "/Blackmagic Design/DaVinci Resolve/Support/Developer" "/Scripting/Modules" ) ) elif sys.platform.startswith("linux"): expected_path = "/opt/resolve/libs/Fusion/Modules" else: raise NotImplementedError( "Unsupported platform: {}".format(sys.platform) ) # check if the default path has it... print(("Unable to find module DaVinciResolveScript from " "$PYTHONPATH - trying default locations")) module_path = os.path.normpath( os.path.join( expected_path, "DaVinciResolveScript.py" ) ) try: import imp bmd = imp.load_source('DaVinciResolveScript', module_path) except ImportError: # No fallbacks ... report error: log.error( ("Unable to find module DaVinciResolveScript - please " "ensure that the module DaVinciResolveScript is " "discoverable by python") ) log.error( ("For a default DaVinci Resolve installation, the " f"module is expected to be located in: {expected_path}") ) sys.exit() # assign global var and return bmdvr = bmd.scriptapp("Resolve") bmdvf = bmd.scriptapp("Fusion") api.bmdvr = bmdvr api.bmdvf = bmdvf log.info(("Assigning resolve module to " f"`openpype.hosts.resolve.api.bmdvr`: {api.bmdvr}")) log.info(("Assigning resolve module to " f"`openpype.hosts.resolve.api.bmdvf`: {api.bmdvf}")) ================================================ FILE: openpype/hosts/resolve/api/workio.py ================================================ """Host API required Work Files tool""" import os from openpype.lib import Logger from .lib import ( get_project_manager, get_current_project ) log = Logger.get_logger(__name__) def file_extensions(): return [".drp"] def has_unsaved_changes(): get_project_manager().SaveProject() return False def save_file(filepath): pm = get_project_manager() file = os.path.basename(filepath) fname, _ = os.path.splitext(file) project = get_current_project() name = project.GetName() response = False if name == "Untitled Project": response = pm.CreateProject(fname) log.info("New project created: {}".format(response)) pm.SaveProject() elif name != fname: response = project.SetName(fname) log.info("Project renamed: {}".format(response)) exported = pm.ExportProject(fname, filepath) log.info("Project exported: {}".format(exported)) def open_file(filepath): """ Loading project """ from . import bmdvr pm = get_project_manager() page = bmdvr.GetCurrentPage() if page is not None: # Save current project only if Resolve has an active page, otherwise # we consider Resolve being in a pre-launch state (no open UI yet) project = pm.GetCurrentProject() print(f"Saving current project: {project}") pm.SaveProject() file = os.path.basename(filepath) fname, _ = os.path.splitext(file) try: # load project from input path project = pm.LoadProject(fname) log.info(f"Project {project.GetName()} opened...") except AttributeError: log.warning((f"Project with name `{fname}` does not exist! It will " f"be imported from {filepath} and then loaded...")) if pm.ImportProject(filepath): # load project from input path project = pm.LoadProject(fname) log.info(f"Project imported/loaded {project.GetName()}...") return True return False return True def current_file(): pm = get_project_manager() file_ext = file_extensions()[0] workdir_path = os.getenv("AVALON_WORKDIR") project = pm.GetCurrentProject() project_name = project.GetName() file_name = project_name + file_ext # create current file path current_file_path = os.path.join(workdir_path, file_name) # return current file path if it exists if os.path.exists(current_file_path): return os.path.normpath(current_file_path) def work_root(session): return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") ================================================ FILE: openpype/hosts/resolve/hooks/pre_resolve_last_workfile.py ================================================ import os from openpype.lib.applications import PreLaunchHook, LaunchTypes class PreLaunchResolveLastWorkfile(PreLaunchHook): """Special hook to open last workfile for Resolve. Checks 'start_last_workfile', if set to False, it will not open last workfile. This property is set explicitly in Launcher. """ order = 10 app_groups = {"resolve"} launch_types = {LaunchTypes.local} def execute(self): if not self.data.get("start_last_workfile"): self.log.info("It is set to not start last workfile on start.") return last_workfile = self.data.get("last_workfile_path") if not last_workfile: self.log.warning("Last workfile was not collected.") return if not os.path.exists(last_workfile): self.log.info("Current context does not have any workfile yet.") return # Add path to launch environment for the startup script to pick up self.log.info( "Setting OPENPYPE_RESOLVE_OPEN_ON_LAUNCH to launch " f"last workfile: {last_workfile}" ) key = "OPENPYPE_RESOLVE_OPEN_ON_LAUNCH" self.launch_context.env[key] = last_workfile ================================================ FILE: openpype/hosts/resolve/hooks/pre_resolve_setup.py ================================================ import os from pathlib import Path import platform from openpype.lib.applications import PreLaunchHook, LaunchTypes from openpype.hosts.resolve.utils import setup class PreLaunchResolveSetup(PreLaunchHook): """ This hook will set up the Resolve scripting environment as described in Resolve's documentation found with the installed application at {resolve}/Support/Developer/Scripting/README.txt Prepares the following environment variables: - `RESOLVE_SCRIPT_API` - `RESOLVE_SCRIPT_LIB` It adds $RESOLVE_SCRIPT_API/Modules to PYTHONPATH. Additionally it sets up the Python home for Python 3 based on the RESOLVE_PYTHON3_HOME in the environment (usually defined in OpenPype's Application environment for Resolve by the admin). For this it sets PYTHONHOME and PATH variables. It also defines: - `RESOLVE_UTILITY_SCRIPTS_DIR`: Destination directory for OpenPype Fusion scripts to be copied to for Resolve to pick them up. - `OPENPYPE_LOG_NO_COLORS` to True to ensure OP doesn't try to use logging with terminal colors as it fails in Resolve. """ app_groups = {"resolve"} launch_types = {LaunchTypes.local} def execute(self): current_platform = platform.system().lower() programdata = self.launch_context.env.get("PROGRAMDATA", "") resolve_script_api_locations = { "windows": ( f"{programdata}/Blackmagic Design/" "DaVinci Resolve/Support/Developer/Scripting" ), "darwin": ( "/Library/Application Support/Blackmagic Design" "/DaVinci Resolve/Developer/Scripting" ), "linux": "/opt/resolve/Developer/Scripting", } resolve_script_api = Path( resolve_script_api_locations[current_platform] ) self.log.info( f"setting RESOLVE_SCRIPT_API variable to {resolve_script_api}" ) self.launch_context.env[ "RESOLVE_SCRIPT_API" ] = resolve_script_api.as_posix() resolve_script_lib_dirs = { "windows": ( "C:/Program Files/Blackmagic Design" "/DaVinci Resolve/fusionscript.dll" ), "darwin": ( "/Applications/DaVinci Resolve/DaVinci Resolve.app" "/Contents/Libraries/Fusion/fusionscript.so" ), "linux": "/opt/resolve/libs/Fusion/fusionscript.so", } resolve_script_lib = Path(resolve_script_lib_dirs[current_platform]) self.launch_context.env[ "RESOLVE_SCRIPT_LIB" ] = resolve_script_lib.as_posix() self.log.info( f"setting RESOLVE_SCRIPT_LIB variable to {resolve_script_lib}" ) # TODO: add OTIO installation from `openpype/requirements.py` # making sure python <3.9.* is installed at provided path python3_home = Path( self.launch_context.env.get("RESOLVE_PYTHON3_HOME", "") ) assert python3_home.is_dir(), ( "Python 3 is not installed at the provided folder path. Either " "make sure the `environments\resolve.json` is having correctly " "set `RESOLVE_PYTHON3_HOME` or make sure Python 3 is installed " f"in given path. \nRESOLVE_PYTHON3_HOME: `{python3_home}`" ) python3_home_str = python3_home.as_posix() self.launch_context.env["PYTHONHOME"] = python3_home_str self.log.info(f"Path to Resolve Python folder: `{python3_home_str}`") # add to the PYTHONPATH env_pythonpath = self.launch_context.env["PYTHONPATH"] modules_path = Path(resolve_script_api, "Modules").as_posix() self.launch_context.env[ "PYTHONPATH" ] = f"{modules_path}{os.pathsep}{env_pythonpath}" self.log.debug(f"PYTHONPATH: {self.launch_context.env['PYTHONPATH']}") # add the pythonhome folder to PATH because on Windows # this is needed for Py3 to be correctly detected within Resolve env_path = self.launch_context.env["PATH"] self.log.info(f"Adding `{python3_home_str}` to the PATH variable") self.launch_context.env[ "PATH" ] = f"{python3_home_str}{os.pathsep}{env_path}" self.log.debug(f"PATH: {self.launch_context.env['PATH']}") resolve_utility_scripts_dirs = { "windows": ( f"{programdata}/Blackmagic Design" "/DaVinci Resolve/Fusion/Scripts/Comp" ), "darwin": ( "/Library/Application Support/Blackmagic Design" "/DaVinci Resolve/Fusion/Scripts/Comp" ), "linux": "/opt/resolve/Fusion/Scripts/Comp", } resolve_utility_scripts_dir = Path( resolve_utility_scripts_dirs[current_platform] ) # setting utility scripts dir for scripts syncing self.launch_context.env[ "RESOLVE_UTILITY_SCRIPTS_DIR" ] = resolve_utility_scripts_dir.as_posix() # remove terminal coloring tags self.launch_context.env["OPENPYPE_LOG_NO_COLORS"] = "True" # Resolve Setup integration setup(self.launch_context.env) ================================================ FILE: openpype/hosts/resolve/hooks/pre_resolve_startup.py ================================================ import os from openpype.lib.applications import PreLaunchHook, LaunchTypes import openpype.hosts.resolve class PreLaunchResolveStartup(PreLaunchHook): """Special hook to configure startup script. """ order = 11 app_groups = {"resolve"} launch_types = {LaunchTypes.local} def execute(self): # Set the openpype prelaunch startup script path for easy access # in the LUA .scriptlib code op_resolve_root = os.path.dirname(openpype.hosts.resolve.__file__) script_path = os.path.join(op_resolve_root, "startup.py") key = "OPENPYPE_RESOLVE_STARTUP_SCRIPT" self.launch_context.env[key] = script_path self.log.info( f"Setting OPENPYPE_RESOLVE_STARTUP_SCRIPT to: {script_path}" ) ================================================ FILE: openpype/hosts/resolve/otio/__init__.py ================================================ ================================================ FILE: openpype/hosts/resolve/otio/davinci_export.py ================================================ """ compatibility OpenTimelineIO 0.12.0 and older """ import os import re import sys import json import opentimelineio as otio from . import utils import clique self = sys.modules[__name__] self.track_types = { "video": otio.schema.TrackKind.Video, "audio": otio.schema.TrackKind.Audio } self.project_fps = None def create_otio_rational_time(frame, fps): return otio.opentime.RationalTime( float(frame), float(fps) ) def create_otio_time_range(start_frame, frame_duration, fps): return otio.opentime.TimeRange( start_time=create_otio_rational_time(start_frame, fps), duration=create_otio_rational_time(frame_duration, fps) ) def create_otio_reference(media_pool_item): metadata = _get_metadata_media_pool_item(media_pool_item) print("media pool item: {}".format(media_pool_item.GetName())) _mp_clip_property = media_pool_item.GetClipProperty path = _mp_clip_property("File Path") reformat_path = utils.get_reformated_path(path, padded=True) padding = utils.get_padding_from_path(path) if padding: metadata.update({ "isSequence": True, "padding": padding }) # get clip property regarding to type fps = float(_mp_clip_property("FPS")) if _mp_clip_property("Type") == "Video": frame_start = int(_mp_clip_property("Start")) frame_duration = int(_mp_clip_property("Frames")) else: audio_duration = str(_mp_clip_property("Duration")) frame_start = 0 frame_duration = int(utils.timecode_to_frames( audio_duration, float(fps))) otio_ex_ref_item = None if padding: # if it is file sequence try to create `ImageSequenceReference` # the OTIO might not be compatible so return nothing and do it old way try: dirname, filename = os.path.split(path) collection = clique.parse(filename, '{head}[{ranges}]{tail}') padding_num = len(re.findall("(\\d+)(?=-)", filename).pop()) otio_ex_ref_item = otio.schema.ImageSequenceReference( target_url_base=dirname + os.sep, name_prefix=collection.format("{head}"), name_suffix=collection.format("{tail}"), start_frame=frame_start, frame_zero_padding=padding_num, rate=fps, available_range=create_otio_time_range( frame_start, frame_duration, fps ) ) except AttributeError: pass if not otio_ex_ref_item: # in case old OTIO or video file create `ExternalReference` otio_ex_ref_item = otio.schema.ExternalReference( target_url=reformat_path, available_range=create_otio_time_range( frame_start, frame_duration, fps ) ) # add metadata to otio item add_otio_metadata(otio_ex_ref_item, media_pool_item, **metadata) return otio_ex_ref_item def create_otio_markers(track_item, fps): track_item_markers = track_item.GetMarkers() markers = [] for marker_frame in track_item_markers: note = track_item_markers[marker_frame]["note"] if "{" in note and "}" in note: metadata = json.loads(note) else: metadata = {"note": note} markers.append( otio.schema.Marker( name=track_item_markers[marker_frame]["name"], marked_range=create_otio_time_range( marker_frame, track_item_markers[marker_frame]["duration"], fps ), color=track_item_markers[marker_frame]["color"].upper(), metadata=metadata ) ) return markers def create_otio_clip(track_item): media_pool_item = track_item.GetMediaPoolItem() _mp_clip_property = media_pool_item.GetClipProperty if not self.project_fps: fps = float(_mp_clip_property("FPS")) else: fps = self.project_fps name = track_item.GetName() media_reference = create_otio_reference(media_pool_item) source_range = create_otio_time_range( int(track_item.GetLeftOffset()), int(track_item.GetDuration()), fps ) if _mp_clip_property("Type") == "Audio": return_clips = list() audio_chanels = _mp_clip_property("Audio Ch") for channel in range(0, int(audio_chanels)): clip = otio.schema.Clip( name=f"{name}_{channel}", source_range=source_range, media_reference=media_reference ) for marker in create_otio_markers(track_item, fps): clip.markers.append(marker) return_clips.append(clip) return return_clips else: clip = otio.schema.Clip( name=name, source_range=source_range, media_reference=media_reference ) for marker in create_otio_markers(track_item, fps): clip.markers.append(marker) return clip def create_otio_gap(gap_start, clip_start, tl_start_frame, fps): return otio.schema.Gap( source_range=create_otio_time_range( gap_start, (clip_start - tl_start_frame) - gap_start, fps ) ) def _create_otio_timeline(project, timeline, fps): metadata = _get_timeline_metadata(project, timeline) start_time = create_otio_rational_time( timeline.GetStartFrame(), fps) otio_timeline = otio.schema.Timeline( name=timeline.GetName(), global_start_time=start_time, metadata=metadata ) return otio_timeline def _get_timeline_metadata(project, timeline): media_pool = project.GetMediaPool() root_folder = media_pool.GetRootFolder() ls_folder = root_folder.GetClipList() timeline = project.GetCurrentTimeline() timeline_name = timeline.GetName() for tl in ls_folder: if tl.GetName() not in timeline_name: continue return _get_metadata_media_pool_item(tl) def _get_metadata_media_pool_item(media_pool_item): data = dict() data.update({k: v for k, v in media_pool_item.GetMetadata().items()}) property = media_pool_item.GetClipProperty() or {} for name, value in property.items(): if "Resolution" in name and "" != value: width, height = value.split("x") data.update({ "width": int(width), "height": int(height) }) if "PAR" in name and "" != value: try: data.update({"pixelAspect": float(value)}) except ValueError: if "Square" in value: data.update({"pixelAspect": float(1)}) else: data.update({"pixelAspect": float(1)}) return data def create_otio_track(track_type, track_name): return otio.schema.Track( name=track_name, kind=self.track_types[track_type] ) def add_otio_gap(clip_start, otio_track, track_item, timeline): # if gap between track start and clip start if clip_start > otio_track.available_range().duration.value: # create gap and add it to track otio_track.append( create_otio_gap( otio_track.available_range().duration.value, track_item.GetStart(), timeline.GetStartFrame(), self.project_fps ) ) def add_otio_metadata(otio_item, media_pool_item, **kwargs): mp_metadata = media_pool_item.GetMetadata() # add additional metadata from kwargs if kwargs: mp_metadata.update(kwargs) # add metadata to otio item metadata for key, value in mp_metadata.items(): otio_item.metadata.update({key: value}) def create_otio_timeline(resolve_project): # get current timeline self.project_fps = resolve_project.GetSetting("timelineFrameRate") timeline = resolve_project.GetCurrentTimeline() # convert timeline to otio otio_timeline = _create_otio_timeline( resolve_project, timeline, self.project_fps) # loop all defined track types for track_type in list(self.track_types.keys()): # get total track count track_count = timeline.GetTrackCount(track_type) # loop all tracks by track indexes for track_index in range(1, int(track_count) + 1): # get current track name track_name = timeline.GetTrackName(track_type, track_index) # convert track to otio otio_track = create_otio_track( track_type, track_name) # get all track items in current track current_track_items = timeline.GetItemListInTrack( track_type, track_index) # loop available track items in current track items for track_item in current_track_items: # skip offline track items if track_item.GetMediaPoolItem() is None: continue # calculate real clip start clip_start = track_item.GetStart() - timeline.GetStartFrame() add_otio_gap( clip_start, otio_track, track_item, timeline) # create otio clip and add it to track otio_clip = create_otio_clip(track_item) if not isinstance(otio_clip, list): otio_track.append(otio_clip) else: for index, clip in enumerate(otio_clip): if index == 0: otio_track.append(clip) else: # add previous otio track to timeline otio_timeline.tracks.append(otio_track) # convert track to otio otio_track = create_otio_track( track_type, track_name) add_otio_gap( clip_start, otio_track, track_item, timeline) otio_track.append(clip) # add track to otio timeline otio_timeline.tracks.append(otio_track) return otio_timeline def write_to_file(otio_timeline, path): otio.adapters.write_to_file(otio_timeline, path) ================================================ FILE: openpype/hosts/resolve/otio/davinci_import.py ================================================ import sys import json import DaVinciResolveScript import opentimelineio as otio self = sys.modules[__name__] self.resolve = DaVinciResolveScript.scriptapp('Resolve') self.fusion = DaVinciResolveScript.scriptapp('Fusion') self.project_manager = self.resolve.GetProjectManager() self.current_project = self.project_manager.GetCurrentProject() self.media_pool = self.current_project.GetMediaPool() self.track_types = { "video": otio.schema.TrackKind.Video, "audio": otio.schema.TrackKind.Audio } self.project_fps = None def build_timeline(otio_timeline): # TODO: build timeline in mediapool `otioImport` folder # TODO: loop otio tracks and build them in the new timeline for clip in otio_timeline.each_clip(): # TODO: create track item print(clip.name) print(clip.parent().name) print(clip.range_in_parent()) def _build_track(otio_track): # TODO: _build_track pass def _build_media_pool_item(otio_media_reference): # TODO: _build_media_pool_item pass def _build_track_item(otio_clip): # TODO: _build_track_item pass def _build_gap(otio_clip): # TODO: _build_gap pass def _build_marker(track_item, otio_marker): frame_start = otio_marker.marked_range.start_time.value frame_duration = otio_marker.marked_range.duration.value # marker attributes frameId = (frame_start / 10) * 10 color = otio_marker.color name = otio_marker.name note = otio_marker.metadata.get("note") or json.dumps(otio_marker.metadata) duration = (frame_duration / 10) * 10 track_item.AddMarker( frameId, color, name, note, duration ) def _build_media_pool_folder(name): """ Returns folder with input name and sets it as current folder. It will create new media bin if none is found in root media bin Args: name (str): name of bin Returns: resolve.api.MediaPool.Folder: description """ root_folder = self.media_pool.GetRootFolder() sub_folders = root_folder.GetSubFolderList() testing_names = list() for subfolder in sub_folders: subf_name = subfolder.GetName() if name in subf_name: testing_names.append(subfolder) else: testing_names.append(False) matching = next((f for f in testing_names if f is not False), None) if not matching: new_folder = self.media_pool.AddSubFolder(root_folder, name) self.media_pool.SetCurrentFolder(new_folder) else: self.media_pool.SetCurrentFolder(matching) return self.media_pool.GetCurrentFolder() def read_from_file(otio_file): otio_timeline = otio.adapters.read_from_file(otio_file) build_timeline(otio_timeline) ================================================ FILE: openpype/hosts/resolve/otio/utils.py ================================================ import re import opentimelineio as otio def timecode_to_frames(timecode, framerate): rt = otio.opentime.from_timecode(timecode, 24) return int(otio.opentime.to_frames(rt)) def frames_to_timecode(frames, framerate): rt = otio.opentime.from_frames(frames, framerate) return otio.opentime.to_timecode(rt) def frames_to_secons(frames, framerate): rt = otio.opentime.from_frames(frames, framerate) return otio.opentime.to_seconds(rt) def get_reformated_path(path, padded=True, first=False): """ Return fixed python expression path Args: path (str): path url or simple file name Returns: type: string with reformated path Example: get_reformated_path("plate.[0001-1008].exr") > plate.%04d.exr """ num_pattern = r"(\[\d+\-\d+\])" padding_pattern = r"(\d+)(?=-)" first_frame_pattern = re.compile(r"\[(\d+)\-\d+\]") if "[" in path: padding = len(re.findall(padding_pattern, path).pop()) if padded: path = re.sub(num_pattern, f"%0{padding}d", path) elif first: first_frame = re.findall(first_frame_pattern, path, flags=0) if len(first_frame) >= 1: first_frame = first_frame[0] path = re.sub(num_pattern, first_frame, path) else: path = re.sub(num_pattern, "%d", path) return path def get_padding_from_path(path): """ Return padding number from DaVinci Resolve sequence path style Args: path (str): path url or simple file name Returns: int: padding number Example: get_padding_from_path("plate.[0001-1008].exr") > 4 """ padding_pattern = "(\\d+)(?=-)" if "[" in path: return len(re.findall(padding_pattern, path).pop()) return None ================================================ FILE: openpype/hosts/resolve/plugins/create/create_shot_clip.py ================================================ # from pprint import pformat from openpype.hosts.resolve.api import plugin, lib from openpype.hosts.resolve.api.lib import ( get_video_track_names, create_bin, ) class CreateShotClip(plugin.Creator): """Publishable clip""" label = "Create Publishable Clip" family = "clip" icon = "film" defaults = ["Main"] gui_tracks = get_video_track_names() gui_name = "OpenPype publish attributes creator" gui_info = "Define sequential rename and fill hierarchy data." gui_inputs = { "renameHierarchy": { "type": "section", "label": "Shot Hierarchy And Rename Settings", "target": "ui", "order": 0, "value": { "hierarchy": { "value": "{folder}/{sequence}", "type": "QLineEdit", "label": "Shot Parent Hierarchy", "target": "tag", "toolTip": "Parents folder for shot root folder, Template filled with `Hierarchy Data` section", # noqa "order": 0}, "clipRename": { "value": False, "type": "QCheckBox", "label": "Rename clips", "target": "ui", "toolTip": "Renaming selected clips on fly", # noqa "order": 1}, "clipName": { "value": "{sequence}{shot}", "type": "QLineEdit", "label": "Clip Name Template", "target": "ui", "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa "order": 2}, "countFrom": { "value": 10, "type": "QSpinBox", "label": "Count sequence from", "target": "ui", "toolTip": "Set when the sequence number stafrom", # noqa "order": 3}, "countSteps": { "value": 10, "type": "QSpinBox", "label": "Stepping number", "target": "ui", "toolTip": "What number is adding every new step", # noqa "order": 4}, } }, "hierarchyData": { "type": "dict", "label": "Shot Template Keywords", "target": "tag", "order": 1, "value": { "folder": { "value": "shots", "type": "QLineEdit", "label": "{folder}", "target": "tag", "toolTip": "Name of folder used for root of generated shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 0}, "episode": { "value": "ep01", "type": "QLineEdit", "label": "{episode}", "target": "tag", "toolTip": "Name of episode.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 1}, "sequence": { "value": "sq01", "type": "QLineEdit", "label": "{sequence}", "target": "tag", "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 2}, "track": { "value": "{_track_}", "type": "QLineEdit", "label": "{track}", "target": "tag", "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 3}, "shot": { "value": "sh###", "type": "QLineEdit", "label": "{shot}", "target": "tag", "toolTip": "Name of shot. `#` is converted to paded number. \nAlso could be used with usable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa "order": 4} } }, "verticalSync": { "type": "section", "label": "Vertical Synchronization Of Attributes", "target": "ui", "order": 2, "value": { "vSyncOn": { "value": True, "type": "QCheckBox", "label": "Enable Vertical Sync", "target": "ui", "toolTip": "Switch on if you want clips above each other to share its attributes", # noqa "order": 0}, "vSyncTrack": { "value": gui_tracks, # noqa "type": "QComboBox", "label": "Hero track", "target": "ui", "toolTip": "Select driving track name which should be mastering all others", # noqa "order": 1 } } }, "publishSettings": { "type": "section", "label": "Publish Settings", "target": "ui", "order": 3, "value": { "subsetName": { "value": ["", "main", "bg", "fg", "bg", "animatic"], "type": "QComboBox", "label": "Subset Name", "target": "ui", "toolTip": "chose subset name pattern, if is selected, name of track layer will be used", # noqa "order": 0}, "subsetFamily": { "value": ["plate", "take"], "type": "QComboBox", "label": "Subset Family", "target": "ui", "toolTip": "What use of this subset is for", # noqa "order": 1}, "reviewTrack": { "value": ["< none >"] + gui_tracks, "type": "QComboBox", "label": "Use Review Track", "target": "ui", "toolTip": "Generate preview videos on fly, if `< none >` is defined nothing will be generated.", # noqa "order": 2}, "audio": { "value": False, "type": "QCheckBox", "label": "Include audio", "target": "tag", "toolTip": "Process subsets with corresponding audio", # noqa "order": 3}, "sourceResolution": { "value": False, "type": "QCheckBox", "label": "Source resolution", "target": "tag", "toolTip": "Is resloution taken from timeline or source?", # noqa "order": 4}, } }, "shotAttr": { "type": "section", "label": "Shot Attributes", "target": "ui", "order": 4, "value": { "workfileFrameStart": { "value": 1001, "type": "QSpinBox", "label": "Workfiles Start Frame", "target": "tag", "toolTip": "Set workfile starting frame number", # noqa "order": 0 }, "handleStart": { "value": 0, "type": "QSpinBox", "label": "Handle start (head)", "target": "tag", "toolTip": "Handle at start of clip", # noqa "order": 1 }, "handleEnd": { "value": 0, "type": "QSpinBox", "label": "Handle end (tail)", "target": "tag", "toolTip": "Handle at end of clip", # noqa "order": 2 } } } } presets = None def process(self): # get key pares from presets and match it on ui inputs for k, v in self.gui_inputs.items(): if v["type"] in ("dict", "section"): # nested dictionary (only one level allowed # for sections and dict) for _k, _v in v["value"].items(): if self.presets.get(_k) is not None: self.gui_inputs[k][ "value"][_k]["value"] = self.presets[_k] if self.presets.get(k): self.gui_inputs[k]["value"] = self.presets[k] # open widget for plugins inputs widget = self.widget(self.gui_name, self.gui_info, self.gui_inputs) widget.exec_() if len(self.selected) < 1: return if not widget.result: print("Operation aborted") return self.rename_add = 0 # get ui output for track name for vertical sync v_sync_track = widget.result["vSyncTrack"]["value"] # sort selected trackItems by sorted_selected_track_items = [] unsorted_selected_track_items = [] print("_____ selected ______") print(self.selected) for track_item_data in self.selected: if track_item_data["track"]["name"] in v_sync_track: sorted_selected_track_items.append(track_item_data) else: unsorted_selected_track_items.append(track_item_data) sorted_selected_track_items.extend(unsorted_selected_track_items) # sequence attrs sq_frame_start = self.timeline.GetStartFrame() sq_markers = self.timeline.GetMarkers() # create media bin for compound clips (trackItems) mp_folder = create_bin(self.timeline.GetName()) kwargs = { "ui_inputs": widget.result, "avalon": self.data, "mp_folder": mp_folder, "sq_frame_start": sq_frame_start, "sq_markers": sq_markers } print(kwargs) for i, track_item_data in enumerate(sorted_selected_track_items): self.rename_index = i self.log.info(track_item_data) # convert track item to timeline media pool item track_item = plugin.PublishClip( self, track_item_data, **kwargs).convert() track_item.SetClipColor(lib.publish_clip_color) ================================================ FILE: openpype/hosts/resolve/plugins/load/load_clip.py ================================================ from openpype.client import get_last_version_by_subset_id from openpype.pipeline import ( get_representation_context, get_current_project_name ) from openpype.hosts.resolve.api import lib, plugin from openpype.hosts.resolve.api.pipeline import ( containerise, update_container, ) from openpype.lib.transcoding import ( VIDEO_EXTENSIONS, IMAGE_EXTENSIONS ) class LoadClip(plugin.TimelineItemLoader): """Load a subset to timeline as clip Place clip to timeline on its asset origin timings collected during conforming to project """ families = ["render2d", "source", "plate", "render", "review"] representations = ["*"] extensions = set( ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) ) label = "Load as clip" order = -10 icon = "code-fork" color = "orange" # for loader multiselection timeline = None # presets clip_color_last = "Olive" clip_color = "Orange" def load(self, context, name, namespace, options): # load clip to timeline and get main variables files = plugin.get_representation_files(context["representation"]) timeline_item = plugin.ClipLoader( self, context, **options).load(files) namespace = namespace or timeline_item.GetName() # update color of clip regarding the version order self.set_item_color(timeline_item, version=context["version"]) data_imprint = self.get_tag_data(context, name, namespace) return containerise( timeline_item, name, namespace, context, self.__class__.__name__, data_imprint) def switch(self, container, representation): self.update(container, representation) def update(self, container, representation): """ Updating previously loaded clips """ context = get_representation_context(representation) name = container['name'] namespace = container['namespace'] timeline_item = container["_timeline_item"] media_pool_item = timeline_item.GetMediaPoolItem() files = plugin.get_representation_files(representation) loader = plugin.ClipLoader(self, context) timeline_item = loader.update(timeline_item, files) # update color of clip regarding the version order self.set_item_color(timeline_item, version=context["version"]) # if original media pool item has no remaining usages left # remove it from the media pool if int(media_pool_item.GetClipProperty("Usage")) == 0: lib.remove_media_pool_item(media_pool_item) data_imprint = self.get_tag_data(context, name, namespace) return update_container(timeline_item, data_imprint) def get_tag_data(self, context, name, namespace): """Return data to be imprinted on the timeline item marker""" representation = context["representation"] version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) colorspace = version_data.get("colorspace", None) object_name = "{}_{}".format(name, namespace) # add additional metadata from the version to imprint Avalon knob # move all version data keys to tag data add_version_data_keys = [ "frameStart", "frameEnd", "source", "author", "fps", "handleStart", "handleEnd" ] data = { key: version_data.get(key, "None") for key in add_version_data_keys } # add variables related to version context data.update({ "representation": str(representation["_id"]), "version": version_name, "colorspace": colorspace, "objectName": object_name }) return data @classmethod def set_item_color(cls, timeline_item, version): """Color timeline item based on whether it is outdated or latest""" # define version name version_name = version.get("name", None) # get all versions in list project_name = get_current_project_name() last_version_doc = get_last_version_by_subset_id( project_name, version["parent"], fields=["name"] ) if last_version_doc: last_version = last_version_doc["name"] else: last_version = None # set clip colour if version_name == last_version: timeline_item.SetClipColor(cls.clip_color_last) else: timeline_item.SetClipColor(cls.clip_color) def remove(self, container): timeline_item = container["_timeline_item"] media_pool_item = timeline_item.GetMediaPoolItem() timeline = lib.get_current_timeline() # DeleteClips function was added in Resolve 18.5+ # by checking None we can detect whether the # function exists in Resolve if timeline.DeleteClips is not None: timeline.DeleteClips([timeline_item]) else: # Resolve versions older than 18.5 can't delete clips via API # so all we can do is just remove the pype marker to 'untag' it if lib.get_pype_marker(timeline_item): # Note: We must call `get_pype_marker` because # `delete_pype_marker` uses a global variable set by # `get_pype_marker` to delete the right marker # TODO: Improve code to avoid the global `temp_marker_frame` lib.delete_pype_marker(timeline_item) # if media pool item has no remaining usages left # remove it from the media pool if int(media_pool_item.GetClipProperty("Usage")) == 0: lib.remove_media_pool_item(media_pool_item) ================================================ FILE: openpype/hosts/resolve/plugins/publish/extract_workfile.py ================================================ import os import pyblish.api from openpype.pipeline import publish from openpype.hosts.resolve.api.lib import get_project_manager class ExtractWorkfile(publish.Extractor): """ Extractor export DRP workfile file representation """ label = "Extract Workfile" order = pyblish.api.ExtractorOrder families = ["workfile"] hosts = ["resolve"] def process(self, instance): # create representation data if "representations" not in instance.data: instance.data["representations"] = [] name = instance.data["name"] project = instance.context.data["activeProject"] staging_dir = self.staging_dir(instance) resolve_workfile_ext = ".drp" drp_file_name = name + resolve_workfile_ext drp_file_path = os.path.normpath( os.path.join(staging_dir, drp_file_name)) # write out the drp workfile get_project_manager().ExportProject( project.GetName(), drp_file_path) # create drp workfile representation representation_drp = { 'name': resolve_workfile_ext[1:], 'ext': resolve_workfile_ext[1:], 'files': drp_file_name, "stagingDir": staging_dir, } instance.data["representations"].append(representation_drp) # add sourcePath attribute to instance if not instance.data.get("sourcePath"): instance.data["sourcePath"] = drp_file_path self.log.info("Added Resolve file representation: {}".format( representation_drp)) ================================================ FILE: openpype/hosts/resolve/plugins/publish/precollect_instances.py ================================================ from pprint import pformat import pyblish from openpype.hosts.resolve.api.lib import ( get_current_timeline_items, get_timeline_item_pype_tag, publish_clip_color, get_publish_attribute, get_otio_clip_instance_data, ) from openpype import AYON_SERVER_ENABLED class PrecollectInstances(pyblish.api.ContextPlugin): """Collect all Track items selection.""" order = pyblish.api.CollectorOrder - 0.49 label = "Precollect Instances" hosts = ["resolve"] def process(self, context): otio_timeline = context.data["otioTimeline"] selected_timeline_items = get_current_timeline_items( filter=True, selecting_color=publish_clip_color) self.log.info( "Processing enabled track items: {}".format( len(selected_timeline_items))) for timeline_item_data in selected_timeline_items: data = {} timeline_item = timeline_item_data["clip"]["item"] # get pype tag data tag_data = get_timeline_item_pype_tag(timeline_item) self.log.debug(f"__ tag_data: {pformat(tag_data)}") if not tag_data: continue if tag_data.get("id") != "pyblish.avalon.instance": continue media_pool_item = timeline_item.GetMediaPoolItem() source_duration = int(media_pool_item.GetClipProperty("Frames")) # solve handles length handle_start = min( tag_data["handleStart"], int(timeline_item.GetLeftOffset())) handle_end = min( tag_data["handleEnd"], int( source_duration - timeline_item.GetRightOffset())) self.log.debug("Handles: <{}, {}>".format(handle_start, handle_end)) # add tag data to instance data data.update({ k: v for k, v in tag_data.items() if k not in ("id", "applieswhole", "label") }) if AYON_SERVER_ENABLED: asset = tag_data["folder_path"] else: asset = tag_data["asset_name"] subset = tag_data["subset"] data.update({ "name": "{}_{}".format(asset, subset), "label": "{} {}".format(asset, subset), "asset": asset, "item": timeline_item, "publish": get_publish_attribute(timeline_item), "fps": context.data["fps"], "handleStart": handle_start, "handleEnd": handle_end, "newAssetPublishing": True, "families": ["clip"], }) # otio clip data otio_data = get_otio_clip_instance_data( otio_timeline, timeline_item_data) or {} data.update(otio_data) # add resolution self.get_resolution_to_data(data, context) # create instance instance = context.create_instance(**data) # create shot instance for shot attributes create/update self.create_shot_instance(context, timeline_item, **data) self.log.info("Creating instance: {}".format(instance)) self.log.debug( "_ instance.data: {}".format(pformat(instance.data))) def get_resolution_to_data(self, data, context): assert data.get("otioClip"), "Missing `otioClip` data" # solve source resolution option if data.get("sourceResolution", None): otio_clip_metadata = data[ "otioClip"].media_reference.metadata data.update({ "resolutionWidth": otio_clip_metadata["width"], "resolutionHeight": otio_clip_metadata["height"], "pixelAspect": otio_clip_metadata["pixelAspect"] }) else: otio_tl_metadata = context.data["otioTimeline"].metadata data.update({ "resolutionWidth": otio_tl_metadata["width"], "resolutionHeight": otio_tl_metadata["height"], "pixelAspect": otio_tl_metadata["pixelAspect"] }) def create_shot_instance(self, context, timeline_item, **data): hero_track = data.get("heroTrack") hierarchy_data = data.get("hierarchyData") if not hero_track: return if not hierarchy_data: return asset = data["asset"] subset = "shotMain" # insert family into families family = "shot" data.update({ "name": "{}_{}".format(asset, subset), "label": "{} {}".format(asset, subset), "subset": subset, "asset": asset, "family": family, "families": [], "publish": get_publish_attribute(timeline_item) }) context.create_instance(**data) ================================================ FILE: openpype/hosts/resolve/plugins/publish/precollect_workfile.py ================================================ import pyblish.api from pprint import pformat from openpype import AYON_SERVER_ENABLED from openpype.pipeline import get_current_asset_name from openpype.hosts.resolve import api as rapi from openpype.hosts.resolve.otio import davinci_export class PrecollectWorkfile(pyblish.api.ContextPlugin): """Precollect the current working file into context""" label = "Precollect Workfile" order = pyblish.api.CollectorOrder - 0.5 def process(self, context): current_asset_name = asset_name = get_current_asset_name() if AYON_SERVER_ENABLED: asset_name = current_asset_name.split("/")[-1] subset = "workfileMain" project = rapi.get_current_project() fps = project.GetSetting("timelineFrameRate") video_tracks = rapi.get_video_track_names() # adding otio timeline to context otio_timeline = davinci_export.create_otio_timeline(project) instance_data = { "name": "{}_{}".format(asset_name, subset), "label": "{} {}".format(current_asset_name, subset), "asset": current_asset_name, "subset": subset, "item": project, "family": "workfile", "families": [] } # create instance with workfile instance = context.create_instance(**instance_data) # update context with main project attributes context_data = { "activeProject": project, "otioTimeline": otio_timeline, "videoTracks": video_tracks, "currentFile": project.GetName(), "fps": fps, } context.data.update(context_data) self.log.info("Creating instance: {}".format(instance)) self.log.debug("__ instance.data: {}".format(pformat(instance.data))) self.log.debug("__ context_data: {}".format(pformat(context_data))) ================================================ FILE: openpype/hosts/resolve/startup.py ================================================ """This script is used as a startup script in Resolve through a .scriptlib file It triggers directly after the launch of Resolve and it's recommended to keep it optimized for fast performance since the Resolve UI is actually interactive while this is running. As such, there's nothing ensuring the user isn't continuing manually before any of the logic here runs. As such we also try to delay any imports as much as possible. This code runs in a separate process to the main Resolve process. """ import os from openpype.lib import Logger import openpype.hosts.resolve.api log = Logger.get_logger(__name__) def ensure_installed_host(): """Install resolve host with openpype and return the registered host. This function can be called multiple times without triggering an additional install. """ from openpype.pipeline import install_host, registered_host host = registered_host() if host: return host host = openpype.hosts.resolve.api.ResolveHost() install_host(host) return registered_host() def launch_menu(): print("Launching Resolve OpenPype menu..") ensure_installed_host() openpype.hosts.resolve.api.launch_pype_menu() def open_workfile(path): # Avoid the need to "install" the host host = ensure_installed_host() host.open_workfile(path) def main(): # Open last workfile workfile_path = os.environ.get("OPENPYPE_RESOLVE_OPEN_ON_LAUNCH") if workfile_path and os.path.exists(workfile_path): log.info(f"Opening last workfile: {workfile_path}") open_workfile(workfile_path) else: log.info("No last workfile set to open. Skipping..") # Launch OpenPype menu from openpype.settings import get_project_settings from openpype.pipeline.context_tools import get_current_project_name project_name = get_current_project_name() log.info(f"Current project name in context: {project_name}") settings = get_project_settings(project_name) if settings.get("resolve", {}).get("launch_openpype_menu_on_start", True): log.info("Launching OpenPype menu..") launch_menu() if __name__ == "__main__": main() ================================================ FILE: openpype/hosts/resolve/utility_scripts/AYON__Menu.py ================================================ import os import sys from openpype.pipeline import install_host from openpype.lib import Logger log = Logger.get_logger(__name__) def main(env): from openpype.hosts.resolve.api import ResolveHost, launch_pype_menu # activate resolve from openpype host = ResolveHost() install_host(host) launch_pype_menu() if __name__ == "__main__": result = main(os.environ) sys.exit(not bool(result)) ================================================ FILE: openpype/hosts/resolve/utility_scripts/OpenPype__Menu.py ================================================ import os import sys from openpype.pipeline import install_host from openpype.lib import Logger log = Logger.get_logger(__name__) def main(env): from openpype.hosts.resolve.api import ResolveHost, launch_pype_menu # activate resolve from openpype host = ResolveHost() install_host(host) launch_pype_menu() if __name__ == "__main__": result = main(os.environ) sys.exit(not bool(result)) ================================================ FILE: openpype/hosts/resolve/utility_scripts/develop/OTIO_export.py ================================================ #!/usr/bin/env python import os from openpype.hosts.resolve.otio import davinci_export as otio_export resolve = bmd.scriptapp("Resolve") # noqa fu = resolve.Fusion() ui = fu.UIManager disp = bmd.UIDispatcher(fu.UIManager) # noqa title_font = ui.Font({"PixelSize": 18}) dlg = disp.AddWindow( { "WindowTitle": "Export OTIO", "ID": "OTIOwin", "Geometry": [250, 250, 250, 100], "Spacing": 0, "Margin": 10 }, [ ui.VGroup( { "Spacing": 2 }, [ ui.Button( { "ID": "exportfilebttn", "Text": "Select Destination", "Weight": 1.25, "ToolTip": "Choose where to save the otio", "Flat": False } ), ui.VGap(), ui.Button( { "ID": "exportbttn", "Text": "Export", "Weight": 2, "ToolTip": "Export the current timeline", "Flat": False } ) ] ) ] ) itm = dlg.GetItems() def _close_window(event): disp.ExitLoop() def _export_button(event): pm = resolve.GetProjectManager() project = pm.GetCurrentProject() timeline = project.GetCurrentTimeline() otio_timeline = otio_export.create_otio_timeline(project) otio_path = os.path.join( itm["exportfilebttn"].Text, timeline.GetName() + ".otio") print(otio_path) otio_export.write_to_file( otio_timeline, otio_path) _close_window(None) def _export_file_pressed(event): selectedPath = fu.RequestDir(os.path.expanduser("~/Documents")) itm["exportfilebttn"].Text = selectedPath dlg.On.OTIOwin.Close = _close_window dlg.On.exportfilebttn.Clicked = _export_file_pressed dlg.On.exportbttn.Clicked = _export_button dlg.Show() disp.RunLoop() dlg.Hide() ================================================ FILE: openpype/hosts/resolve/utility_scripts/develop/OTIO_import.py ================================================ #!/usr/bin/env python import os from openpype.hosts.resolve.otio import davinci_import as otio_import resolve = bmd.scriptapp("Resolve") # noqa fu = resolve.Fusion() ui = fu.UIManager disp = bmd.UIDispatcher(fu.UIManager) # noqa title_font = ui.Font({"PixelSize": 18}) dlg = disp.AddWindow( { "WindowTitle": "Import OTIO", "ID": "OTIOwin", "Geometry": [250, 250, 250, 100], "Spacing": 0, "Margin": 10 }, [ ui.VGroup( { "Spacing": 2 }, [ ui.Button( { "ID": "importOTIOfileButton", "Text": "Select OTIO File Path", "Weight": 1.25, "ToolTip": "Choose otio file to import from", "Flat": False } ), ui.VGap(), ui.Button( { "ID": "importButton", "Text": "Import", "Weight": 2, "ToolTip": "Import otio to new timeline", "Flat": False } ) ] ) ] ) itm = dlg.GetItems() def _close_window(event): disp.ExitLoop() def _import_button(event): otio_import.read_from_file(itm["importOTIOfileButton"].Text) _close_window(None) def _import_file_pressed(event): selected_path = fu.RequestFile(os.path.expanduser("~/Documents")) itm["importOTIOfileButton"].Text = selected_path dlg.On.OTIOwin.Close = _close_window dlg.On.importOTIOfileButton.Clicked = _import_file_pressed dlg.On.importButton.Clicked = _import_button dlg.Show() disp.RunLoop() dlg.Hide() ================================================ FILE: openpype/hosts/resolve/utility_scripts/develop/OpenPype_sync_util_scripts.py ================================================ #!/usr/bin/env python import os import sys from openpype.pipeline import install_host def main(env): from openpype.hosts.resolve.utils import setup import openpype.hosts.resolve.api as bmdvr # Registers openpype's Global pyblish plugins install_host(bmdvr) setup(env) if __name__ == "__main__": result = main(os.environ) sys.exit(not bool(result)) ================================================ FILE: openpype/hosts/resolve/utility_scripts/openpype_startup.scriptlib ================================================ -- Run OpenPype's Python launch script for resolve function file_exists(name) local f = io.open(name, "r") return f ~= nil and io.close(f) end openpype_startup_script = os.getenv("OPENPYPE_RESOLVE_STARTUP_SCRIPT") if openpype_startup_script ~= nil then script = fusion:MapPath(openpype_startup_script) if file_exists(script) then -- We must use RunScript to ensure it runs in a separate -- process to Resolve itself to avoid a deadlock for -- certain imports of OpenPype libraries or Qt print("Running launch script: " .. script) fusion:RunScript(script) else print("Launch script not found at: " .. script) end end ================================================ FILE: openpype/hosts/resolve/utils.py ================================================ import os import shutil from openpype.lib import Logger, is_running_from_build from openpype import AYON_SERVER_ENABLED RESOLVE_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) def setup(env): log = Logger.get_logger("ResolveSetup") scripts = {} util_scripts_env = env.get("RESOLVE_UTILITY_SCRIPTS_SOURCE_DIR") util_scripts_dir = env["RESOLVE_UTILITY_SCRIPTS_DIR"] util_scripts_paths = [os.path.join( RESOLVE_ROOT_DIR, "utility_scripts" )] # collect script dirs if util_scripts_env: log.info("Utility Scripts Env: `{}`".format(util_scripts_env)) util_scripts_paths = util_scripts_env.split( os.pathsep) + util_scripts_paths # collect scripts from dirs for path in util_scripts_paths: scripts.update({path: os.listdir(path)}) log.info("Utility Scripts Dir: `{}`".format(util_scripts_paths)) log.info("Utility Scripts: `{}`".format(scripts)) # Make sure scripts dir exists os.makedirs(util_scripts_dir, exist_ok=True) # make sure no script file is in folder for script in os.listdir(util_scripts_dir): path = os.path.join(util_scripts_dir, script) log.info("Removing `{}`...".format(path)) if os.path.isdir(path): shutil.rmtree(path, onerror=None) else: os.remove(path) # copy scripts into Resolve's utility scripts dir for directory, scripts in scripts.items(): for script in scripts: if ( is_running_from_build() and script in ["tests", "develop"] ): # only copy those if started from build continue src = os.path.join(directory, script) dst = os.path.join(util_scripts_dir, script) # TODO: remove this once we have a proper solution if AYON_SERVER_ENABLED: if "OpenPype__Menu.py" == script: continue else: if "AYON__Menu.py" == script: continue # TODO: Make this a less hacky workaround if script == "openpype_startup.scriptlib": # Handle special case for scriptlib that needs to be a folder # up from the Comp folder in the Fusion scripts dst = os.path.join(os.path.dirname(util_scripts_dir), script) log.info("Copying `{}` to `{}`...".format(src, dst)) if os.path.isdir(src): shutil.copytree( src, dst, symlinks=False, ignore=None, ignore_dangling_symlinks=False ) else: shutil.copy2(src, dst) ================================================ FILE: openpype/hosts/standalonepublisher/__init__.py ================================================ from .addon import StandAlonePublishAddon __all__ = ( "StandAlonePublishAddon", ) ================================================ FILE: openpype/hosts/standalonepublisher/addon.py ================================================ import os from openpype.lib import get_openpype_execute_args from openpype.lib.execute import run_detached_process from openpype.modules import ( click_wrap, OpenPypeModule, ITrayAction, IHostAddon, ) STANDALONEPUBLISH_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) class StandAlonePublishAddon(OpenPypeModule, ITrayAction, IHostAddon): label = "Publisher (legacy)" name = "standalonepublisher" host_name = "standalonepublisher" def initialize(self, modules_settings): self.enabled = modules_settings["standalonepublish_tool"]["enabled"] self.publish_paths = [ os.path.join(STANDALONEPUBLISH_ROOT_DIR, "plugins", "publish") ] def tray_init(self): return def on_action_trigger(self): self.run_standalone_publisher() def connect_with_modules(self, enabled_modules): """Collect publish paths from other modules.""" publish_paths = self.manager.collect_plugin_paths()["publish"] self.publish_paths.extend(publish_paths) def run_standalone_publisher(self): args = get_openpype_execute_args("module", self.name, "launch") run_detached_process(args) def cli(self, click_group): click_group.add_command(cli_main.to_click_obj()) @click_wrap.group( StandAlonePublishAddon.name, help="StandalonePublisher related commands.") def cli_main(): pass @cli_main.command() def launch(): """Launch StandalonePublisher tool UI.""" from openpype.tools import standalonepublish standalonepublish.main() ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_app_name.py ================================================ import pyblish.api class CollectSAAppName(pyblish.api.ContextPlugin): """Collect app name and label.""" label = "Collect App Name/Label" order = pyblish.api.CollectorOrder - 0.5 hosts = ["standalonepublisher"] def process(self, context): context.data["appName"] = "standalone publisher" context.data["appLabel"] = "Standalone publisher" ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py ================================================ import copy import json import pyblish.api from openpype.client import get_asset_by_name from openpype.pipeline.create import get_subset_name class CollectBulkMovInstances(pyblish.api.InstancePlugin): """Collect all available instances for batch publish.""" label = "Collect Bulk Mov Instances" order = pyblish.api.CollectorOrder + 0.489 hosts = ["standalonepublisher"] families = ["render_mov_batch"] new_instance_family = "render" instance_task_names = [ "compositing", "comp" ] default_task_name = "compositing" subset_name_variant = "Default" def process(self, instance): context = instance.context project_name = context.data["projectEntity"]["name"] asset_name = instance.data["asset"] asset_doc = get_asset_by_name(project_name, asset_name) if not asset_doc: raise AssertionError(( "Couldn't find Asset document with name \"{}\"" ).format(asset_name)) available_task_names = {} asset_tasks = asset_doc.get("data", {}).get("tasks") or {} for task_name in asset_tasks.keys(): available_task_names[task_name.lower()] = task_name task_name = self.default_task_name for _task_name in self.instance_task_names: _task_name_low = _task_name.lower() if _task_name_low in available_task_names: task_name = available_task_names[_task_name_low] break subset_name = get_subset_name( self.new_instance_family, self.subset_name_variant, task_name, asset_doc, project_name, host_name=context.data["hostName"], project_settings=context.data["project_settings"] ) instance_name = f"{asset_name}_{subset_name}" # create new instance new_instance = context.create_instance(instance_name) new_instance_data = { "name": instance_name, "label": instance_name, "family": self.new_instance_family, "subset": subset_name, "task": task_name } new_instance.data.update(new_instance_data) # add original instance data except name key for key, value in instance.data.items(): if key in new_instance_data: continue # Make sure value is copy since value may be object which # can be shared across all new created objects new_instance.data[key] = copy.deepcopy(value) # Add `render_mov_batch` for specific validators if "families" not in new_instance.data: new_instance.data["families"] = [] new_instance.data["families"].append("render_mov_batch") # delete original instance context.remove(instance) self.log.info(f"Created new instance: {instance_name}") def converter(value): return str(value) self.log.debug("Instance data: {}".format( json.dumps(new_instance.data, indent=4, default=converter) )) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_context.py ================================================ """ Requires: environment -> SAPUBLISH_INPATH environment -> SAPUBLISH_OUTPATH Provides: context -> returnJsonPath (str) context -> project context -> asset instance -> destination_list (list) instance -> representations (list) instance -> source (list) instance -> representations """ import os import json import copy from pprint import pformat import clique import pyblish.api from openpype.pipeline import legacy_io class CollectContextDataSAPublish(pyblish.api.ContextPlugin): """ Collecting temp json data sent from a host context and path for returning json data back to hostself. """ label = "Collect Context - SA Publish" order = pyblish.api.CollectorOrder - 0.49 hosts = ["standalonepublisher"] # presets batch_extensions = ["edl", "xml", "psd"] def process(self, context): # get json paths from os and load them legacy_io.install() # get json file context input_json_path = os.environ.get("SAPUBLISH_INPATH") with open(input_json_path, "r") as f: in_data = json.load(f) self.log.debug(f"_ in_data: {pformat(in_data)}") self.add_files_to_ignore_cleanup(in_data, context) # exception for editorial if in_data["family"] == "render_mov_batch": in_data_list = self.prepare_mov_batch_instances(in_data) elif in_data["family"] in ["editorial", "background_batch"]: in_data_list = self.multiple_instances(context, in_data) else: in_data_list = [in_data] self.log.debug(f"_ in_data_list: {pformat(in_data_list)}") for in_data in in_data_list: # create instance self.create_instance(context, in_data) def add_files_to_ignore_cleanup(self, in_data, context): all_filepaths = context.data.get("skipCleanupFilepaths") or [] for repre in in_data["representations"]: files = repre["files"] if not isinstance(files, list): files = [files] dirpath = repre["stagingDir"] for filename in files: filepath = os.path.normpath(os.path.join(dirpath, filename)) if filepath not in all_filepaths: all_filepaths.append(filepath) context.data["skipCleanupFilepaths"] = all_filepaths def multiple_instances(self, context, in_data): # avoid subset name duplicity if not context.data.get("subsetNamesCheck"): context.data["subsetNamesCheck"] = list() in_data_list = list() representations = in_data.pop("representations") for repr in representations: in_data_copy = copy.deepcopy(in_data) ext = repr["ext"][1:] subset = in_data_copy["subset"] # filter out non editorial files if ext not in self.batch_extensions: in_data_copy["representations"] = [repr] in_data_copy["subset"] = f"{ext}{subset}" in_data_list.append(in_data_copy) files = repr.get("files") # delete unneeded keys delete_repr_keys = ["frameStart", "frameEnd"] for k in delete_repr_keys: if repr.get(k): repr.pop(k) # convert files to list if it isn't if not isinstance(files, (tuple, list)): files = [files] self.log.debug(f"_ files: {files}") for index, f in enumerate(files): index += 1 # copy dictionaries in_data_copy = copy.deepcopy(in_data_copy) repr_new = copy.deepcopy(repr) repr_new["files"] = f repr_new["name"] = ext in_data_copy["representations"] = [repr_new] # create subset Name new_subset = f"{ext}{index}{subset}" while new_subset in context.data["subsetNamesCheck"]: index += 1 new_subset = f"{ext}{index}{subset}" context.data["subsetNamesCheck"].append(new_subset) in_data_copy["subset"] = new_subset in_data_list.append(in_data_copy) self.log.info(f"Creating subset: {ext}{index}{subset}") return in_data_list def prepare_mov_batch_instances(self, in_data): """Copy of `multiple_instances` method. Method was copied because `batch_extensions` is used in `multiple_instances` but without any family filtering. Since usage of the filtering is unknown and modification of that part may break editorial or PSD batch publishing it was decided to create a copy with this family specific filtering. Also "frameStart" and "frameEnd" keys are removed from instance which is needed for this processing. Instance data will also care about families. TODO: - Merge possible logic with `multiple_instances` method. """ self.log.info("Preparing data for mov batch processing.") in_data_list = [] representations = in_data.pop("representations") for repre in representations: self.log.debug("Processing representation with files {}".format( str(repre["files"]) )) ext = repre["ext"][1:] # Rename representation name repre_name = repre["name"] if repre_name.startswith(ext + "_"): repre["name"] = ext # Skip files that are not available for mov batch publishing # TODO add dynamic expected extensions by family from `in_data` # - with this modification it would be possible to use only # `multiple_instances` method expected_exts = ["mov"] if ext not in expected_exts: self.log.warning(( "Skipping representation." " Does not match expected extensions <{}>. {}" ).format(", ".join(expected_exts), str(repre))) continue files = repre["files"] # Convert files to list if it isn't if not isinstance(files, (tuple, list)): files = [files] # Loop through files and create new instance per each file for filename in files: # Create copy of representation and change it's files and name new_repre = copy.deepcopy(repre) new_repre["files"] = filename new_repre["name"] = ext new_repre["thumbnail"] = True if "tags" not in new_repre: new_repre["tags"] = [] new_repre["tags"].append("review") # Prepare new subset name (temporary name) # - subset name will be changed in batch specific plugins new_subset_name = "{}{}".format( in_data["subset"], os.path.basename(filename) ) # Create copy of instance data as new instance and pass in new # representation in_data_copy = copy.deepcopy(in_data) in_data_copy["representations"] = [new_repre] in_data_copy["subset"] = new_subset_name if "families" not in in_data_copy: in_data_copy["families"] = [] in_data_copy["families"].append("review") in_data_list.append(in_data_copy) return in_data_list def create_instance(self, context, in_data): subset = in_data["subset"] # If instance data already contain families then use it instance_families = in_data.get("families") or [] instance = context.create_instance(subset) instance.data.update( { "subset": subset, "asset": in_data["asset"], "label": subset, "name": subset, "family": in_data["family"], "frameStart": in_data.get("representations", [None])[0].get( "frameStart", None ), "frameEnd": in_data.get("representations", [None])[0].get( "frameEnd", None ), "families": instance_families } ) # Fill version only if 'use_next_available_version' is disabled # and version is filled in instance data version = in_data.get("version") use_next_available_version = in_data.get( "use_next_available_version", True) if not use_next_available_version and version is not None: instance.data["version"] = version self.log.info("collected instance: {}".format(pformat(instance.data))) self.log.info("parsing data: {}".format(pformat(in_data))) instance.data["destination_list"] = list() instance.data["representations"] = list() instance.data["source"] = "standalone publisher" for component in in_data["representations"]: component["destination"] = component["files"] component["stagingDir"] = component["stagingDir"] if isinstance(component["files"], list): collections, _remainder = clique.assemble(component["files"]) self.log.debug("collecting sequence: {}".format(collections)) instance.data["frameStart"] = int(component["frameStart"]) instance.data["frameEnd"] = int(component["frameEnd"]) if component.get("fps"): instance.data["fps"] = int(component["fps"]) ext = component["ext"] if ext.startswith("."): component["ext"] = ext[1:] # Remove 'preview' key from representation data preview = component.pop("preview") if preview: instance.data["families"].append("review") component["tags"] = ["review"] self.log.debug("Adding review family") if "psd" in component["name"]: instance.data["source"] = component["files"] self.log.debug("Adding image:background_batch family") instance.data["representations"].append(component) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_editorial.py ================================================ """ Optional: presets -> extensions ( example of use: ["mov", "mp4"] ) presets -> source_dir ( example of use: "C:/pathToFolder" "{root}/{project[name]}/inputs" "{root[work]}/{project[name]}/inputs" "./input" "../input" "" ) """ import os import opentimelineio as otio import pyblish.api from openpype import lib as plib from openpype.pipeline.context_tools import get_current_project_asset class OTIO_View(pyblish.api.Action): """Currently disabled because OTIO requires PySide2. Issue on Qt.py: https://github.com/PixarAnimationStudios/OpenTimelineIO/issues/289 """ label = "OTIO View" icon = "wrench" on = "failed" def process(self, context, plugin): instance = context[0] representation = instance.data["representations"][0] file_path = os.path.join( representation["stagingDir"], representation["files"] ) plib.run_subprocess(["otioview", file_path]) class CollectEditorial(pyblish.api.InstancePlugin): """Collect Editorial OTIO timeline""" order = pyblish.api.CollectorOrder label = "Collect Editorial" hosts = ["standalonepublisher"] families = ["editorial"] actions = [] # presets extensions = ["mov", "mp4"] source_dir = None def process(self, instance): root_dir = None # remove context test attribute if instance.context.data.get("subsetNamesCheck"): instance.context.data.pop("subsetNamesCheck") self.log.debug(f"__ instance: `{instance}`") # get representation with editorial file for representation in instance.data["representations"]: self.log.debug(f"__ representation: `{representation}`") # make editorial sequence file path staging_dir = representation["stagingDir"] file_path = os.path.join( staging_dir, str(representation["files"]) ) instance.context.data["currentFile"] = file_path # get video file path video_path = None basename = os.path.splitext(os.path.basename(file_path))[0] if self.source_dir != "": source_dir = self.source_dir.replace("\\", "/") if ("./" in source_dir) or ("../" in source_dir): # get current working dir cwd = os.getcwd() # set cwd to staging dir for absolute path solving os.chdir(staging_dir) root_dir = os.path.abspath(source_dir) # set back original cwd os.chdir(cwd) elif "{" in source_dir: root_dir = source_dir else: root_dir = os.path.normpath(source_dir) if root_dir: # search for source data will need to be done instance.data["editorialSourceRoot"] = root_dir instance.data["editorialSourcePath"] = None else: # source data are already found for f in os.listdir(staging_dir): # filter out by not sharing the same name if os.path.splitext(f)[0] not in basename: continue # filter out by respected extensions if os.path.splitext(f)[1][1:] not in self.extensions: continue video_path = os.path.join( staging_dir, f ) self.log.debug(f"__ video_path: `{video_path}`") instance.data["editorialSourceRoot"] = staging_dir instance.data["editorialSourcePath"] = video_path instance.data["stagingDir"] = staging_dir # get editorial sequence file into otio timeline object extension = os.path.splitext(file_path)[1] kwargs = {} if extension == ".edl": # EDL has no frame rate embedded so needs explicit # frame rate else 24 is assumed. kwargs["rate"] = get_current_project_asset()["data"]["fps"] instance.data["otio_timeline"] = otio.adapters.read_from_file( file_path, **kwargs) self.log.info(f"Added OTIO timeline from: `{file_path}`") ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_instances.py ================================================ import os from copy import deepcopy import opentimelineio as otio import pyblish.api from openpype import lib as plib from openpype.pipeline.context_tools import get_current_project_asset class CollectInstances(pyblish.api.InstancePlugin): """Collect instances from editorial's OTIO sequence""" order = pyblish.api.CollectorOrder + 0.01 label = "Collect Editorial Instances" hosts = ["standalonepublisher"] families = ["editorial"] # presets subsets = { "referenceMain": { "family": "review", "families": ["clip"], "extensions": ["mp4"] }, "audioMain": { "family": "audio", "families": ["clip"], "extensions": ["wav"], } } timeline_frame_start = 900000 # starndard edl default (10:00:00:00) timeline_frame_offset = None custom_start_frame = None def process(self, instance): # get context context = instance.context instance_data_filter = [ "editorialSourceRoot", "editorialSourcePath" ] # attribute for checking duplicity during creation if not context.data.get("assetNameCheck"): context.data["assetNameCheck"] = list() # create asset_names conversion table if not context.data.get("assetsShared"): context.data["assetsShared"] = dict() # get timeline otio data timeline = instance.data["otio_timeline"] fps = get_current_project_asset()["data"]["fps"] tracks = timeline.each_child( descended_from_type=otio.schema.Track ) # get data from avalon asset_entity = instance.context.data["assetEntity"] asset_data = asset_entity["data"] asset_name = asset_entity["name"] # Timeline data. handle_start = int(asset_data["handleStart"]) handle_end = int(asset_data["handleEnd"]) for track in tracks: self.log.debug(f"track.name: {track.name}") try: track_start_frame = ( abs(track.source_range.start_time.value) ) self.log.debug(f"track_start_frame: {track_start_frame}") track_start_frame -= self.timeline_frame_start except AttributeError: track_start_frame = 0 self.log.debug(f"track_start_frame: {track_start_frame}") for clip in track.each_child(): if clip.name is None: continue if isinstance(clip, otio.schema.Gap): continue # skip all generators like black empty if isinstance( clip.media_reference, otio.schema.GeneratorReference): continue # Transitions are ignored, because Clips have the full frame # range. if isinstance(clip, otio.schema.Transition): continue # basic unique asset name clip_name = os.path.splitext(clip.name)[0].lower() name = f"{asset_name.split('_')[0]}_{clip_name}" if name not in context.data["assetNameCheck"]: context.data["assetNameCheck"].append(name) else: self.log.warning(f"duplicate shot name: {name}") # frame ranges data clip_in = clip.range_in_parent().start_time.value clip_in += track_start_frame clip_out = clip.range_in_parent().end_time_inclusive().value clip_out += track_start_frame self.log.info(f"clip_in: {clip_in} | clip_out: {clip_out}") # add offset in case there is any if self.timeline_frame_offset: clip_in += self.timeline_frame_offset clip_out += self.timeline_frame_offset clip_duration = clip.duration().value self.log.info(f"clip duration: {clip_duration}") source_in = clip.trimmed_range().start_time.value source_out = source_in + clip_duration source_in_h = source_in - handle_start source_out_h = source_out + handle_end clip_in_h = clip_in - handle_start clip_out_h = clip_out + handle_end # define starting frame for future shot if self.custom_start_frame is not None: frame_start = self.custom_start_frame else: frame_start = clip_in frame_end = frame_start + (clip_duration - 1) # create shared new instance data instance_data = { # shared attributes "asset": name, "assetShareName": name, "item": clip, "clipName": clip_name, # parent time properties "trackStartFrame": track_start_frame, "handleStart": handle_start, "handleEnd": handle_end, "fps": fps, # media source "sourceIn": source_in, "sourceOut": source_out, "sourceInH": source_in_h, "sourceOutH": source_out_h, # timeline "clipIn": clip_in, "clipOut": clip_out, "clipDuration": clip_duration, "clipInH": clip_in_h, "clipOutH": clip_out_h, "clipDurationH": clip_duration + handle_start + handle_end, # task "frameStart": frame_start, "frameEnd": frame_end, "frameStartH": frame_start - handle_start, "frameEndH": frame_end + handle_end, "newAssetPublishing": True } for data_key in instance_data_filter: instance_data.update({ data_key: instance.data.get(data_key)}) # adding subsets to context as instances self.subsets.update({ "shotMain": { "family": "shot", "families": [] } }) for subset, properties in self.subsets.items(): version = properties.get("version") if version == 0: properties.pop("version") # adding Review-able instance subset_instance_data = deepcopy(instance_data) subset_instance_data.update(deepcopy(properties)) subset_instance_data.update({ # unique attributes "name": f"{name}_{subset}", "label": f"{name} {subset} ({clip_in}-{clip_out})", "subset": subset }) # create new instance _instance = instance.context.create_instance( **subset_instance_data) self.log.debug( f"Instance: `{_instance}` | " f"families: `{subset_instance_data['families']}`") context.data["assetsShared"][name] = { "_clipIn": clip_in, "_clipOut": clip_out } self.log.debug("Instance: `{}` | families: `{}`") ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_editorial_resources.py ================================================ import os import re import tempfile import pyblish.api from copy import deepcopy import clique class CollectInstanceResources(pyblish.api.InstancePlugin): """Collect instance's resources""" # must be after `CollectInstances` order = pyblish.api.CollectorOrder + 0.011 label = "Collect Editorial Resources" hosts = ["standalonepublisher"] families = ["clip"] def process(self, instance): self.context = instance.context self.log.info(f"Processing instance: {instance}") self.new_instances = [] subset_files = dict() subset_dirs = list() anatomy = self.context.data["anatomy"] anatomy_data = deepcopy(self.context.data["anatomyData"]) anatomy_data.update({"root": anatomy.roots}) subset = instance.data["subset"] clip_name = instance.data["clipName"] editorial_source_root = instance.data["editorialSourceRoot"] editorial_source_path = instance.data["editorialSourcePath"] # if `editorial_source_path` then loop through if editorial_source_path: # add family if mov or mp4 found which is longer for # cutting `trimming` to enable `ExtractTrimmingVideoAudio` plugin staging_dir = os.path.normpath( tempfile.mkdtemp(prefix="pyblish_tmp_") ) instance.data["stagingDir"] = staging_dir instance.data["families"] += ["trimming"] return # if template pattern in path then fill it with `anatomy_data` if "{" in editorial_source_root: editorial_source_root = editorial_source_root.format( **anatomy_data) self.log.debug(f"root: {editorial_source_root}") # loop `editorial_source_root` and find clip name in folders # and look for any subset name alternatives for root, dirs, _files in os.walk(editorial_source_root): # search only for directories related to clip name correct_clip_dir = None for _d_search in dirs: # avoid all non clip dirs if _d_search not in clip_name: continue # found correct dir for clip correct_clip_dir = _d_search # continue if clip dir was not found if not correct_clip_dir: continue clip_dir_path = os.path.join(root, correct_clip_dir) subset_files_items = list() # list content of clip dir and search for subset items for subset_item in os.listdir(clip_dir_path): # avoid all items which are not defined as subsets by name if subset not in subset_item: continue subset_item_path = os.path.join( clip_dir_path, subset_item) # if it is dir store it to `subset_dirs` list if os.path.isdir(subset_item_path): subset_dirs.append(subset_item_path) # if it is file then store it to `subset_files` list if os.path.isfile(subset_item_path): subset_files_items.append(subset_item_path) if subset_files_items: subset_files.update({clip_dir_path: subset_files_items}) # break the loop if correct_clip_dir was captured # no need to cary on if correct folder was found if correct_clip_dir: break if subset_dirs: # look all dirs and check for subset name alternatives for _dir in subset_dirs: instance_data = deepcopy( {k: v for k, v in instance.data.items()}) sub_dir = os.path.basename(_dir) # if subset name is only alternative then create new instance if sub_dir != subset: instance_data = self.duplicate_instance( instance_data, subset, sub_dir) # create all representations self.create_representations( os.listdir(_dir), instance_data, _dir) if sub_dir == subset: self.new_instances.append(instance_data) # instance.data.update(instance_data) if subset_files: unique_subset_names = list() root_dir = list(subset_files.keys()).pop() files_list = subset_files[root_dir] search_pattern = f"({subset}[A-Za-z0-9]+)(?=[\\._\\s])" for _file in files_list: pattern = re.compile(search_pattern) match = pattern.findall(_file) if not match: continue match_subset = match.pop() if match_subset in unique_subset_names: continue unique_subset_names.append(match_subset) self.log.debug(f"unique_subset_names: {unique_subset_names}") for _un_subs in unique_subset_names: instance_data = self.duplicate_instance( instance.data, subset, _un_subs) # create all representations self.create_representations( [os.path.basename(f) for f in files_list if _un_subs in f], instance_data, root_dir) # remove the original instance as it had been used only # as template and is duplicated self.context.remove(instance) # create all instances in self.new_instances into context for new_instance in self.new_instances: _new_instance = self.context.create_instance( new_instance["name"]) _new_instance.data.update(new_instance) def duplicate_instance(self, instance_data, subset, new_subset): new_instance_data = dict() for _key, _value in instance_data.items(): new_instance_data[_key] = _value if not isinstance(_value, str): continue if subset in _value: new_instance_data[_key] = _value.replace( subset, new_subset) self.log.info(f"Creating new instance: {new_instance_data['name']}") self.new_instances.append(new_instance_data) return new_instance_data def create_representations( self, files_list, instance_data, staging_dir): """ Create representations from Collection object """ # collecting frames for later frame start/end reset frames = list() # break down Collection object to collections and reminders collections, remainder = clique.assemble(files_list) # add staging_dir to instance_data instance_data["stagingDir"] = staging_dir # add representations to instance_data instance_data["representations"] = list() collection_head_name = None # loop through collections and create representations for _collection in collections: ext = _collection.tail[1:] collection_head_name = _collection.head frame_start = list(_collection.indexes)[0] frame_end = list(_collection.indexes)[-1] repre_data = { "frameStart": frame_start, "frameEnd": frame_end, "name": ext, "ext": ext, "files": [item for item in _collection], "stagingDir": staging_dir } if instance_data.get("keepSequence"): repre_data_keep = deepcopy(repre_data) instance_data["representations"].append(repre_data_keep) if "review" in instance_data["families"]: repre_data.update({ "thumbnail": True, "frameStartFtrack": frame_start, "frameEndFtrack": frame_end, "step": 1, "fps": self.context.data.get("fps"), "name": "review", "tags": ["review", "ftrackreview", "delete"], }) instance_data["representations"].append(repre_data) # add to frames for frame range reset frames.append(frame_start) frames.append(frame_end) # loop through reminders and create representations for _reminding_file in remainder: ext = os.path.splitext(_reminding_file)[-1][1:] if ext not in instance_data["extensions"]: continue if collection_head_name and ( (collection_head_name + ext) not in _reminding_file ) and (ext in ["mp4", "mov"]): self.log.info(f"Skipping file: {_reminding_file}") continue frame_start = 1 frame_end = 1 repre_data = { "name": ext, "ext": ext, "files": _reminding_file, "stagingDir": staging_dir } # exception for thumbnail if "thumb" in _reminding_file: repre_data.update({ 'name': "thumbnail", 'thumbnail': True }) # exception for mp4 preview if ext in ["mp4", "mov"]: frame_start = 0 frame_end = ( (instance_data["frameEnd"] - instance_data["frameStart"]) + 1) # add review ftrack family into families for _family in ["review", "ftrack"]: if _family not in instance_data["families"]: instance_data["families"].append(_family) repre_data.update({ "frameStart": frame_start, "frameEnd": frame_end, "frameStartFtrack": frame_start, "frameEndFtrack": frame_end, "step": 1, "fps": self.context.data.get("fps"), "name": "review", "thumbnail": True, "tags": ["review", "ftrackreview", "delete"], }) # add to frames for frame range reset only if no collection if not collections: frames.append(frame_start) frames.append(frame_end) instance_data["representations"].append(repre_data) # reset frame start / end instance_data["frameStart"] = min(frames) instance_data["frameEnd"] = max(frames) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_scenes.py ================================================ # -*- coding: utf-8 -*- """Collect Harmony scenes in Standalone Publisher.""" import copy import glob import os from pprint import pformat import pyblish.api class CollectHarmonyScenes(pyblish.api.InstancePlugin): """Collect Harmony xstage files.""" order = pyblish.api.CollectorOrder + 0.498 label = "Collect Harmony Scene" hosts = ["standalonepublisher"] families = ["harmony.scene"] # presets ignored_instance_data_keys = ("name", "label", "stagingDir", "version") def process(self, instance): """Plugin entry point.""" context = instance.context asset_data = instance.context.data["assetEntity"] asset_name = instance.data["asset"] subset_name = instance.data.get("subset", "sceneMain") anatomy_data = instance.context.data["anatomyData"] repres = instance.data["representations"] staging_dir = repres[0]["stagingDir"] files = repres[0]["files"] if not files.endswith(".zip"): # A harmony project folder / .xstage was dropped instance_name = f"{asset_name}_{subset_name}" task = instance.data.get("task", "harmonyIngest") # create new instance new_instance = context.create_instance(instance_name) # add original instance data except name key for key, value in instance.data.items(): # Make sure value is copy since value may be object which # can be shared across all new created objects if key not in self.ignored_instance_data_keys: new_instance.data[key] = copy.deepcopy(value) self.log.info("Copied data: {}".format(new_instance.data)) # fix anatomy data anatomy_data_new = copy.deepcopy(anatomy_data) project_entity = context.data["projectEntity"] asset_entity = context.data["assetEntity"] task_type = asset_entity["data"]["tasks"].get(task, {}).get("type") project_task_types = project_entity["config"]["tasks"] task_code = project_task_types.get(task_type, {}).get("short_name") # updating hierarchy data anatomy_data_new.update({ "asset": asset_data["name"], "folder": { "name": asset_data["name"], }, "task": { "name": task, "type": task_type, "short": task_code, }, "subset": subset_name }) new_instance.data["label"] = f"{instance_name}" new_instance.data["subset"] = subset_name new_instance.data["extension"] = ".zip" new_instance.data["anatomyData"] = anatomy_data_new new_instance.data["publish"] = True # When a project folder was dropped vs. just an xstage file, find # the latest file xstage version and update the instance if not files.endswith(".xstage"): source_dir = os.path.join( staging_dir, files ).replace("\\", "/") latest_file = max(glob.iglob(source_dir + "/*.xstage"), key=os.path.getctime).replace("\\", "/") new_instance.data["representations"][0]["stagingDir"] = ( source_dir ) new_instance.data["representations"][0]["files"] = ( os.path.basename(latest_file) ) self.log.info(f"Created new instance: {instance_name}") self.log.debug(f"_ inst_data: {pformat(new_instance.data)}") # set original instance for removal self.log.info("Context data: {}".format(context.data)) instance.data["remove"] = True ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_harmony_zips.py ================================================ # -*- coding: utf-8 -*- """Collect zips as Harmony scene files.""" import copy from pprint import pformat import pyblish.api class CollectHarmonyZips(pyblish.api.InstancePlugin): """Collect Harmony zipped projects.""" order = pyblish.api.CollectorOrder + 0.497 label = "Collect Harmony Zipped Projects" hosts = ["standalonepublisher"] families = ["harmony.scene"] extensions = ["zip"] # presets ignored_instance_data_keys = ("name", "label", "stagingDir", "version") def process(self, instance): """Plugin entry point.""" context = instance.context asset_data = instance.context.data["assetEntity"] asset_name = instance.data["asset"] subset_name = instance.data.get("subset", "sceneMain") anatomy_data = instance.context.data["anatomyData"] repres = instance.data["representations"] files = repres[0]["files"] project_entity = context.data["projectEntity"] if files.endswith(".zip"): # A zip file was dropped instance_name = f"{asset_name}_{subset_name}" task = instance.data.get("task", "harmonyIngest") # create new instance new_instance = context.create_instance(instance_name) # add original instance data except name key for key, value in instance.data.items(): # Make sure value is copy since value may be object which # can be shared across all new created objects if key not in self.ignored_instance_data_keys: new_instance.data[key] = copy.deepcopy(value) self.log.info("Copied data: {}".format(new_instance.data)) task_type = asset_data["data"]["tasks"].get(task, {}).get("type") project_task_types = project_entity["config"]["tasks"] task_code = project_task_types.get(task_type, {}).get("short_name") # fix anatomy data anatomy_data_new = copy.deepcopy(anatomy_data) # updating hierarchy data anatomy_data_new.update( { "asset": asset_data["name"], "folder": { "name": asset_data["name"], }, "task": { "name": task, "type": task_type, "short": task_code, }, "subset": subset_name } ) new_instance.data["label"] = f"{instance_name}" new_instance.data["subset"] = subset_name new_instance.data["extension"] = ".zip" new_instance.data["anatomyData"] = anatomy_data_new new_instance.data["publish"] = True self.log.info(f"Created new instance: {instance_name}") self.log.debug(f"_ inst_data: {pformat(new_instance.data)}") # set original instance for removal self.log.info("Context data: {}".format(context.data)) instance.data["remove"] = True ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_hierarchy.py ================================================ import os from pprint import pformat import re from copy import deepcopy import pyblish.api from openpype.client import get_asset_by_id class CollectHierarchyInstance(pyblish.api.ContextPlugin): """Collecting hierarchy context from `parents` and `hierarchy` data present in `clip` family instances coming from the request json data file It will add `hierarchical_context` into each instance for integrate plugins to be able to create needed parents for the context if they don't exist yet """ label = "Collect Hierarchy Clip" order = pyblish.api.CollectorOrder + 0.101 hosts = ["standalonepublisher"] families = ["shot"] # presets shot_rename = True shot_rename_template = None shot_rename_search_patterns = None shot_add_hierarchy = None shot_add_tasks = None def convert_to_entity(self, key, value): # ftrack compatible entity types types = {"shot": "Shot", "folder": "Folder", "episode": "Episode", "sequence": "Sequence", "track": "Sequence", } # convert to entity type entity_type = types.get(key, None) # return if any if entity_type: return {"entity_type": entity_type, "entity_name": value} def rename_with_hierarchy(self, instance): search_text = "" parent_name = instance.context.data["assetEntity"]["name"] clip = instance.data["item"] clip_name = os.path.splitext(clip.name)[0].lower() if self.shot_rename_search_patterns and self.shot_rename: search_text += parent_name + clip_name instance.data["anatomyData"].update({"clip_name": clip_name}) for type, pattern in self.shot_rename_search_patterns.items(): p = re.compile(pattern) match = p.findall(search_text) if not match: continue instance.data["anatomyData"][type] = match[-1] # format to new shot name instance.data["asset"] = self.shot_rename_template.format( **instance.data["anatomyData"]) def create_hierarchy(self, instance): asset_doc = instance.context.data["assetEntity"] project_doc = instance.context.data["projectEntity"] project_name = project_doc["name"] visual_hierarchy = [asset_doc] current_doc = asset_doc while True: visual_parent_id = current_doc["data"]["visualParent"] visual_parent = None if visual_parent_id: visual_parent = get_asset_by_id(project_name, visual_parent_id) if not visual_parent: visual_hierarchy.append(project_doc) break visual_hierarchy.append(visual_parent) current_doc = visual_parent # add current selection context hierarchy from standalonepublisher parents = list() for entity in reversed(visual_hierarchy): parents.append({ "entity_type": entity["data"]["entityType"], "entity_name": entity["name"] }) hierarchy = list() if self.shot_add_hierarchy.get("enabled"): parent_template_patern = re.compile(r"\{([a-z]*?)\}") # fill the parents parts from presets shot_add_hierarchy = self.shot_add_hierarchy.copy() hierarchy_parents = shot_add_hierarchy["parents"].copy() # fill parent keys data template from anatomy data for parent_key in hierarchy_parents: hierarchy_parents[parent_key] = hierarchy_parents[ parent_key].format(**instance.data["anatomyData"]) for _index, _parent in enumerate( shot_add_hierarchy["parents_path"].split("/")): parent_filled = _parent.format(**hierarchy_parents) parent_key = parent_template_patern.findall(_parent).pop() # in case SP context is set to the same folder if (_index == 0) and ("folder" in parent_key) \ and (parents[-1]["entity_name"] == parent_filled): self.log.debug(f" skipping : {parent_filled}") continue # in case first parent is project then start parents from start if (_index == 0) and ("project" in parent_key): self.log.debug("rebuilding parents from scratch") project_parent = parents[0] parents = [project_parent] self.log.debug(f"project_parent: {project_parent}") self.log.debug(f"parents: {parents}") continue prnt = self.convert_to_entity( parent_key, parent_filled) parents.append(prnt) hierarchy.append(parent_filled) # convert hierarchy to string hierarchy = "/".join(hierarchy) # assign to instance data instance.data["hierarchy"] = hierarchy instance.data["parents"] = parents # print self.log.warning(f"Hierarchy: {hierarchy}") self.log.info(f"parents: {parents}") tasks_to_add = dict() if self.shot_add_tasks: project_tasks = project_doc["config"]["tasks"] for task_name, task_data in self.shot_add_tasks.items(): _task_data = deepcopy(task_data) # fixing enumerator from settings _task_data["type"] = task_data["type"][0] # check if task type in project task types if _task_data["type"] in project_tasks.keys(): tasks_to_add.update({task_name: _task_data}) else: raise KeyError( "Wrong FtrackTaskType `{}` for `{}` is not" " existing in `{}``".format( _task_data["type"], task_name, list(project_tasks.keys()))) instance.data["tasks"] = tasks_to_add # updating hierarchy data instance.data["anatomyData"].update({ "asset": instance.data["asset"], "task": "conform" }) def process(self, context): self.log.info("self.shot_add_hierarchy: {}".format( pformat(self.shot_add_hierarchy) )) for instance in context: if instance.data["family"] in self.families: self.processing_instance(instance) def processing_instance(self, instance): self.log.info(f"_ instance: {instance}") # adding anatomyData for burnins instance.data["anatomyData"] = deepcopy( instance.context.data["anatomyData"]) asset = instance.data["asset"] assets_shared = instance.context.data.get("assetsShared") frame_start = instance.data["frameStart"] frame_end = instance.data["frameEnd"] if self.shot_rename_template: self.rename_with_hierarchy(instance) self.create_hierarchy(instance) shot_name = instance.data["asset"] self.log.debug(f"Shot Name: {shot_name}") label = f"{shot_name} ({frame_start}-{frame_end})" instance.data["label"] = label # dealing with shared attributes across instances # with the same asset name if assets_shared.get(asset): asset_shared = assets_shared.get(asset) else: asset_shared = assets_shared[asset] asset_shared.update({ "asset": instance.data["asset"], "hierarchy": instance.data["hierarchy"], "parents": instance.data["parents"], "tasks": instance.data["tasks"], "anatomyData": instance.data["anatomyData"] }) class CollectHierarchyContext(pyblish.api.ContextPlugin): '''Collecting Hierarchy from instances and building context hierarchy tree ''' label = "Collect Hierarchy Context" order = pyblish.api.CollectorOrder + 0.102 hosts = ["standalonepublisher"] families = ["shot"] def update_dict(self, ex_dict, new_dict): for key in ex_dict: if key in new_dict and isinstance(ex_dict[key], dict): new_dict[key] = self.update_dict(ex_dict[key], new_dict[key]) else: if ex_dict.get(key) and new_dict.get(key): continue else: new_dict[key] = ex_dict[key] return new_dict def process(self, context): instances = context # create hierarchyContext attr if context has none assets_shared = context.data.get("assetsShared") final_context = {} for instance in instances: if 'editorial' in instance.data.get('family', ''): continue # inject assetsShared to other instances with # the same `assetShareName` attribute in data asset_shared_name = instance.data.get("assetShareName") s_asset_data = assets_shared.get(asset_shared_name) if s_asset_data: instance.data["asset"] = s_asset_data["asset"] instance.data["parents"] = s_asset_data["parents"] instance.data["hierarchy"] = s_asset_data["hierarchy"] instance.data["tasks"] = s_asset_data["tasks"] instance.data["anatomyData"] = s_asset_data["anatomyData"] # generate hierarchy data only on shot instances if 'shot' not in instance.data.get('family', ''): continue # get handles handle_start = int(instance.data["handleStart"]) handle_end = int(instance.data["handleEnd"]) in_info = {} # suppose that all instances are Shots in_info['entity_type'] = 'Shot' # get custom attributes of the shot in_info['custom_attributes'] = { "handleStart": handle_start, "handleEnd": handle_end, "frameStart": instance.data["frameStart"], "frameEnd": instance.data["frameEnd"], "clipIn": instance.data["clipIn"], "clipOut": instance.data["clipOut"], 'fps': instance.data["fps"] } in_info['tasks'] = instance.data['tasks'] from pprint import pformat parents = instance.data.get('parents', []) self.log.debug(f"parents: {pformat(parents)}") # Split by '/' for AYON where asset is a path name = instance.data["asset"].split("/")[-1] actual = {name: in_info} for parent in reversed(parents): next_dict = {} parent_name = parent["entity_name"] next_dict[parent_name] = {} next_dict[parent_name]["entity_type"] = parent["entity_type"] next_dict[parent_name]["childs"] = actual actual = next_dict final_context = self.update_dict(final_context, actual) # adding hierarchy context to instance context.data["hierarchyContext"] = final_context self.log.debug(f"hierarchyContext: {pformat(final_context)}") self.log.info("Hierarchy instance collected") ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_instance_data.py ================================================ """ Requires: Nothing Provides: Instance """ import pyblish.api from pprint import pformat class CollectInstanceData(pyblish.api.InstancePlugin): """ Collector with only one reason for its existence - remove 'ftrack' family implicitly added by Standalone Publisher """ label = "Collect instance data" order = pyblish.api.CollectorOrder + 0.49 families = ["render", "plate", "review"] hosts = ["standalonepublisher"] def process(self, instance): fps = instance.context.data["fps"] instance.data.update({ "fps": fps }) self.log.debug(f"instance.data: {pformat(instance.data)}") ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_matching_asset.py ================================================ import os import re import collections import pyblish.api from pprint import pformat from openpype.client import get_assets class CollectMatchingAssetToInstance(pyblish.api.InstancePlugin): """ Collecting temp json data sent from a host context and path for returning json data back to hostself. """ label = "Collect Matching Asset to Instance" order = pyblish.api.CollectorOrder - 0.05 hosts = ["standalonepublisher"] families = ["background_batch", "render_mov_batch"] # Version regex to parse asset name and version from filename version_regex = re.compile(r"^(.+)_v([0-9]+)$") def process(self, instance): source_filename = self.get_source_filename(instance) self.log.info("Looking for asset document for file \"{}\"".format( source_filename )) asset_name = os.path.splitext(source_filename)[0].lower() asset_docs_by_name = self.selection_children_by_name(instance) version_number = None # Always first check if source filename is in assets matching_asset_doc = asset_docs_by_name.get(asset_name) if matching_asset_doc is None: # Check if source file contain version in name self.log.debug(( "Asset doc by \"{}\" was not found trying version regex." ).format(asset_name)) regex_result = self.version_regex.findall(asset_name) if regex_result: _asset_name, _version_number = regex_result[0] matching_asset_doc = asset_docs_by_name.get(_asset_name) if matching_asset_doc: version_number = int(_version_number) if matching_asset_doc is None: for asset_name_low, asset_doc in asset_docs_by_name.items(): if asset_name_low in asset_name: matching_asset_doc = asset_doc break if not matching_asset_doc: self.log.debug("Available asset names {}".format( str(list(asset_docs_by_name.keys())) )) # TODO better error message raise AssertionError(( "Filename \"{}\" does not match" " any name of asset documents in database for your selection." ).format(source_filename)) instance.data["asset"] = matching_asset_doc["name"] instance.data["assetEntity"] = matching_asset_doc if version_number is not None: instance.data["version"] = version_number self.log.info( f"Matching asset found: {pformat(matching_asset_doc)}" ) def get_source_filename(self, instance): if instance.data["family"] == "background_batch": return os.path.basename(instance.data["source"]) if len(instance.data["representations"]) != 1: raise ValueError(( "Implementation bug: Instance data contain" " more than one representation." )) repre = instance.data["representations"][0] repre_files = repre["files"] if not isinstance(repre_files, str): raise ValueError(( "Implementation bug: Instance's representation contain" " unexpected value (expected single file). {}" ).format(str(repre_files))) return repre_files def selection_children_by_name(self, instance): storing_key = "childrenDocsForSelection" children_docs = instance.context.data.get(storing_key) if children_docs is None: top_asset_doc = instance.context.data["assetEntity"] assets_by_parent_id = self._asset_docs_by_parent_id(instance) _children_docs = self._children_docs( assets_by_parent_id, top_asset_doc ) children_docs = { children_doc["name"].lower(): children_doc for children_doc in _children_docs } instance.context.data[storing_key] = children_docs return children_docs def _children_docs(self, documents_by_parent_id, parent_doc): # Find all children in reverse order, last children is at first place. output = [] children = documents_by_parent_id.get(parent_doc["_id"]) or tuple() for child in children: output.extend( self._children_docs(documents_by_parent_id, child) ) output.append(parent_doc) return output def _asset_docs_by_parent_id(self, instance): # Query all assets for project and store them by parent's id to list project_name = instance.context.data["projectEntity"]["name"] asset_docs_by_parent_id = collections.defaultdict(list) for asset_doc in get_assets(project_name): parent_id = asset_doc["data"]["visualParent"] asset_docs_by_parent_id[parent_id].append(asset_doc) return asset_docs_by_parent_id ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_remove_marked.py ================================================ # -*- coding: utf-8 -*- """Collect instances that are marked for removal and remove them.""" import pyblish.api class CollectRemoveMarked(pyblish.api.ContextPlugin): """Clean up instances marked for removal. Note: This is a workaround for race conditions and removing of instances used to generate other instances. """ order = pyblish.api.CollectorOrder + 0.499 label = 'Remove Marked Instances' def process(self, context): """Plugin entry point.""" for instance in context: if instance.data.get('remove'): context.remove(instance) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_representation_names.py ================================================ import re import os import pyblish.api class CollectRepresentationNames(pyblish.api.InstancePlugin): """ Sets the representation names for given families based on RegEx filter """ label = "Collect Representation Names" order = pyblish.api.CollectorOrder families = [] hosts = ["standalonepublisher"] name_filter = "" def process(self, instance): for repre in instance.data['representations']: new_repre_name = None if isinstance(repre['files'], list): shortened_name = os.path.splitext(repre['files'][0])[0] new_repre_name = re.search(self.name_filter, shortened_name).group() else: new_repre_name = re.search(self.name_filter, repre['files']).group() if new_repre_name: repre['name'] = new_repre_name repre['outputName'] = repre['name'] ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/collect_texture.py ================================================ import os import re import pyblish.api import json from openpype.lib import ( prepare_template_data, StringTemplate, ) class CollectTextures(pyblish.api.ContextPlugin): """Collect workfile (and its resource_files) and textures. Currently implements use case with Mari and Substance Painter, where one workfile is main (.mra - Mari) with possible additional workfiles (.spp - Substance) Provides: 1 instance per workfile (with 'resources' filled if needed) (workfile family) 1 instance per group of textures (textures family) """ order = pyblish.api.CollectorOrder label = "Collect Textures" hosts = ["standalonepublisher"] families = ["texture_batch"] actions = [] # from presets main_workfile_extensions = ['mra'] other_workfile_extensions = ['spp', 'psd'] texture_extensions = ["exr", "dpx", "jpg", "jpeg", "png", "tiff", "tga", "gif", "svg"] # additional families (ftrack etc.) workfile_families = [] textures_families = [] color_space = ["linsRGB", "raw", "acesg"] # currently implemented placeholders ["color_space"] # describing patterns in file names splitted by regex groups input_naming_patterns = { # workfile: corridorMain_v001.mra > # texture: corridorMain_aluminiumID_v001_baseColor_linsRGB_1001.exr "workfile": r'^([^.]+)(_[^_.]*)?_v([0-9]{3,}).+', "textures": r'^([^_.]+)_([^_.]+)_v([0-9]{3,})_([^_.]+)_({color_space})_(1[0-9]{3}).+', # noqa } # matching regex group position to 'input_naming_patterns' input_naming_groups = { "workfile": ('asset', 'filler', 'version'), "textures": ('asset', 'shader', 'version', 'channel', 'color_space', 'udim') } workfile_subset_template = "textures{Subset}Workfile" # implemented keys: ["color_space", "channel", "subset", "shader"] texture_subset_template = "textures{Subset}_{Shader}_{Channel}" def process(self, context): self.context = context resource_files = {} workfile_files = {} representations = {} version_data = {} asset_builds = set() asset = None for instance in context: if not self.input_naming_patterns: raise ValueError("Naming patterns are not configured. \n" "Ask admin to provide naming conventions " "for workfiles and textures.") if not asset: asset = instance.data["asset"] # selected from SP parsed_subset = instance.data["subset"].replace( instance.data["family"], '') explicit_data = { "subset": parsed_subset } processed_instance = False for repre in instance.data["representations"]: ext = repre["ext"].replace('.', '') asset_build = version = None if isinstance(repre["files"], list): repre_file = repre["files"][0] else: repre_file = repre["files"] if ext in self.main_workfile_extensions or \ ext in self.other_workfile_extensions: formatting_data = self._get_parsed_groups( repre_file, self.input_naming_patterns["workfile"], self.input_naming_groups["workfile"], self.color_space ) self.log.info("Parsed groups from workfile " "name '{}': {}".format(repre_file, formatting_data)) formatting_data.update(explicit_data) fill_pairs = prepare_template_data(formatting_data) workfile_subset = StringTemplate.format_strict_template( self.workfile_subset_template, fill_pairs ) asset_build = self._get_asset_build( repre_file, self.input_naming_patterns["workfile"], self.input_naming_groups["workfile"], self.color_space ) version = self._get_version( repre_file, self.input_naming_patterns["workfile"], self.input_naming_groups["workfile"], self.color_space ) asset_builds.add((asset_build, version, workfile_subset, 'workfile')) processed_instance = True if not representations.get(workfile_subset): representations[workfile_subset] = [] if ext in self.main_workfile_extensions: # workfiles can have only single representation # currently OP is not supporting different extensions in # representation files representations[workfile_subset] = [repre] workfile_files[asset_build] = repre_file if ext in self.other_workfile_extensions: # add only if not added already from main if not representations.get(workfile_subset): representations[workfile_subset] = [repre] # only overwrite if not present if not workfile_files.get(asset_build): workfile_files[asset_build] = repre_file if not resource_files.get(workfile_subset): resource_files[workfile_subset] = [] item = { "files": [os.path.join(repre["stagingDir"], repre["files"])], "source": "standalone publisher" } resource_files[workfile_subset].append(item) if ext in self.texture_extensions: formatting_data = self._get_parsed_groups( repre_file, self.input_naming_patterns["textures"], self.input_naming_groups["textures"], self.color_space ) self.log.info("Parsed groups from texture " "name '{}': {}".format(repre_file, formatting_data)) c_space = self._get_color_space( repre_file, self.color_space ) # optional value channel = self._get_channel_name( repre_file, self.input_naming_patterns["textures"], self.input_naming_groups["textures"], self.color_space ) # optional value shader = self._get_shader_name( repre_file, self.input_naming_patterns["textures"], self.input_naming_groups["textures"], self.color_space ) explicit_data = { "color_space": c_space or '', # None throws exception "channel": channel or '', "shader": shader or '', "subset": parsed_subset or '' } formatting_data.update(explicit_data) fill_pairs = prepare_template_data(formatting_data) subset = StringTemplate.format_strict_template( self.texture_subset_template, fill_pairs ) asset_build = self._get_asset_build( repre_file, self.input_naming_patterns["textures"], self.input_naming_groups["textures"], self.color_space ) version = self._get_version( repre_file, self.input_naming_patterns["textures"], self.input_naming_groups["textures"], self.color_space ) if not representations.get(subset): representations[subset] = [] representations[subset].append(repre) ver_data = { "color_space": c_space or '', "channel_name": channel or '', "shader_name": shader or '' } version_data[subset] = ver_data asset_builds.add( (asset_build, version, subset, "textures")) processed_instance = True if processed_instance: self.context.remove(instance) self._create_new_instances(context, asset, asset_builds, resource_files, representations, version_data, workfile_files) def _create_new_instances(self, context, asset, asset_builds, resource_files, representations, version_data, workfile_files): """Prepare new instances from collected data. Args: context (ContextPlugin) asset (string): selected asset from SP asset_builds (set) of tuples (asset_build, version, subset, family) resource_files (list) of resource dicts - to store additional files to main workfile representations (list) of dicts - to store workfile info OR all collected texture files, key is asset_build version_data (dict) - prepared to store into version doc in DB workfile_files (dict) - to store workfile to add to textures key is asset_build """ # sort workfile first asset_builds = sorted(asset_builds, key=lambda tup: tup[3], reverse=True) # workfile must have version, textures might main_version = None for asset_build, version, subset, family in asset_builds: if not main_version: main_version = version try: version_int = int(version or main_version or 1) except ValueError: self.log.error("Parsed version {} is not " "an number".format(version)) new_instance = context.create_instance(subset) new_instance.data.update( { "subset": subset, "asset": asset, "label": subset, "name": subset, "family": family, "version": version_int, "asset_build": asset_build # remove in validator } ) workfile = workfile_files.get(asset_build) if resource_files.get(subset): # add resources only when workfile is main style for ext in self.main_workfile_extensions: if ext in workfile: new_instance.data.update({ "resources": resource_files.get(subset) }) break # store origin if family == 'workfile': families = self.workfile_families families.append("texture_batch_workfile") new_instance.data["source"] = "standalone publisher" else: families = self.textures_families repre = representations.get(subset)[0] new_instance.context.data["currentFile"] = os.path.join( repre["stagingDir"], workfile or 'dummy.txt') new_instance.data["families"] = families # add data for version document ver_data = version_data.get(subset) if ver_data: if workfile: ver_data['workfile'] = workfile new_instance.data.update( {"versionData": ver_data} ) upd_representations = representations.get(subset) if upd_representations and family != 'workfile': upd_representations = self._update_representations( upd_representations) new_instance.data["representations"] = upd_representations self.log.debug("new instance - {}:: {}".format( family, json.dumps(new_instance.data, indent=4))) def _get_asset_build(self, name, input_naming_patterns, input_naming_groups, color_spaces): """Loops through configured workfile patterns to find asset name. Asset name used to bind workfile and its textures. Args: name (str): workfile name input_naming_patterns (list): [workfile_pattern] or [texture_pattern] input_naming_groups (list) ordinal position of regex groups matching to input_naming.. color_spaces (list) - predefined color spaces """ asset_name = "NOT_AVAIL" return (self._parse_key(name, input_naming_patterns, input_naming_groups, color_spaces, 'asset') or asset_name) def _get_version(self, name, input_naming_patterns, input_naming_groups, color_spaces): found = self._parse_key(name, input_naming_patterns, input_naming_groups, color_spaces, 'version') if found: return found.replace('v', '') self.log.info("No version found in the name {}".format(name)) def _get_udim(self, name, input_naming_patterns, input_naming_groups, color_spaces): """Parses from 'name' udim value.""" found = self._parse_key(name, input_naming_patterns, input_naming_groups, color_spaces, 'udim') if found: return found self.log.warning("Didn't find UDIM in {}".format(name)) def _get_color_space(self, name, color_spaces): """Looks for color_space from a list in a file name. Color space seems not to be recognizable by regex pattern, set of known space spaces must be provided. """ color_space = None found = [cs for cs in color_spaces if re.search("_{}_".format(cs), name)] if not found: self.log.warning("No color space found in {}".format(name)) else: if len(found) > 1: msg = "Multiple color spaces found in {}->{}".format(name, found) self.log.warning(msg) color_space = found[0] return color_space def _get_shader_name(self, name, input_naming_patterns, input_naming_groups, color_spaces): """Return parsed shader name. Shader name is needed for overlapping udims (eg. udims might be used for different materials, shader needed to not overwrite). Unknown format of channel name and color spaces >> cs are known list - 'color_space' used as a placeholder """ found = None try: found = self._parse_key(name, input_naming_patterns, input_naming_groups, color_spaces, 'shader') except ValueError: self.log.warning("Didn't find shader in {}".format(name)) return found def _get_channel_name(self, name, input_naming_patterns, input_naming_groups, color_spaces): """Return parsed channel name. Unknown format of channel name and color spaces >> cs are known list - 'color_space' used as a placeholder """ found = None try: found = self._parse_key(name, input_naming_patterns, input_naming_groups, color_spaces, 'channel') except ValueError: self.log.warning("Didn't find channel in {}".format(name)) return found def _parse_key(self, name, input_naming_patterns, input_naming_groups, color_spaces, key): """Universal way to parse 'name' with configurable regex groups. Args: name (str): workfile name input_naming_patterns (list): [workfile_pattern] or [texture_pattern] input_naming_groups (list) ordinal position of regex groups matching to input_naming.. color_spaces (list) - predefined color spaces Raises: ValueError - if broken 'input_naming_groups' """ parsed_groups = self._get_parsed_groups(name, input_naming_patterns, input_naming_groups, color_spaces) try: parsed_value = parsed_groups[key] return parsed_value except (IndexError, KeyError): msg = ("'Textures group positions' must " + "have '{}' key".format(key)) raise ValueError(msg) def _get_parsed_groups(self, name, input_naming_patterns, input_naming_groups, color_spaces): """Universal way to parse 'name' with configurable regex groups. Args: name (str): workfile name or texture name input_naming_patterns (list): [workfile_pattern] or [texture_pattern] input_naming_groups (list) ordinal position of regex groups matching to input_naming.. color_spaces (list) - predefined color spaces Returns: (dict) {group_name:parsed_value} """ for input_pattern in input_naming_patterns: for cs in color_spaces: pattern = input_pattern.replace('{color_space}', cs) regex_result = re.findall(pattern, name) if regex_result: if len(regex_result[0]) == len(input_naming_groups): return dict(zip(input_naming_groups, regex_result[0])) else: self.log.warning("No of parsed groups doesn't match " "no of group labels") raise ValueError("Name '{}' cannot be parsed by any " "'{}' patterns".format(name, input_naming_patterns)) def _update_representations(self, upd_representations): """Frames dont have sense for textures, add collected udims instead.""" udims = [] for repre in upd_representations: repre.pop("frameStart", None) repre.pop("frameEnd", None) repre.pop("fps", None) # ignore unique name from SP, use extension instead # SP enforces unique name, here different subsets >> unique repres repre["name"] = repre["ext"].replace('.', '') files = repre.get("files", []) if not isinstance(files, list): files = [files] for file_name in files: udim = self._get_udim(file_name, self.input_naming_patterns["textures"], self.input_naming_groups["textures"], self.color_space) udims.append(udim) repre["udim"] = udims # must be this way, used for filling path return upd_representations ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/extract_resources.py ================================================ import os import pyblish.api class ExtractResources(pyblish.api.InstancePlugin): """ Extracts files from instance.data["resources"]. These files are additional (textures etc.), currently not stored in representations! Expects collected 'resourcesDir'. (list of dicts with 'files' key and list of source urls) Provides filled 'transfers' (list of tuples (source_url, target_url)) """ label = "Extract Resources SP" hosts = ["standalonepublisher"] order = pyblish.api.ExtractorOrder families = ["workfile"] def process(self, instance): if not instance.data.get("resources"): self.log.info("No resources") return if not instance.data.get("transfers"): instance.data["transfers"] = [] publish_dir = instance.data["resourcesDir"] transfers = [] for resource in instance.data["resources"]: for file_url in resource.get("files", []): file_name = os.path.basename(file_url) dest_url = os.path.join(publish_dir, file_name) transfers.append((file_url, dest_url)) self.log.info("transfers:: {}".format(transfers)) instance.data["transfers"].extend(transfers) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py ================================================ import os import subprocess import tempfile import pyblish.api from openpype.lib import ( get_ffmpeg_tool_args, get_ffprobe_streams, path_to_subprocess_arg, run_subprocess, ) class ExtractThumbnailSP(pyblish.api.InstancePlugin): """Extract jpeg thumbnail from component input from standalone publisher Uses jpeg file from component if possible (when single or multiple jpegs are loaded to component selected as thumbnail) otherwise extracts from input file/s single jpeg to temp. """ label = "Extract Thumbnail SP" hosts = ["standalonepublisher"] order = pyblish.api.ExtractorOrder # Presetable attribute ffmpeg_args = None def process(self, instance): repres = instance.data.get('representations') if not repres: return thumbnail_repre = None for repre in repres: if repre.get("thumbnail"): thumbnail_repre = repre break if not thumbnail_repre: return thumbnail_repre.pop("thumbnail") files = thumbnail_repre.get("files") if not files: return if isinstance(files, list): first_filename = str(files[0]) else: first_filename = files # Convert to jpeg if not yet full_input_path = os.path.join( thumbnail_repre["stagingDir"], first_filename ) self.log.info("input {}".format(full_input_path)) with tempfile.NamedTemporaryFile(suffix=".jpg") as tmp: full_thumbnail_path = tmp.name self.log.info("output {}".format(full_thumbnail_path)) instance.context.data["cleanupFullPaths"].append(full_thumbnail_path) ffmpeg_executable_args = get_ffmpeg_tool_args("ffmpeg") ffmpeg_args = self.ffmpeg_args or {} jpeg_items = [ subprocess.list2cmdline(ffmpeg_executable_args), # override file if already exists "-y" ] # add input filters from peresets jpeg_items.extend(ffmpeg_args.get("input") or []) # input file jpeg_items.extend([ "-i", path_to_subprocess_arg(full_input_path), # extract only single file "-frames:v", "1", # Add black background for transparent images "-filter_complex", ( "\"color=black,format=rgb24[c]" ";[c][0]scale2ref[c][i]" ";[c][i]overlay=format=auto:shortest=1,setsar=1\"" ), ]) jpeg_items.extend(ffmpeg_args.get("output") or []) # output file jpeg_items.append(path_to_subprocess_arg(full_thumbnail_path)) subprocess_jpeg = " ".join(jpeg_items) if os.getenv("SHELL") in ("/bin/bash", "/bin/sh"): # Escape parentheses for bash subprocess_jpeg = ( subprocess_jpeg .replace("(", "\\(") .replace(")", "\\)") ) # run subprocess self.log.debug("Executing: {}".format(subprocess_jpeg)) run_subprocess( subprocess_jpeg, shell=True, logger=self.log ) # remove thumbnail key from origin repre streams = get_ffprobe_streams(full_thumbnail_path) width = height = None for stream in streams: if "width" in stream and "height" in stream: width = stream["width"] height = stream["height"] break staging_dir, filename = os.path.split(full_thumbnail_path) # create new thumbnail representation representation = { 'name': 'thumbnail', 'ext': 'jpg', 'files': filename, "stagingDir": staging_dir, "tags": ["thumbnail", "delete"], "thumbnail": True } if width and height: representation["width"] = width representation["height"] = height self.log.info(f"New representation {representation}") instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/extract_workfile_location.py ================================================ import os import pyblish.api class ExtractWorkfileUrl(pyblish.api.ContextPlugin): """ Modifies 'workfile' field to contain link to published workfile. Expects that batch contains only single workfile and matching (multiple) textures. """ label = "Extract Workfile Url SP" hosts = ["standalonepublisher"] order = pyblish.api.ExtractorOrder families = ["textures"] def process(self, context): filepath = None # first loop for workfile for instance in context: if instance.data["family"] == 'workfile': anatomy = context.data['anatomy'] template_data = instance.data.get("anatomyData") rep_name = instance.data.get("representations")[0].get("name") template_data["representation"] = rep_name template_data["ext"] = rep_name template_obj = anatomy.templates_obj["publish"]["path"] template_filled = template_obj.format_strict(template_data) filepath = os.path.normpath(template_filled) self.log.info("Using published scene for render {}".format( filepath)) break if not filepath: self.log.info("Texture batch doesn't contain workfile.") return # then apply to all textures for instance in context: if instance.data["family"] == 'textures': instance.data["versionData"]["workfile"] = filepath ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/help/validate_editorial_resources.xml ================================================ Missing source video file ## No attached video file found Process expects presence of source video file with same name prefix as an editorial file in same folder. (example `simple_editorial_setup_Layer1.edl` expects `simple_editorial_setup.mp4` in same folder) ### How to repair? Copy source video file to the folder next to `.edl` file. (On a disk, do not put it into Standalone Publisher.) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/help/validate_frame_ranges.xml ================================================ Invalid frame range ## Invalid frame range Expected duration or '{duration}' frames set in database, workfile contains only '{found}' frames. ### How to repair? Modify configuration in the database or tweak frame range in the workfile. ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/help/validate_shot_duplicates.xml ================================================ Duplicate shots ## Duplicate shot names Process contains duplicated shot names '{duplicates_str}'. ### How to repair? Remove shot duplicates. ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/help/validate_simple_texture_naming.xml ================================================ Invalid texture name ## Invalid file name Submitted file has invalid name: '{invalid_file}' ### How to repair? Texture file must adhere to naming conventions for Unreal: T_{asset}_*.ext ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/help/validate_sources.xml ================================================ Files not found ## Source files not found Process contains duplicated shot names: '{files_not_found}' ### How to repair? Add missing files or run Publish again to collect new publishable files. ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/help/validate_task_existence.xml ================================================ Task not found ## Task not found in database Process contains tasks that don't exist in database: '{task_not_found}' ### How to repair? Remove set task or add task into database into proper place. ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_batch.xml ================================================ No texture files found ## Batch doesn't contain texture files Batch must contain at least one texture file. ### How to repair? Add texture file to the batch or check name if it follows naming convention to match texture files to the batch. ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_has_workfile.xml ================================================ No workfile found ## Batch should contain workfile It is expected that published contains workfile that served as a source for textures. ### How to repair? Add workfile to the batch, or disable this validator if you do not want workfile published. ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_name.xml ================================================ Asset name not found ## Couldn't parse asset name from a file Unable to parse asset name from '{file_name}'. File name doesn't match configured naming convention. ### How to repair? Check Settings: project_settings/standalonepublisher/publish/CollectTextures for naming convention. ### __Detailed Info__ (optional) This error happens when parsing cannot figure out name of asset texture files belong under. Missing keys ## Texture file name is missing some required keys Texture '{file_name}' is missing values for {missing_str} keys. ### How to repair? Fix name of texture file and Publish again. ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_versions.xml ================================================ Texture version ## Texture version mismatch with workfile Workfile '{file_name}' version doesn't match with '{version}' of a texture. ### How to repair? Rename either workfile or texture to contain matching versions ### __Detailed Info__ (optional) This might happen if you are trying to publish textures for older version of workfile (or the other way). (Eg. publishing 'workfile_v001' and 'texture_file_v002') Too many versions ## Too many versions published at same time It is currently expected to publish only batch with single version. Found {found} versions. ### How to repair? Please remove files with different version and split publishing into multiple steps. ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/help/validate_texture_workfiles.xml ================================================ No secondary workfile ## No secondary workfile found Current process expects that primary workfile (for example with a extension '{extension}') will contain also 'secondary' workfile. Secondary workfile for '{file_name}' wasn't found. ### How to repair? Attach secondary workfile or disable this validator and Publish again. ### __Detailed Info__ (optional) This process was implemented for a possible use case of first workfile coming from Mari, secondary workfile for textures from Substance. Publish should contain both if primary workfile is present. ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/validate_editorial_resources.py ================================================ import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, ) class ValidateEditorialResources(pyblish.api.InstancePlugin): """Validate there is a "mov" next to the editorial file.""" label = "Validate Editorial Resources" hosts = ["standalonepublisher"] families = ["clip", "trimming"] # make sure it is enabled only if at least both families are available match = pyblish.api.Subset order = ValidateContentsOrder def process(self, instance): self.log.debug( f"Instance: {instance}, Families: " f"{[instance.data['family']] + instance.data['families']}") check_file = instance.data["editorialSourcePath"] msg = "Missing source video file." if not check_file: raise PublishXmlValidationError(self, msg) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/validate_frame_ranges.py ================================================ import re import pyblish.api from openpype.pipeline.context_tools import get_current_project_asset from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, ) class ValidateFrameRange(pyblish.api.InstancePlugin): """Validating frame range of rendered files against state in DB.""" label = "Validate Frame Range" hosts = ["standalonepublisher"] families = ["render"] order = ValidateContentsOrder optional = True # published data might be sequence (.mov, .mp4) in that counting files # doesnt make sense check_extensions = ["exr", "dpx", "jpg", "jpeg", "png", "tiff", "tga", "gif", "svg"] skip_timelines_check = [] # skip for specific task names (regex) def process(self, instance): if any(re.search(pattern, instance.data["task"]) for pattern in self.skip_timelines_check): self.log.info("Skipping for {} task".format(instance.data["task"])) # TODO replace query with using 'instance.data["assetEntity"]' asset_data = get_current_project_asset(instance.data["asset"])["data"] frame_start = asset_data["frameStart"] frame_end = asset_data["frameEnd"] handle_start = asset_data["handleStart"] handle_end = asset_data["handleEnd"] duration = (frame_end - frame_start + 1) + handle_start + handle_end repre = instance.data.get("representations", [None]) if not repre: self.log.info("No representations, skipping.") return ext = repre[0]['ext'].replace(".", '') if not ext or ext.lower() not in self.check_extensions: self.log.warning("Cannot check for extension {}".format(ext)) return files = instance.data.get("representations", [None])[0]["files"] if isinstance(files, str): files = [files] frames = len(files) msg = "Frame duration from DB:'{}' ". format(int(duration)) +\ " doesn't match number of files:'{}'".format(frames) +\ " Please change frame range for Asset or limit no. of files" formatting_data = {"duration": duration, "found": frames} if frames != duration: raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) self.log.debug("Valid ranges expected '{}' - found '{}'". format(int(duration), frames)) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/validate_shot_duplicates.py ================================================ import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, ) class ValidateShotDuplicates(pyblish.api.ContextPlugin): """Validating no duplicate names are in context.""" label = "Validate Shot Duplicates" hosts = ["standalonepublisher"] order = ValidateContentsOrder def process(self, context): shot_names = [] duplicate_names = [] for instance in context: name = instance.data["name"] if name in shot_names: duplicate_names.append(name) else: shot_names.append(name) msg = "There are duplicate shot names:\n{}".format(duplicate_names) formatting_data = {"duplicates_str": ','.join(duplicate_names)} if duplicate_names: raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py ================================================ import os import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, ) class ValidateSources(pyblish.api.InstancePlugin): """Validates source files. Loops through all 'files' in 'stagingDir' if actually exist. They might got deleted between starting of SP and now. """ order = ValidateContentsOrder label = "Check source files" optional = True # only for unforeseeable cases hosts = ["standalonepublisher"] def process(self, instance): self.log.info("instance {}".format(instance.data)) missing_files = set() for repre in instance.data.get("representations") or []: files = [] if isinstance(repre["files"], str): files.append(repre["files"]) else: files = list(repre["files"]) for file_name in files: source_file = os.path.join(repre["stagingDir"], file_name) if not os.path.exists(source_file): missing_files.add(source_file) msg = "Files '{}' not found".format(','.join(missing_files)) formatting_data = {"files_not_found": ' - {}'.join(missing_files)} if missing_files: raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/validate_task_existence.py ================================================ import pyblish.api from openpype.client import get_assets from openpype.pipeline import PublishXmlValidationError class ValidateTaskExistence(pyblish.api.ContextPlugin): """Validating tasks on instances are filled and existing.""" label = "Validate Task Existence" order = pyblish.api.ValidatorOrder hosts = ["standalonepublisher"] families = ["render_mov_batch"] def process(self, context): asset_names = set() for instance in context: asset_names.add(instance.data["asset"]) project_name = context.data["projectEntity"]["name"] asset_docs = get_assets( project_name, asset_names=asset_names, fields=["name", "data.tasks"] ) tasks_by_asset_names = {} for asset_doc in asset_docs: asset_name = asset_doc["name"] asset_tasks = asset_doc.get("data", {}).get("tasks") or {} tasks_by_asset_names[asset_name] = list(asset_tasks.keys()) missing_tasks = [] for instance in context: asset_name = instance.data["asset"] task_name = instance.data["task"] task_names = tasks_by_asset_names.get(asset_name) or [] if task_name and task_name in task_names: continue missing_tasks.append((asset_name, task_name)) # Everything is OK if not missing_tasks: return # Raise an exception msg = "Couldn't find task name/s required for publishing.\n{}" pair_msgs = [] for missing_pair in missing_tasks: pair_msgs.append( "Asset: \"{}\" Task: \"{}\"".format(*missing_pair) ) msg = msg.format("\n".join(pair_msgs)) formatting_data = {"task_not_found": ' - {}'.join(pair_msgs)} if pair_msgs: raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/validate_texture_batch.py ================================================ import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, ) class ValidateTextureBatch(pyblish.api.InstancePlugin): """Validates that some texture files are present.""" label = "Validate Texture Presence" hosts = ["standalonepublisher"] order = ValidateContentsOrder families = ["texture_batch_workfile"] optional = False def process(self, instance): present = False for instance in instance.context: if instance.data["family"] == "textures": self.log.info("At least some textures present.") return msg = "No textures found in published batch!" if not present: raise PublishXmlValidationError(self, msg) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/validate_texture_has_workfile.py ================================================ import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, ) class ValidateTextureHasWorkfile(pyblish.api.InstancePlugin): """Validates that textures have appropriate workfile attached. Workfile is optional, disable this Validator after Refresh if you are sure it is not needed. """ label = "Validate Texture Has Workfile" hosts = ["standalonepublisher"] order = ValidateContentsOrder families = ["textures"] optional = True def process(self, instance): wfile = instance.data["versionData"].get("workfile") msg = "Textures are missing attached workfile" if not wfile: raise PublishXmlValidationError(self, msg) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/validate_texture_name.py ================================================ import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, ) class ValidateTextureBatchNaming(pyblish.api.InstancePlugin): """Validates that all instances had properly formatted name.""" label = "Validate Texture Batch Naming" hosts = ["standalonepublisher"] order = ValidateContentsOrder families = ["texture_batch_workfile", "textures"] optional = False def process(self, instance): file_name = instance.data["representations"][0]["files"] if isinstance(file_name, list): file_name = file_name[0] msg = "Couldn't find asset name in '{}'\n".format(file_name) + \ "File name doesn't follow configured pattern.\n" + \ "Please rename the file." formatting_data = {"file_name": file_name} if "NOT_AVAIL" in instance.data["asset_build"]: raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) instance.data.pop("asset_build") # not needed anymore if instance.data["family"] == "textures": file_name = instance.data["representations"][0]["files"][0] self._check_proper_collected(instance.data["versionData"], file_name) def _check_proper_collected(self, versionData, file_name): """ Loop through collected versionData to check if name parsing was OK. Args: versionData: (dict) Returns: raises AssertionException """ missing_key_values = [] for key, value in versionData.items(): if not value: missing_key_values.append(key) msg = "Collected data {} doesn't contain values for {}".format( versionData, missing_key_values) + "\n" + \ "Name of the texture file doesn't match expected pattern.\n" + \ "Please rename file(s) {}".format(file_name) missing_str = ','.join(["'{}'".format(key) for key in missing_key_values]) formatting_data = {"file_name": file_name, "missing_str": missing_str} if missing_key_values: raise PublishXmlValidationError(self, msg, key="missing_values", formatting_data=formatting_data) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/validate_texture_versions.py ================================================ import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, ) class ValidateTextureBatchVersions(pyblish.api.InstancePlugin): """Validates that versions match in workfile and textures. Workfile is optional, so if you are sure, you can disable this validator after Refresh. Validates that only single version is published at a time. """ label = "Validate Texture Batch Versions" hosts = ["standalonepublisher"] order = ValidateContentsOrder families = ["textures"] optional = False def process(self, instance): wfile = instance.data["versionData"].get("workfile") version_str = "v{:03d}".format(instance.data["version"]) if not wfile: # no matching workfile, do not check versions self.log.info("No workfile present for textures") return if version_str not in wfile: msg = "Not matching version: texture v{:03d} - workfile {}" msg.format( instance.data["version"], wfile ) raise PublishXmlValidationError(self, msg) present_versions = set() for instance in instance.context: present_versions.add(instance.data["version"]) if len(present_versions) != 1: msg = "Too many versions in a batch!" found = ','.join(["'{}'".format(val) for val in present_versions]) formatting_data = {"found": found} raise PublishXmlValidationError(self, msg, key="too_many", formatting_data=formatting_data) ================================================ FILE: openpype/hosts/standalonepublisher/plugins/publish/validate_texture_workfiles.py ================================================ import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, ) class ValidateTextureBatchWorkfiles(pyblish.api.InstancePlugin): """Validates that textures workfile has collected resources (optional). Collected resources means secondary workfiles (in most cases). """ label = "Validate Texture Workfile Has Resources" hosts = ["standalonepublisher"] order = ValidateContentsOrder families = ["texture_batch_workfile"] optional = True def process(self, instance): if instance.data["family"] != "workfile": return ext = instance.data["representations"][0]["ext"] main_workfile_extensions = self.get_main_workfile_extensions( instance ) if ext not in main_workfile_extensions: self.log.warning("Only secondary workfile present!") return if not instance.data.get("resources"): msg = "No secondary workfile present for workfile '{}'". \ format(instance.data["name"]) ext = main_workfile_extensions[0] formatting_data = {"file_name": instance.data["name"], "extension": ext} raise PublishXmlValidationError( self, msg, formatting_data=formatting_data) @staticmethod def get_main_workfile_extensions(instance): project_settings = instance.context.data["project_settings"] try: extensions = (project_settings["standalonepublisher"] ["publish"] ["CollectTextures"] ["main_workfile_extensions"]) except KeyError: raise Exception("Setting 'Main workfile extensions' not found." " The setting must be set for the" " 'Collect Texture' publish plugin of the" " 'Standalone Publish' tool.") return extensions ================================================ FILE: openpype/hosts/substancepainter/__init__.py ================================================ from .addon import ( SubstanceAddon, SUBSTANCE_HOST_DIR, ) __all__ = ( "SubstanceAddon", "SUBSTANCE_HOST_DIR" ) ================================================ FILE: openpype/hosts/substancepainter/addon.py ================================================ import os from openpype.modules import OpenPypeModule, IHostAddon SUBSTANCE_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) class SubstanceAddon(OpenPypeModule, IHostAddon): name = "substancepainter" host_name = "substancepainter" def initialize(self, module_settings): self.enabled = True def add_implementation_envs(self, env, _app): # Add requirements to SUBSTANCE_PAINTER_PLUGINS_PATH plugin_path = os.path.join(SUBSTANCE_HOST_DIR, "deploy") plugin_path = plugin_path.replace("\\", "/") if env.get("SUBSTANCE_PAINTER_PLUGINS_PATH"): plugin_path += os.pathsep + env["SUBSTANCE_PAINTER_PLUGINS_PATH"] env["SUBSTANCE_PAINTER_PLUGINS_PATH"] = plugin_path # Log in Substance Painter doesn't support custom terminal colors env["OPENPYPE_LOG_NO_COLORS"] = "Yes" def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] return [ os.path.join(SUBSTANCE_HOST_DIR, "hooks") ] def get_workfile_extensions(self): return [".spp", ".toc"] ================================================ FILE: openpype/hosts/substancepainter/api/__init__.py ================================================ from .pipeline import ( SubstanceHost, ) __all__ = [ "SubstanceHost", ] ================================================ FILE: openpype/hosts/substancepainter/api/colorspace.py ================================================ """Substance Painter OCIO management Adobe Substance 3D Painter supports OCIO color management using a per project configuration. Output color spaces are defined at the project level More information see: - https://substance3d.adobe.com/documentation/spdoc/color-management-223053233.html # noqa - https://substance3d.adobe.com/documentation/spdoc/color-management-with-opencolorio-225969419.html # noqa """ import substance_painter.export import substance_painter.js import json from .lib import ( get_document_structure, get_channel_format ) def _iter_document_stack_channels(): """Yield all stack paths and channels project""" for material in get_document_structure()["materials"]: material_name = material["name"] for stack in material["stacks"]: stack_name = stack["name"] if stack_name: stack_path = [material_name, stack_name] else: stack_path = material_name for channel in stack["channels"]: yield stack_path, channel def _get_first_color_and_data_stack_and_channel(): """Return first found color channel and data channel.""" color_channel = None data_channel = None for stack_path, channel in _iter_document_stack_channels(): channel_format = get_channel_format(stack_path, channel) if channel_format["color"]: color_channel = (stack_path, channel) else: data_channel = (stack_path, channel) if color_channel and data_channel: return color_channel, data_channel return color_channel, data_channel def get_project_channel_data(): """Return colorSpace settings for the current substance painter project. In Substance Painter only color channels have Color Management enabled whereas data channels have no color management applied. This can't be changed. The artist can only customize the export color space for color channels per bit-depth for 8 bpc, 16 bpc and 32 bpc. As such this returns the color space for 'data' and for per bit-depth for color channels. Example output: { "data": {'colorSpace': 'Utility - Raw'}, "8": {"colorSpace": "ACES - AcesCG"}, "16": {"colorSpace": "ACES - AcesCG"}, "16f": {"colorSpace": "ACES - AcesCG"}, "32f": {"colorSpace": "ACES - AcesCG"} } """ keys = ["colorSpace"] query = {key: f"${key}" for key in keys} config = { "exportPath": "/", "exportShaderParams": False, "defaultExportPreset": "query_preset", "exportPresets": [{ "name": "query_preset", # List of maps making up this export preset. "maps": [{ "fileName": json.dumps(query), # List of source/destination defining which channels will # make up the texture file. "channels": [], "parameters": { "fileFormat": "exr", "bitDepth": "32f", "dithering": False, "sizeLog2": 4, "paddingAlgorithm": "passthrough", "dilationDistance": 16 } }] }], } def _get_query_output(config): # Return the basename of the single output path we defined result = substance_painter.export.list_project_textures(config) path = next(iter(result.values()))[0] # strip extension and slash since we know relevant json data starts # and ends with { and } characters path = path.strip("/\\.exr") return json.loads(path) # Query for each type of channel (color and data) color_channel, data_channel = _get_first_color_and_data_stack_and_channel() colorspaces = {} for key, channel_data in { "data": data_channel, "color": color_channel }.items(): if channel_data is None: # No channel of that datatype anywhere in the Stack. We're # unable to identify the output color space of the project colorspaces[key] = None continue stack, channel = channel_data # Stack must be a string if not isinstance(stack, str): # Assume iterable stack = "/".join(stack) # Define the temp output config config["exportList"] = [{"rootPath": stack}] config_map = config["exportPresets"][0]["maps"][0] config_map["channels"] = [ { "destChannel": x, "srcChannel": x, "srcMapType": "documentMap", "srcMapName": channel } for x in "RGB" ] if key == "color": # Query for each bit depth # Color space definition can have a different OCIO config set # for 8-bit, 16-bit and 32-bit outputs so we need to check each # bit depth for depth in ["8", "16", "16f", "32f"]: config_map["parameters"]["bitDepth"] = depth # noqa colorspaces[key + depth] = _get_query_output(config) else: # Data channel (not color managed) colorspaces[key] = _get_query_output(config) return colorspaces ================================================ FILE: openpype/hosts/substancepainter/api/lib.py ================================================ import os import re import json from collections import defaultdict import substance_painter.project import substance_painter.resource import substance_painter.js import substance_painter.export from qtpy import QtGui, QtWidgets, QtCore def get_export_presets(): """Return Export Preset resource URLs for all available Export Presets. Returns: dict: {Resource url: GUI Label} """ # TODO: Find more optimal way to find all export templates preset_resources = {} for shelf in substance_painter.resource.Shelves.all(): shelf_path = os.path.normpath(shelf.path()) presets_path = os.path.join(shelf_path, "export-presets") if not os.path.exists(presets_path): continue for filename in os.listdir(presets_path): if filename.endswith(".spexp"): template_name = os.path.splitext(filename)[0] resource = substance_painter.resource.ResourceID( context=shelf.name(), name=template_name ) resource_url = resource.url() preset_resources[resource_url] = template_name # Sort by template name export_templates = dict(sorted(preset_resources.items(), key=lambda x: x[1])) # Add default built-ins at the start # TODO: find the built-ins automatically; scraped with https://gist.github.com/BigRoy/97150c7c6f0a0c916418207b9a2bc8f1 # noqa result = { "export-preset-generator://viewport2d": "2D View", # noqa "export-preset-generator://doc-channel-normal-no-alpha": "Document channels + Normal + AO (No Alpha)", # noqa "export-preset-generator://doc-channel-normal-with-alpha": "Document channels + Normal + AO (With Alpha)", # noqa "export-preset-generator://sketchfab": "Sketchfab", # noqa "export-preset-generator://adobe-standard-material": "Substance 3D Stager", # noqa "export-preset-generator://usd": "USD PBR Metal Roughness", # noqa "export-preset-generator://gltf": "glTF PBR Metal Roughness", # noqa "export-preset-generator://gltf-displacement": "glTF PBR Metal Roughness + Displacement texture (experimental)" # noqa } result.update(export_templates) return result def _convert_stack_path_to_cmd_str(stack_path): """Convert stack path `str` or `[str, str]` for javascript query Example usage: >>> stack_path = _convert_stack_path_to_cmd_str(stack_path) >>> cmd = f"alg.mapexport.channelIdentifiers({stack_path})" >>> substance_painter.js.evaluate(cmd) Args: stack_path (list or str): Path to the stack, could be "Texture set name" or ["Texture set name", "Stack name"] Returns: str: Stack path usable as argument in javascript query. """ return json.dumps(stack_path) def get_channel_identifiers(stack_path=None): """Return the list of channel identifiers. If a context is passed (texture set/stack), return only used channels with resolved user channels. Channel identifiers are: basecolor, height, specular, opacity, emissive, displacement, glossiness, roughness, anisotropylevel, anisotropyangle, transmissive, scattering, reflection, ior, metallic, normal, ambientOcclusion, diffuse, specularlevel, blendingmask, [custom user names]. Args: stack_path (list or str, Optional): Path to the stack, could be "Texture set name" or ["Texture set name", "Stack name"] Returns: list: List of channel identifiers. """ if stack_path is None: stack_path = "" else: stack_path = _convert_stack_path_to_cmd_str(stack_path) cmd = f"alg.mapexport.channelIdentifiers({stack_path})" return substance_painter.js.evaluate(cmd) def get_channel_format(stack_path, channel): """Retrieve the channel format of a specific stack channel. See `alg.mapexport.channelFormat` (javascript API) for more details. The channel format data is: "label" (str): The channel format label: could be one of [sRGB8, L8, RGB8, L16, RGB16, L16F, RGB16F, L32F, RGB32F] "color" (bool): True if the format is in color, False is grayscale "floating" (bool): True if the format uses floating point representation, false otherwise "bitDepth" (int): Bit per color channel (could be 8, 16 or 32 bpc) Arguments: stack_path (list or str): Path to the stack, could be "Texture set name" or ["Texture set name", "Stack name"] channel (str): Identifier of the channel to export (see `get_channel_identifiers`) Returns: dict: The channel format data. """ stack_path = _convert_stack_path_to_cmd_str(stack_path) cmd = f"alg.mapexport.channelFormat({stack_path}, '{channel}')" return substance_painter.js.evaluate(cmd) def get_document_structure(): """Dump the document structure. See `alg.mapexport.documentStructure` (javascript API) for more details. Returns: dict: Document structure or None when no project is open """ return substance_painter.js.evaluate("alg.mapexport.documentStructure()") def get_export_templates(config, format="png", strip_folder=True): """Return export config outputs. This use the Javascript API `alg.mapexport.getPathsExportDocumentMaps` which returns a different output than using the Python equivalent `substance_painter.export.list_project_textures(config)`. The nice thing about the Javascript API version is that it returns the output textures grouped by filename template. A downside is that it doesn't return all the UDIM tiles but per template always returns a single file. Note: The file format needs to be explicitly passed to the Javascript API but upon exporting through the Python API the file format can be based on the output preset. So it's likely the file extension will mismatch Warning: Even though the function appears to solely get the expected outputs the Javascript API will actually create the config's texture output folder if it does not exist yet. As such, a valid path must be set. Example output: { "DefaultMaterial": { "$textureSet_BaseColor(_$colorSpace)(.$udim)": "DefaultMaterial_BaseColor_ACES - ACEScg.1002.png", # noqa "$textureSet_Emissive(_$colorSpace)(.$udim)": "DefaultMaterial_Emissive_ACES - ACEScg.1002.png", # noqa "$textureSet_Height(_$colorSpace)(.$udim)": "DefaultMaterial_Height_Utility - Raw.1002.png", # noqa "$textureSet_Metallic(_$colorSpace)(.$udim)": "DefaultMaterial_Metallic_Utility - Raw.1002.png", # noqa "$textureSet_Normal(_$colorSpace)(.$udim)": "DefaultMaterial_Normal_Utility - Raw.1002.png", # noqa "$textureSet_Roughness(_$colorSpace)(.$udim)": "DefaultMaterial_Roughness_Utility - Raw.1002.png" # noqa } } Arguments: config (dict) Export config format (str, Optional): Output format to write to, defaults to 'png' strip_folder (bool, Optional): Whether to strip the output folder from the output filenames. Returns: dict: The expected output maps. """ folder = config["exportPath"].replace("\\", "/") preset = config["defaultExportPreset"] cmd = f'alg.mapexport.getPathsExportDocumentMaps("{preset}", "{folder}", "{format}")' # noqa result = substance_painter.js.evaluate(cmd) if strip_folder: for _stack, maps in result.items(): for map_template, map_filepath in maps.items(): map_filepath = map_filepath.replace("\\", "/") assert map_filepath.startswith(folder) map_filename = map_filepath[len(folder):].lstrip("/") maps[map_template] = map_filename return result def _templates_to_regex(templates, texture_set, colorspaces, project, mesh): """Return regex based on a Substance Painter expot filename template. This converts Substance Painter export filename templates like `$mesh_$textureSet_BaseColor(_$colorSpace)(.$udim)` into a regex which can be used to query an output filename to help retrieve: - Which template filename the file belongs to. - Which color space the file is written with. - Which udim tile it is exactly. This is used by `get_parsed_export_maps` which tries to as explicitly as possible match the filename pattern against the known possible outputs. That's why Texture Set name, Color spaces, Project path and mesh path must be provided. By doing so we get the best shot at correctly matching the right template because otherwise $texture_set could basically be any string and thus match even that of a color space or mesh. Arguments: templates (list): List of templates to convert to regex. texture_set (str): The texture set to match against. colorspaces (list): The colorspaces defined in the current project. project (str): Filepath of current substance project. mesh (str): Path to mesh file used in current project. Returns: dict: Template: Template regex pattern """ def _filename_no_ext(path): return os.path.splitext(os.path.basename(path))[0] if colorspaces and any(colorspaces): colorspace_match = "|".join(re.escape(c) for c in set(colorspaces)) colorspace_match = f"({colorspace_match})" else: # No colorspace support enabled colorspace_match = "" # Key to regex valid search values key_matches = { "$project": re.escape(_filename_no_ext(project)), "$mesh": re.escape(_filename_no_ext(mesh)), "$textureSet": re.escape(texture_set), "$colorSpace": colorspace_match, "$udim": "([0-9]{4})" } # Turn the templates into regexes regexes = {} for template in templates: # We need to tweak a temp search_regex = re.escape(template) # Let's assume that any ( and ) character in the file template was # intended as an optional template key and do a simple `str.replace` # Note: we are matching against re.escape(template) so will need to # search for the escaped brackets. search_regex = search_regex.replace(re.escape("("), "(") search_regex = search_regex.replace(re.escape(")"), ")?") # Substitute each key into a named group for key, key_expected_regex in key_matches.items(): # We want to use the template as a regex basis in the end so will # escape the whole thing first. Note that thus we'll need to # search for the escaped versions of the keys too. escaped_key = re.escape(key) key_label = key[1:] # key without $ prefix key_expected_grp_regex = f"(?P<{key_label}>{key_expected_regex})" search_regex = search_regex.replace(escaped_key, key_expected_grp_regex) # The filename templates don't include the extension so we add it # to be able to match the out filename beginning to end ext_regex = r"(?P\.[A-Za-z][A-Za-z0-9-]*)" search_regex = rf"^{search_regex}{ext_regex}$" regexes[template] = search_regex return regexes def strip_template(template, strip="._ "): """Return static characters in a substance painter filename template. >>> strip_template("$textureSet_HELLO(.$udim)") # HELLO >>> strip_template("$mesh_$textureSet_HELLO_WORLD_$colorSpace(.$udim)") # HELLO_WORLD >>> strip_template("$textureSet_HELLO(.$udim)", strip=None) # _HELLO >>> strip_template("$mesh_$textureSet_$colorSpace(.$udim)", strip=None) # _HELLO_ >>> strip_template("$textureSet_HELLO(.$udim)") # _HELLO Arguments: template (str): Filename template to strip. strip (str, optional): Characters to strip from beginning and end of the static string in template. Defaults to: `._ `. Returns: str: The static string in filename template. """ # Return only characters that were part of the template that were static. # Remove all keys keys = ["$project", "$mesh", "$textureSet", "$udim", "$colorSpace"] stripped_template = template for key in keys: stripped_template = stripped_template.replace(key, "") # Everything inside an optional bracket space is excluded since it's not # static. We keep a counter to track whether we are currently iterating # over parts of the template that are inside an 'optional' group or not. counter = 0 result = "" for char in stripped_template: if char == "(": counter += 1 elif char == ")": counter -= 1 if counter < 0: counter = 0 else: if counter == 0: result += char if strip: # Strip of any trailing start/end characters. Technically these are # static but usually start and end separators like space or underscore # aren't wanted. result = result.strip(strip) return result def get_parsed_export_maps(config): """Return Export Config's expected output textures with parsed data. This tries to parse the texture outputs using a Python API export config. Parses template keys: $project, $mesh, $textureSet, $colorSpace, $udim Example: {("DefaultMaterial", ""): { "$mesh_$textureSet_BaseColor(_$colorSpace)(.$udim)": [ { // OUTPUT DATA FOR FILE #1 OF THE TEMPLATE }, { // OUTPUT DATA FOR FILE #2 OF THE TEMPLATE }, ] }, }} File output data (all outputs are `str`). 1) Parsed tokens: These are parsed tokens from the template, they will only exist if found in the filename template and output filename. project: Workfile filename without extension mesh: Filename of the loaded mesh without extension textureSet: The texture set, e.g. "DefaultMaterial", colorSpace: The color space, e.g. "ACES - ACEScg", udim: The udim tile, e.g. "1001" 2) Template output and filepath filepath: Full path to the resulting texture map, e.g. "/path/to/mesh_DefaultMaterial_BaseColor_ACES - ACEScg.1002.png", output: "mesh_DefaultMaterial_BaseColor_ACES - ACEScg.1002.png" Note: if template had slashes (folders) then `output` will too. So `output` might include a folder. Returns: dict: [texture_set, stack]: {template: [file1_data, file2_data]} """ # Import is here to avoid recursive lib <-> colorspace imports from .colorspace import get_project_channel_data outputs = substance_painter.export.list_project_textures(config) templates = get_export_templates(config, strip_folder=False) # Get all color spaces set for the current project project_colorspaces = set( data["colorSpace"] for data in get_project_channel_data().values() ) # Get current project mesh path and project path to explicitly match # the $mesh and $project tokens project_mesh_path = substance_painter.project.last_imported_mesh_path() project_path = substance_painter.project.file_path() # Get the current export path to strip this of the beginning of filepath # results, since filename templates don't have these we'll match without # that part of the filename. export_path = config["exportPath"] export_path = export_path.replace("\\", "/") if not export_path.endswith("/"): export_path += "/" # Parse the outputs result = {} for key, filepaths in outputs.items(): texture_set, stack = key if stack: stack_path = f"{texture_set}/{stack}" else: stack_path = texture_set stack_templates = list(templates[stack_path].keys()) template_regex = _templates_to_regex(stack_templates, texture_set=texture_set, colorspaces=project_colorspaces, mesh=project_mesh_path, project=project_path) # Let's precompile the regexes for template, regex in template_regex.items(): template_regex[template] = re.compile(regex) stack_results = defaultdict(list) for filepath in sorted(filepaths): # We strip explicitly using the full parent export path instead of # using `os.path.basename` because export template is allowed to # have subfolders in its template which we want to match against filepath = filepath.replace("\\", "/") assert filepath.startswith(export_path), ( f"Filepath {filepath} must start with folder {export_path}" ) filename = filepath[len(export_path):] for template, regex in template_regex.items(): match = regex.match(filename) if match: parsed = match.groupdict(default={}) # Include some special outputs for convenience parsed["filepath"] = filepath parsed["output"] = filename stack_results[template].append(parsed) break else: raise ValueError(f"Unable to match {filename} against any " f"template in: {list(template_regex.keys())}") result[key] = dict(stack_results) return result def load_shelf(path, name=None): """Add shelf to substance painter (for current application session) This will dynamically add a Shelf for the current session. It's good to note however that these will *not* persist on restart of the host. Note: Consider the loaded shelf a static library of resources. The shelf will *not* be visible in application preferences in Edit > Settings > Libraries. The shelf will *not* show in the Assets browser if it has no existing assets The shelf will *not* be a selectable option for selecting it as a destination to import resources too. """ # Ensure expanded path with forward slashes path = os.path.expandvars(path) path = os.path.abspath(path) path = path.replace("\\", "/") # Path must exist if not os.path.isdir(path): raise ValueError(f"Path is not an existing folder: {path}") # This name must be unique and must only contain lowercase letters, # numbers, underscores or hyphens. if name is None: name = os.path.basename(path) name = name.lower() name = re.sub(r"[^a-z0-9_\-]", "_", name) # sanitize to underscores if substance_painter.resource.Shelves.exists(name): shelf = next( shelf for shelf in substance_painter.resource.Shelves.all() if shelf.name() == name ) if os.path.normpath(shelf.path()) != os.path.normpath(path): raise ValueError(f"Shelf with name '{name}' already exists " f"for a different path: '{shelf.path()}") return print(f"Adding Shelf '{name}' to path: {path}") substance_painter.resource.Shelves.add(name, path) return name def _get_new_project_action(): """Return QAction which triggers Substance Painter's new project dialog""" main_window = substance_painter.ui.get_main_window() # Find the file menu's New file action menubar = main_window.menuBar() new_action = None for action in menubar.actions(): menu = action.menu() if not menu: continue if menu.objectName() != "file": continue # Find the action with the CTRL+N key sequence new_action = next(action for action in menu.actions() if action.shortcut() == QtGui.QKeySequence.New) break return new_action def prompt_new_file_with_mesh(mesh_filepath): """Prompts the user for a new file using Substance Painter's own dialog. This will set the mesh path to load to the given mesh and disables the dialog box to disallow the user to change the path. This way we can allow user configuration of a project but set the mesh path ourselves. Warning: This is very hacky and experimental. Note: If a project is currently open using the same mesh filepath it can't accurately detect whether the user had actually accepted the new project dialog or whether the project afterwards is still the original project, for example when the user might have cancelled the operation. """ app = QtWidgets.QApplication.instance() assert os.path.isfile(mesh_filepath), \ f"Mesh filepath does not exist: {mesh_filepath}" def _setup_file_dialog(): """Set filepath in QFileDialog and trigger accept result""" file_dialog = app.activeModalWidget() assert isinstance(file_dialog, QtWidgets.QFileDialog) # Quickly hide the dialog file_dialog.hide() app.processEvents(QtCore.QEventLoop.ExcludeUserInputEvents, 1000) file_dialog.setDirectory(os.path.dirname(mesh_filepath)) url = QtCore.QUrl.fromLocalFile(os.path.basename(mesh_filepath)) file_dialog.selectUrl(url) # TODO: find a way to improve the process event to # load more complicated mesh app.processEvents(QtCore.QEventLoop.ExcludeUserInputEvents, 3000) file_dialog.done(file_dialog.Accepted) app.processEvents(QtCore.QEventLoop.AllEvents) def _setup_prompt(): app.processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) dialog = app.activeModalWidget() assert dialog.objectName() == "NewProjectDialog" # Set the window title mesh = os.path.basename(mesh_filepath) dialog.setWindowTitle(f"New Project with mesh: {mesh}") # Get the select mesh file button mesh_select = dialog.findChild(QtWidgets.QPushButton, "meshSelect") # Hide the select mesh button to the user to block changing of mesh mesh_select.setVisible(False) # Ensure UI is visually up-to-date app.processEvents(QtCore.QEventLoop.ExcludeUserInputEvents, 8000) # Trigger the 'select file' dialog to set the path and have the # new file dialog to use the path. QtCore.QTimer.singleShot(10, _setup_file_dialog) mesh_select.click() app.processEvents(QtCore.QEventLoop.AllEvents, 5000) mesh_filename = dialog.findChild(QtWidgets.QFrame, "meshFileName") mesh_filename_label = mesh_filename.findChild(QtWidgets.QLabel) if not mesh_filename_label.text(): dialog.close() substance_painter.logging.warning( "Failed to set mesh path with the prompt dialog:" f"{mesh_filepath}\n\n" "Creating new project directly with the mesh path instead.") new_action = _get_new_project_action() if not new_action: raise RuntimeError("Unable to detect new file action..") QtCore.QTimer.singleShot(0, _setup_prompt) new_action.trigger() app.processEvents(QtCore.QEventLoop.AllEvents, 5000) if not substance_painter.project.is_open(): return # Confirm mesh was set as expected project_mesh = substance_painter.project.last_imported_mesh_path() if os.path.normpath(project_mesh) != os.path.normpath(mesh_filepath): return return project_mesh ================================================ FILE: openpype/hosts/substancepainter/api/pipeline.py ================================================ # -*- coding: utf-8 -*- """Pipeline tools for OpenPype Substance Painter integration.""" import os import logging from functools import partial # Substance 3D Painter modules import substance_painter.ui import substance_painter.event import substance_painter.project import pyblish.api from openpype.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost from openpype.settings import ( get_current_project_settings, get_system_settings ) from openpype.pipeline.template_data import get_template_data_with_names from openpype.pipeline import ( register_creator_plugin_path, register_loader_plugin_path, AVALON_CONTAINER_ID, Anatomy ) from openpype.lib import ( StringTemplate, register_event_callback, emit_event, ) from openpype.pipeline.load import any_outdated_containers from openpype.hosts.substancepainter import SUBSTANCE_HOST_DIR from . import lib log = logging.getLogger("openpype.hosts.substance") PLUGINS_DIR = os.path.join(SUBSTANCE_HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") OPENPYPE_METADATA_KEY = "OpenPype" OPENPYPE_METADATA_CONTAINERS_KEY = "containers" # child key OPENPYPE_METADATA_CONTEXT_KEY = "context" # child key OPENPYPE_METADATA_INSTANCES_KEY = "instances" # child key class SubstanceHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): name = "substancepainter" def __init__(self): super(SubstanceHost, self).__init__() self._has_been_setup = False self.menu = None self.callbacks = [] self.shelves = [] def install(self): pyblish.api.register_host("substancepainter") pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) log.info("Installing callbacks ... ") # register_event_callback("init", on_init) self._register_callbacks() # register_event_callback("before.save", before_save) # register_event_callback("save", on_save) register_event_callback("open", on_open) # register_event_callback("new", on_new) log.info("Installing menu ... ") self._install_menu() project_settings = get_current_project_settings() self._install_shelves(project_settings) self._has_been_setup = True def uninstall(self): self._uninstall_shelves() self._uninstall_menu() self._deregister_callbacks() def workfile_has_unsaved_changes(self): if not substance_painter.project.is_open(): return False return substance_painter.project.needs_saving() def get_workfile_extensions(self): return [".spp", ".toc"] def save_workfile(self, dst_path=None): if not substance_painter.project.is_open(): return False if not dst_path: dst_path = self.get_current_workfile() full_save_mode = substance_painter.project.ProjectSaveMode.Full substance_painter.project.save_as(dst_path, full_save_mode) return dst_path def open_workfile(self, filepath): if not os.path.exists(filepath): raise RuntimeError("File does not exist: {}".format(filepath)) # We must first explicitly close current project before opening another if substance_painter.project.is_open(): substance_painter.project.close() substance_painter.project.open(filepath) return filepath def get_current_workfile(self): if not substance_painter.project.is_open(): return None filepath = substance_painter.project.file_path() if filepath and filepath.endswith(".spt"): # When currently in a Substance Painter template assume our # scene isn't saved. This can be the case directly after doing # "New project", the path will then be the template used. This # avoids Workfiles tool trying to save as .spt extension if the # file hasn't been saved before. return return filepath def get_containers(self): if not substance_painter.project.is_open(): return metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY) containers = metadata.get(OPENPYPE_METADATA_CONTAINERS_KEY) if containers: for key, container in containers.items(): container["objectName"] = key yield container def update_context_data(self, data, changes): if not substance_painter.project.is_open(): return metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY) metadata.set(OPENPYPE_METADATA_CONTEXT_KEY, data) def get_context_data(self): if not substance_painter.project.is_open(): return metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY) return metadata.get(OPENPYPE_METADATA_CONTEXT_KEY) or {} def _install_menu(self): from PySide2 import QtWidgets from openpype.tools.utils import host_tools parent = substance_painter.ui.get_main_window() tab_menu_label = os.environ.get("AVALON_LABEL") or "AYON" menu = QtWidgets.QMenu(tab_menu_label) action = menu.addAction("Create...") action.triggered.connect( lambda: host_tools.show_publisher(parent=parent, tab="create") ) action = menu.addAction("Load...") action.triggered.connect( lambda: host_tools.show_loader(parent=parent, use_context=True) ) action = menu.addAction("Publish...") action.triggered.connect( lambda: host_tools.show_publisher(parent=parent, tab="publish") ) action = menu.addAction("Manage...") action.triggered.connect( lambda: host_tools.show_scene_inventory(parent=parent) ) action = menu.addAction("Library...") action.triggered.connect( lambda: host_tools.show_library_loader(parent=parent) ) menu.addSeparator() action = menu.addAction("Work Files...") action.triggered.connect( lambda: host_tools.show_workfiles(parent=parent) ) substance_painter.ui.add_menu(menu) def on_menu_destroyed(): self.menu = None menu.destroyed.connect(on_menu_destroyed) self.menu = menu def _uninstall_menu(self): if self.menu: self.menu.destroy() self.menu = None def _register_callbacks(self): # Prepare emit event callbacks open_callback = partial(emit_event, "open") # Connect to the Substance Painter events dispatcher = substance_painter.event.DISPATCHER for event, callback in [ (substance_painter.event.ProjectOpened, open_callback) ]: dispatcher.connect(event, callback) # Keep a reference so we can deregister if needed self.callbacks.append((event, callback)) def _deregister_callbacks(self): for event, callback in self.callbacks: substance_painter.event.DISPATCHER.disconnect(event, callback) self.callbacks.clear() def _install_shelves(self, project_settings): shelves = project_settings["substancepainter"].get("shelves", {}) if not shelves: return # Prepare formatting data if we detect any path which might have # template tokens like {asset} in there. formatting_data = {} has_formatting_entries = any("{" in path for path in shelves.values()) if has_formatting_entries: project_name = self.get_current_project_name() asset_name = self.get_current_asset_name() task_name = self.get_current_asset_name() system_settings = get_system_settings() formatting_data = get_template_data_with_names(project_name, asset_name, task_name, system_settings) anatomy = Anatomy(project_name) formatting_data["root"] = anatomy.roots for name, path in shelves.items(): shelf_name = None # Allow formatting with anatomy for the paths if "{" in path: path = StringTemplate.format_template(path, formatting_data) try: shelf_name = lib.load_shelf(path, name=name) except ValueError as exc: print(f"Failed to load shelf -> {exc}") if shelf_name: self.shelves.append(shelf_name) def _uninstall_shelves(self): for shelf_name in self.shelves: substance_painter.resource.Shelves.remove(shelf_name) self.shelves.clear() def on_open(): log.info("Running callback on open..") if any_outdated_containers(): from openpype.widgets import popup log.warning("Scene has outdated content.") # Get main window parent = substance_painter.ui.get_main_window() if parent is None: log.info("Skipping outdated content pop-up " "because Substance window can't be found.") else: # Show outdated pop-up def _on_show_inventory(): from openpype.tools.utils import host_tools host_tools.show_scene_inventory(parent=parent) dialog = popup.Popup(parent=parent) dialog.setWindowTitle("Substance scene has outdated content") dialog.setMessage("There are outdated containers in " "your Substance scene.") dialog.on_clicked.connect(_on_show_inventory) dialog.show() def imprint_container(container, name, namespace, context, loader): """Imprint a loaded container with metadata. Containerisation enables a tracking of version, author and origin for loaded assets. Arguments: container (dict): The (substance metadata) dictionary to imprint into. name (str): Name of resulting assembly namespace (str): Namespace under which to host container context (dict): Asset information loader (load.LoaderPlugin): loader instance used to produce container. Returns: None """ data = [ ("schema", "openpype:container-2.0"), ("id", AVALON_CONTAINER_ID), ("name", str(name)), ("namespace", str(namespace) if namespace else None), ("loader", str(loader.__class__.__name__)), ("representation", str(context["representation"]["_id"])), ] for key, value in data: container[key] = value def set_container_metadata(object_name, container_data, update=False): """Helper method to directly set the data for a specific container Args: object_name (str): The unique object name identifier for the container container_data (dict): The data for the container. Note 'objectName' data is derived from `object_name` and key in `container_data` will be ignored. update (bool): Whether to only update the dict data. """ # The objectName is derived from the key in the metadata so won't be stored # in the metadata in the container's data. container_data.pop("objectName", None) metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY) containers = metadata.get(OPENPYPE_METADATA_CONTAINERS_KEY) or {} if update: existing_data = containers.setdefault(object_name, {}) existing_data.update(container_data) # mutable dict, in-place update else: containers[object_name] = container_data metadata.set("containers", containers) def remove_container_metadata(object_name): """Helper method to remove the data for a specific container""" metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY) containers = metadata.get(OPENPYPE_METADATA_CONTAINERS_KEY) if containers: containers.pop(object_name, None) metadata.set("containers", containers) def set_instance(instance_id, instance_data, update=False): """Helper method to directly set the data for a specific container Args: instance_id (str): Unique identifier for the instance instance_data (dict): The instance data to store in the metaadata. """ set_instances({instance_id: instance_data}, update=update) def set_instances(instance_data_by_id, update=False): """Store data for multiple instances at the same time. This is more optimal than querying and setting them in the metadata one by one. """ metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY) instances = metadata.get(OPENPYPE_METADATA_INSTANCES_KEY) or {} for instance_id, instance_data in instance_data_by_id.items(): if update: existing_data = instances.get(instance_id, {}) existing_data.update(instance_data) else: instances[instance_id] = instance_data metadata.set("instances", instances) def remove_instance(instance_id): """Helper method to remove the data for a specific container""" metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY) instances = metadata.get(OPENPYPE_METADATA_INSTANCES_KEY) or {} instances.pop(instance_id, None) metadata.set("instances", instances) def get_instances_by_id(): """Return all instances stored in the project instances metadata""" if not substance_painter.project.is_open(): return {} metadata = substance_painter.project.Metadata(OPENPYPE_METADATA_KEY) return metadata.get(OPENPYPE_METADATA_INSTANCES_KEY) or {} def get_instances(): """Return all instances stored in the project instances as a list""" return list(get_instances_by_id().values()) ================================================ FILE: openpype/hosts/substancepainter/deploy/plugins/openpype_plugin.py ================================================ def cleanup_openpype_qt_widgets(): """ Workaround for Substance failing to shut down correctly when a Qt window was still open at the time of shutting down. This seems to work sometimes, but not all the time. """ # TODO: Create a more reliable method to close down all OpenPype Qt widgets from PySide2 import QtWidgets import substance_painter.ui # Kill OpenPype Qt widgets print("Killing OpenPype Qt widgets..") for widget in QtWidgets.QApplication.topLevelWidgets(): if widget.__module__.startswith("openpype."): print(f"Deleting widget: {widget.__class__.__name__}") substance_painter.ui.delete_ui_element(widget) def start_plugin(): from openpype.pipeline import install_host from openpype.hosts.substancepainter.api import SubstanceHost install_host(SubstanceHost()) def close_plugin(): from openpype.pipeline import uninstall_host cleanup_openpype_qt_widgets() uninstall_host() if __name__ == "__main__": start_plugin() ================================================ FILE: openpype/hosts/substancepainter/deploy/startup/openpype_load_on_first_run.py ================================================ """Ease the OpenPype on-boarding process by loading the plug-in on first run""" OPENPYPE_PLUGIN_NAME = "openpype_plugin" def start_plugin(): try: # This isn't exposed in the official API so we keep it in a try-except from painter_plugins_ui import ( get_settings, LAUNCH_AT_START_KEY, ON_STATE, PLUGINS_MENU, plugin_manager ) # The `painter_plugins_ui` plug-in itself is also a startup plug-in # we need to take into account that it could run either earlier or # later than this startup script, we check whether its menu initialized is_before_plugins_menu = PLUGINS_MENU is None settings = get_settings(OPENPYPE_PLUGIN_NAME) if settings.value(LAUNCH_AT_START_KEY, None) is None: print("Initializing OpenPype plug-in on first run...") if is_before_plugins_menu: print("- running before 'painter_plugins_ui'") # Delay the launch to the painter_plugins_ui initialization settings.setValue(LAUNCH_AT_START_KEY, ON_STATE) else: # Launch now print("- running after 'painter_plugins_ui'") plugin_manager(OPENPYPE_PLUGIN_NAME)(True) # Set the checked state in the menu to avoid confusion action = next(action for action in PLUGINS_MENU._menu.actions() if action.text() == OPENPYPE_PLUGIN_NAME) if action is not None: action.blockSignals(True) action.setChecked(True) action.blockSignals(False) except Exception as exc: print(exc) ================================================ FILE: openpype/hosts/substancepainter/plugins/create/create_textures.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating textures.""" from openpype.pipeline import CreatedInstance, Creator, CreatorError from openpype.lib import ( EnumDef, UILabelDef, NumberDef, BoolDef ) from openpype.hosts.substancepainter.api.pipeline import ( get_instances, set_instance, set_instances, remove_instance ) from openpype.hosts.substancepainter.api.lib import get_export_presets import substance_painter.project class CreateTextures(Creator): """Create a texture set.""" identifier = "io.openpype.creators.substancepainter.textureset" label = "Textures" family = "textureSet" icon = "picture-o" default_variant = "Main" def create(self, subset_name, instance_data, pre_create_data): if not substance_painter.project.is_open(): raise CreatorError("Can't create a Texture Set instance without " "an open project.") # Transfer settings from pre create to instance creator_attributes = instance_data.setdefault( "creator_attributes", dict()) for key in [ "exportPresetUrl", "exportFileFormat", "exportSize", "exportPadding", "exportDilationDistance" ]: if key in pre_create_data: creator_attributes[key] = pre_create_data[key] instance = self.create_instance_in_context(subset_name, instance_data) set_instance( instance_id=instance["instance_id"], instance_data=instance.data_to_store() ) def collect_instances(self): for instance in get_instances(): if (instance.get("creator_identifier") == self.identifier or instance.get("family") == self.family): self.create_instance_in_context_from_existing(instance) def update_instances(self, update_list): instance_data_by_id = {} for instance, _changes in update_list: # Persist the data instance_id = instance.get("instance_id") instance_data = instance.data_to_store() instance_data_by_id[instance_id] = instance_data set_instances(instance_data_by_id, update=True) def remove_instances(self, instances): for instance in instances: remove_instance(instance["instance_id"]) self._remove_instance_from_context(instance) # Helper methods (this might get moved into Creator class) def create_instance_in_context(self, subset_name, data): instance = CreatedInstance( self.family, subset_name, data, self ) self.create_context.creator_adds_instance(instance) return instance def create_instance_in_context_from_existing(self, data): instance = CreatedInstance.from_existing(data, self) self.create_context.creator_adds_instance(instance) return instance def get_instance_attr_defs(self): return [ EnumDef("exportPresetUrl", items=get_export_presets(), label="Output Template"), BoolDef("allowSkippedMaps", label="Allow Skipped Output Maps", tooltip="When enabled this allows the publish to ignore " "output maps in the used output template if one " "or more maps are skipped due to the required " "channels not being present in the current file.", default=True), EnumDef("exportFileFormat", items={ None: "Based on output template", # TODO: Get available extensions from substance API "bmp": "bmp", "ico": "ico", "jpeg": "jpeg", "jng": "jng", "pbm": "pbm", "pgm": "pgm", "png": "png", "ppm": "ppm", "tga": "targa", "tif": "tiff", "wap": "wap", "wbmp": "wbmp", "xpm": "xpm", "gif": "gif", "hdr": "hdr", "exr": "exr", "j2k": "j2k", "jp2": "jp2", "pfm": "pfm", "webp": "webp", # TODO: Unsure why jxr format fails to export # "jxr": "jpeg-xr", # TODO: File formats that combine the exported textures # like psd are not correctly supported due to # publishing only a single file # "psd": "psd", # "sbsar": "sbsar", }, default=None, label="File type"), EnumDef("exportSize", items={ None: "Based on each Texture Set's size", # The key is size of the texture file in log2. # (i.e. 10 means 2^10 = 1024) 7: "128", 8: "256", 9: "512", 10: "1024", 11: "2048", 12: "4096" }, default=None, label="Size"), EnumDef("exportPadding", items={ "passthrough": "No padding (passthrough)", "infinite": "Dilation infinite", "transparent": "Dilation + transparent", "color": "Dilation + default background color", "diffusion": "Dilation + diffusion" }, default="infinite", label="Padding"), NumberDef("exportDilationDistance", minimum=0, maximum=256, decimals=0, default=16, label="Dilation Distance"), UILabelDef("*only used with " "'Dilation + ' padding"), ] def get_pre_create_attr_defs(self): # Use same attributes as for instance attributes return self.get_instance_attr_defs() ================================================ FILE: openpype/hosts/substancepainter/plugins/create/create_workfile.py ================================================ # -*- coding: utf-8 -*- """Creator plugin for creating workfiles.""" from openpype import AYON_SERVER_ENABLED from openpype.pipeline import CreatedInstance, AutoCreator from openpype.client import get_asset_by_name from openpype.hosts.substancepainter.api.pipeline import ( set_instances, set_instance, get_instances ) import substance_painter.project class CreateWorkfile(AutoCreator): """Workfile auto-creator.""" identifier = "io.openpype.creators.substancepainter.workfile" label = "Workfile" family = "workfile" icon = "document" default_variant = "Main" def create(self): if not substance_painter.project.is_open(): return variant = self.default_variant project_name = self.project_name asset_name = self.create_context.get_current_asset_name() task_name = self.create_context.get_current_task_name() host_name = self.create_context.host_name # Workfile instance should always exist and must only exist once. # As such we'll first check if it already exists and is collected. current_instance = next( ( instance for instance in self.create_context.instances if instance.creator_identifier == self.identifier ), None) if current_instance is None: current_instance_asset = None elif AYON_SERVER_ENABLED: current_instance_asset = current_instance["folderPath"] else: current_instance_asset = current_instance["asset"] if current_instance is None: self.log.info("Auto-creating workfile instance...") asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) data = { "task": task_name, "variant": variant } if AYON_SERVER_ENABLED: data["folderPath"] = asset_name else: data["asset"] = asset_name current_instance = self.create_instance_in_context(subset_name, data) elif ( current_instance_asset != asset_name or current_instance["task"] != task_name ): # Update instance context if is not the same asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( variant, task_name, asset_doc, project_name, host_name ) if AYON_SERVER_ENABLED: current_instance["folderPath"] = asset_name else: current_instance["asset"] = asset_name current_instance["task"] = task_name current_instance["subset"] = subset_name set_instance( instance_id=current_instance.get("instance_id"), instance_data=current_instance.data_to_store() ) def collect_instances(self): for instance in get_instances(): if (instance.get("creator_identifier") == self.identifier or instance.get("family") == self.family): self.create_instance_in_context_from_existing(instance) def update_instances(self, update_list): instance_data_by_id = {} for instance, _changes in update_list: # Persist the data instance_id = instance.get("instance_id") instance_data = instance.data_to_store() instance_data_by_id[instance_id] = instance_data set_instances(instance_data_by_id, update=True) # Helper methods (this might get moved into Creator class) def create_instance_in_context(self, subset_name, data): instance = CreatedInstance( self.family, subset_name, data, self ) self.create_context.creator_adds_instance(instance) return instance def create_instance_in_context_from_existing(self, data): instance = CreatedInstance.from_existing(data, self) self.create_context.creator_adds_instance(instance) return instance ================================================ FILE: openpype/hosts/substancepainter/plugins/load/load_mesh.py ================================================ import copy from qtpy import QtWidgets, QtCore from openpype.pipeline import ( load, get_representation_path, ) from openpype.pipeline.load import LoadError from openpype.hosts.substancepainter.api.pipeline import ( imprint_container, set_container_metadata, remove_container_metadata ) from openpype.hosts.substancepainter.api.lib import prompt_new_file_with_mesh import substance_painter.project def _convert(substance_attr): """Return Substance Painter Python API Project attribute from string. This converts a string like "ProjectWorkflow.Default" to for example the Substance Painter Python API equivalent object, like: `substance_painter.project.ProjectWorkflow.Default` Args: substance_attr (str): The `substance_painter.project` attribute, for example "ProjectWorkflow.Default" Returns: Any: Substance Python API object of the project attribute. Raises: ValueError: If attribute does not exist on the `substance_painter.project` python api. """ root = substance_painter.project for attr in substance_attr.split("."): root = getattr(root, attr, None) if root is None: raise ValueError( "Substance Painter project attribute" f" does not exist: {substance_attr}") return root def get_template_by_name(name: str, templates: list[dict]) -> dict: return next( template for template in templates if template["name"] == name ) class SubstanceProjectConfigurationWindow(QtWidgets.QDialog): """The pop-up dialog allows users to choose material duplicate options for importing Max objects when updating or switching assets. """ def __init__(self, project_templates): super(SubstanceProjectConfigurationWindow, self).__init__() self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) self.configuration = None self.template_names = [template["name"] for template in project_templates] self.project_templates = project_templates self.widgets = { "label": QtWidgets.QLabel( "Select your template for project configuration"), "template_options": QtWidgets.QComboBox(), "import_cameras": QtWidgets.QCheckBox("Import Cameras"), "preserve_strokes": QtWidgets.QCheckBox("Preserve Strokes"), "clickbox": QtWidgets.QWidget(), "combobox": QtWidgets.QWidget(), "buttons": QtWidgets.QDialogButtonBox( QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) } self.widgets["template_options"].addItems(self.template_names) template_name = self.widgets["template_options"].currentText() self._update_to_match_template(template_name) # Build clickboxes layout = QtWidgets.QHBoxLayout(self.widgets["clickbox"]) layout.addWidget(self.widgets["import_cameras"]) layout.addWidget(self.widgets["preserve_strokes"]) # Build combobox layout = QtWidgets.QHBoxLayout(self.widgets["combobox"]) layout.addWidget(self.widgets["template_options"]) # Build buttons layout = QtWidgets.QHBoxLayout(self.widgets["buttons"]) # Build layout. layout = QtWidgets.QVBoxLayout(self) layout.addWidget(self.widgets["label"]) layout.addWidget(self.widgets["combobox"]) layout.addWidget(self.widgets["clickbox"]) layout.addWidget(self.widgets["buttons"]) self.widgets["template_options"].currentTextChanged.connect( self._update_to_match_template) self.widgets["buttons"].accepted.connect(self.on_accept) self.widgets["buttons"].rejected.connect(self.on_reject) def on_accept(self): self.configuration = self.get_project_configuration() self.close() def on_reject(self): self.close() def _update_to_match_template(self, template_name): template = get_template_by_name(template_name, self.project_templates) self.widgets["import_cameras"].setChecked(template["import_cameras"]) self.widgets["preserve_strokes"].setChecked( template["preserve_strokes"]) def get_project_configuration(self): templates = self.project_templates template_name = self.widgets["template_options"].currentText() template = get_template_by_name(template_name, templates) template = copy.deepcopy(template) # do not edit the original template["import_cameras"] = self.widgets["import_cameras"].isChecked() template["preserve_strokes"] = ( self.widgets["preserve_strokes"].isChecked() ) for key in ["normal_map_format", "project_workflow", "tangent_space_mode"]: template[key] = _convert(template[key]) return template @classmethod def prompt(cls, templates): dialog = cls(templates) dialog.exec_() configuration = dialog.configuration dialog.deleteLater() return configuration class SubstanceLoadProjectMesh(load.LoaderPlugin): """Load mesh for project""" families = ["*"] representations = ["abc", "fbx", "obj", "gltf"] label = "Load mesh" order = -10 icon = "code-fork" color = "orange" # Defined via settings project_templates = [] def load(self, context, name, namespace, options=None): # Get user inputs result = SubstanceProjectConfigurationWindow.prompt( self.project_templates) if not result: # cancelling loader action return sp_settings = substance_painter.project.Settings( import_cameras=result["import_cameras"], normal_map_format=result["normal_map_format"], project_workflow=result["project_workflow"], tangent_space_mode=result["tangent_space_mode"], default_texture_resolution=result["default_texture_resolution"] ) if not substance_painter.project.is_open(): # Allow to 'initialize' a new project path = self.filepath_from_context(context) sp_settings = substance_painter.project.Settings( import_cameras=result["import_cameras"], normal_map_format=result["normal_map_format"], project_workflow=result["project_workflow"], tangent_space_mode=result["tangent_space_mode"], default_texture_resolution=result["default_texture_resolution"] ) settings = substance_painter.project.create( mesh_file_path=path, settings=sp_settings ) else: # Reload the mesh settings = substance_painter.project.MeshReloadingSettings( import_cameras=result["import_cameras"], preserve_strokes=result["preserve_strokes"]) def on_mesh_reload(status: substance_painter.project.ReloadMeshStatus): # noqa if status == substance_painter.project.ReloadMeshStatus.SUCCESS: # noqa self.log.info("Reload succeeded") else: raise LoadError("Reload of mesh failed") path = self.filepath_from_context(context) substance_painter.project.reload_mesh(path, settings, on_mesh_reload) # Store container container = {} project_mesh_object_name = "_ProjectMesh_" imprint_container(container, name=project_mesh_object_name, namespace=project_mesh_object_name, context=context, loader=self) # We want store some options for updating to keep consistent behavior # from the user's original choice. We don't store 'preserve_strokes' # as we always preserve strokes on updates. container["options"] = { "import_cameras": result["import_cameras"], } set_container_metadata(project_mesh_object_name, container) def switch(self, container, representation): self.update(container, representation) def update(self, container, representation): path = get_representation_path(representation) # Reload the mesh container_options = container.get("options", {}) settings = substance_painter.project.MeshReloadingSettings( import_cameras=container_options.get("import_cameras", True), preserve_strokes=True ) def on_mesh_reload(status: substance_painter.project.ReloadMeshStatus): if status == substance_painter.project.ReloadMeshStatus.SUCCESS: self.log.info("Reload succeeded") else: raise LoadError("Reload of mesh failed") substance_painter.project.reload_mesh(path, settings, on_mesh_reload) # Update container representation object_name = container["objectName"] update_data = {"representation": str(representation["_id"])} set_container_metadata(object_name, update_data, update=True) def remove(self, container): # Remove OpenPype related settings about what model was loaded # or close the project? # TODO: This is likely best 'hidden' away to the user because # this will leave the project's mesh unmanaged. remove_container_metadata(container["objectName"]) ================================================ FILE: openpype/hosts/substancepainter/plugins/publish/collect_current_file.py ================================================ import pyblish.api from openpype.pipeline import registered_host class CollectCurrentFile(pyblish.api.ContextPlugin): """Inject the current working file into context""" order = pyblish.api.CollectorOrder - 0.49 label = "Current Workfile" hosts = ["substancepainter"] def process(self, context): host = registered_host() path = host.get_current_workfile() context.data["currentFile"] = path self.log.debug(f"Current workfile: {path}") ================================================ FILE: openpype/hosts/substancepainter/plugins/publish/collect_textureset_images.py ================================================ import os import copy import pyblish.api from openpype.pipeline import publish import substance_painter.textureset from openpype.hosts.substancepainter.api.lib import ( get_parsed_export_maps, strip_template ) from openpype.pipeline.create import get_subset_name from openpype.client import get_asset_by_name class CollectTextureSet(pyblish.api.InstancePlugin): """Extract Textures using an output template config""" # TODO: Production-test usage of color spaces # TODO: Detect what source data channels end up in each file label = "Collect Texture Set images" hosts = ["substancepainter"] families = ["textureSet"] order = pyblish.api.CollectorOrder def process(self, instance): config = self.get_export_config(instance) asset_doc = get_asset_by_name( project_name=instance.context.data["projectName"], asset_name=instance.data["asset"] ) instance.data["exportConfig"] = config maps = get_parsed_export_maps(config) # Let's break the instance into multiple instances to integrate # a subset per generated texture or texture UDIM sequence for (texture_set_name, stack_name), template_maps in maps.items(): self.log.info(f"Processing {texture_set_name}/{stack_name}") for template, outputs in template_maps.items(): self.log.info(f"Processing {template}") self.create_image_instance(instance, template, outputs, asset_doc=asset_doc, texture_set_name=texture_set_name, stack_name=stack_name) def create_image_instance(self, instance, template, outputs, asset_doc, texture_set_name, stack_name): """Create a new instance per image or UDIM sequence. The new instances will be of family `image`. """ context = instance.context first_filepath = outputs[0]["filepath"] fnames = [os.path.basename(output["filepath"]) for output in outputs] ext = os.path.splitext(first_filepath)[1] assert ext.lstrip("."), f"No extension: {ext}" always_include_texture_set_name = False # todo: make this configurable all_texture_sets = substance_painter.textureset.all_texture_sets() texture_set = substance_painter.textureset.TextureSet.from_name( texture_set_name ) # Define the suffix we want to give this particular texture # set and set up a remapped subset naming for it. suffix = "" if always_include_texture_set_name or len(all_texture_sets) > 1: # More than one texture set, include texture set name suffix += f".{texture_set_name}" if texture_set.is_layered_material() and stack_name: # More than one stack, include stack name suffix += f".{stack_name}" # Always include the map identifier map_identifier = strip_template(template) suffix += f".{map_identifier}" image_subset = get_subset_name( # TODO: The family actually isn't 'texture' currently but for now # this is only done so the subset name starts with 'texture' family="texture", variant=instance.data["variant"] + suffix, task_name=instance.data.get("task"), asset_doc=asset_doc, project_name=context.data["projectName"], host_name=context.data["hostName"], project_settings=context.data["project_settings"] ) # Prepare representation representation = { "name": ext.lstrip("."), "ext": ext.lstrip("."), "files": fnames if len(fnames) > 1 else fnames[0], } # Mark as UDIM explicitly if it has UDIM tiles. if bool(outputs[0].get("udim")): # The representation for a UDIM sequence should have a `udim` key # that is a list of all udim tiles (str) like: ["1001", "1002"] # strings. See CollectTextures plug-in and Integrators. representation["udim"] = [output["udim"] for output in outputs] # Set up the representation for thumbnail generation # TODO: Simplify this once thumbnail extraction is refactored staging_dir = os.path.dirname(first_filepath) representation["tags"] = ["review"] representation["stagingDir"] = staging_dir # Clone the instance image_instance = context.create_instance(image_subset) image_instance[:] = instance[:] image_instance.data.update(copy.deepcopy(dict(instance.data))) image_instance.data["name"] = image_subset image_instance.data["label"] = image_subset image_instance.data["subset"] = image_subset image_instance.data["family"] = "image" image_instance.data["families"] = ["image", "textures"] image_instance.data["representations"] = [representation] # Group the textures together in the loader image_instance.data["subsetGroup"] = instance.data["subset"] # Store the texture set name and stack name on the instance image_instance.data["textureSetName"] = texture_set_name image_instance.data["textureStackName"] = stack_name # Store color space with the instance # Note: The extractor will assign it to the representation colorspace = outputs[0].get("colorSpace") if colorspace: self.log.debug(f"{image_subset} colorspace: {colorspace}") image_instance.data["colorspace"] = colorspace # Store the instance in the original instance as a member instance.append(image_instance) def get_export_config(self, instance): """Return an export configuration dict for texture exports. This config can be supplied to: - `substance_painter.export.export_project_textures` - `substance_painter.export.list_project_textures` See documentation on substance_painter.export module about the formatting of the configuration dictionary. Args: instance (pyblish.api.Instance): Texture Set instance to be published. Returns: dict: Export config """ creator_attrs = instance.data["creator_attributes"] preset_url = creator_attrs["exportPresetUrl"] self.log.debug(f"Exporting using preset: {preset_url}") # See: https://substance3d.adobe.com/documentation/ptpy/api/substance_painter/export # noqa config = { # noqa "exportShaderParams": True, "exportPath": publish.get_instance_staging_dir(instance), "defaultExportPreset": preset_url, # Custom overrides to the exporter "exportParameters": [ { "parameters": { "fileFormat": creator_attrs["exportFileFormat"], "sizeLog2": creator_attrs["exportSize"], "paddingAlgorithm": creator_attrs["exportPadding"], "dilationDistance": creator_attrs["exportDilationDistance"] # noqa } } ] } # Create the list of Texture Sets to export. config["exportList"] = [] for texture_set in substance_painter.textureset.all_texture_sets(): config["exportList"].append({"rootPath": texture_set.name()}) # Consider None values from the creator attributes optionals for override in config["exportParameters"]: parameters = override.get("parameters") for key, value in dict(parameters).items(): if value is None: parameters.pop(key) return config ================================================ FILE: openpype/hosts/substancepainter/plugins/publish/collect_workfile_representation.py ================================================ import os import pyblish.api class CollectWorkfileRepresentation(pyblish.api.InstancePlugin): """Create a publish representation for the current workfile instance.""" order = pyblish.api.CollectorOrder label = "Workfile representation" hosts = ["substancepainter"] families = ["workfile"] def process(self, instance): context = instance.context current_file = context.data["currentFile"] folder, file = os.path.split(current_file) filename, ext = os.path.splitext(file) instance.data["representations"] = [{ "name": ext.lstrip("."), "ext": ext.lstrip("."), "files": file, "stagingDir": folder, }] ================================================ FILE: openpype/hosts/substancepainter/plugins/publish/extract_textures.py ================================================ import substance_painter.export from openpype.pipeline import KnownPublishError, publish class ExtractTextures(publish.Extractor, publish.ColormanagedPyblishPluginMixin): """Extract Textures using an output template config. Note: This Extractor assumes that `collect_textureset_images` has prepared the relevant export config and has also collected the individual image instances for publishing including its representation. That is why this particular Extractor doesn't specify representations to integrate. """ label = "Extract Texture Set" hosts = ["substancepainter"] families = ["textureSet"] # Run before thumbnail extractors order = publish.Extractor.order - 0.1 def process(self, instance): config = instance.data["exportConfig"] result = substance_painter.export.export_project_textures(config) if result.status != substance_painter.export.ExportStatus.Success: raise KnownPublishError( "Failed to export texture set: {}".format(result.message) ) # Log what files we generated for (texture_set_name, stack_name), maps in result.textures.items(): # Log our texture outputs self.log.info(f"Exported stack: {texture_set_name} {stack_name}") for texture_map in maps: self.log.info(f"Exported texture: {texture_map}") # We'll insert the color space data for each image instance that we # added into this texture set. The collector couldn't do so because # some anatomy and other instance data needs to be collected prior context = instance.context for image_instance in instance: representation = next(iter(image_instance.data["representations"])) colorspace = image_instance.data.get("colorspace") if not colorspace: self.log.debug("No color space data present for instance: " f"{image_instance}") continue self.set_representation_colorspace(representation, context=context, colorspace=colorspace) # The TextureSet instance should not be integrated. It generates no # output data. Instead the separated texture instances are generated # from it which themselves integrate into the database. instance.data["integrate"] = False ================================================ FILE: openpype/hosts/substancepainter/plugins/publish/increment_workfile.py ================================================ import pyblish.api from openpype.lib import version_up from openpype.pipeline import registered_host class IncrementWorkfileVersion(pyblish.api.ContextPlugin): """Increment current workfile version.""" order = pyblish.api.IntegratorOrder + 1 label = "Increment Workfile Version" optional = True hosts = ["substancepainter"] def process(self, context): assert all(result["success"] for result in context.data["results"]), ( "Publishing not successful so version is not increased.") host = registered_host() path = context.data["currentFile"] self.log.info(f"Incrementing current workfile to: {path}") host.save_workfile(version_up(path)) ================================================ FILE: openpype/hosts/substancepainter/plugins/publish/save_workfile.py ================================================ import pyblish.api from openpype.pipeline import ( registered_host, KnownPublishError ) class SaveCurrentWorkfile(pyblish.api.ContextPlugin): """Save current workfile""" label = "Save current workfile" order = pyblish.api.ExtractorOrder - 0.49 hosts = ["substancepainter"] def process(self, context): host = registered_host() current = host.get_current_workfile() if context.data["currentFile"] != current: raise KnownPublishError("Workfile has changed during publishing!") if host.workfile_has_unsaved_changes(): self.log.info("Saving current file: {}".format(current)) host.save_workfile() else: self.log.debug("Skipping workfile save because there are no " "unsaved changes.") ================================================ FILE: openpype/hosts/substancepainter/plugins/publish/validate_ouput_maps.py ================================================ import copy import os import pyblish.api import substance_painter.export from openpype.pipeline import PublishValidationError class ValidateOutputMaps(pyblish.api.InstancePlugin): """Validate all output maps for Output Template are generated. Output maps will be skipped by Substance Painter if it is an output map in the Substance Output Template which uses channels that the current substance painter project has not painted or generated. """ order = pyblish.api.ValidatorOrder label = "Validate output maps" hosts = ["substancepainter"] families = ["textureSet"] def process(self, instance): config = instance.data["exportConfig"] # Substance Painter API does not allow to query the actual output maps # it will generate without actually exporting the files. So we try to # generate the smallest size / fastest export as possible config = copy.deepcopy(config) parameters = config["exportParameters"][0]["parameters"] parameters["sizeLog2"] = [1, 1] # output 2x2 images (smallest) parameters["paddingAlgorithm"] = "passthrough" # no dilation (faster) parameters["dithering"] = False # no dithering (faster) result = substance_painter.export.export_project_textures(config) if result.status != substance_painter.export.ExportStatus.Success: raise PublishValidationError( "Failed to export texture set: {}".format(result.message) ) generated_files = set() for texture_maps in result.textures.values(): for texture_map in texture_maps: generated_files.add(os.path.normpath(texture_map)) # Directly clean up our temporary export os.remove(texture_map) creator_attributes = instance.data.get("creator_attributes", {}) allow_skipped_maps = creator_attributes.get("allowSkippedMaps", True) error_report_missing = [] for image_instance in instance: # Confirm whether the instance has its expected files generated. # We assume there's just one representation and that it is # the actual texture representation from the collector. representation = next(iter(image_instance.data["representations"])) staging_dir = representation["stagingDir"] filenames = representation["files"] if not isinstance(filenames, (list, tuple)): # Convert single file to list filenames = [filenames] missing = [] for filename in filenames: filepath = os.path.join(staging_dir, filename) filepath = os.path.normpath(filepath) if filepath not in generated_files: self.log.warning(f"Missing texture: {filepath}") missing.append(filepath) if not missing: continue if allow_skipped_maps: # TODO: This is changing state on the instance's which # should not be done during validation. self.log.warning(f"Disabling texture instance: " f"{image_instance}") image_instance.data["active"] = False image_instance.data["publish"] = False image_instance.data["integrate"] = False representation.setdefault("tags", []).append("delete") continue else: error_report_missing.append((image_instance, missing)) if error_report_missing: message = ( "The Texture Set skipped exporting some output maps which are " "defined in the Output Template. This happens if the Output " "Templates exports maps from channels which you do not " "have in your current Substance Painter project.\n\n" "To allow this enable the *Allow Skipped Output Maps* setting " "on the instance.\n\n" f"Instance {instance} skipped exporting output maps:\n" "" ) for image_instance, missing in error_report_missing: missing_str = ", ".join(missing) message += f"- **{image_instance}** skipped: {missing_str}\n" raise PublishValidationError( message=message, title="Missing output maps" ) ================================================ FILE: openpype/hosts/traypublisher/__init__.py ================================================ from .addon import TrayPublishAddon __all__ = ( "TrayPublishAddon", ) ================================================ FILE: openpype/hosts/traypublisher/addon.py ================================================ import os from openpype.lib import get_openpype_execute_args from openpype.lib.execute import run_detached_process from openpype.modules import ( click_wrap, OpenPypeModule, ITrayAction, IHostAddon, ) TRAYPUBLISH_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) class TrayPublishAddon(OpenPypeModule, IHostAddon, ITrayAction): label = "Publisher" name = "traypublisher" host_name = "traypublisher" def initialize(self, modules_settings): self.enabled = True self.publish_paths = [ os.path.join(TRAYPUBLISH_ROOT_DIR, "plugins", "publish") ] def tray_init(self): return def on_action_trigger(self): self.run_traypublisher() def connect_with_modules(self, enabled_modules): """Collect publish paths from other modules.""" publish_paths = self.manager.collect_plugin_paths()["publish"] self.publish_paths.extend(publish_paths) def run_traypublisher(self): args = get_openpype_execute_args( "module", self.name, "launch" ) run_detached_process(args) def cli(self, click_group): click_group.add_command(cli_main.to_click_obj()) @click_wrap.group( TrayPublishAddon.name, help="TrayPublisher related commands.") def cli_main(): pass @cli_main.command() def launch(): """Launch TrayPublish tool UI.""" from openpype.tools import traypublisher traypublisher.main() ================================================ FILE: openpype/hosts/traypublisher/api/__init__.py ================================================ from .pipeline import ( TrayPublisherHost, ) __all__ = ( "TrayPublisherHost", ) ================================================ FILE: openpype/hosts/traypublisher/api/editorial.py ================================================ import re from copy import deepcopy from openpype.client import get_asset_by_id from openpype.pipeline.create import CreatorError class ShotMetadataSolver: """ Solving hierarchical metadata Used during editorial publishing. Works with input clip name and settings defining python formatable template. Settings also define searching patterns and its token keys used for formatting in templates. """ NO_DECOR_PATERN = re.compile(r"\{([a-z]*?)\}") # presets clip_name_tokenizer = None shot_rename = True shot_hierarchy = None shot_add_tasks = None def __init__( self, clip_name_tokenizer, shot_rename, shot_hierarchy, shot_add_tasks, logger ): self.clip_name_tokenizer = clip_name_tokenizer self.shot_rename = shot_rename self.shot_hierarchy = shot_hierarchy self.shot_add_tasks = shot_add_tasks self.log = logger def _rename_template(self, data): """Shot renaming function Args: data (dict): formatting data Raises: CreatorError: If missing keys Returns: str: formatted new name """ shot_rename_template = self.shot_rename[ "shot_rename_template"] try: # format to new shot name return shot_rename_template.format(**data) except KeyError as _error: raise CreatorError(( "Make sure all keys in settings are correct:: \n\n" f"From template string {shot_rename_template} > " f"`{_error}` has no equivalent in \n" f"{list(data.keys())} input formatting keys!" )) def _generate_tokens(self, clip_name, source_data): """Token generator Settings defines token pairs key and regex expression. Args: clip_name (str): name of clip in editorial source_data (dict): data for formatting Raises: CreatorError: if missing key Returns: dict: updated source_data """ output_data = deepcopy(source_data["anatomy_data"]) output_data["clip_name"] = clip_name if not self.clip_name_tokenizer: return output_data parent_name = source_data["selected_asset_doc"]["name"] search_text = parent_name + clip_name for token_key, pattern in self.clip_name_tokenizer.items(): p = re.compile(pattern) match = p.findall(search_text) if not match: raise CreatorError(( "Make sure regex expression works with your data: \n\n" f"'{token_key}' with regex '{pattern}' in your settings\n" "can't find any match in your clip name " f"'{search_text}'!\n\nLook to: " "'project_settings/traypublisher/editorial_creators" "/editorial_simple/clip_name_tokenizer'\n" "at your project settings..." )) # QUESTION:how to refactor `match[-1]` to some better way? output_data[token_key] = match[-1] return output_data def _create_parents_from_settings(self, parents, data): """formatting parent components. Args: parents (list): list of dict parent components data (dict): formatting data Raises: CreatorError: missing formatting key CreatorError: missing token key KeyError: missing parent token Returns: list: list of dict of parent components """ # fill the parents parts from presets shot_hierarchy = deepcopy(self.shot_hierarchy) hierarchy_parents = shot_hierarchy["parents"] # fill parent keys data template from anatomy data try: _parent_tokens_formatting_data = { parent_token["name"]: parent_token["value"].format(**data) for parent_token in hierarchy_parents } except KeyError as _error: raise CreatorError(( "Make sure all keys in settings are correct : \n" f"`{_error}` has no equivalent in \n{list(data.keys())}" )) _parent_tokens_type = { parent_token["name"]: parent_token["type"] for parent_token in hierarchy_parents } for _index, _parent in enumerate( shot_hierarchy["parents_path"].split("/") ): # format parent token with value which is formatted try: parent_name = _parent.format( **_parent_tokens_formatting_data) except KeyError as _error: raise CreatorError(( "Make sure all keys in settings are correct : \n\n" f"`{_error}` from template string " f"{shot_hierarchy['parents_path']}, " f" has no equivalent in \n" f"{list(_parent_tokens_formatting_data.keys())} parents" )) parent_token_name = ( self.NO_DECOR_PATERN.findall(_parent).pop()) if not parent_token_name: raise KeyError( f"Parent token is not found in: `{_parent}`") # find parent type parent_token_type = _parent_tokens_type[parent_token_name] # in case selected context is set to the same asset if ( _index == 0 and parents[-1]["entity_name"] == parent_name ): continue # in case first parent is project then start parents from start if ( _index == 0 and parent_token_type == "Project" ): project_parent = parents[0] parents = [project_parent] continue parents.append({ "entity_type": parent_token_type, "entity_name": parent_name }) return parents def _create_hierarchy_path(self, parents): """Converting hierarchy path from parents Args: parents (list): list of dict parent components Returns: str: hierarchy path """ return "/".join( [ p["entity_name"] for p in parents if p["entity_type"] != "Project" ] ) if parents else "" def _get_parents_from_selected_asset( self, asset_doc, project_doc ): """Returning parents from context on selected asset. Context defined in Traypublisher project tree. Args: asset_doc (db obj): selected asset doc project_doc (db obj): actual project doc Returns: list: list of dict parent components """ project_name = project_doc["name"] visual_hierarchy = [asset_doc] current_doc = asset_doc # looping through all available visual parents # if they are not available anymore than it breaks while True: visual_parent_id = current_doc["data"]["visualParent"] visual_parent = None if visual_parent_id: visual_parent = get_asset_by_id(project_name, visual_parent_id) if not visual_parent: visual_hierarchy.append(project_doc) break visual_hierarchy.append(visual_parent) current_doc = visual_parent # add current selection context hierarchy return [ { "entity_type": entity["data"]["entityType"], "entity_name": entity["name"] } for entity in reversed(visual_hierarchy) ] def _generate_tasks_from_settings(self, project_doc): """Convert settings inputs to task data. Args: project_doc (db obj): actual project doc Raises: KeyError: Missing task type in project doc Returns: dict: tasks data """ tasks_to_add = {} project_tasks = project_doc["config"]["tasks"] for task_name, task_data in self.shot_add_tasks.items(): _task_data = deepcopy(task_data) # check if task type in project task types if _task_data["type"] in project_tasks.keys(): tasks_to_add[task_name] = _task_data else: raise KeyError( "Missing task type `{}` for `{}` is not" " existing in `{}``".format( _task_data["type"], task_name, list(project_tasks.keys()) ) ) return tasks_to_add def generate_data(self, clip_name, source_data): """Metadata generator. Converts input data to hierarchy mentadata. Args: clip_name (str): clip name source_data (dict): formatting data Returns: (str, dict): shot name and hierarchy data """ tasks = {} asset_doc = source_data["selected_asset_doc"] project_doc = source_data["project_doc"] # match clip to shot name at start shot_name = clip_name # parse all tokens and generate formatting data formatting_data = self._generate_tokens(shot_name, source_data) # generate parents from selected asset parents = self._get_parents_from_selected_asset(asset_doc, project_doc) if self.shot_rename["enabled"]: shot_name = self._rename_template(formatting_data) self.log.info(f"Renamed shot name: {shot_name}") if self.shot_hierarchy["enabled"]: parents = self._create_parents_from_settings( parents, formatting_data) if self.shot_add_tasks: tasks = self._generate_tasks_from_settings( project_doc) # generate hierarchy path from parents hierarchy_path = self._create_hierarchy_path(parents) if hierarchy_path: folder_path = f"/{hierarchy_path}/{shot_name}" else: folder_path = f"/{shot_name}" return shot_name, { "hierarchy": hierarchy_path, "folderPath": folder_path, "parents": parents, "tasks": tasks } ================================================ FILE: openpype/hosts/traypublisher/api/pipeline.py ================================================ import os import json import tempfile import atexit import pyblish.api from openpype.pipeline import ( register_creator_plugin_path, legacy_io, ) from openpype.host import HostBase, IPublishHost ROOT_DIR = os.path.dirname(os.path.dirname( os.path.abspath(__file__) )) PUBLISH_PATH = os.path.join(ROOT_DIR, "plugins", "publish") CREATE_PATH = os.path.join(ROOT_DIR, "plugins", "create") class TrayPublisherHost(HostBase, IPublishHost): name = "traypublisher" def install(self): os.environ["AVALON_APP"] = self.name legacy_io.Session["AVALON_APP"] = self.name pyblish.api.register_host("traypublisher") pyblish.api.register_plugin_path(PUBLISH_PATH) register_creator_plugin_path(CREATE_PATH) def get_context_title(self): return HostContext.get_project_name() def get_context_data(self): return HostContext.get_context_data() def update_context_data(self, data, changes): HostContext.save_context_data(data) def set_project_name(self, project_name): # TODO Deregister project specific plugins and register new project # plugins os.environ["AVALON_PROJECT"] = project_name legacy_io.Session["AVALON_PROJECT"] = project_name legacy_io.install() HostContext.set_project_name(project_name) class HostContext: _context_json_path = None @staticmethod def _on_exit(): if ( HostContext._context_json_path and os.path.exists(HostContext._context_json_path) ): os.remove(HostContext._context_json_path) @classmethod def get_context_json_path(cls): if cls._context_json_path is None: output_file = tempfile.NamedTemporaryFile( mode="w", prefix="traypub_", suffix=".json" ) output_file.close() cls._context_json_path = output_file.name atexit.register(HostContext._on_exit) print(cls._context_json_path) return cls._context_json_path @classmethod def _get_data(cls, group=None): json_path = cls.get_context_json_path() data = {} if not os.path.exists(json_path): with open(json_path, "w") as json_stream: json.dump(data, json_stream) else: with open(json_path, "r") as json_stream: content = json_stream.read() if content: data = json.loads(content) if group is None: return data return data.get(group) @classmethod def _save_data(cls, group, new_data): json_path = cls.get_context_json_path() data = cls._get_data() data[group] = new_data with open(json_path, "w") as json_stream: json.dump(data, json_stream) @classmethod def add_instance(cls, instance): instances = cls.get_instances() instances.append(instance) cls.save_instances(instances) @classmethod def get_instances(cls): return cls._get_data("instances") or [] @classmethod def save_instances(cls, instances): cls._save_data("instances", instances) @classmethod def get_context_data(cls): return cls._get_data("context") or {} @classmethod def save_context_data(cls, data): cls._save_data("context", data) @classmethod def get_project_name(cls): return cls._get_data("project_name") @classmethod def set_project_name(cls, project_name): cls._save_data("project_name", project_name) @classmethod def get_data_to_store(cls): return { "project_name": cls.get_project_name(), "instances": cls.get_instances(), "context": cls.get_context_data(), } def list_instances(): return HostContext.get_instances() def update_instances(update_list): updated_instances = {} for instance, _changes in update_list: updated_instances[instance.id] = instance.data_to_store() instances = HostContext.get_instances() for instance_data in instances: instance_id = instance_data["instance_id"] if instance_id in updated_instances: new_instance_data = updated_instances[instance_id] old_keys = set(instance_data.keys()) new_keys = set(new_instance_data.keys()) instance_data.update(new_instance_data) for key in (old_keys - new_keys): instance_data.pop(key) HostContext.save_instances(instances) def remove_instances(instances): if not isinstance(instances, (tuple, list)): instances = [instances] current_instances = HostContext.get_instances() for instance in instances: instance_id = instance.data["instance_id"] found_idx = None for idx, _instance in enumerate(current_instances): if instance_id == _instance["instance_id"]: found_idx = idx break if found_idx is not None: current_instances.pop(found_idx) HostContext.save_instances(current_instances) def get_context_data(): return HostContext.get_context_data() def update_context_data(data, changes): HostContext.save_context_data(data) ================================================ FILE: openpype/hosts/traypublisher/api/plugin.py ================================================ from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_assets, get_subsets, get_last_versions, get_asset_name_identifier, ) from openpype.lib.attribute_definitions import ( FileDef, BoolDef, NumberDef, UISeparatorDef, ) from openpype.lib.transcoding import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS from openpype.pipeline.create import ( Creator, HiddenCreator, CreatedInstance, cache_and_get_instances, PRE_CREATE_THUMBNAIL_KEY, ) from .pipeline import ( list_instances, update_instances, remove_instances, HostContext, ) REVIEW_EXTENSIONS = set(IMAGE_EXTENSIONS) | set(VIDEO_EXTENSIONS) SHARED_DATA_KEY = "openpype.traypublisher.instances" class HiddenTrayPublishCreator(HiddenCreator): host_name = "traypublisher" settings_category = "traypublisher" def collect_instances(self): instances_by_identifier = cache_and_get_instances( self, SHARED_DATA_KEY, list_instances ) for instance_data in instances_by_identifier[self.identifier]: instance = CreatedInstance.from_existing(instance_data, self) self._add_instance_to_context(instance) def update_instances(self, update_list): update_instances(update_list) def remove_instances(self, instances): remove_instances(instances) for instance in instances: self._remove_instance_from_context(instance) def _store_new_instance(self, new_instance): """Tray publisher specific method to store instance. Instance is stored into "workfile" of traypublisher and also add it to CreateContext. Args: new_instance (CreatedInstance): Instance that should be stored. """ # Host implementation of storing metadata about instance HostContext.add_instance(new_instance.data_to_store()) # Add instance to current context self._add_instance_to_context(new_instance) class TrayPublishCreator(Creator): create_allow_context_change = True host_name = "traypublisher" settings_category = "traypublisher" def collect_instances(self): instances_by_identifier = cache_and_get_instances( self, SHARED_DATA_KEY, list_instances ) for instance_data in instances_by_identifier[self.identifier]: instance = CreatedInstance.from_existing(instance_data, self) self._add_instance_to_context(instance) def update_instances(self, update_list): update_instances(update_list) def remove_instances(self, instances): remove_instances(instances) for instance in instances: self._remove_instance_from_context(instance) def _store_new_instance(self, new_instance): """Tray publisher specific method to store instance. Instance is stored into "workfile" of traypublisher and also add it to CreateContext. Args: new_instance (CreatedInstance): Instance that should be stored. """ # Host implementation of storing metadata about instance HostContext.add_instance(new_instance.data_to_store()) new_instance.mark_as_stored() # Add instance to current context self._add_instance_to_context(new_instance) class SettingsCreator(TrayPublishCreator): create_allow_context_change = True create_allow_thumbnail = True allow_version_control = False extensions = [] def create(self, subset_name, data, pre_create_data): # Pass precreate data to creator attributes thumbnail_path = pre_create_data.pop(PRE_CREATE_THUMBNAIL_KEY, None) # Fill 'version_to_use' if version control is enabled if self.allow_version_control: if AYON_SERVER_ENABLED: asset_name = data["folderPath"] else: asset_name = data["asset"] subset_docs_by_asset_id = self._prepare_next_versions( [asset_name], [subset_name]) version = subset_docs_by_asset_id[asset_name].get(subset_name) pre_create_data["version_to_use"] = version data["_previous_last_version"] = version data["creator_attributes"] = pre_create_data data["settings_creator"] = True # Create new instance new_instance = CreatedInstance(self.family, subset_name, data, self) self._store_new_instance(new_instance) if thumbnail_path: self.set_instance_thumbnail_path(new_instance.id, thumbnail_path) def _prepare_next_versions(self, asset_names, subset_names): """Prepare next versions for given asset and subset names. Todos: Expect combination of subset names by asset name to avoid unnecessary server calls for unused subsets. Args: asset_names (Iterable[str]): Asset names. subset_names (Iterable[str]): Subset names. Returns: dict[str, dict[str, int]]: Last versions by asset and subset names. """ # Prepare all versions for all combinations to '1' subset_docs_by_asset_id = { asset_name: { subset_name: 1 for subset_name in subset_names } for asset_name in asset_names } if not asset_names or not subset_names: return subset_docs_by_asset_id asset_docs = get_assets( self.project_name, asset_names=asset_names, fields=["_id", "name", "data.parents"] ) asset_names_by_id = { asset_doc["_id"]: get_asset_name_identifier(asset_doc) for asset_doc in asset_docs } subset_docs = list(get_subsets( self.project_name, asset_ids=asset_names_by_id.keys(), subset_names=subset_names, fields=["_id", "name", "parent"] )) subset_ids = {subset_doc["_id"] for subset_doc in subset_docs} last_versions = get_last_versions( self.project_name, subset_ids, fields=["name", "parent"]) for subset_doc in subset_docs: asset_id = subset_doc["parent"] asset_name = asset_names_by_id[asset_id] subset_name = subset_doc["name"] subset_id = subset_doc["_id"] last_version = last_versions.get(subset_id) version = 0 if last_version is not None: version = last_version["name"] subset_docs_by_asset_id[asset_name][subset_name] += version return subset_docs_by_asset_id def _fill_next_versions(self, instances_data): """Fill next version for instances. Instances have also stored previous next version to be able to recognize if user did enter different version. If version was not changed by user, or user set it to '0' the next version will be updated by current database state. """ filtered_instance_data = [] for instance in instances_data: previous_last_version = instance.get("_previous_last_version") creator_attributes = instance["creator_attributes"] use_next_version = creator_attributes.get( "use_next_version", True) version = creator_attributes.get("version_to_use", 0) if ( use_next_version or version == 0 or version == previous_last_version ): filtered_instance_data.append(instance) if AYON_SERVER_ENABLED: asset_names = { instance["folderPath"] for instance in filtered_instance_data } else: asset_names = { instance["asset"] for instance in filtered_instance_data } subset_names = { instance["subset"] for instance in filtered_instance_data} subset_docs_by_asset_id = self._prepare_next_versions( asset_names, subset_names ) for instance in filtered_instance_data: if AYON_SERVER_ENABLED: asset_name = instance["folderPath"] else: asset_name = instance["asset"] subset_name = instance["subset"] version = subset_docs_by_asset_id[asset_name][subset_name] instance["creator_attributes"]["version_to_use"] = version instance["_previous_last_version"] = version def collect_instances(self): """Collect instances from host. Overriden to be able to manage version control attributes. If version control is disabled, the attributes will be removed from instances, and next versions are filled if is version control enabled. """ instances_by_identifier = cache_and_get_instances( self, SHARED_DATA_KEY, list_instances ) instances = instances_by_identifier[self.identifier] if not instances: return if self.allow_version_control: self._fill_next_versions(instances) for instance_data in instances: # Make sure that there are not data related to version control # if plugin does not support it if not self.allow_version_control: instance_data.pop("_previous_last_version", None) creator_attributes = instance_data["creator_attributes"] creator_attributes.pop("version_to_use", None) creator_attributes.pop("use_next_version", None) instance = CreatedInstance.from_existing(instance_data, self) self._add_instance_to_context(instance) def get_instance_attr_defs(self): defs = self.get_pre_create_attr_defs() if self.allow_version_control: defs += [ UISeparatorDef(), BoolDef( "use_next_version", default=True, label="Use next version", ), NumberDef( "version_to_use", default=1, minimum=0, maximum=999, label="Version to use", ) ] return defs def get_pre_create_attr_defs(self): # Use same attributes as for instance attributes return [ FileDef( "representation_files", folders=False, extensions=self.extensions, allow_sequences=self.allow_sequences, single_item=not self.allow_multiple_items, label="Representations", ), FileDef( "reviewable", folders=False, extensions=REVIEW_EXTENSIONS, allow_sequences=True, single_item=True, label="Reviewable representations", extensions_label="Single reviewable item" ) ] @classmethod def from_settings(cls, item_data): identifier = item_data["identifier"] family = item_data["family"] if not identifier: identifier = "settings_{}".format(family) return type( "{}{}".format(cls.__name__, identifier), (cls, ), { "family": family, "identifier": identifier, "label": item_data["label"].strip(), "icon": item_data["icon"], "description": item_data["description"], "detailed_description": item_data["detailed_description"], "extensions": item_data["extensions"], "allow_sequences": item_data["allow_sequences"], "allow_multiple_items": item_data["allow_multiple_items"], "allow_version_control": item_data.get( "allow_version_control", False), "default_variants": item_data["default_variants"], } ) ================================================ FILE: openpype/hosts/traypublisher/batch_parsing.py ================================================ """Functions to parse asset names, versions from file names""" import os import re from openpype.lib import Logger from openpype.client import get_assets, get_asset_by_name def get_asset_doc_from_file_name(source_filename, project_name, version_regex, all_selected_asset_ids=None): """Try to parse out asset name from file name provided. Artists might provide various file name formats. Currently handled: - chair.mov - chair_v001.mov - my_chair_to_upload.mov """ version = None asset_name = os.path.splitext(source_filename)[0] # Always first check if source filename is directly asset (eg. 'chair.mov') matching_asset_doc = get_asset_by_name_case_not_sensitive( project_name, asset_name, all_selected_asset_ids) if matching_asset_doc is None: # name contains also a version matching_asset_doc, version = ( parse_with_version(project_name, asset_name, version_regex, all_selected_asset_ids)) if matching_asset_doc is None: matching_asset_doc = parse_containing(project_name, asset_name, all_selected_asset_ids) return matching_asset_doc, version def parse_with_version(project_name, asset_name, version_regex, all_selected_asset_ids=None, log=None): """Try to parse asset name from a file name containing version too Eg. 'chair_v001.mov' >> 'chair', 1 """ if not log: log = Logger.get_logger(__name__) log.debug( ("Asset doc by \"{}\" was not found, trying version regex.". format(asset_name))) matching_asset_doc = version_number = None regex_result = version_regex.findall(asset_name) if regex_result: _asset_name, _version_number = regex_result[0] matching_asset_doc = get_asset_by_name_case_not_sensitive( project_name, _asset_name, all_selected_asset_ids=all_selected_asset_ids) if matching_asset_doc: version_number = int(_version_number) return matching_asset_doc, version_number def parse_containing(project_name, asset_name, all_selected_asset_ids=None): """Look if file name contains any existing asset name""" for asset_doc in get_assets(project_name, asset_ids=all_selected_asset_ids, fields=["name"]): if asset_doc["name"].lower() in asset_name.lower(): return get_asset_by_name(project_name, asset_doc["name"]) def get_asset_by_name_case_not_sensitive(project_name, asset_name, all_selected_asset_ids=None, log=None): """Handle more cases in file names""" if not log: log = Logger.get_logger(__name__) asset_name = re.compile(asset_name, re.IGNORECASE) assets = list(get_assets(project_name, asset_ids=all_selected_asset_ids, asset_names=[asset_name])) if assets: if len(assets) > 1: log.warning("Too many records found for {}".format( asset_name)) return return assets.pop() ================================================ FILE: openpype/hosts/traypublisher/plugins/create/create_colorspace_look.py ================================================ # -*- coding: utf-8 -*- """Creator of colorspace look files. This creator is used to publish colorspace look files thanks to production type `ociolook`. All files are published as representation. """ from pathlib import Path from openpype import AYON_SERVER_ENABLED from openpype.client import get_asset_by_name from openpype.lib.attribute_definitions import ( FileDef, EnumDef, TextDef, UISeparatorDef ) from openpype.pipeline import ( CreatedInstance, CreatorError ) from openpype.pipeline import colorspace from openpype.hosts.traypublisher.api.plugin import TrayPublishCreator class CreateColorspaceLook(TrayPublishCreator): """Creates colorspace look files.""" identifier = "io.openpype.creators.traypublisher.colorspace_look" label = "Colorspace Look" family = "ociolook" description = "Publishes color space look file." extensions = [".cc", ".cube", ".3dl", ".spi1d", ".spi3d", ".csp", ".lut"] enabled = False colorspace_items = [ (None, "Not set") ] colorspace_attr_show = False config_items = None config_data = None def get_detail_description(self): return """# Colorspace Look This creator publishes color space look file (LUT). """ def get_icon(self): return "mdi.format-color-fill" def create(self, subset_name, instance_data, pre_create_data): repr_file = pre_create_data.get("luts_file") if not repr_file: raise CreatorError("No files specified") files = repr_file.get("filenames") if not files: # this should never happen raise CreatorError("Missing files from representation") if AYON_SERVER_ENABLED: asset_name = instance_data["folderPath"] else: asset_name = instance_data["asset"] asset_doc = get_asset_by_name( self.project_name, asset_name) subset_name = self.get_subset_name( variant=instance_data["variant"], task_name=instance_data["task"] or "Not set", project_name=self.project_name, asset_doc=asset_doc, ) instance_data["creator_attributes"] = { "abs_lut_path": ( Path(repr_file["directory"]) / files[0]).as_posix() } # Create new instance new_instance = CreatedInstance(self.family, subset_name, instance_data, self) new_instance.transient_data["config_items"] = self.config_items new_instance.transient_data["config_data"] = self.config_data self._store_new_instance(new_instance) def collect_instances(self): super().collect_instances() for instance in self.create_context.instances: if instance.creator_identifier == self.identifier: instance.transient_data["config_items"] = self.config_items instance.transient_data["config_data"] = self.config_data def get_instance_attr_defs(self): return [ EnumDef( "working_colorspace", self.colorspace_items, default="Not set", label="Working Colorspace", ), UISeparatorDef( label="Advanced1" ), TextDef( "abs_lut_path", label="LUT Path", ), EnumDef( "input_colorspace", self.colorspace_items, default="Not set", label="Input Colorspace", ), EnumDef( "direction", [ (None, "Not set"), ("forward", "Forward"), ("inverse", "Inverse") ], default="Not set", label="Direction" ), EnumDef( "interpolation", [ (None, "Not set"), ("linear", "Linear"), ("tetrahedral", "Tetrahedral"), ("best", "Best"), ("nearest", "Nearest") ], default="Not set", label="Interpolation" ), EnumDef( "output_colorspace", self.colorspace_items, default="Not set", label="Output Colorspace", ), ] def get_pre_create_attr_defs(self): return [ FileDef( "luts_file", folders=False, extensions=self.extensions, allow_sequences=False, single_item=True, label="Look Files", ) ] def apply_settings(self, project_settings, system_settings): host = self.create_context.host host_name = host.name project_name = host.get_current_project_name() config_data = colorspace.get_imageio_config( project_name, host_name, project_settings=project_settings ) if not config_data: self.enabled = False return filepath = config_data["path"] config_items = colorspace.get_ocio_config_colorspaces(filepath) labeled_colorspaces = colorspace.get_colorspaces_enumerator_items( config_items, include_aliases=True, include_roles=True ) self.config_items = config_items self.config_data = config_data self.colorspace_items.extend(labeled_colorspaces) self.enabled = True ================================================ FILE: openpype/hosts/traypublisher/plugins/create/create_editorial.py ================================================ import os from copy import deepcopy import opentimelineio as otio from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_asset_by_name, get_project ) from openpype.hosts.traypublisher.api.plugin import ( TrayPublishCreator, HiddenTrayPublishCreator ) from openpype.hosts.traypublisher.api.editorial import ( ShotMetadataSolver ) from openpype.pipeline import CreatedInstance from openpype.lib import ( get_ffprobe_data, convert_ffprobe_fps_value, FileDef, TextDef, NumberDef, EnumDef, BoolDef, UISeparatorDef, UILabelDef ) CLIP_ATTR_DEFS = [ EnumDef( "fps", items=[ {"value": "from_selection", "label": "From selection"}, {"value": 23.997, "label": "23.976"}, {"value": 24, "label": "24"}, {"value": 25, "label": "25"}, {"value": 29.97, "label": "29.97"}, {"value": 30, "label": "30"} ], label="FPS" ), NumberDef( "workfile_start_frame", default=1001, label="Workfile start frame" ), NumberDef( "handle_start", default=0, label="Handle start" ), NumberDef( "handle_end", default=0, label="Handle end" ) ] class EditorialClipInstanceCreatorBase(HiddenTrayPublishCreator): """ Wrapper class for clip family creators Args: HiddenTrayPublishCreator (BaseCreator): hidden supporting class """ host_name = "traypublisher" def create(self, instance_data, source_data=None): subset_name = instance_data["subset"] # Create new instance new_instance = CreatedInstance( self.family, subset_name, instance_data, self ) self._store_new_instance(new_instance) return new_instance def get_instance_attr_defs(self): return [ BoolDef( "add_review_family", default=True, label="Review" ) ] class EditorialShotInstanceCreator(EditorialClipInstanceCreatorBase): """ Shot family class The shot metadata instance carrier. Args: EditorialClipInstanceCreatorBase (BaseCreator): hidden supporting class """ identifier = "editorial_shot" family = "shot" label = "Editorial Shot" def get_instance_attr_defs(self): instance_attributes = [] if AYON_SERVER_ENABLED: instance_attributes.append( TextDef( "folderPath", label="Folder path" ) ) else: instance_attributes.append( TextDef( "shotName", label="Shot name" ) ) instance_attributes.extend(CLIP_ATTR_DEFS) return instance_attributes class EditorialPlateInstanceCreator(EditorialClipInstanceCreatorBase): """ Plate family class Plate representation instance. Args: EditorialClipInstanceCreatorBase (BaseCreator): hidden supporting class """ identifier = "editorial_plate" family = "plate" label = "Editorial Plate" class EditorialAudioInstanceCreator(EditorialClipInstanceCreatorBase): """ Audio family class Audio representation instance. Args: EditorialClipInstanceCreatorBase (BaseCreator): hidden supporting class """ identifier = "editorial_audio" family = "audio" label = "Editorial Audio" class EditorialReviewInstanceCreator(EditorialClipInstanceCreatorBase): """ Review family class Review representation instance. Args: EditorialClipInstanceCreatorBase (BaseCreator): hidden supporting class """ identifier = "editorial_review" family = "review" label = "Editorial Review" class EditorialSimpleCreator(TrayPublishCreator): """ Editorial creator class Simple workflow creator. This creator only disecting input video file into clip chunks and then converts each to defined format defined Settings for each subset preset. Args: TrayPublishCreator (Creator): Tray publisher plugin class """ label = "Editorial Simple" family = "editorial" identifier = "editorial_simple" default_variants = [ "main" ] description = "Editorial files to generate shots." detailed_description = """ Supporting publishing new shots to project or updating already created. Publishing will create OTIO file. """ icon = "fa.file" def __init__( self, project_settings, *args, **kwargs ): super(EditorialSimpleCreator, self).__init__( project_settings, *args, **kwargs ) editorial_creators = deepcopy( project_settings["traypublisher"]["editorial_creators"] ) # get this creator settings by identifier self._creator_settings = editorial_creators.get(self.identifier) clip_name_tokenizer = self._creator_settings["clip_name_tokenizer"] shot_rename = self._creator_settings["shot_rename"] shot_hierarchy = self._creator_settings["shot_hierarchy"] shot_add_tasks = self._creator_settings["shot_add_tasks"] self._shot_metadata_solver = ShotMetadataSolver( clip_name_tokenizer, shot_rename, shot_hierarchy, shot_add_tasks, self.log ) # try to set main attributes from settings if self._creator_settings.get("default_variants"): self.default_variants = self._creator_settings["default_variants"] def create(self, subset_name, instance_data, pre_create_data): allowed_family_presets = self._get_allowed_family_presets( pre_create_data) clip_instance_properties = { k: v for k, v in pre_create_data.items() if k != "sequence_filepath_data" if k not in [ i["family"] for i in self._creator_settings["family_presets"] ] } if AYON_SERVER_ENABLED: asset_name = instance_data["folderPath"] else: asset_name = instance_data["asset"] asset_doc = get_asset_by_name(self.project_name, asset_name) if pre_create_data["fps"] == "from_selection": # get asset doc data attributes fps = asset_doc["data"]["fps"] else: fps = float(pre_create_data["fps"]) instance_data.update({ "fps": fps }) # get path of sequence sequence_path_data = pre_create_data["sequence_filepath_data"] media_path_data = pre_create_data["media_filepaths_data"] sequence_paths = self._get_path_from_file_data( sequence_path_data, multi=True) media_path = self._get_path_from_file_data(media_path_data) first_otio_timeline = None for seq_path in sequence_paths: # get otio timeline otio_timeline = self._create_otio_timeline( seq_path, fps) # Create all clip instances clip_instance_properties.update({ "fps": fps, "parent_asset_name": asset_name, "variant": instance_data["variant"] }) # create clip instances self._get_clip_instances( otio_timeline, media_path, clip_instance_properties, allowed_family_presets, os.path.basename(seq_path), first_otio_timeline ) if not first_otio_timeline: # assign otio timeline for multi file to layer first_otio_timeline = otio_timeline # create otio editorial instance self._create_otio_instance( subset_name, instance_data, seq_path, media_path, first_otio_timeline ) def _create_otio_instance( self, subset_name, data, sequence_path, media_path, otio_timeline ): """Otio instance creating function Args: subset_name (str): name of subset data (dict): instance data sequence_path (str): path to sequence file media_path (str): path to media file otio_timeline (otio.Timeline): otio timeline object """ # Pass precreate data to creator attributes data.update({ "sequenceFilePath": sequence_path, "editorialSourcePath": media_path, "otioTimeline": otio.adapters.write_to_string(otio_timeline) }) new_instance = CreatedInstance( self.family, subset_name, data, self ) self._store_new_instance(new_instance) def _create_otio_timeline(self, sequence_path, fps): """Creating otio timeline from sequence path Args: sequence_path (str): path to sequence file fps (float): frame per second Returns: otio.Timeline: otio timeline object """ # get editorial sequence file into otio timeline object extension = os.path.splitext(sequence_path)[1] kwargs = {} if extension == ".edl": # EDL has no frame rate embedded so needs explicit # frame rate else 24 is assumed. kwargs["rate"] = fps kwargs["ignore_timecode_mismatch"] = True return otio.adapters.read_from_file(sequence_path, **kwargs) def _get_path_from_file_data(self, file_path_data, multi=False): """Converting creator path data to single path string Args: file_path_data (FileDefItem): creator path data inputs multi (bool): switch to multiple files mode Raises: FileExistsError: in case nothing had been set Returns: str: path string """ return_path_list = [] if isinstance(file_path_data, list): return_path_list = [ os.path.join(f["directory"], f["filenames"][0]) for f in file_path_data ] if not return_path_list: raise FileExistsError( f"File path was not added: {file_path_data}") return return_path_list if multi else return_path_list[0] def _get_clip_instances( self, otio_timeline, media_path, instance_data, family_presets, sequence_file_name, first_otio_timeline=None ): """Helping function for creating clip instance Args: otio_timeline (otio.Timeline): otio timeline object media_path (str): media file path string instance_data (dict): clip instance data family_presets (list): list of dict settings subset presets """ self.asset_name_check = [] tracks = [ track for track in otio_timeline.each_child( descended_from_type=otio.schema.Track) if track.kind == "Video" ] # media data for audio stream and reference solving media_data = self._get_media_source_metadata(media_path) for track in tracks: # set track name track.name = f"{sequence_file_name} - {otio_timeline.name}" try: track_start_frame = ( abs(track.source_range.start_time.value) ) track_start_frame -= self.timeline_frame_start except AttributeError: track_start_frame = 0 for otio_clip in track.each_child(): if not self._validate_clip_for_processing(otio_clip): continue # get available frames info to clip data self._create_otio_reference(otio_clip, media_path, media_data) # convert timeline range to source range self._restore_otio_source_range(otio_clip) base_instance_data = self._get_base_instance_data( otio_clip, instance_data, track_start_frame ) parenting_data = { "instance_label": None, "instance_id": None } for _fpreset in family_presets: # exclude audio family if no audio stream if ( _fpreset["family"] == "audio" and not media_data.get("audio") ): continue instance = self._make_subset_instance( otio_clip, _fpreset, deepcopy(base_instance_data), parenting_data ) # add track to first otioTimeline if it is in input args if first_otio_timeline: first_otio_timeline.tracks.append(deepcopy(track)) def _restore_otio_source_range(self, otio_clip): """Infusing source range. Otio clip is missing proper source clip range so here we add them from from parent timeline frame range. Args: otio_clip (otio.Clip): otio clip object """ otio_clip.source_range = otio_clip.range_in_parent() def _create_otio_reference( self, otio_clip, media_path, media_data ): """Creating otio reference at otio clip. Args: otio_clip (otio.Clip): otio clip object media_path (str): media file path string media_data (dict): media metadata """ start_frame = media_data["start_frame"] frame_duration = media_data["duration"] fps = media_data["fps"] available_range = otio.opentime.TimeRange( start_time=otio.opentime.RationalTime( start_frame, fps), duration=otio.opentime.RationalTime( frame_duration, fps) ) # in case old OTIO or video file create `ExternalReference` media_reference = otio.schema.ExternalReference( target_url=media_path, available_range=available_range ) otio_clip.media_reference = media_reference def _get_media_source_metadata(self, path): """Get all available metadata from file Args: path (str): media file path string Raises: AssertionError: ffprobe couldn't read metadata Returns: dict: media file metadata """ return_data = {} try: media_data = get_ffprobe_data( path, self.log ) # get video stream data video_streams = [] audio_streams = [] for stream in media_data["streams"]: codec_type = stream.get("codec_type") if codec_type == "audio": audio_streams.append(stream) elif codec_type == "video": video_streams.append(stream) if not video_streams: raise ValueError( "Could not find video stream in source file." ) video_stream = video_streams[0] return_data = { "video": True, "start_frame": 0, "duration": int(video_stream["nb_frames"]), "fps": float( convert_ffprobe_fps_value( video_stream["r_frame_rate"] ) ) } # get audio streams data if audio_streams: return_data["audio"] = True except Exception as exc: raise AssertionError(( "FFprobe couldn't read information about input file: " f"\"{path}\". Error message: {exc}" )) return return_data def _make_subset_instance( self, otio_clip, preset, instance_data, parenting_data ): """Making subset instance from input preset Args: otio_clip (otio.Clip): otio clip object preset (dict): single family preset instance_data (dict): instance data parenting_data (dict): shot instance parent data Returns: CreatedInstance: creator instance object """ family = preset["family"] label = self._make_subset_naming( preset, instance_data ) instance_data["label"] = label # add file extension filter only if it is not shot family if family == "shot": instance_data["otioClip"] = ( otio.adapters.write_to_string(otio_clip)) c_instance = self.create_context.creators[ "editorial_shot"].create( instance_data) parenting_data.update({ "instance_label": label, "instance_id": c_instance.data["instance_id"] }) else: # add review family if defined instance_data.update({ "outputFileType": preset["output_file_type"], "parent_instance_id": parenting_data["instance_id"], "creator_attributes": { "parent_instance": parenting_data["instance_label"], "add_review_family": preset.get("review") } }) creator_identifier = f"editorial_{family}" editorial_clip_creator = self.create_context.creators[ creator_identifier] c_instance = editorial_clip_creator.create( instance_data) return c_instance def _make_subset_naming( self, preset, instance_data ): """ Subset name maker Args: preset (dict): single preset item instance_data (dict): instance data Returns: str: label string """ if AYON_SERVER_ENABLED: asset_name = instance_data["creator_attributes"]["folderPath"] else: asset_name = instance_data["creator_attributes"]["shotName"] variant_name = instance_data["variant"] family = preset["family"] # get variant name from preset or from inheritance _variant_name = preset.get("variant") or variant_name # subset name subset_name = "{}{}".format( family, _variant_name.capitalize() ) label = "{} {}".format( asset_name, subset_name ) instance_data.update({ "family": family, "label": label, "variant": _variant_name, "subset": subset_name, }) return label def _get_base_instance_data( self, otio_clip, instance_data, track_start_frame, ): """ Factoring basic set of instance data. Args: otio_clip (otio.Clip): otio clip object instance_data (dict): precreate instance data track_start_frame (int): track start frame Returns: dict: instance data """ # get clip instance properties parent_asset_name = instance_data["parent_asset_name"] handle_start = instance_data["handle_start"] handle_end = instance_data["handle_end"] timeline_offset = instance_data["timeline_offset"] workfile_start_frame = instance_data["workfile_start_frame"] fps = instance_data["fps"] variant_name = instance_data["variant"] # basic unique asset name clip_name = os.path.splitext(otio_clip.name)[0] project_doc = get_project(self.project_name) shot_name, shot_metadata = self._shot_metadata_solver.generate_data( clip_name, { "anatomy_data": { "project": { "name": self.project_name, "code": project_doc["data"]["code"] }, "parent": parent_asset_name, "app": self.host_name }, "selected_asset_doc": get_asset_by_name( self.project_name, parent_asset_name), "project_doc": project_doc } ) # It should be validated only in openpype since we are supporting # publishing to AYON with folder path and uniqueness is not an issue if not AYON_SERVER_ENABLED: self._validate_name_uniqueness(shot_name) timing_data = self._get_timing_data( otio_clip, timeline_offset, track_start_frame, workfile_start_frame ) # create creator attributes creator_attributes = { "workfile_start_frame": workfile_start_frame, "fps": fps, "handle_start": int(handle_start), "handle_end": int(handle_end) } # add timing data creator_attributes.update(timing_data) # create base instance data base_instance_data = { "shotName": shot_name, "variant": variant_name, "task": "", "newAssetPublishing": True, "trackStartFrame": track_start_frame, "timelineOffset": timeline_offset, # creator_attributes "creator_attributes": creator_attributes } # update base instance data with context data # and also update creator attributes with context data if AYON_SERVER_ENABLED: # TODO: this is here just to be able to publish # to AYON with folder path creator_attributes["folderPath"] = shot_metadata.pop("folderPath") base_instance_data["folderPath"] = parent_asset_name else: creator_attributes.update({ "shotName": shot_name, "Parent hierarchy path": shot_metadata["hierarchy"] }) base_instance_data["asset"] = parent_asset_name # add creator attributes to shared instance data base_instance_data["creator_attributes"] = creator_attributes # add hierarchy shot metadata base_instance_data.update(shot_metadata) return base_instance_data def _get_timing_data( self, otio_clip, timeline_offset, track_start_frame, workfile_start_frame ): """Returning available timing data Args: otio_clip (otio.Clip): otio clip object timeline_offset (int): offset value track_start_frame (int): starting frame input workfile_start_frame (int): start frame for shot's workfiles Returns: dict: timing metadata """ # frame ranges data clip_in = otio_clip.range_in_parent().start_time.value clip_in += track_start_frame clip_out = otio_clip.range_in_parent().end_time_inclusive().value clip_out += track_start_frame # add offset in case there is any if timeline_offset: clip_in += timeline_offset clip_out += timeline_offset clip_duration = otio_clip.duration().value source_in = otio_clip.trimmed_range().start_time.value source_out = source_in + clip_duration # define starting frame for future shot frame_start = ( clip_in if workfile_start_frame is None else workfile_start_frame ) frame_end = frame_start + (clip_duration - 1) return { "frameStart": int(frame_start), "frameEnd": int(frame_end), "clipIn": int(clip_in), "clipOut": int(clip_out), "clipDuration": int(otio_clip.duration().value), "sourceIn": int(source_in), "sourceOut": int(source_out) } def _get_allowed_family_presets(self, pre_create_data): """ Filter out allowed family presets. Args: pre_create_data (dict): precreate attributes inputs Returns: list: lit of dict with preset items """ return [ {"family": "shot"}, *[ preset for preset in self._creator_settings["family_presets"] if pre_create_data[preset["family"]] ] ] def _validate_clip_for_processing(self, otio_clip): """Validate otio clip attributes Args: otio_clip (otio.Clip): otio clip object Returns: bool: True if all passing conditions """ if otio_clip.name is None: return False if isinstance(otio_clip, otio.schema.Gap): return False # skip all generators like black empty if isinstance( otio_clip.media_reference, otio.schema.GeneratorReference): return False # Transitions are ignored, because Clips have the full frame # range. if isinstance(otio_clip, otio.schema.Transition): return False return True def _validate_name_uniqueness(self, name): """ Validating name uniqueness. In context of other clip names in sequence file. Args: name (str): shot name string """ if name not in self.asset_name_check: self.asset_name_check.append(name) else: self.log.warning( f"Duplicate shot name: {name}! " "Please check names in the input sequence files." ) def get_pre_create_attr_defs(self): """ Creating pre-create attributes at creator plugin. Returns: list: list of attribute object instances """ # Use same attributes as for instance attrobites attr_defs = [ FileDef( "sequence_filepath_data", folders=False, extensions=[ ".edl", ".xml", ".aaf", ".fcpxml" ], allow_sequences=False, single_item=False, label="Sequence file", ), FileDef( "media_filepaths_data", folders=False, extensions=[ ".mov", ".mp4", ".wav" ], allow_sequences=False, single_item=False, label="Media files", ), # TODO: perhaps better would be timecode and fps input NumberDef( "timeline_offset", default=0, label="Timeline offset" ), UISeparatorDef(), UILabelDef("Clip instance attributes"), UISeparatorDef() ] # add variants swithers attr_defs.extend( BoolDef(_var["family"], label=_var["family"]) for _var in self._creator_settings["family_presets"] ) attr_defs.append(UISeparatorDef()) attr_defs.extend(CLIP_ATTR_DEFS) return attr_defs ================================================ FILE: openpype/hosts/traypublisher/plugins/create/create_from_settings.py ================================================ import os from openpype.lib import Logger from openpype.settings import get_project_settings log = Logger.get_logger(__name__) def initialize(): from openpype.hosts.traypublisher.api.plugin import SettingsCreator project_name = os.environ["AVALON_PROJECT"] project_settings = get_project_settings(project_name) simple_creators = project_settings["traypublisher"]["simple_creators"] global_variables = globals() for item in simple_creators: dynamic_plugin = SettingsCreator.from_settings(item) global_variables[dynamic_plugin.__name__] = dynamic_plugin initialize() ================================================ FILE: openpype/hosts/traypublisher/plugins/create/create_movie_batch.py ================================================ import copy import os import re from openpype import AYON_SERVER_ENABLED from openpype.client import get_asset_name_identifier from openpype.lib import ( FileDef, BoolDef, ) from openpype.pipeline import ( CreatedInstance, ) from openpype.pipeline.create import ( get_subset_name, TaskNotSetError, ) from openpype.hosts.traypublisher.api.plugin import TrayPublishCreator from openpype.hosts.traypublisher.batch_parsing import ( get_asset_doc_from_file_name ) class BatchMovieCreator(TrayPublishCreator): """Creates instances from movie file(s). Intended for .mov files, but should work for any video file. Doesn't handle image sequences though. """ identifier = "render_movie_batch" label = "Batch Movies" family = "render" description = "Publish batch of video files" create_allow_context_change = False version_regex = re.compile(r"^(.+)_v([0-9]+)$") # Position batch creator after simple creators order = 110 def apply_settings(self, project_settings): creator_settings = ( project_settings["traypublisher"]["create"]["BatchMovieCreator"] ) self.default_variants = creator_settings["default_variants"] self.default_tasks = creator_settings["default_tasks"] self.extensions = creator_settings["extensions"] def get_icon(self): return "fa.file" def create(self, subset_name, data, pre_create_data): file_paths = pre_create_data.get("filepath") if not file_paths: return for file_info in file_paths: instance_data = copy.deepcopy(data) file_name = file_info["filenames"][0] filepath = os.path.join(file_info["directory"], file_name) instance_data["creator_attributes"] = {"filepath": filepath} asset_doc, version = get_asset_doc_from_file_name( file_name, self.project_name, self.version_regex) subset_name, task_name = self._get_subset_and_task( asset_doc, data["variant"], self.project_name) asset_name = get_asset_name_identifier(asset_doc) instance_data["task"] = task_name if AYON_SERVER_ENABLED: instance_data["folderPath"] = asset_name else: instance_data["asset"] = asset_name # Create new instance new_instance = CreatedInstance(self.family, subset_name, instance_data, self) self._store_new_instance(new_instance) def _get_subset_and_task(self, asset_doc, variant, project_name): """Create subset name according to standard template process""" task_name = self._get_task_name(asset_doc) try: subset_name = get_subset_name( self.family, variant, task_name, asset_doc, project_name ) except TaskNotSetError: # Create instance with fake task # - instance will be marked as invalid so it can't be published # but user have ability to change it # NOTE: This expect that there is not task 'Undefined' on asset task_name = "Undefined" subset_name = get_subset_name( self.family, variant, task_name, asset_doc, project_name ) return subset_name, task_name def _get_task_name(self, asset_doc): """Get applicable task from 'asset_doc' """ available_task_names = {} asset_tasks = asset_doc.get("data", {}).get("tasks") or {} for task_name in asset_tasks.keys(): available_task_names[task_name.lower()] = task_name task_name = None for _task_name in self.default_tasks: _task_name_low = _task_name.lower() if _task_name_low in available_task_names: task_name = available_task_names[_task_name_low] break return task_name def get_instance_attr_defs(self): return [ BoolDef( "add_review_family", default=True, label="Review" ) ] def get_pre_create_attr_defs(self): # Use same attributes as for instance attributes return [ FileDef( "filepath", folders=False, single_item=False, extensions=self.extensions, allow_sequences=False, label="Filepath" ), BoolDef( "add_review_family", default=True, label="Review" ) ] def get_detail_description(self): return """# Publish batch of .mov to multiple assets. File names must then contain only asset name, or asset name + version. (eg. 'chair.mov', 'chair_v001.mov', not really safe `my_chair_v001.mov` """ ================================================ FILE: openpype/hosts/traypublisher/plugins/create/create_online.py ================================================ # -*- coding: utf-8 -*- """Creator of online files. Online file retain their original name and use it as subset name. To avoid conflicts, this creator checks if subset with this name already exists under selected asset. """ from pathlib import Path # from openpype.client import get_subset_by_name, get_asset_by_name from openpype.lib.attribute_definitions import FileDef, BoolDef from openpype.pipeline import ( CreatedInstance, CreatorError ) from openpype.hosts.traypublisher.api.plugin import TrayPublishCreator class OnlineCreator(TrayPublishCreator): """Creates instance from file and retains its original name.""" identifier = "io.openpype.creators.traypublisher.online" label = "Online" family = "online" description = "Publish file retaining its original file name" extensions = [".mov", ".mp4", ".mxf", ".m4v", ".mpg", ".exr", ".dpx", ".tif", ".png", ".jpg"] def get_detail_description(self): return """# Create file retaining its original file name. This will publish files using template helping to retain original file name and that file name is used as subset name. Bz default it tries to guard against multiple publishes of the same file.""" def get_icon(self): return "fa.file" def create(self, subset_name, instance_data, pre_create_data): repr_file = pre_create_data.get("representation_file") if not repr_file: raise CreatorError("No files specified") files = repr_file.get("filenames") if not files: # this should never happen raise CreatorError("Missing files from representation") origin_basename = Path(files[0]).stem # disable check for existing subset with the same name """ asset = get_asset_by_name( self.project_name, instance_data["asset"], fields=["_id"]) if get_subset_by_name( self.project_name, origin_basename, asset["_id"], fields=["_id"]): raise CreatorError(f"subset with {origin_basename} already " "exists in selected asset") """ instance_data["originalBasename"] = origin_basename subset_name = origin_basename instance_data["creator_attributes"] = { "path": (Path(repr_file["directory"]) / files[0]).as_posix() } # Create new instance new_instance = CreatedInstance(self.family, subset_name, instance_data, self) self._store_new_instance(new_instance) def get_instance_attr_defs(self): return [ BoolDef( "add_review_family", default=True, label="Review" ) ] def get_pre_create_attr_defs(self): return [ FileDef( "representation_file", folders=False, extensions=self.extensions, allow_sequences=True, single_item=True, label="Representation", ), BoolDef( "add_review_family", default=True, label="Review" ) ] def get_subset_name( self, variant, task_name, asset_doc, project_name, host_name=None, instance=None ): if instance is None: return "{originalBasename}" return instance.data["subset"] ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_app_name.py ================================================ import pyblish.api class CollectTrayPublisherAppName(pyblish.api.ContextPlugin): """Collect app name and label.""" label = "Collect App Name/Label" order = pyblish.api.CollectorOrder - 0.5 hosts = ["traypublisher"] def process(self, context): context.data["appName"] = "tray publisher" context.data["appLabel"] = "Tray publisher" ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_clip_instances.py ================================================ from pprint import pformat import pyblish.api class CollectClipInstance(pyblish.api.InstancePlugin): """Collect clip instances and resolve its parent""" label = "Collect Clip Instances" order = pyblish.api.CollectorOrder - 0.081 hosts = ["traypublisher"] families = ["plate", "review", "audio"] def process(self, instance): creator_identifier = instance.data["creator_identifier"] if creator_identifier not in [ "editorial_plate", "editorial_audio", "editorial_review" ]: return instance.data["families"].append("clip") parent_instance_id = instance.data["parent_instance_id"] edit_shared_data = instance.context.data["editorialSharedData"] instance.data.update( edit_shared_data[parent_instance_id] ) if "editorialSourcePath" in instance.context.data.keys(): instance.data["editorialSourcePath"] = ( instance.context.data["editorialSourcePath"]) instance.data["families"].append("trimming") self.log.debug(pformat(instance.data)) ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_colorspace_look.py ================================================ import os from pprint import pformat import pyblish.api from openpype.pipeline import publish from openpype.pipeline import colorspace class CollectColorspaceLook(pyblish.api.InstancePlugin, publish.OpenPypePyblishPluginMixin): """Collect OCIO colorspace look from LUT file """ label = "Collect Colorspace Look" order = pyblish.api.CollectorOrder hosts = ["traypublisher"] families = ["ociolook"] def process(self, instance): creator_attrs = instance.data["creator_attributes"] lut_repre_name = "LUTfile" file_url = creator_attrs["abs_lut_path"] file_name = os.path.basename(file_url) base_name, ext = os.path.splitext(file_name) # set output name with base_name which was cleared # of all symbols and all parts were capitalized output_name = (base_name.replace("_", " ") .replace(".", " ") .replace("-", " ") .title() .replace(" ", "")) # get config items config_items = instance.data["transientData"]["config_items"] config_data = instance.data["transientData"]["config_data"] # get colorspace items converted_color_data = {} for colorspace_key in [ "working_colorspace", "input_colorspace", "output_colorspace" ]: if creator_attrs[colorspace_key]: color_data = colorspace.convert_colorspace_enumerator_item( creator_attrs[colorspace_key], config_items) converted_color_data[colorspace_key] = color_data else: converted_color_data[colorspace_key] = None # add colorspace to config data if converted_color_data["working_colorspace"]: config_data["colorspace"] = ( converted_color_data["working_colorspace"]["name"] ) # create lut representation data lut_repre = { "name": lut_repre_name, "output": output_name, "ext": ext.lstrip("."), "files": file_name, "stagingDir": os.path.dirname(file_url), "tags": [] } instance.data.update({ "representations": [lut_repre], "source": file_url, "ocioLookWorkingSpace": converted_color_data["working_colorspace"], "ocioLookItems": [ { "name": lut_repre_name, "ext": ext.lstrip("."), "input_colorspace": converted_color_data[ "input_colorspace"], "output_colorspace": converted_color_data[ "output_colorspace"], "direction": creator_attrs["direction"], "interpolation": creator_attrs["interpolation"], "config_data": config_data } ], }) self.log.debug(pformat(instance.data)) ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_editorial_instances.py ================================================ import os from pprint import pformat import pyblish.api import opentimelineio as otio class CollectEditorialInstance(pyblish.api.InstancePlugin): """Collect data for instances created by settings creators.""" label = "Collect Editorial Instances" order = pyblish.api.CollectorOrder - 0.1 hosts = ["traypublisher"] families = ["editorial"] def process(self, instance): if "families" not in instance.data: instance.data["families"] = [] if "representations" not in instance.data: instance.data["representations"] = [] fpath = instance.data["sequenceFilePath"] otio_timeline_string = instance.data.pop("otioTimeline") otio_timeline = otio.adapters.read_from_string( otio_timeline_string) instance.context.data["otioTimeline"] = otio_timeline instance.context.data["editorialSourcePath"] = ( instance.data["editorialSourcePath"]) self.log.info(fpath) instance.data["stagingDir"] = os.path.dirname(fpath) _, ext = os.path.splitext(fpath) instance.data["representations"].append({ "ext": ext[1:], "name": ext[1:], "stagingDir": instance.data["stagingDir"], "files": os.path.basename(fpath) }) self.log.debug("Created Editorial Instance {}".format( pformat(instance.data) )) ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_editorial_reviewable.py ================================================ import pyblish.api class CollectEditorialReviewable(pyblish.api.InstancePlugin): """ Collect review input from user. Adds the input to instance data. """ label = "Collect Editorial Reviewable" order = pyblish.api.CollectorOrder families = ["plate", "review", "audio"] hosts = ["traypublisher"] def process(self, instance): creator_identifier = instance.data["creator_identifier"] if creator_identifier not in [ "editorial_plate", "editorial_audio", "editorial_review" ]: return creator_attributes = instance.data["creator_attributes"] if creator_attributes["add_review_family"]: instance.data["families"].append("review") self.log.debug("instance.data {}".format(instance.data)) ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_explicit_colorspace.py ================================================ import pyblish.api from openpype.pipeline import ( publish, registered_host ) from openpype.lib import EnumDef from openpype.pipeline import colorspace from openpype.pipeline.publish import KnownPublishError class CollectColorspace(pyblish.api.InstancePlugin, publish.OpenPypePyblishPluginMixin, publish.ColormanagedPyblishPluginMixin): """Collect explicit user defined representation colorspaces""" label = "Choose representation colorspace" order = pyblish.api.CollectorOrder + 0.49 hosts = ["traypublisher"] families = ["render", "plate", "reference", "image", "online"] enabled = False colorspace_items = [ (None, "Don't override") ] colorspace_attr_show = False config_items = None def process(self, instance): values = self.get_attr_values_from_data(instance.data) colorspace_value = values.get("colorspace", None) if colorspace_value is None: return color_data = colorspace.convert_colorspace_enumerator_item( colorspace_value, self.config_items) colorspace_name = self._colorspace_name_by_type(color_data) self.log.debug("Explicit colorspace name: {}".format(colorspace_name)) context = instance.context for repre in instance.data.get("representations", {}): self.set_representation_colorspace( representation=repre, context=context, colorspace=colorspace_name ) def _colorspace_name_by_type(self, colorspace_data): """ Returns colorspace name by type Arguments: colorspace_data (dict): colorspace data Returns: str: colorspace name """ if colorspace_data["type"] == "colorspaces": return colorspace_data["name"] elif colorspace_data["type"] == "roles": return colorspace_data["colorspace"] else: raise KnownPublishError( ( "Collecting of colorspace failed. used config is missing " "colorspace type: '{}' . Please contact your pipeline TD." ).format(colorspace_data['type']) ) @classmethod def apply_settings(cls, project_settings): host = registered_host() host_name = host.name project_name = host.get_current_project_name() config_data = colorspace.get_imageio_config( project_name, host_name, project_settings=project_settings ) if config_data: filepath = config_data["path"] config_items = colorspace.get_ocio_config_colorspaces(filepath) labeled_colorspaces = colorspace.get_colorspaces_enumerator_items( config_items, include_aliases=True, include_roles=True ) cls.config_items = config_items cls.colorspace_items.extend(labeled_colorspaces) cls.enabled = True @classmethod def get_attribute_defs(cls): return [ EnumDef( "colorspace", cls.colorspace_items, default="Don't override", label="Override Colorspace" ) ] ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_frame_data_from_asset_entity.py ================================================ import pyblish.api class CollectFrameDataFromAssetEntity(pyblish.api.InstancePlugin): """Collect Frame Data From AssetEntity found in context Frame range data will only be collected if the keys are not yet collected for the instance. """ order = pyblish.api.CollectorOrder + 0.491 label = "Collect Missing Frame Data From Asset" families = ["plate", "pointcache", "vdbcache", "online", "render"] hosts = ["traypublisher"] def process(self, instance): missing_keys = [] for key in ( "fps", "frameStart", "frameEnd", "handleStart", "handleEnd" ): if key not in instance.data: missing_keys.append(key) keys_set = [] for key in missing_keys: asset_data = instance.data["assetEntity"]["data"] if key in asset_data: instance.data[key] = asset_data[key] keys_set.append(key) if keys_set: self.log.debug(f"Frame range data {keys_set} " "has been collected from asset entity.") ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_movie_batch.py ================================================ import os import pyblish.api from openpype.pipeline import OpenPypePyblishPluginMixin class CollectMovieBatch( pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin ): """Collect file url for batch movies and create representation. Adds review on instance and to repre.tags based on value of toggle button on creator. """ label = "Collect Movie Batch Files" order = pyblish.api.CollectorOrder hosts = ["traypublisher"] def process(self, instance): if instance.data.get("creator_identifier") != "render_movie_batch": return creator_attributes = instance.data["creator_attributes"] file_url = creator_attributes["filepath"] file_name = os.path.basename(file_url) _, ext = os.path.splitext(file_name) repre = { "name": ext[1:], "ext": ext[1:], "files": file_name, "stagingDir": os.path.dirname(file_url), "tags": [] } instance.data["representations"].append(repre) if creator_attributes["add_review_family"]: repre["tags"].append("review") instance.data["families"].append("review") if not instance.data.get("thumbnailSource"): instance.data["thumbnailSource"] = file_url instance.data["source"] = file_url self.log.debug("instance.data {}".format(instance.data)) ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_online_file.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from pathlib import Path class CollectOnlineFile(pyblish.api.InstancePlugin): """Collect online file and retain its file name.""" label = "Collect Online File" order = pyblish.api.CollectorOrder families = ["online"] hosts = ["traypublisher"] def process(self, instance): file = Path(instance.data["creator_attributes"]["path"]) review = instance.data["creator_attributes"]["add_review_family"] instance.data["review"] = review if "review" not in instance.data["families"]: instance.data["families"].append("review") self.log.info(f"Adding review: {review}") instance.data["representations"].append( { "name": file.suffix.lstrip("."), "ext": file.suffix.lstrip("."), "files": file.name, "stagingDir": file.parent.as_posix(), "tags": ["review"] if review else [] } ) ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_review_frames.py ================================================ # -*- coding: utf-8 -*- import pyblish.api class CollectReviewInfo(pyblish.api.InstancePlugin): """Collect data required for review instances. ExtractReview plugin requires frame start/end, fps on instance data which are missing on instances from TrayPublishes. Warning: This is temporary solution to "make it work". Contains removed changes from https://github.com/ynput/OpenPype/pull/4383 reduced only for review instances. """ label = "Collect Review Info" order = pyblish.api.CollectorOrder + 0.491 families = ["review"] hosts = ["traypublisher"] def process(self, instance): asset_entity = instance.data.get("assetEntity") if instance.data.get("frameStart") is not None or not asset_entity: self.log.debug("Missing required data on instance") return asset_data = asset_entity["data"] # Store collected data for logging collected_data = {} for key in ( "fps", "frameStart", "frameEnd", "handleStart", "handleEnd", ): if key in instance.data or key not in asset_data: continue value = asset_data[key] collected_data[key] = value instance.data[key] = value self.log.debug("Collected data: {}".format(str(collected_data))) ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_sequence_frame_data.py ================================================ import pyblish.api import clique from openpype.pipeline import OptionalPyblishPluginMixin class CollectSequenceFrameData( pyblish.api.InstancePlugin, OptionalPyblishPluginMixin ): """Collect Original Sequence Frame Data If the representation includes files with frame numbers, then set `frameStart` and `frameEnd` for the instance to the start and end frame respectively """ order = pyblish.api.CollectorOrder + 0.4905 label = "Collect Original Sequence Frame Data" families = ["plate", "pointcache", "vdbcache", "online", "render"] hosts = ["traypublisher"] optional = True def process(self, instance): if not self.is_active(instance.data): return # editorial would fail since they might not be in database yet new_asset_publishing = instance.data.get("newAssetPublishing") if new_asset_publishing: self.log.debug("Instance is creating new asset. Skipping.") return frame_data = self.get_frame_data_from_repre_sequence(instance) if not frame_data: # if no dict data skip collecting the frame range data return for key, value in frame_data.items(): instance.data[key] = value self.log.debug(f"Collected Frame range data '{key}':{value} ") def get_frame_data_from_repre_sequence(self, instance): repres = instance.data.get("representations") asset_data = instance.data["assetEntity"]["data"] if repres: first_repre = repres[0] if "ext" not in first_repre: self.log.warning("Cannot find file extension" " in representation data") return files = first_repre["files"] collections, _ = clique.assemble(files) if not collections: # No sequences detected and we can't retrieve # frame range self.log.debug( "No sequences detected in the representation data." " Skipping collecting frame range data.") return collection = collections[0] repres_frames = list(collection.indexes) return { "frameStart": repres_frames[0], "frameEnd": repres_frames[-1], "handleStart": 0, "handleEnd": 0, "fps": asset_data["fps"] } ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_shot_instances.py ================================================ from pprint import pformat import pyblish.api import opentimelineio as otio from openpype import AYON_SERVER_ENABLED class CollectShotInstance(pyblish.api.InstancePlugin): """ Collect shot instances Resolving its user inputs from creator attributes to instance data. """ label = "Collect Shot Instances" order = pyblish.api.CollectorOrder - 0.09 hosts = ["traypublisher"] families = ["shot"] SHARED_KEYS = [ "asset", "fps", "handleStart", "handleEnd", "frameStart", "frameEnd", "clipIn", "clipOut", "clipDuration", "sourceIn", "sourceOut", "otioClip", "workfileFrameStart" ] def process(self, instance): creator_identifier = instance.data["creator_identifier"] if "editorial" not in creator_identifier: return # get otio clip object otio_clip = self._get_otio_clip(instance) instance.data["otioClip"] = otio_clip # first solve the inputs from creator attr data = self._solve_inputs_to_data(instance) instance.data.update(data) # distribute all shared keys to clips instances self._distribute_shared_data(instance) self._solve_hierarchy_context(instance) self.log.debug(pformat(instance.data)) def _get_otio_clip(self, instance): """ Converts otio string data. Convert them to proper otio object and finds its equivalent at otio timeline. This process is a hack to support also resolving parent range. Args: instance (obj): publishing instance Returns: otio.Clip: otio clip object """ context = instance.context # convert otio clip from string to object otio_clip_string = instance.data.pop("otioClip") otio_clip = otio.adapters.read_from_string( otio_clip_string) otio_timeline = context.data["otioTimeline"] clips = [ clip for clip in otio_timeline.each_child( descended_from_type=otio.schema.Clip) if clip.name == otio_clip.name if clip.parent().kind == "Video" ] otio_clip = clips.pop() return otio_clip def _distribute_shared_data(self, instance): """ Distribute all defined keys. All data are shared between all related instances in context. Args: instance (obj): publishing instance """ context = instance.context instance_id = instance.data["instance_id"] if not context.data.get("editorialSharedData"): context.data["editorialSharedData"] = {} context.data["editorialSharedData"][instance_id] = { _k: _v for _k, _v in instance.data.items() if _k in self.SHARED_KEYS } def _solve_inputs_to_data(self, instance): """ Resolve all user inputs into instance data. Args: instance (obj): publishing instance Returns: dict: instance data updating data """ _cr_attrs = instance.data["creator_attributes"] workfile_start_frame = _cr_attrs["workfile_start_frame"] frame_start = _cr_attrs["frameStart"] frame_end = _cr_attrs["frameEnd"] frame_dur = frame_end - frame_start data = { "fps": float(_cr_attrs["fps"]), "handleStart": _cr_attrs["handle_start"], "handleEnd": _cr_attrs["handle_end"], "frameStart": workfile_start_frame, "frameEnd": workfile_start_frame + frame_dur, "clipIn": _cr_attrs["clipIn"], "clipOut": _cr_attrs["clipOut"], "clipDuration": _cr_attrs["clipDuration"], "sourceIn": _cr_attrs["sourceIn"], "sourceOut": _cr_attrs["sourceOut"], "workfileFrameStart": workfile_start_frame } if AYON_SERVER_ENABLED: data["asset"] = _cr_attrs["folderPath"] else: data["asset"] = _cr_attrs["shotName"] return data def _solve_hierarchy_context(self, instance): """ Adding hierarchy data to context shared data. Args: instance (obj): publishing instance """ context = instance.context final_context = ( context.data["hierarchyContext"] if context.data.get("hierarchyContext") else {} ) # get handles handle_start = int(instance.data["handleStart"]) handle_end = int(instance.data["handleEnd"]) in_info = { "entity_type": "Shot", "custom_attributes": { "handleStart": handle_start, "handleEnd": handle_end, "frameStart": instance.data["frameStart"], "frameEnd": instance.data["frameEnd"], "clipIn": instance.data["clipIn"], "clipOut": instance.data["clipOut"], "fps": instance.data["fps"] }, "tasks": instance.data["tasks"] } parents = instance.data.get('parents', []) # Split by '/' for AYON where asset is a path asset_name = instance.data["asset"].split("/")[-1] actual = {asset_name: in_info} for parent in reversed(parents): parent_name = parent["entity_name"] next_dict = { parent_name: { "entity_type": parent["entity_type"], "childs": actual } } actual = next_dict final_context = self._update_dict(final_context, actual) # adding hierarchy context to instance context.data["hierarchyContext"] = final_context def _update_dict(self, ex_dict, new_dict): """ Recursion function Updating nested data with another nested data. Args: ex_dict (dict): nested data new_dict (dict): nested data Returns: dict: updated nested data """ for key in ex_dict: if key in new_dict and isinstance(ex_dict[key], dict): new_dict[key] = self._update_dict(ex_dict[key], new_dict[key]) elif not ex_dict.get(key) or not new_dict.get(key): new_dict[key] = ex_dict[key] return new_dict ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py ================================================ import os import tempfile from pathlib import Path import clique import pyblish.api class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin): """Collect data for instances created by settings creators. Plugin create representations for simple instances based on 'representation_files' attribute stored on instance data. There is also possibility to have reviewable representation which can be stored under 'reviewable' attribute stored on instance data. If there was already created representation with the same files as 'reviewable' contains Representations can be marked for review and in that case is also added 'review' family to instance families. For review can be marked only one representation so **first** representation that has extension available in '_review_extensions' is used for review. For instance 'source' is used path from last representation created from 'representation_files'. Set staging directory on instance. That is probably never used because each created representation has it's own staging dir. """ label = "Collect Settings Simple Instances" order = pyblish.api.CollectorOrder - 0.49 hosts = ["traypublisher"] def process(self, instance): if not instance.data.get("settings_creator"): return instance_label = instance.data["name"] # Create instance's staging dir in temp tmp_folder = tempfile.mkdtemp(prefix="traypublisher_") instance.data["stagingDir"] = tmp_folder instance.context.data["cleanupFullPaths"].append(tmp_folder) self.log.debug(( "Created temp staging directory for instance {}. {}" ).format(instance_label, tmp_folder)) self._fill_version(instance, instance_label) # Store filepaths for validation of their existence source_filepaths = [] # Make sure there are no representations with same name repre_names_counter = {} # Store created names for logging repre_names = [] # Store set of filepaths per each representation representation_files_mapping = [] source = self._create_main_representations( instance, source_filepaths, repre_names_counter, repre_names, representation_files_mapping ) self._create_review_representation( instance, source_filepaths, repre_names_counter, repre_names, representation_files_mapping ) source_filepaths = list(set(source_filepaths)) instance.data["source"] = source instance.data["sourceFilepaths"] = source_filepaths # NOTE: Missing filepaths should not cause crashes (at least not here) # - if filepaths are required they should crash on validation if source_filepaths: # NOTE: Original basename is not handling sequences # - we should maybe not fill the key when sequence is used? origin_basename = Path(source_filepaths[0]).stem instance.data["originalBasename"] = origin_basename self.log.debug( ( "Created Simple Settings instance \"{}\"" " with {} representations: {}" ).format( instance_label, len(instance.data["representations"]), ", ".join(repre_names) ) ) def _fill_version(self, instance, instance_label): """Fill instance version under which will be instance integrated. Instance must have set 'use_next_version' to 'False' and 'version_to_use' to version to use. Args: instance (pyblish.api.Instance): Instance to fill version for. instance_label (str): Label of instance to fill version for. """ creator_attributes = instance.data["creator_attributes"] use_next_version = creator_attributes.get("use_next_version", True) # If 'version_to_use' is '0' it means that next version should be used version_to_use = creator_attributes.get("version_to_use", 0) if use_next_version or not version_to_use: return instance.data["version"] = version_to_use self.log.debug( "Version for instance \"{}\" was set to \"{}\"".format( instance_label, version_to_use)) def _create_main_representations( self, instance, source_filepaths, repre_names_counter, repre_names, representation_files_mapping ): creator_attributes = instance.data["creator_attributes"] filepath_items = creator_attributes["representation_files"] if not isinstance(filepath_items, list): filepath_items = [filepath_items] source = None for filepath_item in filepath_items: # Skip if filepath item does not have filenames if not filepath_item["filenames"]: continue filepaths = { os.path.join(filepath_item["directory"], filename) for filename in filepath_item["filenames"] } source_filepaths.extend(filepaths) source = self._calculate_source(filepaths) representation = self._create_representation_data( filepath_item, repre_names_counter, repre_names ) instance.data["representations"].append(representation) representation_files_mapping.append( (filepaths, representation, source) ) return source def _create_review_representation( self, instance, source_filepaths, repre_names_counter, repre_names, representation_files_mapping ): # Skip review representation creation if there are no representations # created for "main" part # - review representation must not be created in that case so # validation can care about it if not representation_files_mapping: self.log.warning(( "There are missing source representations." " Creation of review representation was skipped." )) return creator_attributes = instance.data["creator_attributes"] review_file_item = creator_attributes["reviewable"] filenames = review_file_item.get("filenames") if not filenames: self.log.debug(( "Filepath for review is not defined." " Skipping review representation creation." )) return item_dir = review_file_item["directory"] first_filepath = os.path.join(item_dir, filenames[0]) filepaths = { os.path.join(item_dir, filename) for filename in filenames } source_filepaths.extend(filepaths) # First try to find out representation with same filepaths # so it's not needed to create new representation just for review review_representation = None # Review path (only for logging) review_path = None for item in representation_files_mapping: _filepaths, representation, repre_path = item if _filepaths == filepaths: review_representation = representation review_path = repre_path break if review_representation is None: self.log.debug("Creating new review representation") review_path = self._calculate_source(filepaths) review_representation = self._create_representation_data( review_file_item, repre_names_counter, repre_names ) instance.data["representations"].append(review_representation) if "review" not in instance.data["families"]: instance.data["families"].append("review") if not instance.data.get("thumbnailSource"): instance.data["thumbnailSource"] = first_filepath review_representation["tags"].append("review") # Adding "review" to representation name since it can clash with main # representation if they share the same extension. review_representation["outputName"] = "review" self.log.debug("Representation {} was marked for review. {}".format( review_representation["name"], review_path )) def _create_representation_data( self, filepath_item, repre_names_counter, repre_names ): """Create new representation data based on file item. Args: filepath_item (Dict[str, Any]): Item with information about representation paths. repre_names_counter (Dict[str, int]): Store count of representation names. repre_names (List[str]): All used representation names. For logging purposes. Returns: Dict: Prepared base representation data. """ filenames = filepath_item["filenames"] _, ext = os.path.splitext(filenames[0]) if len(filenames) == 1: filenames = filenames[0] repre_name = repre_ext = ext[1:] if repre_name not in repre_names_counter: repre_names_counter[repre_name] = 2 else: counter = repre_names_counter[repre_name] repre_names_counter[repre_name] += 1 repre_name = "{}_{}".format(repre_name, counter) repre_names.append(repre_name) return { "ext": repre_ext, "name": repre_name, "stagingDir": filepath_item["directory"], "files": filenames, "tags": [] } def _calculate_source(self, filepaths): cols, rems = clique.assemble(filepaths) if cols: source = cols[0].format("{head}{padding}{tail}") elif rems: source = rems[0] return source ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/collect_source.py ================================================ import pyblish.api class CollectSource(pyblish.api.ContextPlugin): """Collecting instances from traypublisher host.""" label = "Collect source" order = pyblish.api.CollectorOrder - 0.49 hosts = ["traypublisher"] def process(self, context): # get json paths from os and load them source_name = "traypublisher" for instance in context: source = instance.data.get("source") if not source: instance.data["source"] = source_name self.log.info(( "Source of instance \"{}\" is changed to \"{}\"" ).format(instance.data["name"], source_name)) else: self.log.info(( "Source of instance \"{}\" was already set to \"{}\"" ).format(instance.data["name"], source)) ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/extract_colorspace_look.py ================================================ import os import json import pyblish.api from openpype.pipeline import publish class ExtractColorspaceLook(publish.Extractor, publish.OpenPypePyblishPluginMixin): """Extract OCIO colorspace look from LUT file """ label = "Extract Colorspace Look" order = pyblish.api.ExtractorOrder hosts = ["traypublisher"] families = ["ociolook"] def process(self, instance): ociolook_items = instance.data["ocioLookItems"] ociolook_working_color = instance.data["ocioLookWorkingSpace"] staging_dir = self.staging_dir(instance) # create ociolook file attributes ociolook_file_name = "ocioLookFile.json" ociolook_file_content = { "version": 1, "data": { "ocioLookItems": ociolook_items, "ocioLookWorkingSpace": ociolook_working_color } } # write ociolook content into json file saved in staging dir file_url = os.path.join(staging_dir, ociolook_file_name) with open(file_url, "w") as f_: json.dump(ociolook_file_content, f_, indent=4) # create lut representation data ociolook_repre = { "name": "ocioLookFile", "ext": "json", "files": ociolook_file_name, "stagingDir": staging_dir, "tags": [] } instance.data["representations"].append(ociolook_repre) ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/help/validate_existing_version.xml ================================================ Version already exists ## Version already exists Version {version} you have set on instance '{subset_name}' under '{asset_name}' already exists. This validation is enabled by default to prevent accidental override of existing versions. ### How to repair? - Click on 'Repair' action -> this will change version to next available. - Disable validation on the instance if you are sure you want to override the version. - Reset publishing and manually change the version number. ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/help/validate_frame_ranges.xml ================================================ Invalid frame range ## Invalid frame range Expected duration or '{duration}' frames set in database, workfile contains only '{found}' frames. ### How to repair? Modify configuration in the database or tweak frame range in the workfile. ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/validate_colorspace.py ================================================ import pyblish.api from openpype.pipeline import ( publish, PublishValidationError ) from openpype.pipeline.colorspace import ( get_ocio_config_colorspaces ) class ValidateColorspace(pyblish.api.InstancePlugin, publish.OpenPypePyblishPluginMixin, publish.ColormanagedPyblishPluginMixin): """Validate representation colorspaces""" label = "Validate representation colorspace" order = pyblish.api.ValidatorOrder hosts = ["traypublisher"] families = ["render", "plate", "reference", "image", "online"] def process(self, instance): config_colorspaces = {} # cache of colorspaces per config path for repre in instance.data.get("representations", {}): colorspace_data = repre.get("colorspaceData", {}) if not colorspace_data: # Nothing to validate continue config_path = colorspace_data["config"]["path"] if config_path not in config_colorspaces: colorspaces = get_ocio_config_colorspaces(config_path) if not colorspaces.get("colorspaces"): message = ( f"OCIO config '{config_path}' does not contain any " "colorspaces. This is an error in the OCIO config. " "Contact your pipeline TD.", ) raise PublishValidationError( title="Colorspace validation", message=message, description=message ) config_colorspaces[config_path] = set( colorspaces["colorspaces"]) colorspace = colorspace_data["colorspace"] self.log.debug( f"Validating representation '{repre['name']}' " f"colorspace '{colorspace}'" ) if colorspace not in config_colorspaces[config_path]: message = ( f"Representation '{repre['name']}' colorspace " f"'{colorspace}' does not exist in OCIO config: " f"{config_path}" ) raise PublishValidationError( title="Representation colorspace", message=message, description=message ) ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/validate_colorspace_look.py ================================================ import pyblish.api from openpype.pipeline import ( publish, PublishValidationError ) class ValidateColorspaceLook(pyblish.api.InstancePlugin, publish.OpenPypePyblishPluginMixin): """Validate colorspace look attributes""" label = "Validate colorspace look attributes" order = pyblish.api.ValidatorOrder hosts = ["traypublisher"] families = ["ociolook"] def process(self, instance): create_context = instance.context.data["create_context"] created_instance = create_context.get_instance_by_id( instance.data["instance_id"]) creator_defs = created_instance.creator_attribute_defs ociolook_working_color = instance.data.get("ocioLookWorkingSpace") ociolook_items = instance.data.get("ocioLookItems", []) creator_defs_by_key = {_def.key: _def.label for _def in creator_defs} not_set_keys = {} if not ociolook_working_color: not_set_keys["working_colorspace"] = creator_defs_by_key[ "working_colorspace"] for ociolook_item in ociolook_items: item_not_set_keys = self.validate_colorspace_set_attrs( ociolook_item, creator_defs_by_key) if item_not_set_keys: not_set_keys[ociolook_item["name"]] = item_not_set_keys if not_set_keys: message = ( "Colorspace look attributes are not set: \n" ) for key, value in not_set_keys.items(): if isinstance(value, list): values_string = "\n\t- ".join(value) message += f"\n\t{key}:\n\t- {values_string}" else: message += f"\n\t{value}" raise PublishValidationError( title="Colorspace Look attributes", message=message, description=message ) def validate_colorspace_set_attrs( self, ociolook_item, creator_defs_by_key ): """Validate colorspace look attributes""" self.log.debug(f"Validate colorspace look attributes: {ociolook_item}") check_keys = [ "input_colorspace", "output_colorspace", "direction", "interpolation" ] not_set_keys = [] for key in check_keys: if ociolook_item[key]: # key is set and it is correct continue def_label = creator_defs_by_key.get(key) if not def_label: # raise since key is not recognized by creator defs raise KeyError( f"Colorspace look attribute '{key}' is not " f"recognized by creator attributes: {creator_defs_by_key}" ) not_set_keys.append(def_label) return not_set_keys ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/validate_existing_version.py ================================================ import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, OptionalPyblishPluginMixin, RepairAction, ) class ValidateExistingVersion( OptionalPyblishPluginMixin, pyblish.api.InstancePlugin ): label = "Validate Existing Version" order = ValidateContentsOrder hosts = ["traypublisher"] actions = [RepairAction] settings_category = "traypublisher" optional = True def process(self, instance): if not self.is_active(instance.data): return version = instance.data.get("version") if version is None: return last_version = instance.data.get("latestVersion") if last_version is None or last_version < version: return subset_name = instance.data["subset"] msg = "Version {} already exists for subset {}.".format( version, subset_name) formatting_data = { "subset_name": subset_name, "asset_name": instance.data["asset"], "version": version } raise PublishXmlValidationError( self, msg, formatting_data=formatting_data) @classmethod def repair(cls, instance): create_context = instance.context.data["create_context"] created_instance = create_context.get_instance_by_id( instance.data["instance_id"]) creator_attributes = created_instance["creator_attributes"] # Disable version override creator_attributes["use_next_version"] = True create_context.save_changes() ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/validate_filepaths.py ================================================ import os import pyblish.api from openpype.pipeline import PublishValidationError class ValidateFilePath(pyblish.api.InstancePlugin): """Validate existence of source filepaths on instance. Plugins looks into key 'sourceFilepaths' and validate if paths there actually exist on disk. Also validate if the key is filled but is empty. In that case also crashes so do not fill the key if unfilled value should not cause error. This is primarily created for Simple Creator instances. """ label = "Validate Filepaths" order = pyblish.api.ValidatorOrder - 0.49 hosts = ["traypublisher"] def process(self, instance): if "sourceFilepaths" not in instance.data: self.log.info(( "Skipped validation of source filepaths existence." " Instance does not have collected 'sourceFilepaths'" )) return family = instance.data["family"] label = instance.data["name"] filepaths = instance.data["sourceFilepaths"] if not filepaths: raise PublishValidationError( ( "Source filepaths of '{}' instance \"{}\" are not filled" ).format(family, label), "File not filled", ( "## Files were not filled" "\nThis mean that you didn't enter any files into required" " file input." "\n- Please refresh publishing and check instance" " {}" ).format(label) ) not_found_files = [ filepath for filepath in filepaths if not os.path.exists(filepath) ] if not_found_files: joined_paths = "\n".join([ "- {}".format(filepath) for filepath in not_found_files ]) raise PublishValidationError( ( "Filepath of '{}' instance \"{}\" does not exist:\n{}" ).format(family, label, joined_paths), "File not found", ( "## Files were not found\nFiles\n{}" "\n\nCheck if the path is still available." ).format(joined_paths) ) ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py ================================================ import re import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishXmlValidationError, OptionalPyblishPluginMixin, ) class ValidateFrameRange(OptionalPyblishPluginMixin, pyblish.api.InstancePlugin): """Validating frame range of rendered files against state in DB.""" label = "Validate Frame Range" hosts = ["traypublisher"] families = ["render", "plate"] order = ValidateContentsOrder optional = True # published data might be sequence (.mov, .mp4) in that counting files # doesnt make sense check_extensions = ["exr", "dpx", "jpg", "jpeg", "png", "tiff", "tga", "gif", "svg"] skip_timelines_check = [] # skip for specific task names (regex) def process(self, instance): # Skip the instance if is not active by data on the instance if not self.is_active(instance.data): return # editorial would fail since they might not be in database yet new_asset_publishing = instance.data.get("newAssetPublishing") if new_asset_publishing: self.log.debug("Instance is creating new asset. Skipping.") return if (self.skip_timelines_check and any(re.search(pattern, instance.data["task"]) for pattern in self.skip_timelines_check)): self.log.info("Skipping for {} task".format(instance.data["task"])) asset_doc = instance.data["assetEntity"] asset_data = asset_doc["data"] frame_start = asset_data["frameStart"] frame_end = asset_data["frameEnd"] handle_start = asset_data["handleStart"] handle_end = asset_data["handleEnd"] duration = (frame_end - frame_start + 1) + handle_start + handle_end repres = instance.data.get("representations") if not repres: self.log.info("No representations, skipping.") return first_repre = repres[0] ext = first_repre['ext'].replace(".", '') if not ext or ext.lower() not in self.check_extensions: self.log.warning("Cannot check for extension {}".format(ext)) return files = first_repre["files"] if isinstance(files, str): files = [files] frames = len(files) msg = ( "Frame duration from DB:'{}' doesn't match number of files:'{}'" " Please change frame range for Asset or limit no. of files" ). format(int(duration), frames) formatting_data = {"duration": duration, "found": frames} if frames != duration: raise PublishXmlValidationError(self, msg, formatting_data=formatting_data) self.log.debug("Valid ranges expected '{}' - found '{}'". format(int(duration), frames)) ================================================ FILE: openpype/hosts/traypublisher/plugins/publish/validate_online_file.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError, OptionalPyblishPluginMixin, ) from openpype.client import get_subset_by_name class ValidateOnlineFile(OptionalPyblishPluginMixin, pyblish.api.InstancePlugin): """Validate that subset doesn't exist yet.""" label = "Validate Existing Online Files" hosts = ["traypublisher"] families = ["online"] order = ValidateContentsOrder optional = True def process(self, instance): if not self.is_active(instance.data): return project_name = instance.context.data["projectName"] asset_id = instance.data["assetEntity"]["_id"] subset = get_subset_by_name( project_name, instance.data["subset"], asset_id) if subset: raise PublishValidationError( "Subset to be published already exists.", title=self.label ) ================================================ FILE: openpype/hosts/tvpaint/__init__.py ================================================ from .addon import ( get_launch_script_path, TVPaintAddon, TVPAINT_ROOT_DIR, ) __all__ = ( "get_launch_script_path", "TVPaintAddon", "TVPAINT_ROOT_DIR", ) ================================================ FILE: openpype/hosts/tvpaint/addon.py ================================================ import os from openpype.modules import OpenPypeModule, IHostAddon TVPAINT_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) def get_launch_script_path(): return os.path.join( TVPAINT_ROOT_DIR, "api", "launch_script.py" ) class TVPaintAddon(OpenPypeModule, IHostAddon): name = "tvpaint" host_name = "tvpaint" def initialize(self, module_settings): self.enabled = True def add_implementation_envs(self, env, _app): """Modify environments to contain all required for implementation.""" defaults = { "OPENPYPE_LOG_NO_COLORS": "True" } for key, value in defaults.items(): if not env.get(key): env[key] = value def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] return [ os.path.join(TVPAINT_ROOT_DIR, "hooks") ] def get_workfile_extensions(self): return [".tvpp"] ================================================ FILE: openpype/hosts/tvpaint/api/__init__.py ================================================ from .communication_server import CommunicationWrapper from .pipeline import ( TVPaintHost, ) __all__ = ( "CommunicationWrapper", "TVPaintHost", ) ================================================ FILE: openpype/hosts/tvpaint/api/communication_server.py ================================================ import os import json import time import subprocess import collections import asyncio import logging import socket import platform import filecmp import tempfile import threading import shutil from contextlib import closing from aiohttp import web from aiohttp_json_rpc import JsonRpc from aiohttp_json_rpc.protocol import ( encode_request, encode_error, decode_msg, JsonRpcMsgTyp ) from aiohttp_json_rpc.exceptions import RpcError from openpype import AYON_SERVER_ENABLED from openpype.lib import emit_event from openpype.hosts.tvpaint.tvpaint_plugin import get_plugin_files_path log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) class CommunicationWrapper: # TODO add logs and exceptions communicator = None log = logging.getLogger("CommunicationWrapper") @classmethod def create_qt_communicator(cls, *args, **kwargs): """Create communicator for Artist usage.""" communicator = QtCommunicator(*args, **kwargs) cls.set_communicator(communicator) return communicator @classmethod def set_communicator(cls, communicator): if not cls.communicator: cls.communicator = communicator else: cls.log.warning("Communicator was set multiple times.") @classmethod def client(cls): if not cls.communicator: return None return cls.communicator.client() @classmethod def execute_george(cls, george_script): """Execute passed goerge script in TVPaint.""" if not cls.communicator: return return cls.communicator.execute_george(george_script) class WebSocketServer: def __init__(self): self.client = None self.loop = asyncio.new_event_loop() self.app = web.Application(loop=self.loop) self.port = self.find_free_port() self.websocket_thread = WebsocketServerThread( self, self.port, loop=self.loop ) @property def server_is_running(self): return self.websocket_thread.server_is_running def add_route(self, *args, **kwargs): self.app.router.add_route(*args, **kwargs) @staticmethod def find_free_port(): with closing( socket.socket(socket.AF_INET, socket.SOCK_STREAM) ) as sock: sock.bind(("", 0)) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) port = sock.getsockname()[1] return port def start(self): self.websocket_thread.start() def stop(self): try: if self.websocket_thread.is_running: log.debug("Stopping websocket server") self.websocket_thread.is_running = False self.websocket_thread.stop() except Exception: log.warning( "Error has happened during Killing websocket server", exc_info=True ) class WebsocketServerThread(threading.Thread): """ Listener for websocket rpc requests. It would be probably better to "attach" this to main thread (as for example Harmony needs to run something on main thread), but currently it creates separate thread and separate asyncio event loop """ def __init__(self, module, port, loop): super(WebsocketServerThread, self).__init__() self.is_running = False self.server_is_running = False self.port = port self.module = module self.loop = loop self.runner = None self.site = None self.tasks = [] def run(self): self.is_running = True try: log.debug("Starting websocket server") self.loop.run_until_complete(self.start_server()) log.info( "Running Websocket server on URL:" " \"ws://localhost:{}\"".format(self.port) ) asyncio.ensure_future(self.check_shutdown(), loop=self.loop) self.server_is_running = True self.loop.run_forever() except Exception: log.warning( "Websocket Server service has failed", exc_info=True ) finally: self.server_is_running = False # optional self.loop.close() self.is_running = False log.info("Websocket server stopped") async def start_server(self): """ Starts runner and TCPsite """ self.runner = web.AppRunner(self.module.app) await self.runner.setup() self.site = web.TCPSite(self.runner, "localhost", self.port) await self.site.start() def stop(self): """Sets is_running flag to false, 'check_shutdown' shuts server down""" self.is_running = False async def check_shutdown(self): """ Future that is running and checks if server should be running periodically. """ while self.is_running: while self.tasks: task = self.tasks.pop(0) log.debug("waiting for task {}".format(task)) await task log.debug("returned value {}".format(task.result)) await asyncio.sleep(0.5) log.debug("## Server shutdown started") await self.site.stop() log.debug("# Site stopped") await self.runner.cleanup() log.debug("# Server runner stopped") tasks = [ task for task in asyncio.all_tasks() if task is not asyncio.current_task() ] list(map(lambda task: task.cancel(), tasks)) # cancel all the tasks results = await asyncio.gather(*tasks, return_exceptions=True) log.debug(f"Finished awaiting cancelled tasks, results: {results}...") await self.loop.shutdown_asyncgens() # to really make sure everything else has time to stop await asyncio.sleep(0.07) self.loop.stop() class BaseTVPaintRpc(JsonRpc): def __init__(self, communication_obj, route_name="", **kwargs): super().__init__(**kwargs) self.requests_ids = collections.defaultdict(lambda: 0) self.waiting_requests = collections.defaultdict(list) self.responses = collections.defaultdict(list) self.route_name = route_name self.communication_obj = communication_obj async def _handle_rpc_msg(self, http_request, raw_msg): # This is duplicated code from super but there is no way how to do it # to be able handle server->client requests host = http_request.host if host in self.waiting_requests: try: _raw_message = raw_msg.data msg = decode_msg(_raw_message) except RpcError as error: await self._ws_send_str(http_request, encode_error(error)) return if msg.type in (JsonRpcMsgTyp.RESULT, JsonRpcMsgTyp.ERROR): msg_data = json.loads(_raw_message) if msg_data.get("id") in self.waiting_requests[host]: self.responses[host].append(msg_data) return return await super()._handle_rpc_msg(http_request, raw_msg) def client_connected(self): # TODO This is poor check. Add check it is client from TVPaint if self.clients: return True return False def send_notification(self, client, method, params=None): if params is None: params = [] asyncio.run_coroutine_threadsafe( client.ws.send_str(encode_request(method, params=params)), loop=self.loop ) def send_request(self, client, method, params=None, timeout=0): if params is None: params = [] client_host = client.host request_id = self.requests_ids[client_host] self.requests_ids[client_host] += 1 self.waiting_requests[client_host].append(request_id) log.debug("Sending request to client {} ({}, {}) id: {}".format( client_host, method, params, request_id )) future = asyncio.run_coroutine_threadsafe( client.ws.send_str(encode_request(method, request_id, params)), loop=self.loop ) result = future.result() not_found = object() response = not_found start = time.time() while True: if client.ws.closed: return None for _response in self.responses[client_host]: _id = _response.get("id") if _id == request_id: response = _response break if response is not not_found: break if timeout > 0 and (time.time() - start) > timeout: raise Exception("Timeout passed") return time.sleep(0.1) if response is not_found: raise Exception("Connection closed") self.responses[client_host].remove(response) error = response.get("error") result = response.get("result") if error: raise Exception("Error happened: {}".format(error)) return result class QtTVPaintRpc(BaseTVPaintRpc): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) from openpype.tools.utils import host_tools self.tools_helper = host_tools.HostToolsHelper() route_name = self.route_name # Register methods self.add_methods( (route_name, self.workfiles_tool), (route_name, self.loader_tool), (route_name, self.publish_tool), (route_name, self.scene_inventory_tool), (route_name, self.library_loader_tool), (route_name, self.experimental_tools) ) # Panel routes for tools async def workfiles_tool(self): log.info("Triggering Workfile tool") item = MainThreadItem(self.tools_helper.show_workfiles) self._execute_in_main_thread(item, wait=False) return async def loader_tool(self): log.info("Triggering Loader tool") item = MainThreadItem(self.tools_helper.show_loader) self._execute_in_main_thread(item, wait=False) return async def publish_tool(self): log.info("Triggering Publish tool") item = MainThreadItem(self.tools_helper.show_publisher_tool) self._execute_in_main_thread(item, wait=False) return async def scene_inventory_tool(self): """Open Scene Inventory tool. Function can't confirm if tool was opened becauise one part of SceneInventory initialization is calling websocket request to host but host can't response because is waiting for response from this call. """ log.info("Triggering Scene inventory tool") item = MainThreadItem(self.tools_helper.show_scene_inventory) # Do not wait for result of callback self._execute_in_main_thread(item, wait=False) return async def library_loader_tool(self): log.info("Triggering Library loader tool") item = MainThreadItem(self.tools_helper.show_library_loader) self._execute_in_main_thread(item, wait=False) return async def experimental_tools(self): log.info("Triggering Library loader tool") item = MainThreadItem(self.tools_helper.show_experimental_tools_dialog) self._execute_in_main_thread(item, wait=False) return async def _async_execute_in_main_thread(self, item, **kwargs): await self.communication_obj.async_execute_in_main_thread( item, **kwargs ) def _execute_in_main_thread(self, item, **kwargs): return self.communication_obj.execute_in_main_thread(item, **kwargs) class MainThreadItem: """Structure to store information about callback in main thread. Item should be used to execute callback in main thread which may be needed for execution of Qt objects. Item store callback (callable variable), arguments and keyword arguments for the callback. Item hold information about it's process. """ not_set = object() sleep_time = 0.1 def __init__(self, callback, *args, **kwargs): self.done = False self.exception = self.not_set self.result = self.not_set self.callback = callback self.args = args self.kwargs = kwargs def execute(self): """Execute callback and store its result. Method must be called from main thread. Item is marked as `done` when callback execution finished. Store output of callback of exception information when callback raises one. """ log.debug("Executing process in main thread") if self.done: log.warning("- item is already processed") return callback = self.callback args = self.args kwargs = self.kwargs log.info("Running callback: {}".format(str(callback))) try: result = callback(*args, **kwargs) self.result = result except Exception as exc: self.exception = exc finally: self.done = True def wait(self): """Wait for result from main thread. This method stops current thread until callback is executed. Returns: object: Output of callback. May be any type or object. Raises: Exception: Reraise any exception that happened during callback execution. """ while not self.done: time.sleep(self.sleep_time) if self.exception is self.not_set: return self.result raise self.exception async def async_wait(self): """Wait for result from main thread. Returns: object: Output of callback. May be any type or object. Raises: Exception: Reraise any exception that happened during callback execution. """ while not self.done: await asyncio.sleep(self.sleep_time) if self.exception is self.not_set: return self.result raise self.exception class BaseCommunicator: def __init__(self): self.process = None self.websocket_server = None self.websocket_rpc = None self.exit_code = None self._connected_client = None @property def server_is_running(self): if self.websocket_server is None: return False return self.websocket_server.server_is_running def _windows_file_process(self, src_dst_mapping, to_remove): """Windows specific file processing asking for admin permissions. It is required to have administration permissions to modify plugin files in TVPaint installation folder. Method requires `pywin32` python module. Args: src_dst_mapping (list, tuple, set): Mapping of source file to destination. Both must be full path. Each item must be iterable of size 2 `(C:/src/file.dll, C:/dst/file.dll)`. to_remove (list): Fullpath to files that should be removed. """ import pythoncom from win32comext.shell import shell # Create temp folder where plugin files are temporary copied # - reason is that copy to TVPaint requires administartion permissions # but admin may not have access to source folder tmp_dir = os.path.normpath( tempfile.mkdtemp(prefix="tvpaint_copy_") ) # Copy source to temp folder and create new mapping dst_folders = collections.defaultdict(list) new_src_dst_mapping = [] for old_src, dst in src_dst_mapping: new_src = os.path.join(tmp_dir, os.path.split(old_src)[1]) shutil.copy(old_src, new_src) new_src_dst_mapping.append((new_src, dst)) for src, dst in new_src_dst_mapping: src = os.path.normpath(src) dst = os.path.normpath(dst) dst_filename = os.path.basename(dst) dst_folder_path = os.path.dirname(dst) dst_folders[dst_folder_path].append((dst_filename, src)) # create an instance of IFileOperation fo = pythoncom.CoCreateInstance( shell.CLSID_FileOperation, None, pythoncom.CLSCTX_ALL, shell.IID_IFileOperation ) # Add delete command to file operation object for filepath in to_remove: item = shell.SHCreateItemFromParsingName( filepath, None, shell.IID_IShellItem ) fo.DeleteItem(item) # here you can use SetOperationFlags, progress Sinks, etc. for folder_path, items in dst_folders.items(): # create an instance of IShellItem for the target folder folder_item = shell.SHCreateItemFromParsingName( folder_path, None, shell.IID_IShellItem ) for _dst_filename, source_file_path in items: # create an instance of IShellItem for the source item copy_item = shell.SHCreateItemFromParsingName( source_file_path, None, shell.IID_IShellItem ) # queue the copy operation fo.CopyItem(copy_item, folder_item, _dst_filename, None) # commit fo.PerformOperations() # Remove temp folder shutil.rmtree(tmp_dir) def _prepare_windows_plugin(self, launch_args): """Copy plugin to TVPaint plugins and set PATH to dependencies. Check if plugin in TVPaint's plugins exist and match to plugin version to current implementation version. Based on 64-bit or 32-bit version of the plugin. Path to libraries required for plugin is added to PATH variable. """ host_executable = launch_args[0] executable_file = os.path.basename(host_executable) if "64bit" in executable_file: subfolder = "windows_x64" elif "32bit" in executable_file: subfolder = "windows_x86" else: raise ValueError( "Can't determine if executable " "leads to 32-bit or 64-bit TVPaint!" ) plugin_files_path = get_plugin_files_path() # Folder for right windows plugin files source_plugins_dir = os.path.join(plugin_files_path, subfolder) # Path to libraries (.dll) required for plugin library # - additional libraries can be copied to TVPaint installation folder # (next to executable) or added to PATH environment variable additional_libs_folder = os.path.join( source_plugins_dir, "additional_libraries" ) additional_libs_folder = additional_libs_folder.replace("\\", "/") if ( os.path.exists(additional_libs_folder) and additional_libs_folder not in os.environ["PATH"] ): os.environ["PATH"] += (os.pathsep + additional_libs_folder) # Path to TVPaint's plugins folder (where we want to add our plugin) host_plugins_path = os.path.join( os.path.dirname(host_executable), "plugins" ) # Files that must be copied to TVPaint's plugin folder plugin_dir = os.path.join(source_plugins_dir, "plugin") to_copy = [] to_remove = [] # Remove old plugin name deprecated_filepath = os.path.join( host_plugins_path, "AvalonPlugin.dll" ) if os.path.exists(deprecated_filepath): to_remove.append(deprecated_filepath) for filename in os.listdir(plugin_dir): src_full_path = os.path.join(plugin_dir, filename) dst_full_path = os.path.join(host_plugins_path, filename) if dst_full_path in to_remove: to_remove.remove(dst_full_path) if ( not os.path.exists(dst_full_path) or not filecmp.cmp(src_full_path, dst_full_path) ): to_copy.append((src_full_path, dst_full_path)) # Skip copy if everything is done if not to_copy and not to_remove: return # Try to copy try: self._windows_file_process(to_copy, to_remove) except Exception: log.error("Plugin copy failed", exc_info=True) # Validate copy was done invalid_copy = [] for src, dst in to_copy: if not os.path.exists(dst) or not filecmp.cmp(src, dst): invalid_copy.append((src, dst)) # Validate delete was dones invalid_remove = [] for filepath in to_remove: if os.path.exists(filepath): invalid_remove.append(filepath) if not invalid_remove and not invalid_copy: return msg_parts = [] if invalid_remove: msg_parts.append( "Failed to remove files: {}".format(", ".join(invalid_remove)) ) if invalid_copy: _invalid = [ "\"{}\" -> \"{}\"".format(src, dst) for src, dst in invalid_copy ] msg_parts.append( "Failed to copy files: {}".format(", ".join(_invalid)) ) raise RuntimeError(" & ".join(msg_parts)) def _launch_tv_paint(self, launch_args): flags = ( subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP ) env = os.environ.copy() # Remove QuickTime from PATH on windows # - quicktime overrides TVPaint's ffmpeg encode/decode which may # cause issues on loading if platform.system().lower() == "windows": new_path = [] for path in env["PATH"].split(os.pathsep): if path and "quicktime" not in path.lower(): new_path.append(path) env["PATH"] = os.pathsep.join(new_path) kwargs = { "env": env, "creationflags": flags } self.process = subprocess.Popen(launch_args, **kwargs) def _create_routes(self): self.websocket_rpc = BaseTVPaintRpc( self, loop=self.websocket_server.loop ) self.websocket_server.add_route( "*", "/", self.websocket_rpc.handle_request ) def _start_webserver(self): self.websocket_server.start() # Make sure RPC is using same loop as websocket server while not self.websocket_server.server_is_running: time.sleep(0.1) def _stop_webserver(self): self.websocket_server.stop() def _exit(self, exit_code=None): self._stop_webserver() if exit_code is not None: self.exit_code = exit_code if self.exit_code is None: self.exit_code = 0 def stop(self): """Stop communication and currently running python process.""" log.info("Stopping communication") self._exit() def launch(self, launch_args): """Prepare all required data and launch host. First is prepared websocket server as communication point for host, when server is ready to use host is launched as subprocess. """ if platform.system().lower() == "windows": self._prepare_windows_plugin(launch_args) # Launch TVPaint and the websocket server. log.info("Launching TVPaint") self.websocket_server = WebSocketServer() self._create_routes() os.environ["WEBSOCKET_URL"] = "ws://localhost:{}".format( self.websocket_server.port ) log.info("Added request handler for url: {}".format( os.environ["WEBSOCKET_URL"] )) self._start_webserver() # Start TVPaint when server is running self._launch_tv_paint(launch_args) log.info("Waiting for client connection") while True: if self.process.poll() is not None: log.debug("Host process is not alive. Exiting") self._exit(1) return if self.websocket_rpc.client_connected(): log.info("Client has connected") break time.sleep(0.5) self._on_client_connect() emit_event("application.launched") def _on_client_connect(self): self._initial_textfile_write() def _initial_textfile_write(self): """Show popup about Write to file at start of TVPaint.""" tmp_file = tempfile.NamedTemporaryFile( mode="w", prefix="a_tvp_", suffix=".txt", delete=False ) tmp_file.close() tmp_filepath = tmp_file.name.replace("\\", "/") george_script = ( "tv_writetextfile \"strict\" \"append\" \"{}\" \"empty\"" ).format(tmp_filepath) result = CommunicationWrapper.execute_george(george_script) # Remote the file os.remove(tmp_filepath) if result is None: log.warning( "Host was probably closed before plugin was initialized." ) elif result.lower() == "forbidden": log.warning("User didn't confirm saving files.") def _client(self): if not self.websocket_rpc: log.warning("Communicator's server did not start yet.") return None for client in self.websocket_rpc.clients: if not client.ws.closed: return client log.warning("Client is not yet connected to Communicator.") return None def client(self): if not self._connected_client or self._connected_client.ws.closed: self._connected_client = self._client() return self._connected_client def send_request(self, method, params=None): client = self.client() if not client: return return self.websocket_rpc.send_request( client, method, params ) def send_notification(self, method, params=None): client = self.client() if not client: return self.websocket_rpc.send_notification( client, method, params ) def execute_george(self, george_script): """Execute passed goerge script in TVPaint.""" return self.send_request( "execute_george", [george_script] ) def execute_george_through_file(self, george_script): """Execute george script with temp file. Allows to execute multiline george script without stopping websocket client. On windows make sure script does not contain paths with backwards slashes in paths, TVPaint won't execute properly in that case. Args: george_script (str): George script to execute. May be multilined. """ temporary_file = tempfile.NamedTemporaryFile( mode="w", prefix="a_tvp_", suffix=".grg", delete=False ) temporary_file.write(george_script) temporary_file.close() temp_file_path = temporary_file.name.replace("\\", "/") self.execute_george("tv_runscript {}".format(temp_file_path)) os.remove(temp_file_path) class QtCommunicator(BaseCommunicator): label = os.getenv("AVALON_LABEL") if not label: label = "AYON" if AYON_SERVER_ENABLED else "OpenPype" title = "{} Tools".format(label) menu_definitions = { "title": title, "menu_items": [ { "callback": "workfiles_tool", "label": "Workfiles", "help": "Open workfiles tool" }, { "callback": "loader_tool", "label": "Load", "help": "Open loader tool" }, { "callback": "scene_inventory_tool", "label": "Scene inventory", "help": "Open scene inventory tool" }, { "callback": "publish_tool", "label": "Publish", "help": "Open publisher" }, { "callback": "library_loader_tool", "label": "Library", "help": "Open library loader tool" }, { "callback": "experimental_tools", "label": "Experimental tools", "help": "Open experimental tools dialog" } ] } def __init__(self, qt_app): super().__init__() self.callback_queue = collections.deque() self.qt_app = qt_app def _create_routes(self): self.websocket_rpc = QtTVPaintRpc( self, loop=self.websocket_server.loop ) self.websocket_server.add_route( "*", "/", self.websocket_rpc.handle_request ) def execute_in_main_thread(self, main_thread_item, wait=True): """Add `MainThreadItem` to callback queue and wait for result.""" self.callback_queue.append(main_thread_item) if wait: return main_thread_item.wait() return async def async_execute_in_main_thread(self, main_thread_item, wait=True): """Add `MainThreadItem` to callback queue and wait for result.""" self.callback_queue.append(main_thread_item) if wait: return await main_thread_item.async_wait() def main_thread_listen(self): """Get last `MainThreadItem` from queue. Must be called from main thread. Method checks if host process is still running as it may cause issues if not. """ # check if host still running if self.process.poll() is not None: self._exit() return None if self.callback_queue: return self.callback_queue.popleft() return None def _on_client_connect(self): super()._on_client_connect() self._build_menu() def _build_menu(self): self.send_request( "define_menu", [self.menu_definitions] ) def _exit(self, *args, **kwargs): super()._exit(*args, **kwargs) emit_event("application.exit") self.qt_app.exit(self.exit_code) ================================================ FILE: openpype/hosts/tvpaint/api/launch_script.py ================================================ import os import sys import signal import traceback import ctypes import platform import logging from qtpy import QtWidgets, QtCore, QtGui from openpype import style from openpype.pipeline import install_host from openpype.hosts.tvpaint.api import ( TVPaintHost, CommunicationWrapper, ) log = logging.getLogger(__name__) def safe_excepthook(*args): traceback.print_exception(*args) def main(launch_args): # Be sure server won't crash at any moment but just print traceback sys.excepthook = safe_excepthook # Create QtApplication for tools # - QApplicaiton is also main thread/event loop of the server qt_app = QtWidgets.QApplication([]) tvpaint_host = TVPaintHost() # Execute pipeline installation install_host(tvpaint_host) # Create Communicator object and trigger launch # - this must be done before anything is processed communicator = CommunicationWrapper.create_qt_communicator(qt_app) communicator.launch(launch_args) def process_in_main_thread(): """Execution of `MainThreadItem`.""" item = communicator.main_thread_listen() if item: item.execute() timer = QtCore.QTimer() timer.setInterval(100) timer.timeout.connect(process_in_main_thread) timer.start() # Register terminal signal handler def signal_handler(*_args): print("You pressed Ctrl+C. Process ended.") communicator.stop() signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) qt_app.setQuitOnLastWindowClosed(False) qt_app.setStyleSheet(style.load_stylesheet()) # Load avalon icon icon_path = style.app_icon_path() if icon_path: icon = QtGui.QIcon(icon_path) qt_app.setWindowIcon(icon) # Set application name to be able show application icon in task bar if platform.system().lower() == "windows": ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( u"WebsocketServer" ) # Run Qt application event processing sys.exit(qt_app.exec_()) if __name__ == "__main__": args = list(sys.argv) if os.path.abspath(__file__) == os.path.normpath(args[0]): # Pop path to script args.pop(0) main(args) ================================================ FILE: openpype/hosts/tvpaint/api/lib.py ================================================ import os import logging import tempfile from .communication_server import CommunicationWrapper log = logging.getLogger(__name__) def execute_george(george_script, communicator=None): if not communicator: communicator = CommunicationWrapper.communicator return communicator.execute_george(george_script) def execute_george_through_file(george_script, communicator=None): """Execute george script with temp file. Allows to execute multiline george script without stopping websocket client. On windows make sure script does not contain paths with backwards slashes in paths, TVPaint won't execute properly in that case. Args: george_script (str): George script to execute. May be multilined. """ if not communicator: communicator = CommunicationWrapper.communicator return communicator.execute_george_through_file(george_script) def parse_layers_data(data): """Parse layers data loaded in 'get_layers_data'.""" layers = [] layers_raw = data.split("\n") for layer_raw in layers_raw: layer_raw = layer_raw.strip() if not layer_raw: continue ( layer_id, group_id, visible, position, opacity, name, layer_type, frame_start, frame_end, prelighttable, postlighttable, selected, editable, sencil_state, is_current ) = layer_raw.split("|") layer = { "layer_id": int(layer_id), "group_id": int(group_id), "visible": visible == "ON", "position": int(position), # Opacity from 'tv_layerinfo' is always set to '0' so it's unusable # "opacity": int(opacity), "name": name, "type": layer_type, "frame_start": int(frame_start), "frame_end": int(frame_end), "prelighttable": prelighttable == "1", "postlighttable": postlighttable == "1", "selected": selected == "1", "editable": editable == "1", "sencil_state": sencil_state, "is_current": is_current == "1" } layers.append(layer) return layers def get_layers_data_george_script(output_filepath, layer_ids=None): """Prepare george script which will collect all layers from workfile.""" output_filepath = output_filepath.replace("\\", "/") george_script_lines = [ # Variable containing full path to output file "output_path = \"{}\"".format(output_filepath), # Get Current Layer ID "tv_LayerCurrentID", "current_layer_id = result" ] # Script part for getting and storing layer information to temp layer_data_getter = ( # Get information about layer's group "tv_layercolor \"get\" layer_id", "group_id = result", "tv_LayerInfo layer_id", ( "PARSE result visible position opacity name" " type startFrame endFrame prelighttable postlighttable" " selected editable sencilState" ), # Check if layer ID match `tv_LayerCurrentID` "is_current=0", "IF CMP(current_layer_id, layer_id)==1", # - mark layer as selected if layer id match to current layer id "is_current=1", "selected=1", "END", # Prepare line with data separated by "|" ( "line = layer_id'|'group_id'|'visible'|'position'|'opacity'|'" "name'|'type'|'startFrame'|'endFrame'|'prelighttable'|'" "postlighttable'|'selected'|'editable'|'sencilState'|'is_current" ), # Write data to output file "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' line", ) # Collect data for all layers if layers are not specified if layer_ids is None: george_script_lines.extend(( # Layer loop variables "loop = 1", "idx = 0", # Layers loop "WHILE loop", "tv_LayerGetID idx", "layer_id = result", "idx = idx + 1", # Stop loop if layer_id is "NONE" "IF CMP(layer_id, \"NONE\")==1", "loop = 0", "ELSE", *layer_data_getter, "END", "END" )) else: for layer_id in layer_ids: george_script_lines.append("layer_id = {}".format(layer_id)) george_script_lines.extend(layer_data_getter) return "\n".join(george_script_lines) def layers_data(layer_ids=None, communicator=None): """Backwards compatible function of 'get_layers_data'.""" return get_layers_data(layer_ids, communicator) def get_layers_data(layer_ids=None, communicator=None): """Collect all layers information from currently opened workfile.""" output_file = tempfile.NamedTemporaryFile( mode="w", prefix="a_tvp_", suffix=".txt", delete=False ) output_file.close() if layer_ids is not None and isinstance(layer_ids, int): layer_ids = [layer_ids] output_filepath = output_file.name george_script = get_layers_data_george_script(output_filepath, layer_ids) execute_george_through_file(george_script, communicator) with open(output_filepath, "r") as stream: data = stream.read() output = parse_layers_data(data) os.remove(output_filepath) return output def parse_group_data(data): """Parse group data collected in 'get_groups_data'.""" output = [] groups_raw = data.split("\n") for group_raw in groups_raw: group_raw = group_raw.strip() if not group_raw: continue parts = group_raw.split("|") # Check for length and concatenate 2 last items until length match # - this happens if name contain spaces while len(parts) > 6: last_item = parts.pop(-1) parts[-1] = "|".join([parts[-1], last_item]) clip_id, group_id, red, green, blue, name = parts group = { "group_id": int(group_id), "name": name, "clip_id": int(clip_id), "red": int(red), "green": int(green), "blue": int(blue), } output.append(group) return output def groups_data(communicator=None): """Backwards compatible function of 'get_groups_data'.""" return get_groups_data(communicator) def get_groups_data(communicator=None): """Information about groups from current workfile.""" output_file = tempfile.NamedTemporaryFile( mode="w", prefix="a_tvp_", suffix=".txt", delete=False ) output_file.close() output_filepath = output_file.name.replace("\\", "/") george_script_lines = ( # Variable containing full path to output file "output_path = \"{}\"".format(output_filepath), "empty = 0", # Loop over 26 groups which is ATM maximum possible (in 11.7) # - ref: https://www.tvpaint.com/forum/viewtopic.php?t=13880 "FOR idx = 1 TO 26", # Receive information about groups "tv_layercolor \"getcolor\" 0 idx", "PARSE result clip_id group_index c_red c_green c_blue group_name", # Create and add line to output file "line = clip_id'|'group_index'|'c_red'|'c_green'|'c_blue'|'group_name", "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' line", "END", ) george_script = "\n".join(george_script_lines) execute_george_through_file(george_script, communicator) with open(output_filepath, "r") as stream: data = stream.read() output = parse_group_data(data) os.remove(output_filepath) return output def get_layers_pre_post_behavior(layer_ids, communicator=None): """Collect data about pre and post behavior of layer ids. Pre and Post behaviors is enumerator of possible values: - "none" - "repeat" - "pingpong" - "hold" Example output: ```json { 0: { "pre": "none", "post": "repeat" } } ``` Returns: dict: Key is layer id value is dictionary with "pre" and "post" keys. """ # Skip if is empty if not layer_ids: return {} # Auto convert to list if not isinstance(layer_ids, (list, set, tuple)): layer_ids = [layer_ids] # Prepare temp file output_file = tempfile.NamedTemporaryFile( mode="w", prefix="a_tvp_", suffix=".txt", delete=False ) output_file.close() output_filepath = output_file.name.replace("\\", "/") george_script_lines = [ # Variable containing full path to output file "output_path = \"{}\"".format(output_filepath), ] for layer_id in layer_ids: george_script_lines.extend([ "layer_id = {}".format(layer_id), "tv_layerprebehavior layer_id", "pre_beh = result", "tv_layerpostbehavior layer_id", "post_beh = result", "line = layer_id'|'pre_beh'|'post_beh", "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' line" ]) george_script = "\n".join(george_script_lines) execute_george_through_file(george_script, communicator) # Read data with open(output_filepath, "r") as stream: data = stream.read() # Remove temp file os.remove(output_filepath) # Parse data output = {} raw_lines = data.split("\n") for raw_line in raw_lines: line = raw_line.strip() if not line: continue parts = line.split("|") if len(parts) != 3: continue layer_id, pre_beh, post_beh = parts output[int(layer_id)] = { "pre": pre_beh.lower(), "post": post_beh.lower() } return output def get_layers_exposure_frames(layer_ids, layers_data=None, communicator=None): """Get exposure frames. Easily said returns frames where keyframes are. Recognized with george function `tv_exposureinfo` returning "Head". Args: layer_ids (list): Ids of a layers for which exposure frames should look for. layers_data (list): Precollected layers data. If are not passed then 'get_layers_data' is used. communicator (BaseCommunicator): Communicator used for communication with TVPaint. Returns: dict: Frames where exposure is set to "Head" by layer id. """ if layers_data is None: layers_data = get_layers_data(layer_ids) _layers_by_id = { layer["layer_id"]: layer for layer in layers_data } layers_by_id = { layer_id: _layers_by_id.get(layer_id) for layer_id in layer_ids } tmp_file = tempfile.NamedTemporaryFile( mode="w", prefix="a_tvp_", suffix=".txt", delete=False ) tmp_file.close() tmp_output_path = tmp_file.name.replace("\\", "/") george_script_lines = [ "output_path = \"{}\"".format(tmp_output_path) ] output = {} layer_id_mapping = {} for layer_id, layer_data in layers_by_id.items(): layer_id_mapping[str(layer_id)] = layer_id output[layer_id] = [] if not layer_data: continue first_frame = layer_data["frame_start"] last_frame = layer_data["frame_end"] george_script_lines.extend([ "line = \"\"", "layer_id = {}".format(layer_id), "line = line''layer_id", "tv_layerset layer_id", "frame = {}".format(first_frame), "WHILE (frame <= {})".format(last_frame), "tv_exposureinfo frame", "exposure = result", "IF (CMP(exposure, \"Head\") == 1)", "line = line'|'frame", "END", "frame = frame + 1", "END", "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' line" ]) execute_george_through_file("\n".join(george_script_lines), communicator) with open(tmp_output_path, "r") as stream: data = stream.read() os.remove(tmp_output_path) lines = [] for line in data.split("\n"): line = line.strip() if line: lines.append(line) for line in lines: line_items = list(line.split("|")) layer_id = line_items.pop(0) _layer_id = layer_id_mapping[layer_id] output[_layer_id] = [int(frame) for frame in line_items] return output def get_exposure_frames( layer_id, first_frame=None, last_frame=None, communicator=None ): """Get exposure frames. Easily said returns frames where keyframes are. Recognized with george function `tv_exposureinfo` returning "Head". Args: layer_id (int): Id of a layer for which exposure frames should look for. first_frame (int): From which frame will look for exposure frames. Used layers first frame if not entered. last_frame (int): Last frame where will look for exposure frames. Used layers last frame if not entered. Returns: list: Frames where exposure is set to "Head". """ if first_frame is None or last_frame is None: layer = layers_data(layer_id)[0] if first_frame is None: first_frame = layer["frame_start"] if last_frame is None: last_frame = layer["frame_end"] tmp_file = tempfile.NamedTemporaryFile( mode="w", prefix="a_tvp_", suffix=".txt", delete=False ) tmp_file.close() tmp_output_path = tmp_file.name.replace("\\", "/") george_script_lines = [ "tv_layerset {}".format(layer_id), "output_path = \"{}\"".format(tmp_output_path), "output = \"\"", "frame = {}".format(first_frame), "WHILE (frame <= {})".format(last_frame), "tv_exposureinfo frame", "exposure = result", "IF (CMP(exposure, \"Head\") == 1)", "IF (CMP(output, \"\") == 1)", "output = output''frame", "ELSE", "output = output'|'frame", "END", "END", "frame = frame + 1", "END", "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' output" ] execute_george_through_file("\n".join(george_script_lines), communicator) with open(tmp_output_path, "r") as stream: data = stream.read() os.remove(tmp_output_path) lines = [] for line in data.split("\n"): line = line.strip() if line: lines.append(line) exposure_frames = [] for line in lines: for frame in line.split("|"): exposure_frames.append(int(frame)) return exposure_frames def get_scene_data(communicator=None): """Scene data of currently opened scene. Result contains resolution, pixel aspect, fps mark in/out with states, frame start and background color. Returns: dict: Scene data collected in many ways. """ workfile_info = execute_george("tv_projectinfo", communicator) workfile_info_parts = workfile_info.split(" ") # Project frame start - not used workfile_info_parts.pop(-1) field_order = workfile_info_parts.pop(-1) frame_rate = float(workfile_info_parts.pop(-1)) pixel_apsect = float(workfile_info_parts.pop(-1)) height = int(workfile_info_parts.pop(-1)) width = int(workfile_info_parts.pop(-1)) # Marks return as "{frame - 1} {state} ", example "0 set". result = execute_george("tv_markin", communicator) mark_in_frame, mark_in_state, _ = result.split(" ") result = execute_george("tv_markout", communicator) mark_out_frame, mark_out_state, _ = result.split(" ") start_frame = execute_george("tv_startframe", communicator) return { "width": width, "height": height, "pixel_aspect": pixel_apsect, "fps": frame_rate, "field_order": field_order, "mark_in": int(mark_in_frame), "mark_in_state": mark_in_state, "mark_in_set": mark_in_state == "set", "mark_out": int(mark_out_frame), "mark_out_state": mark_out_state, "mark_out_set": mark_out_state == "set", "start_frame": int(start_frame), "bg_color": get_scene_bg_color(communicator) } def get_scene_bg_color(communicator=None): """Background color set on scene. Is important for review exporting where scene bg color is used as background. """ output_file = tempfile.NamedTemporaryFile( mode="w", prefix="a_tvp_", suffix=".txt", delete=False ) output_file.close() output_filepath = output_file.name.replace("\\", "/") george_script_lines = [ # Variable containing full path to output file "output_path = \"{}\"".format(output_filepath), "tv_background", "bg_color = result", # Write data to output file "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' bg_color" ] george_script = "\n".join(george_script_lines) execute_george_through_file(george_script, communicator) with open(output_filepath, "r") as stream: data = stream.read() os.remove(output_filepath) data = data.strip() if not data: return None return data.split(" ") ================================================ FILE: openpype/hosts/tvpaint/api/pipeline.py ================================================ import os import json import tempfile import logging import requests import pyblish.api from openpype.client import get_asset_by_name from openpype.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost from openpype.hosts.tvpaint import TVPAINT_ROOT_DIR from openpype.settings import get_current_project_settings from openpype.lib import register_event_callback from openpype.pipeline import ( legacy_io, register_loader_plugin_path, register_creator_plugin_path, AVALON_CONTAINER_ID, ) from openpype.pipeline.context_tools import get_global_context from .lib import ( execute_george, execute_george_through_file ) log = logging.getLogger(__name__) METADATA_SECTION = "avalon" SECTION_NAME_CONTEXT = "context" SECTION_NAME_CREATE_CONTEXT = "create_context" SECTION_NAME_INSTANCES = "instances" SECTION_NAME_CONTAINERS = "containers" # Maximum length of metadata chunk string # TODO find out the max (500 is safe enough) TVPAINT_CHUNK_LENGTH = 500 """TVPaint's Metadata Metadata are stored to TVPaint's workfile. Workfile works similar to .ini file but has few limitation. Most important limitation is that value under key has limited length. Due to this limitation each metadata section/key stores number of "subkeys" that are related to the section. Example: Metadata key `"instances"` may have stored value "2". In that case it is expected that there are also keys `["instances0", "instances1"]`. Workfile data looks like: ``` [avalon] instances0=[{{__dq__}id{__dq__}: {__dq__}pyblish.avalon.instance{__dq__... instances1=...more data... instances=2 ``` """ class TVPaintHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): name = "tvpaint" def install(self): """Install TVPaint-specific functionality.""" log.info("OpenPype - Installing TVPaint integration") legacy_io.install() # Create workdir folder if does not exist yet workdir = legacy_io.Session["AVALON_WORKDIR"] if not os.path.exists(workdir): os.makedirs(workdir) plugins_dir = os.path.join(TVPAINT_ROOT_DIR, "plugins") publish_dir = os.path.join(plugins_dir, "publish") load_dir = os.path.join(plugins_dir, "load") create_dir = os.path.join(plugins_dir, "create") pyblish.api.register_host("tvpaint") pyblish.api.register_plugin_path(publish_dir) register_loader_plugin_path(load_dir) register_creator_plugin_path(create_dir) register_event_callback("application.launched", self.initial_launch) register_event_callback("application.exit", self.application_exit) def get_current_project_name(self): """ Returns: Union[str, None]: Current project name. """ return self.get_current_context().get("project_name") def get_current_asset_name(self): """ Returns: Union[str, None]: Current asset name. """ return self.get_current_context().get("asset_name") def get_current_task_name(self): """ Returns: Union[str, None]: Current task name. """ return self.get_current_context().get("task_name") def get_current_context(self): context = get_current_workfile_context() if not context: return get_global_context() if "project_name" in context: return context # This is legacy way how context was stored return { "project_name": context.get("project"), "asset_name": context.get("asset"), "task_name": context.get("task") } # --- Create --- def get_context_data(self): return get_workfile_metadata(SECTION_NAME_CREATE_CONTEXT, {}) def update_context_data(self, data, changes): return write_workfile_metadata(SECTION_NAME_CREATE_CONTEXT, data) def list_instances(self): """List all created instances from current workfile.""" return list_instances() def write_instances(self, data): return write_instances(data) # --- Workfile --- def open_workfile(self, filepath): george_script = "tv_LoadProject '\"'\"{}\"'\"'".format( filepath.replace("\\", "/") ) return execute_george_through_file(george_script) def save_workfile(self, filepath=None): if not filepath: filepath = self.get_current_workfile() context = get_global_context() save_current_workfile_context(context) # Execute george script to save workfile. george_script = "tv_SaveProject {}".format(filepath.replace("\\", "/")) return execute_george(george_script) def work_root(self, session): return session["AVALON_WORKDIR"] def get_current_workfile(self): return execute_george("tv_GetProjectName") def workfile_has_unsaved_changes(self): return None def get_workfile_extensions(self): return [".tvpp"] # --- Load --- def get_containers(self): return get_containers() def initial_launch(self): # Setup project settings if its the template that's launched. # TODO also check for template creation when it's possible to define # templates last_workfile = os.environ.get("AVALON_LAST_WORKFILE") if not last_workfile or os.path.exists(last_workfile): return log.info("Setting up project...") global_context = get_global_context() project_name = global_context.get("project_name") asset_name = global_context.get("aset_name") if not project_name or not asset_name: return asset_doc = get_asset_by_name(project_name, asset_name) set_context_settings(project_name, asset_doc) def application_exit(self): """Logic related to TimerManager. Todo: This should be handled out of TVPaint integration logic. """ data = get_current_project_settings() stop_timer = data["tvpaint"]["stop_timer_on_application_exit"] if not stop_timer: return # Stop application timer. webserver_url = os.environ.get("OPENPYPE_WEBSERVER_URL") rest_api_url = "{}/timers_manager/stop_timer".format(webserver_url) requests.post(rest_api_url) def containerise( name, namespace, members, context, loader, current_containers=None ): """Add new container to metadata. Args: name (str): Container name. namespace (str): Container namespace. members (list): List of members that were loaded and belongs to the container (layer names). current_containers (list): Preloaded containers. Should be used only on update/switch when containers were modified during the process. Returns: dict: Container data stored to workfile metadata. """ container_data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "members": members, "name": name, "namespace": namespace, "loader": str(loader), "representation": str(context["representation"]["_id"]) } if current_containers is None: current_containers = get_containers() # Add container to containers list current_containers.append(container_data) # Store data to metadata write_workfile_metadata(SECTION_NAME_CONTAINERS, current_containers) return container_data def split_metadata_string(text, chunk_length=None): """Split string by length. Split text to chunks by entered length. Example: ```python text = "ABCDEFGHIJKLM" result = split_metadata_string(text, 3) print(result) >>> ['ABC', 'DEF', 'GHI', 'JKL'] ``` Args: text (str): Text that will be split into chunks. chunk_length (int): Single chunk size. Default chunk_length is set to global variable `TVPAINT_CHUNK_LENGTH`. Returns: list: List of strings with at least one item. """ if chunk_length is None: chunk_length = TVPAINT_CHUNK_LENGTH chunks = [] for idx in range(chunk_length, len(text) + chunk_length, chunk_length): start_idx = idx - chunk_length chunks.append(text[start_idx:idx]) return chunks def get_workfile_metadata_string_for_keys(metadata_keys): """Read metadata for specific keys from current project workfile. All values from entered keys are stored to single string without separator. Function is designed to help get all values for one metadata key at once. So order of passed keys matteres. Args: metadata_keys (list, str): Metadata keys for which data should be retrieved. Order of keys matters! It is possible to enter only single key as string. """ # Add ability to pass only single key if isinstance(metadata_keys, str): metadata_keys = [metadata_keys] output_file = tempfile.NamedTemporaryFile( mode="w", prefix="a_tvp_", suffix=".txt", delete=False ) output_file.close() output_filepath = output_file.name.replace("\\", "/") george_script_parts = [] george_script_parts.append( "output_path = \"{}\"".format(output_filepath) ) # Store data for each index of metadata key for metadata_key in metadata_keys: george_script_parts.append( "tv_readprojectstring \"{}\" \"{}\" \"\"".format( METADATA_SECTION, metadata_key ) ) george_script_parts.append( "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' result" ) # Execute the script george_script = "\n".join(george_script_parts) execute_george_through_file(george_script) # Load data from temp file with open(output_filepath, "r") as stream: file_content = stream.read() # Remove `\n` from content output_string = file_content.replace("\n", "") # Delete temp file os.remove(output_filepath) return output_string def get_workfile_metadata_string(metadata_key): """Read metadata for specific key from current project workfile.""" result = get_workfile_metadata_string_for_keys([metadata_key]) if not result: return None stripped_result = result.strip() if not stripped_result: return None # NOTE Backwards compatibility when metadata key did not store range of key # indexes but the value itself # NOTE We don't have to care about negative values with `isdecimal` check if not stripped_result.isdecimal(): metadata_string = result else: keys = [] for idx in range(int(stripped_result)): keys.append("{}{}".format(metadata_key, idx)) metadata_string = get_workfile_metadata_string_for_keys(keys) # Replace quotes plaholders with their values metadata_string = ( metadata_string .replace("{__sq__}", "'") .replace("{__dq__}", "\"") ) return metadata_string def get_workfile_metadata(metadata_key, default=None): """Read and parse metadata for specific key from current project workfile. Pipeline use function to store loaded and created instances within keys stored in `SECTION_NAME_INSTANCES` and `SECTION_NAME_CONTAINERS` constants. Args: metadata_key (str): Key defying which key should read. It is expected value contain json serializable string. """ if default is None: default = [] json_string = get_workfile_metadata_string(metadata_key) if json_string: try: return json.loads(json_string) except json.decoder.JSONDecodeError: # TODO remove when backwards compatibility of storing metadata # will be removed print(( "Fixed invalid metadata in workfile." " Not serializable string was: {}" ).format(json_string)) write_workfile_metadata(metadata_key, default) return default def write_workfile_metadata(metadata_key, value): """Write metadata for specific key into current project workfile. George script has specific way how to work with quotes which should be solved automatically with this function. Args: metadata_key (str): Key defying under which key value will be stored. value (dict,list,str): Data to store they must be json serializable. """ if isinstance(value, (dict, list)): value = json.dumps(value) if not value: value = "" # Handle quotes in dumped json string # - replace single and double quotes with placeholders value = ( value .replace("'", "{__sq__}") .replace("\"", "{__dq__}") ) chunks = split_metadata_string(value) chunks_len = len(chunks) write_template = "tv_writeprojectstring \"{}\" \"{}\" \"{}\"" george_script_parts = [] # Add information about chunks length to metadata key itself george_script_parts.append( write_template.format(METADATA_SECTION, metadata_key, chunks_len) ) # Add chunk values to indexed metadata keys for idx, chunk_value in enumerate(chunks): sub_key = "{}{}".format(metadata_key, idx) george_script_parts.append( write_template.format(METADATA_SECTION, sub_key, chunk_value) ) george_script = "\n".join(george_script_parts) return execute_george_through_file(george_script) def get_current_workfile_context(): """Return context in which was workfile saved.""" return get_workfile_metadata(SECTION_NAME_CONTEXT, {}) def save_current_workfile_context(context): """Save context which was used to create a workfile.""" return write_workfile_metadata(SECTION_NAME_CONTEXT, context) def list_instances(): """List all created instances from current workfile.""" return get_workfile_metadata(SECTION_NAME_INSTANCES) def write_instances(data): return write_workfile_metadata(SECTION_NAME_INSTANCES, data) def get_containers(): output = get_workfile_metadata(SECTION_NAME_CONTAINERS) if output: for item in output: if "objectName" not in item and "members" in item: members = item["members"] if isinstance(members, list): members = "|".join([str(member) for member in members]) item["objectName"] = members return output def set_context_settings(project_name, asset_doc): """Set workfile settings by asset document data. Change fps, resolution and frame start/end. """ width_key = "resolutionWidth" height_key = "resolutionHeight" width = asset_doc["data"].get(width_key) height = asset_doc["data"].get(height_key) if width is None or height is None: print("Resolution was not found!") else: execute_george( "tv_resizepage {} {} 0".format(width, height) ) framerate = asset_doc["data"].get("fps") if framerate is not None: execute_george( "tv_framerate {} \"timestretch\"".format(framerate) ) else: print("Framerate was not found!") frame_start = asset_doc["data"].get("frameStart") frame_end = asset_doc["data"].get("frameEnd") if frame_start is None or frame_end is None: print("Frame range was not found!") return handle_start = asset_doc["data"].get("handleStart") handle_end = asset_doc["data"].get("handleEnd") # Always start from 0 Mark In and set only Mark Out mark_in = 0 mark_out = mark_in + (frame_end - frame_start) + handle_start + handle_end execute_george("tv_markin {} set".format(mark_in)) execute_george("tv_markout {} set".format(mark_out)) ================================================ FILE: openpype/hosts/tvpaint/api/plugin.py ================================================ import re from openpype.pipeline import LoaderPlugin from openpype.pipeline.create import ( CreatedInstance, get_subset_name, AutoCreator, Creator, ) from openpype.pipeline.create.creator_plugins import cache_and_get_instances from .lib import get_layers_data SHARED_DATA_KEY = "openpype.tvpaint.instances" class TVPaintCreatorCommon: @property def subset_template_family_filter(self): return self.family def _cache_and_get_instances(self): return cache_and_get_instances( self, SHARED_DATA_KEY, self.host.list_instances ) def _collect_create_instances(self): instances_by_identifier = self._cache_and_get_instances() for instance_data in instances_by_identifier[self.identifier]: instance = CreatedInstance.from_existing(instance_data, self) self._add_instance_to_context(instance) def _update_create_instances(self, update_list): if not update_list: return cur_instances = self.host.list_instances() cur_instances_by_id = {} for instance_data in cur_instances: instance_id = instance_data.get("instance_id") if instance_id: cur_instances_by_id[instance_id] = instance_data for instance, changes in update_list: instance_data = changes.new_value cur_instance_data = cur_instances_by_id.get(instance.id) if cur_instance_data is None: cur_instances.append(instance_data) continue for key in set(cur_instance_data) - set(instance_data): cur_instance_data.pop(key) cur_instance_data.update(instance_data) self.host.write_instances(cur_instances) def _custom_get_subset_name( self, variant, task_name, asset_doc, project_name, host_name=None, instance=None ): dynamic_data = self.get_dynamic_data( variant, task_name, asset_doc, project_name, host_name, instance ) return get_subset_name( self.family, variant, task_name, asset_doc, project_name, host_name, dynamic_data=dynamic_data, project_settings=self.project_settings, family_filter=self.subset_template_family_filter ) class TVPaintCreator(Creator, TVPaintCreatorCommon): def collect_instances(self): self._collect_create_instances() def update_instances(self, update_list): self._update_create_instances(update_list) def remove_instances(self, instances): ids_to_remove = { instance.id for instance in instances } cur_instances = self.host.list_instances() changed = False new_instances = [] for instance_data in cur_instances: if instance_data.get("instance_id") in ids_to_remove: changed = True else: new_instances.append(instance_data) if changed: self.host.write_instances(new_instances) for instance in instances: self._remove_instance_from_context(instance) def get_dynamic_data(self, *args, **kwargs): # Change asset and name by current workfile context create_context = self.create_context asset_name = create_context.get_current_asset_name() task_name = create_context.get_current_task_name() output = {} if asset_name: output["asset"] = asset_name if task_name: output["task"] = task_name return output def get_subset_name(self, *args, **kwargs): return self._custom_get_subset_name(*args, **kwargs) def _store_new_instance(self, new_instance): instances_data = self.host.list_instances() instances_data.append(new_instance.data_to_store()) self.host.write_instances(instances_data) self._add_instance_to_context(new_instance) class TVPaintAutoCreator(AutoCreator, TVPaintCreatorCommon): def collect_instances(self): self._collect_create_instances() def update_instances(self, update_list): self._update_create_instances(update_list) def get_subset_name(self, *args, **kwargs): return self._custom_get_subset_name(*args, **kwargs) class Loader(LoaderPlugin): hosts = ["tvpaint"] @staticmethod def get_members_from_container(container): if "members" not in container and "objectName" in container: # Backwards compatibility layer_ids_str = container.get("objectName") return [ int(layer_id) for layer_id in layer_ids_str.split("|") ] return container["members"] def get_unique_layer_name(self, asset_name, name): """Layer name with counter as suffix. Find higher 3 digit suffix from all layer names in scene matching regex `{asset_name}_{name}_{suffix}`. Higher 3 digit suffix is used as base for next number if scene does not contain layer matching regex `0` is used ase base. Args: asset_name (str): Name of subset's parent asset document. name (str): Name of loaded subset. Returns: (str): `{asset_name}_{name}_{higher suffix + 1}` """ layer_name_base = "{}_{}".format(asset_name, name) counter_regex = re.compile(r"_(\d{3})$") higher_counter = 0 for layer in get_layers_data(): layer_name = layer["name"] if not layer_name.startswith(layer_name_base): continue number_subpart = layer_name[len(layer_name_base):] groups = counter_regex.findall(number_subpart) if len(groups) != 1: continue counter = int(groups[0]) if counter > higher_counter: higher_counter = counter continue return "{}_{:0>3d}".format(layer_name_base, higher_counter + 1) ================================================ FILE: openpype/hosts/tvpaint/hooks/pre_launch_args.py ================================================ from openpype.lib import get_openpype_execute_args from openpype.lib.applications import PreLaunchHook, LaunchTypes class TvpaintPrelaunchHook(PreLaunchHook): """Launch arguments preparation. Hook add python executable and script path to tvpaint implementation before tvpaint executable and add last workfile path to launch arguments. Existence of last workfile is checked. If workfile does not exists tries to copy templated workfile from predefined path. """ app_groups = {"tvpaint"} launch_types = {LaunchTypes.local} def execute(self): # Pop tvpaint executable executable_path = self.launch_context.launch_args.pop(0) # Pop rest of launch arguments - There should not be other arguments! remainders = [] while self.launch_context.launch_args: remainders.append(self.launch_context.launch_args.pop(0)) new_launch_args = get_openpype_execute_args( "run", self.launch_script_path(), executable_path ) # Append as whole list as these areguments should not be separated self.launch_context.launch_args.append(new_launch_args) if remainders: self.log.warning(( "There are unexpected launch arguments in TVPaint launch. {}" ).format(str(remainders))) self.launch_context.launch_args.extend(remainders) def launch_script_path(self): from openpype.hosts.tvpaint import get_launch_script_path return get_launch_script_path() ================================================ FILE: openpype/hosts/tvpaint/lib.py ================================================ import os import shutil import collections from PIL import Image, ImageDraw def backwards_id_conversion(data_by_layer_id): """Convert layer ids to strings from integers.""" for key in tuple(data_by_layer_id.keys()): if not isinstance(key, str): data_by_layer_id[str(key)] = data_by_layer_id.pop(key) def get_frame_filename_template(frame_end, filename_prefix=None, ext=None): """Get file template with frame key for rendered files. This is simple template contains `{frame}{ext}` for sequential outputs and `single_file{ext}` for single file output. Output is rendered to temporary folder so filename should not matter as integrator change them. """ frame_padding = 4 frame_end_str_len = len(str(frame_end)) if frame_end_str_len > frame_padding: frame_padding = frame_end_str_len ext = ext or ".png" filename_prefix = filename_prefix or "" return "{}{{frame:0>{}}}{}".format(filename_prefix, frame_padding, ext) def get_layer_pos_filename_template(range_end, filename_prefix=None, ext=None): filename_prefix = filename_prefix or "" new_filename_prefix = filename_prefix + "pos_{pos}." return get_frame_filename_template(range_end, new_filename_prefix, ext) def _calculate_pre_behavior_copy( range_start, exposure_frames, pre_beh, layer_frame_start, layer_frame_end, output_idx_by_frame_idx ): """Calculate frames before first exposure frame based on pre behavior. Function may skip whole processing if first exposure frame is before layer's first frame. In that case pre behavior does not make sense. Args: range_start(int): First frame of range which should be rendered. exposure_frames(list): List of all exposure frames on layer. pre_beh(str): Pre behavior of layer (enum of 4 strings). layer_frame_start(int): First frame of layer. layer_frame_end(int): Last frame of layer. output_idx_by_frame_idx(dict): References to already prepared frames and where result will be stored. """ # Check if last layer frame is after range end if layer_frame_start < range_start: return first_exposure_frame = min(exposure_frames) # Skip if last exposure frame is after range end if first_exposure_frame < range_start: return # Calculate frame count of layer frame_count = layer_frame_end - layer_frame_start + 1 if pre_beh == "none": # Just fill all frames from last exposure frame to range end with None for frame_idx in range(range_start, layer_frame_start): output_idx_by_frame_idx[frame_idx] = None elif pre_beh == "hold": # Keep first frame for whole time for frame_idx in range(range_start, layer_frame_start): output_idx_by_frame_idx[frame_idx] = first_exposure_frame elif pre_beh == "repeat": # Loop backwards from last frame of layer for frame_idx in reversed(range(range_start, layer_frame_start)): eq_frame_idx_offset = ( (layer_frame_end - frame_idx) % frame_count ) eq_frame_idx = layer_frame_start + ( layer_frame_end - eq_frame_idx_offset ) output_idx_by_frame_idx[frame_idx] = eq_frame_idx elif pre_beh == "pingpong": half_seq_len = frame_count - 1 seq_len = half_seq_len * 2 for frame_idx in reversed(range(range_start, layer_frame_start)): eq_frame_idx_offset = (layer_frame_start - frame_idx) % seq_len if eq_frame_idx_offset > half_seq_len: eq_frame_idx_offset = (seq_len - eq_frame_idx_offset) eq_frame_idx = layer_frame_start + eq_frame_idx_offset output_idx_by_frame_idx[frame_idx] = eq_frame_idx def _calculate_post_behavior_copy( range_end, exposure_frames, post_beh, layer_frame_start, layer_frame_end, output_idx_by_frame_idx ): """Calculate frames after last frame of layer based on post behavior. Function may skip whole processing if last layer frame is after range_end. In that case post behavior does not make sense. Args: range_end(int): Last frame of range which should be rendered. exposure_frames(list): List of all exposure frames on layer. post_beh(str): Post behavior of layer (enum of 4 strings). layer_frame_start(int): First frame of layer. layer_frame_end(int): Last frame of layer. output_idx_by_frame_idx(dict): References to already prepared frames and where result will be stored. """ # Check if last layer frame is after range end if layer_frame_end >= range_end: return last_exposure_frame = max(exposure_frames) # Skip if last exposure frame is after range end # - this is probably irrelevant with layer frame end check? if last_exposure_frame >= range_end: return # Calculate frame count of layer frame_count = layer_frame_end - layer_frame_start + 1 if post_beh == "none": # Just fill all frames from last exposure frame to range end with None for frame_idx in range(layer_frame_end + 1, range_end + 1): output_idx_by_frame_idx[frame_idx] = None elif post_beh == "hold": # Keep last exposure frame to the end for frame_idx in range(layer_frame_end + 1, range_end + 1): output_idx_by_frame_idx[frame_idx] = last_exposure_frame elif post_beh == "repeat": # Loop backwards from last frame of layer for frame_idx in range(layer_frame_end + 1, range_end + 1): eq_frame_idx = layer_frame_start + (frame_idx % frame_count) output_idx_by_frame_idx[frame_idx] = eq_frame_idx elif post_beh == "pingpong": half_seq_len = frame_count - 1 seq_len = half_seq_len * 2 for frame_idx in range(layer_frame_end + 1, range_end + 1): eq_frame_idx_offset = (frame_idx - layer_frame_end) % seq_len if eq_frame_idx_offset > half_seq_len: eq_frame_idx_offset = seq_len - eq_frame_idx_offset eq_frame_idx = layer_frame_end - eq_frame_idx_offset output_idx_by_frame_idx[frame_idx] = eq_frame_idx def _calculate_in_range_frames( range_start, range_end, exposure_frames, layer_frame_end, output_idx_by_frame_idx ): """Calculate frame references in defined range. Function may skip whole processing if last layer frame is after range_end. In that case post behavior does not make sense. Args: range_start(int): First frame of range which should be rendered. range_end(int): Last frame of range which should be rendered. exposure_frames(list): List of all exposure frames on layer. layer_frame_end(int): Last frame of layer. output_idx_by_frame_idx(dict): References to already prepared frames and where result will be stored. """ # Calculate in range frames in_range_frames = [] for frame_idx in exposure_frames: if range_start <= frame_idx <= range_end: output_idx_by_frame_idx[frame_idx] = frame_idx in_range_frames.append(frame_idx) if in_range_frames: first_in_range_frame = min(in_range_frames) # Calculate frames from first exposure frames to range end or last # frame of layer (post behavior should be calculated since that time) previous_exposure = first_in_range_frame for frame_idx in range(first_in_range_frame, range_end + 1): if frame_idx > layer_frame_end: break if frame_idx in exposure_frames: previous_exposure = frame_idx else: output_idx_by_frame_idx[frame_idx] = previous_exposure # There can be frames before first exposure frame in range # First check if we don't alreade have first range frame filled if range_start in output_idx_by_frame_idx: return first_exposure_frame = max(exposure_frames) last_exposure_frame = max(exposure_frames) # Check if is first exposure frame smaller than defined range # if not then skip if first_exposure_frame >= range_start: return # Check is if last exposure frame is also before range start # in that case we can't use fill frames before out range if last_exposure_frame < range_start: return closest_exposure_frame = first_exposure_frame for frame_idx in exposure_frames: if frame_idx >= range_start: break if frame_idx > closest_exposure_frame: closest_exposure_frame = frame_idx output_idx_by_frame_idx[closest_exposure_frame] = closest_exposure_frame for frame_idx in range(range_start, range_end + 1): if frame_idx in output_idx_by_frame_idx: break output_idx_by_frame_idx[frame_idx] = closest_exposure_frame def _cleanup_frame_references(output_idx_by_frame_idx): """Cleanup frame references to frame reference. Cleanup not direct references to rendered frame. ``` // Example input { 1: 1, 2: 1, 3: 2 } // Result { 1: 1, 2: 1, 3: 1 // Changed reference to final rendered frame } ``` Result is dictionary where keys leads to frame that should be rendered. """ for frame_idx in tuple(output_idx_by_frame_idx.keys()): reference_idx = output_idx_by_frame_idx[frame_idx] # Skip transparent frames if reference_idx is None or reference_idx == frame_idx: continue real_reference_idx = reference_idx _tmp_reference_idx = reference_idx while True: _temp = output_idx_by_frame_idx[_tmp_reference_idx] if _temp == _tmp_reference_idx: real_reference_idx = _tmp_reference_idx break _tmp_reference_idx = _temp if real_reference_idx != reference_idx: output_idx_by_frame_idx[frame_idx] = real_reference_idx def _cleanup_out_range_frames(output_idx_by_frame_idx, range_start, range_end): """Cleanup frame references to frames out of passed range. First available frame in range is used ``` // Example input. Range 2-3 { 1: 1, 2: 1, 3: 1 } // Result { 2: 2, // Redirect to self as is first that reference out range 3: 2 // Redirect to first redirected frame } ``` Result is dictionary where keys leads to frame that should be rendered. """ in_range_frames_by_out_frames = collections.defaultdict(set) out_range_frames = set() for frame_idx in tuple(output_idx_by_frame_idx.keys()): # Skip frames that are already out of range if frame_idx < range_start or frame_idx > range_end: out_range_frames.add(frame_idx) continue reference_idx = output_idx_by_frame_idx[frame_idx] # Skip transparent frames if reference_idx is None: continue # Skip references in range if reference_idx < range_start or reference_idx > range_end: in_range_frames_by_out_frames[reference_idx].add(frame_idx) for reference_idx in tuple(in_range_frames_by_out_frames.keys()): frame_indexes = in_range_frames_by_out_frames.pop(reference_idx) new_reference = None for frame_idx in frame_indexes: if new_reference is None: new_reference = frame_idx output_idx_by_frame_idx[frame_idx] = new_reference # Finally remove out of range frames for frame_idx in out_range_frames: output_idx_by_frame_idx.pop(frame_idx) def calculate_layer_frame_references( range_start, range_end, layer_frame_start, layer_frame_end, exposure_frames, pre_beh, post_beh ): """Calculate frame references for one layer based on it's data. Output is dictionary where key is frame index referencing to rendered frame index. If frame index should be rendered then is referencing to self. ``` // Example output { 1: 1, // Reference to self - will be rendered 2: 1, // Reference to frame 1 - will be copied 3: 1, // Reference to frame 1 - will be copied 4: 4, // Reference to self - will be rendered ... 20: 4 // Reference to frame 4 - will be copied 21: None // Has reference to None - transparent image } ``` Args: range_start(int): First frame of range which should be rendered. range_end(int): Last frame of range which should be rendered. layer_frame_start(int)L First frame of layer. layer_frame_end(int): Last frame of layer. exposure_frames(list): List of all exposure frames on layer. pre_beh(str): Pre behavior of layer (enum of 4 strings). post_beh(str): Post behavior of layer (enum of 4 strings). """ # Output variable output_idx_by_frame_idx = {} # Skip if layer does not have any exposure frames if not exposure_frames: return output_idx_by_frame_idx # First calculate in range frames _calculate_in_range_frames( range_start, range_end, exposure_frames, layer_frame_end, output_idx_by_frame_idx ) # Calculate frames by pre behavior of layer _calculate_pre_behavior_copy( range_start, exposure_frames, pre_beh, layer_frame_start, layer_frame_end, output_idx_by_frame_idx ) # Calculate frames by post behavior of layer _calculate_post_behavior_copy( range_end, exposure_frames, post_beh, layer_frame_start, layer_frame_end, output_idx_by_frame_idx ) # Cleanup of referenced frames _cleanup_frame_references(output_idx_by_frame_idx) # Remove frames out of range _cleanup_out_range_frames(output_idx_by_frame_idx, range_start, range_end) return output_idx_by_frame_idx def calculate_layers_extraction_data( layers_data, exposure_frames_by_layer_id, behavior_by_layer_id, range_start, range_end, skip_not_visible=True, filename_prefix=None, ext=None ): """Calculate extraction data for passed layers data. ``` { : { "frame_references": {...}, "filenames_by_frame_index": {...} }, ... } ``` Frame references contains frame index reference to rendered frame index. Filename by frame index represents filename under which should be frame stored. Directory is not handled here because each usage may need different approach. Args: layers_data(list): Layers data loaded from TVPaint. exposure_frames_by_layer_id(dict): Exposure frames of layers stored by layer id. behavior_by_layer_id(dict): Pre and Post behavior of layers stored by layer id. range_start(int): First frame of rendered range. range_end(int): Last frame of rendered range. skip_not_visible(bool): Skip calculations for hidden layers (Skipped by default). filename_prefix(str): Prefix before filename. ext(str): Extension which filenames will have ('.png' is default). Returns: dict: Prepared data for rendering by layer position. """ # Make sure layer ids are strings # backwards compatibility when layer ids were integers backwards_id_conversion(exposure_frames_by_layer_id) backwards_id_conversion(behavior_by_layer_id) layer_template = get_layer_pos_filename_template( range_end, filename_prefix, ext ) output = {} for layer_data in layers_data: if skip_not_visible and not layer_data["visible"]: continue orig_layer_id = layer_data["layer_id"] layer_id = str(orig_layer_id) # Skip if does not have any exposure frames (empty layer) exposure_frames = exposure_frames_by_layer_id[layer_id] if not exposure_frames: continue layer_position = layer_data["position"] layer_frame_start = layer_data["frame_start"] layer_frame_end = layer_data["frame_end"] layer_behavior = behavior_by_layer_id[layer_id] pre_behavior = layer_behavior["pre"] post_behavior = layer_behavior["post"] frame_references = calculate_layer_frame_references( range_start, range_end, layer_frame_start, layer_frame_end, exposure_frames, pre_behavior, post_behavior ) # All values in 'frame_references' reference to a frame that must be # rendered out frames_to_render = set(frame_references.values()) # Remove 'None' reference (transparent image) if None in frames_to_render: frames_to_render.remove(None) # Skip layer if has nothing to render if not frames_to_render: continue # All filenames that should be as output (not final output) filename_frames = ( set(range(range_start, range_end + 1)) | frames_to_render ) filenames_by_frame_index = {} for frame_idx in filename_frames: filenames_by_frame_index[frame_idx] = layer_template.format( pos=layer_position, frame=frame_idx ) # Store objects under the layer id output[orig_layer_id] = { "frame_references": frame_references, "filenames_by_frame_index": filenames_by_frame_index } return output def create_transparent_image_from_source(src_filepath, dst_filepath): """Create transparent image of same type and size as source image.""" img_obj = Image.open(src_filepath) painter = ImageDraw.Draw(img_obj) painter.rectangle((0, 0, *img_obj.size), fill=(0, 0, 0, 0)) img_obj.save(dst_filepath) def fill_reference_frames(frame_references, filepaths_by_frame): # Store path to first transparent image if there is any for frame_idx, ref_idx in frame_references.items(): # Frame referencing to self should be rendered and used as source # and reference indexes with None can't be filled if ref_idx is None or frame_idx == ref_idx: continue # Get destination filepath src_filepath = filepaths_by_frame[ref_idx] dst_filepath = filepaths_by_frame[frame_idx] if hasattr(os, "link"): os.link(src_filepath, dst_filepath) else: shutil.copy(src_filepath, dst_filepath) def copy_render_file(src_path, dst_path): """Create copy file of an image.""" if hasattr(os, "link"): os.link(src_path, dst_path) else: shutil.copy(src_path, dst_path) def cleanup_rendered_layers(filepaths_by_layer_id): """Delete all files for each individual layer files after compositing.""" # Collect all filepaths from data all_filepaths = [] for filepaths_by_frame in filepaths_by_layer_id.values(): all_filepaths.extend(filepaths_by_frame.values()) # Loop over loop for filepath in set(all_filepaths): if filepath is not None and os.path.exists(filepath): os.remove(filepath) def composite_rendered_layers( layers_data, filepaths_by_layer_id, range_start, range_end, dst_filepaths_by_frame, cleanup=True ): """Composite multiple rendered layers by their position. Result is single frame sequence with transparency matching content created in TVPaint. Missing source filepaths are replaced with transparent images but at least one image must be rendered and exist. Function can be used even if single layer was created to fill transparent filepaths. Args: layers_data(list): Layers data loaded from TVPaint. filepaths_by_layer_id(dict): Rendered filepaths stored by frame index per layer id. Used as source for compositing. range_start(int): First frame of rendered range. range_end(int): Last frame of rendered range. dst_filepaths_by_frame(dict): Output filepaths by frame where final image after compositing will be stored. Path must not clash with source filepaths. cleanup(bool): Remove all source filepaths when done with compositing. """ # Prepare layers by their position # - position tells in which order will compositing happen layer_ids_by_position = {} for layer in layers_data: layer_position = layer["position"] layer_ids_by_position[layer_position] = layer["layer_id"] # Sort layer positions sorted_positions = tuple(reversed(sorted(layer_ids_by_position.keys()))) # Prepare variable where filepaths without any rendered content # - transparent will be created transparent_filepaths = set() # Store first final filepath first_dst_filepath = None for frame_idx in range(range_start, range_end + 1): dst_filepath = dst_filepaths_by_frame[frame_idx] src_filepaths = [] for layer_position in sorted_positions: layer_id = layer_ids_by_position[layer_position] filepaths_by_frame = filepaths_by_layer_id[layer_id] src_filepath = filepaths_by_frame.get(frame_idx) if src_filepath is not None: src_filepaths.append(src_filepath) if not src_filepaths: transparent_filepaths.add(dst_filepath) continue # Store first destination filepath to be used for transparent images if first_dst_filepath is None: first_dst_filepath = dst_filepath if len(src_filepaths) == 1: src_filepath = src_filepaths[0] if cleanup: os.rename(src_filepath, dst_filepath) else: copy_render_file(src_filepath, dst_filepath) else: composite_images(src_filepaths, dst_filepath) # Store first transparent filepath to be able copy it transparent_filepath = None for dst_filepath in transparent_filepaths: if transparent_filepath is None: create_transparent_image_from_source( first_dst_filepath, dst_filepath ) transparent_filepath = dst_filepath else: copy_render_file(transparent_filepath, dst_filepath) # Remove all files that were used as source for compositing if cleanup: cleanup_rendered_layers(filepaths_by_layer_id) def composite_images(input_image_paths, output_filepath): """Composite images in order from passed list. Raises: ValueError: When entered list is empty. """ if not input_image_paths: raise ValueError("Nothing to composite.") img_obj = None for image_filepath in input_image_paths: _img_obj = Image.open(image_filepath) if img_obj is None: img_obj = _img_obj else: img_obj.alpha_composite(_img_obj) img_obj.save(output_filepath) def rename_filepaths_by_frame_start( filepaths_by_frame, range_start, range_end, new_frame_start ): """Change frames in filenames of finished images to new frame start.""" # Calculate frame end new_frame_end = range_end + (new_frame_start - range_start) # Create filename template filename_template = get_frame_filename_template( max(range_end, new_frame_end) ) # Use different ranges based on Mark In and output Frame Start values # - this is to make sure that filename renaming won't affect files that # are not renamed yet if range_start < new_frame_start: source_range = range(range_end, range_start - 1, -1) output_range = range(new_frame_end, new_frame_start - 1, -1) else: # This is less possible situation as frame start will be in most # cases higher than Mark In. source_range = range(range_start, range_end + 1) output_range = range(new_frame_start, new_frame_end + 1) # Skip if source first frame is same as destination first frame new_dst_filepaths = {} for src_frame, dst_frame in zip(source_range, output_range): src_filepath = os.path.normpath(filepaths_by_frame[src_frame]) dirpath, src_filename = os.path.split(src_filepath) dst_filename = filename_template.format(frame=dst_frame) dst_filepath = os.path.join(dirpath, dst_filename) if src_filename != dst_filename: os.rename(src_filepath, dst_filepath) new_dst_filepaths[dst_frame] = dst_filepath return new_dst_filepaths ================================================ FILE: openpype/hosts/tvpaint/plugins/create/convert_legacy.py ================================================ import collections from openpype.pipeline.create.creator_plugins import ( SubsetConvertorPlugin, cache_and_get_instances, ) from openpype.hosts.tvpaint.api.plugin import SHARED_DATA_KEY from openpype.hosts.tvpaint.api.lib import get_groups_data class TVPaintLegacyConverted(SubsetConvertorPlugin): """Conversion of legacy instances in scene to new creators. This convertor handles only instances created by core creators. All instances that would be created using auto-creators are removed as at the moment of finding them would there already be existing instances. """ identifier = "tvpaint.legacy.converter" def find_instances(self): instances_by_identifier = cache_and_get_instances( self, SHARED_DATA_KEY, self.host.list_instances ) if instances_by_identifier[None]: self.add_convertor_item("Convert legacy instances") def convert(self): current_instances = self.host.list_instances() to_convert = collections.defaultdict(list) converted = False for instance in current_instances: if instance.get("creator_identifier") is not None: continue converted = True family = instance.get("family") if family in ( "renderLayer", "renderPass", "renderScene", "review", "workfile", ): to_convert[family].append(instance) else: instance["keep"] = False # Skip if nothing was changed if not converted: self.remove_convertor_item() return self._convert_render_layers( to_convert["renderLayer"], current_instances) self._convert_render_passes( to_convert["renderPass"], current_instances) self._convert_render_scenes( to_convert["renderScene"], current_instances) self._convert_workfiles( to_convert["workfile"], current_instances) self._convert_reviews( to_convert["review"], current_instances) new_instances = [ instance for instance in current_instances if instance.get("keep") is not False ] self.host.write_instances(new_instances) # remove legacy item if all is fine self.remove_convertor_item() def _convert_render_layers(self, render_layers, current_instances): if not render_layers: return # Look for possible existing render layers in scene render_layers_by_group_id = {} for instance in current_instances: if instance.get("creator_identifier") == "render.layer": group_id = instance["creator_identifier"]["group_id"] render_layers_by_group_id[group_id] = instance groups_by_id = { group["group_id"]: group for group in get_groups_data() } for render_layer in render_layers: group_id = render_layer.pop("group_id") # Just remove legacy instance if group is already occupied if group_id in render_layers_by_group_id: render_layer["keep"] = False continue # Add identifier render_layer["creator_identifier"] = "render.layer" # Change 'uuid' to 'instance_id' render_layer["instance_id"] = render_layer.pop("uuid") # Fill creator attributes render_layer["creator_attributes"] = { "group_id": group_id } render_layer["family"] = "render" group = groups_by_id[group_id] # Use group name for variant group["variant"] = group["name"] def _convert_render_passes(self, render_passes, current_instances): if not render_passes: return # Render passes must have available render layers so we look for render # layers first # - '_convert_render_layers' must be called before this method render_layers_by_group_id = {} for instance in current_instances: if instance.get("creator_identifier") == "render.layer": group_id = instance["creator_attributes"]["group_id"] render_layers_by_group_id[group_id] = instance for render_pass in render_passes: group_id = render_pass.pop("group_id") render_layer = render_layers_by_group_id.get(group_id) if not render_layer: render_pass["keep"] = False continue render_pass["creator_identifier"] = "render.pass" render_pass["instance_id"] = render_pass.pop("uuid") render_pass["family"] = "render" render_pass["creator_attributes"] = { "render_layer_instance_id": render_layer["instance_id"] } render_pass["variant"] = render_pass.pop("pass") render_pass.pop("renderlayer") # Rest of instances are just marked for deletion def _convert_render_scenes(self, render_scenes, current_instances): for render_scene in render_scenes: render_scene["keep"] = False def _convert_workfiles(self, workfiles, current_instances): for render_scene in workfiles: render_scene["keep"] = False def _convert_reviews(self, reviews, current_instances): for render_scene in reviews: render_scene["keep"] = False ================================================ FILE: openpype/hosts/tvpaint/plugins/create/create_render.py ================================================ """Render Layer and Passes creators. Render layer is main part which is represented by group in TVPaint. All TVPaint layers marked with that group color are part of the render layer. To be more specific about some parts of layer it is possible to create sub-sets of layer which are named passes. Render pass consist of layers in same color group as render layer but define more specific part. For example render layer could be 'Bob' which consist of 5 TVPaint layers. - Bob has 'head' which consist of 2 TVPaint layers -> Render pass 'head' - Bob has 'body' which consist of 1 TVPaint layer -> Render pass 'body' - Bob has 'arm' which consist of 1 TVPaint layer -> Render pass 'arm' - Last layer does not belong to render pass at all Bob will be rendered as 'beauty' of bob (all visible layers in group). His head will be rendered too but without any other parts. The same for body and arm. What is this good for? Compositing has more power how the renders are used. Can do transforms on each render pass without need to modify a re-render them using TVPaint. The workflow may hit issues when there are used other blending modes than default 'color' blend more. In that case it is not recommended to use this workflow at all as other blend modes may affect all layers in clip which can't be done. There is special case for simple publishing of scene which is called 'render.scene'. That will use all visible layers and render them as one big sequence. Todos: Add option to extract marked layers and passes as json output format for AfterEffects. """ import collections from typing import Any, Optional, Union from openpype import AYON_SERVER_ENABLED from openpype.client import get_asset_by_name, get_asset_name_identifier from openpype.lib import ( prepare_template_data, AbstractAttrDef, UILabelDef, UISeparatorDef, EnumDef, TextDef, BoolDef, ) from openpype.pipeline.create import ( CreatedInstance, CreatorError, ) from openpype.hosts.tvpaint.api.plugin import ( TVPaintCreator, TVPaintAutoCreator, ) from openpype.hosts.tvpaint.api.lib import ( get_layers_data, get_groups_data, execute_george_through_file, ) RENDER_LAYER_DETAILED_DESCRIPTIONS = ( """Render Layer is "a group of TVPaint layers" Be aware Render Layer is not TVPaint layer. All TVPaint layers in the scene with the color group id are rendered in the beauty pass. To create sub passes use Render Pass creator which is dependent on existence of render layer instance. The group can represent an asset (tree) or different part of scene that consist of one or more TVPaint layers that can be used as single item during compositing (for example). In some cases may be needed to have sub parts of the layer. For example 'Bob' could be Render Layer which has 'Arm', 'Head' and 'Body' as Render Passes. """ ) RENDER_PASS_DETAILED_DESCRIPTIONS = ( """Render Pass is sub part of Render Layer. Render Pass can consist of one or more TVPaint layers. Render Pass must belong to a Render Layer. Marked TVPaint layers will change it's group color to match group color of Render Layer. """ ) AUTODETECT_RENDER_DETAILED_DESCRIPTION = ( """Semi-automated Render Layer and Render Pass creation. Based on information in TVPaint scene will be created Render Layers and Render Passes. All color groups used in scene will be used for Render Layer creation. Name of the group is used as a variant. All TVPaint layers under the color group will be created as Render Pass where layer name is used as variant. The plugin will use all used color groups and layers, or can skip those that are not visible. There is option to auto-rename color groups before Render Layer creation. That is based on settings template where is filled index of used group from bottom to top. """ ) class CreateRenderlayer(TVPaintCreator): """Mark layer group as Render layer instance. All TVPaint layers in the scene with the color group id are rendered in the beauty pass. To create sub passes use Render Layer creator which is dependent on existence of render layer instance. """ label = "Render Layer" family = "render" subset_template_family_filter = "renderLayer" identifier = "render.layer" icon = "fa5.images" # George script to change color group rename_script_template = ( "tv_layercolor \"setcolor\"" " {clip_id} {group_id} {r} {g} {b} \"{name}\"" ) # Order to be executed before Render Pass creator order = 90 description = "Mark TVPaint color group as one Render Layer." detailed_description = RENDER_LAYER_DETAILED_DESCRIPTIONS # Settings # - Default render pass name for beauty default_pass_name = "beauty" # - Mark by default instance for review mark_for_review = True def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_render_layer"] ) self.default_variant = plugin_settings["default_variant"] self.default_variants = plugin_settings["default_variants"] self.default_pass_name = plugin_settings["default_pass_name"] self.mark_for_review = plugin_settings["mark_for_review"] def get_dynamic_data( self, variant, task_name, asset_doc, project_name, host_name, instance ): dynamic_data = super().get_dynamic_data( variant, task_name, asset_doc, project_name, host_name, instance ) dynamic_data["renderpass"] = self.default_pass_name dynamic_data["renderlayer"] = variant return dynamic_data def _get_selected_group_ids(self): return { layer["group_id"] for layer in get_layers_data() if layer["selected"] } def create(self, subset_name, instance_data, pre_create_data): self.log.debug("Query data from workfile.") group_name = instance_data["variant"] group_id = pre_create_data.get("group_id") # This creator should run only on one group if group_id is None or group_id == -1: selected_groups = self._get_selected_group_ids() selected_groups.discard(0) if len(selected_groups) > 1: raise CreatorError("You have selected more than one group") if len(selected_groups) == 0: raise CreatorError("You don't have selected any group") group_id = tuple(selected_groups)[0] self.log.debug("Querying groups data from workfile.") groups_data = get_groups_data() group_item = None for group_data in groups_data: if group_data["group_id"] == group_id: group_item = group_data for instance in self.create_context.instances: if ( instance.creator_identifier == self.identifier and instance["creator_attributes"]["group_id"] == group_id ): raise CreatorError(( f"Group \"{group_item.get('name')}\" is already used" f" by another render layer \"{instance['subset']}\"" )) self.log.debug(f"Selected group id is \"{group_id}\".") if "creator_attributes" not in instance_data: instance_data["creator_attributes"] = {} creator_attributes = instance_data["creator_attributes"] mark_for_review = pre_create_data.get("mark_for_review") if mark_for_review is None: mark_for_review = self.mark_for_review creator_attributes["group_id"] = group_id creator_attributes["mark_for_review"] = mark_for_review self.log.info(f"Subset name is {subset_name}") new_instance = CreatedInstance( self.family, subset_name, instance_data, self ) self._store_new_instance(new_instance) if not group_id or group_item["name"] == group_name: return new_instance self.log.debug("Changing name of the group.") # Rename TVPaint group (keep color same) # - groups can't contain spaces rename_script = self.rename_script_template.format( clip_id=group_item["clip_id"], group_id=group_item["group_id"], r=group_item["red"], g=group_item["green"], b=group_item["blue"], name=group_name ) execute_george_through_file(rename_script) self.log.info(( f"Name of group with index {group_id}" f" was changed to \"{group_name}\"." )) return new_instance def _get_groups_enum(self): groups_enum = [] empty_groups = [] for group in get_groups_data(): group_name = group["name"] item = { "label": group_name, "value": group["group_id"] } # TVPaint have defined how many color groups is available, but # the count is not consistent across versions. It is not possible # to know how many groups there is. # if group_name and group_name != "0": if empty_groups: groups_enum.extend(empty_groups) empty_groups = [] groups_enum.append(item) else: empty_groups.append(item) return groups_enum def get_pre_create_attr_defs(self): groups_enum = self._get_groups_enum() groups_enum.insert(0, {"label": "", "value": -1}) return [ EnumDef( "group_id", label="Group", items=groups_enum ), BoolDef( "mark_for_review", label="Review", default=self.mark_for_review ) ] def get_instance_attr_defs(self): groups_enum = self._get_groups_enum() return [ EnumDef( "group_id", label="Group", items=groups_enum ), BoolDef( "mark_for_review", label="Review", default=self.mark_for_review ) ] def update_instances(self, update_list): self._update_color_groups() self._update_renderpass_groups() super().update_instances(update_list) def _update_color_groups(self): render_layer_instances = [] for instance in self.create_context.instances: if instance.creator_identifier == self.identifier: render_layer_instances.append(instance) if not render_layer_instances: return groups_by_id = { group["group_id"]: group for group in get_groups_data() } grg_script_lines = [] for instance in render_layer_instances: group_id = instance["creator_attributes"]["group_id"] variant = instance["variant"] group = groups_by_id[group_id] if group["name"] == variant: continue grg_script_lines.append(self.rename_script_template.format( clip_id=group["clip_id"], group_id=group["group_id"], r=group["red"], g=group["green"], b=group["blue"], name=variant )) if grg_script_lines: execute_george_through_file("\n".join(grg_script_lines)) def _update_renderpass_groups(self): render_layer_instances = {} render_pass_instances = collections.defaultdict(list) for instance in self.create_context.instances: if instance.creator_identifier == CreateRenderPass.identifier: render_layer_id = ( instance["creator_attributes"]["render_layer_instance_id"] ) render_pass_instances[render_layer_id].append(instance) elif instance.creator_identifier == self.identifier: render_layer_instances[instance.id] = instance if not render_pass_instances or not render_layer_instances: return layers_data = get_layers_data() layers_by_name = collections.defaultdict(list) for layer in layers_data: layers_by_name[layer["name"]].append(layer) george_lines = [] for render_layer_id, instances in render_pass_instances.items(): render_layer_inst = render_layer_instances.get(render_layer_id) if render_layer_inst is None: continue group_id = render_layer_inst["creator_attributes"]["group_id"] layer_names = set() for instance in instances: layer_names |= set(instance["layer_names"]) for layer_name in layer_names: george_lines.extend( f"tv_layercolor \"set\" {layer['layer_id']} {group_id}" for layer in layers_by_name[layer_name] if layer["group_id"] != group_id ) if george_lines: execute_george_through_file("\n".join(george_lines)) class CreateRenderPass(TVPaintCreator): family = "render" subset_template_family_filter = "renderPass" identifier = "render.pass" label = "Render Pass" icon = "fa5.image" description = "Mark selected TVPaint layers as pass of Render Layer." detailed_description = RENDER_PASS_DETAILED_DESCRIPTIONS order = CreateRenderlayer.order + 10 # Settings mark_for_review = True def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_render_pass"] ) self.default_variant = plugin_settings["default_variant"] self.default_variants = plugin_settings["default_variants"] self.mark_for_review = plugin_settings["mark_for_review"] def collect_instances(self): instances_by_identifier = self._cache_and_get_instances() render_layers = { instance_data["instance_id"]: { "variant": instance_data["variant"], "template_data": prepare_template_data({ "renderlayer": instance_data["variant"] }) } for instance_data in ( instances_by_identifier[CreateRenderlayer.identifier] ) } for instance_data in instances_by_identifier[self.identifier]: render_layer_instance_id = ( instance_data .get("creator_attributes", {}) .get("render_layer_instance_id") ) render_layer_info = render_layers.get(render_layer_instance_id, {}) self.update_instance_labels( instance_data, render_layer_info.get("variant"), render_layer_info.get("template_data") ) instance = CreatedInstance.from_existing(instance_data, self) self._add_instance_to_context(instance) def get_dynamic_data( self, variant, task_name, asset_doc, project_name, host_name, instance ): dynamic_data = super().get_dynamic_data( variant, task_name, asset_doc, project_name, host_name, instance ) dynamic_data["renderpass"] = variant dynamic_data["renderlayer"] = "{renderlayer}" return dynamic_data def update_instance_labels( self, instance, render_layer_variant, render_layer_data=None ): old_label = instance.get("label") old_group = instance.get("group") new_label = None new_group = None if render_layer_variant is not None: if render_layer_data is None: render_layer_data = prepare_template_data({ "renderlayer": render_layer_variant }) try: new_label = instance["subset"].format(**render_layer_data) except (KeyError, ValueError): pass new_group = f"{self.get_group_label()} ({render_layer_variant})" instance["label"] = new_label instance["group"] = new_group return old_group != new_group or old_label != new_label def create(self, subset_name, instance_data, pre_create_data): render_layer_instance_id = pre_create_data.get( "render_layer_instance_id" ) if not render_layer_instance_id: raise CreatorError(( "You cannot create a Render Pass without a Render Layer." " Please select one first" )) render_layer_instance = self.create_context.instances_by_id.get( render_layer_instance_id ) if render_layer_instance is None: raise CreatorError(( "RenderLayer instance was not found" f" by id \"{render_layer_instance_id}\"" )) group_id = render_layer_instance["creator_attributes"]["group_id"] self.log.debug("Query data from workfile.") layers_data = get_layers_data() self.log.debug("Checking selection.") # Get all selected layers and their group ids marked_layer_names = pre_create_data.get("layer_names") if marked_layer_names is not None: layers_by_name = {layer["name"]: layer for layer in layers_data} marked_layers = [] for layer_name in marked_layer_names: layer = layers_by_name.get(layer_name) if layer is None: raise CreatorError( f"Layer with name \"{layer_name}\" was not found") marked_layers.append(layer) else: marked_layers = [ layer for layer in layers_data if layer["selected"] ] # Raise if nothing is selected if not marked_layers: raise CreatorError( "Nothing is selected. Please select layers.") marked_layer_names = {layer["name"] for layer in marked_layers} marked_layer_names = set(marked_layer_names) instances_to_remove = [] for instance in self.create_context.instances: if instance.creator_identifier != self.identifier: continue cur_layer_names = set(instance["layer_names"]) if not cur_layer_names.intersection(marked_layer_names): continue new_layer_names = cur_layer_names - marked_layer_names if new_layer_names: instance["layer_names"] = list(new_layer_names) else: instances_to_remove.append(instance) render_layer = render_layer_instance["variant"] subset_name_fill_data = {"renderlayer": render_layer} # Format dynamic keys in subset name label = subset_name try: label = label.format( **prepare_template_data(subset_name_fill_data) ) except (KeyError, ValueError): pass self.log.info(f"New subset name is \"{label}\".") instance_data["label"] = label instance_data["group"] = f"{self.get_group_label()} ({render_layer})" instance_data["layer_names"] = list(marked_layer_names) if "creator_attributes" not in instance_data: instance_data["creator_attributes"] = {} creator_attributes = instance_data["creator_attributes"] mark_for_review = pre_create_data.get("mark_for_review") if mark_for_review is None: mark_for_review = self.mark_for_review creator_attributes["mark_for_review"] = mark_for_review creator_attributes["render_layer_instance_id"] = ( render_layer_instance_id ) new_instance = CreatedInstance( self.family, subset_name, instance_data, self ) instances_data = self._remove_and_filter_instances( instances_to_remove ) instances_data.append(new_instance.data_to_store()) self.host.write_instances(instances_data) self._add_instance_to_context(new_instance) self._change_layers_group(marked_layers, group_id) return new_instance def _change_layers_group(self, layers, group_id): filtered_layers = [ layer for layer in layers if layer["group_id"] != group_id ] if filtered_layers: self.log.info(( "Changing group of " f"{','.join([l['name'] for l in filtered_layers])}" f" to {group_id}" )) george_lines = [ f"tv_layercolor \"set\" {layer['layer_id']} {group_id}" for layer in filtered_layers ] execute_george_through_file("\n".join(george_lines)) def _remove_and_filter_instances(self, instances_to_remove): instances_data = self.host.list_instances() if not instances_to_remove: return instances_data removed_ids = set() for instance in instances_to_remove: removed_ids.add(instance.id) self._remove_instance_from_context(instance) return [ instance_data for instance_data in instances_data if instance_data.get("instance_id") not in removed_ids ] def get_pre_create_attr_defs(self): # Find available Render Layers # - instances are created after creators reset current_instances = self.host.list_instances() render_layers = [ { "value": inst["instance_id"], "label": inst["subset"] } for inst in current_instances if inst.get("creator_identifier") == CreateRenderlayer.identifier ] if not render_layers: render_layers.append({"value": None, "label": "N/A"}) return [ EnumDef( "render_layer_instance_id", label="Render Layer", items=render_layers ), UILabelDef( "NOTE: Try to hit refresh if you don't see a Render Layer" ), BoolDef( "mark_for_review", label="Review", default=self.mark_for_review ) ] def get_instance_attr_defs(self): # Find available Render Layers current_instances = self.create_context.instances render_layers = [ { "value": instance.id, "label": instance.label } for instance in current_instances if instance.creator_identifier == CreateRenderlayer.identifier ] if not render_layers: render_layers.append({"value": None, "label": "N/A"}) return [ EnumDef( "render_layer_instance_id", label="Render Layer", items=render_layers ), UILabelDef( "NOTE: Try to hit refresh if you don't see a Render Layer" ), BoolDef( "mark_for_review", label="Review", default=self.mark_for_review ) ] class TVPaintAutoDetectRenderCreator(TVPaintCreator): """Create Render Layer and Render Pass instances based on scene data. This is auto-detection creator which can be triggered by user to create instances based on information in scene. Each used color group in scene will be created as Render Layer where group name is used as variant and each TVPaint layer as Render Pass where layer name is used as variant. Never will have any instances, all instances belong to different creators. """ family = "render" label = "Render Layer/Passes" identifier = "render.auto.detect.creator" order = CreateRenderPass.order + 10 description = ( "Create Render Layers and Render Passes based on scene setup" ) detailed_description = AUTODETECT_RENDER_DETAILED_DESCRIPTION # Settings enabled = False allow_group_rename = True group_name_template = "L{group_index}" group_idx_offset = 10 group_idx_padding = 3 def apply_settings(self, project_settings): plugin_settings = ( project_settings ["tvpaint"] ["create"] ["auto_detect_render"] ) self.enabled = plugin_settings.get("enabled", False) self.allow_group_rename = plugin_settings["allow_group_rename"] self.group_name_template = plugin_settings["group_name_template"] self.group_idx_offset = plugin_settings["group_idx_offset"] self.group_idx_padding = plugin_settings["group_idx_padding"] def _rename_groups( self, groups_order: list[int], scene_groups: list[dict[str, Any]] ): new_group_name_by_id: dict[int, str] = {} groups_by_id: dict[int, dict[str, Any]] = { group["group_id"]: group for group in scene_groups } # Count only renamed groups for idx, group_id in enumerate(groups_order): group_index_value: str = ( "{{:0>{}}}" .format(self.group_idx_padding) .format((idx + 1) * self.group_idx_offset) ) group_name_fill_values: dict[str, str] = { "groupIdx": group_index_value, "groupidx": group_index_value, "group_idx": group_index_value, "group_index": group_index_value, } group_name: str = self.group_name_template.format( **group_name_fill_values ) group: dict[str, Any] = groups_by_id[group_id] if group["name"] != group_name: new_group_name_by_id[group_id] = group_name grg_lines: list[str] = [] for group_id, group_name in new_group_name_by_id.items(): group: dict[str, Any] = groups_by_id[group_id] grg_line: str = "tv_layercolor \"setcolor\" {} {} {} {} {}".format( group["clip_id"], group_id, group["red"], group["green"], group["blue"], group_name ) grg_lines.append(grg_line) group["name"] = group_name if grg_lines: execute_george_through_file("\n".join(grg_lines)) def _prepare_render_layer( self, project_name: str, asset_doc: dict[str, Any], task_name: str, group_id: int, groups: list[dict[str, Any]], mark_for_review: bool, existing_instance: Optional[CreatedInstance] = None, ) -> Union[CreatedInstance, None]: match_group: Union[dict[str, Any], None] = next( ( group for group in groups if group["group_id"] == group_id ), None ) if not match_group: return None variant: str = match_group["name"] creator: CreateRenderlayer = ( self.create_context.creators[CreateRenderlayer.identifier] ) subset_name: str = creator.get_subset_name( variant, task_name, asset_doc, project_name, host_name=self.create_context.host_name, ) asset_name = get_asset_name_identifier(asset_doc) if existing_instance is not None: if AYON_SERVER_ENABLED: existing_instance["folderPath"] = asset_name else: existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name return existing_instance instance_data: dict[str, str] = { "task": task_name, "family": creator.family, "variant": variant } if AYON_SERVER_ENABLED: instance_data["folderPath"] = asset_name else: instance_data["asset"] = asset_name pre_create_data: dict[str, str] = { "group_id": group_id, "mark_for_review": mark_for_review } return creator.create(subset_name, instance_data, pre_create_data) def _prepare_render_passes( self, project_name: str, asset_doc: dict[str, Any], task_name: str, render_layer_instance: CreatedInstance, layers: list[dict[str, Any]], mark_for_review: bool, existing_render_passes: list[CreatedInstance] ): creator: CreateRenderPass = ( self.create_context.creators[CreateRenderPass.identifier] ) render_pass_by_layer_name = {} for render_pass in existing_render_passes: for layer_name in render_pass["layer_names"]: render_pass_by_layer_name[layer_name] = render_pass asset_name = get_asset_name_identifier(asset_doc) for layer in layers: layer_name = layer["name"] variant = layer_name render_pass = render_pass_by_layer_name.get(layer_name) if render_pass is not None: if (render_pass["layer_names"]) > 1: variant = render_pass["variant"] subset_name = creator.get_subset_name( variant, task_name, asset_doc, project_name, host_name=self.create_context.host_name, instance=render_pass ) if render_pass is not None: if AYON_SERVER_ENABLED: render_pass["folderPath"] = asset_name else: render_pass["asset"] = asset_name render_pass["task"] = task_name render_pass["subset"] = subset_name continue instance_data: dict[str, str] = { "task": task_name, "family": creator.family, "variant": variant } if AYON_SERVER_ENABLED: instance_data["folderPath"] = asset_name else: instance_data["asset"] = asset_name pre_create_data: dict[str, Any] = { "render_layer_instance_id": render_layer_instance.id, "layer_names": [layer_name], "mark_for_review": mark_for_review } creator.create(subset_name, instance_data, pre_create_data) def _filter_groups( self, layers_by_group_id, groups_order, only_visible_groups ): new_groups_order = [] for group_id in groups_order: layers: list[dict[str, Any]] = layers_by_group_id[group_id] if not layers: continue if ( only_visible_groups and not any( layer for layer in layers if layer["visible"] ) ): continue new_groups_order.append(group_id) return new_groups_order def create(self, subset_name, instance_data, pre_create_data): project_name: str = self.create_context.get_current_project_name() if AYON_SERVER_ENABLED: asset_name: str = instance_data["folderPath"] else: asset_name: str = instance_data["asset"] task_name: str = instance_data["task"] asset_doc: dict[str, Any] = get_asset_by_name( project_name, asset_name) render_layers_by_group_id: dict[int, CreatedInstance] = {} render_passes_by_render_layer_id: dict[int, list[CreatedInstance]] = ( collections.defaultdict(list) ) for instance in self.create_context.instances: if instance.creator_identifier == CreateRenderlayer.identifier: group_id = instance["creator_attributes"]["group_id"] render_layers_by_group_id[group_id] = instance elif instance.creator_identifier == CreateRenderPass.identifier: render_layer_id = ( instance ["creator_attributes"] ["render_layer_instance_id"] ) render_passes_by_render_layer_id[render_layer_id].append( instance ) layers_by_group_id: dict[int, list[dict[str, Any]]] = ( collections.defaultdict(list) ) scene_layers: list[dict[str, Any]] = get_layers_data() scene_groups: list[dict[str, Any]] = get_groups_data() groups_order: list[int] = [] for layer in scene_layers: group_id: int = layer["group_id"] # Skip 'default' group if group_id == 0: continue layers_by_group_id[group_id].append(layer) if group_id not in groups_order: groups_order.append(group_id) groups_order.reverse() mark_layers_for_review = pre_create_data.get( "mark_layers_for_review", False ) mark_passes_for_review = pre_create_data.get( "mark_passes_for_review", False ) rename_groups = pre_create_data.get("rename_groups", False) only_visible_groups = pre_create_data.get("only_visible_groups", False) groups_order = self._filter_groups( layers_by_group_id, groups_order, only_visible_groups ) if not groups_order: return if rename_groups: self._rename_groups(groups_order, scene_groups) # Make sure all render layers are created for group_id in groups_order: instance: Union[CreatedInstance, None] = ( self._prepare_render_layer( project_name, asset_doc, task_name, group_id, scene_groups, mark_layers_for_review, render_layers_by_group_id.get(group_id), ) ) if instance is not None: render_layers_by_group_id[group_id] = instance for group_id in groups_order: layers: list[dict[str, Any]] = layers_by_group_id[group_id] render_layer_instance: Union[CreatedInstance, None] = ( render_layers_by_group_id.get(group_id) ) if not layers or render_layer_instance is None: continue self._prepare_render_passes( project_name, asset_doc, task_name, render_layer_instance, layers, mark_passes_for_review, render_passes_by_render_layer_id[render_layer_instance.id] ) def get_pre_create_attr_defs(self) -> list[AbstractAttrDef]: render_layer_creator: CreateRenderlayer = ( self.create_context.creators[CreateRenderlayer.identifier] ) render_pass_creator: CreateRenderPass = ( self.create_context.creators[CreateRenderPass.identifier] ) output = [] if self.allow_group_rename: output.extend([ BoolDef( "rename_groups", label="Rename color groups", tooltip="Will rename color groups using studio template", default=True ), BoolDef( "only_visible_groups", label="Only visible color groups", tooltip=( "Render Layers and rename will happen only on color" " groups with visible layers." ), default=True ), UISeparatorDef() ]) output.extend([ BoolDef( "mark_layers_for_review", label="Mark RenderLayers for review", default=render_layer_creator.mark_for_review ), BoolDef( "mark_passes_for_review", label="Mark RenderPasses for review", default=render_pass_creator.mark_for_review ) ]) return output class TVPaintSceneRenderCreator(TVPaintAutoCreator): family = "render" subset_template_family_filter = "renderScene" identifier = "render.scene" label = "Scene Render" icon = "fa.file-image-o" # Settings default_pass_name = "beauty" mark_for_review = True active_on_create = False def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_render_scene"] ) self.default_variant = plugin_settings["default_variant"] self.default_variants = plugin_settings["default_variants"] self.mark_for_review = plugin_settings["mark_for_review"] self.active_on_create = plugin_settings["active_on_create"] self.default_pass_name = plugin_settings["default_pass_name"] def get_dynamic_data(self, variant, *args, **kwargs): dynamic_data = super().get_dynamic_data(variant, *args, **kwargs) dynamic_data["renderpass"] = "{renderpass}" dynamic_data["renderlayer"] = variant return dynamic_data def _create_new_instance(self): create_context = self.create_context host_name = create_context.host_name project_name = create_context.get_current_project_name() asset_name = create_context.get_current_asset_name() task_name = create_context.get_current_task_name() asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, project_name, host_name ) data = { "task": task_name, "variant": self.default_variant, "creator_attributes": { "render_pass_name": self.default_pass_name, "mark_for_review": True }, "label": self._get_label( subset_name, self.default_pass_name ) } if AYON_SERVER_ENABLED: data["folderPath"] = asset_name else: data["asset"] = asset_name if not self.active_on_create: data["active"] = False new_instance = CreatedInstance( self.family, subset_name, data, self ) instances_data = self.host.list_instances() instances_data.append(new_instance.data_to_store()) self.host.write_instances(instances_data) self._add_instance_to_context(new_instance) return new_instance def create(self): existing_instance = None for instance in self.create_context.instances: if instance.creator_identifier == self.identifier: existing_instance = instance break if existing_instance is None: return self._create_new_instance() create_context = self.create_context host_name = create_context.host_name project_name = create_context.get_current_project_name() asset_name = create_context.get_current_asset_name() task_name = create_context.get_current_task_name() existing_name = None if AYON_SERVER_ENABLED: existing_name = existing_instance.get("folderPath") if existing_name is None: existing_name = existing_instance["asset"] if ( existing_name != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( existing_instance["variant"], task_name, asset_doc, project_name, host_name, existing_instance ) if AYON_SERVER_ENABLED: existing_instance["folderPath"] = asset_name else: existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name existing_instance["label"] = self._get_label( existing_instance["subset"], existing_instance["creator_attributes"]["render_pass_name"] ) def _get_label(self, subset_name, render_pass_name): try: subset_name = subset_name.format(**prepare_template_data({ "renderpass": render_pass_name })) except (KeyError, ValueError): pass return subset_name def get_instance_attr_defs(self): return [ TextDef( "render_pass_name", label="Pass Name", default=self.default_pass_name, tooltip=( "Value is calculated during publishing and UI will update" " label after refresh." ) ), BoolDef( "mark_for_review", label="Review", default=self.mark_for_review ) ] ================================================ FILE: openpype/hosts/tvpaint/plugins/create/create_review.py ================================================ from openpype import AYON_SERVER_ENABLED from openpype.client import get_asset_by_name from openpype.pipeline import CreatedInstance from openpype.hosts.tvpaint.api.plugin import TVPaintAutoCreator class TVPaintReviewCreator(TVPaintAutoCreator): family = "review" identifier = "scene.review" label = "Review" icon = "ei.video" # Settings active_on_create = True def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_review"] ) self.default_variant = plugin_settings["default_variant"] self.default_variants = plugin_settings["default_variants"] self.active_on_create = plugin_settings["active_on_create"] def create(self): existing_instance = None for instance in self.create_context.instances: if instance.creator_identifier == self.identifier: existing_instance = instance break create_context = self.create_context host_name = create_context.host_name project_name = create_context.get_current_project_name() asset_name = create_context.get_current_asset_name() task_name = create_context.get_current_task_name() if existing_instance is None: existing_asset_name = None elif AYON_SERVER_ENABLED: existing_asset_name = existing_instance["folderPath"] else: existing_asset_name = existing_instance["asset"] if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, project_name, host_name ) data = { "task": task_name, "variant": self.default_variant } if AYON_SERVER_ENABLED: data["folderPath"] = asset_name else: data["asset"] = asset_name if not self.active_on_create: data["active"] = False new_instance = CreatedInstance( self.family, subset_name, data, self ) instances_data = self.host.list_instances() instances_data.append(new_instance.data_to_store()) self.host.write_instances(instances_data) self._add_instance_to_context(new_instance) elif ( existing_asset_name != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( existing_instance["variant"], task_name, asset_doc, project_name, host_name, existing_instance ) if AYON_SERVER_ENABLED: existing_instance["folderPath"] = asset_name else: existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name ================================================ FILE: openpype/hosts/tvpaint/plugins/create/create_workfile.py ================================================ from openpype import AYON_SERVER_ENABLED from openpype.client import get_asset_by_name from openpype.pipeline import CreatedInstance from openpype.hosts.tvpaint.api.plugin import TVPaintAutoCreator class TVPaintWorkfileCreator(TVPaintAutoCreator): family = "workfile" identifier = "workfile" label = "Workfile" icon = "fa.file-o" def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_workfile"] ) self.default_variant = plugin_settings["default_variant"] self.default_variants = plugin_settings["default_variants"] def create(self): existing_instance = None for instance in self.create_context.instances: if instance.creator_identifier == self.identifier: existing_instance = instance break create_context = self.create_context host_name = create_context.host_name project_name = create_context.get_current_project_name() asset_name = create_context.get_current_asset_name() task_name = create_context.get_current_task_name() if existing_instance is None: existing_asset_name = None elif AYON_SERVER_ENABLED: existing_asset_name = existing_instance["folderPath"] else: existing_asset_name = existing_instance["asset"] if existing_instance is None: asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( self.default_variant, task_name, asset_doc, project_name, host_name ) data = { "task": task_name, "variant": self.default_variant } if AYON_SERVER_ENABLED: data["folderPath"] = asset_name else: data["asset"] = asset_name new_instance = CreatedInstance( self.family, subset_name, data, self ) instances_data = self.host.list_instances() instances_data.append(new_instance.data_to_store()) self.host.write_instances(instances_data) self._add_instance_to_context(new_instance) elif ( existing_asset_name != asset_name or existing_instance["task"] != task_name ): asset_doc = get_asset_by_name(project_name, asset_name) subset_name = self.get_subset_name( existing_instance["variant"], task_name, asset_doc, project_name, host_name, existing_instance ) if AYON_SERVER_ENABLED: existing_instance["folderPath"] = asset_name else: existing_instance["asset"] = asset_name existing_instance["task"] = task_name existing_instance["subset"] = subset_name ================================================ FILE: openpype/hosts/tvpaint/plugins/load/load_image.py ================================================ from openpype.lib.attribute_definitions import BoolDef from openpype.hosts.tvpaint.api import plugin from openpype.hosts.tvpaint.api.lib import execute_george_through_file class ImportImage(plugin.Loader): """Load image or image sequence to TVPaint as new layer.""" families = ["render", "image", "background", "plate", "review"] representations = ["*"] label = "Import Image" order = 1 icon = "image" color = "white" import_script = ( "filepath = \"{}\"\n" "layer_name = \"{}\"\n" "tv_loadsequence filepath {}PARSE layer_id\n" "tv_layerrename layer_id layer_name" ) defaults = { "stretch": True, "timestretch": True, "preload": True } @classmethod def get_options(cls, contexts): return [ BoolDef( "stretch", label="Stretch to project size", default=cls.defaults["stretch"], tooltip="Stretch loaded image/s to project resolution?" ), BoolDef( "timestretch", label="Stretch to timeline length", default=cls.defaults["timestretch"], tooltip="Clip loaded image/s to timeline length?" ), BoolDef( "preload", label="Preload loaded image/s", default=cls.defaults["preload"], tooltip="Preload image/s?" ) ] def load(self, context, name, namespace, options): stretch = options.get("stretch", self.defaults["stretch"]) timestretch = options.get("timestretch", self.defaults["timestretch"]) preload = options.get("preload", self.defaults["preload"]) load_options = [] if stretch: load_options.append("\"STRETCH\"") if timestretch: load_options.append("\"TIMESTRETCH\"") if preload: load_options.append("\"PRELOAD\"") load_options_str = "" for load_option in load_options: load_options_str += (load_option + " ") # Prepare layer name asset_name = context["asset"]["name"] version_name = context["version"]["name"] layer_name = "{}_{}_v{:0>3}".format( asset_name, name, version_name ) # Fill import script with filename and layer name # - filename mus not contain backwards slashes path = self.filepath_from_context(context).replace("\\", "/") george_script = self.import_script.format( path, layer_name, load_options_str ) return execute_george_through_file(george_script) ================================================ FILE: openpype/hosts/tvpaint/plugins/load/load_reference_image.py ================================================ import collections from openpype.lib.attribute_definitions import BoolDef from openpype.pipeline import ( get_representation_context, register_host, ) from openpype.hosts.tvpaint.api import plugin from openpype.hosts.tvpaint.api.lib import ( get_layers_data, execute_george_through_file, ) from openpype.hosts.tvpaint.api.pipeline import ( write_workfile_metadata, SECTION_NAME_CONTAINERS, containerise, ) class LoadImage(plugin.Loader): """Load image or image sequence to TVPaint as new layer.""" families = ["render", "image", "background", "plate", "review"] representations = ["*"] label = "Load Image" order = 1 icon = "image" color = "white" import_script = ( "filepath = '\"'\"{}\"'\"'\n" "layer_name = \"{}\"\n" "tv_loadsequence filepath {}PARSE layer_id\n" "tv_layerrename layer_id layer_name" ) defaults = { "stretch": True, "timestretch": True, "preload": True } @classmethod def get_options(cls, contexts): return [ BoolDef( "stretch", label="Stretch to project size", default=cls.defaults["stretch"], tooltip="Stretch loaded image/s to project resolution?" ), BoolDef( "timestretch", label="Stretch to timeline length", default=cls.defaults["timestretch"], tooltip="Clip loaded image/s to timeline length?" ), BoolDef( "preload", label="Preload loaded image/s", default=cls.defaults["preload"], tooltip="Preload image/s?" ) ] def load(self, context, name, namespace, options): stretch = options.get("stretch", self.defaults["stretch"]) timestretch = options.get("timestretch", self.defaults["timestretch"]) preload = options.get("preload", self.defaults["preload"]) load_options = [] if stretch: load_options.append("\"STRETCH\"") if timestretch: load_options.append("\"TIMESTRETCH\"") if preload: load_options.append("\"PRELOAD\"") load_options_str = "" for load_option in load_options: load_options_str += (load_option + " ") # Prepare layer name asset_name = context["asset"]["name"] subset_name = context["subset"]["name"] layer_name = self.get_unique_layer_name(asset_name, subset_name) path = self.filepath_from_context(context) # Fill import script with filename and layer name # - filename mus not contain backwards slashes george_script = self.import_script.format( path.replace("\\", "/"), layer_name, load_options_str ) execute_george_through_file(george_script) loaded_layer = None layers = get_layers_data() for layer in layers: if layer["name"] == layer_name: loaded_layer = layer break if loaded_layer is None: raise AssertionError( "Loading probably failed during execution of george script." ) layer_names = [loaded_layer["name"]] namespace = namespace or layer_name return containerise( name=name, namespace=namespace, members=layer_names, context=context, loader=self.__class__.__name__ ) def _remove_layers(self, layer_names=None, layer_ids=None, layers=None): if not layer_names and not layer_ids: self.log.warning("Got empty layer names list.") return if layers is None: layers = get_layers_data() available_ids = set(layer["layer_id"] for layer in layers) if layer_ids is None: # Backwards compatibility (layer ids were stored instead of names) layer_names_are_ids = True for layer_name in layer_names: if ( not isinstance(layer_name, int) and not layer_name.isnumeric() ): layer_names_are_ids = False break if layer_names_are_ids: layer_ids = layer_names layer_ids_to_remove = [] if layer_ids is not None: for layer_id in layer_ids: if layer_id in available_ids: layer_ids_to_remove.append(layer_id) else: layers_by_name = collections.defaultdict(list) for layer in layers: layers_by_name[layer["name"]].append(layer) for layer_name in layer_names: layers = layers_by_name[layer_name] if len(layers) == 1: layer_ids_to_remove.append(layers[0]["layer_id"]) if not layer_ids_to_remove: self.log.warning("No layers to delete.") return george_script_lines = [] for layer_id in layer_ids_to_remove: line = "tv_layerkill {}".format(layer_id) george_script_lines.append(line) george_script = "\n".join(george_script_lines) execute_george_through_file(george_script) def _remove_container(self, container): if not container: return representation = container["representation"] members = self.get_members_from_container(container) host = register_host() current_containers = host.get_containers() pop_idx = None for idx, cur_con in enumerate(current_containers): cur_members = self.get_members_from_container(cur_con) if ( cur_members == members and cur_con["representation"] == representation ): pop_idx = idx break if pop_idx is None: self.log.warning( "Didn't find container in workfile containers. {}".format( container ) ) return current_containers.pop(pop_idx) write_workfile_metadata( SECTION_NAME_CONTAINERS, current_containers ) def remove(self, container): members = self.get_members_from_container(container) self.log.warning("Layers to delete {}".format(members)) self._remove_layers(members) self._remove_container(container) def switch(self, container, representation): self.update(container, representation) def update(self, container, representation): """Replace container with different version. New layers are loaded as first step. Then is tried to change data in new layers with data from old layers. When that is done old layers are removed. """ # Create new containers first context = get_representation_context(representation) # Get layer ids from previous container old_layer_names = self.get_members_from_container(container) # Backwards compatibility (layer ids were stored instead of names) old_layers_are_ids = True for name in old_layer_names: if isinstance(name, int) or name.isnumeric(): continue old_layers_are_ids = False break old_layers = [] layers = get_layers_data() previous_layer_ids = set(layer["layer_id"] for layer in layers) if old_layers_are_ids: for layer in layers: if layer["layer_id"] in old_layer_names: old_layers.append(layer) else: layers_by_name = collections.defaultdict(list) for layer in layers: layers_by_name[layer["name"]].append(layer) for layer_name in old_layer_names: layers = layers_by_name[layer_name] if len(layers) == 1: old_layers.append(layers[0]) # Prepare few data new_start_position = None new_group_id = None layer_ids_to_remove = set() for layer in old_layers: layer_ids_to_remove.add(layer["layer_id"]) position = layer["position"] group_id = layer["group_id"] if new_start_position is None: new_start_position = position elif new_start_position > position: new_start_position = position if new_group_id is None: new_group_id = group_id elif new_group_id < 0: continue elif new_group_id != group_id: new_group_id = -1 # Remove old container self._remove_container(container) # Remove old layers self._remove_layers(layer_ids=layer_ids_to_remove) name = container["name"] namespace = container["namespace"] new_container = self.load(context, name, namespace, {}) new_layer_names = self.get_members_from_container(new_container) layers = get_layers_data() new_layers = [] for layer in layers: if layer["layer_id"] in previous_layer_ids: continue if layer["name"] in new_layer_names: new_layers.append(layer) george_script_lines = [] # Group new layers to same group as previous container layers had # - all old layers must be under same group if new_group_id is not None and new_group_id > 0: for layer in new_layers: line = "tv_layercolor \"set\" {} {}".format( layer["layer_id"], new_group_id ) george_script_lines.append(line) # Rename new layer to have same name # - only if both old and new have one layer if len(old_layers) == 1 and len(new_layers) == 1: layer_name = old_layers[0]["name"] george_script_lines.append( "tv_layerrename {} \"{}\"".format( new_layers[0]["layer_id"], layer_name ) ) # Change position of new layer # - this must be done before remove old layers if len(new_layers) == 1 and new_start_position is not None: new_layer = new_layers[0] george_script_lines.extend([ "tv_layerset {}".format(new_layer["layer_id"]), "tv_layermove {}".format(new_start_position) ]) # Execute george scripts if there are any if george_script_lines: george_script = "\n".join(george_script_lines) execute_george_through_file(george_script) ================================================ FILE: openpype/hosts/tvpaint/plugins/load/load_sound.py ================================================ import os import tempfile from openpype.hosts.tvpaint.api import plugin from openpype.hosts.tvpaint.api.lib import ( execute_george_through_file, ) class ImportSound(plugin.Loader): """Load sound to TVPaint. Sound layers does not have ids but only position index so we can't reference them as we can't say which is which input. We might do that (in future) by input path. Which may be identifier if we'll allow only one loaded instance of the representation as an audio. This plugin does not work for all version of TVPaint. Known working version is TVPaint 11.0.10 . It is allowed to load video files as sound but it does not check if video file contain any audio. """ families = ["audio", "review", "plate"] representations = ["*"] label = "Import Sound" order = 1 icon = "image" color = "white" import_script_lines = ( "sound_path = '\"'\"{}\"'\"'", "output_path = \"{}\"", # Try to get sound clip info to check if we are in TVPaint that can # load sound "tv_clipcurrentid", "clip_id = result", "tv_soundclipinfo clip_id 0", "IF CMP(result,\"\")==1", ( "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"'" " 'success|'" ), "EXIT", "END", "tv_soundclipnew sound_path", "line = 'success|'result", "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' line" ) def load(self, context, name, namespace, options): # Create temp file for output output_file = tempfile.NamedTemporaryFile( mode="w", prefix="pype_tvp_", suffix=".txt", delete=False ) output_file.close() output_filepath = output_file.name.replace("\\", "/") # Prepare george script path = self.filepath_from_context(context).replace("\\", "/") import_script = "\n".join(self.import_script_lines) george_script = import_script.format( path, output_filepath ) self.log.info("*** George script:\n{}\n***".format(george_script)) # Execute geoge script execute_george_through_file(george_script) # Read output file lines = [] with open(output_filepath, "r") as file_stream: for line in file_stream: line = line.rstrip() if line: lines.append(line) # Clean up temp file os.remove(output_filepath) output = {} for line in lines: key, value = line.split("|") output[key] = value success = output.get("success") # Successfully loaded sound if success == "0": return if success == "": raise ValueError( "Your TVPaint version does not support loading of" " sound through George script. Please use manual load." ) if success is None: raise ValueError( "Unknown error happened during load." " Please report and try to use manual load." ) # Possible errors by TVPaint documentation # https://www.tvpaint.com/doc/tvpaint-animation-11/george-commands#tv_soundclipnew if success == "-1": raise ValueError( "BUG: George command did not get enough arguments." ) if success == "-2": # Who know what does that mean? raise ValueError("No current clip without mixer.") if success == "-3": raise ValueError("TVPaint couldn't read the file.") if success == "-4": raise ValueError("TVPaint couldn't add the track.") raise ValueError("BUG: Unknown success value {}.".format(success)) ================================================ FILE: openpype/hosts/tvpaint/plugins/load/load_workfile.py ================================================ import os from openpype.lib import StringTemplate from openpype.pipeline import ( registered_host, get_current_context, Anatomy, ) from openpype.pipeline.workfile import ( get_workfile_template_key_from_context, get_last_workfile_with_version, ) from openpype.pipeline.template_data import get_template_data_with_names from openpype.hosts.tvpaint.api import plugin from openpype.hosts.tvpaint.api.lib import ( execute_george_through_file, ) from openpype.hosts.tvpaint.api.pipeline import ( get_current_workfile_context, ) from openpype.pipeline.version_start import get_versioning_start class LoadWorkfile(plugin.Loader): """Load workfile.""" families = ["workfile"] representations = ["tvpp"] label = "Load Workfile" def load(self, context, name, namespace, options): # Load context of current workfile as first thing # - which context and extension has filepath = self.filepath_from_context(context) filepath = filepath.replace("\\", "/") if not os.path.exists(filepath): raise FileExistsError( "The loaded file does not exist. Try downloading it first." ) host = registered_host() current_file = host.get_current_workfile() work_context = get_current_workfile_context() george_script = "tv_LoadProject '\"'\"{}\"'\"'".format( filepath ) execute_george_through_file(george_script) # Save workfile. host_name = "tvpaint" project_name = work_context.get("project") asset_name = work_context.get("asset") task_name = work_context.get("task") # Far cases when there is workfile without work_context if not asset_name: context = get_current_context() project_name = context["project_name"] asset_name = context["asset_name"] task_name = context["task_name"] template_key = get_workfile_template_key_from_context( asset_name, task_name, host_name, project_name=project_name ) anatomy = Anatomy(project_name) data = get_template_data_with_names( project_name, asset_name, task_name, host_name ) data["root"] = anatomy.roots file_template = anatomy.templates[template_key]["file"] # Define saving file extension extensions = host.get_workfile_extensions() if current_file: # Match the extension of current file _, extension = os.path.splitext(current_file) else: # Fall back to the first extension supported for this host. extension = extensions[0] data["ext"] = extension folder_template = anatomy.templates[template_key]["folder"] work_root = StringTemplate.format_strict_template( folder_template, data ) version = get_last_workfile_with_version( work_root, file_template, data, extensions )[1] if version is None: version = get_versioning_start( project_name, "tvpaint", task_name=task_name, task_type=data["task"]["type"], family="workfile" ) else: version += 1 data["version"] = version filename = StringTemplate.format_strict_template( file_template, data ) path = os.path.join(work_root, filename) host.save_workfile(path) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py ================================================ import pyblish.api class CollectOutputFrameRange(pyblish.api.InstancePlugin): """Collect frame start/end from context. When instances are collected context does not contain `frameStart` and `frameEnd` keys yet. They are collected in global plugin `CollectContextEntities`. """ label = "Collect output frame range" order = pyblish.api.CollectorOrder + 0.4999 hosts = ["tvpaint"] families = ["review", "render"] def process(self, instance): asset_doc = instance.data.get("assetEntity") if not asset_doc: return context = instance.context frame_start = asset_doc["data"]["frameStart"] fps = asset_doc["data"]["fps"] frame_end = frame_start + ( context.data["sceneMarkOut"] - context.data["sceneMarkIn"] ) instance.data["fps"] = fps instance.data["frameStart"] = frame_start instance.data["frameEnd"] = frame_end self.log.info( "Set frames {}-{} on instance {} ".format( frame_start, frame_end, instance.data["subset"] ) ) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/collect_render_instances.py ================================================ import copy import pyblish.api from openpype.lib import prepare_template_data class CollectRenderInstances(pyblish.api.InstancePlugin): label = "Collect Render Instances" order = pyblish.api.CollectorOrder - 0.4 hosts = ["tvpaint"] families = ["render", "review"] ignore_render_pass_transparency = False def process(self, instance): context = instance.context creator_identifier = instance.data["creator_identifier"] if creator_identifier == "render.layer": self._collect_data_for_render_layer(instance) elif creator_identifier == "render.pass": self._collect_data_for_render_pass(instance) elif creator_identifier == "render.scene": self._collect_data_for_render_scene(instance) else: if creator_identifier == "scene.review": self._collect_data_for_review(instance) return subset_name = instance.data["subset"] instance.data["name"] = subset_name instance.data["label"] = "{} [{}-{}]".format( subset_name, context.data["sceneMarkIn"] + 1, context.data["sceneMarkOut"] + 1 ) def _collect_data_for_render_layer(self, instance): instance.data["families"].append("renderLayer") creator_attributes = instance.data["creator_attributes"] group_id = creator_attributes["group_id"] if creator_attributes["mark_for_review"]: instance.data["families"].append("review") layers_data = instance.context.data["layersData"] instance.data["layers"] = [ copy.deepcopy(layer) for layer in layers_data if layer["group_id"] == group_id ] def _collect_data_for_render_pass(self, instance): instance.data["families"].append("renderPass") layer_names = set(instance.data["layer_names"]) layers_data = instance.context.data["layersData"] creator_attributes = instance.data["creator_attributes"] if creator_attributes["mark_for_review"]: instance.data["families"].append("review") instance.data["layers"] = [ copy.deepcopy(layer) for layer in layers_data if layer["name"] in layer_names ] instance.data["ignoreLayersTransparency"] = ( self.ignore_render_pass_transparency ) render_layer_data = None render_layer_id = creator_attributes["render_layer_instance_id"] for in_data in instance.context.data["workfileInstances"]: if ( in_data.get("creator_identifier") == "render.layer" and in_data["instance_id"] == render_layer_id ): render_layer_data = in_data break instance.data["renderLayerData"] = copy.deepcopy(render_layer_data) # Invalid state if render_layer_data is None: return render_layer_name = render_layer_data["variant"] subset_name = instance.data["subset"] instance.data["subset"] = subset_name.format( **prepare_template_data({"renderlayer": render_layer_name}) ) def _collect_data_for_render_scene(self, instance): instance.data["families"].append("renderScene") creator_attributes = instance.data["creator_attributes"] if creator_attributes["mark_for_review"]: instance.data["families"].append("review") instance.data["layers"] = copy.deepcopy( instance.context.data["layersData"] ) render_pass_name = ( instance.data["creator_attributes"]["render_pass_name"] ) subset_name = instance.data["subset"] instance.data["subset"] = subset_name.format( **prepare_template_data({"renderpass": render_pass_name}) ) def _collect_data_for_review(self, instance): instance.data["layers"] = copy.deepcopy( instance.context.data["layersData"] ) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/collect_workfile.py ================================================ import os import json import pyblish.api class CollectWorkfile(pyblish.api.InstancePlugin): label = "Collect Workfile" order = pyblish.api.CollectorOrder - 0.4 hosts = ["tvpaint"] families = ["workfile"] def process(self, instance): context = instance.context current_file = context.data["currentFile"] self.log.info( "Workfile path used for workfile family: {}".format(current_file) ) dirpath, filename = os.path.split(current_file) basename, ext = os.path.splitext(filename) instance.data["representations"].append({ "name": ext.lstrip("."), "ext": ext.lstrip("."), "files": filename, "stagingDir": dirpath }) self.log.info("Collected workfile instance: {}".format( json.dumps(instance.data, indent=4) )) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py ================================================ import os import json import tempfile import pyblish.api from openpype.pipeline import legacy_io from openpype.hosts.tvpaint.api.lib import ( execute_george, execute_george_through_file, get_layers_data, get_groups_data, ) from openpype.hosts.tvpaint.api.pipeline import ( SECTION_NAME_CONTEXT, SECTION_NAME_INSTANCES, SECTION_NAME_CONTAINERS, get_workfile_metadata_string, write_workfile_metadata, get_current_workfile_context, list_instances, ) class ResetTVPaintWorkfileMetadata(pyblish.api.Action): """Fix invalid metadata in workfile.""" label = "Reset invalid workfile metadata" on = "failed" def process(self, context, plugin): metadata_keys = { SECTION_NAME_CONTEXT: {}, SECTION_NAME_INSTANCES: [], SECTION_NAME_CONTAINERS: [] } for metadata_key, default in metadata_keys.items(): json_string = get_workfile_metadata_string(metadata_key) if not json_string: continue try: return json.loads(json_string) except Exception: self.log.warning( ( "Couldn't parse metadata from key \"{}\"." " Will reset to default value \"{}\"." " Loaded value was: {}" ).format(metadata_key, default, json_string), exc_info=True ) write_workfile_metadata(metadata_key, default) class CollectWorkfileData(pyblish.api.ContextPlugin): label = "Collect Workfile Data" order = pyblish.api.CollectorOrder - 0.45 hosts = ["tvpaint"] actions = [ResetTVPaintWorkfileMetadata] def process(self, context): current_project_id = execute_george("tv_projectcurrentid") execute_george("tv_projectselect {}".format(current_project_id)) # Collect and store current context to have reference current_context = { "project_name": context.data["projectName"], "asset_name": context.data["asset"], "task_name": context.data["task"] } self.log.debug("Current context is: {}".format(current_context)) # Collect context from workfile metadata self.log.info("Collecting workfile context") workfile_context = get_current_workfile_context() if "project" in workfile_context: workfile_context = { "project_name": workfile_context.get("project"), "asset_name": workfile_context.get("asset"), "task_name": workfile_context.get("task"), } # Store workfile context to pyblish context context.data["workfile_context"] = workfile_context if workfile_context: # Change current context with context from workfile key_map = ( ("AVALON_ASSET", "asset_name"), ("AVALON_TASK", "task_name") ) for env_key, key in key_map: legacy_io.Session[env_key] = workfile_context[key] os.environ[env_key] = workfile_context[key] self.log.info("Context changed to: {}".format(workfile_context)) asset_name = workfile_context["asset_name"] task_name = workfile_context["task_name"] else: asset_name = current_context["asset_name"] task_name = current_context["task_name"] # Handle older workfiles or workfiles without metadata self.log.warning(( "Workfile does not contain information about context." " Using current Session context." )) # Store context asset name context.data["asset"] = asset_name context.data["task"] = task_name self.log.info( "Context is set to Asset: \"{}\" and Task: \"{}\"".format( asset_name, task_name ) ) # Collect instances self.log.info("Collecting instance data from workfile") instance_data = list_instances() context.data["workfileInstances"] = instance_data self.log.debug( "Instance data:\"{}".format(json.dumps(instance_data, indent=4)) ) # Collect information about layers self.log.info("Collecting layers data from workfile") layers_data = get_layers_data() layers_by_name = {} for layer in layers_data: layer_name = layer["name"] if layer_name not in layers_by_name: layers_by_name[layer_name] = [] layers_by_name[layer_name].append(layer) context.data["layersData"] = layers_data context.data["layersByName"] = layers_by_name self.log.debug( "Layers data:\"{}".format(json.dumps(layers_data, indent=4)) ) # Collect information about groups self.log.info("Collecting groups data from workfile") group_data = get_groups_data() context.data["groupsData"] = group_data self.log.debug( "Group data:\"{}".format(json.dumps(group_data, indent=4)) ) self.log.info("Collecting scene data from workfile") workfile_info_parts = execute_george("tv_projectinfo").split(" ") # Project frame start - not used workfile_info_parts.pop(-1) field_order = workfile_info_parts.pop(-1) frame_rate = float(workfile_info_parts.pop(-1)) pixel_apsect = float(workfile_info_parts.pop(-1)) height = int(workfile_info_parts.pop(-1)) width = int(workfile_info_parts.pop(-1)) workfile_path = " ".join(workfile_info_parts).replace("\"", "") # Marks return as "{frame - 1} {state} ", example "0 set". result = execute_george("tv_markin") mark_in_frame, mark_in_state, _ = result.split(" ") result = execute_george("tv_markout") mark_out_frame, mark_out_state, _ = result.split(" ") scene_data = { "currentFile": workfile_path, "sceneWidth": width, "sceneHeight": height, "scenePixelAspect": pixel_apsect, "sceneFps": frame_rate, "sceneFieldOrder": field_order, "sceneMarkIn": int(mark_in_frame), "sceneMarkInState": mark_in_state == "set", "sceneMarkOut": int(mark_out_frame), "sceneMarkOutState": mark_out_state == "set", "sceneStartFrame": int(execute_george("tv_startframe")), "sceneBgColor": self._get_bg_color() } self.log.debug( "Scene data: {}".format(json.dumps(scene_data, indent=4)) ) context.data.update(scene_data) def _get_bg_color(self): """Background color set on scene. Is important for review exporting where scene bg color is used as background. """ output_file = tempfile.NamedTemporaryFile( mode="w", prefix="a_tvp_", suffix=".txt", delete=False ) output_file.close() output_filepath = output_file.name.replace("\\", "/") george_script_lines = [ # Variable containing full path to output file "output_path = \"{}\"".format(output_filepath), "tv_background", "bg_color = result", # Write data to output file ( "tv_writetextfile" " \"strict\" \"append\" '\"'output_path'\"' bg_color" ) ] george_script = "\n".join(george_script_lines) execute_george_through_file(george_script) with open(output_filepath, "r") as stream: data = stream.read() os.remove(output_filepath) data = data.strip() if not data: return None return data.split(" ") ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/extract_convert_to_exr.py ================================================ """Plugin converting png files from ExtractSequence into exrs. Requires: ExtractSequence - source of PNG ExtractReview - review was already created so we can convert to any exr """ import os import json import pyblish.api from openpype.lib import ( get_oiio_tool_args, ToolNotFoundError, run_subprocess, ) from openpype.pipeline import KnownPublishError class ExtractConvertToEXR(pyblish.api.InstancePlugin): # Offset to get after ExtractSequence plugin. order = pyblish.api.ExtractorOrder + 0.1 label = "Extract Sequence EXR" hosts = ["tvpaint"] families = ["render"] enabled = False # Replace source PNG files or just add replace_pngs = True # EXR compression exr_compression = "ZIP" def process(self, instance): repres = instance.data.get("representations") if not repres: return try: oiio_args = get_oiio_tool_args("oiiotool") except ToolNotFoundError: # Raise an exception when oiiotool is not available # - this can currently happen on MacOS machines raise KnownPublishError( "OpenImageIO tool is not available on this machine." ) new_repres = [] for repre in repres: if repre["name"] != "png": continue self.log.info( "Processing representation: {}".format( json.dumps(repre, sort_keys=True, indent=4) ) ) src_filepaths = set() new_filenames = [] for src_filename in repre["files"]: dst_filename = os.path.splitext(src_filename)[0] + ".exr" new_filenames.append(dst_filename) src_filepath = os.path.join(repre["stagingDir"], src_filename) dst_filepath = os.path.join(repre["stagingDir"], dst_filename) src_filepaths.add(src_filepath) args = oiio_args + [ src_filepath, "--compression", self.exr_compression, # TODO how to define color conversion? "--colorconvert", "sRGB", "linear", "-o", dst_filepath ] run_subprocess(args) new_repres.append( { "name": "exr", "ext": "exr", "files": new_filenames, "stagingDir": repre["stagingDir"], "tags": list(repre["tags"]) } ) if self.replace_pngs: instance.data["representations"].remove(repre) for filepath in src_filepaths: instance.context.data["cleanupFullPaths"].append(filepath) instance.data["representations"].extend(new_repres) self.log.info( "Representations: {}".format( json.dumps( instance.data["representations"], sort_keys=True, indent=4 ) ) ) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/extract_sequence.py ================================================ import os import copy import tempfile from PIL import Image import pyblish.api from openpype.pipeline.publish import ( KnownPublishError, get_publish_instance_families, ) from openpype.hosts.tvpaint.api.lib import ( execute_george, execute_george_through_file, get_layers_pre_post_behavior, get_layers_exposure_frames, ) from openpype.hosts.tvpaint.lib import ( calculate_layers_extraction_data, get_frame_filename_template, fill_reference_frames, composite_rendered_layers, rename_filepaths_by_frame_start, ) class ExtractSequence(pyblish.api.Extractor): label = "Extract Sequence" hosts = ["tvpaint"] families = ["review", "render"] # Modifiable with settings review_bg = [255, 255, 255, 255] def process(self, instance): self.log.info( "* Processing instance \"{}\"".format(instance.data["label"]) ) # Get all layers and filter out not visible layers = instance.data["layers"] filtered_layers = [ layer for layer in layers if layer["visible"] ] layer_names = [str(layer["name"]) for layer in filtered_layers] if not layer_names: self.log.info( "None of the layers from the instance" " are visible. Extraction skipped." ) return joined_layer_names = ", ".join( ["\"{}\"".format(name) for name in layer_names] ) self.log.debug( "Instance has {} layers with names: {}".format( len(layer_names), joined_layer_names ) ) ignore_layers_transparency = instance.data.get( "ignoreLayersTransparency", False ) mark_in = instance.context.data["sceneMarkIn"] mark_out = instance.context.data["sceneMarkOut"] # Change scene Start Frame to 0 to prevent frame index issues # - issue is that TVPaint versions deal with frame indexes in a # different way when Start Frame is not `0` # NOTE It will be set back after rendering scene_start_frame = instance.context.data["sceneStartFrame"] execute_george("tv_startframe 0") # Frame start/end may be stored as float frame_start = int(instance.data["frameStart"]) # Handles are not stored per instance but on Context handle_start = instance.context.data["handleStart"] scene_bg_color = instance.context.data["sceneBgColor"] # Prepare output frames output_frame_start = frame_start - handle_start # Change output frame start to 0 if handles cause it's negative number if output_frame_start < 0: self.log.warning(( "Frame start with handles has negative value." " Changed to \"0\". Frames start: {}, Handle Start: {}" ).format(frame_start, handle_start)) output_frame_start = 0 # Calculate frame end output_frame_end = output_frame_start + (mark_out - mark_in) # Save to staging dir output_dir = instance.data.get("stagingDir") if not output_dir: # Create temp folder if staging dir is not set output_dir = ( tempfile.mkdtemp(prefix="tvpaint_render_") ).replace("\\", "/") instance.data["stagingDir"] = output_dir self.log.debug( "Files will be rendered to folder: {}".format(output_dir) ) if instance.data["family"] == "review": result = self.render_review( output_dir, mark_in, mark_out, scene_bg_color ) else: # Render output result = self.render( output_dir, mark_in, mark_out, filtered_layers, ignore_layers_transparency ) output_filepaths_by_frame_idx, thumbnail_fullpath = result # Change scene frame Start back to previous value execute_george("tv_startframe {}".format(scene_start_frame)) # Sequence of one frame if not output_filepaths_by_frame_idx: self.log.warning("Extractor did not create any output.") return repre_files = self._rename_output_files( output_filepaths_by_frame_idx, mark_in, mark_out, output_frame_start ) # Fill tags and new families from project settings instance_families = get_publish_instance_families(instance) tags = [] if "review" in instance_families: tags.append("review") # Sequence of one frame single_file = len(repre_files) == 1 if single_file: repre_files = repre_files[0] # Extension is hardcoded # - changing extension would require change code new_repre = { "name": "png", "ext": "png", "files": repre_files, "stagingDir": output_dir, "tags": tags } if not single_file: new_repre["frameStart"] = output_frame_start new_repre["frameEnd"] = output_frame_end self.log.debug("Creating new representation: {}".format(new_repre)) instance.data["representations"].append(new_repre) if not thumbnail_fullpath: return thumbnail_ext = os.path.splitext( thumbnail_fullpath )[1].replace(".", "") # Create thumbnail representation thumbnail_repre = { "name": "thumbnail", "ext": thumbnail_ext, "outputName": "thumb", "files": os.path.basename(thumbnail_fullpath), "stagingDir": output_dir, "tags": ["thumbnail"] } instance.data["representations"].append(thumbnail_repre) def _rename_output_files( self, filepaths_by_frame, mark_in, mark_out, output_frame_start ): new_filepaths_by_frame = rename_filepaths_by_frame_start( filepaths_by_frame, mark_in, mark_out, output_frame_start ) repre_filenames = [] for filepath in new_filepaths_by_frame.values(): repre_filenames.append(os.path.basename(filepath)) if mark_in < output_frame_start: repre_filenames = list(reversed(repre_filenames)) return repre_filenames def render_review( self, output_dir, mark_in, mark_out, scene_bg_color ): """ Export images from TVPaint using `tv_savesequence` command. Args: output_dir (str): Directory where files will be stored. mark_in (int): Starting frame index from which export will begin. mark_out (int): On which frame index export will end. scene_bg_color (list): Bg color set in scene. Result of george script command `tv_background`. Returns: tuple: With 2 items first is list of filenames second is path to thumbnail. """ filename_template = get_frame_filename_template(mark_out) self.log.debug("Preparing data for rendering.") first_frame_filepath = os.path.join( output_dir, filename_template.format(frame=mark_in) ) bg_color = self._get_review_bg_color() george_script_lines = [ # Change bg color to color from settings "tv_background \"color\" {} {} {}".format(*bg_color), "tv_SaveMode \"PNG\"", "export_path = \"{}\"".format( first_frame_filepath.replace("\\", "/") ), "tv_savesequence '\"'export_path'\"' {} {}".format( mark_in, mark_out ) ] if scene_bg_color: # Change bg color back to previous scene bg color _scene_bg_color = copy.deepcopy(scene_bg_color) bg_type = _scene_bg_color.pop(0) orig_color_command = [ "tv_background", "\"{}\"".format(bg_type) ] orig_color_command.extend(_scene_bg_color) george_script_lines.append(" ".join(orig_color_command)) execute_george_through_file("\n".join(george_script_lines)) first_frame_filepath = None output_filepaths_by_frame_idx = {} for frame_idx in range(mark_in, mark_out + 1): filename = filename_template.format(frame=frame_idx) filepath = os.path.join(output_dir, filename) output_filepaths_by_frame_idx[frame_idx] = filepath if not os.path.exists(filepath): raise KnownPublishError( "Output was not rendered. File was not found {}".format( filepath ) ) if first_frame_filepath is None: first_frame_filepath = filepath thumbnail_filepath = None if first_frame_filepath and os.path.exists(first_frame_filepath): thumbnail_filepath = os.path.join(output_dir, "thumbnail.jpg") source_img = Image.open(first_frame_filepath) if source_img.mode.lower() != "rgb": source_img = source_img.convert("RGB") source_img.save(thumbnail_filepath) return output_filepaths_by_frame_idx, thumbnail_filepath def render( self, output_dir, mark_in, mark_out, layers, ignore_layer_opacity ): """ Export images from TVPaint. Args: output_dir (str): Directory where files will be stored. mark_in (int): Starting frame index from which export will begin. mark_out (int): On which frame index export will end. layers (list): List of layers to be exported. ignore_layer_opacity (bool): Layer's opacity will be ignored. Returns: tuple: With 2 items first is list of filenames second is path to thumbnail. """ self.log.debug("Preparing data for rendering.") # Map layers by position layers_by_position = {} layers_by_id = {} layer_ids = [] for layer in layers: layer_id = layer["layer_id"] position = layer["position"] layers_by_position[position] = layer layers_by_id[layer_id] = layer layer_ids.append(layer_id) # Sort layer positions in reverse order sorted_positions = list(reversed(sorted(layers_by_position.keys()))) if not sorted_positions: return [], None self.log.debug("Collecting pre/post behavior of individual layers.") behavior_by_layer_id = get_layers_pre_post_behavior(layer_ids) exposure_frames_by_layer_id = get_layers_exposure_frames( layer_ids, layers ) extraction_data_by_layer_id = calculate_layers_extraction_data( layers, exposure_frames_by_layer_id, behavior_by_layer_id, mark_in, mark_out ) # Render layers filepaths_by_layer_id = {} for layer_id, render_data in extraction_data_by_layer_id.items(): layer = layers_by_id[layer_id] filepaths_by_layer_id[layer_id] = self._render_layer( render_data, layer, output_dir, ignore_layer_opacity ) # Prepare final filepaths where compositing should store result output_filepaths_by_frame = {} thumbnail_src_filepath = None finale_template = get_frame_filename_template(mark_out) for frame_idx in range(mark_in, mark_out + 1): filename = finale_template.format(frame=frame_idx) filepath = os.path.join(output_dir, filename) output_filepaths_by_frame[frame_idx] = filepath if thumbnail_src_filepath is None: thumbnail_src_filepath = filepath self.log.info("Started compositing of layer frames.") composite_rendered_layers( layers, filepaths_by_layer_id, mark_in, mark_out, output_filepaths_by_frame ) self.log.info("Compositing finished") thumbnail_filepath = None if thumbnail_src_filepath and os.path.exists(thumbnail_src_filepath): source_img = Image.open(thumbnail_src_filepath) thumbnail_filepath = os.path.join(output_dir, "thumbnail.jpg") # Composite background only on rgba images # - just making sure if source_img.mode.lower() == "rgba": bg_color = self._get_review_bg_color() self.log.debug("Adding thumbnail background color {}.".format( " ".join([str(val) for val in bg_color]) )) bg_image = Image.new("RGBA", source_img.size, bg_color) thumbnail_obj = Image.alpha_composite(bg_image, source_img) thumbnail_obj.convert("RGB").save(thumbnail_filepath) else: self.log.info(( "Source for thumbnail has mode \"{}\" (Expected: RGBA)." " Can't use thubmanail background color." ).format(source_img.mode)) source_img.save(thumbnail_filepath) return output_filepaths_by_frame, thumbnail_filepath def _get_review_bg_color(self): red = green = blue = 255 if self.review_bg: if len(self.review_bg) == 4: red, green, blue, _ = self.review_bg elif len(self.review_bg) == 3: red, green, blue = self.review_bg return (red, green, blue) def _render_layer( self, render_data, layer, output_dir, ignore_layer_opacity ): frame_references = render_data["frame_references"] filenames_by_frame_index = render_data["filenames_by_frame_index"] layer_id = layer["layer_id"] george_script_lines = [ "tv_layerset {}".format(layer_id), "tv_SaveMode \"PNG\"" ] # Set density to 100 and store previous opacity if ignore_layer_opacity: george_script_lines.extend([ "tv_layerdensity 100", "orig_opacity = result", ]) filepaths_by_frame = {} frames_to_render = [] for frame_idx, ref_idx in frame_references.items(): # None reference is skipped because does not have source if ref_idx is None: filepaths_by_frame[frame_idx] = None continue filename = filenames_by_frame_index[frame_idx] dst_path = "/".join([output_dir, filename]) filepaths_by_frame[frame_idx] = dst_path if frame_idx != ref_idx: continue frames_to_render.append(str(frame_idx)) # Go to frame george_script_lines.append("tv_layerImage {}".format(frame_idx)) # Store image to output george_script_lines.append("tv_saveimage \"{}\"".format(dst_path)) # Set density back to origin opacity if ignore_layer_opacity: george_script_lines.append("tv_layerdensity orig_opacity") self.log.debug("Rendering Exposure frames {} of layer {} ({})".format( ",".join(frames_to_render), layer_id, layer["name"] )) # Let TVPaint render layer's image execute_george_through_file("\n".join(george_script_lines)) # Fill frames between `frame_start_index` and `frame_end_index` self.log.debug("Filling frames not rendered frames.") fill_reference_frames(frame_references, filepaths_by_frame) return filepaths_by_frame ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/help/validate_asset_name.xml ================================================ Subset context ## Invalid subset context Context of the given subset doesn't match your current scene. ### How to repair? Yout can fix this with "Repair" button on the right. This will use '{expected_asset}' asset name and overwrite '{found_asset}' asset name in scene metadata. After that restart publishing with Reload button. ### How could this happen? The subset was created in different scene with different context or the scene file was copy pasted from different context. ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/help/validate_duplicated_layer_names.xml ================================================ Layer names ## Duplicated layer names Can't determine which layers should be published because there are duplicated layer names in the scene. ### Duplicated layer names {layer_names} *Check layer names for all subsets in list on left side.* ### How to repair? Hide/rename/remove layers that should not be published. If all of them should be published then you have duplicated subset names in the scene. In that case you have to recrete them and use different variant name. ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/help/validate_layers_visibility.xml ================================================ Layers visibility ## All layers are not visible Layers visibility was changed during publishing which caused that all layers for subset "{instance_name}" are hidden. ### Layer names for **{instance_name}** {layer_names} *Check layer names for all subsets in the list on the left side.* ### How to repair? Reset publishing and do not change visibility of layers after hitting publish button. ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/help/validate_marks.xml ================================================ Frame range ## Invalid render frame range Scene frame range which will be rendered is defined by MarkIn and MarkOut. Expected frame range is {expected_frame_range} and current frame range is {current_frame_range}. It is also required that MarkIn and MarkOut are enabled in the scene. Their color is highlighted on timeline when are enabled. - MarkIn is {mark_in_enable_state} - MarkOut is {mark_out_enable_state} ### How to repair? Yout can fix this with "Repair" button on the right. That will change MarkOut to {expected_mark_out}. Or you can manually modify MarkIn and MarkOut in the scene timeline. ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/help/validate_missing_layer_names.xml ================================================ Missing layers ## Missing layers for render pass Render pass subset "{instance_name}" has stored layer names that belong to it's rendering scope but layers were not found in scene. ### Missing layer names {layer_names} ### How to repair? Find layers that belong to subset {instance_name} and rename them back to expected layer names or remove the subset and create new with right layers. ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/help/validate_render_layer_group.xml ================================================ Overused Color group ## One Color group is used by multiple Render Layers Single color group used by multiple Render Layers would cause clashes of rendered TVPaint layers. The same layers would be used for output files of both groups. ### Missing layer names {groups_information} ### How to repair? Refresh, go to 'Publish' tab and go through Render Layers and change their groups to not clash each other. If you reach limit of TVPaint color groups there is nothing you can do about it to fix the issue. ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/help/validate_render_pass_group.xml ================================================ Render pass group ## Invalid group of Render Pass layers Layers of Render Pass {instance_name} belong to Render Group which is defined by TVPaint color group {expected_group}. But the layers are not in the group. ### How to repair? Change the color group to {expected_group} on layers {layer_names}. ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/help/validate_scene_settings.xml ================================================ Scene settings ## Invalid scene settings Scene settings do not match to expected values. **FPS** - Expected value: {expected_fps} - Current value: {current_fps} **Resolution** - Expected value: {expected_width}x{expected_height} - Current value: {current_width}x{current_height} **Pixel ratio** - Expected value: {expected_pixel_ratio} - Current value: {current_pixel_ratio} ### How to repair? FPS and Pixel ratio can be modified in scene setting. Wrong resolution can be fixed with changing resolution of scene but due to TVPaint limitations it is possible that you will need to create new scene. ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/help/validate_start_frame.xml ================================================ First frame ## MarkIn is not set to 0 MarkIn in your scene must start from 0 fram index but MarkIn is set to {current_start_frame}. ### How to repair? You can modify MarkIn manually or hit the "Repair" button on the right which will change MarkIn to 0 (does not change MarkOut). ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_metadata.xml ================================================ Missing metadata ## Your scene miss context metadata Your scene does not contain metadata about {missing_metadata}. ### How to repair? Resave the scene using Workfiles tool or hit the "Repair" button on the right. ### How this could happen? You're using scene file that was not created using Workfiles tool. ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/help/validate_workfile_project_name.xml ================================================ Project name ## Your scene is from different project It is not possible to publish into project "{workfile_project_name}" when TVPaint was opened with project "{env_project_name}" in context. ### How to repair? If the workfile belongs to project "{env_project_name}" then use Workfiles tool to resave it. Otherwise close TVPaint and launch it again from project you want to publish in. ### How this could happen? You've opened workfile from different project. You've opened TVPaint on a task from "{env_project_name}" then you've opened TVPaint again on task from "{workfile_project_name}" without closing the TVPaint. Because TVPaint can run only once the project didn't change. ### Why it is important? Because project may affect how TVPaint works or change publishing behavior it is dangerous to allow change project context in many ways. For example publishing will not run as expected. ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py ================================================ import pyblish.api from openpype.lib import version_up from openpype.pipeline import registered_host class IncrementWorkfileVersion(pyblish.api.ContextPlugin): """Increment current workfile version.""" order = pyblish.api.IntegratorOrder + 1 label = "Increment Workfile Version" optional = True hosts = ["tvpaint"] def process(self, context): assert all(result["success"] for result in context.data["results"]), ( "Publishing not successful so version is not increased.") host = registered_host() path = context.data["currentFile"] host.save_workfile(version_up(path)) self.log.info('Incrementing workfile version') ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py ================================================ import pyblish.api from openpype import AYON_SERVER_ENABLED from openpype.pipeline import ( PublishXmlValidationError, OptionalPyblishPluginMixin, ) from openpype.hosts.tvpaint.api.pipeline import ( list_instances, write_instances, ) class FixAssetNames(pyblish.api.Action): """Repair the asset names. Change instanace metadata in the workfile. """ label = "Repair" icon = "wrench" on = "failed" def process(self, context, plugin): context_asset_name = context.data["asset"] old_instance_items = list_instances() new_instance_items = [] for instance_item in old_instance_items: if AYON_SERVER_ENABLED: instance_asset_name = instance_item.get("folderPath") else: instance_asset_name = instance_item.get("asset") if ( instance_asset_name and instance_asset_name != context_asset_name ): if AYON_SERVER_ENABLED: instance_item["folderPath"] = context_asset_name else: instance_item["asset"] = context_asset_name new_instance_items.append(instance_item) write_instances(new_instance_items) class ValidateAssetName( OptionalPyblishPluginMixin, pyblish.api.ContextPlugin ): """Validate asset name present on instance. Asset name on instance should be the same as context's. """ label = "Validate Asset Names" order = pyblish.api.ValidatorOrder hosts = ["tvpaint"] actions = [FixAssetNames] def process(self, context): if not self.is_active(context.data): return context_asset_name = context.data["asset"] for instance in context: asset_name = instance.data.get("asset") if asset_name and asset_name == context_asset_name: continue instance_label = ( instance.data.get("label") or instance.data["name"] ) raise PublishXmlValidationError( self, ( "Different asset name on instance then context's." " Instance \"{}\" has asset name: \"{}\"" " Context asset name is: \"{}\"" ).format( instance_label, asset_name, context_asset_name ), formatting_data={ "expected_asset": context_asset_name, "found_asset": asset_name } ) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/validate_duplicated_layer_names.py ================================================ import pyblish.api from openpype.pipeline import PublishXmlValidationError class ValidateLayersGroup(pyblish.api.InstancePlugin): """Validate layer names for publishing are unique for whole workfile.""" label = "Validate Duplicated Layers Names" order = pyblish.api.ValidatorOrder families = ["renderPass"] def process(self, instance): # Prepare layers layers_by_name = instance.context.data["layersByName"] # Layers ids of an instance layer_names = instance.data["layer_names"] # Check if all layers from render pass are in right group duplicated_layer_names = [] for layer_name in layer_names: layers = layers_by_name.get(layer_name) # It is not job of this validator to handle missing layers if layers is None: continue if len(layers) > 1: duplicated_layer_names.append(layer_name) # Everything is OK and skip exception if not duplicated_layer_names: return layers_msg = ", ".join([ "\"{}\"".format(layer_name) for layer_name in duplicated_layer_names ]) detail_lines = [ "- {}".format(layer_name) for layer_name in set(duplicated_layer_names) ] raise PublishXmlValidationError( self, ( "Layers have duplicated names for instance {}." # Description what's wrong " There are layers with same name and one of them is marked" " for publishing so it is not possible to know which should" " be published. Please look for layers with names: {}" ).format(instance.data["label"], layers_msg), formatting_data={ "layer_names": "
".join(detail_lines) } ) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py ================================================ import pyblish.api from openpype.pipeline import PublishXmlValidationError # TODO @iLLiCiTiT add repair action to disable instances? class ValidateLayersVisiblity(pyblish.api.InstancePlugin): """Validate existence of renderPass layers.""" label = "Validate Layers Visibility" order = pyblish.api.ValidatorOrder families = ["review", "render"] def process(self, instance): layers = instance.data.get("layers") # Instance have empty layers # - it is not job of this validator to check that if not layers: return layer_names = set() for layer in layers: layer_names.add(layer["name"]) if layer["visible"]: return instance_label = ( instance.data.get("label") or instance.data["name"] ) raise PublishXmlValidationError( self, "All layers of instance \"{}\" are not visible.".format( instance_label ), formatting_data={ "instance_name": instance_label, "layer_names": "
".join([ "- {}".format(layer_name) for layer_name in layer_names ]) } ) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/validate_marks.py ================================================ import json import pyblish.api from openpype.pipeline import ( PublishXmlValidationError, OptionalPyblishPluginMixin, ) from openpype.hosts.tvpaint.api.lib import execute_george class ValidateMarksRepair(pyblish.api.Action): """Repair the marks.""" label = "Repair" icon = "wrench" on = "failed" def process(self, context, plugin): expected_data = ValidateMarks.get_expected_data(context) execute_george( "tv_markin {} set".format(expected_data["markIn"]) ) execute_george( "tv_markout {} set".format(expected_data["markOut"]) ) class ValidateMarks( OptionalPyblishPluginMixin, pyblish.api.ContextPlugin ): """Validate mark in and out are enabled and it's duration. Mark In/Out does not have to match frameStart and frameEnd but duration is important. """ label = "Validate Mark In/Out" order = pyblish.api.ValidatorOrder optional = True actions = [ValidateMarksRepair] @staticmethod def get_expected_data(context): scene_mark_in = context.data["sceneMarkIn"] # Data collected in `CollectContextEntities` frame_end = context.data["frameEnd"] frame_start = context.data["frameStart"] handle_start = context.data["handleStart"] handle_end = context.data["handleEnd"] # Calculate expected Mark out (Mark In + duration - 1) expected_mark_out = ( scene_mark_in + (frame_end - frame_start) + handle_start + handle_end ) return { "markIn": scene_mark_in, "markInState": True, "markOut": expected_mark_out, "markOutState": True } def process(self, context): if not self.is_active(context.data): return current_data = { "markIn": context.data["sceneMarkIn"], "markInState": context.data["sceneMarkInState"], "markOut": context.data["sceneMarkOut"], "markOutState": context.data["sceneMarkOutState"] } expected_data = self.get_expected_data(context) invalid = {} for k in current_data.keys(): if current_data[k] != expected_data[k]: invalid[k] = { "current": current_data[k], "expected": expected_data[k] } # Validation ends if not invalid: return current_frame_range = ( (current_data["markOut"] - current_data["markIn"]) + 1 ) expected_frame_range = ( (expected_data["markOut"] - expected_data["markIn"]) + 1 ) mark_in_enable_state = "disabled" if current_data["markInState"]: mark_in_enable_state = "enabled" mark_out_enable_state = "disabled" if current_data["markOutState"]: mark_out_enable_state = "enabled" raise PublishXmlValidationError( self, "Marks does not match database:\n{}".format( json.dumps(invalid, sort_keys=True, indent=4) ), formatting_data={ "current_frame_range": str(current_frame_range), "expected_frame_range": str(expected_frame_range), "mark_in_enable_state": mark_in_enable_state, "mark_out_enable_state": mark_out_enable_state, "expected_mark_out": expected_data["markOut"] } ) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/validate_missing_layer_names.py ================================================ import pyblish.api from openpype.pipeline import PublishXmlValidationError class ValidateMissingLayers(pyblish.api.InstancePlugin): """Validate existence of renderPass layers.""" label = "Validate Missing Layers Names" order = pyblish.api.ValidatorOrder families = ["renderPass"] def process(self, instance): # Prepare layers layers_by_name = instance.context.data["layersByName"] # Layers ids of an instance layer_names = instance.data["layer_names"] # Check if all layers from render pass are in right group missing_layer_names = [] for layer_name in layer_names: layers = layers_by_name.get(layer_name) if not layers: missing_layer_names.append(layer_name) # Everything is OK and skip exception if not missing_layer_names: return layers_msg = ", ".join([ "\"{}\"".format(layer_name) for layer_name in missing_layer_names ]) instance_label = ( instance.data.get("label") or instance.data["name"] ) description_layer_names = "
".join([ "- {}".format(layer_name) for layer_name in missing_layer_names ]) # Raise an error raise PublishXmlValidationError( self, ( "Layers were not found by name for instance \"{}\"." # Description what's wrong " Layer names marked for publishing are not available" " in layers list. Missing layer names: {}" ).format(instance.data["label"], layers_msg), formatting_data={ "instance_name": instance_label, "layer_names": description_layer_names } ) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/validate_render_layer_group.py ================================================ import collections import pyblish.api from openpype.pipeline import PublishXmlValidationError class ValidateRenderLayerGroups(pyblish.api.ContextPlugin): """Validate group ids of renderLayer subsets. Validate that there are not 2 render layers using the same group. """ label = "Validate Render Layers Group" order = pyblish.api.ValidatorOrder + 0.1 def process(self, context): # Prepare layers render_layers_by_group_id = collections.defaultdict(list) for instance in context: families = instance.data.get("families") if not families or "renderLayer" not in families: continue group_id = instance.data["creator_attributes"]["group_id"] render_layers_by_group_id[group_id].append(instance) duplicated_instances = [] for group_id, instances in render_layers_by_group_id.items(): if len(instances) > 1: duplicated_instances.append((group_id, instances)) if not duplicated_instances: return # Exception message preparations groups_data = context.data["groupsData"] groups_by_id = { group["group_id"]: group for group in groups_data } per_group_msgs = [] groups_information_lines = [] for group_id, instances in duplicated_instances: group = groups_by_id[group_id] group_label = "Group \"{}\" ({})".format( group["name"], group["group_id"], ) line_join_subset_names = "\n".join([ f" - {instance['subset']}" for instance in instances ]) joined_subset_names = ", ".join([ f"\"{instance['subset']}\"" for instance in instances ]) per_group_msgs.append( "{} < {} >".format(group_label, joined_subset_names) ) groups_information_lines.append( "{}\n{}".format(group_label, line_join_subset_names) ) # Raise an error raise PublishXmlValidationError( self, ( "More than one Render Layer is using the same TVPaint" " group color. {}" ).format(" | ".join(per_group_msgs)), formatting_data={ "groups_information": "\n".join(groups_information_lines) } ) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/validate_render_pass_group.py ================================================ import collections import pyblish.api from openpype.pipeline import PublishXmlValidationError class ValidateLayersGroup(pyblish.api.InstancePlugin): """Validate group ids of renderPass layers. Validates that all layers are in same group as they were during creation. """ label = "Validate Layers Group" order = pyblish.api.ValidatorOrder + 0.1 families = ["renderPass"] def process(self, instance): # Prepare layers layers_data = instance.context.data["layersData"] layers_by_name = { layer["name"]: layer for layer in layers_data } # Expected group id for instance layers group_id = instance.data["group_id"] # Layers ids of an instance layer_names = instance.data["layer_names"] # Check if all layers from render pass are in right group invalid_layers_by_group_id = collections.defaultdict(list) invalid_layer_names = set() for layer_name in layer_names: layer = layers_by_name.get(layer_name) _group_id = layer["group_id"] if _group_id != group_id: invalid_layers_by_group_id[_group_id].append(layer) invalid_layer_names.add(layer_name) # Everything is OK and skip exception if not invalid_layers_by_group_id: return # Exception message preparations groups_data = instance.context.data["groupsData"] groups_by_id = { group["group_id"]: group for group in groups_data } correct_group = groups_by_id[group_id] per_group_msgs = [] for _group_id, layers in invalid_layers_by_group_id.items(): _group = groups_by_id[_group_id] layers_msgs = [] for layer in layers: layers_msgs.append( "\"{}\" (id: {})".format(layer["name"], layer["layer_id"]) ) per_group_msgs.append( "Group \"{}\" (id: {}) < {} >".format( _group["name"], _group["group_id"], ", ".join(layers_msgs) ) ) # Raise an error raise PublishXmlValidationError( self, ( # Short message "Layers in wrong group." # Description what's wrong " Layers from render pass \"{}\" must be in group {} (id: {})." # Detailed message " Layers in wrong group: {}" ).format( instance.data["label"], correct_group["name"], correct_group["group_id"], " | ".join(per_group_msgs) ), formatting_data={ "instance_name": ( instance.data.get("label") or instance.data["name"] ), "expected_group": correct_group["name"], "layer_names": ", ".join(invalid_layer_names) } ) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/validate_scene_settings.py ================================================ import json import pyblish.api from openpype.pipeline import ( PublishXmlValidationError, OptionalPyblishPluginMixin, ) # TODO @iLliCiTiT add fix action for fps class ValidateProjectSettings( OptionalPyblishPluginMixin, pyblish.api.ContextPlugin ): """Validate scene settings against database.""" label = "Validate Scene Settings" order = pyblish.api.ValidatorOrder optional = True def process(self, context): if not self.is_active(context.data): return expected_data = context.data["assetEntity"]["data"] scene_data = { "fps": context.data.get("sceneFps"), "resolutionWidth": context.data.get("sceneWidth"), "resolutionHeight": context.data.get("sceneHeight"), "pixelAspect": context.data.get("scenePixelAspect") } invalid = {} for k in scene_data.keys(): expected_value = expected_data[k] if scene_data[k] != expected_value: invalid[k] = { "current": scene_data[k], "expected": expected_value } if not invalid: return raise PublishXmlValidationError( self, "Scene settings does not match database:\n{}".format( json.dumps(invalid, sort_keys=True, indent=4) ), formatting_data={ "expected_fps": expected_data["fps"], "current_fps": scene_data["fps"], "expected_width": expected_data["resolutionWidth"], "expected_height": expected_data["resolutionHeight"], "current_width": scene_data["resolutionWidth"], "current_height": scene_data["resolutionHeight"], "expected_pixel_ratio": expected_data["pixelAspect"], "current_pixel_ratio": scene_data["pixelAspect"] } ) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py ================================================ import pyblish.api from openpype.pipeline import ( PublishXmlValidationError, OptionalPyblishPluginMixin, ) from openpype.hosts.tvpaint.api.lib import execute_george class RepairStartFrame(pyblish.api.Action): """Repair start frame.""" label = "Repair" icon = "wrench" on = "failed" def process(self, context, plugin): execute_george("tv_startframe 0") class ValidateStartFrame( OptionalPyblishPluginMixin, pyblish.api.ContextPlugin ): """Validate start frame being at frame 0.""" label = "Validate Start Frame" order = pyblish.api.ValidatorOrder hosts = ["tvpaint"] actions = [RepairStartFrame] optional = True def process(self, context): if not self.is_active(context.data): return start_frame = execute_george("tv_startframe") if start_frame == 0: return raise PublishXmlValidationError( self, "Start frame has to be frame 0.", formatting_data={ "current_start_frame": start_frame } ) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/validate_workfile_metadata.py ================================================ import pyblish.api from openpype.pipeline import ( PublishXmlValidationError, PublishValidationError, registered_host, ) class ValidateWorkfileMetadataRepair(pyblish.api.Action): """Store current context into workfile metadata.""" label = "Use current context" icon = "wrench" on = "failed" def process(self, context, _plugin): """Save current workfile which should trigger storing of metadata.""" current_file = context.data["currentFile"] host = registered_host() # Save file should trigger host.save_workfile(current_file) class ValidateWorkfileMetadata(pyblish.api.ContextPlugin): """Validate if wokrfile contain required metadata for publising.""" label = "Validate Workfile Metadata" order = pyblish.api.ValidatorOrder families = ["workfile"] actions = [ValidateWorkfileMetadataRepair] required_keys = {"project_name", "asset_name", "task_name"} def process(self, context): workfile_context = context.data["workfile_context"] if not workfile_context: raise PublishValidationError( "Current workfile is missing whole metadata about context.", "Missing context", ( "Current workfile is missing metadata about task." " To fix this issue save the file using Workfiles tool." ) ) missing_keys = [] for key in self.required_keys: value = workfile_context.get(key) if not value: missing_keys.append(key) if missing_keys: raise PublishXmlValidationError( self, "Current workfile is missing metadata about {}.".format( ", ".join(missing_keys) ), formatting_data={ "missing_metadata": ", ".join(missing_keys) } ) ================================================ FILE: openpype/hosts/tvpaint/plugins/publish/validate_workfile_project_name.py ================================================ import pyblish.api from openpype.pipeline import PublishXmlValidationError class ValidateWorkfileProjectName(pyblish.api.ContextPlugin): """Validate project name stored in workfile metadata. It is not possible to publish from different project than is set in environment variable "AVALON_PROJECT". """ label = "Validate Workfile Project Name" order = pyblish.api.ValidatorOrder def process(self, context): workfile_context = context.data.get("workfile_context") # If workfile context is missing than project is matching to # global project if not workfile_context: self.log.info( "Workfile context (\"workfile_context\") is not filled." ) return workfile_project_name = workfile_context["project_name"] env_project_name = context.data["projectName"] if workfile_project_name == env_project_name: self.log.info(( "Both workfile project and environment project are same. {}" ).format(env_project_name)) return # Raise an error raise PublishXmlValidationError( self, ( # Short message "Workfile from different Project ({})." # Description what's wrong " It is not possible to publish when TVPaint was launched in" "context of different project. Current context project is" " \"{}\". Launch TVPaint in context of project \"{}\"" " and then publish." ).format( workfile_project_name, env_project_name, workfile_project_name, ), formatting_data={ "workfile_project_name": workfile_project_name, "expected_project_name": env_project_name } ) ================================================ FILE: openpype/hosts/tvpaint/tvpaint_plugin/__init__.py ================================================ import os def get_plugin_files_path(): current_dir = os.path.dirname(os.path.abspath(__file__)) return os.path.join(current_dir, "plugin_files") ================================================ FILE: openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.17) project(OpenPypePlugin C CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_EXTENSIONS OFF) set(IP_ENABLE_UNICODE OFF) set(IP_ENABLE_DOCTEST OFF) if(MSVC) set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) add_definitions(-D_CRT_SECURE_NO_WARNINGS) # Define WIN64 or WIN32 for TVPaint SDK if(CMAKE_SIZEOF_VOID_P EQUAL 8) message("64bit") add_definitions(-DWIN64) elseif(CMAKE_SIZEOF_VOID_P EQUAL 4) message("32bit") add_definitions(-DWIN32) endif() endif() # TODO better options option(BOOST_ROOT "Path to root of Boost" "") option(OPENSSL_INCLUDE "OpenSSL include path" "") option(OPENSSL_LIB_DIR "OpenSSL lib path" "") option(WEBSOCKETPP_INCLUDE "Websocketpp include path" "") option(JSONRPCPP_INCLUDE "Jsonrpcpp include path" "") # Use static boost libraries set(Boost_USE_STATIC_LIBS ON) find_package(Boost COMPONENTS random chrono date_time regex REQUIRED) include_directories( "${TVPAINT_SDK_INCLUDE}" "${OPENSSL_INCLUDE}" "${WEBSOCKETPP_INCLUDE}" "${JSONRPCPP_INCLUDE}" "${Boost_INCLUDE_DIRS}" ) link_directories( "${OPENSSL_LIB_DIR}" "${Boost_LIBRARY_DIRS}" ) add_library(jsonrpcpp INTERFACE) add_library(${PROJECT_NAME} SHARED library.cpp library.def "${TVPAINT_SDK_LIB}/dllx.c") target_link_libraries(${PROJECT_NAME} ${Boost_LIBRARIES}) target_link_libraries(${PROJECT_NAME} jsonrpcpp) ================================================ FILE: openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/README.md ================================================ README for TVPaint Avalon plugin ================================ Introduction ------------ This project is dedicated to integrate Avalon functionality to TVPaint. This implementation is using TVPaint plugin (C/C++) which can communicate with python process. The communication should allow to trigger tools or pipeline functions from TVPaint and accept requests from python process at the same time. Current implementation is based on websocket protocol, using json-rpc communication (specification 2.0). Project is in beta stage, tested only on Windows. To be able to load plugin, environment variable `WEBSOCKET_URL` must be set otherwise plugin won't load at all. Plugin should not affect TVPaint if python server crash, but buttons won't work. ## Requirements - Python server - python >= 3.6 - aiohttp - aiohttp-json-rpc ### Windows - pywin32 - required only for plugin installation ## Requirements - Plugin compilation - TVPaint SDK - Ask for SDK on TVPaint support. - Boost 1.72.0 - Boost is used across other plugins (Should be possible to use different version with CMakeLists modification) - Websocket++/Websocketpp - Websocket library (https://github.com/zaphoyd/websocketpp) - OpenSSL library - Required by Websocketpp - jsonrpcpp - C++ library handling json-rpc 2.0 (https://github.com/badaix/jsonrpcpp) - nlohmann/json - Required for jsonrpcpp (https://github.com/nlohmann/json) ### jsonrpcpp This library has `nlohmann/json` as it's part, but current `master` has old version which has bug and probably won't be possible to use library on windows without using last `nlohmann/json`. ## TODO - modify code and CMake to be able to compile on MacOS/Linux - separate websocket logic from plugin logic - hide buttons and show error message if server is closed ================================================ FILE: openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp ================================================ #ifdef _WIN32 // Include before #include #endif #include #include #include #include #include #include #include #include "plugdllx.h" #include #include #include #include "json.hpp" #include "jsonrpcpp.hpp" // All functions not exported should be static. // All global variables should be static. // mReq Identification of the requester. (=0 closed, !=0 requester ID) static struct { bool firstParams; DWORD mReq; void* mLocalFile; PIFilter *current_filter; // Id counter for client requests int client_request_id; // There are new menu items bool newMenuItems; // Menu item definitions received from connection nlohmann::json menuItems; // Menu items used in requester by their ID nlohmann::json menuItemsById; std::list menuItemsIds; // Messages from server before processing. // - messages can't be process at the moment of receive as client is running in thread std::queue messages; // Responses to requests mapped by request id std::map responses; } Data = { true, 0, nullptr, nullptr, 1, false, nlohmann::json::object(), nlohmann::json::object() }; // Json rpc 2.0 parser - for handling messages and callbacks jsonrpcpp::Parser parser; typedef websocketpp::client client; class connection_metadata { private: websocketpp::connection_hdl m_hdl; client *m_endpoint; std::string m_status; public: typedef websocketpp::lib::shared_ptr ptr; connection_metadata(websocketpp::connection_hdl hdl, client *endpoint) : m_hdl(hdl), m_status("Connecting") { m_endpoint = endpoint; } void on_open(client *c, websocketpp::connection_hdl hdl) { m_status = "Open"; } void on_fail(client *c, websocketpp::connection_hdl hdl) { m_status = "Failed"; } void on_close(client *c, websocketpp::connection_hdl hdl) { m_status = "Closed"; } void on_message(websocketpp::connection_hdl, client::message_ptr msg) { std::string json_str; if (msg->get_opcode() == websocketpp::frame::opcode::text) { json_str = msg->get_payload(); } else { json_str = websocketpp::utility::to_hex(msg->get_payload()); } process_message(json_str); } void process_message(std::string msg) { std::cout << "--> " << msg << "\n"; try { jsonrpcpp::entity_ptr entity = parser.do_parse(msg); if (!entity) { // Return error code? } else if (entity->is_response()) { jsonrpcpp::Response response = jsonrpcpp::Response(entity->to_json()); Data.responses[response.id().int_id()] = response; } else if (entity->is_request() || entity->is_notification()) { Data.messages.push(msg); } } catch (const jsonrpcpp::RequestException &e) { std::string message = e.to_json().dump(); std::cout << "<-- " << e.to_json().dump() << "\n"; send(message); } catch (const jsonrpcpp::ParseErrorException &e) { std::string message = e.to_json().dump(); std::cout << "<-- " << message << "\n"; send(message); } catch (const jsonrpcpp::RpcException &e) { std::cerr << "RpcException: " << e.what() << "\n"; std::string message = jsonrpcpp::ParseErrorException(e.what()).to_json().dump(); std::cout << "<-- " << message << "\n"; send(message); } catch (const std::exception &e) { std::cerr << "Exception: " << e.what() << "\n"; } } void send(std::string message) { if (get_status() != "Open") { return; } websocketpp::lib::error_code ec; m_endpoint->send(m_hdl, message, websocketpp::frame::opcode::text, ec); if (ec) { std::cout << "> Error sending message: " << ec.message() << std::endl; return; } } void send_notification(jsonrpcpp::Notification *notification) { send(notification->to_json().dump()); } void send_response(jsonrpcpp::Response *response) { send(response->to_json().dump()); } void send_request(jsonrpcpp::Request *request) { send(request->to_json().dump()); } websocketpp::connection_hdl get_hdl() const { return m_hdl; } std::string get_status() const { return m_status; } }; class websocket_endpoint { private: client m_endpoint; connection_metadata::ptr client_metadata; websocketpp::lib::shared_ptr m_thread; bool thread_is_running = false; public: websocket_endpoint() { m_endpoint.clear_access_channels(websocketpp::log::alevel::all); m_endpoint.clear_error_channels(websocketpp::log::elevel::all); } ~websocket_endpoint() { close_connection(); } void close_connection() { m_endpoint.stop_perpetual(); if (connected()) { // Close client close(websocketpp::close::status::normal, ""); } if (thread_is_running) { // Join thread m_thread->join(); thread_is_running = false; } } bool connected() { return (client_metadata && client_metadata->get_status() == "Open"); } int connect(std::string const &uri) { if (client_metadata && client_metadata->get_status() == "Open") { std::cout << "> Already connected" << std::endl; return 0; } m_endpoint.init_asio(); m_endpoint.start_perpetual(); m_thread.reset(new websocketpp::lib::thread(&client::run, &m_endpoint)); thread_is_running = true; websocketpp::lib::error_code ec; client::connection_ptr con = m_endpoint.get_connection(uri, ec); if (ec) { std::cout << "> Connect initialization error: " << ec.message() << std::endl; return -1; } client_metadata = websocketpp::lib::make_shared(con->get_handle(), &m_endpoint); con->set_open_handler(websocketpp::lib::bind( &connection_metadata::on_open, client_metadata, &m_endpoint, websocketpp::lib::placeholders::_1 )); con->set_fail_handler(websocketpp::lib::bind( &connection_metadata::on_fail, client_metadata, &m_endpoint, websocketpp::lib::placeholders::_1 )); con->set_close_handler(websocketpp::lib::bind( &connection_metadata::on_close, client_metadata, &m_endpoint, websocketpp::lib::placeholders::_1 )); con->set_message_handler(websocketpp::lib::bind( &connection_metadata::on_message, client_metadata, websocketpp::lib::placeholders::_1, websocketpp::lib::placeholders::_2 )); m_endpoint.connect(con); return 1; } void close(websocketpp::close::status::value code, std::string reason) { if (!client_metadata || client_metadata->get_status() != "Open") { std::cout << "> Not connected yet" << std::endl; return; } websocketpp::lib::error_code ec; m_endpoint.close(client_metadata->get_hdl(), code, reason, ec); if (ec) { std::cout << "> Error initiating close: " << ec.message() << std::endl; } } void send(std::string message) { if (!client_metadata || client_metadata->get_status() != "Open") { std::cout << "> Not connected yet" << std::endl; return; } client_metadata->send(message); } void send_notification(jsonrpcpp::Notification *notification) { client_metadata->send_notification(notification); } void send_response(jsonrpcpp::Response *response) { client_metadata->send(response->to_json().dump()); } void send_response(std::shared_ptr response) { client_metadata->send(response->to_json().dump()); } void send_request(jsonrpcpp::Request *request) { client_metadata->send_request(request); } }; class Communicator { private: // URL to websocket server std::string websocket_url; // Should be avalon plugin available? // - this may change during processing if websocketet url is not set or server is down bool server_available; public: Communicator(std::string url); Communicator(); websocket_endpoint endpoint; bool is_connected(); bool is_usable(); void connect(); void process_requests(); jsonrpcpp::Response call_method(std::string method_name, nlohmann::json params); void call_notification(std::string method_name, nlohmann::json params); }; Communicator::Communicator(std::string url) { // URL to websocket server websocket_url = url; // Should be avalon plugin available? // - this may change during processing if websocketet url is not set or server is down if (url == "") { server_available = false; } else { server_available = true; } } bool Communicator::is_connected(){ return endpoint.connected(); } bool Communicator::is_usable(){ return server_available; } void Communicator::connect() { if (!server_available) { return; } int con_result; con_result = endpoint.connect(websocket_url); if (con_result == -1) { server_available = false; } else { server_available = true; } } void Communicator::call_notification(std::string method_name, nlohmann::json params) { if (!server_available || !is_connected()) {return;} jsonrpcpp::Notification notification = {method_name, params}; endpoint.send_notification(¬ification); } jsonrpcpp::Response Communicator::call_method(std::string method_name, nlohmann::json params) { jsonrpcpp::Response response; if (!server_available || !is_connected()) { return response; } int request_id = Data.client_request_id++; jsonrpcpp::Request request = {request_id, method_name, params}; endpoint.send_request(&request); bool found = false; while (!found) { std::map::iterator iter = Data.responses.find(request_id); if (iter != Data.responses.end()) { //element found == was found response response = iter->second; Data.responses.erase(request_id); found = true; } else { std::this_thread::sleep_for(std::chrono::milliseconds(100)); } } return response; } void Communicator::process_requests() { if (!server_available || !is_connected() || Data.messages.empty()) {return;} std::string msg = Data.messages.front(); Data.messages.pop(); std::cout << "Parsing: " << msg << std::endl; // TODO: add try->except block auto response = parser.parse(msg); if (response->is_response()) { endpoint.send_response(response); } else { jsonrpcpp::request_ptr request = std::dynamic_pointer_cast(response); jsonrpcpp::Error error("Method \"" + request->method() + "\" not found", -32601); jsonrpcpp::Response _response(request->id(), error); endpoint.send_response(&_response); } } jsonrpcpp::response_ptr define_menu(const jsonrpcpp::Id &id, const jsonrpcpp::Parameter ¶ms) { /* Define plugin menu. Menu is defined with json with "title" and "menu_items". Each item in "menu_items" must have keys: - "callback" - callback called with RPC when button is clicked - "label" - label of button - "help" - tooltip of button ``` { "title": "< Menu title>", "menu_items": [ { "callback": "workfiles_tool", "label": "Workfiles", "help": "Open workfiles tool" }, ... ] } ``` */ Data.menuItems = params.to_json()[0]; Data.newMenuItems = true; std::string output; return std::make_shared(id, output); } jsonrpcpp::response_ptr execute_george(const jsonrpcpp::Id &id, const jsonrpcpp::Parameter ¶ms) { const char *george_script; char cmd_output[1024] = {0}; char empty_char = {0}; std::string std_george_script; std::string output; nlohmann::json json_params = params.to_json(); std_george_script = json_params[0]; george_script = std_george_script.c_str(); // Result of `TVSendCmd` is int with length of output string TVSendCmd(Data.current_filter, george_script, cmd_output); for (int i = 0; i < sizeof(cmd_output); i++) { if (cmd_output[i] == empty_char){ break; } output += cmd_output[i]; } return std::make_shared(id, output); } void register_callbacks(){ parser.register_request_callback("define_menu", define_menu); parser.register_request_callback("execute_george", execute_george); } Communicator* communication = nullptr; //////////////////////////////////////////////////////////////////////////////////////// static char* GetLocalString( PIFilter* iFilter, int iNum, char* iDefault ) { char* str; if( Data.mLocalFile == NULL ) return iDefault; str = TVGetLocalString( iFilter, Data.mLocalFile, iNum ); if( str == NULL || strlen( str ) == 0 ) return iDefault; return str; } /**************************************************************************************/ // Localisation // numbers (like 10011) are IDs in the localized file. // strings are the default values to use when the ID is not found // in the localized file (or the localized file doesn't exist). std::string label_from_evn() { std::string _plugin_label = "OpenPype"; if (std::getenv("AVALON_LABEL") && std::getenv("AVALON_LABEL") != "") { _plugin_label = std::getenv("AVALON_LABEL"); } return _plugin_label; } std::string plugin_label = label_from_evn(); #define TXT_REQUESTER GetLocalString( iFilter, 100, "OpenPype Tools" ) #define TXT_REQUESTER_ERROR GetLocalString( iFilter, 30001, "Can't Open Requester !" ) //////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////// // The functions directly called by Aura through the plugin interface /**************************************************************************************/ // "About" function. void FAR PASCAL PI_About( PIFilter* iFilter ) { char text[256]; sprintf( text, "%s %d,%d", iFilter->PIName, iFilter->PIVersion, iFilter->PIRevision ); // Just open a warning popup with the filter name and version. // You can open a much nicer requester if you want. TVWarning( iFilter, text ); } /**************************************************************************************/ // Function called at Aura startup, when the filter is loaded. // Should do as little as possible to keep Aura's startup time small. int FAR PASCAL PI_Open( PIFilter* iFilter ) { Data.current_filter = iFilter; char tmp[256]; strcpy( iFilter->PIName, plugin_label.c_str() ); iFilter->PIVersion = 1; iFilter->PIRevision = 0; // If this plugin was the one open at Aura shutdown, re-open it TVReadUserString( iFilter, iFilter->PIName, "Open", tmp, "0", 255 ); if( atoi( tmp ) ) { PI_Parameters( iFilter, NULL ); // NULL as iArg means "open the requester" } char *env_value = std::getenv("WEBSOCKET_URL"); if (env_value != NULL) { communication = new Communicator(env_value); communication->connect(); register_callbacks(); } return 1; // OK } /**************************************************************************************/ // Aura shutdown: we make all the necessary cleanup void FAR PASCAL PI_Close( PIFilter* iFilter ) { if( Data.mLocalFile ) { TVCloseLocalFile( iFilter, Data.mLocalFile ); } if( Data.mReq ) { TVCloseReq( iFilter, Data.mReq ); } if (communication != nullptr) { communication->endpoint.close_connection(); delete communication; } } int newMenuItemsProcess(PIFilter* iFilter) { // Menu items defined with `define_menu` should be propagated. // Change flag that there are new menu items (avoid infinite loop) Data.newMenuItems = false; // Skip if requester does not exists if (Data.mReq == 0) { return 0; } // Remove all previous menu items for (int menu_id : Data.menuItemsIds) { TVRemoveButtonReq(iFilter, Data.mReq, menu_id); } // Clear caches Data.menuItemsById.clear(); Data.menuItemsIds.clear(); // We use a variable to contains the vertical position of the buttons. // Each time we create a button, we add its size to this variable. // This makes it very easy to add/remove/displace buttons in a requester. int x_pos = 9; int y_pos = 5; // Menu width int menu_width = 185; // Single menu item width int btn_width = menu_width - 19; // Single row height (btn height is 18) int row_height = 20; // Additional height to menu int height_offset = 5; // This is a very simple requester, so we create it's content right here instead // of waiting for the PICBREQ_OPEN message... // Not recommended for more complex requesters. (see the other examples) const char *menu_title = TXT_REQUESTER; if (Data.menuItems.contains("title")) { menu_title = Data.menuItems["title"].get()->c_str(); } // Sets the title of the requester. TVSetReqTitle( iFilter, Data.mReq, menu_title ); // Resize menu // First get current position and sizes (we only need the position) int current_x = 0; int current_y = 0; int current_width = 0; int current_height = 0; TVInfoReq(iFilter, Data.mReq, ¤t_x, ¤t_y, ¤t_width, ¤t_height); // Calculate new height int menu_height = (row_height * Data.menuItems["menu_items"].size()) + height_offset; // Resize TVResizeReq(iFilter, Data.mReq, current_x, current_y, menu_width, menu_height); // Add menu items int item_counter = 1; for (auto& item : Data.menuItems["menu_items"].items()) { int item_id = item_counter * 10; item_counter ++; std::string item_id_str = std::to_string(item_id); nlohmann::json item_data = item.value(); const char *item_label = item_data["label"].get()->c_str(); const char *help_text = item_data["help"].get()->c_str(); std::string item_callback = item_data["callback"].get(); TVAddButtonReq(iFilter, Data.mReq, x_pos, y_pos, btn_width, 0, item_id, PIRBF_BUTTON_NORMAL|PIRBF_BUTTON_ACTION, item_label); TVSetButtonInfoText( iFilter, Data.mReq, item_id, help_text ); y_pos += row_height; Data.menuItemsById[std::to_string(item_id)] = item_callback; Data.menuItemsIds.push_back(item_id); } return 1; } /**************************************************************************************/ // we have something to do ! int FAR PASCAL PI_Parameters( PIFilter* iFilter, char* iArg ) { if( !iArg ) { // If the requester is not open, we open it. if( Data.mReq == 0) { // Create empty requester because menu items are defined with // `define_menu` callback DWORD req = TVOpenFilterReqEx( iFilter, 185, 20, NULL, NULL, PIRF_STANDARD_REQ | PIRF_COLLAPSABLE_REQ, FILTERREQ_NO_TBAR ); if( req == 0 ) { TVWarning( iFilter, TXT_REQUESTER_ERROR ); return 0; } Data.mReq = req; // This is a very simple requester, so we create it's content right here instead // of waiting for the PICBREQ_OPEN message... // Not recommended for more complex requesters. (see the other examples) // Sets the title of the requester. TVSetReqTitle( iFilter, Data.mReq, TXT_REQUESTER ); // Request to listen to ticks TVGrabTicks(iFilter, req, PITICKS_FLAG_ON); if ( Data.firstParams == true ) { Data.firstParams = false; } else { newMenuItemsProcess(iFilter); } } else { // If it is already open, we just put it on front of all other requesters. TVReqToFront( iFilter, Data.mReq ); } } return 1; } /**************************************************************************************/ // something happened that needs our attention. // Global variable where current button up data are stored std::string button_up_item_id_str; int FAR PASCAL PI_Msg( PIFilter* iFilter, INTPTR iEvent, INTPTR iReq, INTPTR* iArgs ) { Data.current_filter = iFilter; // what did happen ? switch( iEvent ) { // The user just 'clicked' on a normal button case PICBREQ_BUTTON_UP: button_up_item_id_str = std::to_string(iArgs[0]); if (Data.menuItemsById.contains(button_up_item_id_str)) { std::string callback_name = Data.menuItemsById[button_up_item_id_str].get(); communication->call_method(callback_name, nlohmann::json::array()); } TVExecute( iFilter ); break; // The requester was just closed. case PICBREQ_CLOSE: // requester doesn't exists anymore Data.mReq = 0; char tmp[256]; // Save the requester state (opened or closed) // iArgs[4] contains a flag which tells us if the requester // has been closed by the user (flag=0) or by Aura's shutdown (flag=1). // If it was by Aura's shutdown, that means this requester was the // last one open, so we should reopen this one the next time Aura // is started. Else we won't open it next time. sprintf( tmp, "%d", (int)(iArgs[4]) ); // Save it in Aura's init file. TVWriteUserString( iFilter, iFilter->PIName, "Open", tmp ); break; case PICBREQ_TICKS: if (Data.newMenuItems) { newMenuItemsProcess(iFilter); } if (communication != nullptr) { communication->process_requests(); } } return 1; } /**************************************************************************************/ // Start of the 'execution' of the filter for a new sequence. // - iNumImages contains the total number of frames to be processed. // Here you should allocate memory that is used for all frames, // and precompute all the stuff that doesn't change from frame to frame. int FAR PASCAL PI_SequenceStart( PIFilter* iFilter, int iNumImages ) { // In this simple example we don't have anything to allocate/precompute. // 1 means 'continue', 0 means 'error, abort' (like 'not enough memory') return 1; } // Here you should cleanup what you've done in PI_SequenceStart void FAR PASCAL PI_SequenceFinish( PIFilter* iFilter ) {} /**************************************************************************************/ // This is called before each frame. // Here you should allocate memory and precompute all the stuff you can. int FAR PASCAL PI_Start( PIFilter* iFilter, double iPos, double iSize ) { return 1; } void FAR PASCAL PI_Finish( PIFilter* iFilter ) { // nothing special to cleanup } /**************************************************************************************/ // 'Execution' of the filter. int FAR PASCAL PI_Work( PIFilter* iFilter ) { return 1; } ================================================ FILE: openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.def ================================================ LIBRARY Avalonplugin EXPORTS PI_Msg PI_Open PI_About PI_Parameters PI_Start PI_Work PI_Finish PI_Close ================================================ FILE: openpype/hosts/tvpaint/worker/__init__.py ================================================ from .worker_job import ( JobFailed, ExecuteSimpleGeorgeScript, ExecuteGeorgeScript, CollectSceneData, SenderTVPaintCommands, ProcessTVPaintCommands ) from .worker import main __all__ = ( "JobFailed", "ExecuteSimpleGeorgeScript", "ExecuteGeorgeScript", "CollectSceneData", "SenderTVPaintCommands", "ProcessTVPaintCommands", "main" ) ================================================ FILE: openpype/hosts/tvpaint/worker/worker.py ================================================ import os import signal import time import tempfile import shutil import asyncio from openpype.hosts.tvpaint.api.communication_server import ( BaseCommunicator, CommunicationWrapper ) from openpype_modules.job_queue.job_workers import WorkerJobsConnection from .worker_job import ProcessTVPaintCommands class TVPaintWorkerCommunicator(BaseCommunicator): """Modified commuicator which cares about processing jobs. Received jobs are send to TVPaint by parsing 'ProcessTVPaintCommands'. """ def __init__(self, server_url): super().__init__() self.return_code = 1 self._server_url = server_url self._worker_connection = None def _start_webserver(self): """Create connection to workers server before TVPaint server.""" loop = self.websocket_server.loop self._worker_connection = WorkerJobsConnection( self._server_url, "tvpaint", loop ) asyncio.ensure_future( self._worker_connection.main_loop(register_worker=False), loop=loop ) super()._start_webserver() def _open_init_file(self): """Open init TVPaint file. File triggers dialog missing path to audio file which must be closed once and is ignored for rest of running process. """ current_dir = os.path.dirname(os.path.abspath(__file__)) init_filepath = os.path.join(current_dir, "init_file.tvpp") with tempfile.NamedTemporaryFile( mode="w", prefix="a_tvp_", suffix=".tvpp" ) as tmp_file: tmp_filepath = tmp_file.name.replace("\\", "/") shutil.copy(init_filepath, tmp_filepath) george_script = "tv_LoadProject '\"'\"{}\"'\"'".format(tmp_filepath) self.execute_george_through_file(george_script) self.execute_george("tv_projectclose") os.remove(tmp_filepath) def _on_client_connect(self, *args, **kwargs): super()._on_client_connect(*args, **kwargs) self._open_init_file() # Register as "ready to work" worker self._worker_connection.register_as_worker() def stop(self): """Stop worker connection and TVPaint server.""" self._worker_connection.stop() self.return_code = 0 super().stop() @property def current_job(self): """Retrieve job which should be processed.""" if self._worker_connection: return self._worker_connection.current_job return None def _check_process(self): if self.process is None: return True if self.process.poll() is not None: asyncio.ensure_future( self._worker_connection.disconnect(), loop=self.websocket_server.loop ) self._exit() return False return True def _process_job(self): job = self.current_job if job is None: return # Prepare variables used for sendig success = False message = "Unknown function" data = None job_data = job["data"] workfile = job_data["workfile"] # Currently can process only "commands" function if job_data.get("function") == "commands": try: commands = ProcessTVPaintCommands( workfile, job_data["commands"], self ) commands.execute() data = commands.response_data() success = True message = "Executed" except Exception as exc: message = "Error on worker: {}".format(str(exc)) self._worker_connection.finish_job(success, message, data) def main_loop(self): """Main loop where jobs are processed. Server is stopped by killing this process or TVPaint process. """ while self.server_is_running: if self._check_process(): self._process_job() time.sleep(1) return self.return_code def _start_tvpaint(tvpaint_executable_path, server_url): communicator = TVPaintWorkerCommunicator(server_url) CommunicationWrapper.set_communicator(communicator) communicator.launch([tvpaint_executable_path]) def main(tvpaint_executable_path, server_url): # Register terminal signal handler def signal_handler(*_args): print("Termination signal received. Stopping.") if CommunicationWrapper.communicator is not None: CommunicationWrapper.communicator.stop() signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) _start_tvpaint(tvpaint_executable_path, server_url) communicator = CommunicationWrapper.communicator if communicator is None: print("Communicator is not set") return 1 return communicator.main_loop() ================================================ FILE: openpype/hosts/tvpaint/worker/worker_job.py ================================================ import os import tempfile import inspect import copy import json import time from uuid import uuid4 from abc import ABCMeta, abstractmethod, abstractproperty import six from openpype.lib import Logger from openpype.modules import ModulesManager TMP_FILE_PREFIX = "opw_tvp_" class JobFailed(Exception): """Raised when job was sent and finished unsuccessfully.""" def __init__(self, job_status): job_state = job_status["state"] job_message = job_status["message"] or "Unknown issue" error_msg = ( "Job didn't finish properly." " Job state: \"{}\" | Job message: \"{}\"" ).format(job_state, job_message) self.job_status = job_status super().__init__(error_msg) @six.add_metaclass(ABCMeta) class BaseCommand: """Abstract TVPaint command which can be executed through worker. Each command must have unique name and implemented 'execute' and 'from_existing' methods. Command also have id which is created on command creation. The idea is that command is just a data container on sender side send through server to a worker where is replicated one by one, executed and result sent back to sender through server. """ @abstractproperty def name(self): """Command name (must be unique).""" pass def __init__(self, data=None): if data is None: data = {} else: data = copy.deepcopy(data) # Use 'id' from data when replicating on process side command_id = data.get("id") if command_id is None: command_id = str(uuid4()) data["id"] = command_id data["command"] = self.name self._parent = None self._result = None self._command_data = data self._done = False def job_queue_root(self): """Access to job queue root. Job queue root is shared access point to files shared across senders and workers. """ if self._parent is None: return None return self._parent.job_queue_root() def set_parent(self, parent): self._parent = parent @property def id(self): """Command id.""" return self._command_data["id"] @property def parent(self): """Parent of command expected type of 'TVPaintCommands'.""" return self._parent @property def communicator(self): """TVPaint communicator. Available only on worker side. """ return self._parent.communicator @property def done(self): """Is command done.""" return self._done def set_done(self): """Change state of done.""" self._done = True def set_result(self, result): """Set result of executed command.""" self._result = result def result(self): """Result of command.""" return copy.deepcopy(self._result) def response_data(self): """Data send as response to sender.""" return { "id": self.id, "result": self._result, "done": self._done } def command_data(self): """Raw command data.""" return copy.deepcopy(self._command_data) @abstractmethod def execute(self): """Execute command on worker side.""" pass @classmethod @abstractmethod def from_existing(cls, data): """Recreate object based on passed data.""" pass def execute_george(self, george_script): """Execute george script in TVPaint.""" return self.parent.execute_george(george_script) def execute_george_through_file(self, george_script): """Execute george script through temp file in TVPaint.""" return self.parent.execute_george_through_file(george_script) class ExecuteSimpleGeorgeScript(BaseCommand): """Execute simple george script in TVPaint. Args: script(str): Script that will be executed. """ name = "execute_george_simple" def __init__(self, script, data=None): data = data or {} data["script"] = script self._script = script super().__init__(data) def execute(self): self._result = self.execute_george(self._script) @classmethod def from_existing(cls, data): script = data.pop("script") return cls(script, data) class ExecuteGeorgeScript(BaseCommand): """Execute multiline george script in TVPaint. Args: script_lines(list): Lines that will be executed in george script through temp george file. tmp_file_keys(list): List of formatting keys in george script that require replacement with path to a temp file where result will be stored. The content of file is stored to result by the key. root_dir_key(str): Formatting key that will be replaced in george script with job queue root which can be different on worker side. data(dict): Raw data about command. """ name = "execute_george_through_file" def __init__( self, script_lines, tmp_file_keys=None, root_dir_key=None, data=None ): data = data or {} if not tmp_file_keys: tmp_file_keys = data.get("tmp_file_keys") or [] data["script_lines"] = script_lines data["tmp_file_keys"] = tmp_file_keys data["root_dir_key"] = root_dir_key self._script_lines = script_lines self._tmp_file_keys = tmp_file_keys self._root_dir_key = root_dir_key super().__init__(data) def execute(self): filepath_by_key = {} script = self._script_lines if isinstance(script, list): script = "\n".join(script) # Replace temporary files in george script for key in self._tmp_file_keys: output_file = tempfile.NamedTemporaryFile( mode="w", prefix=TMP_FILE_PREFIX, suffix=".txt", delete=False ) output_file.close() format_key = "{" + key + "}" output_path = output_file.name.replace("\\", "/") script = script.replace(format_key, output_path) filepath_by_key[key] = output_path # Replace job queue root in script if self._root_dir_key: job_queue_root = self.job_queue_root() format_key = "{" + self._root_dir_key + "}" script = script.replace( format_key, job_queue_root.replace("\\", "/") ) # Execute the script self.execute_george_through_file(script) # Store result of temporary files result = {} for key, filepath in filepath_by_key.items(): with open(filepath, "r") as stream: data = stream.read() result[key] = data os.remove(filepath) self._result = result @classmethod def from_existing(cls, data): """Recreate the object from data.""" script_lines = data.pop("script_lines") tmp_file_keys = data.pop("tmp_file_keys", None) root_dir_key = data.pop("root_dir_key", None) return cls(script_lines, tmp_file_keys, root_dir_key, data) class CollectSceneData(BaseCommand): """Helper command which will collect all useful info about workfile. Result is dictionary with all layers data, exposure frames by layer ids pre/post behavior of layers by their ids, group information and scene data. """ name = "collect_scene_data" def execute(self): from openpype.hosts.tvpaint.api.lib import ( get_layers_data, get_groups_data, get_layers_pre_post_behavior, get_layers_exposure_frames, get_scene_data ) groups_data = get_groups_data(communicator=self.communicator) layers_data = get_layers_data(communicator=self.communicator) layer_ids = [ layer_data["layer_id"] for layer_data in layers_data ] pre_post_beh_by_layer_id = get_layers_pre_post_behavior( layer_ids, communicator=self.communicator ) exposure_frames_by_layer_id = get_layers_exposure_frames( layer_ids, layers_data, communicator=self.communicator ) self._result = { "layers_data": layers_data, "exposure_frames_by_layer_id": exposure_frames_by_layer_id, "pre_post_beh_by_layer_id": pre_post_beh_by_layer_id, "groups_data": groups_data, "scene_data": get_scene_data(self.communicator) } @classmethod def from_existing(cls, data): return cls(data) @six.add_metaclass(ABCMeta) class TVPaintCommands: """Wrapper around TVPaint commands to be able send multiple commands. Commands may send one or multiple commands at once. Also gives api access for commands info. Base for sender and receiver which are extending the logic for their purposes. One of differences is preparation of workfile path. Args: workfile(str): Path to workfile. job_queue_module(JobQueueModule): Object of OpenPype module JobQueue. """ def __init__(self, workfile, job_queue_module=None): self._log = None self._commands = [] self._command_classes_by_name = None if job_queue_module is None: manager = ModulesManager() job_queue_module = manager.modules_by_name["job_queue"] self._job_queue_module = job_queue_module self._workfile = self._prepare_workfile(workfile) @abstractmethod def _prepare_workfile(self, workfile): """Modification of workfile path on initialization to match platorm.""" pass def job_queue_root(self): """Job queue root for current platform using current settings.""" return self._job_queue_module.get_jobs_root_from_settings() @property def log(self): """Access to logger object.""" if self._log is None: self._log = Logger.get_logger(self.__class__.__name__) return self._log @property def classes_by_name(self): """Prepare commands classes for validation and recreation of commands. It is expected that all commands are defined in this python file so we're looking for all implementation of BaseCommand in globals. """ if self._command_classes_by_name is None: command_classes_by_name = {} for attr in globals().values(): if ( not inspect.isclass(attr) or not issubclass(attr, BaseCommand) or attr is BaseCommand ): continue if inspect.isabstract(attr): self.log.debug( "Skipping abstract class {}".format(attr.__name__) ) command_classes_by_name[attr.name] = attr self._command_classes_by_name = command_classes_by_name return self._command_classes_by_name def add_command(self, command): """Add command to process.""" command.set_parent(self) self._commands.append(command) def result(self): """Result of commands in list in which they were processed.""" return [ command.result() for command in self._commands ] def response_data(self): """Data which should be send from worker.""" return [ command.response_data() for command in self._commands ] class SenderTVPaintCommands(TVPaintCommands): """Sender implementation of TVPaint Commands.""" def _prepare_workfile(self, workfile): """Remove job queue root from workfile path. It is expected that worker will add it's root before passed workfile. """ new_workfile = workfile.replace("\\", "/") job_queue_root = self.job_queue_root().replace("\\", "/") if job_queue_root not in new_workfile: raise ValueError(( "Workfile is not located in JobQueue root." " Workfile path: \"{}\". JobQueue root: \"{}\"" ).format(workfile, job_queue_root)) return new_workfile.replace(job_queue_root, "") def commands_data(self): """Commands data to be able recreate them.""" return [ command.command_data() for command in self._commands ] def to_job_data(self): """Convert commands to job data before sending to workers server.""" return { "workfile": self._workfile, "function": "commands", "commands": self.commands_data() } def set_result(self, result): commands_by_id = { command.id: command for command in self._commands } for item in result: command = commands_by_id[item["id"]] command.set_result(item["result"]) command.set_done() def _send_job(self): """Send job to a workers server.""" # Send job data to job queue server job_data = self.to_job_data() self.log.debug("Sending job to JobQueue server.\n{}".format( json.dumps(job_data, indent=4) )) job_id = self._job_queue_module.send_job("tvpaint", job_data) self.log.info(( "Job sent to JobQueue server and got id \"{}\"." " Waiting for finishing the job." ).format(job_id)) return job_id def send_job_and_wait(self): """Send job to workers server and wait for response. Result of job is stored into the object. Raises: JobFailed: When job was finished but not successfully. """ job_id = self._send_job() while True: job_status = self._job_queue_module.get_job_status(job_id) if job_status["done"]: break time.sleep(1) # Check if job state is done if job_status["state"] != "done": raise JobFailed(job_status) self.set_result(job_status["result"]) self.log.debug("Job is done and result is stored.") class ProcessTVPaintCommands(TVPaintCommands): """Worker side of TVPaint Commands. It is expected this object is created only on worker's side from existing data loaded from job. Workfile path logic is based on 'SenderTVPaintCommands'. """ def __init__(self, workfile, commands, communicator): super(ProcessTVPaintCommands, self).__init__(workfile) self._communicator = communicator self.commands_from_data(commands) def _prepare_workfile(self, workfile): """Preprend job queue root before passed workfile.""" workfile = workfile.replace("\\", "/") job_queue_root = self.job_queue_root().replace("\\", "/") new_workfile = "/".join([job_queue_root, workfile]) while "//" in new_workfile: new_workfile = new_workfile.replace("//", "/") return os.path.normpath(new_workfile) @property def communicator(self): """Access to TVPaint communicator.""" return self._communicator def commands_from_data(self, commands_data): """Recreate command from passed data.""" for command_data in commands_data: command_name = command_data["command"] klass = self.classes_by_name[command_name] command = klass.from_existing(command_data) self.add_command(command) def execute_george(self, george_script): """Helper method to execute george script.""" return self.communicator.execute_george(george_script) def execute_george_through_file(self, george_script): """Helper method to execute george script through temp file.""" temporary_file = tempfile.NamedTemporaryFile( mode="w", prefix=TMP_FILE_PREFIX, suffix=".grg", delete=False ) temporary_file.write(george_script) temporary_file.close() temp_file_path = temporary_file.name.replace("\\", "/") self.execute_george("tv_runscript {}".format(temp_file_path)) os.remove(temp_file_path) def _open_workfile(self): """Open workfile in TVPaint.""" workfile = self._workfile print("Opening workfile {}".format(workfile)) george_script = "tv_LoadProject '\"'\"{}\"'\"'".format(workfile) self.execute_george_through_file(george_script) def _close_workfile(self): """Close workfile in TVPaint.""" print("Closing workfile") self.execute_george_through_file("tv_projectclose") def execute(self): """Execute commands.""" # First open the workfile self._open_workfile() # Execute commands one by one # TODO maybe stop processing when command fails? print("Commands execution started ({})".format(len(self._commands))) for command in self._commands: command.execute() command.set_done() # Finally close workfile self._close_workfile() ================================================ FILE: openpype/hosts/unreal/README.md ================================================ ## Unreal Integration Supported Unreal Engine version is 4.26+ (mainly because of major Python changes done there). ### Project naming Unreal doesn't support project names starting with non-alphabetic character. So names like `123_myProject` are invalid. If Ayon detects such name it automatically prepends letter **P** to make it valid name, so `123_myProject` will become `P123_myProject`. There is also soft-limit on project name length to be shorter than 20 characters. Longer names will issue warning in Unreal Editor that there might be possible side effects. ================================================ FILE: openpype/hosts/unreal/__init__.py ================================================ from .addon import UnrealAddon __all__ = ( "UnrealAddon", ) ================================================ FILE: openpype/hosts/unreal/addon.py ================================================ import os import re from openpype.modules import IHostAddon, OpenPypeModule UNREAL_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) class UnrealAddon(OpenPypeModule, IHostAddon): name = "unreal" host_name = "unreal" def initialize(self, module_settings): self.enabled = True def get_global_environments(self): return { "AYON_UNREAL_ROOT": UNREAL_ROOT_DIR, } def add_implementation_envs(self, env, app): """Modify environments to contain all required for implementation.""" # Set AYON_UNREAL_PLUGIN required for Unreal implementation # Imports are in this method for Python 2 compatiblity of an addon from pathlib import Path from .lib import get_compatible_integration from openpype.widgets.message_window import Window pattern = re.compile(r'^\d+-\d+$') if not pattern.match(app.name): msg = ( "Unreal application key in the settings must be in format" "'5-0' or '5-1'" ) Window( parent=None, title="Unreal application name format", message=msg, level="critical") raise ValueError(msg) ue_version = app.name.replace("-", ".") unreal_plugin_path = os.path.join( UNREAL_ROOT_DIR, "integration", "UE_{}".format(ue_version), "Ayon" ) if not Path(unreal_plugin_path).exists(): compatible_versions = get_compatible_integration( ue_version, Path(UNREAL_ROOT_DIR) / "integration" ) if compatible_versions: unreal_plugin_path = compatible_versions[-1] / "Ayon" unreal_plugin_path = unreal_plugin_path.as_posix() if not env.get("AYON_UNREAL_PLUGIN") or \ env.get("AYON_UNREAL_PLUGIN") != unreal_plugin_path: env["AYON_UNREAL_PLUGIN"] = unreal_plugin_path # Set default environments if are not set via settings defaults = { "OPENPYPE_LOG_NO_COLORS": "True", "UE_PYTHONPATH": os.environ.get("PYTHONPATH", ""), } for key, value in defaults.items(): if not env.get(key): env[key] = value def get_launch_hook_paths(self, app): if app.host_name != self.host_name: return [] return [ os.path.join(UNREAL_ROOT_DIR, "hooks") ] def get_workfile_extensions(self): return [".uproject"] ================================================ FILE: openpype/hosts/unreal/api/__init__.py ================================================ # -*- coding: utf-8 -*- """Unreal Editor Ayon host API.""" from .plugin import ( UnrealActorCreator, UnrealAssetCreator, Loader ) from .pipeline import ( install, uninstall, ls, publish, containerise, show_creator, show_loader, show_publisher, show_manager, show_experimental_tools, show_tools_dialog, show_tools_popup, instantiate, UnrealHost, set_sequence_hierarchy, generate_sequence, maintained_selection ) __all__ = [ "install", "uninstall", "Loader", "ls", "publish", "containerise", "show_creator", "show_loader", "show_publisher", "show_manager", "show_experimental_tools", "show_tools_dialog", "show_tools_popup", "instantiate", "UnrealHost", "set_sequence_hierarchy", "generate_sequence", "maintained_selection" ] ================================================ FILE: openpype/hosts/unreal/api/helpers.py ================================================ # -*- coding: utf-8 -*- import unreal # noqa class AyonUnrealException(Exception): pass @unreal.uclass() class AyonHelpers(unreal.AyonLib): """Class wrapping some useful functions for Ayon. This class is extending native BP class in Ayon Integration Plugin. """ @unreal.ufunction(params=[str, unreal.LinearColor, bool]) def set_folder_color(self, path: str, color: unreal.LinearColor) -> None: """Set color on folder in Content Browser. This method sets color on folder in Content Browser. Unfortunately there is no way to refresh Content Browser so new color isn't applied immediately. They are saved to config file and appears correctly only after Editor is restarted. Args: path (str): Path to folder color (:class:`unreal.LinearColor`): Color of the folder Example: AyonHelpers().set_folder_color( "/Game/Path", unreal.LinearColor(a=1.0, r=1.0, g=0.5, b=0) ) Note: This will take effect only after Editor is restarted. I couldn't find a way to refresh it. Also, this saves the color definition into the project config, binding this path with color. So if you delete this path and later re-create, it will set this color again. """ self.c_set_folder_color(path, color, False) ================================================ FILE: openpype/hosts/unreal/api/pipeline.py ================================================ # -*- coding: utf-8 -*- import os import json import logging from typing import List from contextlib import contextmanager import semver import time import pyblish.api from openpype.client import get_asset_by_name, get_assets from openpype.pipeline import ( register_loader_plugin_path, register_creator_plugin_path, register_inventory_action_path, deregister_loader_plugin_path, deregister_creator_plugin_path, deregister_inventory_action_path, AYON_CONTAINER_ID, legacy_io, ) from openpype.tools.utils import host_tools import openpype.hosts.unreal from openpype.host import HostBase, ILoadHost, IPublishHost import unreal # noqa # Rename to Ayon once parent module renames logger = logging.getLogger("openpype.hosts.unreal") AYON_CONTAINERS = "AyonContainers" AYON_ASSET_DIR = "/Game/Ayon/Assets" CONTEXT_CONTAINER = "Ayon/context.json" UNREAL_VERSION = semver.VersionInfo( *os.getenv("AYON_UNREAL_VERSION").split(".") ) HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.unreal.__file__)) PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") class UnrealHost(HostBase, ILoadHost, IPublishHost): """Unreal host implementation. For some time this class will re-use functions from module based implementation for backwards compatibility of older unreal projects. """ name = "unreal" def install(self): install() def get_containers(self): return ls() @staticmethod def show_tools_popup(): """Show tools popup with actions leading to show other tools.""" show_tools_popup() @staticmethod def show_tools_dialog(): """Show tools dialog with actions leading to show other tools.""" show_tools_dialog() def update_context_data(self, data, changes): content_path = unreal.Paths.project_content_dir() op_ctx = content_path + CONTEXT_CONTAINER attempts = 3 for i in range(attempts): try: with open(op_ctx, "w+") as f: json.dump(data, f) break except IOError as e: if i == attempts - 1: raise Exception( "Failed to write context data. Aborting.") from e unreal.log_warning("Failed to write context data. Retrying...") i += 1 time.sleep(3) continue def get_context_data(self): content_path = unreal.Paths.project_content_dir() op_ctx = content_path + CONTEXT_CONTAINER if not os.path.isfile(op_ctx): return {} with open(op_ctx, "r") as fp: data = json.load(fp) return data def install(): """Install Unreal configuration for OpenPype.""" print("-=" * 40) logo = '''. . · │ ·∙/ ·-∙•∙-· / \\ /∙· / \\ ∙ \\ │ / ∙ \\ \\ · / / \\\\ ∙ ∙ // \\\\/ \\// ___ │ │ │ │ │ │ │___│ -· ·-─═─-∙ A Y O N ∙-─═─-· by YNPUT . ''' print(logo) print("installing Ayon for Unreal ...") print("-=" * 40) logger.info("installing Ayon for Unreal") pyblish.api.register_host("unreal") pyblish.api.register_plugin_path(str(PUBLISH_PATH)) register_loader_plugin_path(str(LOAD_PATH)) register_creator_plugin_path(str(CREATE_PATH)) register_inventory_action_path(str(INVENTORY_PATH)) _register_callbacks() _register_events() def uninstall(): """Uninstall Unreal configuration for Ayon.""" pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) deregister_loader_plugin_path(str(LOAD_PATH)) deregister_creator_plugin_path(str(CREATE_PATH)) deregister_inventory_action_path(str(INVENTORY_PATH)) def _register_callbacks(): """ TODO: Implement callbacks if supported by UE """ pass def _register_events(): """ TODO: Implement callbacks if supported by UE """ pass def ls(): """List all containers. List all found in *Content Manager* of Unreal and return metadata from them. Adding `objectName` to set. """ ar = unreal.AssetRegistryHelpers.get_asset_registry() # UE 5.1 changed how class name is specified class_name = ["/Script/Ayon", "AyonAssetContainer"] if UNREAL_VERSION.major == 5 and UNREAL_VERSION.minor > 0 else "AyonAssetContainer" # noqa ayon_containers = ar.get_assets_by_class(class_name, True) # get_asset_by_class returns AssetData. To get all metadata we need to # load asset. get_tag_values() work only on metadata registered in # Asset Registry Project settings (and there is no way to set it with # python short of editing ini configuration file). for asset_data in ayon_containers: asset = asset_data.get_asset() data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) data["objectName"] = asset_data.asset_name yield cast_map_to_str_dict(data) def ls_inst(): ar = unreal.AssetRegistryHelpers.get_asset_registry() # UE 5.1 changed how class name is specified class_name = [ "/Script/Ayon", "AyonPublishInstance" ] if ( UNREAL_VERSION.major == 5 and UNREAL_VERSION.minor > 0 ) else "AyonPublishInstance" # noqa instances = ar.get_assets_by_class(class_name, True) # get_asset_by_class returns AssetData. To get all metadata we need to # load asset. get_tag_values() work only on metadata registered in # Asset Registry Project settings (and there is no way to set it with # python short of editing ini configuration file). for asset_data in instances: asset = asset_data.get_asset() data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) data["objectName"] = asset_data.asset_name yield cast_map_to_str_dict(data) def parse_container(container): """To get data from container, AyonAssetContainer must be loaded. Args: container(str): path to container Returns: dict: metadata stored on container """ asset = unreal.EditorAssetLibrary.load_asset(container) data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) data["objectName"] = asset.get_name() data = cast_map_to_str_dict(data) return data def publish(): """Shorthand to publish from within host.""" import pyblish.util return pyblish.util.publish() def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"): """Bundles *nodes* (assets) into a *container* and add metadata to it. Unreal doesn't support *groups* of assets that you can add metadata to. But it does support folders that helps to organize asset. Unfortunately those folders are just that - you cannot add any additional information to them. Ayon Integration Plugin is providing way out - Implementing `AssetContainer` Blueprint class. This class when added to folder can handle metadata on it using standard :func:`unreal.EditorAssetLibrary.set_metadata_tag()` and :func:`unreal.EditorAssetLibrary.get_metadata_tag_values()`. It also stores and monitor all changes in assets in path where it resides. List of those assets is available as `assets` property. This is list of strings starting with asset type and ending with its path: `Material /Game/Ayon/Test/TestMaterial.TestMaterial` """ # 1 - create directory for container root = "/Game" container_name = f"{name}{suffix}" new_name = move_assets_to_path(root, container_name, nodes) # 2 - create Asset Container there path = f"{root}/{new_name}" create_container(container=container_name, path=path) namespace = path data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "name": new_name, "namespace": namespace, "loader": str(loader), "representation": context["representation"]["_id"], } # 3 - imprint data imprint(f"{path}/{container_name}", data) return path def instantiate(root, name, data, assets=None, suffix="_INS"): """Bundles *nodes* into *container*. Marking it with metadata as publishable instance. If assets are provided, they are moved to new path where `AyonPublishInstance` class asset is created and imprinted with metadata. This can then be collected for publishing by Pyblish for example. Args: root (str): root path where to create instance container name (str): name of the container data (dict): data to imprint on container assets (list of str): list of asset paths to include in publish instance suffix (str): suffix string to append to instance name """ container_name = f"{name}{suffix}" # if we specify assets, create new folder and move them there. If not, # just create empty folder if assets: new_name = move_assets_to_path(root, container_name, assets) else: new_name = create_folder(root, name) path = f"{root}/{new_name}" create_publish_instance(instance=container_name, path=path) imprint(f"{path}/{container_name}", data) def imprint(node, data): loaded_asset = unreal.EditorAssetLibrary.load_asset(node) for key, value in data.items(): # Support values evaluated at imprint if callable(value): value = value() # Unreal doesn't support NoneType in metadata values if value is None: value = "" unreal.EditorAssetLibrary.set_metadata_tag( loaded_asset, key, str(value) ) with unreal.ScopedEditorTransaction("Ayon containerising"): unreal.EditorAssetLibrary.save_asset(node) def show_tools_popup(): """Show popup with tools. Popup will disappear on click or losing focus. """ from openpype.hosts.unreal.api import tools_ui tools_ui.show_tools_popup() def show_tools_dialog(): """Show dialog with tools. Dialog will stay visible. """ from openpype.hosts.unreal.api import tools_ui tools_ui.show_tools_dialog() def show_creator(): host_tools.show_creator() def show_loader(): host_tools.show_loader(use_context=True) def show_publisher(): host_tools.show_publish() def show_manager(): host_tools.show_scene_inventory() def show_experimental_tools(): host_tools.show_experimental_tools_dialog() def create_folder(root: str, name: str) -> str: """Create new folder. If folder exists, append number at the end and try again, incrementing if needed. Args: root (str): path root name (str): folder name Returns: str: folder name Example: >>> create_folder("/Game/Foo") /Game/Foo >>> create_folder("/Game/Foo") /Game/Foo1 """ eal = unreal.EditorAssetLibrary index = 1 while True: if eal.does_directory_exist(f"{root}/{name}"): name = f"{name}{index}" index += 1 else: eal.make_directory(f"{root}/{name}") break return name def move_assets_to_path(root: str, name: str, assets: List[str]) -> str: """Moving (renaming) list of asset paths to new destination. Args: root (str): root of the path (eg. `/Game`) name (str): name of destination directory (eg. `Foo` ) assets (list of str): list of asset paths Returns: str: folder name Example: This will get paths of all assets under `/Game/Test` and move them to `/Game/NewTest`. If `/Game/NewTest` already exists, then resulting path will be `/Game/NewTest1` >>> assets = unreal.EditorAssetLibrary.list_assets("/Game/Test") >>> move_assets_to_path("/Game", "NewTest", assets) NewTest """ eal = unreal.EditorAssetLibrary name = create_folder(root, name) unreal.log(assets) for asset in assets: loaded = eal.load_asset(asset) eal.rename_asset(asset, f"{root}/{name}/{loaded.get_name()}") return name def create_container(container: str, path: str) -> unreal.Object: """Helper function to create Asset Container class on given path. This Asset Class helps to mark given path as Container and enable asset version control on it. Args: container (str): Asset Container name path (str): Path where to create Asset Container. This path should point into container folder Returns: :class:`unreal.Object`: instance of created asset Example: create_container( "/Game/modelingFooCharacter_CON", "modelingFooCharacter_CON" ) """ factory = unreal.AyonAssetContainerFactory() tools = unreal.AssetToolsHelpers().get_asset_tools() return tools.create_asset(container, path, None, factory) def create_publish_instance(instance: str, path: str) -> unreal.Object: """Helper function to create Ayon Publish Instance on given path. This behaves similarly as :func:`create_ayon_container`. Args: path (str): Path where to create Publish Instance. This path should point into container folder instance (str): Publish Instance name Returns: :class:`unreal.Object`: instance of created asset Example: create_publish_instance( "/Game/modelingFooCharacter_INST", "modelingFooCharacter_INST" ) """ factory = unreal.AyonPublishInstanceFactory() tools = unreal.AssetToolsHelpers().get_asset_tools() return tools.create_asset(instance, path, None, factory) def cast_map_to_str_dict(umap) -> dict: """Cast Unreal Map to dict. Helper function to cast Unreal Map object to plain old python dict. This will also cast values and keys to str. Useful for metadata dicts. Args: umap: Unreal Map object Returns: dict """ return {str(key): str(value) for (key, value) in umap.items()} def get_subsequences(sequence: unreal.LevelSequence): """Get list of subsequences from sequence. Args: sequence (unreal.LevelSequence): Sequence Returns: list(unreal.LevelSequence): List of subsequences """ tracks = sequence.get_master_tracks() subscene_track = next( ( t for t in tracks if t.get_class() == unreal.MovieSceneSubTrack.static_class() ), None, ) if subscene_track is not None and subscene_track.get_sections(): return subscene_track.get_sections() return [] def set_sequence_hierarchy( seq_i, seq_j, max_frame_i, min_frame_j, max_frame_j, map_paths ): # Get existing sequencer tracks or create them if they don't exist tracks = seq_i.get_master_tracks() subscene_track = None visibility_track = None for t in tracks: if t.get_class() == unreal.MovieSceneSubTrack.static_class(): subscene_track = t if (t.get_class() == unreal.MovieSceneLevelVisibilityTrack.static_class()): visibility_track = t if not subscene_track: subscene_track = seq_i.add_master_track(unreal.MovieSceneSubTrack) if not visibility_track: visibility_track = seq_i.add_master_track( unreal.MovieSceneLevelVisibilityTrack) # Create the sub-scene section subscenes = subscene_track.get_sections() subscene = None for s in subscenes: if s.get_editor_property('sub_sequence') == seq_j: subscene = s break if not subscene: subscene = subscene_track.add_section() subscene.set_row_index(len(subscene_track.get_sections())) subscene.set_editor_property('sub_sequence', seq_j) subscene.set_range( min_frame_j, max_frame_j + 1) # Create the visibility section ar = unreal.AssetRegistryHelpers.get_asset_registry() maps = [] for m in map_paths: # Unreal requires to load the level to get the map name unreal.EditorLevelLibrary.save_all_dirty_levels() unreal.EditorLevelLibrary.load_level(m) maps.append(str(ar.get_asset_by_object_path(m).asset_name)) vis_section = visibility_track.add_section() index = len(visibility_track.get_sections()) vis_section.set_range( min_frame_j, max_frame_j + 1) vis_section.set_visibility(unreal.LevelVisibility.VISIBLE) vis_section.set_row_index(index) vis_section.set_level_names(maps) if min_frame_j > 1: hid_section = visibility_track.add_section() hid_section.set_range( 1, min_frame_j) hid_section.set_visibility(unreal.LevelVisibility.HIDDEN) hid_section.set_row_index(index) hid_section.set_level_names(maps) if max_frame_j < max_frame_i: hid_section = visibility_track.add_section() hid_section.set_range( max_frame_j + 1, max_frame_i + 1) hid_section.set_visibility(unreal.LevelVisibility.HIDDEN) hid_section.set_row_index(index) hid_section.set_level_names(maps) def generate_sequence(h, h_dir): tools = unreal.AssetToolsHelpers().get_asset_tools() sequence = tools.create_asset( asset_name=h, package_path=h_dir, asset_class=unreal.LevelSequence, factory=unreal.LevelSequenceFactoryNew() ) project_name = legacy_io.active_project() asset_data = get_asset_by_name( project_name, h_dir.split('/')[-1], fields=["_id", "data.fps"] ) start_frames = [] end_frames = [] elements = list(get_assets( project_name, parent_ids=[asset_data["_id"]], fields=["_id", "data.clipIn", "data.clipOut"] )) for e in elements: start_frames.append(e.get('data').get('clipIn')) end_frames.append(e.get('data').get('clipOut')) elements.extend(get_assets( project_name, parent_ids=[e["_id"]], fields=["_id", "data.clipIn", "data.clipOut"] )) min_frame = min(start_frames) max_frame = max(end_frames) fps = asset_data.get('data').get("fps") sequence.set_display_rate( unreal.FrameRate(fps, 1.0)) sequence.set_playback_start(min_frame) sequence.set_playback_end(max_frame) sequence.set_work_range_start(min_frame / fps) sequence.set_work_range_end(max_frame / fps) sequence.set_view_range_start(min_frame / fps) sequence.set_view_range_end(max_frame / fps) tracks = sequence.get_master_tracks() track = None for t in tracks: if (t.get_class() == unreal.MovieSceneCameraCutTrack.static_class()): track = t break if not track: track = sequence.add_master_track( unreal.MovieSceneCameraCutTrack) return sequence, (min_frame, max_frame) def _get_comps_and_assets( component_class, asset_class, old_assets, new_assets, selected ): eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) components = [] if selected: sel_actors = eas.get_selected_level_actors() for actor in sel_actors: comps = actor.get_components_by_class(component_class) components.extend(comps) else: comps = eas.get_all_level_actors_components() components = [ c for c in comps if isinstance(c, component_class) ] # Get all the static meshes among the old assets in a dictionary with # the name as key selected_old_assets = {} for a in old_assets: asset = unreal.EditorAssetLibrary.load_asset(a) if isinstance(asset, asset_class): selected_old_assets[asset.get_name()] = asset # Get all the static meshes among the new assets in a dictionary with # the name as key selected_new_assets = {} for a in new_assets: asset = unreal.EditorAssetLibrary.load_asset(a) if isinstance(asset, asset_class): selected_new_assets[asset.get_name()] = asset return components, selected_old_assets, selected_new_assets def replace_static_mesh_actors(old_assets, new_assets, selected): smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) static_mesh_comps, old_meshes, new_meshes = _get_comps_and_assets( unreal.StaticMeshComponent, unreal.StaticMesh, old_assets, new_assets, selected ) for old_name, old_mesh in old_meshes.items(): new_mesh = new_meshes.get(old_name) if not new_mesh: continue smes.replace_mesh_components_meshes( static_mesh_comps, old_mesh, new_mesh) def replace_skeletal_mesh_actors(old_assets, new_assets, selected): skeletal_mesh_comps, old_meshes, new_meshes = _get_comps_and_assets( unreal.SkeletalMeshComponent, unreal.SkeletalMesh, old_assets, new_assets, selected ) for old_name, old_mesh in old_meshes.items(): new_mesh = new_meshes.get(old_name) if not new_mesh: continue for comp in skeletal_mesh_comps: if comp.get_skeletal_mesh_asset() == old_mesh: comp.set_skeletal_mesh_asset(new_mesh) def replace_geometry_cache_actors(old_assets, new_assets, selected): geometry_cache_comps, old_caches, new_caches = _get_comps_and_assets( unreal.GeometryCacheComponent, unreal.GeometryCache, old_assets, new_assets, selected ) for old_name, old_mesh in old_caches.items(): new_mesh = new_caches.get(old_name) if not new_mesh: continue for comp in geometry_cache_comps: if comp.get_editor_property("geometry_cache") == old_mesh: comp.set_geometry_cache(new_mesh) def delete_asset_if_unused(container, asset_content): ar = unreal.AssetRegistryHelpers.get_asset_registry() references = set() for asset_path in asset_content: asset = ar.get_asset_by_object_path(asset_path) refs = ar.get_referencers( asset.package_name, unreal.AssetRegistryDependencyOptions( include_soft_package_references=False, include_hard_package_references=True, include_searchable_names=False, include_soft_management_references=False, include_hard_management_references=False )) if not refs: continue references = references.union(set(refs)) # Filter out references that are in the Temp folder cleaned_references = { ref for ref in references if not str(ref).startswith("/Temp/")} # Check which of the references are Levels for ref in cleaned_references: loaded_asset = unreal.EditorAssetLibrary.load_asset(ref) if isinstance(loaded_asset, unreal.World): # If there is at least a level, we can stop, we don't want to # delete the container return unreal.log("Previous version unused, deleting...") # No levels, delete the asset unreal.EditorAssetLibrary.delete_directory(container["namespace"]) @contextmanager def maintained_selection(): """Stub to be either implemented or replaced. This is needed for old publisher implementation, but it is not supported (yet) in UE. """ try: yield finally: pass ================================================ FILE: openpype/hosts/unreal/api/plugin.py ================================================ # -*- coding: utf-8 -*- import ast import collections import sys import six from abc import ( ABC, ABCMeta, ) import unreal from .pipeline import ( create_publish_instance, imprint, ls_inst, UNREAL_VERSION ) from openpype.lib import ( BoolDef, UILabelDef ) from openpype.pipeline import ( Creator, LoaderPlugin, CreatorError, CreatedInstance ) @six.add_metaclass(ABCMeta) class UnrealBaseCreator(Creator): """Base class for Unreal creator plugins.""" root = "/Game/Ayon/AyonPublishInstances" suffix = "_INS" @staticmethod def cache_subsets(shared_data): """Cache instances for Creators to shared data. Create `unreal_cached_subsets` key when needed in shared data and fill it with all collected instances from the scene under its respective creator identifiers. If legacy instances are detected in the scene, create `unreal_cached_legacy_subsets` there and fill it with all legacy subsets under family as a key. Args: Dict[str, Any]: Shared data. Return: Dict[str, Any]: Shared data dictionary. """ if shared_data.get("unreal_cached_subsets") is None: unreal_cached_subsets = collections.defaultdict(list) unreal_cached_legacy_subsets = collections.defaultdict(list) for instance in ls_inst(): creator_id = instance.get("creator_identifier") if creator_id: unreal_cached_subsets[creator_id].append(instance) else: family = instance.get("family") unreal_cached_legacy_subsets[family].append(instance) shared_data["unreal_cached_subsets"] = unreal_cached_subsets shared_data["unreal_cached_legacy_subsets"] = ( unreal_cached_legacy_subsets ) return shared_data def create(self, subset_name, instance_data, pre_create_data): try: instance_name = f"{subset_name}{self.suffix}" pub_instance = create_publish_instance(instance_name, self.root) instance_data["subset"] = subset_name instance_data["instance_path"] = f"{self.root}/{instance_name}" instance = CreatedInstance( self.family, subset_name, instance_data, self) self._add_instance_to_context(instance) pub_instance.set_editor_property('add_external_assets', True) assets = pub_instance.get_editor_property('asset_data_external') ar = unreal.AssetRegistryHelpers.get_asset_registry() for member in pre_create_data.get("members", []): obj = ar.get_asset_by_object_path(member).get_asset() assets.add(obj) imprint(f"{self.root}/{instance_name}", instance.data_to_store()) return instance except Exception as er: six.reraise( CreatorError, CreatorError(f"Creator error: {er}"), sys.exc_info()[2]) def collect_instances(self): # cache instances if missing self.cache_subsets(self.collection_shared_data) for instance in self.collection_shared_data[ "unreal_cached_subsets"].get(self.identifier, []): # Unreal saves metadata as string, so we need to convert it back instance['creator_attributes'] = ast.literal_eval( instance.get('creator_attributes', '{}')) instance['publish_attributes'] = ast.literal_eval( instance.get('publish_attributes', '{}')) created_instance = CreatedInstance.from_existing(instance, self) self._add_instance_to_context(created_instance) def update_instances(self, update_list): for created_inst, changes in update_list: instance_node = created_inst.get("instance_path", "") if not instance_node: unreal.log_warning( f"Instance node not found for {created_inst}") continue new_values = { key: changes[key].new_value for key in changes.changed_keys } imprint( instance_node, new_values ) def remove_instances(self, instances): for instance in instances: instance_node = instance.data.get("instance_path", "") if instance_node: unreal.EditorAssetLibrary.delete_asset(instance_node) self._remove_instance_from_context(instance) @six.add_metaclass(ABCMeta) class UnrealAssetCreator(UnrealBaseCreator): """Base class for Unreal creator plugins based on assets.""" def create(self, subset_name, instance_data, pre_create_data): """Create instance of the asset. Args: subset_name (str): Name of the subset. instance_data (dict): Data for the instance. pre_create_data (dict): Data for the instance. Returns: CreatedInstance: Created instance. """ try: # Check if instance data has members, filled by the plugin. # If not, use selection. if not pre_create_data.get("members"): pre_create_data["members"] = [] if pre_create_data.get("use_selection"): utilib = unreal.EditorUtilityLibrary sel_objects = utilib.get_selected_assets() pre_create_data["members"] = [ a.get_path_name() for a in sel_objects] super(UnrealAssetCreator, self).create( subset_name, instance_data, pre_create_data) except Exception as er: six.reraise( CreatorError, CreatorError(f"Creator error: {er}"), sys.exc_info()[2]) def get_pre_create_attr_defs(self): return [ BoolDef("use_selection", label="Use selection", default=True) ] @six.add_metaclass(ABCMeta) class UnrealActorCreator(UnrealBaseCreator): """Base class for Unreal creator plugins based on actors.""" def create(self, subset_name, instance_data, pre_create_data): """Create instance of the asset. Args: subset_name (str): Name of the subset. instance_data (dict): Data for the instance. pre_create_data (dict): Data for the instance. Returns: CreatedInstance: Created instance. """ try: if UNREAL_VERSION.major == 5: world = unreal.UnrealEditorSubsystem().get_editor_world() else: world = unreal.EditorLevelLibrary.get_editor_world() # Check if the level is saved if world.get_path_name().startswith("/Temp/"): raise CreatorError( "Level must be saved before creating instances.") # Check if instance data has members, filled by the plugin. # If not, use selection. if not instance_data.get("members"): actor_subsystem = unreal.EditorActorSubsystem() sel_actors = actor_subsystem.get_selected_level_actors() selection = [a.get_path_name() for a in sel_actors] instance_data["members"] = selection instance_data["level"] = world.get_path_name() super(UnrealActorCreator, self).create( subset_name, instance_data, pre_create_data) except Exception as er: six.reraise( CreatorError, CreatorError(f"Creator error: {er}"), sys.exc_info()[2]) def get_pre_create_attr_defs(self): return [ UILabelDef("Select actors to create instance from them.") ] class Loader(LoaderPlugin, ABC): """This serves as skeleton for future Ayon specific functionality""" pass ================================================ FILE: openpype/hosts/unreal/api/rendering.py ================================================ import os import unreal from openpype.settings import get_project_settings from openpype.pipeline import Anatomy from openpype.hosts.unreal.api import pipeline from openpype.widgets.message_window import Window queue = None executor = None def _queue_finish_callback(exec, success): unreal.log("Render completed. Success: " + str(success)) # Delete our reference so we don't keep it alive. global executor global queue del executor del queue def _job_finish_callback(job, success): # You can make any edits you want to the editor world here, and the world # will be duplicated when the next render happens. Make sure you undo your # edits in OnQueueFinishedCallback if you don't want to leak state changes # into the editor world. unreal.log("Individual job completed.") def start_rendering(): """ Start the rendering process. """ unreal.log("Starting rendering...") # Get selected sequences assets = unreal.EditorUtilityLibrary.get_selected_assets() if not assets: Window( parent=None, title="No assets selected", message="No assets selected. Select a render instance.", level="warning") raise RuntimeError( "No assets selected. You need to select a render instance.") # instances = pipeline.ls_inst() instances = [ a for a in assets if a.get_class().get_name() == "AyonPublishInstance"] inst_data = [] for i in instances: data = pipeline.parse_container(i.get_path_name()) if data["family"] == "render": inst_data.append(data) try: project = os.environ.get("AVALON_PROJECT") anatomy = Anatomy(project) root = anatomy.roots['renders'] except Exception as e: raise Exception( "Could not find render root in anatomy settings.") from e render_dir = f"{root}/{project}" # subsystem = unreal.get_editor_subsystem( # unreal.MoviePipelineQueueSubsystem) # queue = subsystem.get_queue() global queue queue = unreal.MoviePipelineQueue() ar = unreal.AssetRegistryHelpers.get_asset_registry() data = get_project_settings(project) config = None config_path = str(data.get("unreal").get("render_config_path")) if config_path and unreal.EditorAssetLibrary.does_asset_exist(config_path): unreal.log("Found saved render configuration") config = ar.get_asset_by_object_path(config_path).get_asset() for i in inst_data: sequence = ar.get_asset_by_object_path(i["sequence"]).get_asset() sequences = [{ "sequence": sequence, "output": f"{i['output']}", "frame_range": ( int(float(i["frameStart"])), int(float(i["frameEnd"])) + 1) }] render_list = [] # Get all the sequences to render. If there are subsequences, # add them and their frame ranges to the render list. We also # use the names for the output paths. for seq in sequences: subscenes = pipeline.get_subsequences(seq.get('sequence')) if subscenes: for sub_seq in subscenes: sequences.append({ "sequence": sub_seq.get_sequence(), "output": (f"{seq.get('output')}/" f"{sub_seq.get_sequence().get_name()}"), "frame_range": ( sub_seq.get_start_frame(), sub_seq.get_end_frame()) }) else: # Avoid rendering camera sequences if "_camera" not in seq.get('sequence').get_name(): render_list.append(seq) # Create the rendering jobs and add them to the queue. for render_setting in render_list: job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob) job.sequence = unreal.SoftObjectPath(i["master_sequence"]) job.map = unreal.SoftObjectPath(i["master_level"]) job.author = "Ayon" # If we have a saved configuration, copy it to the job. if config: job.get_configuration().copy_from(config) # User data could be used to pass data to the job, that can be # read in the job's OnJobFinished callback. We could, # for instance, pass the AyonPublishInstance's path to the job. # job.user_data = "" output_dir = render_setting.get('output') shot_name = render_setting.get('sequence').get_name() settings = job.get_configuration().find_or_add_setting_by_class( unreal.MoviePipelineOutputSetting) settings.output_resolution = unreal.IntPoint(1920, 1080) settings.custom_start_frame = render_setting.get("frame_range")[0] settings.custom_end_frame = render_setting.get("frame_range")[1] settings.use_custom_playback_range = True settings.file_name_format = f"{shot_name}" + ".{frame_number}" settings.output_directory.path = f"{render_dir}/{output_dir}" job.get_configuration().find_or_add_setting_by_class( unreal.MoviePipelineDeferredPassBase) render_format = data.get("unreal").get("render_format", "png") if render_format == "png": job.get_configuration().find_or_add_setting_by_class( unreal.MoviePipelineImageSequenceOutput_PNG) elif render_format == "exr": job.get_configuration().find_or_add_setting_by_class( unreal.MoviePipelineImageSequenceOutput_EXR) elif render_format == "jpg": job.get_configuration().find_or_add_setting_by_class( unreal.MoviePipelineImageSequenceOutput_JPG) elif render_format == "bmp": job.get_configuration().find_or_add_setting_by_class( unreal.MoviePipelineImageSequenceOutput_BMP) # If there are jobs in the queue, start the rendering process. if queue.get_jobs(): global executor executor = unreal.MoviePipelinePIEExecutor() preroll_frames = data.get("unreal").get("preroll_frames", 0) settings = unreal.MoviePipelinePIEExecutorSettings() settings.set_editor_property( "initial_delay_frame_count", preroll_frames) executor.on_executor_finished_delegate.add_callable_unique( _queue_finish_callback) executor.on_individual_job_finished_delegate.add_callable_unique( _job_finish_callback) # Only available on PIE Executor executor.execute(queue) ================================================ FILE: openpype/hosts/unreal/api/tools_ui.py ================================================ import sys from qtpy import QtWidgets, QtCore, QtGui from openpype import ( resources, style ) from openpype.tools.utils import host_tools from openpype.tools.utils.lib import qt_app_context from openpype.hosts.unreal.api import rendering class ToolsBtnsWidget(QtWidgets.QWidget): """Widget containing buttons which are clickable.""" tool_required = QtCore.Signal(str) def __init__(self, parent=None): super(ToolsBtnsWidget, self).__init__(parent) load_btn = QtWidgets.QPushButton("Load...", self) publish_btn = QtWidgets.QPushButton("Publisher...", self) manage_btn = QtWidgets.QPushButton("Manage...", self) render_btn = QtWidgets.QPushButton("Render...", self) experimental_tools_btn = QtWidgets.QPushButton( "Experimental tools...", self ) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(load_btn, 0) layout.addWidget(publish_btn, 0) layout.addWidget(manage_btn, 0) layout.addWidget(render_btn, 0) layout.addWidget(experimental_tools_btn, 0) layout.addStretch(1) load_btn.clicked.connect(self._on_load) publish_btn.clicked.connect(self._on_publish) manage_btn.clicked.connect(self._on_manage) render_btn.clicked.connect(self._on_render) experimental_tools_btn.clicked.connect(self._on_experimental) def _on_create(self): self.tool_required.emit("creator") def _on_load(self): self.tool_required.emit("loader") def _on_publish(self): self.tool_required.emit("publisher") def _on_manage(self): self.tool_required.emit("sceneinventory") def _on_render(self): rendering.start_rendering() def _on_experimental(self): self.tool_required.emit("experimental_tools") class ToolsDialog(QtWidgets.QDialog): """Dialog with tool buttons that will stay opened until user close it.""" def __init__(self, *args, **kwargs): super(ToolsDialog, self).__init__(*args, **kwargs) self.setWindowTitle("Ayon tools") icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( QtCore.Qt.Window | QtCore.Qt.WindowStaysOnTopHint ) self.setFocusPolicy(QtCore.Qt.StrongFocus) tools_widget = ToolsBtnsWidget(self) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(tools_widget) tools_widget.tool_required.connect(self._on_tool_require) self._tools_widget = tools_widget self._first_show = True def sizeHint(self): result = super(ToolsDialog, self).sizeHint() result.setWidth(result.width() * 2) return result def showEvent(self, event): super(ToolsDialog, self).showEvent(event) if self._first_show: self.setStyleSheet(style.load_stylesheet()) self._first_show = False def _on_tool_require(self, tool_name): host_tools.show_tool_by_name(tool_name, parent=self) class ToolsPopup(ToolsDialog): """Popup with tool buttons that will close when loose focus.""" def __init__(self, *args, **kwargs): super(ToolsPopup, self).__init__(*args, **kwargs) self.setWindowFlags( QtCore.Qt.FramelessWindowHint | QtCore.Qt.Popup ) def showEvent(self, event): super(ToolsPopup, self).showEvent(event) app = QtWidgets.QApplication.instance() app.processEvents() pos = QtGui.QCursor.pos() self.move(pos) class WindowCache: """Cached objects and methods to be used in global scope.""" _dialog = None _popup = None _first_show = True @classmethod def _before_show(cls): """Create QApplication if does not exists yet.""" if not cls._first_show: return cls._first_show = False if not QtWidgets.QApplication.instance(): QtWidgets.QApplication(sys.argv) @classmethod def show_popup(cls): cls._before_show() with qt_app_context(): if cls._popup is None: cls._popup = ToolsPopup() cls._popup.show() @classmethod def show_dialog(cls): cls._before_show() with qt_app_context(): if cls._dialog is None: cls._dialog = ToolsDialog() cls._dialog.show() cls._dialog.raise_() cls._dialog.activateWindow() def show_tools_popup(): WindowCache.show_popup() def show_tools_dialog(): WindowCache.show_dialog() ================================================ FILE: openpype/hosts/unreal/hooks/pre_workfile_preparation.py ================================================ # -*- coding: utf-8 -*- """Hook to launch Unreal and prepare projects.""" import os import copy import shutil import tempfile from pathlib import Path from qtpy import QtCore from openpype import resources from openpype.lib.applications import ( PreLaunchHook, ApplicationLaunchFailed, LaunchTypes, ) from openpype.pipeline.workfile import get_workfile_template_key import openpype.hosts.unreal.lib as unreal_lib from openpype.hosts.unreal.ue_workers import ( UEProjectGenerationWorker, UEPluginInstallWorker ) from openpype.hosts.unreal.ui import SplashScreen class UnrealPrelaunchHook(PreLaunchHook): """Hook to handle launching Unreal. This hook will check if current workfile path has Unreal project inside. IF not, it initializes it, and finally it pass path to the project by environment variable to Unreal launcher shell script. """ app_groups = {"unreal"} launch_types = {LaunchTypes.local} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.signature = f"( {self.__class__.__name__} )" def _get_work_filename(self): # Use last workfile if was found if self.data.get("last_workfile_path"): last_workfile = Path(self.data.get("last_workfile_path")) if last_workfile and last_workfile.exists(): return last_workfile.name # Prepare data for fill data and for getting workfile template key anatomy = self.data["anatomy"] project_doc = self.data["project_doc"] # Use already prepared workdir data workdir_data = copy.deepcopy(self.data["workdir_data"]) task_type = workdir_data.get("task", {}).get("type") # QUESTION raise exception if version is part of filename template? workdir_data["version"] = 1 workdir_data["ext"] = "uproject" # Get workfile template key for current context workfile_template_key = get_workfile_template_key( task_type, self.host_name, project_name=project_doc["name"] ) # Fill templates template_obj = anatomy.templates_obj[workfile_template_key]["file"] # Return filename return template_obj.format_strict(workdir_data) def exec_plugin_install(self, engine_path: Path, env: dict = None): # set up the QThread and worker with necessary signals env = env or os.environ q_thread = QtCore.QThread() ue_plugin_worker = UEPluginInstallWorker() q_thread.started.connect(ue_plugin_worker.run) ue_plugin_worker.setup(engine_path, env) ue_plugin_worker.moveToThread(q_thread) splash_screen = SplashScreen( "Installing plugin", resources.get_resource("app_icons", "ue4.png") ) # set up the splash screen with necessary triggers ue_plugin_worker.installing.connect( splash_screen.update_top_label_text ) ue_plugin_worker.progress.connect(splash_screen.update_progress) ue_plugin_worker.log.connect(splash_screen.append_log) ue_plugin_worker.finished.connect(splash_screen.quit_and_close) ue_plugin_worker.failed.connect(splash_screen.fail) splash_screen.start_thread(q_thread) splash_screen.show_ui() if not splash_screen.was_proc_successful(): raise ApplicationLaunchFailed("Couldn't run the application! " "Plugin failed to install!") def exec_ue_project_gen(self, engine_version: str, unreal_project_name: str, engine_path: Path, project_dir: Path): self.log.info(( f"{self.signature} Creating unreal " f"project [ {unreal_project_name} ]" )) q_thread = QtCore.QThread() ue_project_worker = UEProjectGenerationWorker() ue_project_worker.setup( engine_version, self.data["project_name"], unreal_project_name, engine_path, project_dir ) ue_project_worker.moveToThread(q_thread) q_thread.started.connect(ue_project_worker.run) splash_screen = SplashScreen( "Initializing UE project", resources.get_resource("app_icons", "ue4.png") ) ue_project_worker.stage_begin.connect( splash_screen.update_top_label_text ) ue_project_worker.progress.connect(splash_screen.update_progress) ue_project_worker.log.connect(splash_screen.append_log) ue_project_worker.finished.connect(splash_screen.quit_and_close) ue_project_worker.failed.connect(splash_screen.fail) splash_screen.start_thread(q_thread) splash_screen.show_ui() if not splash_screen.was_proc_successful(): raise ApplicationLaunchFailed("Couldn't run the application! " "Failed to generate the project!") def execute(self): """Hook entry method.""" workdir = self.launch_context.env["AVALON_WORKDIR"] executable = str(self.launch_context.executable) engine_version = self.app_name.split("/")[-1].replace("-", ".") try: if int(engine_version.split(".")[0]) < 4 and \ int(engine_version.split(".")[1]) < 26: raise ApplicationLaunchFailed(( f"{self.signature} Old unsupported version of UE " f"detected - {engine_version}")) except ValueError: # there can be string in minor version and in that case # int cast is failing. This probably happens only with # early access versions and is of no concert for this check # so let's keep it quiet. ... unreal_project_filename = self._get_work_filename() unreal_project_name = os.path.splitext(unreal_project_filename)[0] # Unreal is sensitive about project names longer then 20 chars if len(unreal_project_name) > 20: raise ApplicationLaunchFailed( f"Project name exceeds 20 characters ({unreal_project_name})!" ) # Unreal doesn't accept non alphabet characters at the start # of the project name. This is because project name is then used # in various places inside c++ code and there variable names cannot # start with non-alpha. We append 'P' before project name to solve it. # 😱 if not unreal_project_name[:1].isalpha(): self.log.warning(( "Project name doesn't start with alphabet " f"character ({unreal_project_name}). Appending 'P'" )) unreal_project_name = f"P{unreal_project_name}" unreal_project_filename = f'{unreal_project_name}.uproject' project_path = Path(os.path.join(workdir, unreal_project_name)) self.log.info(( f"{self.signature} requested UE version: " f"[ {engine_version} ]" )) project_path.mkdir(parents=True, exist_ok=True) # engine_path points to the specific Unreal Engine root # so, we are going up from the executable itself 3 levels. engine_path: Path = Path(executable).parents[3] # Check if new env variable exists, and if it does, if the path # actually contains the plugin. If not, install it. built_plugin_path = self.launch_context.env.get( "AYON_BUILT_UNREAL_PLUGIN", None) if unreal_lib.check_built_plugin_existance(built_plugin_path): self.log.info(( f"{self.signature} using existing built Ayon plugin from " f"{built_plugin_path}" )) unreal_lib.copy_built_plugin(engine_path, Path(built_plugin_path)) else: # Set "AYON_UNREAL_PLUGIN" to current process environment for # execution of `create_unreal_project` env_key = "AYON_UNREAL_PLUGIN" if self.launch_context.env.get(env_key): self.log.info(( f"{self.signature} using Ayon plugin from " f"{self.launch_context.env.get(env_key)}" )) if self.launch_context.env.get(env_key): os.environ[env_key] = self.launch_context.env[env_key] if not unreal_lib.check_plugin_existence(engine_path): self.exec_plugin_install(engine_path) project_file = project_path / unreal_project_filename if not project_file.is_file(): with tempfile.TemporaryDirectory() as temp_dir: self.exec_ue_project_gen(engine_version, unreal_project_name, engine_path, Path(temp_dir)) try: self.log.info(( f"Moving from {temp_dir} to " f"{project_path.as_posix()}" )) shutil.copytree( temp_dir, project_path, dirs_exist_ok=True) except shutil.Error as e: raise ApplicationLaunchFailed(( f"{self.signature} Cannot copy directory {temp_dir} " f"to {project_path.as_posix()} - {e}" )) from e self.launch_context.env["AYON_UNREAL_VERSION"] = engine_version # Append project file to launch arguments self.launch_context.launch_args.append( f"\"{project_file.as_posix()}\"") ================================================ FILE: openpype/hosts/unreal/lib.py ================================================ # -*- coding: utf-8 -*- """Unreal launching and project tools.""" import json import os import platform import re import subprocess from collections import OrderedDict from distutils import dir_util from pathlib import Path from typing import List from openpype.settings import get_project_settings def get_engine_versions(env=None): """Detect Unreal Engine versions. This will try to detect location and versions of installed Unreal Engine. Location can be overridden by `UNREAL_ENGINE_LOCATION` environment variable. .. deprecated:: 3.15.4 Args: env (dict, optional): Environment to use. Returns: OrderedDict: dictionary with version as a key and dir as value. so the highest version is first. Example: >>> get_engine_versions() { "4.23": "C:/Epic Games/UE_4.23", "4.24": "C:/Epic Games/UE_4.24" } """ env = env or os.environ engine_locations = {} try: root, dirs, _ = next(os.walk(env["UNREAL_ENGINE_LOCATION"])) for directory in dirs: if directory.startswith("UE"): try: ver = re.split(r"[-_]", directory)[1] except IndexError: continue engine_locations[ver] = os.path.join(root, directory) except KeyError: # environment variable not set pass except OSError: # specified directory doesn't exist pass except StopIteration: # specified directory doesn't exist pass # if we've got something, terminate auto-detection process if engine_locations: return OrderedDict(sorted(engine_locations.items())) # else kick in platform specific detection if platform.system().lower() == "windows": return OrderedDict(sorted(_win_get_engine_versions().items())) if platform.system().lower() == "linux": # on linux, there is no installation and getting Unreal Engine involves # git clone. So we'll probably depend on `UNREAL_ENGINE_LOCATION`. pass if platform.system().lower() == "darwin": return OrderedDict(sorted(_darwin_get_engine_version().items())) return OrderedDict() def get_editor_exe_path(engine_path: Path, engine_version: str) -> Path: """Get UE Editor executable path.""" ue_path = engine_path / "Engine/Binaries" if platform.system().lower() == "windows": if engine_version.split(".")[0] == "4": ue_path /= "Win64/UE4Editor.exe" elif engine_version.split(".")[0] == "5": ue_path /= "Win64/UnrealEditor.exe" elif platform.system().lower() == "linux": ue_path /= "Linux/UE4Editor" elif platform.system().lower() == "darwin": ue_path /= "Mac/UE4Editor" return ue_path def _win_get_engine_versions(): """Get Unreal Engine versions on Windows. If engines are installed via Epic Games Launcher then there is: `%PROGRAMDATA%/Epic/UnrealEngineLauncher/LauncherInstalled.dat` This file is JSON file listing installed stuff, Unreal engines are marked with `"AppName" = "UE_X.XX"`` like `UE_4.24` .. deprecated:: 3.15.4 Returns: dict: version as a key and path as a value. """ install_json_path = os.path.join( os.getenv("PROGRAMDATA"), "Epic", "UnrealEngineLauncher", "LauncherInstalled.dat", ) return _parse_launcher_locations(install_json_path) def _darwin_get_engine_version() -> dict: """Get Unreal Engine versions on MacOS. It works the same as on Windows, just JSON file location is different. .. deprecated:: 3.15.4 Returns: dict: version as a key and path as a value. See Also: :func:`_win_get_engine_versions`. """ install_json_path = os.path.join( os.getenv("HOME"), "Library", "Application Support", "Epic", "UnrealEngineLauncher", "LauncherInstalled.dat", ) return _parse_launcher_locations(install_json_path) def _parse_launcher_locations(install_json_path: str) -> dict: """This will parse locations from json file. .. deprecated:: 3.15.4 Args: install_json_path (str): Path to `LauncherInstalled.dat`. Returns: dict: with unreal engine versions as keys and paths to those engine installations as value. """ engine_locations = {} if os.path.isfile(install_json_path): with open(install_json_path, "r") as ilf: try: install_data = json.load(ilf) except json.JSONDecodeError as e: raise Exception( "Invalid `LauncherInstalled.dat file. `" "Cannot determine Unreal Engine location." ) from e for installation in install_data.get("InstallationList", []): if installation.get("AppName").startswith("UE_"): ver = installation.get("AppName").split("_")[1] engine_locations[ver] = installation.get("InstallLocation") return engine_locations def create_unreal_project(project_name: str, unreal_project_name: str, ue_version: str, pr_dir: Path, engine_path: Path, dev_mode: bool = False, env: dict = None) -> None: """This will create `.uproject` file at specified location. As there is no way I know to create a project via command line, this is easiest option. Unreal project file is basically a JSON file. If we find the `AYON_UNREAL_PLUGIN` environment variable we assume this is the location of the Integration Plugin and we copy its content to the project folder and enable this plugin. Args: project_name (str): Name of the project in AYON. unreal_project_name (str): Name of the project in Unreal. ue_version (str): Unreal engine version (like 4.23). pr_dir (Path): Path to directory where project will be created. engine_path (Path): Path to Unreal Engine installation. dev_mode (bool, optional): Flag to trigger C++ style Unreal project needing Visual Studio and other tools to compile plugins from sources. This will trigger automatically if `Binaries` directory is not found in plugin folders as this indicates this is only source distribution of the plugin. Dev mode is also set in Settings. env (dict, optional): Environment to use. If not set, `os.environ`. Throws: NotImplementedError: For unsupported platforms. Returns: None Deprecated: since 3.16.0 """ env = env or os.environ preset = get_project_settings(project_name)["unreal"]["project_setup"] ue_id = ".".join(ue_version.split(".")[:2]) # get unreal engine identifier # ------------------------------------------------------------------------- # FIXME (antirotor): As of 4.26 this is problem with UE4 built from # sources. In that case Engine ID is calculated per machine/user and not # from Engine files as this code then reads. This then prevents UE4 # to directly open project as it will complain about project being # created in different UE4 version. When user convert such project # to his UE4 version, Engine ID is replaced in uproject file. If some # other user tries to open it, it will present him with similar error. # engine_path should be the location of UE_X.X folder ue_editor_exe: Path = get_editor_exe_path(engine_path, ue_version) cmdlet_project: Path = get_path_to_cmdlet_project(ue_version) project_file = pr_dir / f"{unreal_project_name}.uproject" print("--- Generating a new project ...") commandlet_cmd = [f'{ue_editor_exe.as_posix()}', f'{cmdlet_project.as_posix()}', f'-run=AyonGenerateProject', f'{project_file.resolve().as_posix()}'] if dev_mode or preset["dev_mode"]: commandlet_cmd.append('-GenerateCode') gen_process = subprocess.Popen(commandlet_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) for line in gen_process.stdout: print(line.decode(), end='') gen_process.stdout.close() return_code = gen_process.wait() if return_code and return_code != 0: raise RuntimeError( (f"Failed to generate '{unreal_project_name}' project! " f"Exited with return code {return_code}")) print("--- Project has been generated successfully.") with open(project_file.as_posix(), mode="r+") as pf: pf_json = json.load(pf) pf_json["EngineAssociation"] = get_build_id(engine_path, ue_version) pf.seek(0) json.dump(pf_json, pf, indent=4) pf.truncate() print(f'--- Engine ID has been written into the project file') if dev_mode or preset["dev_mode"]: u_build_tool = get_path_to_ubt(engine_path, ue_version) arch = "Win64" if platform.system().lower() == "windows": arch = "Win64" elif platform.system().lower() == "linux": arch = "Linux" elif platform.system().lower() == "darwin": # we need to test this out arch = "Mac" command1 = [u_build_tool.as_posix(), "-projectfiles", f"-project={project_file}", "-progress"] subprocess.run(command1) command2 = [u_build_tool.as_posix(), f"-ModuleWithSuffix={unreal_project_name},3555", arch, "Development", "-TargetType=Editor", f'-Project={project_file}', f'{project_file}', "-IgnoreJunk"] subprocess.run(command2) # ensure we have PySide2 installed in engine python_path = None if platform.system().lower() == "windows": python_path = engine_path / ("Engine/Binaries/ThirdParty/" "Python3/Win64/python.exe") if platform.system().lower() == "linux": python_path = engine_path / ("Engine/Binaries/ThirdParty/" "Python3/Linux/bin/python3") if platform.system().lower() == "darwin": python_path = engine_path / ("Engine/Binaries/ThirdParty/" "Python3/Mac/bin/python3") if not python_path: raise NotImplementedError("Unsupported platform") if not python_path.exists(): raise RuntimeError(f"Unreal Python not found at {python_path}") subprocess.check_call( [python_path.as_posix(), "-m", "pip", "install", "pyside2"]) def get_path_to_uat(engine_path: Path) -> Path: if platform.system().lower() == "windows": return engine_path / "Engine/Build/BatchFiles/RunUAT.bat" if platform.system().lower() in ["linux", "darwin"]: return engine_path / "Engine/Build/BatchFiles/RunUAT.sh" def get_compatible_integration( ue_version: str, integration_root: Path) -> List[Path]: """Get path to compatible version of integration plugin. This will try to get the closest compatible versions to the one specified in sorted list. Args: ue_version (str): version of the current Unreal Engine. integration_root (Path): path to built-in integration plugins. Returns: list of Path: Sorted list of paths closest to the specified version. """ major, minor = ue_version.split(".") integration_paths = [p for p in integration_root.iterdir() if p.is_dir()] compatible_versions = [] for i in integration_paths: # parse version from path try: i_major, i_minor = re.search( r"(?P\d+).(?P\d+)$", i.name).groups() except AttributeError: # in case there is no match, just skip to next continue # consider versions with different major so different that they # are incompatible if int(major) != int(i_major): continue compatible_versions.append(i) sorted(set(compatible_versions)) return compatible_versions def get_path_to_cmdlet_project(ue_version: str) -> Path: cmd_project = Path( os.path.dirname(os.path.abspath(__file__))) # For now, only tested on Windows (For Linux and Mac # it has to be implemented) cmd_project /= f"integration/UE_{ue_version}" # if the integration doesn't exist for current engine version # try to find the closest to it. if cmd_project.exists(): return cmd_project / "CommandletProject/CommandletProject.uproject" if compatible_versions := get_compatible_integration( ue_version, cmd_project.parent ): return compatible_versions[-1] / "CommandletProject/CommandletProject.uproject" # noqa: E501 else: raise RuntimeError( ("There are no compatible versions of Unreal " "integration plugin compatible with running version " f"of Unreal Engine {ue_version}")) def get_path_to_ubt(engine_path: Path, ue_version: str) -> Path: u_build_tool_path = engine_path / "Engine/Binaries/DotNET" if ue_version.split(".")[0] == "4": u_build_tool_path /= "UnrealBuildTool.exe" elif ue_version.split(".")[0] == "5": u_build_tool_path /= "UnrealBuildTool/UnrealBuildTool.exe" return Path(u_build_tool_path) def get_build_id(engine_path: Path, ue_version: str) -> str: ue_modules = Path() if platform.system().lower() == "windows": ue_modules_path = engine_path / "Engine/Binaries/Win64" if ue_version.split(".")[0] == "4": ue_modules_path /= "UE4Editor.modules" elif ue_version.split(".")[0] == "5": ue_modules_path /= "UnrealEditor.modules" ue_modules = Path(ue_modules_path) if platform.system().lower() == "linux": ue_modules = Path(os.path.join(engine_path, "Engine", "Binaries", "Linux", "UE4Editor.modules")) if platform.system().lower() == "darwin": ue_modules = Path(os.path.join(engine_path, "Engine", "Binaries", "Mac", "UE4Editor.modules")) if ue_modules.exists(): print("--- Loading Engine ID from modules file ...") with open(ue_modules, "r") as mp: loaded_modules = json.load(mp) if loaded_modules.get("BuildId"): return "{" + loaded_modules.get("BuildId") + "}" def check_built_plugin_existance(plugin_path) -> bool: if not plugin_path: return False integration_plugin_path = Path(plugin_path) if not integration_plugin_path.is_dir(): raise RuntimeError("Path to the integration plugin is null!") if not (integration_plugin_path / "Binaries").is_dir() \ or not (integration_plugin_path / "Intermediate").is_dir(): return False return True def copy_built_plugin(engine_path: Path, plugin_path: Path) -> None: ayon_plugin_path: Path = engine_path / "Engine/Plugins/Marketplace/Ayon" if not ayon_plugin_path.is_dir(): ayon_plugin_path.mkdir(parents=True, exist_ok=True) engine_plugin_config_path: Path = ayon_plugin_path / "Config" engine_plugin_config_path.mkdir(exist_ok=True) dir_util._path_created = {} dir_util.copy_tree(plugin_path.as_posix(), ayon_plugin_path.as_posix()) def check_plugin_existence(engine_path: Path, env: dict = None) -> bool: env = env or os.environ integration_plugin_path: Path = Path(env.get("AYON_UNREAL_PLUGIN", "")) if not os.path.isdir(integration_plugin_path): raise RuntimeError("Path to the integration plugin is null!") # Create a path to the plugin in the engine op_plugin_path: Path = engine_path / "Engine/Plugins/Marketplace/Ayon" if not op_plugin_path.is_dir(): return False if not (op_plugin_path / "Binaries").is_dir() \ or not (op_plugin_path / "Intermediate").is_dir(): return False return True def try_installing_plugin(engine_path: Path, env: dict = None) -> None: env = env or os.environ integration_plugin_path: Path = Path(env.get("AYON_UNREAL_PLUGIN", "")) if not os.path.isdir(integration_plugin_path): raise RuntimeError("Path to the integration plugin is null!") # Create a path to the plugin in the engine op_plugin_path: Path = engine_path / "Engine/Plugins/Marketplace/Ayon" if not op_plugin_path.is_dir(): op_plugin_path.mkdir(parents=True, exist_ok=True) engine_plugin_config_path: Path = op_plugin_path / "Config" engine_plugin_config_path.mkdir(exist_ok=True) dir_util._path_created = {} if not (op_plugin_path / "Binaries").is_dir() \ or not (op_plugin_path / "Intermediate").is_dir(): _build_and_move_plugin(engine_path, op_plugin_path, env) def _build_and_move_plugin(engine_path: Path, plugin_build_path: Path, env: dict = None) -> None: uat_path: Path = get_path_to_uat(engine_path) env = env or os.environ integration_plugin_path: Path = Path(env.get("AYON_UNREAL_PLUGIN", "")) if uat_path.is_file(): temp_dir: Path = integration_plugin_path.parent / "Temp" temp_dir.mkdir(exist_ok=True) uplugin_path: Path = integration_plugin_path / "Ayon.uplugin" # in order to successfully build the plugin, # It must be built outside the Engine directory and then moved build_plugin_cmd: List[str] = [f'{uat_path.as_posix()}', 'BuildPlugin', f'-Plugin={uplugin_path.as_posix()}', f'-Package={temp_dir.as_posix()}'] subprocess.run(build_plugin_cmd) # Copy the contents of the 'Temp' dir into the # 'Ayon' directory in the engine dir_util.copy_tree(temp_dir.as_posix(), plugin_build_path.as_posix()) # We need to also copy the config folder. # The UAT doesn't include the Config folder in the build plugin_install_config_path: Path = plugin_build_path / "Config" integration_plugin_config_path = integration_plugin_path / "Config" dir_util.copy_tree(integration_plugin_config_path.as_posix(), plugin_install_config_path.as_posix()) dir_util.remove_tree(temp_dir.as_posix()) ================================================ FILE: openpype/hosts/unreal/plugins/__init__.py ================================================ ================================================ FILE: openpype/hosts/unreal/plugins/create/create_camera.py ================================================ # -*- coding: utf-8 -*- import unreal from openpype.pipeline import CreatorError from openpype.hosts.unreal.api.pipeline import UNREAL_VERSION from openpype.hosts.unreal.api.plugin import ( UnrealAssetCreator, ) class CreateCamera(UnrealAssetCreator): """Create Camera.""" identifier = "io.ayon.creators.unreal.camera" label = "Camera" family = "camera" icon = "fa.camera" def create(self, subset_name, instance_data, pre_create_data): if pre_create_data.get("use_selection"): sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() selection = [a.get_path_name() for a in sel_objects] if len(selection) != 1: raise CreatorError("Please select only one object.") # Add the current level path to the metadata if UNREAL_VERSION.major == 5: world = unreal.UnrealEditorSubsystem().get_editor_world() else: world = unreal.EditorLevelLibrary.get_editor_world() instance_data["level"] = world.get_path_name() super(CreateCamera, self).create( subset_name, instance_data, pre_create_data) ================================================ FILE: openpype/hosts/unreal/plugins/create/create_layout.py ================================================ # -*- coding: utf-8 -*- from openpype.hosts.unreal.api.plugin import ( UnrealActorCreator, ) class CreateLayout(UnrealActorCreator): """Layout output for character rigs.""" identifier = "io.ayon.creators.unreal.layout" label = "Layout" family = "layout" icon = "cubes" ================================================ FILE: openpype/hosts/unreal/plugins/create/create_look.py ================================================ # -*- coding: utf-8 -*- import unreal from openpype.pipeline import CreatorError from openpype.hosts.unreal.api.pipeline import ( create_folder ) from openpype.hosts.unreal.api.plugin import ( UnrealAssetCreator ) from openpype.lib import UILabelDef class CreateLook(UnrealAssetCreator): """Shader connections defining shape look.""" identifier = "io.ayon.creators.unreal.look" label = "Look" family = "look" icon = "paint-brush" def create(self, subset_name, instance_data, pre_create_data): # We need to set this to True for the parent class to work pre_create_data["use_selection"] = True sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() selection = [a.get_path_name() for a in sel_objects] if len(selection) != 1: raise CreatorError("Please select only one asset.") selected_asset = selection[0] look_directory = "/Game/Ayon/Looks" # Create the folder folder_name = create_folder(look_directory, subset_name) path = f"{look_directory}/{folder_name}" instance_data["look"] = path # Create a new cube static mesh ar = unreal.AssetRegistryHelpers.get_asset_registry() cube = ar.get_asset_by_object_path("/Engine/BasicShapes/Cube.Cube") # Get the mesh of the selected object original_mesh = ar.get_asset_by_object_path(selected_asset).get_asset() materials = original_mesh.get_editor_property('static_materials') pre_create_data["members"] = [] # Add the materials to the cube for material in materials: mat_name = material.get_editor_property('material_slot_name') object_path = f"{path}/{mat_name}.{mat_name}" unreal_object = unreal.EditorAssetLibrary.duplicate_loaded_asset( cube.get_asset(), object_path ) # Remove the default material of the cube object unreal_object.get_editor_property('static_materials').pop() unreal_object.add_material( material.get_editor_property('material_interface')) pre_create_data["members"].append(object_path) unreal.EditorAssetLibrary.save_asset(object_path) super(CreateLook, self).create( subset_name, instance_data, pre_create_data) def get_pre_create_attr_defs(self): return [ UILabelDef("Select the asset from which to create the look.") ] ================================================ FILE: openpype/hosts/unreal/plugins/create/create_render.py ================================================ # -*- coding: utf-8 -*- from pathlib import Path import unreal from openpype.hosts.unreal.api.pipeline import ( UNREAL_VERSION, create_folder, get_subsequences, ) from openpype.hosts.unreal.api.plugin import ( UnrealAssetCreator ) from openpype.lib import ( UILabelDef, UISeparatorDef, BoolDef, NumberDef ) class CreateRender(UnrealAssetCreator): """Create instance for sequence for rendering""" identifier = "io.ayon.creators.unreal.render" label = "Render" family = "render" icon = "eye" def create_instance( self, instance_data, subset_name, pre_create_data, selected_asset_path, master_seq, master_lvl, seq_data ): instance_data["members"] = [selected_asset_path] instance_data["sequence"] = selected_asset_path instance_data["master_sequence"] = master_seq instance_data["master_level"] = master_lvl instance_data["output"] = seq_data.get('output') instance_data["frameStart"] = seq_data.get('frame_range')[0] instance_data["frameEnd"] = seq_data.get('frame_range')[1] super(CreateRender, self).create( subset_name, instance_data, pre_create_data) def create_with_new_sequence( self, subset_name, instance_data, pre_create_data ): # If the option to create a new level sequence is selected, # create a new level sequence and a master level. root = f"/Game/Ayon/Sequences" # Create a new folder for the sequence in root sequence_dir_name = create_folder(root, subset_name) sequence_dir = f"{root}/{sequence_dir_name}" unreal.log_warning(f"sequence_dir: {sequence_dir}") # Create the level sequence asset_tools = unreal.AssetToolsHelpers.get_asset_tools() seq = asset_tools.create_asset( asset_name=subset_name, package_path=sequence_dir, asset_class=unreal.LevelSequence, factory=unreal.LevelSequenceFactoryNew()) seq.set_playback_start(pre_create_data.get("start_frame")) seq.set_playback_end(pre_create_data.get("end_frame")) pre_create_data["members"] = [seq.get_path_name()] unreal.EditorAssetLibrary.save_asset(seq.get_path_name()) # Create the master level if UNREAL_VERSION.major >= 5: curr_level = unreal.LevelEditorSubsystem().get_current_level() else: world = unreal.EditorLevelLibrary.get_editor_world() levels = unreal.EditorLevelUtils.get_levels(world) curr_level = levels[0] if len(levels) else None if not curr_level: raise RuntimeError("No level loaded.") curr_level_path = curr_level.get_outer().get_path_name() # If the level path does not start with "/Game/", the current # level is a temporary, unsaved level. if curr_level_path.startswith("/Game/"): if UNREAL_VERSION.major >= 5: unreal.LevelEditorSubsystem().save_current_level() else: unreal.EditorLevelLibrary.save_current_level() ml_path = f"{sequence_dir}/{subset_name}_MasterLevel" if UNREAL_VERSION.major >= 5: unreal.LevelEditorSubsystem().new_level(ml_path) else: unreal.EditorLevelLibrary.new_level(ml_path) seq_data = { "sequence": seq, "output": f"{seq.get_name()}", "frame_range": ( seq.get_playback_start(), seq.get_playback_end())} self.create_instance( instance_data, subset_name, pre_create_data, seq.get_path_name(), seq.get_path_name(), ml_path, seq_data) def create_from_existing_sequence( self, subset_name, instance_data, pre_create_data ): ar = unreal.AssetRegistryHelpers.get_asset_registry() sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() selection = [ a.get_path_name() for a in sel_objects if a.get_class().get_name() == "LevelSequence"] if len(selection) == 0: raise RuntimeError("Please select at least one Level Sequence.") seq_data = None for sel in selection: selected_asset = ar.get_asset_by_object_path(sel).get_asset() selected_asset_path = selected_asset.get_path_name() # Check if the selected asset is a level sequence asset. if selected_asset.get_class().get_name() != "LevelSequence": unreal.log_warning( f"Skipping {selected_asset.get_name()}. It isn't a Level " "Sequence.") if pre_create_data.get("use_hierarchy"): # The asset name is the the third element of the path which # contains the map. # To take the asset name, we remove from the path the prefix # "/Game/OpenPype/" and then we split the path by "/". sel_path = selected_asset_path asset_name = sel_path.replace( "/Game/Ayon/", "").split("/")[0] search_path = f"/Game/Ayon/{asset_name}" else: search_path = Path(selected_asset_path).parent.as_posix() # Get the master sequence and the master level. # There should be only one sequence and one level in the directory. try: ar_filter = unreal.ARFilter( class_names=["LevelSequence"], package_paths=[search_path], recursive_paths=False) sequences = ar.get_assets(ar_filter) master_seq = sequences[0].get_asset().get_path_name() master_seq_obj = sequences[0].get_asset() ar_filter = unreal.ARFilter( class_names=["World"], package_paths=[search_path], recursive_paths=False) levels = ar.get_assets(ar_filter) master_lvl = levels[0].get_asset().get_path_name() except IndexError: raise RuntimeError( f"Could not find the hierarchy for the selected sequence.") # If the selected asset is the master sequence, we get its data # and then we create the instance for the master sequence. # Otherwise, we cycle from the master sequence to find the selected # sequence and we get its data. This data will be used to create # the instance for the selected sequence. In particular, # we get the frame range of the selected sequence and its final # output path. master_seq_data = { "sequence": master_seq_obj, "output": f"{master_seq_obj.get_name()}", "frame_range": ( master_seq_obj.get_playback_start(), master_seq_obj.get_playback_end())} if (selected_asset_path == master_seq or pre_create_data.get("use_hierarchy")): seq_data = master_seq_data else: seq_data_list = [master_seq_data] for seq in seq_data_list: subscenes = get_subsequences(seq.get('sequence')) for sub_seq in subscenes: sub_seq_obj = sub_seq.get_sequence() curr_data = { "sequence": sub_seq_obj, "output": (f"{seq.get('output')}/" f"{sub_seq_obj.get_name()}"), "frame_range": ( sub_seq.get_start_frame(), sub_seq.get_end_frame() - 1)} # If the selected asset is the current sub-sequence, # we get its data and we break the loop. # Otherwise, we add the current sub-sequence data to # the list of sequences to check. if sub_seq_obj.get_path_name() == selected_asset_path: seq_data = curr_data break seq_data_list.append(curr_data) # If we found the selected asset, we break the loop. if seq_data is not None: break # If we didn't find the selected asset, we don't create the # instance. if not seq_data: unreal.log_warning( f"Skipping {selected_asset.get_name()}. It isn't a " "sub-sequence of the master sequence.") continue self.create_instance( instance_data, subset_name, pre_create_data, selected_asset_path, master_seq, master_lvl, seq_data) def create(self, subset_name, instance_data, pre_create_data): if pre_create_data.get("create_seq"): self.create_with_new_sequence( subset_name, instance_data, pre_create_data) else: self.create_from_existing_sequence( subset_name, instance_data, pre_create_data) def get_pre_create_attr_defs(self): return [ UILabelDef( "Select a Level Sequence to render or create a new one." ), BoolDef( "create_seq", label="Create a new Level Sequence", default=False ), UILabelDef( "WARNING: If you create a new Level Sequence, the current\n" "level will be saved and a new Master Level will be created." ), NumberDef( "start_frame", label="Start Frame", default=0, minimum=-999999, maximum=999999 ), NumberDef( "end_frame", label="Start Frame", default=150, minimum=-999999, maximum=999999 ), UISeparatorDef(), UILabelDef( "The following settings are valid only if you are not\n" "creating a new sequence." ), BoolDef( "use_hierarchy", label="Use Hierarchy", default=False ), ] ================================================ FILE: openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py ================================================ # -*- coding: utf-8 -*- from openpype.hosts.unreal.api.plugin import ( UnrealAssetCreator, ) class CreateStaticMeshFBX(UnrealAssetCreator): """Create Static Meshes as FBX geometry.""" identifier = "io.ayon.creators.unreal.staticmeshfbx" label = "Static Mesh (FBX)" family = "unrealStaticMesh" icon = "cube" ================================================ FILE: openpype/hosts/unreal/plugins/create/create_uasset.py ================================================ # -*- coding: utf-8 -*- from pathlib import Path import unreal from openpype.pipeline import CreatorError from openpype.hosts.unreal.api.plugin import ( UnrealAssetCreator, ) class CreateUAsset(UnrealAssetCreator): """Create UAsset.""" identifier = "io.ayon.creators.unreal.uasset" label = "UAsset" family = "uasset" icon = "cube" extension = ".uasset" def create(self, subset_name, instance_data, pre_create_data): if pre_create_data.get("use_selection"): ar = unreal.AssetRegistryHelpers.get_asset_registry() sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() selection = [a.get_path_name() for a in sel_objects] if len(selection) != 1: raise CreatorError("Please select only one object.") obj = selection[0] asset = ar.get_asset_by_object_path(obj).get_asset() sys_path = unreal.SystemLibrary.get_system_path(asset) if not sys_path: raise CreatorError( f"{Path(obj).name} is not on the disk. Likely it needs to" "be saved first.") if Path(sys_path).suffix != self.extension: raise CreatorError( f"{Path(sys_path).name} is not a {self.label}.") super(CreateUAsset, self).create( subset_name, instance_data, pre_create_data) class CreateUMap(CreateUAsset): """Create Level.""" identifier = "io.ayon.creators.unreal.umap" label = "Level" family = "uasset" extension = ".umap" def create(self, subset_name, instance_data, pre_create_data): instance_data["families"] = ["umap"] super(CreateUMap, self).create( subset_name, instance_data, pre_create_data) ================================================ FILE: openpype/hosts/unreal/plugins/inventory/delete_unused_assets.py ================================================ import unreal from openpype.hosts.unreal.api.tools_ui import qt_app_context from openpype.hosts.unreal.api.pipeline import delete_asset_if_unused from openpype.pipeline import InventoryAction class DeleteUnusedAssets(InventoryAction): """Delete all the assets that are not used in any level. """ label = "Delete Unused Assets" icon = "trash" color = "red" order = 1 dialog = None def _delete_unused_assets(self, containers): allowed_families = ["model", "rig"] for container in containers: container_dir = container.get("namespace") if container.get("family") not in allowed_families: unreal.log_warning( f"Container {container_dir} is not supported.") continue asset_content = unreal.EditorAssetLibrary.list_assets( container_dir, recursive=True, include_folder=False ) delete_asset_if_unused(container, asset_content) def _show_confirmation_dialog(self, containers): from qtpy import QtCore from openpype.widgets import popup from openpype.style import load_stylesheet dialog = popup.Popup() dialog.setWindowFlags( QtCore.Qt.Window | QtCore.Qt.WindowStaysOnTopHint ) dialog.setFocusPolicy(QtCore.Qt.StrongFocus) dialog.setWindowTitle("Delete all unused assets") dialog.setMessage( "You are about to delete all the assets in the project that \n" "are not used in any level. Are you sure you want to continue?" ) dialog.setButtonText("Delete") dialog.on_clicked.connect( lambda: self._delete_unused_assets(containers) ) dialog.show() dialog.raise_() dialog.activateWindow() dialog.setStyleSheet(load_stylesheet()) self.dialog = dialog def process(self, containers): with qt_app_context(): self._show_confirmation_dialog(containers) ================================================ FILE: openpype/hosts/unreal/plugins/inventory/update_actors.py ================================================ import unreal from openpype.hosts.unreal.api.pipeline import ( ls, replace_static_mesh_actors, replace_skeletal_mesh_actors, replace_geometry_cache_actors, ) from openpype.pipeline import InventoryAction def update_assets(containers, selected): allowed_families = ["model", "rig"] # Get all the containers in the Unreal Project all_containers = ls() for container in containers: container_dir = container.get("namespace") if container.get("family") not in allowed_families: unreal.log_warning( f"Container {container_dir} is not supported.") continue # Get all containers with same asset_name but different objectName. # These are the containers that need to be updated in the level. sa_containers = [ i for i in all_containers if ( i.get("asset_name") == container.get("asset_name") and i.get("objectName") != container.get("objectName") ) ] asset_content = unreal.EditorAssetLibrary.list_assets( container_dir, recursive=True, include_folder=False ) # Update all actors in level for sa_cont in sa_containers: sa_dir = sa_cont.get("namespace") old_content = unreal.EditorAssetLibrary.list_assets( sa_dir, recursive=True, include_folder=False ) if container.get("family") == "rig": replace_skeletal_mesh_actors( old_content, asset_content, selected) replace_static_mesh_actors( old_content, asset_content, selected) elif container.get("family") == "model": if container.get("loader") == "PointCacheAlembicLoader": replace_geometry_cache_actors( old_content, asset_content, selected) else: replace_static_mesh_actors( old_content, asset_content, selected) unreal.EditorLevelLibrary.save_current_level() class UpdateAllActors(InventoryAction): """Update all the Actors in the current level to the version of the asset selected in the scene manager. """ label = "Replace all Actors in level to this version" icon = "arrow-up" def process(self, containers): update_assets(containers, False) class UpdateSelectedActors(InventoryAction): """Update only the selected Actors in the current level to the version of the asset selected in the scene manager. """ label = "Replace selected Actors in level to this version" icon = "arrow-up" def process(self, containers): update_assets(containers, True) ================================================ FILE: openpype/hosts/unreal/plugins/load/load_alembic_animation.py ================================================ # -*- coding: utf-8 -*- """Load Alembic Animation.""" import os from openpype.pipeline import ( get_representation_path, AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa class AnimationAlembicLoader(plugin.Loader): """Load Unreal SkeletalMesh from Alembic""" families = ["animation"] label = "Import Alembic Animation" representations = ["abc"] icon = "cube" color = "orange" def get_task(self, filename, asset_dir, asset_name, replace): task = unreal.AssetImportTask() options = unreal.AbcImportSettings() sm_settings = unreal.AbcStaticMeshSettings() conversion_settings = unreal.AbcConversionSettings( preset=unreal.AbcConversionPreset.CUSTOM, flip_u=False, flip_v=False, rotation=[0.0, 0.0, 0.0], scale=[1.0, 1.0, -1.0]) task.set_editor_property('filename', filename) task.set_editor_property('destination_path', asset_dir) task.set_editor_property('destination_name', asset_name) task.set_editor_property('replace_existing', replace) task.set_editor_property('automated', True) task.set_editor_property('save', True) options.set_editor_property( 'import_type', unreal.AlembicImportType.SKELETAL) options.static_mesh_settings = sm_settings options.conversion_settings = conversion_settings task.options = options return task def load(self, context, name, namespace, data): """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new directory and then it will create AssetContainer there and imprint it with metadata. This will mark this path as container. Args: context (dict): application context name (str): subset name namespace (str): in Unreal this is basically path to container. This is not passed here, so namespace is set by `containerise()` because only then we know real path. data (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content """ # Create directory for asset and ayon container root = unreal_pipeline.AYON_ASSET_DIR asset = context.get('asset').get('name') suffix = "_CON" if asset: asset_name = "{}_{}".format(asset, name) else: asset_name = "{}".format(name) version = context.get('version') # Check if version is hero version and use different name if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: name_version = f"{name}_v{version.get('name'):03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{root}/{asset}/{name_version}", suffix="") container_name += suffix if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): unreal.EditorAssetLibrary.make_directory(asset_dir) path = self.filepath_from_context(context) task = self.get_task(path, asset_dir, asset_name, False) asset_tools = unreal.AssetToolsHelpers.get_asset_tools() asset_tools.import_asset_tasks([task]) # Create Asset Container unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), "representation": context["representation"]["_id"], "parent": context["representation"]["parent"], "family": context["representation"]["context"]["family"] } unreal_pipeline.imprint( f"{asset_dir}/{container_name}", data) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) return asset_content def update(self, container, representation): name = container["asset_name"] source_path = get_representation_path(representation) destination_path = container["namespace"] task = self.get_task(source_path, destination_path, name, True) # do import fbx and replace existing data asset_tools = unreal.AssetToolsHelpers.get_asset_tools() asset_tools.import_asset_tasks([task]) container_path = f"{container['namespace']}/{container['objectName']}" # update metadata unreal_pipeline.imprint( container_path, { "representation": str(representation["_id"]), "parent": str(representation["parent"]) }) asset_content = unreal.EditorAssetLibrary.list_assets( destination_path, recursive=True, include_folder=True ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) unreal.EditorAssetLibrary.delete_directory(path) asset_content = unreal.EditorAssetLibrary.list_assets( parent_path, recursive=False ) if len(asset_content) == 0: unreal.EditorAssetLibrary.delete_directory(parent_path) ================================================ FILE: openpype/hosts/unreal/plugins/load/load_animation.py ================================================ # -*- coding: utf-8 -*- """Load FBX with animations.""" import os import json import unreal from unreal import EditorAssetLibrary from unreal import MovieSceneSkeletalAnimationTrack from unreal import MovieSceneSkeletalAnimationSection from openpype.pipeline.context_tools import get_current_project_asset from openpype.pipeline import ( get_representation_path, AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline class AnimationFBXLoader(plugin.Loader): """Load Unreal SkeletalMesh from FBX.""" families = ["animation"] label = "Import FBX Animation" representations = ["fbx"] icon = "cube" color = "orange" def _process(self, path, asset_dir, asset_name, instance_name): automated = False actor = None task = unreal.AssetImportTask() task.options = unreal.FbxImportUI() if instance_name: automated = True # Old method to get the actor # actor_name = 'PersistentLevel.' + instance_name # actor = unreal.EditorLevelLibrary.get_actor_reference(actor_name) actors = unreal.EditorLevelLibrary.get_all_level_actors() for a in actors: if a.get_class().get_name() != "SkeletalMeshActor": continue if a.get_actor_label() == instance_name: actor = a break if not actor: raise Exception(f"Could not find actor {instance_name}") skeleton = actor.skeletal_mesh_component.skeletal_mesh.skeleton task.options.set_editor_property('skeleton', skeleton) if not actor: return None asset_doc = get_current_project_asset(fields=["data.fps"]) task.set_editor_property('filename', path) task.set_editor_property('destination_path', asset_dir) task.set_editor_property('destination_name', asset_name) task.set_editor_property('replace_existing', False) task.set_editor_property('automated', automated) task.set_editor_property('save', False) # set import options here task.options.set_editor_property( 'automated_import_should_detect_type', False) task.options.set_editor_property( 'original_import_type', unreal.FBXImportType.FBXIT_SKELETAL_MESH) task.options.set_editor_property( 'mesh_type_to_import', unreal.FBXImportType.FBXIT_ANIMATION) task.options.set_editor_property('import_mesh', False) task.options.set_editor_property('import_animations', True) task.options.set_editor_property('override_full_name', True) task.options.anim_sequence_import_data.set_editor_property( 'animation_length', unreal.FBXAnimationLengthImportType.FBXALIT_EXPORTED_TIME ) task.options.anim_sequence_import_data.set_editor_property( 'import_meshes_in_bone_hierarchy', False) task.options.anim_sequence_import_data.set_editor_property( 'use_default_sample_rate', False) task.options.anim_sequence_import_data.set_editor_property( 'custom_sample_rate', asset_doc.get("data", {}).get("fps")) task.options.anim_sequence_import_data.set_editor_property( 'import_custom_attribute', True) task.options.anim_sequence_import_data.set_editor_property( 'import_bone_tracks', True) task.options.anim_sequence_import_data.set_editor_property( 'remove_redundant_keys', False) task.options.anim_sequence_import_data.set_editor_property( 'convert_scene', True) unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) asset_content = EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True ) animation = None for a in asset_content: imported_asset_data = EditorAssetLibrary.find_asset_data(a) imported_asset = unreal.AssetRegistryHelpers.get_asset( imported_asset_data) if imported_asset.__class__ == unreal.AnimSequence: animation = imported_asset break if animation: animation.set_editor_property('enable_root_motion', True) actor.skeletal_mesh_component.set_editor_property( 'animation_mode', unreal.AnimationMode.ANIMATION_SINGLE_NODE) actor.skeletal_mesh_component.animation_data.set_editor_property( 'anim_to_play', animation) return animation def load(self, context, name, namespace, options=None): """ Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new directory and then it will create AssetContainer there and imprint it with metadata. This will mark this path as container. Args: context (dict): application context name (str): subset name namespace (str): in Unreal this is basically path to container. This is not passed here, so namespace is set by `containerise()` because only then we know real path. data (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content """ # Create directory for asset and Ayon container hierarchy = context.get('asset').get('data').get('parents') root = "/Game/Ayon" asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{root}/Animations/{asset}/{name}", suffix="") ar = unreal.AssetRegistryHelpers.get_asset_registry() _filter = unreal.ARFilter( class_names=["World"], package_paths=[f"{root}/{hierarchy[0]}"], recursive_paths=False) levels = ar.get_assets(_filter) master_level = levels[0].get_asset().get_path_name() hierarchy_dir = root for h in hierarchy: hierarchy_dir = f"{hierarchy_dir}/{h}" hierarchy_dir = f"{hierarchy_dir}/{asset}" _filter = unreal.ARFilter( class_names=["World"], package_paths=[f"{hierarchy_dir}/"], recursive_paths=True) levels = ar.get_assets(_filter) level = levels[0].get_asset().get_path_name() unreal.EditorLevelLibrary.save_all_dirty_levels() unreal.EditorLevelLibrary.load_level(level) container_name += suffix EditorAssetLibrary.make_directory(asset_dir) path = self.filepath_from_context(context) libpath = path.replace(".fbx", ".json") with open(libpath, "r") as fp: data = json.load(fp) instance_name = data.get("instance_name") animation = self._process(path, asset_dir, asset_name, instance_name) asset_content = EditorAssetLibrary.list_assets( hierarchy_dir, recursive=True, include_folder=False) # Get the sequence for the layout, excluding the camera one. sequences = [a for a in asset_content if (EditorAssetLibrary.find_asset_data(a).get_class() == unreal.LevelSequence.static_class() and "_camera" not in a.split("/")[-1])] ar = unreal.AssetRegistryHelpers.get_asset_registry() for s in sequences: sequence = ar.get_asset_by_object_path(s).get_asset() possessables = [ p for p in sequence.get_possessables() if p.get_display_name() == instance_name] for p in possessables: tracks = [ t for t in p.get_tracks() if (t.get_class() == MovieSceneSkeletalAnimationTrack.static_class())] for t in tracks: sections = [ s for s in t.get_sections() if (s.get_class() == MovieSceneSkeletalAnimationSection.static_class())] for s in sections: s.params.set_editor_property('animation', animation) # Create Asset Container unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), "representation": context["representation"]["_id"], "parent": context["representation"]["parent"], "family": context["representation"]["context"]["family"] } unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data) imported_content = EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=False) for a in imported_content: EditorAssetLibrary.save_asset(a) unreal.EditorLevelLibrary.save_current_level() unreal.EditorLevelLibrary.load_level(master_level) def update(self, container, representation): name = container["asset_name"] source_path = get_representation_path(representation) asset_doc = get_current_project_asset(fields=["data.fps"]) destination_path = container["namespace"] task = unreal.AssetImportTask() task.options = unreal.FbxImportUI() task.set_editor_property('filename', source_path) task.set_editor_property('destination_path', destination_path) # strip suffix task.set_editor_property('destination_name', name) task.set_editor_property('replace_existing', True) task.set_editor_property('automated', True) task.set_editor_property('save', True) # set import options here task.options.set_editor_property( 'automated_import_should_detect_type', False) task.options.set_editor_property( 'original_import_type', unreal.FBXImportType.FBXIT_SKELETAL_MESH) task.options.set_editor_property( 'mesh_type_to_import', unreal.FBXImportType.FBXIT_ANIMATION) task.options.set_editor_property('import_mesh', False) task.options.set_editor_property('import_animations', True) task.options.set_editor_property('override_full_name', True) task.options.anim_sequence_import_data.set_editor_property( 'animation_length', unreal.FBXAnimationLengthImportType.FBXALIT_EXPORTED_TIME ) task.options.anim_sequence_import_data.set_editor_property( 'import_meshes_in_bone_hierarchy', False) task.options.anim_sequence_import_data.set_editor_property( 'use_default_sample_rate', False) task.options.anim_sequence_import_data.set_editor_property( 'custom_sample_rate', asset_doc.get("data", {}).get("fps")) task.options.anim_sequence_import_data.set_editor_property( 'import_custom_attribute', True) task.options.anim_sequence_import_data.set_editor_property( 'import_bone_tracks', True) task.options.anim_sequence_import_data.set_editor_property( 'remove_redundant_keys', False) task.options.anim_sequence_import_data.set_editor_property( 'convert_scene', True) skeletal_mesh = EditorAssetLibrary.load_asset( container.get('namespace') + "/" + container.get('asset_name')) skeleton = skeletal_mesh.get_editor_property('skeleton') task.options.set_editor_property('skeleton', skeleton) # do import fbx and replace existing data unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) container_path = f'{container["namespace"]}/{container["objectName"]}' # update metadata unreal_pipeline.imprint( container_path, { "representation": str(representation["_id"]), "parent": str(representation["parent"]) }) asset_content = EditorAssetLibrary.list_assets( destination_path, recursive=True, include_folder=True ) for a in asset_content: EditorAssetLibrary.save_asset(a) def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) EditorAssetLibrary.delete_directory(path) asset_content = EditorAssetLibrary.list_assets( parent_path, recursive=False, include_folder=True ) if len(asset_content) == 0: EditorAssetLibrary.delete_directory(parent_path) ================================================ FILE: openpype/hosts/unreal/plugins/load/load_camera.py ================================================ # -*- coding: utf-8 -*- """Load camera from FBX.""" from pathlib import Path import unreal from unreal import ( EditorAssetLibrary, EditorLevelLibrary, EditorLevelUtils, LevelSequenceEditorBlueprintLibrary as LevelSequenceLib, ) from openpype.client import get_asset_by_name from openpype.pipeline import ( AYON_CONTAINER_ID, get_current_project_name, ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( generate_sequence, set_sequence_hierarchy, create_container, imprint, ) class CameraLoader(plugin.Loader): """Load Unreal StaticMesh from FBX""" families = ["camera"] label = "Load Camera" representations = ["fbx"] icon = "cube" color = "orange" def _import_camera( self, world, sequence, bindings, import_fbx_settings, import_filename ): ue_version = unreal.SystemLibrary.get_engine_version().split('.') ue_major = int(ue_version[0]) ue_minor = int(ue_version[1]) if ue_major == 4 and ue_minor <= 26: unreal.SequencerTools.import_fbx( world, sequence, bindings, import_fbx_settings, import_filename ) elif (ue_major == 4 and ue_minor >= 27) or ue_major == 5: unreal.SequencerTools.import_level_sequence_fbx( world, sequence, bindings, import_fbx_settings, import_filename ) else: raise NotImplementedError( f"Unreal version {ue_major} not supported") def load(self, context, name, namespace, data): """ Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new directory and then it will create AssetContainer there and imprint it with metadata. This will mark this path as container. Args: context (dict): application context name (str): subset name namespace (str): in Unreal this is basically path to container. This is not passed here, so namespace is set by `containerise()` because only then we know real path. data (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content """ # Create directory for asset and Ayon container hierarchy = context.get('asset').get('data').get('parents') root = "/Game/Ayon" hierarchy_dir = root hierarchy_dir_list = [] for h in hierarchy: hierarchy_dir = f"{hierarchy_dir}/{h}" hierarchy_dir_list.append(hierarchy_dir) asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" tools = unreal.AssetToolsHelpers().get_asset_tools() # Create a unique name for the camera directory unique_number = 1 if EditorAssetLibrary.does_directory_exist(f"{hierarchy_dir}/{asset}"): asset_content = EditorAssetLibrary.list_assets( f"{root}/{asset}", recursive=False, include_folder=True ) # Get highest number to make a unique name folders = [a for a in asset_content if a[-1] == "/" and f"{name}_" in a] # Get number from folder name. Splits the string by "_" and # removes the last element (which is a "/"). f_numbers = [int(f.split("_")[-1][:-1]) for f in folders] f_numbers.sort() unique_number = f_numbers[-1] + 1 if f_numbers else 1 asset_dir, container_name = tools.create_unique_asset_name( f"{hierarchy_dir}/{asset}/{name}_{unique_number:02d}", suffix="") container_name += suffix EditorAssetLibrary.make_directory(asset_dir) # Create map for the shot, and create hierarchy of map. If the maps # already exist, we will use them. h_dir = hierarchy_dir_list[0] h_asset = hierarchy[0] master_level = f"{h_dir}/{h_asset}_map.{h_asset}_map" if not EditorAssetLibrary.does_asset_exist(master_level): EditorLevelLibrary.new_level(f"{h_dir}/{h_asset}_map") level = f"{asset_dir}/{asset}_map_camera.{asset}_map_camera" if not EditorAssetLibrary.does_asset_exist(level): EditorLevelLibrary.new_level(f"{asset_dir}/{asset}_map_camera") EditorLevelLibrary.load_level(master_level) EditorLevelUtils.add_level_to_world( EditorLevelLibrary.get_editor_world(), level, unreal.LevelStreamingDynamic ) EditorLevelLibrary.save_all_dirty_levels() EditorLevelLibrary.load_level(level) # Get all the sequences in the hierarchy. It will create them, if # they don't exist. frame_ranges = [] sequences = [] for (h_dir, h) in zip(hierarchy_dir_list, hierarchy): root_content = EditorAssetLibrary.list_assets( h_dir, recursive=False, include_folder=False) existing_sequences = [ EditorAssetLibrary.find_asset_data(asset) for asset in root_content if EditorAssetLibrary.find_asset_data( asset).get_class().get_name() == 'LevelSequence' ] if existing_sequences: for seq in existing_sequences: sequences.append(seq.get_asset()) frame_ranges.append(( seq.get_asset().get_playback_start(), seq.get_asset().get_playback_end())) else: sequence, frame_range = generate_sequence(h, h_dir) sequences.append(sequence) frame_ranges.append(frame_range) EditorAssetLibrary.make_directory(asset_dir) cam_seq = tools.create_asset( asset_name=f"{asset}_camera", package_path=asset_dir, asset_class=unreal.LevelSequence, factory=unreal.LevelSequenceFactoryNew() ) # Add sequences data to hierarchy for i in range(len(sequences) - 1): set_sequence_hierarchy( sequences[i], sequences[i + 1], frame_ranges[i][1], frame_ranges[i + 1][0], frame_ranges[i + 1][1], [level]) project_name = get_current_project_name() data = get_asset_by_name(project_name, asset)["data"] cam_seq.set_display_rate( unreal.FrameRate(data.get("fps"), 1.0)) cam_seq.set_playback_start(data.get('clipIn')) cam_seq.set_playback_end(data.get('clipOut') + 1) set_sequence_hierarchy( sequences[-1], cam_seq, frame_ranges[-1][1], data.get('clipIn'), data.get('clipOut'), [level]) settings = unreal.MovieSceneUserImportFBXSettings() settings.set_editor_property('reduce_keys', False) if cam_seq: path = self.filepath_from_context(context) self._import_camera( EditorLevelLibrary.get_editor_world(), cam_seq, cam_seq.get_bindings(), settings, path ) # Set range of all sections # Changing the range of the section is not enough. We need to change # the frame of all the keys in the section. for possessable in cam_seq.get_possessables(): for tracks in possessable.get_tracks(): for section in tracks.get_sections(): section.set_range( data.get('clipIn'), data.get('clipOut') + 1) for channel in section.get_all_channels(): for key in channel.get_keys(): old_time = key.get_time().get_editor_property( 'frame_number') old_time_value = old_time.get_editor_property( 'value') new_time = old_time_value + ( data.get('clipIn') - data.get('frameStart') ) key.set_time(unreal.FrameNumber(value=new_time)) # Create Asset Container create_container( container=container_name, path=asset_dir) data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), "representation": context["representation"]["_id"], "parent": context["representation"]["parent"], "family": context["representation"]["context"]["family"] } imprint(f"{asset_dir}/{container_name}", data) EditorLevelLibrary.save_all_dirty_levels() EditorLevelLibrary.load_level(master_level) # Save all assets in the hierarchy asset_content = EditorAssetLibrary.list_assets( hierarchy_dir_list[0], recursive=True, include_folder=False ) for a in asset_content: EditorAssetLibrary.save_asset(a) return asset_content def update(self, container, representation): ar = unreal.AssetRegistryHelpers.get_asset_registry() curr_level_sequence = LevelSequenceLib.get_current_level_sequence() curr_time = LevelSequenceLib.get_current_time() is_cam_lock = LevelSequenceLib.is_camera_cut_locked_to_viewport() editor_subsystem = unreal.UnrealEditorSubsystem() vp_loc, vp_rot = editor_subsystem.get_level_viewport_camera_info() asset_dir = container.get('namespace') EditorLevelLibrary.save_current_level() _filter = unreal.ARFilter( class_names=["LevelSequence"], package_paths=[asset_dir], recursive_paths=False) sequences = ar.get_assets(_filter) _filter = unreal.ARFilter( class_names=["World"], package_paths=[asset_dir], recursive_paths=True) maps = ar.get_assets(_filter) # There should be only one map in the list EditorLevelLibrary.load_level(maps[0].get_asset().get_path_name()) level_sequence = sequences[0].get_asset() display_rate = level_sequence.get_display_rate() playback_start = level_sequence.get_playback_start() playback_end = level_sequence.get_playback_end() sequence_name = f"{container.get('asset')}_camera" # Get the actors in the level sequence. objs = unreal.SequencerTools.get_bound_objects( unreal.EditorLevelLibrary.get_editor_world(), level_sequence, level_sequence.get_bindings(), unreal.SequencerScriptingRange( has_start_value=True, has_end_value=True, inclusive_start=level_sequence.get_playback_start(), exclusive_end=level_sequence.get_playback_end() ) ) # Delete actors from the map for o in objs: if o.bound_objects[0].get_class().get_name() == "CineCameraActor": actor_path = o.bound_objects[0].get_path_name().split(":")[-1] actor = EditorLevelLibrary.get_actor_reference(actor_path) EditorLevelLibrary.destroy_actor(actor) # Remove the Level Sequence from the parent. # We need to traverse the hierarchy from the master sequence to find # the level sequence. root = "/Game/Ayon" namespace = container.get('namespace').replace(f"{root}/", "") ms_asset = namespace.split('/')[0] _filter = unreal.ARFilter( class_names=["LevelSequence"], package_paths=[f"{root}/{ms_asset}"], recursive_paths=False) sequences = ar.get_assets(_filter) master_sequence = sequences[0].get_asset() _filter = unreal.ARFilter( class_names=["World"], package_paths=[f"{root}/{ms_asset}"], recursive_paths=False) levels = ar.get_assets(_filter) master_level = levels[0].get_asset().get_path_name() sequences = [master_sequence] parent = None sub_scene = None for s in sequences: tracks = s.get_master_tracks() subscene_track = None for t in tracks: if t.get_class() == unreal.MovieSceneSubTrack.static_class(): subscene_track = t if subscene_track: sections = subscene_track.get_sections() for ss in sections: if ss.get_sequence().get_name() == sequence_name: parent = s sub_scene = ss break sequences.append(ss.get_sequence()) for i, ss in enumerate(sections): ss.set_row_index(i) if parent: break assert parent, "Could not find the parent sequence" EditorAssetLibrary.delete_asset(level_sequence.get_path_name()) settings = unreal.MovieSceneUserImportFBXSettings() settings.set_editor_property('reduce_keys', False) tools = unreal.AssetToolsHelpers().get_asset_tools() new_sequence = tools.create_asset( asset_name=sequence_name, package_path=asset_dir, asset_class=unreal.LevelSequence, factory=unreal.LevelSequenceFactoryNew() ) new_sequence.set_display_rate(display_rate) new_sequence.set_playback_start(playback_start) new_sequence.set_playback_end(playback_end) sub_scene.set_sequence(new_sequence) self._import_camera( EditorLevelLibrary.get_editor_world(), new_sequence, new_sequence.get_bindings(), settings, str(representation["data"]["path"]) ) # Set range of all sections # Changing the range of the section is not enough. We need to change # the frame of all the keys in the section. project_name = get_current_project_name() asset = container.get('asset') data = get_asset_by_name(project_name, asset)["data"] for possessable in new_sequence.get_possessables(): for tracks in possessable.get_tracks(): for section in tracks.get_sections(): section.set_range( data.get('clipIn'), data.get('clipOut') + 1) for channel in section.get_all_channels(): for key in channel.get_keys(): old_time = key.get_time().get_editor_property( 'frame_number') old_time_value = old_time.get_editor_property( 'value') new_time = old_time_value + ( data.get('clipIn') - data.get('frameStart') ) key.set_time(unreal.FrameNumber(value=new_time)) data = { "representation": str(representation["_id"]), "parent": str(representation["parent"]) } imprint(f"{asset_dir}/{container.get('container_name')}", data) EditorLevelLibrary.save_current_level() asset_content = EditorAssetLibrary.list_assets( f"{root}/{ms_asset}", recursive=True, include_folder=False) for a in asset_content: EditorAssetLibrary.save_asset(a) EditorLevelLibrary.load_level(master_level) if curr_level_sequence: LevelSequenceLib.open_level_sequence(curr_level_sequence) LevelSequenceLib.set_current_time(curr_time) LevelSequenceLib.set_lock_camera_cut_to_viewport(is_cam_lock) editor_subsystem.set_level_viewport_camera_info(vp_loc, vp_rot) def remove(self, container): asset_dir = container.get('namespace') path = Path(asset_dir) ar = unreal.AssetRegistryHelpers.get_asset_registry() _filter = unreal.ARFilter( class_names=["LevelSequence"], package_paths=[asset_dir], recursive_paths=False) sequences = ar.get_assets(_filter) if not sequences: raise Exception("Could not find sequence.") world = ar.get_asset_by_object_path( EditorLevelLibrary.get_editor_world().get_path_name()) _filter = unreal.ARFilter( class_names=["World"], package_paths=[asset_dir], recursive_paths=True) maps = ar.get_assets(_filter) # There should be only one map in the list if not maps: raise Exception("Could not find map.") map = maps[0] EditorLevelLibrary.save_all_dirty_levels() EditorLevelLibrary.load_level(map.get_asset().get_path_name()) # Remove the camera from the level. actors = EditorLevelLibrary.get_all_level_actors() for a in actors: if a.__class__ == unreal.CineCameraActor: EditorLevelLibrary.destroy_actor(a) EditorLevelLibrary.save_all_dirty_levels() EditorLevelLibrary.load_level(world.get_asset().get_path_name()) # There should be only one sequence in the path. sequence_name = sequences[0].asset_name # Remove the Level Sequence from the parent. # We need to traverse the hierarchy from the master sequence to find # the level sequence. root = "/Game/Ayon" namespace = container.get('namespace').replace(f"{root}/", "") ms_asset = namespace.split('/')[0] _filter = unreal.ARFilter( class_names=["LevelSequence"], package_paths=[f"{root}/{ms_asset}"], recursive_paths=False) sequences = ar.get_assets(_filter) master_sequence = sequences[0].get_asset() _filter = unreal.ARFilter( class_names=["World"], package_paths=[f"{root}/{ms_asset}"], recursive_paths=False) levels = ar.get_assets(_filter) master_level = levels[0].get_full_name() sequences = [master_sequence] parent = None for s in sequences: tracks = s.get_master_tracks() subscene_track = None visibility_track = None for t in tracks: if t.get_class() == unreal.MovieSceneSubTrack.static_class(): subscene_track = t if (t.get_class() == unreal.MovieSceneLevelVisibilityTrack.static_class()): visibility_track = t if subscene_track: sections = subscene_track.get_sections() for ss in sections: if ss.get_sequence().get_name() == sequence_name: parent = s subscene_track.remove_section(ss) break sequences.append(ss.get_sequence()) # Update subscenes indexes. for i, ss in enumerate(sections): ss.set_row_index(i) if visibility_track: sections = visibility_track.get_sections() for ss in sections: if (unreal.Name(f"{container.get('asset')}_map_camera") in ss.get_level_names()): visibility_track.remove_section(ss) # Update visibility sections indexes. i = -1 prev_name = [] for ss in sections: if prev_name != ss.get_level_names(): i += 1 ss.set_row_index(i) prev_name = ss.get_level_names() if parent: break assert parent, "Could not find the parent sequence" # Create a temporary level to delete the layout level. EditorLevelLibrary.save_all_dirty_levels() EditorAssetLibrary.make_directory(f"{root}/tmp") tmp_level = f"{root}/tmp/temp_map" if not EditorAssetLibrary.does_asset_exist(f"{tmp_level}.temp_map"): EditorLevelLibrary.new_level(tmp_level) else: EditorLevelLibrary.load_level(tmp_level) # Delete the layout directory. EditorAssetLibrary.delete_directory(asset_dir) EditorLevelLibrary.load_level(master_level) EditorAssetLibrary.delete_directory(f"{root}/tmp") # Check if there isn't any more assets in the parent folder, and # delete it if not. asset_content = EditorAssetLibrary.list_assets( path.parent.as_posix(), recursive=False, include_folder=True ) if len(asset_content) == 0: EditorAssetLibrary.delete_directory(path.parent.as_posix()) ================================================ FILE: openpype/hosts/unreal/plugins/load/load_geometrycache_abc.py ================================================ # -*- coding: utf-8 -*- """Loader for published alembics.""" import os from openpype.pipeline import ( get_representation_path, AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( AYON_ASSET_DIR, create_container, imprint, ) import unreal # noqa class PointCacheAlembicLoader(plugin.Loader): """Load Point Cache from Alembic""" families = ["model", "pointcache"] label = "Import Alembic Point Cache" representations = ["abc"] icon = "cube" color = "orange" root = AYON_ASSET_DIR @staticmethod def get_task( filename, asset_dir, asset_name, replace, frame_start=None, frame_end=None ): task = unreal.AssetImportTask() options = unreal.AbcImportSettings() gc_settings = unreal.AbcGeometryCacheSettings() conversion_settings = unreal.AbcConversionSettings() sampling_settings = unreal.AbcSamplingSettings() task.set_editor_property('filename', filename) task.set_editor_property('destination_path', asset_dir) task.set_editor_property('destination_name', asset_name) task.set_editor_property('replace_existing', replace) task.set_editor_property('automated', True) task.set_editor_property('save', True) options.set_editor_property( 'import_type', unreal.AlembicImportType.GEOMETRY_CACHE) gc_settings.set_editor_property('flatten_tracks', False) conversion_settings.set_editor_property('flip_u', False) conversion_settings.set_editor_property('flip_v', True) conversion_settings.set_editor_property( 'scale', unreal.Vector(x=100.0, y=100.0, z=100.0)) conversion_settings.set_editor_property( 'rotation', unreal.Vector(x=-90.0, y=0.0, z=180.0)) if frame_start is not None: sampling_settings.set_editor_property('frame_start', frame_start) if frame_end is not None: sampling_settings.set_editor_property('frame_end', frame_end) options.geometry_cache_settings = gc_settings options.conversion_settings = conversion_settings options.sampling_settings = sampling_settings task.options = options return task def import_and_containerize( self, filepath, asset_dir, asset_name, container_name, frame_start, frame_end ): unreal.EditorAssetLibrary.make_directory(asset_dir) task = self.get_task( filepath, asset_dir, asset_name, False, frame_start, frame_end) unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # Create Asset Container create_container(container=container_name, path=asset_dir) def imprint( self, asset, asset_dir, container_name, asset_name, representation, frame_start, frame_end ): data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), "representation": representation["_id"], "parent": representation["parent"], "family": representation["context"]["family"], "frame_start": frame_start, "frame_end": frame_end } imprint(f"{asset_dir}/{container_name}", data) def load(self, context, name, namespace, options): """Load and containerise representation into Content Browser. Args: context (dict): application context name (str): subset name namespace (str): in Unreal this is basically path to container. This is not passed here, so namespace is set by `containerise()` because only then we know real path. data (dict): Those would be data to be imprinted. Returns: list(str): list of container content """ # Create directory for asset and Ayon container asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" version = context.get('version') # Check if version is hero version and use different name if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: name_version = f"{name}_v{version.get('name'):03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{self.root}/{asset}/{name_version}", suffix="") container_name += suffix frame_start = context.get('asset').get('data').get('frameStart') frame_end = context.get('asset').get('data').get('frameEnd') # If frame start and end are the same, we increase the end frame by # one, otherwise Unreal will not import it if frame_start == frame_end: frame_end += 1 if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): path = self.filepath_from_context(context) self.import_and_containerize( path, asset_dir, asset_name, container_name, frame_start, frame_end) self.imprint( asset, asset_dir, container_name, asset_name, context["representation"], frame_start, frame_end) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) return asset_content def update(self, container, representation): context = representation.get("context", {}) unreal.log_warning(context) if not context: raise RuntimeError("No context found in representation") # Create directory for asset and Ayon container asset = context.get('asset') name = context.get('subset') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" version = context.get('version') # Check if version is hero version and use different name name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{self.root}/{asset}/{name_version}", suffix="") container_name += suffix frame_start = int(container.get("frame_start")) frame_end = int(container.get("frame_end")) if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): path = get_representation_path(representation) self.import_and_containerize( path, asset_dir, asset_name, container_name, frame_start, frame_end) self.imprint( asset, asset_dir, container_name, asset_name, representation, frame_start, frame_end) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=False ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) unreal.EditorAssetLibrary.delete_directory(path) asset_content = unreal.EditorAssetLibrary.list_assets( parent_path, recursive=False ) if len(asset_content) == 0: unreal.EditorAssetLibrary.delete_directory(parent_path) ================================================ FILE: openpype/hosts/unreal/plugins/load/load_layout.py ================================================ # -*- coding: utf-8 -*- """Loader for layouts.""" import json import collections from pathlib import Path import unreal from unreal import ( EditorAssetLibrary, EditorLevelLibrary, EditorLevelUtils, AssetToolsHelpers, FBXImportType, MovieSceneLevelVisibilityTrack, MovieSceneSubTrack, LevelSequenceEditorBlueprintLibrary as LevelSequenceLib, ) from openpype.client import get_asset_by_name, get_representations from openpype.pipeline import ( discover_loader_plugins, loaders_from_representation, load_container, get_representation_path, AYON_CONTAINER_ID, get_current_project_name, ) from openpype.pipeline.context_tools import get_current_project_asset from openpype.settings import get_current_project_settings from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( generate_sequence, set_sequence_hierarchy, create_container, imprint, ls, ) class LayoutLoader(plugin.Loader): """Load Layout from a JSON file""" families = ["layout"] representations = ["json"] label = "Load Layout" icon = "code-fork" color = "orange" ASSET_ROOT = "/Game/Ayon" def _get_asset_containers(self, path): ar = unreal.AssetRegistryHelpers.get_asset_registry() asset_content = EditorAssetLibrary.list_assets( path, recursive=True) asset_containers = [] # Get all the asset containers for a in asset_content: obj = ar.get_asset_by_object_path(a) if obj.get_asset().get_class().get_name() == 'AyonAssetContainer': asset_containers.append(obj) return asset_containers @staticmethod def _get_fbx_loader(loaders, family): name = "" if family == 'rig': name = "SkeletalMeshFBXLoader" elif family == 'model': name = "StaticMeshFBXLoader" elif family == 'camera': name = "CameraLoader" if name == "": return None for loader in loaders: if loader.__name__ == name: return loader return None @staticmethod def _get_abc_loader(loaders, family): name = "" if family == 'rig': name = "SkeletalMeshAlembicLoader" elif family == 'model': name = "StaticMeshAlembicLoader" if name == "": return None for loader in loaders: if loader.__name__ == name: return loader return None def _transform_from_basis(self, transform, basis): """Transform a transform from a basis to a new basis.""" # Get the basis matrix basis_matrix = unreal.Matrix( basis[0], basis[1], basis[2], basis[3] ) transform_matrix = unreal.Matrix( transform[0], transform[1], transform[2], transform[3] ) new_transform = ( basis_matrix.get_inverse() * transform_matrix * basis_matrix) return new_transform.transform() def _process_family( self, assets, class_name, transform, basis, sequence, inst_name=None ): ar = unreal.AssetRegistryHelpers.get_asset_registry() actors = [] bindings = [] for asset in assets: obj = ar.get_asset_by_object_path(asset).get_asset() if obj.get_class().get_name() == class_name: t = self._transform_from_basis(transform, basis) actor = EditorLevelLibrary.spawn_actor_from_object( obj, t.translation ) actor.set_actor_rotation(t.rotation.rotator(), False) actor.set_actor_scale3d(t.scale3d) if class_name == 'SkeletalMesh': skm_comp = actor.get_editor_property( 'skeletal_mesh_component') skm_comp.set_bounds_scale(10.0) actors.append(actor) if sequence: binding = None for p in sequence.get_possessables(): if p.get_name() == actor.get_name(): binding = p break if not binding: binding = sequence.add_possessable(actor) bindings.append(binding) return actors, bindings def _import_animation( self, asset_dir, path, instance_name, skeleton, actors_dict, animation_file, bindings_dict, sequence ): anim_file = Path(animation_file) anim_file_name = anim_file.with_suffix('') anim_path = f"{asset_dir}/animations/{anim_file_name}" asset_doc = get_current_project_asset() # Import animation task = unreal.AssetImportTask() task.options = unreal.FbxImportUI() task.set_editor_property( 'filename', str(path.with_suffix(f".{animation_file}"))) task.set_editor_property('destination_path', anim_path) task.set_editor_property( 'destination_name', f"{instance_name}_animation") task.set_editor_property('replace_existing', False) task.set_editor_property('automated', True) task.set_editor_property('save', False) # set import options here task.options.set_editor_property( 'automated_import_should_detect_type', False) task.options.set_editor_property( 'original_import_type', FBXImportType.FBXIT_SKELETAL_MESH) task.options.set_editor_property( 'mesh_type_to_import', FBXImportType.FBXIT_ANIMATION) task.options.set_editor_property('import_mesh', False) task.options.set_editor_property('import_animations', True) task.options.set_editor_property('override_full_name', True) task.options.set_editor_property('skeleton', skeleton) task.options.anim_sequence_import_data.set_editor_property( 'animation_length', unreal.FBXAnimationLengthImportType.FBXALIT_EXPORTED_TIME ) task.options.anim_sequence_import_data.set_editor_property( 'import_meshes_in_bone_hierarchy', False) task.options.anim_sequence_import_data.set_editor_property( 'use_default_sample_rate', False) task.options.anim_sequence_import_data.set_editor_property( 'custom_sample_rate', asset_doc.get("data", {}).get("fps")) task.options.anim_sequence_import_data.set_editor_property( 'import_custom_attribute', True) task.options.anim_sequence_import_data.set_editor_property( 'import_bone_tracks', True) task.options.anim_sequence_import_data.set_editor_property( 'remove_redundant_keys', False) task.options.anim_sequence_import_data.set_editor_property( 'convert_scene', True) AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) asset_content = unreal.EditorAssetLibrary.list_assets( anim_path, recursive=False, include_folder=False ) animation = None for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) imported_asset_data = unreal.EditorAssetLibrary.find_asset_data(a) imported_asset = unreal.AssetRegistryHelpers.get_asset( imported_asset_data) if imported_asset.__class__ == unreal.AnimSequence: animation = imported_asset break if animation: actor = None if actors_dict.get(instance_name): for a in actors_dict.get(instance_name): if a.get_class().get_name() == 'SkeletalMeshActor': actor = a break animation.set_editor_property('enable_root_motion', True) actor.skeletal_mesh_component.set_editor_property( 'animation_mode', unreal.AnimationMode.ANIMATION_SINGLE_NODE) actor.skeletal_mesh_component.animation_data.set_editor_property( 'anim_to_play', animation) if sequence: # Add animation to the sequencer bindings = bindings_dict.get(instance_name) ar = unreal.AssetRegistryHelpers.get_asset_registry() for binding in bindings: tracks = binding.get_tracks() track = None track = tracks[0] if tracks else binding.add_track( unreal.MovieSceneSkeletalAnimationTrack) sections = track.get_sections() section = None if not sections: section = track.add_section() else: section = sections[0] sec_params = section.get_editor_property('params') curr_anim = sec_params.get_editor_property('animation') if curr_anim: # Checks if the animation path has a container. # If it does, it means that the animation is # already in the sequencer. anim_path = str(Path( curr_anim.get_path_name()).parent ).replace('\\', '/') _filter = unreal.ARFilter( class_names=["AyonAssetContainer"], package_paths=[anim_path], recursive_paths=False) containers = ar.get_assets(_filter) if len(containers) > 0: return section.set_range( sequence.get_playback_start(), sequence.get_playback_end()) sec_params = section.get_editor_property('params') sec_params.set_editor_property('animation', animation) def _get_repre_docs_by_version_id(self, data): version_ids = { element.get("version") for element in data if element.get("representation") } version_ids.discard(None) output = collections.defaultdict(list) if not version_ids: return output project_name = get_current_project_name() repre_docs = get_representations( project_name, representation_names=["fbx", "abc"], version_ids=version_ids, fields=["_id", "parent", "name"] ) for repre_doc in repre_docs: version_id = str(repre_doc["parent"]) output[version_id].append(repre_doc) return output def _process(self, lib_path, asset_dir, sequence, repr_loaded=None): ar = unreal.AssetRegistryHelpers.get_asset_registry() with open(lib_path, "r") as fp: data = json.load(fp) all_loaders = discover_loader_plugins() if not repr_loaded: repr_loaded = [] path = Path(lib_path) skeleton_dict = {} actors_dict = {} bindings_dict = {} loaded_assets = [] repre_docs_by_version_id = self._get_repre_docs_by_version_id(data) for element in data: representation = None repr_format = None if element.get('representation'): repre_docs = repre_docs_by_version_id[element.get("version")] if not repre_docs: self.log.error( f"No valid representation found for version " f"{element.get('version')}") continue repre_doc = repre_docs[0] representation = str(repre_doc["_id"]) repr_format = repre_doc["name"] # This is to keep compatibility with old versions of the # json format. elif element.get('reference_fbx'): representation = element.get('reference_fbx') repr_format = 'fbx' elif element.get('reference_abc'): representation = element.get('reference_abc') repr_format = 'abc' # If reference is None, this element is skipped, as it cannot be # imported in Unreal if not representation: continue instance_name = element.get('instance_name') skeleton = None if representation not in repr_loaded: repr_loaded.append(representation) family = element.get('family') loaders = loaders_from_representation( all_loaders, representation) loader = None if repr_format == 'fbx': loader = self._get_fbx_loader(loaders, family) elif repr_format == 'abc': loader = self._get_abc_loader(loaders, family) if not loader: self.log.error( f"No valid loader found for {representation}") continue options = { # "asset_dir": asset_dir } assets = load_container( loader, representation, namespace=instance_name, options=options ) container = None for asset in assets: obj = ar.get_asset_by_object_path(asset).get_asset() if obj.get_class().get_name() == 'AyonAssetContainer': container = obj if obj.get_class().get_name() == 'Skeleton': skeleton = obj loaded_assets.append(container.get_path_name()) instances = [ item for item in data if ((item.get('version') and item.get('version') == element.get('version')) or item.get('reference_fbx') == representation or item.get('reference_abc') == representation)] for instance in instances: # transform = instance.get('transform') transform = instance.get('transform_matrix') basis = instance.get('basis') inst = instance.get('instance_name') actors = [] if family == 'model': actors, _ = self._process_family( assets, 'StaticMesh', transform, basis, sequence, inst ) elif family == 'rig': actors, bindings = self._process_family( assets, 'SkeletalMesh', transform, basis, sequence, inst ) actors_dict[inst] = actors bindings_dict[inst] = bindings if skeleton: skeleton_dict[representation] = skeleton else: skeleton = skeleton_dict.get(representation) animation_file = element.get('animation') if animation_file and skeleton: self._import_animation( asset_dir, path, instance_name, skeleton, actors_dict, animation_file, bindings_dict, sequence) return loaded_assets @staticmethod def _remove_family(assets, components, class_name, prop_name): ar = unreal.AssetRegistryHelpers.get_asset_registry() objects = [] for a in assets: obj = ar.get_asset_by_object_path(a) if obj.get_asset().get_class().get_name() == class_name: objects.append(obj) for obj in objects: for comp in components: if comp.get_editor_property(prop_name) == obj.get_asset(): comp.get_owner().destroy_actor() def _remove_actors(self, path): asset_containers = self._get_asset_containers(path) # Get all the static and skeletal meshes components in the level components = EditorLevelLibrary.get_all_level_actors_components() static_meshes_comp = [ c for c in components if c.get_class().get_name() == 'StaticMeshComponent'] skel_meshes_comp = [ c for c in components if c.get_class().get_name() == 'SkeletalMeshComponent'] # For all the asset containers, get the static and skeletal meshes. # Then, check the components in the level and destroy the matching # actors. for asset_container in asset_containers: package_path = asset_container.get_editor_property('package_path') family = EditorAssetLibrary.get_metadata_tag( asset_container.get_asset(), 'family') assets = EditorAssetLibrary.list_assets( str(package_path), recursive=False) if family == 'model': self._remove_family( assets, static_meshes_comp, 'StaticMesh', 'static_mesh') elif family == 'rig': self._remove_family( assets, skel_meshes_comp, 'SkeletalMesh', 'skeletal_mesh') def load(self, context, name, namespace, options): """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new directory and then it will create AssetContainer there and imprint it with metadata. This will mark this path as container. Args: context (dict): application context name (str): subset name namespace (str): in Unreal this is basically path to container. This is not passed here, so namespace is set by `containerise()` because only then we know real path. options (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content """ data = get_current_project_settings() create_sequences = data["unreal"]["level_sequences_for_layouts"] # Create directory for asset and Ayon container hierarchy = context.get('asset').get('data').get('parents') root = self.ASSET_ROOT hierarchy_dir = root hierarchy_dir_list = [] for h in hierarchy: hierarchy_dir = f"{hierarchy_dir}/{h}" hierarchy_dir_list.append(hierarchy_dir) asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else name tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( "{}/{}/{}".format(hierarchy_dir, asset, name), suffix="") container_name += suffix EditorAssetLibrary.make_directory(asset_dir) master_level = None shot = None sequences = [] level = f"{asset_dir}/{asset}_map.{asset}_map" EditorLevelLibrary.new_level(f"{asset_dir}/{asset}_map") if create_sequences: # Create map for the shot, and create hierarchy of map. If the # maps already exist, we will use them. if hierarchy: h_dir = hierarchy_dir_list[0] h_asset = hierarchy[0] master_level = f"{h_dir}/{h_asset}_map.{h_asset}_map" if not EditorAssetLibrary.does_asset_exist(master_level): EditorLevelLibrary.new_level(f"{h_dir}/{h_asset}_map") if master_level: EditorLevelLibrary.load_level(master_level) EditorLevelUtils.add_level_to_world( EditorLevelLibrary.get_editor_world(), level, unreal.LevelStreamingDynamic ) EditorLevelLibrary.save_all_dirty_levels() EditorLevelLibrary.load_level(level) # Get all the sequences in the hierarchy. It will create them, if # they don't exist. frame_ranges = [] for (h_dir, h) in zip(hierarchy_dir_list, hierarchy): root_content = EditorAssetLibrary.list_assets( h_dir, recursive=False, include_folder=False) existing_sequences = [ EditorAssetLibrary.find_asset_data(asset) for asset in root_content if EditorAssetLibrary.find_asset_data( asset).get_class().get_name() == 'LevelSequence' ] if not existing_sequences: sequence, frame_range = generate_sequence(h, h_dir) sequences.append(sequence) frame_ranges.append(frame_range) else: for e in existing_sequences: sequences.append(e.get_asset()) frame_ranges.append(( e.get_asset().get_playback_start(), e.get_asset().get_playback_end())) shot = tools.create_asset( asset_name=asset, package_path=asset_dir, asset_class=unreal.LevelSequence, factory=unreal.LevelSequenceFactoryNew() ) # sequences and frame_ranges have the same length for i in range(0, len(sequences) - 1): set_sequence_hierarchy( sequences[i], sequences[i + 1], frame_ranges[i][1], frame_ranges[i + 1][0], frame_ranges[i + 1][1], [level]) project_name = get_current_project_name() data = get_asset_by_name(project_name, asset)["data"] shot.set_display_rate( unreal.FrameRate(data.get("fps"), 1.0)) shot.set_playback_start(0) shot.set_playback_end(data.get('clipOut') - data.get('clipIn') + 1) if sequences: set_sequence_hierarchy( sequences[-1], shot, frame_ranges[-1][1], data.get('clipIn'), data.get('clipOut'), [level]) EditorLevelLibrary.load_level(level) path = self.filepath_from_context(context) loaded_assets = self._process(path, asset_dir, shot) for s in sequences: EditorAssetLibrary.save_asset(s.get_path_name()) EditorLevelLibrary.save_current_level() # Create Asset Container create_container( container=container_name, path=asset_dir) data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), "representation": context["representation"]["_id"], "parent": context["representation"]["parent"], "family": context["representation"]["context"]["family"], "loaded_assets": loaded_assets } imprint( "{}/{}".format(asset_dir, container_name), data) save_dir = hierarchy_dir_list[0] if create_sequences else asset_dir asset_content = EditorAssetLibrary.list_assets( save_dir, recursive=True, include_folder=False) for a in asset_content: EditorAssetLibrary.save_asset(a) if master_level: EditorLevelLibrary.load_level(master_level) return asset_content def update(self, container, representation): data = get_current_project_settings() create_sequences = data["unreal"]["level_sequences_for_layouts"] ar = unreal.AssetRegistryHelpers.get_asset_registry() curr_level_sequence = LevelSequenceLib.get_current_level_sequence() curr_time = LevelSequenceLib.get_current_time() is_cam_lock = LevelSequenceLib.is_camera_cut_locked_to_viewport() editor_subsystem = unreal.UnrealEditorSubsystem() vp_loc, vp_rot = editor_subsystem.get_level_viewport_camera_info() root = "/Game/Ayon" asset_dir = container.get('namespace') context = representation.get("context") hierarchy = context.get('hierarchy').split("/") sequence = None master_level = None if create_sequences: h_dir = f"{root}/{hierarchy[0]}" h_asset = hierarchy[0] master_level = f"{h_dir}/{h_asset}_map.{h_asset}_map" filter = unreal.ARFilter( class_names=["LevelSequence"], package_paths=[asset_dir], recursive_paths=False) sequences = ar.get_assets(filter) sequence = sequences[0].get_asset() prev_level = None if not master_level: curr_level = unreal.LevelEditorSubsystem().get_current_level() curr_level_path = curr_level.get_outer().get_path_name() # If the level path does not start with "/Game/", the current # level is a temporary, unsaved level. if curr_level_path.startswith("/Game/"): prev_level = curr_level_path # Get layout level filter = unreal.ARFilter( class_names=["World"], package_paths=[asset_dir], recursive_paths=False) levels = ar.get_assets(filter) layout_level = levels[0].get_asset().get_path_name() EditorLevelLibrary.save_all_dirty_levels() EditorLevelLibrary.load_level(layout_level) # Delete all the actors in the level actors = unreal.EditorLevelLibrary.get_all_level_actors() for actor in actors: unreal.EditorLevelLibrary.destroy_actor(actor) if create_sequences: EditorLevelLibrary.save_current_level() EditorAssetLibrary.delete_directory(f"{asset_dir}/animations/") source_path = get_representation_path(representation) loaded_assets = self._process(source_path, asset_dir, sequence) data = { "representation": str(representation["_id"]), "parent": str(representation["parent"]), "loaded_assets": loaded_assets } imprint( "{}/{}".format(asset_dir, container.get('container_name')), data) EditorLevelLibrary.save_current_level() save_dir = f"{root}/{hierarchy[0]}" if create_sequences else asset_dir asset_content = EditorAssetLibrary.list_assets( save_dir, recursive=True, include_folder=False) for a in asset_content: EditorAssetLibrary.save_asset(a) if master_level: EditorLevelLibrary.load_level(master_level) elif prev_level: EditorLevelLibrary.load_level(prev_level) if curr_level_sequence: LevelSequenceLib.open_level_sequence(curr_level_sequence) LevelSequenceLib.set_current_time(curr_time) LevelSequenceLib.set_lock_camera_cut_to_viewport(is_cam_lock) editor_subsystem.set_level_viewport_camera_info(vp_loc, vp_rot) def remove(self, container): """ Delete the layout. First, check if the assets loaded with the layout are used by other layouts. If not, delete the assets. """ data = get_current_project_settings() create_sequences = data["unreal"]["level_sequences_for_layouts"] root = "/Game/Ayon" path = Path(container.get("namespace")) containers = ls() layout_containers = [ c for c in containers if (c.get('asset_name') != container.get('asset_name') and c.get('family') == "layout")] # Check if the assets have been loaded by other layouts, and deletes # them if they haven't. for asset in eval(container.get('loaded_assets')): layouts = [ lc for lc in layout_containers if asset in lc.get('loaded_assets')] if not layouts: EditorAssetLibrary.delete_directory(str(Path(asset).parent)) # Delete the parent folder if there aren't any more # layouts in it. asset_content = EditorAssetLibrary.list_assets( str(Path(asset).parent.parent), recursive=False, include_folder=True ) if len(asset_content) == 0: EditorAssetLibrary.delete_directory( str(Path(asset).parent.parent)) master_sequence = None master_level = None sequences = [] if create_sequences: # Remove the Level Sequence from the parent. # We need to traverse the hierarchy from the master sequence to # find the level sequence. namespace = container.get('namespace').replace(f"{root}/", "") ms_asset = namespace.split('/')[0] ar = unreal.AssetRegistryHelpers.get_asset_registry() _filter = unreal.ARFilter( class_names=["LevelSequence"], package_paths=[f"{root}/{ms_asset}"], recursive_paths=False) sequences = ar.get_assets(_filter) master_sequence = sequences[0].get_asset() _filter = unreal.ARFilter( class_names=["World"], package_paths=[f"{root}/{ms_asset}"], recursive_paths=False) levels = ar.get_assets(_filter) master_level = levels[0].get_asset().get_path_name() sequences = [master_sequence] parent = None for s in sequences: tracks = s.get_master_tracks() subscene_track = None visibility_track = None for t in tracks: if t.get_class() == MovieSceneSubTrack.static_class(): subscene_track = t if (t.get_class() == MovieSceneLevelVisibilityTrack.static_class()): visibility_track = t if subscene_track: sections = subscene_track.get_sections() for ss in sections: if (ss.get_sequence().get_name() == container.get('asset')): parent = s subscene_track.remove_section(ss) break sequences.append(ss.get_sequence()) # Update subscenes indexes. i = 0 for ss in sections: ss.set_row_index(i) i += 1 if visibility_track: sections = visibility_track.get_sections() for ss in sections: if (unreal.Name(f"{container.get('asset')}_map") in ss.get_level_names()): visibility_track.remove_section(ss) # Update visibility sections indexes. i = -1 prev_name = [] for ss in sections: if prev_name != ss.get_level_names(): i += 1 ss.set_row_index(i) prev_name = ss.get_level_names() if parent: break assert parent, "Could not find the parent sequence" # Create a temporary level to delete the layout level. EditorLevelLibrary.save_all_dirty_levels() EditorAssetLibrary.make_directory(f"{root}/tmp") tmp_level = f"{root}/tmp/temp_map" if not EditorAssetLibrary.does_asset_exist(f"{tmp_level}.temp_map"): EditorLevelLibrary.new_level(tmp_level) else: EditorLevelLibrary.load_level(tmp_level) # Delete the layout directory. EditorAssetLibrary.delete_directory(str(path)) if create_sequences: EditorLevelLibrary.load_level(master_level) EditorAssetLibrary.delete_directory(f"{root}/tmp") # Delete the parent folder if there aren't any more layouts in it. asset_content = EditorAssetLibrary.list_assets( str(path.parent), recursive=False, include_folder=True ) if len(asset_content) == 0: EditorAssetLibrary.delete_directory(str(path.parent)) ================================================ FILE: openpype/hosts/unreal/plugins/load/load_layout_existing.py ================================================ import json from pathlib import Path import unreal from unreal import EditorLevelLibrary from openpype.client import get_representations from openpype.pipeline import ( discover_loader_plugins, loaders_from_representation, load_container, get_representation_path, AYON_CONTAINER_ID, get_current_project_name, ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as upipeline class ExistingLayoutLoader(plugin.Loader): """ Load Layout for an existing scene, and match the existing assets. """ families = ["layout"] representations = ["json"] label = "Load Layout on Existing Scene" icon = "code-fork" color = "orange" ASSET_ROOT = "/Game/Ayon" delete_unmatched_assets = True @classmethod def apply_settings(cls, project_settings, *args, **kwargs): super(ExistingLayoutLoader, cls).apply_settings( project_settings, *args, **kwargs ) cls.delete_unmatched_assets = ( project_settings["unreal"]["delete_unmatched_assets"] ) @staticmethod def _create_container( asset_name, asset_dir, asset, representation, parent, family ): container_name = f"{asset_name}_CON" container = None if not unreal.EditorAssetLibrary.does_asset_exist( f"{asset_dir}/{container_name}" ): container = upipeline.create_container(container_name, asset_dir) else: ar = unreal.AssetRegistryHelpers.get_asset_registry() obj = ar.get_asset_by_object_path( f"{asset_dir}/{container_name}.{container_name}") container = obj.get_asset() data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, "asset_name": asset_name, # "loader": str(self.__class__.__name__), "representation": representation, "parent": parent, "family": family } upipeline.imprint( "{}/{}".format(asset_dir, container_name), data) return container.get_path_name() @staticmethod def _get_current_level(): ue_version = unreal.SystemLibrary.get_engine_version().split('.') ue_major = ue_version[0] if ue_major == '4': return EditorLevelLibrary.get_editor_world() elif ue_major == '5': return unreal.LevelEditorSubsystem().get_current_level() raise NotImplementedError( f"Unreal version {ue_major} not supported") def _transform_from_basis(self, transform, basis): """Transform a transform from a basis to a new basis.""" # Get the basis matrix basis_matrix = unreal.Matrix( basis[0], basis[1], basis[2], basis[3] ) transform_matrix = unreal.Matrix( transform[0], transform[1], transform[2], transform[3] ) new_transform = ( basis_matrix.get_inverse() * transform_matrix * basis_matrix) return new_transform.transform() def _spawn_actor(self, obj, lasset): actor = EditorLevelLibrary.spawn_actor_from_object( obj, unreal.Vector(0.0, 0.0, 0.0) ) actor.set_actor_label(lasset.get('instance_name')) transform = lasset.get('transform_matrix') basis = lasset.get('basis') computed_transform = self._transform_from_basis(transform, basis) actor.set_actor_transform(computed_transform, False, True) @staticmethod def _get_fbx_loader(loaders, family): name = "" if family == 'rig': name = "SkeletalMeshFBXLoader" elif family == 'model' or family == 'staticMesh': name = "StaticMeshFBXLoader" elif family == 'camera': name = "CameraLoader" if name == "": return None for loader in loaders: if loader.__name__ == name: return loader return None @staticmethod def _get_abc_loader(loaders, family): name = "" if family == 'rig': name = "SkeletalMeshAlembicLoader" elif family == 'model': name = "StaticMeshAlembicLoader" if name == "": return None for loader in loaders: if loader.__name__ == name: return loader return None def _load_asset(self, repr_data, representation, instance_name, family): repr_format = repr_data.get('name') all_loaders = discover_loader_plugins() loaders = loaders_from_representation( all_loaders, representation) loader = None if repr_format == 'fbx': loader = self._get_fbx_loader(loaders, family) elif repr_format == 'abc': loader = self._get_abc_loader(loaders, family) if not loader: self.log.error(f"No valid loader found for {representation}") return [] # This option is necessary to avoid importing the assets with a # different conversion compared to the other assets. For ABC files, # it is in fact impossible to access the conversion settings. So, # we must assume that the Maya conversion settings have been applied. options = { "default_conversion": True } assets = load_container( loader, representation, namespace=instance_name, options=options ) return assets def _get_valid_repre_docs(self, project_name, version_ids): valid_formats = ['fbx', 'abc'] repre_docs = list(get_representations( project_name, representation_names=valid_formats, version_ids=version_ids )) repre_doc_by_version_id = {} for repre_doc in repre_docs: version_id = str(repre_doc["parent"]) repre_doc_by_version_id[version_id] = repre_doc return repre_doc_by_version_id def _process(self, lib_path, project_name): ar = unreal.AssetRegistryHelpers.get_asset_registry() actors = EditorLevelLibrary.get_all_level_actors() with open(lib_path, "r") as fp: data = json.load(fp) elements = [] repre_ids = set() # Get all the representations in the JSON from the database. for element in data: repre_id = element.get('representation') if repre_id: repre_ids.add(repre_id) elements.append(element) repre_docs = get_representations( project_name, representation_ids=repre_ids ) repre_docs_by_id = { str(repre_doc["_id"]): repre_doc for repre_doc in repre_docs } layout_data = [] version_ids = set() for element in elements: repre_id = element.get("representation") repre_doc = repre_docs_by_id.get(repre_id) if not repre_doc: raise AssertionError("Representation not found") if not (repre_doc.get('data') or repre_doc['data'].get('path')): raise AssertionError("Representation does not have path") if not repre_doc.get('context'): raise AssertionError("Representation does not have context") layout_data.append((repre_doc, element)) version_ids.add(repre_doc["parent"]) # Prequery valid repre documents for all elements at once valid_repre_doc_by_version_id = self._get_valid_repre_docs( project_name, version_ids) containers = [] actors_matched = [] for (repr_data, lasset) in layout_data: # For every actor in the scene, check if it has a representation in # those we got from the JSON. If so, create a container for it. # Otherwise, remove it from the scene. found = False for actor in actors: if not actor.get_class().get_name() == 'StaticMeshActor': continue if actor in actors_matched: continue # Get the original path of the file from which the asset has # been imported. smc = actor.get_editor_property('static_mesh_component') mesh = smc.get_editor_property('static_mesh') import_data = mesh.get_editor_property('asset_import_data') filename = import_data.get_first_filename() path = Path(filename) if (not path.name or path.name not in repr_data.get('data').get('path')): continue actor.set_actor_label(lasset.get('instance_name')) mesh_path = Path(mesh.get_path_name()).parent.as_posix() # Create the container for the asset. asset = repr_data.get('context').get('asset') subset = repr_data.get('context').get('subset') container = self._create_container( f"{asset}_{subset}", mesh_path, asset, repr_data.get('_id'), repr_data.get('parent'), repr_data.get('context').get('family') ) containers.append(container) # Set the transform for the actor. transform = lasset.get('transform_matrix') basis = lasset.get('basis') computed_transform = self._transform_from_basis( transform, basis) actor.set_actor_transform(computed_transform, False, True) actors_matched.append(actor) found = True break # If an actor has not been found for this representation, # we check if it has been loaded already by checking all the # loaded containers. If so, we add it to the scene. Otherwise, # we load it. if found: continue all_containers = upipeline.ls() loaded = False for container in all_containers: repr = container.get('representation') if not repr == str(repr_data.get('_id')): continue asset_dir = container.get('namespace') filter = unreal.ARFilter( class_names=["StaticMesh"], package_paths=[asset_dir], recursive_paths=False) assets = ar.get_assets(filter) for asset in assets: obj = asset.get_asset() self._spawn_actor(obj, lasset) loaded = True break # If the asset has not been loaded yet, we load it. if loaded: continue assets = self._load_asset( valid_repre_doc_by_version_id.get(lasset.get('version')), lasset.get('representation'), lasset.get('instance_name'), lasset.get('family') ) for asset in assets: obj = ar.get_asset_by_object_path(asset).get_asset() if not obj.get_class().get_name() == 'StaticMesh': continue self._spawn_actor(obj, lasset) break # Check if an actor was not matched to a representation. # If so, remove it from the scene. for actor in actors: if not actor.get_class().get_name() == 'StaticMeshActor': continue if actor not in actors_matched: self.log.warning(f"Actor {actor.get_name()} not matched.") if self.delete_unmatched_assets: EditorLevelLibrary.destroy_actor(actor) return containers def load(self, context, name, namespace, options): print("Loading Layout and Match Assets") asset = context.get('asset').get('name') asset_name = f"{asset}_{name}" if asset else name container_name = f"{asset}_{name}_CON" curr_level = self._get_current_level() if not curr_level: raise AssertionError("Current level not saved") project_name = context["project"]["name"] path = self.filepath_from_context(context) containers = self._process(path, project_name) curr_level_path = Path( curr_level.get_outer().get_path_name()).parent.as_posix() if not unreal.EditorAssetLibrary.does_asset_exist( f"{curr_level_path}/{container_name}" ): upipeline.create_container( container=container_name, path=curr_level_path) data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "asset": asset, "namespace": curr_level_path, "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), "representation": context["representation"]["_id"], "parent": context["representation"]["parent"], "family": context["representation"]["context"]["family"], "loaded_assets": containers } upipeline.imprint(f"{curr_level_path}/{container_name}", data) def update(self, container, representation): asset_dir = container.get('namespace') source_path = get_representation_path(representation) project_name = get_current_project_name() containers = self._process(source_path, project_name) data = { "representation": str(representation["_id"]), "parent": str(representation["parent"]), "loaded_assets": containers } upipeline.imprint( "{}/{}".format(asset_dir, container.get('container_name')), data) ================================================ FILE: openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py ================================================ # -*- coding: utf-8 -*- """Load Skeletal Mesh alembics.""" import os from openpype.pipeline import ( get_representation_path, AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( AYON_ASSET_DIR, create_container, imprint, ) import unreal # noqa class SkeletalMeshAlembicLoader(plugin.Loader): """Load Unreal SkeletalMesh from Alembic""" families = ["pointcache", "skeletalMesh"] label = "Import Alembic Skeletal Mesh" representations = ["abc"] icon = "cube" color = "orange" root = AYON_ASSET_DIR @staticmethod def get_task(filename, asset_dir, asset_name, replace, default_conversion): task = unreal.AssetImportTask() options = unreal.AbcImportSettings() conversion_settings = unreal.AbcConversionSettings( preset=unreal.AbcConversionPreset.CUSTOM, flip_u=False, flip_v=False, rotation=[0.0, 0.0, 0.0], scale=[1.0, 1.0, 1.0]) task.set_editor_property('filename', filename) task.set_editor_property('destination_path', asset_dir) task.set_editor_property('destination_name', asset_name) task.set_editor_property('replace_existing', replace) task.set_editor_property('automated', True) task.set_editor_property('save', True) options.set_editor_property( 'import_type', unreal.AlembicImportType.SKELETAL) if not default_conversion: conversion_settings = unreal.AbcConversionSettings( preset=unreal.AbcConversionPreset.CUSTOM, flip_u=False, flip_v=False, rotation=[0.0, 0.0, 0.0], scale=[1.0, 1.0, 1.0]) options.conversion_settings = conversion_settings task.options = options return task def import_and_containerize( self, filepath, asset_dir, asset_name, container_name, default_conversion=False ): unreal.EditorAssetLibrary.make_directory(asset_dir) task = self.get_task( filepath, asset_dir, asset_name, False, default_conversion) unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # Create Asset Container create_container(container=container_name, path=asset_dir) def imprint( self, asset, asset_dir, container_name, asset_name, representation ): data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), "representation": representation["_id"], "parent": representation["parent"], "family": representation["context"]["family"] } imprint(f"{asset_dir}/{container_name}", data) def load(self, context, name, namespace, options): """Load and containerise representation into Content Browser. Args: context (dict): application context name (str): subset name namespace (str): in Unreal this is basically path to container. This is not passed here, so namespace is set by `containerise()` because only then we know real path. data (dict): Those would be data to be imprinted. Returns: list(str): list of container content """ # Create directory for asset and ayon container asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" version = context.get('version') # Check if version is hero version and use different name if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: name_version = f"{name}_v{version.get('name'):03d}" default_conversion = False if options.get("default_conversion"): default_conversion = options.get("default_conversion") tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{self.root}/{asset}/{name_version}", suffix="") container_name += suffix if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): path = self.filepath_from_context(context) self.import_and_containerize(path, asset_dir, asset_name, container_name, default_conversion) self.imprint( asset, asset_dir, container_name, asset_name, context["representation"]) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) return asset_content def update(self, container, representation): context = representation.get("context", {}) if not context: raise RuntimeError("No context found in representation") # Create directory for asset and Ayon container asset = context.get('asset') name = context.get('subset') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" version = context.get('version') # Check if version is hero version and use different name name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{self.root}/{asset}/{name_version}", suffix="") container_name += suffix if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): path = get_representation_path(representation) self.import_and_containerize(path, asset_dir, asset_name, container_name) self.imprint( asset, asset_dir, container_name, asset_name, representation) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=False ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) unreal.EditorAssetLibrary.delete_directory(path) asset_content = unreal.EditorAssetLibrary.list_assets( parent_path, recursive=False ) if len(asset_content) == 0: unreal.EditorAssetLibrary.delete_directory(parent_path) ================================================ FILE: openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py ================================================ # -*- coding: utf-8 -*- """Load Skeletal Meshes form FBX.""" import os from openpype.pipeline import ( get_representation_path, AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( AYON_ASSET_DIR, create_container, imprint, ) import unreal # noqa class SkeletalMeshFBXLoader(plugin.Loader): """Load Unreal SkeletalMesh from FBX.""" families = ["rig", "skeletalMesh"] label = "Import FBX Skeletal Mesh" representations = ["fbx"] icon = "cube" color = "orange" root = AYON_ASSET_DIR @staticmethod def get_task(filename, asset_dir, asset_name, replace): task = unreal.AssetImportTask() options = unreal.FbxImportUI() task.set_editor_property('filename', filename) task.set_editor_property('destination_path', asset_dir) task.set_editor_property('destination_name', asset_name) task.set_editor_property('replace_existing', replace) task.set_editor_property('automated', True) task.set_editor_property('save', True) options.set_editor_property( 'automated_import_should_detect_type', False) options.set_editor_property('import_as_skeletal', True) options.set_editor_property('import_animations', False) options.set_editor_property('import_mesh', True) options.set_editor_property('import_materials', False) options.set_editor_property('import_textures', False) options.set_editor_property('skeleton', None) options.set_editor_property('create_physics_asset', False) options.set_editor_property( 'mesh_type_to_import', unreal.FBXImportType.FBXIT_SKELETAL_MESH) options.skeletal_mesh_import_data.set_editor_property( 'import_content_type', unreal.FBXImportContentType.FBXICT_ALL) options.skeletal_mesh_import_data.set_editor_property( 'normal_import_method', unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS) task.options = options return task def import_and_containerize( self, filepath, asset_dir, asset_name, container_name ): unreal.EditorAssetLibrary.make_directory(asset_dir) task = self.get_task( filepath, asset_dir, asset_name, False) unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # Create Asset Container create_container(container=container_name, path=asset_dir) def imprint( self, asset, asset_dir, container_name, asset_name, representation ): data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), "representation": representation["_id"], "parent": representation["parent"], "family": representation["context"]["family"] } imprint(f"{asset_dir}/{container_name}", data) def load(self, context, name, namespace, options): """Load and containerise representation into Content Browser. Args: context (dict): application context name (str): subset name namespace (str): in Unreal this is basically path to container. This is not passed here, so namespace is set by `containerise()` because only then we know real path. data (dict): Those would be data to be imprinted. Returns: list(str): list of container content """ # Create directory for asset and Ayon container asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" version = context.get('version') # Check if version is hero version and use different name if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: name_version = f"{name}_v{version.get('name'):03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{self.root}/{asset}/{name_version}", suffix="" ) container_name += suffix if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): path = self.filepath_from_context(context) self.import_and_containerize( path, asset_dir, asset_name, container_name) self.imprint( asset, asset_dir, container_name, asset_name, context["representation"]) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) return asset_content def update(self, container, representation): context = representation.get("context", {}) if not context: raise RuntimeError("No context found in representation") # Create directory for asset and Ayon container asset = context.get('asset') name = context.get('subset') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" version = context.get('version') # Check if version is hero version and use different name name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{self.root}/{asset}/{name_version}", suffix="") container_name += suffix if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): path = get_representation_path(representation) self.import_and_containerize( path, asset_dir, asset_name, container_name) self.imprint( asset, asset_dir, container_name, asset_name, representation) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=False ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) unreal.EditorAssetLibrary.delete_directory(path) asset_content = unreal.EditorAssetLibrary.list_assets( parent_path, recursive=False ) if len(asset_content) == 0: unreal.EditorAssetLibrary.delete_directory(parent_path) ================================================ FILE: openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py ================================================ # -*- coding: utf-8 -*- """Loader for Static Mesh alembics.""" import os from openpype.pipeline import ( get_representation_path, AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( AYON_ASSET_DIR, create_container, imprint, ) import unreal # noqa class StaticMeshAlembicLoader(plugin.Loader): """Load Unreal StaticMesh from Alembic""" families = ["model", "staticMesh"] label = "Import Alembic Static Mesh" representations = ["abc"] icon = "cube" color = "orange" root = AYON_ASSET_DIR @staticmethod def get_task(filename, asset_dir, asset_name, replace, default_conversion): task = unreal.AssetImportTask() options = unreal.AbcImportSettings() sm_settings = unreal.AbcStaticMeshSettings() task.set_editor_property('filename', filename) task.set_editor_property('destination_path', asset_dir) task.set_editor_property('destination_name', asset_name) task.set_editor_property('replace_existing', replace) task.set_editor_property('automated', True) task.set_editor_property('save', True) # set import options here # Unreal 4.24 ignores the settings. It works with Unreal 4.26 options.set_editor_property( 'import_type', unreal.AlembicImportType.STATIC_MESH) sm_settings.set_editor_property('merge_meshes', True) if not default_conversion: conversion_settings = unreal.AbcConversionSettings( preset=unreal.AbcConversionPreset.CUSTOM, flip_u=False, flip_v=False, rotation=[0.0, 0.0, 0.0], scale=[1.0, 1.0, 1.0]) options.conversion_settings = conversion_settings options.static_mesh_settings = sm_settings task.options = options return task def import_and_containerize( self, filepath, asset_dir, asset_name, container_name, default_conversion=False ): unreal.EditorAssetLibrary.make_directory(asset_dir) task = self.get_task( filepath, asset_dir, asset_name, False, default_conversion) unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # Create Asset Container create_container(container=container_name, path=asset_dir) def imprint( self, asset, asset_dir, container_name, asset_name, representation ): data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), "representation": representation["_id"], "parent": representation["parent"], "family": representation["context"]["family"] } imprint(f"{asset_dir}/{container_name}", data) def load(self, context, name, namespace, options): """Load and containerise representation into Content Browser. Args: context (dict): application context name (str): subset name namespace (str): in Unreal this is basically path to container. This is not passed here, so namespace is set by `containerise()` because only then we know real path. data (dict): Those would be data to be imprinted. Returns: list(str): list of container content """ # Create directory for asset and Ayon container asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" version = context.get('version') # Check if version is hero version and use different name if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: name_version = f"{name}_v{version.get('name'):03d}" default_conversion = False if options.get("default_conversion"): default_conversion = options.get("default_conversion") tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{self.root}/{asset}/{name_version}", suffix="") container_name += suffix if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): path = self.filepath_from_context(context) self.import_and_containerize(path, asset_dir, asset_name, container_name, default_conversion) self.imprint( asset, asset_dir, container_name, asset_name, context["representation"]) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=False ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) return asset_content def update(self, container, representation): context = representation.get("context", {}) if not context: raise RuntimeError("No context found in representation") # Create directory for asset and Ayon container asset = context.get('asset') name = context.get('subset') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" version = context.get('version') # Check if version is hero version and use different name name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{self.root}/{asset}/{name_version}", suffix="") container_name += suffix if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): path = get_representation_path(representation) self.import_and_containerize(path, asset_dir, asset_name, container_name) self.imprint( asset, asset_dir, container_name, asset_name, representation) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=False ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) unreal.EditorAssetLibrary.delete_directory(path) asset_content = unreal.EditorAssetLibrary.list_assets( parent_path, recursive=False ) if len(asset_content) == 0: unreal.EditorAssetLibrary.delete_directory(parent_path) ================================================ FILE: openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py ================================================ # -*- coding: utf-8 -*- """Load Static meshes form FBX.""" import os from openpype.pipeline import ( get_representation_path, AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api.pipeline import ( AYON_ASSET_DIR, create_container, imprint, ) import unreal # noqa class StaticMeshFBXLoader(plugin.Loader): """Load Unreal StaticMesh from FBX.""" families = ["model", "staticMesh"] label = "Import FBX Static Mesh" representations = ["fbx"] icon = "cube" color = "orange" root = AYON_ASSET_DIR @staticmethod def get_task(filename, asset_dir, asset_name, replace): task = unreal.AssetImportTask() options = unreal.FbxImportUI() import_data = unreal.FbxStaticMeshImportData() task.set_editor_property('filename', filename) task.set_editor_property('destination_path', asset_dir) task.set_editor_property('destination_name', asset_name) task.set_editor_property('replace_existing', replace) task.set_editor_property('automated', True) task.set_editor_property('save', True) # set import options here options.set_editor_property( 'automated_import_should_detect_type', False) options.set_editor_property('import_animations', False) import_data.set_editor_property('combine_meshes', True) import_data.set_editor_property('remove_degenerates', False) options.static_mesh_import_data = import_data task.options = options return task def import_and_containerize( self, filepath, asset_dir, asset_name, container_name ): unreal.EditorAssetLibrary.make_directory(asset_dir) task = self.get_task( filepath, asset_dir, asset_name, False) unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # Create Asset Container create_container(container=container_name, path=asset_dir) def imprint( self, asset, asset_dir, container_name, asset_name, representation ): data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), "representation": representation["_id"], "parent": representation["parent"], "family": representation["context"]["family"] } imprint(f"{asset_dir}/{container_name}", data) def load(self, context, name, namespace, options): """Load and containerise representation into Content Browser. Args: context (dict): application context name (str): subset name namespace (str): in Unreal this is basically path to container. This is not passed here, so namespace is set by `containerise()` because only then we know real path. options (dict): Those would be data to be imprinted. Returns: list(str): list of container content """ # Create directory for asset and Ayon container asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" version = context.get('version') # Check if version is hero version and use different name if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: name_version = f"{name}_v{version.get('name'):03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{self.root}/{asset}/{name_version}", suffix="" ) container_name += suffix if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): path = self.filepath_from_context(context) self.import_and_containerize( path, asset_dir, asset_name, container_name) self.imprint( asset, asset_dir, container_name, asset_name, context["representation"]) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) return asset_content def update(self, container, representation): context = representation.get("context", {}) if not context: raise RuntimeError("No context found in representation") # Create directory for asset and Ayon container asset = context.get('asset') name = context.get('subset') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" version = context.get('version') # Check if version is hero version and use different name name_version = f"{name}_v{version:03d}" if version else f"{name}_hero" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{self.root}/{asset}/{name_version}", suffix="") container_name += suffix if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): path = get_representation_path(representation) self.import_and_containerize( path, asset_dir, asset_name, container_name) self.imprint( asset, asset_dir, container_name, asset_name, representation) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=False ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) unreal.EditorAssetLibrary.delete_directory(path) asset_content = unreal.EditorAssetLibrary.list_assets( parent_path, recursive=False ) if len(asset_content) == 0: unreal.EditorAssetLibrary.delete_directory(parent_path) ================================================ FILE: openpype/hosts/unreal/plugins/load/load_uasset.py ================================================ # -*- coding: utf-8 -*- """Load UAsset.""" from pathlib import Path import shutil from openpype.pipeline import ( get_representation_path, AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa class UAssetLoader(plugin.Loader): """Load UAsset.""" families = ["uasset"] label = "Load UAsset" representations = ["uasset"] icon = "cube" color = "orange" extension = "uasset" def load(self, context, name, namespace, options): """Load and containerise representation into Content Browser. Args: context (dict): application context name (str): subset name namespace (str): in Unreal this is basically path to container. This is not passed here, so namespace is set by `containerise()` because only then we know real path. options (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content """ # Create directory for asset and Ayon container root = unreal_pipeline.AYON_ASSET_DIR asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{root}/{asset}/{name}", suffix="" ) unique_number = 1 while unreal.EditorAssetLibrary.does_directory_exist( f"{asset_dir}_{unique_number:02}" ): unique_number += 1 asset_dir = f"{asset_dir}_{unique_number:02}" container_name = f"{container_name}_{unique_number:02}{suffix}" unreal.EditorAssetLibrary.make_directory(asset_dir) destination_path = asset_dir.replace( "/Game", Path(unreal.Paths.project_content_dir()).as_posix(), 1) path = self.filepath_from_context(context) shutil.copy( path, f"{destination_path}/{name}_{unique_number:02}.{self.extension}") # Create Asset Container unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), "representation": context["representation"]["_id"], "parent": context["representation"]["parent"], "family": context["representation"]["context"]["family"], } unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) return asset_content def update(self, container, representation): ar = unreal.AssetRegistryHelpers.get_asset_registry() asset_dir = container["namespace"] name = representation["context"]["subset"] unique_number = container["container_name"].split("_")[-2] destination_path = asset_dir.replace( "/Game", Path(unreal.Paths.project_content_dir()).as_posix(), 1) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=False, include_folder=True ) for asset in asset_content: obj = ar.get_asset_by_object_path(asset).get_asset() if obj.get_class().get_name() != "AyonAssetContainer": unreal.EditorAssetLibrary.delete_asset(asset) update_filepath = get_representation_path(representation) shutil.copy( update_filepath, f"{destination_path}/{name}_{unique_number}.{self.extension}") container_path = f'{container["namespace"]}/{container["objectName"]}' # update metadata unreal_pipeline.imprint( container_path, { "representation": str(representation["_id"]), "parent": str(representation["parent"]), } ) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) def remove(self, container): path = container["namespace"] parent_path = Path(path).parent.as_posix() unreal.EditorAssetLibrary.delete_directory(path) asset_content = unreal.EditorAssetLibrary.list_assets( parent_path, recursive=False ) if len(asset_content) == 0: unreal.EditorAssetLibrary.delete_directory(parent_path) class UMapLoader(UAssetLoader): """Load Level.""" families = ["uasset"] label = "Load Level" representations = ["umap"] extension = "umap" ================================================ FILE: openpype/hosts/unreal/plugins/load/load_yeticache.py ================================================ # -*- coding: utf-8 -*- """Loader for Yeti Cache.""" import os import json from openpype.pipeline import ( get_representation_path, AYON_CONTAINER_ID ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa class YetiLoader(plugin.Loader): """Load Yeti Cache""" families = ["yeticacheUE"] label = "Import Yeti" representations = ["abc"] icon = "pagelines" color = "orange" @staticmethod def get_task(filename, asset_dir, asset_name, replace): task = unreal.AssetImportTask() options = unreal.AbcImportSettings() task.set_editor_property('filename', filename) task.set_editor_property('destination_path', asset_dir) task.set_editor_property('destination_name', asset_name) task.set_editor_property('replace_existing', replace) task.set_editor_property('automated', True) task.set_editor_property('save', True) task.options = options return task @staticmethod def is_groom_module_active(): """ Check if Groom plugin is active. This is a workaround, because the Unreal python API don't have any method to check if plugin is active. """ prj_file = unreal.Paths.get_project_file_path() with open(prj_file, "r") as fp: data = json.load(fp) plugins = data.get("Plugins") if not plugins: return False plugin_names = [p.get("Name") for p in plugins] return "HairStrands" in plugin_names def load(self, context, name, namespace, options): """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new directory and then it will create AssetContainer there and imprint it with metadata. This will mark this path as container. Args: context (dict): application context name (str): subset name namespace (str): in Unreal this is basically path to container. This is not passed here, so namespace is set by `containerise()` because only then we know real path. data (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content """ # Check if Groom plugin is active if not self.is_groom_module_active(): raise RuntimeError("Groom plugin is not activated.") # Create directory for asset and Ayon container root = unreal_pipeline.AYON_ASSET_DIR asset = context.get('asset').get('name') suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( f"{root}/{asset}/{name}", suffix="") unique_number = 1 while unreal.EditorAssetLibrary.does_directory_exist( f"{asset_dir}_{unique_number:02}" ): unique_number += 1 asset_dir = f"{asset_dir}_{unique_number:02}" container_name = f"{container_name}_{unique_number:02}{suffix}" if not unreal.EditorAssetLibrary.does_directory_exist(asset_dir): unreal.EditorAssetLibrary.make_directory(asset_dir) path = self.filepath_from_context(context) task = self.get_task(path, asset_dir, asset_name, False) unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { "schema": "ayon:container-2.0", "id": AYON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, "asset_name": asset_name, "loader": str(self.__class__.__name__), "representation": context["representation"]["_id"], "parent": context["representation"]["parent"], "family": context["representation"]["context"]["family"] } unreal_pipeline.imprint(f"{asset_dir}/{container_name}", data) asset_content = unreal.EditorAssetLibrary.list_assets( asset_dir, recursive=True, include_folder=True ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) return asset_content def update(self, container, representation): name = container["asset_name"] source_path = get_representation_path(representation) destination_path = container["namespace"] task = self.get_task(source_path, destination_path, name, True) # do import fbx and replace existing data unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) container_path = f'{container["namespace"]}/{container["objectName"]}' # update metadata unreal_pipeline.imprint( container_path, { "representation": str(representation["_id"]), "parent": str(representation["parent"]) }) asset_content = unreal.EditorAssetLibrary.list_assets( destination_path, recursive=True, include_folder=True ) for a in asset_content: unreal.EditorAssetLibrary.save_asset(a) def remove(self, container): path = container["namespace"] parent_path = os.path.dirname(path) unreal.EditorAssetLibrary.delete_directory(path) asset_content = unreal.EditorAssetLibrary.list_assets( parent_path, recursive=False ) if len(asset_content) == 0: unreal.EditorAssetLibrary.delete_directory(parent_path) ================================================ FILE: openpype/hosts/unreal/plugins/publish/collect_current_file.py ================================================ # -*- coding: utf-8 -*- """Collect current project path.""" import unreal # noqa import pyblish.api class CollectUnrealCurrentFile(pyblish.api.ContextPlugin): """Inject the current working file into context.""" order = pyblish.api.CollectorOrder - 0.5 label = "Unreal Current File" hosts = ['unreal'] def process(self, context): """Inject the current working file.""" current_file = unreal.Paths.get_project_file_path() context.data['currentFile'] = current_file assert current_file != '', "Current file is empty. " \ "Save the file before continuing." ================================================ FILE: openpype/hosts/unreal/plugins/publish/collect_instance_members.py ================================================ import unreal import pyblish.api class CollectInstanceMembers(pyblish.api.InstancePlugin): """ Collect members of instance. This collector will collect the assets for the families that support to have them included as External Data, and will add them to the instance as members. """ order = pyblish.api.CollectorOrder + 0.1 hosts = ["unreal"] families = ["camera", "look", "unrealStaticMesh", "uasset"] label = "Collect Instance Members" def process(self, instance): """Collect members of instance.""" self.log.info("Collecting instance members") ar = unreal.AssetRegistryHelpers.get_asset_registry() inst_path = instance.data.get('instance_path') inst_name = inst_path.split('/')[-1] pub_instance = ar.get_asset_by_object_path( f"{inst_path}.{inst_name}").get_asset() if not pub_instance: self.log.error(f"{inst_path}.{inst_name}") raise RuntimeError(f"Instance {instance} not found.") if not pub_instance.get_editor_property("add_external_assets"): # No external assets in the instance return assets = pub_instance.get_editor_property('asset_data_external') members = [asset.get_path_name() for asset in assets] self.log.debug(f"Members: {members}") instance.data["members"] = members ================================================ FILE: openpype/hosts/unreal/plugins/publish/collect_remove_marked.py ================================================ import pyblish.api class CollectRemoveMarked(pyblish.api.ContextPlugin): """Remove marked data Remove instances that have 'remove' in their instance.data """ order = pyblish.api.CollectorOrder + 0.499 label = 'Remove Marked Instances' def process(self, context): self.log.debug(context) # make ftrack publishable instances_to_remove = [] for instance in context: if instance.data.get('remove'): instances_to_remove.append(instance) for instance in instances_to_remove: context.remove(instance) ================================================ FILE: openpype/hosts/unreal/plugins/publish/collect_render_instances.py ================================================ import os from pathlib import Path import unreal from openpype.pipeline import get_current_project_name from openpype.pipeline import Anatomy from openpype.hosts.unreal.api import pipeline import pyblish.api class CollectRenderInstances(pyblish.api.InstancePlugin): """ This collector will try to find all the rendered frames. """ order = pyblish.api.CollectorOrder hosts = ["unreal"] families = ["render"] label = "Collect Render Instances" def process(self, instance): self.log.debug("Preparing Rendering Instances") context = instance.context data = instance.data data['remove'] = True ar = unreal.AssetRegistryHelpers.get_asset_registry() sequence = ar.get_asset_by_object_path( data.get('sequence')).get_asset() sequences = [{ "sequence": sequence, "output": data.get('output'), "frame_range": ( data.get('frameStart'), data.get('frameEnd')) }] for s in sequences: self.log.debug(f"Processing: {s.get('sequence').get_name()}") subscenes = pipeline.get_subsequences(s.get('sequence')) if subscenes: for ss in subscenes: sequences.append({ "sequence": ss.get_sequence(), "output": (f"{s.get('output')}/" f"{ss.get_sequence().get_name()}"), "frame_range": ( ss.get_start_frame(), ss.get_end_frame() - 1) }) else: # Avoid creating instances for camera sequences if "_camera" not in s.get('sequence').get_name(): seq = s.get('sequence') seq_name = seq.get_name() new_instance = context.create_instance( f"{data.get('subset')}_" f"{seq_name}") new_instance[:] = seq_name new_data = new_instance.data new_data["asset"] = f"/{s.get('output')}" new_data["setMembers"] = seq_name new_data["family"] = "render" new_data["families"] = ["render", "review"] new_data["parent"] = data.get("parent") new_data["subset"] = f"{data.get('subset')}_{seq_name}" new_data["level"] = data.get("level") new_data["output"] = s.get('output') new_data["fps"] = seq.get_display_rate().numerator new_data["frameStart"] = int(s.get('frame_range')[0]) new_data["frameEnd"] = int(s.get('frame_range')[1]) new_data["sequence"] = seq.get_path_name() new_data["master_sequence"] = data["master_sequence"] new_data["master_level"] = data["master_level"] self.log.debug(f"new instance data: {new_data}") try: project = get_current_project_name() anatomy = Anatomy(project) root = anatomy.roots['renders'] except Exception as e: raise Exception(( "Could not find render root " "in anatomy settings.")) from e render_dir = f"{root}/{project}/{s.get('output')}" render_path = Path(render_dir) frames = [] for x in render_path.iterdir(): if x.is_file() and x.suffix == '.png': frames.append(str(x.name)) if "representations" not in new_instance.data: new_instance.data["representations"] = [] repr = { 'frameStart': instance.data["frameStart"], 'frameEnd': instance.data["frameEnd"], 'name': 'png', 'ext': 'png', 'files': frames, 'stagingDir': render_dir, 'tags': ['review'] } new_instance.data["representations"].append(repr) ================================================ FILE: openpype/hosts/unreal/plugins/publish/extract_camera.py ================================================ # -*- coding: utf-8 -*- """Extract camera from Unreal.""" import os import unreal from openpype.pipeline import publish from openpype.hosts.unreal.api.pipeline import UNREAL_VERSION class ExtractCamera(publish.Extractor): """Extract a camera.""" label = "Extract Camera" hosts = ["unreal"] families = ["camera"] optional = True def process(self, instance): ar = unreal.AssetRegistryHelpers.get_asset_registry() # Define extract output file path staging_dir = self.staging_dir(instance) fbx_filename = "{}.fbx".format(instance.name) # Perform extraction self.log.info("Performing extraction..") # Check if the loaded level is the same of the instance if UNREAL_VERSION.major == 5: world = unreal.UnrealEditorSubsystem().get_editor_world() else: world = unreal.EditorLevelLibrary.get_editor_world() current_level = world.get_path_name() assert current_level == instance.data.get("level"), \ "Wrong level loaded" for member in instance.data.get('members'): data = ar.get_asset_by_object_path(member) if UNREAL_VERSION.major == 5: is_level_sequence = ( data.asset_class_path.asset_name == "LevelSequence") else: is_level_sequence = (data.asset_class == "LevelSequence") if is_level_sequence: sequence = data.get_asset() if UNREAL_VERSION.major == 5 and UNREAL_VERSION.minor >= 1: params = unreal.SequencerExportFBXParams( world=world, root_sequence=sequence, sequence=sequence, bindings=sequence.get_bindings(), master_tracks=sequence.get_master_tracks(), fbx_file_name=os.path.join(staging_dir, fbx_filename) ) unreal.SequencerTools.export_level_sequence_fbx(params) elif UNREAL_VERSION.major == 4 and UNREAL_VERSION.minor == 26: unreal.SequencerTools.export_fbx( world, sequence, sequence.get_bindings(), unreal.FbxExportOption(), os.path.join(staging_dir, fbx_filename) ) else: # Unreal 5.0 or 4.27 unreal.SequencerTools.export_level_sequence_fbx( world, sequence, sequence.get_bindings(), unreal.FbxExportOption(), os.path.join(staging_dir, fbx_filename) ) if not os.path.isfile(os.path.join(staging_dir, fbx_filename)): raise RuntimeError("Failed to extract camera") if "representations" not in instance.data: instance.data["representations"] = [] fbx_representation = { 'name': 'fbx', 'ext': 'fbx', 'files': fbx_filename, "stagingDir": staging_dir, } instance.data["representations"].append(fbx_representation) ================================================ FILE: openpype/hosts/unreal/plugins/publish/extract_layout.py ================================================ # -*- coding: utf-8 -*- import os import json import math import unreal from unreal import EditorLevelLibrary as ell from unreal import EditorAssetLibrary as eal from openpype.client import get_representation_by_name from openpype.pipeline import publish class ExtractLayout(publish.Extractor): """Extract a layout.""" label = "Extract Layout" hosts = ["unreal"] families = ["layout"] optional = True def process(self, instance): # Define extract output file path staging_dir = self.staging_dir(instance) # Perform extraction self.log.info("Performing extraction..") # Check if the loaded level is the same of the instance current_level = ell.get_editor_world().get_path_name() assert current_level == instance.data.get("level"), \ "Wrong level loaded" json_data = [] project_name = instance.context.data["projectName"] for member in instance[:]: actor = ell.get_actor_reference(member) mesh = None # Check type the type of mesh if actor.get_class().get_name() == 'SkeletalMeshActor': mesh = actor.skeletal_mesh_component.skeletal_mesh elif actor.get_class().get_name() == 'StaticMeshActor': mesh = actor.static_mesh_component.static_mesh if mesh: # Search the reference to the Asset Container for the object path = unreal.Paths.get_path(mesh.get_path_name()) filter = unreal.ARFilter( class_names=["AyonAssetContainer"], package_paths=[path]) ar = unreal.AssetRegistryHelpers.get_asset_registry() try: asset_container = ar.get_assets(filter)[0].get_asset() except IndexError: self.log.error("AssetContainer not found.") return parent_id = eal.get_metadata_tag(asset_container, "parent") family = eal.get_metadata_tag(asset_container, "family") self.log.info("Parent: {}".format(parent_id)) blend = get_representation_by_name( project_name, "blend", parent_id, fields=["_id"] ) blend_id = blend["_id"] json_element = {} json_element["reference"] = str(blend_id) json_element["family"] = family json_element["instance_name"] = actor.get_name() json_element["asset_name"] = mesh.get_name() import_data = mesh.get_editor_property("asset_import_data") json_element["file_path"] = import_data.get_first_filename() transform = actor.get_actor_transform() json_element["transform"] = { "translation": { "x": -transform.translation.x, "y": transform.translation.y, "z": transform.translation.z }, "rotation": { "x": math.radians(transform.rotation.euler().x), "y": math.radians(transform.rotation.euler().y), "z": math.radians(180.0 - transform.rotation.euler().z) }, "scale": { "x": transform.scale3d.x, "y": transform.scale3d.y, "z": transform.scale3d.z } } json_data.append(json_element) json_filename = "{}.json".format(instance.name) json_path = os.path.join(staging_dir, json_filename) with open(json_path, "w+") as file: json.dump(json_data, fp=file, indent=2) if "representations" not in instance.data: instance.data["representations"] = [] json_representation = { 'name': 'json', 'ext': 'json', 'files': json_filename, "stagingDir": staging_dir, } instance.data["representations"].append(json_representation) ================================================ FILE: openpype/hosts/unreal/plugins/publish/extract_look.py ================================================ # -*- coding: utf-8 -*- import json import os import unreal from unreal import MaterialEditingLibrary as mat_lib from openpype.pipeline import publish class ExtractLook(publish.Extractor): """Extract look.""" label = "Extract Look" hosts = ["unreal"] families = ["look"] optional = True def process(self, instance): # Define extract output file path staging_dir = self.staging_dir(instance) resources_dir = instance.data["resourcesDir"] ar = unreal.AssetRegistryHelpers.get_asset_registry() transfers = [] json_data = [] for member in instance: asset = ar.get_asset_by_object_path(member) obj = asset.get_asset() name = asset.get_editor_property('asset_name') json_element = {'material': str(name)} material_obj = obj.get_editor_property('static_materials')[0] material = material_obj.material_interface base_color = mat_lib.get_material_property_input_node( material, unreal.MaterialProperty.MP_BASE_COLOR) base_color_name = base_color.get_editor_property('parameter_name') texture = mat_lib.get_material_default_texture_parameter_value( material, base_color_name) if texture: # Export Texture tga_filename = f"{instance.name}_{name}_texture.tga" tga_exporter = unreal.TextureExporterTGA() tga_export_task = unreal.AssetExportTask() tga_export_task.set_editor_property('exporter', tga_exporter) tga_export_task.set_editor_property('automated', True) tga_export_task.set_editor_property('object', texture) tga_export_task.set_editor_property( 'filename', f"{staging_dir}/{tga_filename}") tga_export_task.set_editor_property('prompt', False) tga_export_task.set_editor_property('selected', False) unreal.Exporter.run_asset_export_task(tga_export_task) json_element['tga_filename'] = tga_filename transfers.append(( f"{staging_dir}/{tga_filename}", f"{resources_dir}/{tga_filename}")) fbx_filename = f"{instance.name}_{name}.fbx" fbx_exporter = unreal.StaticMeshExporterFBX() fbx_exporter.set_editor_property('text', False) options = unreal.FbxExportOption() options.set_editor_property('ascii', False) options.set_editor_property('collision', False) task = unreal.AssetExportTask() task.set_editor_property('exporter', fbx_exporter) task.set_editor_property('options', options) task.set_editor_property('automated', True) task.set_editor_property('object', object) task.set_editor_property( 'filename', f"{staging_dir}/{fbx_filename}") task.set_editor_property('prompt', False) task.set_editor_property('selected', False) unreal.Exporter.run_asset_export_task(task) json_element['fbx_filename'] = fbx_filename transfers.append(( f"{staging_dir}/{fbx_filename}", f"{resources_dir}/{fbx_filename}")) json_data.append(json_element) json_filename = f"{instance.name}.json" json_path = os.path.join(staging_dir, json_filename) with open(json_path, "w+") as file: json.dump(json_data, fp=file, indent=2) if "transfers" not in instance.data: instance.data["transfers"] = [] if "representations" not in instance.data: instance.data["representations"] = [] json_representation = { 'name': 'json', 'ext': 'json', 'files': json_filename, "stagingDir": staging_dir, } instance.data["representations"].append(json_representation) instance.data["transfers"].extend(transfers) ================================================ FILE: openpype/hosts/unreal/plugins/publish/extract_uasset.py ================================================ from pathlib import Path import shutil import unreal from openpype.pipeline import publish class ExtractUAsset(publish.Extractor): """Extract a UAsset.""" label = "Extract UAsset" hosts = ["unreal"] families = ["uasset", "umap"] optional = True def process(self, instance): extension = ( "umap" if "umap" in instance.data.get("families") else "uasset") ar = unreal.AssetRegistryHelpers.get_asset_registry() self.log.debug("Performing extraction..") staging_dir = self.staging_dir(instance) members = instance.data.get("members", []) if not members: raise RuntimeError("No members found in instance.") # UAsset publishing supports only one member obj = members[0] asset = ar.get_asset_by_object_path(obj).get_asset() sys_path = unreal.SystemLibrary.get_system_path(asset) filename = Path(sys_path).name shutil.copy(sys_path, staging_dir) self.log.info(f"instance.data: {instance.data}") if "representations" not in instance.data: instance.data["representations"] = [] representation = { "name": extension, "ext": extension, "files": filename, "stagingDir": staging_dir, } instance.data["representations"].append(representation) ================================================ FILE: openpype/hosts/unreal/plugins/publish/validate_no_dependencies.py ================================================ import unreal import pyblish.api class ValidateNoDependencies(pyblish.api.InstancePlugin): """Ensure that the uasset has no dependencies The uasset is checked for dependencies. If there are any, the instance cannot be published. """ order = pyblish.api.ValidatorOrder label = "Check no dependencies" families = ["uasset"] hosts = ["unreal"] optional = True def process(self, instance): ar = unreal.AssetRegistryHelpers.get_asset_registry() all_dependencies = [] for obj in instance[:]: asset = ar.get_asset_by_object_path(obj) dependencies = ar.get_dependencies( asset.package_name, unreal.AssetRegistryDependencyOptions( include_soft_package_references=False, include_hard_package_references=True, include_searchable_names=False, include_soft_management_references=False, include_hard_management_references=False )) if dependencies: for dep in dependencies: if str(dep).startswith("/Game/"): all_dependencies.append(str(dep)) if all_dependencies: raise RuntimeError( f"Dependencies found: {all_dependencies}") ================================================ FILE: openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py ================================================ import clique import os import re import pyblish.api from openpype.pipeline.publish import PublishValidationError class ValidateSequenceFrames(pyblish.api.InstancePlugin): """Ensure the sequence of frames is complete The files found in the folder are checked against the frameStart and frameEnd of the instance. If the first or last file is not corresponding with the first or last frame it is flagged as invalid. """ order = pyblish.api.ValidatorOrder label = "Validate Sequence Frames" families = ["render"] hosts = ["unreal"] optional = True def process(self, instance): representations = instance.data.get("representations") for repr in representations: data = instance.data.get("assetEntity", {}).get("data", {}) repr_files = repr["files"] if isinstance(repr_files, str): continue ext = repr.get("ext") if not ext: _, ext = os.path.splitext(repr_files[0]) elif not ext.startswith("."): ext = ".{}".format(ext) pattern = r"\D?(?P(?P0*)\d+){}$".format( re.escape(ext)) patterns = [pattern] collections, remainder = clique.assemble( repr["files"], minimum_items=1, patterns=patterns) if remainder: raise PublishValidationError( "Some files have been found outside a sequence. " f"Invalid files: {remainder}") if not collections: raise PublishValidationError( "We have been unable to find a sequence in the " "files. Please ensure the files are named " "appropriately. " f"Files: {repr_files}") if len(collections) > 1: raise PublishValidationError( "Multiple collections detected. There should be a single " "collection per representation. " f"Collections identified: {collections}") collection = collections[0] frames = list(collection.indexes) if instance.data.get("slate"): # Slate is not part of the frame range frames = frames[1:] current_range = (frames[0], frames[-1]) required_range = (data["clipIn"], data["clipOut"]) if current_range != required_range: raise PublishValidationError( f"Invalid frame range: {current_range} - " f"expected: {required_range}") missing = collection.holes().indexes if missing: raise PublishValidationError( "Missing frames have been detected. " f"Missing frames: {missing}") ================================================ FILE: openpype/hosts/unreal/ue_workers.py ================================================ import json import os import platform import re import subprocess import tempfile from distutils import dir_util from distutils.dir_util import copy_tree from pathlib import Path from typing import List, Union from qtpy import QtCore import openpype.hosts.unreal.lib as ue_lib from openpype.settings import get_project_settings def parse_comp_progress(line: str, progress_signal: QtCore.Signal(int)): match = re.search(r"\[[1-9]+/[0-9]+]", line) if match is not None: split: list[str] = match.group().split("/") curr: float = float(split[0][1:]) total: float = float(split[1][:-1]) progress_signal.emit(int((curr / total) * 100.0)) def parse_prj_progress(line: str, progress_signal: QtCore.Signal(int)): match = re.search("@progress", line) if match is not None: percent_match = re.search(r"\d{1,3}", line) progress_signal.emit(int(percent_match.group())) def retrieve_exit_code(line: str): match = re.search(r"ExitCode=\d+", line) if match is not None: split: list[str] = match.group().split("=") return int(split[1]) return None class UEWorker(QtCore.QObject): finished = QtCore.Signal(str) failed = QtCore.Signal(str, int) progress = QtCore.Signal(int) log = QtCore.Signal(str) engine_path: Path = None env = None def execute(self): raise NotImplementedError("Please implement this method!") def run(self): try: self.execute() except Exception as e: import traceback self.log.emit(str(e)) self.log.emit(traceback.format_exc()) self.failed.emit(str(e), 1) raise e class UEProjectGenerationWorker(UEWorker): stage_begin = QtCore.Signal(str) ue_version: str = None project_name: str = None project_dir: Path = None dev_mode = False def setup(self, ue_version: str, project_name: str, unreal_project_name, engine_path: Path, project_dir: Path, dev_mode: bool = False, env: dict = None): """Set the worker with necessary parameters. Args: ue_version (str): Unreal Engine version. project_name (str): Name of the project in AYON. unreal_project_name (str): Name of the project in Unreal. engine_path (Path): Path to the Unreal Engine. project_dir (Path): Path to the project directory. dev_mode (bool, optional): Whether to run the project in dev mode. Defaults to False. env (dict, optional): Environment variables. Defaults to None. """ self.ue_version = ue_version self.project_dir = project_dir self.env = env or os.environ preset = get_project_settings(project_name)["unreal"]["project_setup"] if dev_mode or preset["dev_mode"]: self.dev_mode = True self.project_name = unreal_project_name self.engine_path = engine_path def execute(self): # engine_path should be the location of UE_X.X folder ue_editor_exe = ue_lib.get_editor_exe_path(self.engine_path, self.ue_version) cmdlet_project = ue_lib.get_path_to_cmdlet_project(self.ue_version) project_file = self.project_dir / f"{self.project_name}.uproject" print("--- Generating a new project ...") # 1st stage stage_count = 2 if self.dev_mode: stage_count = 4 self.stage_begin.emit( ("Generating a new UE project ... 1 out of " f"{stage_count}")) # Need to copy the commandlet project to a temporary folder where # users don't need admin rights to write to. cmdlet_tmp = tempfile.TemporaryDirectory() cmdlet_filename = cmdlet_project.name cmdlet_dir = cmdlet_project.parent.as_posix() cmdlet_tmp_name = Path(cmdlet_tmp.name) cmdlet_tmp_file = cmdlet_tmp_name.joinpath(cmdlet_filename) copy_tree( cmdlet_dir, cmdlet_tmp_name.as_posix()) commandlet_cmd = [ f"{ue_editor_exe.as_posix()}", f"{cmdlet_tmp_file.as_posix()}", "-run=AyonGenerateProject", f"{project_file.resolve().as_posix()}", ] if self.dev_mode: commandlet_cmd.append("-GenerateCode") gen_process = subprocess.Popen(commandlet_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) for line in gen_process.stdout: decoded_line = line.decode(errors="replace") print(decoded_line, end="") self.log.emit(decoded_line) gen_process.stdout.close() return_code = gen_process.wait() cmdlet_tmp.cleanup() if return_code and return_code != 0: msg = ( f"Failed to generate {self.project_name} " f"project! Exited with return code {return_code}" ) self.failed.emit(msg, return_code) raise RuntimeError(msg) print("--- Project has been generated successfully.") self.stage_begin.emit( (f"Writing the Engine ID of the build UE ... 1" f" out of {stage_count}")) if not project_file.is_file(): msg = ("Failed to write the Engine ID into .uproject file! Can " "not read!") self.failed.emit(msg) raise RuntimeError(msg) with open(project_file.as_posix(), mode="r+") as pf: pf_json = json.load(pf) pf_json["EngineAssociation"] = ue_lib.get_build_id( self.engine_path, self.ue_version ) print(pf_json["EngineAssociation"]) pf.seek(0) json.dump(pf_json, pf, indent=4) pf.truncate() print("--- Engine ID has been written into the project file") self.progress.emit(90) if self.dev_mode: # 2nd stage self.stage_begin.emit( (f"Generating project files ... 2 out of " f"{stage_count}")) self.progress.emit(0) ubt_path = ue_lib.get_path_to_ubt(self.engine_path, self.ue_version) arch = "Win64" if platform.system().lower() == "windows": arch = "Win64" elif platform.system().lower() == "linux": arch = "Linux" elif platform.system().lower() == "darwin": # we need to test this out arch = "Mac" gen_prj_files_cmd = [ubt_path.as_posix(), "-projectfiles", f"-project={project_file}", "-progress"] gen_proc = subprocess.Popen(gen_prj_files_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) for line in gen_proc.stdout: decoded_line: str = line.decode(errors="replace") print(decoded_line, end="") self.log.emit(decoded_line) parse_prj_progress(decoded_line, self.progress) gen_proc.stdout.close() return_code = gen_proc.wait() if return_code and return_code != 0: msg = ("Failed to generate project files! " f"Exited with return code {return_code}") self.failed.emit(msg, return_code) raise RuntimeError(msg) self.stage_begin.emit( f"Building the project ... 3 out of {stage_count}") self.progress.emit(0) # 3rd stage build_prj_cmd = [ubt_path.as_posix(), f"-ModuleWithSuffix={self.project_name},3555", arch, "Development", "-TargetType=Editor", f"-Project={project_file}", f"{project_file}", "-IgnoreJunk"] build_prj_proc = subprocess.Popen(build_prj_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) for line in build_prj_proc.stdout: decoded_line: str = line.decode(errors="replace") print(decoded_line, end="") self.log.emit(decoded_line) parse_comp_progress(decoded_line, self.progress) build_prj_proc.stdout.close() return_code = build_prj_proc.wait() if return_code and return_code != 0: msg = ("Failed to build project! " f"Exited with return code {return_code}") self.failed.emit(msg, return_code) raise RuntimeError(msg) # ensure we have PySide2 installed in engine self.progress.emit(0) self.stage_begin.emit( (f"Checking PySide2 installation... {stage_count} " f" out of {stage_count}")) python_path = None if platform.system().lower() == "windows": python_path = self.engine_path / ("Engine/Binaries/ThirdParty/" "Python3/Win64/python.exe") if platform.system().lower() == "linux": python_path = self.engine_path / ("Engine/Binaries/ThirdParty/" "Python3/Linux/bin/python3") if platform.system().lower() == "darwin": python_path = self.engine_path / ("Engine/Binaries/ThirdParty/" "Python3/Mac/bin/python3") if not python_path: msg = "Unsupported platform" self.failed.emit(msg, 1) raise NotImplementedError(msg) if not python_path.exists(): msg = f"Unreal Python not found at {python_path}" self.failed.emit(msg, 1) raise RuntimeError(msg) pyside_cmd = [python_path.as_posix(), "-m", "pip", "install", "pyside2"] pyside_install = subprocess.Popen(pyside_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) for line in pyside_install.stdout: decoded_line: str = line.decode(errors="replace") print(decoded_line, end="") self.log.emit(decoded_line) pyside_install.stdout.close() return_code = pyside_install.wait() if return_code and return_code != 0: msg = ("Failed to create the project! " "The installation of PySide2 has failed!") self.failed.emit(msg, return_code) raise RuntimeError(msg) self.progress.emit(100) self.finished.emit("Project successfully built!") class UEPluginInstallWorker(UEWorker): installing = QtCore.Signal(str) def setup(self, engine_path: Path, env: dict = None, ): self.engine_path = engine_path self.env = env or os.environ def _build_and_move_plugin(self, plugin_build_path: Path): uat_path: Path = ue_lib.get_path_to_uat(self.engine_path) src_plugin_dir = Path(self.env.get("AYON_UNREAL_PLUGIN", "")) if not os.path.isdir(src_plugin_dir): msg = "Path to the integration plugin is null!" self.failed.emit(msg, 1) raise RuntimeError(msg) if not uat_path.is_file(): msg = "Building failed! Path to UAT is invalid!" self.failed.emit(msg, 1) raise RuntimeError(msg) temp_dir: Path = src_plugin_dir.parent / "Temp" temp_dir.mkdir(exist_ok=True) uplugin_path: Path = src_plugin_dir / "Ayon.uplugin" # in order to successfully build the plugin, # It must be built outside the Engine directory and then moved build_plugin_cmd: List[str] = [f"{uat_path.as_posix()}", "BuildPlugin", f"-Plugin={uplugin_path.as_posix()}", f"-Package={temp_dir.as_posix()}"] build_proc = subprocess.Popen(build_plugin_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) return_code: Union[None, int] = None for line in build_proc.stdout: decoded_line: str = line.decode(errors="replace") print(decoded_line, end="") self.log.emit(decoded_line) if return_code is None: return_code = retrieve_exit_code(decoded_line) parse_comp_progress(decoded_line, self.progress) build_proc.stdout.close() build_proc.wait() if return_code and return_code != 0: msg = ("Failed to build plugin" f" project! Exited with return code {return_code}") dir_util.remove_tree(temp_dir.as_posix()) self.failed.emit(msg, return_code) raise RuntimeError(msg) # Copy the contents of the 'Temp' dir into the # 'Ayon' directory in the engine dir_util.copy_tree(temp_dir.as_posix(), plugin_build_path.as_posix()) # We need to also copy the config folder. # The UAT doesn't include the Config folder in the build plugin_install_config_path: Path = plugin_build_path / "Config" src_plugin_config_path = src_plugin_dir / "Config" dir_util.copy_tree(src_plugin_config_path.as_posix(), plugin_install_config_path.as_posix()) dir_util.remove_tree(temp_dir.as_posix()) def execute(self): src_plugin_dir = Path(self.env.get("AYON_UNREAL_PLUGIN", "")) if not os.path.isdir(src_plugin_dir): msg = "Path to the integration plugin is null!" self.failed.emit(msg, 1) raise RuntimeError(msg) # Create a path to the plugin in the engine op_plugin_path = self.engine_path / "Engine/Plugins/Marketplace" \ "/Ayon" if not op_plugin_path.is_dir(): self.installing.emit("Installing and building the plugin ...") op_plugin_path.mkdir(parents=True, exist_ok=True) engine_plugin_config_path = op_plugin_path / "Config" engine_plugin_config_path.mkdir(exist_ok=True) dir_util._path_created = {} if not (op_plugin_path / "Binaries").is_dir() \ or not (op_plugin_path / "Intermediate").is_dir(): self.installing.emit("Building the plugin ...") print("--- Building the plugin...") self._build_and_move_plugin(op_plugin_path) self.finished.emit("Plugin successfully installed") ================================================ FILE: openpype/hosts/unreal/ui/__init__.py ================================================ from .splash_screen import SplashScreen __all__ = ( "SplashScreen", ) ================================================ FILE: openpype/hosts/unreal/ui/splash_screen.py ================================================ from qtpy import QtWidgets, QtCore, QtGui from openpype import style, resources class SplashScreen(QtWidgets.QDialog): """Splash screen for executing a process on another thread. It is able to inform about the progress of the process and log given information. """ splash_icon = None top_label = None show_log_btn: QtWidgets.QLabel = None progress_bar = None log_text: QtWidgets.QLabel = None scroll_area: QtWidgets.QScrollArea = None close_btn: QtWidgets.QPushButton = None scroll_bar: QtWidgets.QScrollBar = None is_log_visible = False is_scroll_auto = True thread_return_code = None q_thread: QtCore.QThread = None def __init__(self, window_title: str, splash_icon=None, window_icon=None): """ Args: window_title (str): String which sets the window title splash_icon (str | bytes | None): A resource (pic) which is used for the splash icon window_icon (str | bytes | None: A resource (pic) which is used for the window's icon """ super(SplashScreen, self).__init__() if splash_icon is None: splash_icon = resources.get_openpype_icon_filepath() if window_icon is None: window_icon = resources.get_openpype_icon_filepath() self.splash_icon = splash_icon self.setWindowIcon(QtGui.QIcon(window_icon)) self.setWindowTitle(window_title) self.init_ui() def was_proc_successful(self) -> bool: return self.thread_return_code == 0 def start_thread(self, q_thread: QtCore.QThread): """Saves the reference to this thread and starts it. Args: q_thread (QtCore.QThread): A QThread containing a given worker (QtCore.QObject) Returns: None """ if not q_thread: raise RuntimeError("Failed to run a worker thread! " "The thread is null!") self.q_thread = q_thread self.q_thread.start() @QtCore.Slot() def quit_and_close(self): """Quits the thread and closes the splash screen. Note that this means the thread has exited with the return code 0! Returns: None """ self.thread_return_code = 0 self.q_thread.quit() if not self.q_thread.wait(5000): raise RuntimeError("Failed to quit the QThread! " "The deadline has been reached! The thread " "has not finished it's execution!.") self.close() @QtCore.Slot() def toggle_log(self): if self.is_log_visible: self.scroll_area.hide() width = self.width() self.adjustSize() self.resize(width, self.height()) else: self.scroll_area.show() self.scroll_bar.setValue(self.scroll_bar.maximum()) self.resize(self.width(), 300) self.is_log_visible = not self.is_log_visible def show_ui(self): """Shows the splash screen. BEWARE THAT THIS FUNCTION IS BLOCKING (The execution of code can not proceed further beyond this function until the splash screen is closed!) Returns: None """ self.show() self.exec_() def init_ui(self): self.resize(450, 100) self.setMinimumWidth(250) self.setStyleSheet(style.load_stylesheet()) # Top Section self.top_label = QtWidgets.QLabel(self) self.top_label.setText("Starting process ...") self.top_label.setWordWrap(True) icon = QtWidgets.QLabel(self) icon.setPixmap(QtGui.QPixmap(self.splash_icon)) icon.setFixedHeight(45) icon.setFixedWidth(45) icon.setScaledContents(True) self.close_btn = QtWidgets.QPushButton(self) self.close_btn.setText("Quit") self.close_btn.clicked.connect(self.close) self.close_btn.setFixedWidth(80) self.close_btn.hide() self.show_log_btn = QtWidgets.QPushButton(self) self.show_log_btn.setText("Show log") self.show_log_btn.setFixedWidth(80) self.show_log_btn.clicked.connect(self.toggle_log) button_layout = QtWidgets.QVBoxLayout() button_layout.addWidget(self.show_log_btn) button_layout.addWidget(self.close_btn) # Progress Bar self.progress_bar = QtWidgets.QProgressBar() self.progress_bar.setValue(0) self.progress_bar.setAlignment(QtCore.Qt.AlignTop) # Log Content self.scroll_area = QtWidgets.QScrollArea(self) self.scroll_area.hide() log_widget = QtWidgets.QWidget(self.scroll_area) self.scroll_area.setWidgetResizable(True) self.scroll_area.setHorizontalScrollBarPolicy( QtCore.Qt.ScrollBarAlwaysOn ) self.scroll_area.setVerticalScrollBarPolicy( QtCore.Qt.ScrollBarAlwaysOn ) self.scroll_area.setWidget(log_widget) self.scroll_bar = self.scroll_area.verticalScrollBar() self.scroll_bar.sliderMoved.connect(self.on_scroll) self.log_text = QtWidgets.QLabel(self) self.log_text.setText('') self.log_text.setAlignment(QtCore.Qt.AlignTop) log_layout = QtWidgets.QVBoxLayout(log_widget) log_layout.addWidget(self.log_text) top_layout = QtWidgets.QHBoxLayout() top_layout.setAlignment(QtCore.Qt.AlignTop) top_layout.addWidget(icon) top_layout.addSpacing(10) top_layout.addWidget(self.top_label) top_layout.addSpacing(10) top_layout.addLayout(button_layout) main_layout = QtWidgets.QVBoxLayout(self) main_layout.addLayout(top_layout) main_layout.addSpacing(10) main_layout.addWidget(self.progress_bar) main_layout.addSpacing(10) main_layout.addWidget(self.scroll_area) self.setWindowFlags( QtCore.Qt.Window | QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint | QtCore.Qt.WindowMinimizeButtonHint ) desktop_rect = QtWidgets.QApplication.desktop().availableGeometry(self) center = desktop_rect.center() self.move( center.x() - (self.width() * 0.5), center.y() - (self.height() * 0.5) ) @QtCore.Slot(int) def update_progress(self, value: int): self.progress_bar.setValue(value) @QtCore.Slot(str) def update_top_label_text(self, text: str): self.top_label.setText(text) @QtCore.Slot(str, str) def append_log(self, text: str, end: str = ''): """A slot used for receiving log info and appending it to scroll area's content. Args: text (str): A log text that will append to the current one in the scroll area. end (str): end string which can be appended to the end of the given line (for ex. a line break). Returns: None """ self.log_text.setText(self.log_text.text() + text + end) if self.is_scroll_auto: self.scroll_bar.setValue(self.scroll_bar.maximum()) @QtCore.Slot(int) def on_scroll(self, position: int): """ A slot for the vertical scroll bar's movement. This ensures the auto-scrolling feature of the scroll area when the scroll bar is at its maximum value. Args: position (int): Position value of the scroll bar. Returns: None """ if self.scroll_bar.maximum() == position: self.is_scroll_auto = True return self.is_scroll_auto = False @QtCore.Slot(str, int) def fail(self, text: str, return_code: int = 1): """ A slot used for signals which can emit when a worker (process) has failed. at this moment the splash screen doesn't close by itself. it has to be closed by the user. Args: text (str): A text which can be set to the top label. Returns: return_code (int): Return code of the thread's code """ self.top_label.setText(text) self.close_btn.show() self.thread_return_code = return_code self.q_thread.exit(return_code) self.q_thread.wait() ================================================ FILE: openpype/hosts/webpublisher/README.md ================================================ Webpublisher ------------- Plugins meant for processing of Webpublisher. Gets triggered by calling `openpype_console modules webpublisher publish` with appropriate arguments. ================================================ FILE: openpype/hosts/webpublisher/__init__.py ================================================ from .addon import ( WebpublisherAddon, WEBPUBLISHER_ROOT_DIR, ) __all__ = ( "WebpublisherAddon", "WEBPUBLISHER_ROOT_DIR", ) ================================================ FILE: openpype/hosts/webpublisher/addon.py ================================================ import os from openpype.modules import click_wrap, OpenPypeModule, IHostAddon WEBPUBLISHER_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) class WebpublisherAddon(OpenPypeModule, IHostAddon): name = "webpublisher" host_name = "webpublisher" def initialize(self, module_settings): self.enabled = True def headless_publish(self, log, close_plugin_name=None, is_test=False): """Runs publish in a opened host with a context. Close Python process at the end. """ from .lib import get_webpublish_conn, publish_and_log, publish_in_test if is_test: publish_in_test(log, close_plugin_name) return dbcon = get_webpublish_conn() _id = os.environ.get("BATCH_LOG_ID") if not _id: log.warning("Unable to store log records, " "batch will be unfinished!") return publish_and_log( dbcon, _id, log, close_plugin_name=close_plugin_name ) def cli(self, click_group): click_group.add_command(cli_main.to_click_obj()) @click_wrap.group( WebpublisherAddon.name, help="Webpublisher related commands.") def cli_main(): pass @cli_main.command() @click_wrap.argument("path") @click_wrap.option("-u", "--user", help="User email address") @click_wrap.option("-p", "--project", help="Project") @click_wrap.option("-t", "--targets", help="Targets", default=None, multiple=True) def publish(project, path, user=None, targets=None): """Start publishing (Inner command). Publish collects json from paths provided as an argument. More than one path is allowed. """ from .publish_functions import cli_publish cli_publish(project, path, user, targets) @cli_main.command() @click_wrap.argument("path") @click_wrap.option("-p", "--project", help="Project") @click_wrap.option("-h", "--host", help="Host") @click_wrap.option("-u", "--user", help="User email address") @click_wrap.option("-t", "--targets", help="Targets", default=None, multiple=True) def publishfromapp(project, path, host, user=None, targets=None): """Start publishing through application (Inner command). Publish collects json from paths provided as an argument. More than one path is allowed. """ from .publish_functions import cli_publish_from_app cli_publish_from_app(project, path, host, user, targets) @cli_main.command() @click_wrap.option("-e", "--executable", help="Executable") @click_wrap.option("-u", "--upload_dir", help="Upload dir") @click_wrap.option("-h", "--host", help="Host", default=None) @click_wrap.option("-p", "--port", help="Port", default=None) def webserver(executable, upload_dir, host=None, port=None): """Start service for communication with Webpublish Front end. OP must be congigured on a machine, eg. OPENPYPE_MONGO filled AND FTRACK_BOT_API_KEY provided with api key from Ftrack. Expect "pype.club" user created on Ftrack. """ from .webserver_service import run_webserver run_webserver(executable, upload_dir, host, port) ================================================ FILE: openpype/hosts/webpublisher/api/__init__.py ================================================ import os import logging import pyblish.api from openpype.host import HostBase from openpype.hosts.webpublisher import WEBPUBLISHER_ROOT_DIR log = logging.getLogger("openpype.hosts.webpublisher") class WebpublisherHost(HostBase): name = "webpublisher" def install(self): print("Installing Pype config...") pyblish.api.register_host(self.name) publish_plugin_dir = os.path.join( WEBPUBLISHER_ROOT_DIR, "plugins", "publish" ) pyblish.api.register_plugin_path(publish_plugin_dir) self.log.info(publish_plugin_dir) ================================================ FILE: openpype/hosts/webpublisher/lib.py ================================================ import os from datetime import datetime import collections import json from bson.objectid import ObjectId import pyblish.util import pyblish.api from openpype.client.mongo import OpenPypeMongoConnection from openpype.settings import get_project_settings from openpype.lib import Logger from openpype.lib.profiles_filtering import filter_profiles ERROR_STATUS = "error" IN_PROGRESS_STATUS = "in_progress" REPROCESS_STATUS = "reprocess" SENT_REPROCESSING_STATUS = "sent_for_reprocessing" FINISHED_REPROCESS_STATUS = "republishing_finished" FINISHED_OK_STATUS = "finished_ok" log = Logger.get_logger(__name__) def parse_json(path): """Parses json file at 'path' location Returns: (dict) or None if unparsable Raises: AssertionError if 'path' doesn't exist """ path = path.strip('\"') assert os.path.isfile(path), ( "Path to json file doesn't exist. \"{}\"".format(path) ) data = None with open(path, "r") as json_file: try: data = json.load(json_file) except Exception as exc: log.error( "Error loading json: {} - Exception: {}".format(path, exc) ) return data def get_batch_asset_task_info(ctx): """Parses context data from webpublisher's batch metadata Returns: (tuple): asset, task_name (Optional), task_type """ task_type = "default_task_type" task_name = None asset = None if ctx["type"] == "task": items = ctx["path"].split('/') asset = items[-2] task_name = ctx["name"] task_type = ctx["attributes"]["type"] else: asset = ctx["name"] return asset, task_name, task_type def find_close_plugin(close_plugin_name, log): if close_plugin_name: plugins = pyblish.api.discover() for plugin in plugins: if plugin.__name__ == close_plugin_name: return plugin log.debug("Close plugin not found, app might not close.") def publish_in_test(log, close_plugin_name=None): """Loops through all plugins, logs to console. Used for tests. Args: log (Logger) close_plugin_name (Optional[str]): Name of plugin with responsibility to close application. """ # Error exit as soon as any error occurs. error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}" close_plugin = find_close_plugin(close_plugin_name, log) for result in pyblish.util.publish_iter(): for record in result["records"]: # Why do we log again? pyblish logger is logging to stdout... log.info("{}: {}".format(result["plugin"].label, record.msg)) if not result["error"]: continue # QUESTION We don't break on error? error_message = error_format.format(**result) log.error(error_message) if close_plugin: # close host app explicitly after error context = pyblish.api.Context() close_plugin().process(context) def get_webpublish_conn(): """Get connection to OP 'webpublishes' collection.""" mongo_client = OpenPypeMongoConnection.get_mongo_client() database_name = os.environ["OPENPYPE_DATABASE_NAME"] return mongo_client[database_name]["webpublishes"] def start_webpublish_log(dbcon, batch_id, user): """Start new log record for 'batch_id' Args: dbcon (OpenPypeMongoConnection) batch_id (str) user (str) Returns (ObjectId) from DB """ return dbcon.insert_one({ "batch_id": batch_id, "start_date": datetime.now(), "user": user, "status": IN_PROGRESS_STATUS, "progress": 0 # integer 0-100, percentage }).inserted_id def publish_and_log(dbcon, _id, log, close_plugin_name=None, batch_id=None): """Loops through all plugins, logs ok and fails into OP DB. Args: dbcon (OpenPypeMongoConnection) _id (str) - id of current job in DB log (openpype.lib.Logger) batch_id (str) - id sent from frontend close_plugin_name (str): name of plugin with responsibility to close host app """ # Error exit as soon as any error occurs. error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}\n" error_format += "-" * 80 + "\n" close_plugin = find_close_plugin(close_plugin_name, log) if isinstance(_id, str): _id = ObjectId(_id) log_lines = [] processed = 0 log_every = 5 for result in pyblish.util.publish_iter(): for record in result["records"]: log_lines.append("{}: {}".format( result["plugin"].label, record.msg)) processed += 1 if result["error"]: log.error(error_format.format(**result)) log_lines = [error_format.format(**result)] + log_lines dbcon.update_one( {"_id": _id}, {"$set": { "finish_date": datetime.now(), "status": ERROR_STATUS, "log": os.linesep.join(log_lines) }} ) if close_plugin: # close host app explicitly after error context = pyblish.api.Context() close_plugin().process(context) return elif processed % log_every == 0: # pyblish returns progress in 0.0 - 2.0 progress = min(round(result["progress"] / 2 * 100), 99) dbcon.update_one( {"_id": _id}, {"$set": { "progress": progress, "log": os.linesep.join(log_lines) }} ) # final update if batch_id: dbcon.update_many( {"batch_id": batch_id, "status": SENT_REPROCESSING_STATUS}, { "$set": { "finish_date": datetime.now(), "status": FINISHED_REPROCESS_STATUS, } } ) dbcon.update_one( {"_id": _id}, { "$set": { "finish_date": datetime.now(), "status": FINISHED_OK_STATUS, "progress": 100, "log": os.linesep.join(log_lines) } } ) def fail_batch(_id, dbcon, msg): """Set current batch as failed as there is some problem. Raises: ValueError """ dbcon.update_one( {"_id": _id}, {"$set": { "finish_date": datetime.now(), "status": ERROR_STATUS, "log": msg }} ) raise ValueError(msg) def find_variant_key(application_manager, host): """Searches for latest installed variant for 'host' Args: application_manager (ApplicationManager) host (str) Returns (string) (optional) Raises: (ValueError) if no variant found """ app_group = application_manager.app_groups.get(host) if not app_group or not app_group.enabled: raise ValueError("No application {} configured".format(host)) found_variant_key = None # finds most up-to-date variant if any installed sorted_variants = collections.OrderedDict( sorted(app_group.variants.items())) for variant_key, variant in sorted_variants.items(): for executable in variant.executables: if executable.exists(): found_variant_key = variant_key if not found_variant_key: raise ValueError("No executable for {} found".format(host)) return found_variant_key def get_task_data(batch_dir): """Return parsed data from first task manifest.json Used for `publishfromapp` command where batch contains only single task with publishable workfile. Returns: (dict) Throws: (ValueError) if batch or task manifest not found or broken """ batch_data = parse_json(os.path.join(batch_dir, "manifest.json")) if not batch_data: raise ValueError( "Cannot parse batch meta in {} folder".format(batch_dir)) task_dir_name = batch_data["tasks"][0] task_data = parse_json(os.path.join(batch_dir, task_dir_name, "manifest.json")) if not task_data: raise ValueError( "Cannot parse batch meta in {} folder".format(task_data)) return task_data def get_timeout(project_name, host_name, task_type): """Returns timeout(seconds) from Setting profile.""" filter_data = { "task_types": task_type, "hosts": host_name } timeout_profiles = (get_project_settings(project_name)["webpublisher"] ["timeout_profiles"]) matching_item = filter_profiles(timeout_profiles, filter_data) timeout = 3600 if matching_item: timeout = matching_item["timeout"] return timeout ================================================ FILE: openpype/hosts/webpublisher/plugins/publish/collect_batch_data.py ================================================ """Parses batch context from json and continues in publish process. Provides: context -> Loaded batch file. - asset - task (task name) - taskType - project_name - variant """ import os import pyblish.api from openpype.pipeline import legacy_io from openpype_modules.webpublisher.lib import ( parse_json, get_batch_asset_task_info, get_webpublish_conn, IN_PROGRESS_STATUS ) class CollectBatchData(pyblish.api.ContextPlugin): """Collect batch data from json stored in 'OPENPYPE_PUBLISH_DATA' env dir. The directory must contain 'manifest.json' file where batch data should be stored. """ # must be really early, context values are only in json file order = pyblish.api.CollectorOrder - 0.495 label = "Collect batch data" hosts = ["webpublisher"] def process(self, context): batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") assert batch_dir, ( "Missing `OPENPYPE_PUBLISH_DATA`") assert os.path.exists(batch_dir), \ "Folder {} doesn't exist".format(batch_dir) project_name = os.environ.get("AVALON_PROJECT") if project_name is None: raise AssertionError( "Environment `AVALON_PROJECT` was not found." "Could not set project `root` which may cause issues." ) batch_data = parse_json(os.path.join(batch_dir, "manifest.json")) context.data["batchDir"] = batch_dir context.data["batchData"] = batch_data asset_name, task_name, task_type = get_batch_asset_task_info( batch_data["context"] ) os.environ["AVALON_ASSET"] = asset_name legacy_io.Session["AVALON_ASSET"] = asset_name os.environ["AVALON_TASK"] = task_name legacy_io.Session["AVALON_TASK"] = task_name context.data["asset"] = asset_name context.data["task"] = task_name context.data["taskType"] = task_type context.data["project_name"] = project_name context.data["variant"] = batch_data["variant"] self._set_ctx_path(batch_data) def _set_ctx_path(self, batch_data): dbcon = get_webpublish_conn() batch_id = batch_data["batch"] ctx_path = batch_data["context"]["path"] self.log.info("ctx_path: {}".format(ctx_path)) self.log.info("batch_id: {}".format(batch_id)) if ctx_path and batch_id: self.log.info("Updating log record") dbcon.update_one( { "batch_id": batch_id, "status": IN_PROGRESS_STATUS }, { "$set": { "path": ctx_path } } ) ================================================ FILE: openpype/hosts/webpublisher/plugins/publish/collect_fps.py ================================================ """ Requires: Nothing Provides: Instance """ import pyblish.api from pprint import pformat class CollectFPS(pyblish.api.InstancePlugin): """ Adds fps from context to instance because of ExtractReview """ label = "Collect fps" order = pyblish.api.CollectorOrder + 0.49 hosts = ["webpublisher"] def process(self, instance): instance_fps = instance.data.get("fps") if instance_fps is None: instance.data["fps"] = instance.context.data["fps"] self.log.debug(f"instance.data: {pformat(instance.data)}") ================================================ FILE: openpype/hosts/webpublisher/plugins/publish/collect_published_files.py ================================================ """Create instances from batch data and continues in publish process. Requires: CollectBatchData Provides: context, instances -> All data from previous publishing process. """ import os import clique import tempfile import math import pyblish.api from openpype.client import ( get_asset_by_name, get_last_version_by_subset_name ) from openpype.lib import ( prepare_template_data, get_ffprobe_streams, convert_ffprobe_fps_value, ) from openpype.pipeline.create import get_subset_name from openpype_modules.webpublisher.lib import parse_json from openpype.pipeline.version_start import get_versioning_start class CollectPublishedFiles(pyblish.api.ContextPlugin): """ This collector will try to find json files in provided `OPENPYPE_PUBLISH_DATA`. Those files _MUST_ share same context. This covers 'basic' webpublishes, eg artists uses Standalone Publisher to publish rendered frames or assets. This is not applicable for 'studio' processing where host application is called to process uploaded workfile and render frames itself. For each task configure what properties should resulting instance have based on uploaded files: - uploading sequence of 'png' >> create instance of 'render' family, by adding 'review' to 'Families' and 'Create review' to Tags it will produce review. There might be difference between single(>>image) and sequence(>>render) uploaded files. """ # must be really early, context values are only in json file order = pyblish.api.CollectorOrder - 0.490 label = "Collect rendered frames" hosts = ["webpublisher"] targets = ["filespublish"] # from Settings task_type_to_family = [] sync_next_version = False # find max version to be published, use for all def process(self, context): batch_dir = context.data["batchDir"] task_subfolders = [] for folder_name in os.listdir(batch_dir): full_path = os.path.join(batch_dir, folder_name) if os.path.isdir(full_path): task_subfolders.append(full_path) self.log.info("task_sub:: {}".format(task_subfolders)) project_name = context.data["project_name"] asset_name = context.data["asset"] asset_doc = get_asset_by_name(project_name, asset_name) task_name = context.data["task"] task_type = context.data["taskType"] project_name = context.data["project_name"] variant = context.data["variant"] next_versions = [] instances = [] for task_dir in task_subfolders: task_data = parse_json(os.path.join(task_dir, "manifest.json")) self.log.info("task_data:: {}".format(task_data)) is_sequence = len(task_data["files"]) > 1 first_file = task_data["files"][0] _, extension = os.path.splitext(first_file) extension = extension.lower() family, families, tags = self._get_family( self.task_type_to_family, task_type, is_sequence, extension.replace(".", '')) subset_name = get_subset_name( family, variant, task_name, asset_doc, project_name=project_name, host_name="webpublisher", project_settings=context.data["project_settings"] ) version = self._get_next_version( project_name, asset_doc, task_name, task_type, family, subset_name, context ) next_versions.append(version) instance = context.create_instance(subset_name) instance.data["asset"] = asset_name instance.data["subset"] = subset_name # set configurable result family instance.data["family"] = family # set configurable additional families instance.data["families"] = families instance.data["version"] = version instance.data["stagingDir"] = tempfile.mkdtemp() instance.data["source"] = "webpublisher" # to convert from email provided into Ftrack username instance.data["user_email"] = task_data["user"] if is_sequence: instance.data["representations"] = self._process_sequence( task_data["files"], task_dir, tags ) instance.data["frameStart"] = \ instance.data["representations"][0]["frameStart"] instance.data["frameEnd"] = \ instance.data["representations"][0]["frameEnd"] else: frame_start = asset_doc["data"]["frameStart"] instance.data["frameStart"] = frame_start instance.data["frameEnd"] = asset_doc["data"]["frameEnd"] instance.data["representations"] = self._get_single_repre( task_dir, task_data["files"], tags ) if family != 'workfile': file_url = os.path.join(task_dir, task_data["files"][0]) try: no_of_frames = self._get_number_of_frames(file_url) if no_of_frames: frame_end = ( int(frame_start) + math.ceil(no_of_frames) ) frame_end = math.ceil(frame_end) - 1 instance.data["frameEnd"] = frame_end self.log.debug("frameEnd:: {}".format( instance.data["frameEnd"])) except Exception: self.log.warning("Unable to count frames duration.") instance.data["handleStart"] = asset_doc["data"]["handleStart"] instance.data["handleEnd"] = asset_doc["data"]["handleEnd"] if "review" in tags: first_file_path = os.path.join(task_dir, first_file) instance.data["thumbnailSource"] = first_file_path instances.append(instance) self.log.info("instance.data:: {}".format(instance.data)) if not self.sync_next_version: return # overwrite specific version with same version for all max_next_version = max(next_versions) for inst in instances: inst.data["version"] = max_next_version self.log.debug("overwritten version:: {}".format(max_next_version)) def _get_subset_name(self, family, subset_template, task_name, variant): fill_pairs = { "variant": variant, "family": family, "task": task_name } subset = subset_template.format(**prepare_template_data(fill_pairs)) return subset def _get_single_repre(self, task_dir, files, tags): _, ext = os.path.splitext(files[0]) ext = ext.lower() repre_data = { "name": ext[1:], "ext": ext[1:], "files": files[0], "stagingDir": task_dir, "tags": tags } self.log.info("single file repre_data.data:: {}".format(repre_data)) return [repre_data] def _process_sequence(self, files, task_dir, tags): """Prepare representation for sequence of files.""" collections, remainder = clique.assemble(files) assert len(collections) == 1, \ "Too many collections in {}".format(files) frame_start = list(collections[0].indexes)[0] frame_end = list(collections[0].indexes)[-1] ext = collections[0].tail ext = ext.lower() repre_data = { "frameStart": frame_start, "frameEnd": frame_end, "name": ext[1:], "ext": ext[1:], "files": files, "stagingDir": task_dir, "tags": tags # configurable tags from Settings } self.log.info("sequences repre_data.data:: {}".format(repre_data)) return [repre_data] def _get_family(self, settings, task_type, is_sequence, extension): """Guess family based on input data. Args: settings (dict): configuration per task_type task_type (str): Animation|Art etc is_sequence (bool): single file or sequence extension (str): without '.' Returns: (family, [families], tags) tuple AssertionError if not matching family found """ task_type = task_type.lower() lower_cased_task_types = {} for t_type, task in settings.items(): lower_cased_task_types[t_type.lower()] = task task_obj = lower_cased_task_types.get(task_type) assert task_obj, "No family configuration for '{}'".format(task_type) found_family = None families_config = [] # backward compatibility, should be removed pretty soon if isinstance(task_obj, dict): for family, config in task_obj: config["result_family"] = family families_config.append(config) else: families_config = task_obj for config in families_config: if is_sequence != config["is_sequence"]: continue extensions = config.get("extensions") or [] lower_extensions = set() for ext in extensions: if ext: ext = ext.lower() if ext.startswith("."): ext = ext[1:] lower_extensions.add(ext) # all extensions setting if not lower_extensions or extension in lower_extensions: found_family = config["result_family"] break msg = "No family found for combination of " +\ "task_type: {}, is_sequence:{}, extension: {}".format( task_type, is_sequence, extension) assert found_family, msg return (found_family, config["families"], config["tags"]) def _get_next_version( self, project_name, asset_doc, task_name, task_type, family, subset_name, context ): """Returns version number or 1 for 'asset' and 'subset'""" version_doc = get_last_version_by_subset_name( project_name, subset_name, asset_doc["_id"], fields=["name"] ) if version_doc: version = int(version_doc["name"]) + 1 else: version = get_versioning_start( project_name, "webpublisher", task_name=task_name, task_type=task_type, family=family, subset=subset_name, project_settings=context.data["project_settings"] ) return version def _get_number_of_frames(self, file_url): """Return duration in frames""" try: streams = get_ffprobe_streams(file_url, self.log) except Exception as exc: raise AssertionError(( "FFprobe couldn't read information about input file: \"{}\"." " Error message: {}" ).format(file_url, str(exc))) first_video_stream = None for stream in streams: if "width" in stream and "height" in stream: first_video_stream = stream break if first_video_stream: nb_frames = stream.get("nb_frames") if nb_frames: try: return int(nb_frames) except ValueError: self.log.warning( "nb_frames {} not convertible".format(nb_frames)) duration = stream.get("duration") frame_rate = convert_ffprobe_fps_value( stream.get("r_frame_rate", '0/0') ) self.log.debug("duration:: {} frame_rate:: {}".format( duration, frame_rate)) try: return float(duration) * float(frame_rate) except ValueError: self.log.warning( "{} or {} cannot be converted".format(duration, frame_rate)) self.log.warning("Cannot get number of frames") ================================================ FILE: openpype/hosts/webpublisher/plugins/publish/collect_tvpaint_instances.py ================================================ """ Requires: CollectTVPaintWorkfileData Provides: Instances """ import os import re import copy import pyblish.api from openpype.pipeline.create import get_subset_name class CollectTVPaintInstances(pyblish.api.ContextPlugin): label = "Collect TVPaint Instances" order = pyblish.api.CollectorOrder + 0.2 hosts = ["webpublisher"] targets = ["tvpaint_worker"] workfile_family = "workfile" workfile_variant = "" review_family = "review" review_variant = "Main" render_pass_family = "renderPass" render_layer_family = "renderLayer" render_layer_pass_name = "beauty" # Set by settings # Regex must contain 'layer' and 'variant' groups which are extracted from # name when instances are created layer_name_regex = r"(?PL[0-9]{3}_\w+)_(?P.+)" def process(self, context): # Prepare compiled regex layer_name_regex = re.compile(self.layer_name_regex) layers_data = context.data["layersData"] host_name = "tvpaint" task_name = context.data.get("task") asset_doc = context.data["assetEntity"] project_doc = context.data["projectEntity"] project_name = project_doc["name"] new_instances = [] # Workfile instance workfile_subset_name = get_subset_name( self.workfile_family, self.workfile_variant, task_name, asset_doc, project_name, host_name, project_settings=context.data["project_settings"] ) workfile_instance = self._create_workfile_instance( context, workfile_subset_name ) new_instances.append(workfile_instance) # Review instance review_subset_name = get_subset_name( self.review_family, self.review_variant, task_name, asset_doc, project_name, host_name, project_settings=context.data["project_settings"] ) review_instance = self._create_review_instance( context, review_subset_name ) new_instances.append(review_instance) # Get render layers and passes from TVPaint layers # - it's based on regex extraction layers_by_layer_and_pass = {} for layer in layers_data: # Filter only visible layers if not layer["visible"]: continue result = layer_name_regex.search(layer["name"]) # Layer name not matching layer name regex # should raise an exception? if result is None: continue render_layer = result.group("layer") render_pass = result.group("pass") render_pass_maping = layers_by_layer_and_pass.get( render_layer ) if render_pass_maping is None: render_pass_maping = {} layers_by_layer_and_pass[render_layer] = render_pass_maping if render_pass not in render_pass_maping: render_pass_maping[render_pass] = [] render_pass_maping[render_pass].append(copy.deepcopy(layer)) layers_by_render_layer = {} for render_layer, render_passes in layers_by_layer_and_pass.items(): render_layer_layers = [] layers_by_render_layer[render_layer] = render_layer_layers for render_pass, layers in render_passes.items(): render_layer_layers.extend(copy.deepcopy(layers)) dynamic_data = { "render_pass": render_pass, "render_layer": render_layer, # Override family for subset name "family": "render" } subset_name = get_subset_name( self.render_pass_family, render_pass, task_name, asset_doc, project_name, host_name, dynamic_data=dynamic_data, project_settings=context.data["project_settings"] ) instance = self._create_render_pass_instance( context, layers, subset_name ) new_instances.append(instance) for render_layer, layers in layers_by_render_layer.items(): variant = render_layer dynamic_data = { "render_pass": self.render_layer_pass_name, "render_layer": render_layer, # Override family for subset name "family": "render" } subset_name = get_subset_name( self.render_layer_family, variant, task_name, asset_doc, project_name, host_name, dynamic_data=dynamic_data, project_settings=context.data["project_settings"] ) instance = self._create_render_layer_instance( context, layers, subset_name ) new_instances.append(instance) # Set data same for all instances frame_start = context.data.get("frameStart") frame_end = context.data.get("frameEnd") for instance in new_instances: if ( instance.data.get("frameStart") is None or instance.data.get("frameEnd") is None ): instance.data["frameStart"] = frame_start instance.data["frameEnd"] = frame_end if instance.data.get("asset") is None: instance.data["asset"] = asset_doc["name"] if instance.data.get("task") is None: instance.data["task"] = task_name if "representations" not in instance.data: instance.data["representations"] = [] if "source" not in instance.data: instance.data["source"] = "webpublisher" def _create_workfile_instance(self, context, subset_name): workfile_path = context.data["workfilePath"] staging_dir = os.path.dirname(workfile_path) filename = os.path.basename(workfile_path) ext = os.path.splitext(filename)[-1] return context.create_instance(**{ "name": subset_name, "label": subset_name, "subset": subset_name, "family": self.workfile_family, "families": [], "stagingDir": staging_dir, "representations": [{ "name": ext.lstrip("."), "ext": ext.lstrip("."), "files": filename, "stagingDir": staging_dir }] }) def _create_review_instance(self, context, subset_name): staging_dir = self._create_staging_dir(context, subset_name) layers_data = context.data["layersData"] # Filter hidden layers filtered_layers_data = [ copy.deepcopy(layer) for layer in layers_data if layer["visible"] ] return context.create_instance(**{ "name": subset_name, "label": subset_name, "subset": subset_name, "family": self.review_family, "families": [], "layers": filtered_layers_data, "stagingDir": staging_dir }) def _create_render_pass_instance(self, context, layers, subset_name): staging_dir = self._create_staging_dir(context, subset_name) # Global instance data modifications # Fill families return context.create_instance(**{ "name": subset_name, "subset": subset_name, "label": subset_name, "family": "render", # Add `review` family for thumbnail integration "families": [self.render_pass_family, "review"], "representations": [], "layers": layers, "stagingDir": staging_dir }) def _create_render_layer_instance(self, context, layers, subset_name): staging_dir = self._create_staging_dir(context, subset_name) # Global instance data modifications # Fill families return context.create_instance(**{ "name": subset_name, "subset": subset_name, "label": subset_name, "family": "render", # Add `review` family for thumbnail integration "families": [self.render_layer_family, "review"], "representations": [], "layers": layers, "stagingDir": staging_dir }) def _create_staging_dir(self, context, subset_name): context_staging_dir = context.data["contextStagingDir"] staging_dir = os.path.join(context_staging_dir, subset_name) if not os.path.exists(staging_dir): os.makedirs(staging_dir) return staging_dir ================================================ FILE: openpype/hosts/webpublisher/plugins/publish/collect_tvpaint_workfile_data.py ================================================ """ Requires: CollectPublishedFiles CollectModules Provides: workfilePath - Path to tvpaint workfile sceneData - Scene data loaded from the workfile groupsData - layersData layersExposureFrames layersPrePostBehavior """ import os import uuid import json import shutil import pyblish.api from openpype.hosts.tvpaint.worker import ( SenderTVPaintCommands, CollectSceneData ) from openpype_modules.webpublisher.lib import parse_json class CollectTVPaintWorkfileData(pyblish.api.ContextPlugin): label = "Collect TVPaint Workfile data" order = pyblish.api.CollectorOrder - 0.4 hosts = ["webpublisher"] targets = ["tvpaint_worker"] def process(self, context): # Get JobQueue module modules = context.data["openPypeModules"] job_queue_module = modules["job_queue"] jobs_root = job_queue_module.get_jobs_root() if not jobs_root: raise ValueError("Job Queue root is not set.") context.data["jobsRoot"] = jobs_root context_staging_dir = self._create_context_staging_dir(jobs_root) workfile_path = self._extract_workfile_path( context, context_staging_dir ) context.data["contextStagingDir"] = context_staging_dir context.data["workfilePath"] = workfile_path # Prepare tvpaint command collect_scene_data_command = CollectSceneData() # Create TVPaint sender commands commands = SenderTVPaintCommands(workfile_path, job_queue_module) commands.add_command(collect_scene_data_command) # Send job and wait for answer commands.send_job_and_wait() collected_data = collect_scene_data_command.result() layers_data = collected_data["layers_data"] groups_data = collected_data["groups_data"] scene_data = collected_data["scene_data"] exposure_frames_by_layer_id = ( collected_data["exposure_frames_by_layer_id"] ) pre_post_beh_by_layer_id = ( collected_data["pre_post_beh_by_layer_id"] ) # Store results # scene data store the same way as TVPaint collector scene_data = { "sceneWidth": scene_data["width"], "sceneHeight": scene_data["height"], "scenePixelAspect": scene_data["pixel_aspect"], "sceneFps": scene_data["fps"], "sceneFieldOrder": scene_data["field_order"], "sceneMarkIn": scene_data["mark_in"], # scene_data["mark_in_state"], "sceneMarkInState": scene_data["mark_in_set"], "sceneMarkOut": scene_data["mark_out"], # scene_data["mark_out_state"], "sceneMarkOutState": scene_data["mark_out_set"], "sceneStartFrame": scene_data["start_frame"], "sceneBgColor": scene_data["bg_color"] } context.data["sceneData"] = scene_data # Store only raw data context.data["groupsData"] = groups_data context.data["layersData"] = layers_data context.data["layersExposureFrames"] = exposure_frames_by_layer_id context.data["layersPrePostBehavior"] = pre_post_beh_by_layer_id self.log.debug( ( "Collected data" "\nScene data: {}" "\nLayers data: {}" "\nExposure frames: {}" "\nPre/Post behavior: {}" ).format( json.dumps(scene_data, indent=4), json.dumps(layers_data, indent=4), json.dumps(exposure_frames_by_layer_id, indent=4), json.dumps(pre_post_beh_by_layer_id, indent=4) ) ) def _create_context_staging_dir(self, jobs_root): if not os.path.exists(jobs_root): os.makedirs(jobs_root) random_folder_name = str(uuid.uuid4()) full_path = os.path.join(jobs_root, random_folder_name) if not os.path.exists(full_path): os.makedirs(full_path) return full_path def _extract_workfile_path(self, context, context_staging_dir): """Find first TVPaint file in tasks and use it.""" batch_dir = context.data["batchDir"] batch_data = context.data["batchData"] src_workfile_path = None for task_id in batch_data["tasks"]: if src_workfile_path is not None: break task_dir = os.path.join(batch_dir, task_id) task_manifest_path = os.path.join(task_dir, "manifest.json") task_data = parse_json(task_manifest_path) task_files = task_data["files"] for filename in task_files: _, ext = os.path.splitext(filename) if ext.lower() == ".tvpp": src_workfile_path = os.path.join(task_dir, filename) break # Copy workfile to job queue work root new_workfile_path = os.path.join( context_staging_dir, os.path.basename(src_workfile_path) ) shutil.copy(src_workfile_path, new_workfile_path) return new_workfile_path ================================================ FILE: openpype/hosts/webpublisher/plugins/publish/extract_tvpaint_workfile.py ================================================ import os import copy from openpype.hosts.tvpaint.worker import ( SenderTVPaintCommands, ExecuteSimpleGeorgeScript, ExecuteGeorgeScript ) import pyblish.api from openpype.hosts.tvpaint.lib import ( calculate_layers_extraction_data, get_frame_filename_template, fill_reference_frames, composite_rendered_layers, rename_filepaths_by_frame_start ) from PIL import Image class ExtractTVPaintSequences(pyblish.api.Extractor): label = "Extract TVPaint Sequences" hosts = ["webpublisher"] targets = ["tvpaint_worker"] # Context plugin does not have families filtering families_filter = ["review", "renderPass", "renderLayer"] job_queue_root_key = "jobs_root" # Modifiable with settings review_bg = [255, 255, 255, 255] def process(self, context): # Get workfle path workfile_path = context.data["workfilePath"] jobs_root = context.data["jobsRoot"] jobs_root_slashed = jobs_root.replace("\\", "/") # Prepare scene data scene_data = context.data["sceneData"] scene_mark_in = scene_data["sceneMarkIn"] scene_mark_out = scene_data["sceneMarkOut"] scene_start_frame = scene_data["sceneStartFrame"] scene_bg_color = scene_data["sceneBgColor"] # Prepare layers behavior behavior_by_layer_id = context.data["layersPrePostBehavior"] exposure_frames_by_layer_id = context.data["layersExposureFrames"] # Handles are not stored per instance but on Context handle_start = context.data["handleStart"] handle_end = context.data["handleEnd"] # Get JobQueue module modules = context.data["openPypeModules"] job_queue_module = modules["job_queue"] tvpaint_commands = SenderTVPaintCommands( workfile_path, job_queue_module ) # Change scene Start Frame to 0 to prevent frame index issues # - issue is that TVPaint versions deal with frame indexes in a # different way when Start Frame is not `0` # NOTE It will be set back after rendering tvpaint_commands.add_command( ExecuteSimpleGeorgeScript("tv_startframe 0") ) root_key_replacement = "{" + self.job_queue_root_key + "}" after_render_instances = [] for instance in context: instance_families = set(instance.data.get("families", [])) instance_families.add(instance.data["family"]) valid = False for family in instance_families: if family in self.families_filter: valid = True break if not valid: continue self.log.info("* Preparing commands for instance \"{}\"".format( instance.data["label"] )) # Get all layers and filter out not visible layers = instance.data["layers"] filtered_layers = [layer for layer in layers if layer["visible"]] if not filtered_layers: self.log.info( "None of the layers from the instance" " are visible. Extraction skipped." ) continue joined_layer_names = ", ".join([ "\"{}\"".format(str(layer["name"])) for layer in filtered_layers ]) self.log.debug( "Instance has {} layers with names: {}".format( len(filtered_layers), joined_layer_names ) ) # Staging dir must be created during collection staging_dir = instance.data["stagingDir"].replace("\\", "/") job_root_template = staging_dir.replace( jobs_root_slashed, root_key_replacement ) # Frame start/end may be stored as float frame_start = int(instance.data["frameStart"]) frame_end = int(instance.data["frameEnd"]) # Prepare output frames output_frame_start = frame_start - handle_start output_frame_end = frame_end + handle_end # Change output frame start to 0 if handles cause it's negative # number if output_frame_start < 0: self.log.warning(( "Frame start with handles has negative value." " Changed to \"0\". Frames start: {}, Handle Start: {}" ).format(frame_start, handle_start)) output_frame_start = 0 # Create copy of scene Mark In/Out mark_in, mark_out = scene_mark_in, scene_mark_out # Fix possible changes of output frame mark_out, output_frame_end = self._fix_range_changes( mark_in, mark_out, output_frame_start, output_frame_end ) filename_template = get_frame_filename_template( max(scene_mark_out, output_frame_end) ) # ----------------------------------------------------------------- self.log.debug( "Files will be rendered to folder: {}".format(staging_dir) ) output_filepaths_by_frame_idx = {} for frame_idx in range(mark_in, mark_out + 1): filename = filename_template.format(frame=frame_idx) filepath = os.path.join(staging_dir, filename) output_filepaths_by_frame_idx[frame_idx] = filepath # Prepare data for post render processing post_render_data = { "output_dir": staging_dir, "layers": filtered_layers, "output_filepaths_by_frame_idx": output_filepaths_by_frame_idx, "instance": instance, "is_layers_render": False, "output_frame_start": output_frame_start, "output_frame_end": output_frame_end } # Store them to list after_render_instances.append(post_render_data) # Review rendering if instance.data["family"] == "review": self.add_render_review_command( tvpaint_commands, mark_in, mark_out, scene_bg_color, job_root_template, filename_template ) continue # Layers rendering extraction_data_by_layer_id = calculate_layers_extraction_data( filtered_layers, exposure_frames_by_layer_id, behavior_by_layer_id, mark_in, mark_out ) filepaths_by_layer_id = self.add_render_command( tvpaint_commands, job_root_template, staging_dir, filtered_layers, extraction_data_by_layer_id ) # Add more data to post render processing post_render_data.update({ "is_layers_render": True, "extraction_data_by_layer_id": extraction_data_by_layer_id, "filepaths_by_layer_id": filepaths_by_layer_id }) # Change scene frame Start back to previous value tvpaint_commands.add_command( ExecuteSimpleGeorgeScript( "tv_startframe {}".format(scene_start_frame) ) ) self.log.info("Sending the job and waiting for response...") tvpaint_commands.send_job_and_wait() self.log.info("Render job finished") for post_render_data in after_render_instances: self._post_render_processing(post_render_data, mark_in, mark_out) def _fix_range_changes( self, mark_in, mark_out, output_frame_start, output_frame_end ): # Check Marks range and output range output_range = output_frame_end - output_frame_start marks_range = mark_out - mark_in # Lower Mark Out if mark range is bigger than output # - do not rendered not used frames if output_range < marks_range: new_mark_out = mark_out - (marks_range - output_range) self.log.warning(( "Lowering render range to {} frames. Changed Mark Out {} -> {}" ).format(marks_range + 1, mark_out, new_mark_out)) # Assign new mark out to variable mark_out = new_mark_out # Lower output frame end so representation has right `frameEnd` value elif output_range > marks_range: new_output_frame_end = ( output_frame_end - (output_range - marks_range) ) self.log.warning(( "Lowering representation range to {} frames." " Changed frame end {} -> {}" ).format(output_range + 1, mark_out, new_output_frame_end)) output_frame_end = new_output_frame_end return mark_out, output_frame_end def _post_render_processing(self, post_render_data, mark_in, mark_out): # Unpack values instance = post_render_data["instance"] output_filepaths_by_frame_idx = ( post_render_data["output_filepaths_by_frame_idx"] ) is_layers_render = post_render_data["is_layers_render"] output_dir = post_render_data["output_dir"] layers = post_render_data["layers"] output_frame_start = post_render_data["output_frame_start"] output_frame_end = post_render_data["output_frame_end"] # Trigger post processing of layers rendering # - only few frames were rendered this will complete the sequence # - multiple layers can be in single instance they must be composite # over each other if is_layers_render: self._finish_layer_render( layers, post_render_data["extraction_data_by_layer_id"], post_render_data["filepaths_by_layer_id"], mark_in, mark_out, output_filepaths_by_frame_idx ) # Create thumbnail thumbnail_filepath = os.path.join(output_dir, "thumbnail.jpg") thumbnail_src_path = output_filepaths_by_frame_idx[mark_in] self._create_thumbnail(thumbnail_src_path, thumbnail_filepath) # Rename filepaths to final frames repre_files = self._rename_output_files( output_filepaths_by_frame_idx, mark_in, mark_out, output_frame_start ) # Fill tags and new families family_lowered = instance.data["family"].lower() tags = [] if family_lowered in ("review", "renderlayer"): tags.append("review") # Sequence of one frame single_file = len(repre_files) == 1 if single_file: repre_files = repre_files[0] # Extension is hardcoded # - changing extension would require change code new_repre = { "name": "png", "ext": "png", "files": repre_files, "stagingDir": output_dir, "tags": tags } if not single_file: new_repre["frameStart"] = output_frame_start new_repre["frameEnd"] = output_frame_end self.log.debug("Creating new representation: {}".format(new_repre)) instance.data["representations"].append(new_repre) if family_lowered in ("renderpass", "renderlayer"): # Change family to render instance.data["family"] = "render" thumbnail_ext = os.path.splitext(thumbnail_filepath)[1] # Create thumbnail representation thumbnail_repre = { "name": "thumbnail", "ext": thumbnail_ext.replace(".", ""), "outputName": "thumb", "files": os.path.basename(thumbnail_filepath), "stagingDir": output_dir, "tags": ["thumbnail"] } instance.data["representations"].append(thumbnail_repre) def _rename_output_files( self, filepaths_by_frame, mark_in, mark_out, output_frame_start ): new_filepaths_by_frame = rename_filepaths_by_frame_start( filepaths_by_frame, mark_in, mark_out, output_frame_start ) repre_filenames = [] for filepath in new_filepaths_by_frame.values(): repre_filenames.append(os.path.basename(filepath)) if mark_in < output_frame_start: repre_filenames = list(reversed(repre_filenames)) return repre_filenames def add_render_review_command( self, tvpaint_commands, mark_in, mark_out, scene_bg_color, job_root_template, filename_template ): """ Export images from TVPaint using `tv_savesequence` command. Args: output_dir (str): Directory where files will be stored. mark_in (int): Starting frame index from which export will begin. mark_out (int): On which frame index export will end. scene_bg_color (list): Bg color set in scene. Result of george script command `tv_background`. """ self.log.debug("Preparing data for rendering.") bg_color = self._get_review_bg_color() first_frame_filepath = "/".join([ job_root_template, filename_template.format(frame=mark_in) ]) george_script_lines = [ # Change bg color to color from settings "tv_background \"color\" {} {} {}".format(*bg_color), "tv_SaveMode \"PNG\"", "export_path = \"{}\"".format( first_frame_filepath.replace("\\", "/") ), "tv_savesequence '\"'export_path'\"' {} {}".format( mark_in, mark_out ) ] if scene_bg_color: # Change bg color back to previous scene bg color _scene_bg_color = copy.deepcopy(scene_bg_color) bg_type = _scene_bg_color.pop(0) orig_color_command = [ "tv_background", "\"{}\"".format(bg_type) ] orig_color_command.extend(_scene_bg_color) george_script_lines.append(" ".join(orig_color_command)) tvpaint_commands.add_command( ExecuteGeorgeScript( george_script_lines, root_dir_key=self.job_queue_root_key ) ) def add_render_command( self, tvpaint_commands, job_root_template, staging_dir, layers, extraction_data_by_layer_id ): """ Export images from TVPaint. Args: output_dir (str): Directory where files will be stored. mark_in (int): Starting frame index from which export will begin. mark_out (int): On which frame index export will end. layers (list): List of layers to be exported. Returns: tuple: With 2 items first is list of filenames second is path to thumbnail. """ # Map layers by position layers_by_id = { layer["layer_id"]: layer for layer in layers } # Render layers filepaths_by_layer_id = {} for layer_id, render_data in extraction_data_by_layer_id.items(): layer = layers_by_id[layer_id] frame_references = render_data["frame_references"] filenames_by_frame_index = render_data["filenames_by_frame_index"] filepaths_by_frame = {} command_filepath_by_frame = {} for frame_idx, ref_idx in frame_references.items(): # None reference is skipped because does not have source if ref_idx is None: filepaths_by_frame[frame_idx] = None continue filename = filenames_by_frame_index[frame_idx] filepaths_by_frame[frame_idx] = os.path.join( staging_dir, filename ) if frame_idx == ref_idx: command_filepath_by_frame[frame_idx] = "/".join( [job_root_template, filename] ) self._add_render_layer_command( tvpaint_commands, layer, command_filepath_by_frame ) filepaths_by_layer_id[layer_id] = filepaths_by_frame return filepaths_by_layer_id def _add_render_layer_command( self, tvpaint_commands, layer, filepaths_by_frame ): george_script_lines = [ # Set current layer by position "tv_layergetid {}".format(layer["position"]), "layer_id = result", "tv_layerset layer_id", "tv_SaveMode \"PNG\"" ] for frame_idx, filepath in filepaths_by_frame.items(): if filepath is None: continue # Go to frame george_script_lines.append("tv_layerImage {}".format(frame_idx)) # Store image to output george_script_lines.append( "tv_saveimage \"{}\"".format(filepath.replace("\\", "/")) ) tvpaint_commands.add_command( ExecuteGeorgeScript( george_script_lines, root_dir_key=self.job_queue_root_key ) ) def _finish_layer_render( self, layers, extraction_data_by_layer_id, filepaths_by_layer_id, mark_in, mark_out, output_filepaths_by_frame_idx ): # Fill frames between `frame_start_index` and `frame_end_index` self.log.debug("Filling frames not rendered frames.") for layer_id, render_data in extraction_data_by_layer_id.items(): frame_references = render_data["frame_references"] filepaths_by_frame = filepaths_by_layer_id[layer_id] fill_reference_frames(frame_references, filepaths_by_frame) # Prepare final filepaths where compositing should store result self.log.info("Started compositing of layer frames.") composite_rendered_layers( layers, filepaths_by_layer_id, mark_in, mark_out, output_filepaths_by_frame_idx ) def _create_thumbnail(self, thumbnail_src_path, thumbnail_filepath): if not os.path.exists(thumbnail_src_path): return source_img = Image.open(thumbnail_src_path) # Composite background only on rgba images # - just making sure if source_img.mode.lower() == "rgba": bg_color = self._get_review_bg_color() self.log.debug("Adding thumbnail background color {}.".format( " ".join([str(val) for val in bg_color]) )) bg_image = Image.new("RGBA", source_img.size, bg_color) thumbnail_obj = Image.alpha_composite(bg_image, source_img) thumbnail_obj.convert("RGB").save(thumbnail_filepath) else: self.log.info(( "Source for thumbnail has mode \"{}\" (Expected: RGBA)." " Can't use thubmanail background color." ).format(source_img.mode)) source_img.save(thumbnail_filepath) def _get_review_bg_color(self): red = green = blue = 255 if self.review_bg: if len(self.review_bg) == 4: red, green, blue, _ = self.review_bg elif len(self.review_bg) == 3: red, green, blue = self.review_bg return (red, green, blue) ================================================ FILE: openpype/hosts/webpublisher/plugins/publish/others_cleanup_job_root.py ================================================ # -*- coding: utf-8 -*- """Cleanup leftover files from publish.""" import os import shutil import pyblish.api class CleanUpJobRoot(pyblish.api.ContextPlugin): """Cleans up the job root directory after a successful publish. Remove all files in job root as all of them should be published. """ order = pyblish.api.IntegratorOrder + 1 label = "Clean Up Job Root" optional = True active = True def process(self, context): context_staging_dir = context.data.get("contextStagingDir") if not context_staging_dir: self.log.info("Key 'contextStagingDir' is empty.") elif not os.path.exists(context_staging_dir): self.log.info(( "Job root directory for this publish does not" " exists anymore \"{}\"." ).format(context_staging_dir)) else: self.log.info("Deleting job root with all files.") shutil.rmtree(context_staging_dir) ================================================ FILE: openpype/hosts/webpublisher/plugins/publish/validate_tvpaint_workfile_data.py ================================================ import pyblish.api class ValidateWorkfileData(pyblish.api.ContextPlugin): """Validate mark in and out are enabled and it's duration. Mark In/Out does not have to match frameStart and frameEnd but duration is important. """ label = "Validate Workfile Data" order = pyblish.api.ValidatorOrder targets = ["tvpaint_worker"] def process(self, context): # Data collected in `CollectContextEntities` frame_start = context.data["frameStart"] frame_end = context.data["frameEnd"] handle_start = context.data["handleStart"] handle_end = context.data["handleEnd"] scene_data = context.data["sceneData"] scene_mark_in = scene_data["sceneMarkIn"] scene_mark_out = scene_data["sceneMarkOut"] expected_range = ( (frame_end - frame_start + 1) + handle_start + handle_end ) marks_range = scene_mark_out - scene_mark_in + 1 if expected_range != marks_range: raise AssertionError(( "Wrong Mark In/Out range." " Expected range is {} frames got {} frames" ).format(expected_range, marks_range)) ================================================ FILE: openpype/hosts/webpublisher/publish_functions.py ================================================ import os import time import pyblish.api import pyblish.util from openpype.lib import Logger from openpype.lib.applications import ( ApplicationManager, LaunchTypes, ) from openpype.pipeline import install_host from openpype.hosts.webpublisher.api import WebpublisherHost from .lib import ( get_batch_asset_task_info, get_webpublish_conn, start_webpublish_log, publish_and_log, fail_batch, find_variant_key, get_task_data, get_timeout, IN_PROGRESS_STATUS ) def cli_publish(project_name, batch_path, user_email, targets): """Start headless publishing. Used to publish rendered assets, workfiles etc via Webpublisher. Eventually should be yanked out to Webpublisher cli. Publish use json from passed paths argument. Args: project_name (str): project to publish (only single context is expected per call of 'publish') batch_path (str): Path batch folder. Contains subfolders with resources (workfile, another subfolder 'renders' etc.) user_email (string): email address for webpublisher - used to find Ftrack user with same email targets (list): Pyblish targets (to choose validator for example) Raises: RuntimeError: When there is no path to process. """ if not batch_path: raise RuntimeError("No publish paths specified") log = Logger.get_logger("Webpublish") log.info("Webpublish command") # Register target and host webpublisher_host = WebpublisherHost() os.environ["OPENPYPE_PUBLISH_DATA"] = batch_path os.environ["AVALON_PROJECT"] = project_name os.environ["AVALON_APP"] = webpublisher_host.name os.environ["USER_EMAIL"] = user_email os.environ["HEADLESS_PUBLISH"] = 'true' # to use in app lib if targets: if isinstance(targets, str): targets = [targets] for target in targets: pyblish.api.register_target(target) install_host(webpublisher_host) log.info("Running publish ...") _, batch_id = os.path.split(batch_path) dbcon = get_webpublish_conn() _id = start_webpublish_log(dbcon, batch_id, user_email) task_data = get_task_data(batch_path) if not task_data["context"]: msg = "Batch manifest must contain context data" msg += "Create new batch and set context properly." fail_batch(_id, dbcon, msg) publish_and_log(dbcon, _id, log, batch_id=batch_id) log.info("Publish finished.") def cli_publish_from_app( project_name, batch_path, host_name, user_email, targets ): """Opens installed variant of 'host' and run remote publish there. Eventually should be yanked out to Webpublisher cli. Currently implemented and tested for Photoshop where customer wants to process uploaded .psd file and publish collected layers from there. Triggered by Webpublisher. Checks if no other batches are running (status =='in_progress). If so, it sleeps for SLEEP (this is separate process), waits for WAIT_FOR seconds altogether. Requires installed host application on the machine. Runs publish process as user would, in automatic fashion. Args: project_name (str): project to publish (only single context is expected per call of publish batch_path (str): Path batch folder. Contains subfolders with resources (workfile, another subfolder 'renders' etc.) host_name (str): 'photoshop' user_email (string): email address for webpublisher - used to find Ftrack user with same email targets (list): Pyblish targets (to choose validator for example) """ log = Logger.get_logger("PublishFromApp") log.info("Webpublish photoshop command") task_data = get_task_data(batch_path) workfile_path = os.path.join(batch_path, task_data["task"], task_data["files"][0]) print("workfile_path {}".format(workfile_path)) batch_id = task_data["batch"] dbcon = get_webpublish_conn() # safer to start logging here, launch might be broken altogether _id = start_webpublish_log(dbcon, batch_id, user_email) batches_in_progress = list(dbcon.find({"status": IN_PROGRESS_STATUS})) if len(batches_in_progress) > 1: running_batches = [str(batch["_id"]) for batch in batches_in_progress if batch["_id"] != _id] msg = "There are still running batches {}\n". \ format("\n".join(running_batches)) msg += "Ask admin to check them and reprocess current batch" fail_batch(_id, dbcon, msg) if not task_data["context"]: msg = "Batch manifest must contain context data" msg += "Create new batch and set context properly." fail_batch(_id, dbcon, msg) asset_name, task_name, task_type = get_batch_asset_task_info( task_data["context"]) application_manager = ApplicationManager() found_variant_key = find_variant_key(application_manager, host_name) app_name = "{}/{}".format(host_name, found_variant_key) data = { "last_workfile_path": workfile_path, "start_last_workfile": True, "project_name": project_name, "asset_name": asset_name, "task_name": task_name, "launch_type": LaunchTypes.automated, } launch_context = application_manager.create_launch_context( app_name, **data) launch_context.run_prelaunch_hooks() # must have for proper launch of app env = launch_context.env print("env:: {}".format(env)) env["OPENPYPE_PUBLISH_DATA"] = batch_path # must pass identifier to update log lines for a batch env["BATCH_LOG_ID"] = str(_id) env["HEADLESS_PUBLISH"] = 'true' # to use in app lib env["USER_EMAIL"] = user_email os.environ.update(env) # Why is this here? Registered host in this process does not affect # regitered host in launched process. pyblish.api.register_host(host_name) if targets: if isinstance(targets, str): targets = [targets] current_targets = os.environ.get("PYBLISH_TARGETS", "").split( os.pathsep) for target in targets: current_targets.append(target) os.environ["PYBLISH_TARGETS"] = os.pathsep.join( set(current_targets)) launched_app = application_manager.launch_with_context(launch_context) timeout = get_timeout(project_name, host_name, task_type) time_start = time.time() while launched_app.poll() is None: time.sleep(0.5) if time.time() - time_start > timeout: launched_app.terminate() msg = "Timeout reached" fail_batch(_id, dbcon, msg) ================================================ FILE: openpype/hosts/webpublisher/webserver_service/__init__.py ================================================ from .webserver import run_webserver __all__ = ( "run_webserver", ) ================================================ FILE: openpype/hosts/webpublisher/webserver_service/webpublish_routes.py ================================================ """Routes and etc. for webpublisher API.""" import os import json import datetime import collections import subprocess from bson.objectid import ObjectId from aiohttp.web_response import Response from openpype.client import ( get_projects, get_assets, ) from openpype.lib import Logger from openpype.settings import get_project_settings from openpype_modules.webserver.base_routes import RestApiEndpoint from openpype_modules.webpublisher import WebpublisherAddon from openpype_modules.webpublisher.lib import ( get_webpublish_conn, get_task_data, ERROR_STATUS, REPROCESS_STATUS ) log = Logger.get_logger("WebpublishRoutes") class ResourceRestApiEndpoint(RestApiEndpoint): def __init__(self, resource): self.resource = resource super(ResourceRestApiEndpoint, self).__init__() class WebpublishApiEndpoint(ResourceRestApiEndpoint): @property def dbcon(self): return self.resource.dbcon class JsonApiResource: """Resource for json manipulation. All resources handling sending output to REST should inherit from """ @staticmethod def json_dump_handler(value): if isinstance(value, datetime.datetime): return value.isoformat() if isinstance(value, ObjectId): return str(value) if isinstance(value, set): return list(value) raise TypeError(value) @classmethod def encode(cls, data): return json.dumps( data, indent=4, default=cls.json_dump_handler ).encode("utf-8") class RestApiResource(JsonApiResource): """Resource carrying needed info and Avalon DB connection for publish.""" def __init__(self, server_manager, executable, upload_dir, studio_task_queue=None): self.server_manager = server_manager self.upload_dir = upload_dir self.executable = executable if studio_task_queue is None: studio_task_queue = collections.deque().dequeu self.studio_task_queue = studio_task_queue class WebpublishRestApiResource(JsonApiResource): """Resource carrying OP DB connection for storing batch info into DB.""" def __init__(self): self.dbcon = get_webpublish_conn() class ProjectsEndpoint(ResourceRestApiEndpoint): """Returns list of dict with project info (id, name).""" async def get(self) -> Response: output = [] for project_doc in get_projects(): ret_val = { "id": project_doc["_id"], "name": project_doc["name"] } output.append(ret_val) return Response( status=200, body=self.resource.encode(output), content_type="application/json" ) class HiearchyEndpoint(ResourceRestApiEndpoint): """Returns dictionary with context tree from assets.""" async def get(self, project_name) -> Response: query_projection = { "_id": 1, "data.tasks": 1, "data.visualParent": 1, "data.entityType": 1, "name": 1, "type": 1, } asset_docs = get_assets(project_name, fields=query_projection.keys()) asset_docs_by_id = { asset_doc["_id"]: asset_doc for asset_doc in asset_docs } asset_docs_by_parent_id = collections.defaultdict(list) for asset_doc in asset_docs_by_id.values(): parent_id = asset_doc["data"].get("visualParent") asset_docs_by_parent_id[parent_id].append(asset_doc) assets = collections.defaultdict(list) for parent_id, children in asset_docs_by_parent_id.items(): for child in children: node = assets.get(child["_id"]) if not node: node = Node(child["_id"], child["data"].get("entityType", "Folder"), child["name"]) assets[child["_id"]] = node tasks = child["data"].get("tasks", {}) for t_name, t_con in tasks.items(): task_node = TaskNode("task", t_name) task_node["attributes"]["type"] = t_con.get("type") task_node.parent = node parent_node = assets.get(parent_id) if not parent_node: asset_doc = asset_docs_by_id.get(parent_id) if asset_doc: # regular node parent_node = Node(parent_id, asset_doc["data"].get("entityType", "Folder"), asset_doc["name"]) else: # root parent_node = Node(parent_id, "project", project_name) assets[parent_id] = parent_node node.parent = parent_node roots = [x for x in assets.values() if x.parent is None] return Response( status=200, body=self.resource.encode(roots[0]), content_type="application/json" ) class Node(dict): """Node element in context tree.""" def __init__(self, uid, node_type, name): self._parent = None # pointer to parent Node self["type"] = node_type self["name"] = name self['id'] = uid # keep reference to id # self['children'] = [] # collection of pointers to child Nodes @property def parent(self): return self._parent # simply return the object at the _parent pointer @parent.setter def parent(self, node): self._parent = node # add this node to parent's list of children node['children'].append(self) class TaskNode(Node): """Special node type only for Tasks.""" def __init__(self, node_type, name): self._parent = None self["type"] = node_type self["name"] = name self["attributes"] = {} class BatchPublishEndpoint(WebpublishApiEndpoint): """Triggers headless publishing of batch.""" async def post(self, request) -> Response: # Validate existence of openpype executable openpype_app = self.resource.executable if not openpype_app or not os.path.exists(openpype_app): msg = "Non existent OpenPype executable {}".format(openpype_app) raise RuntimeError(msg) log.info("BatchPublishEndpoint called") content = await request.json() # Each filter have extensions which are checked on first task item # - first filter with extensions that are on first task is used # - filter defines command and can extend arguments dictionary # This is used only if 'studio_processing' is enabled on batch studio_processing_filters = [ # TVPaint filter { "extensions": [".tvpp"], "command": "publish", "arguments": { "targets": ["tvpaint_worker", "webpublish"] }, "add_to_queue": False }, # Photoshop filter { "extensions": [".psd", ".psb"], "command": "publishfromapp", "arguments": { # Command 'publishfromapp' requires --host argument "host": "photoshop", # Make sure targets are set to None for cases that default # would change # - targets argument is not used in 'publishfromapp' "targets": ["automated", "webpublish"] }, # does publish need to be handled by a queue, eg. only # single process running concurrently? "add_to_queue": True } ] batch_dir = os.path.join(self.resource.upload_dir, content["batch"]) # Default command and arguments command = "publish" add_args = { # All commands need 'project' and 'user' "project": content["project_name"], "user": content["user"], "targets": ["filespublish", "webpublish"] } add_to_queue = False if content.get("studio_processing"): log.info("Post processing called for {}".format(batch_dir)) task_data = get_task_data(batch_dir) for process_filter in studio_processing_filters: filter_extensions = process_filter.get("extensions") or [] for file_name in task_data["files"]: file_ext = os.path.splitext(file_name)[-1].lower() if file_ext in filter_extensions: # Change command command = process_filter["command"] # Update arguments add_args.update( process_filter.get("arguments") or {} ) add_to_queue = process_filter["add_to_queue"] break args = [ openpype_app, "module", WebpublisherAddon.name, command, batch_dir ] for key, value in add_args.items(): # Skip key values where value is None if value is None: continue arg_key = "--{}".format(key) if not isinstance(value, (tuple, list)): value = [value] for item in value: args += [arg_key, item] log.info("args:: {}".format(args)) if add_to_queue: log.debug("Adding to queue") self.resource.studio_task_queue.append(args) else: subprocess.Popen(args) return Response( status=200, content_type="application/json" ) class TaskPublishEndpoint(WebpublishApiEndpoint): """Prepared endpoint triggered after each task - for future development.""" async def post(self, request) -> Response: return Response( status=200, body=self.resource.encode([]), content_type="application/json" ) class BatchStatusEndpoint(WebpublishApiEndpoint): """Returns dict with info for batch_id. Uses 'WebpublishRestApiResource'. """ async def get(self, batch_id) -> Response: output = self.dbcon.find_one({"batch_id": batch_id}) if output: status = 200 else: output = {"msg": "Batch id {} not found".format(batch_id), "status": "queued", "progress": 0} status = 404 body = self.resource.encode(output) return Response( status=status, body=body, content_type="application/json" ) class UserReportEndpoint(WebpublishApiEndpoint): """Returns list of dict with batch info for user (email address). Uses 'WebpublishRestApiResource'. """ async def get(self, user) -> Response: output = list(self.dbcon.find({"user": user}, projection={"log": False})) if output: status = 200 else: output = {"msg": "User {} not found".format(user)} status = 404 body = self.resource.encode(output) return Response( status=status, body=body, content_type="application/json" ) class ConfiguredExtensionsEndpoint(WebpublishApiEndpoint): """Returns dict of extensions which have mapping to family. Returns: { "file_exts": [], "sequence_exts": [] } """ async def get(self, project_name=None) -> Response: sett = get_project_settings(project_name) configured = { "file_exts": set(), "sequence_exts": set(), # workfiles that could have "Studio Processing" hardcoded for now "studio_exts": set(["psd", "psb", "tvpp", "tvp"]) } collect_conf = sett["webpublisher"]["publish"]["CollectPublishedFiles"] configs = collect_conf.get("task_type_to_family", []) mappings = [] for _, conf_mappings in configs.items(): if isinstance(conf_mappings, dict): conf_mappings = conf_mappings.values() for conf_mapping in conf_mappings: mappings.append(conf_mapping) for mapping in mappings: if mapping["is_sequence"]: configured["sequence_exts"].update(mapping["extensions"]) else: configured["file_exts"].update(mapping["extensions"]) return Response( status=200, body=self.resource.encode(dict(configured)), content_type="application/json" ) class BatchReprocessEndpoint(WebpublishApiEndpoint): """Marks latest 'batch_id' for reprocessing, returns 404 if not found. Uses 'WebpublishRestApiResource'. """ async def post(self, batch_id) -> Response: batches = self.dbcon.find({"batch_id": batch_id, "status": ERROR_STATUS}).sort("_id", -1) if batches: self.dbcon.update_one( {"_id": batches[0]["_id"]}, {"$set": {"status": REPROCESS_STATUS}} ) output = [{"msg": "Batch id {} set to reprocess".format(batch_id)}] status = 200 else: output = [{"msg": "Batch id {} not found".format(batch_id)}] status = 404 body = self.resource.encode(output) return Response( status=status, body=body, content_type="application/json" ) ================================================ FILE: openpype/hosts/webpublisher/webserver_service/webserver.py ================================================ import collections import time import os from datetime import datetime import requests import json import subprocess from openpype.client import OpenPypeMongoConnection from openpype.modules import ModulesManager from openpype.lib import Logger from openpype_modules.webpublisher.lib import ( ERROR_STATUS, REPROCESS_STATUS, SENT_REPROCESSING_STATUS ) from .webpublish_routes import ( RestApiResource, WebpublishRestApiResource, HiearchyEndpoint, ProjectsEndpoint, ConfiguredExtensionsEndpoint, BatchPublishEndpoint, BatchReprocessEndpoint, BatchStatusEndpoint, TaskPublishEndpoint, UserReportEndpoint ) log = Logger.get_logger("webserver_gui") def run_webserver(executable, upload_dir, host=None, port=None): """Runs webserver in command line, adds routes.""" if not host: host = "localhost" if not port: port = 8079 manager = ModulesManager() webserver_module = manager.modules_by_name["webserver"] server_manager = webserver_module.create_new_server_manager(port, host) webserver_url = server_manager.url # queue for publishfromapp tasks studio_task_queue = collections.deque() resource = RestApiResource(server_manager, upload_dir=upload_dir, executable=executable, studio_task_queue=studio_task_queue) projects_endpoint = ProjectsEndpoint(resource) server_manager.add_route( "GET", "/api/projects", projects_endpoint.dispatch ) hiearchy_endpoint = HiearchyEndpoint(resource) server_manager.add_route( "GET", "/api/hierarchy/{project_name}", hiearchy_endpoint.dispatch ) configured_ext_endpoint = ConfiguredExtensionsEndpoint(resource) server_manager.add_route( "GET", "/api/webpublish/configured_ext/{project_name}", configured_ext_endpoint.dispatch ) # triggers publish webpublisher_task_publish_endpoint = BatchPublishEndpoint(resource) server_manager.add_route( "POST", "/api/webpublish/batch", webpublisher_task_publish_endpoint.dispatch ) webpublisher_batch_publish_endpoint = TaskPublishEndpoint(resource) server_manager.add_route( "POST", "/api/webpublish/task", webpublisher_batch_publish_endpoint.dispatch ) # reporting webpublish_resource = WebpublishRestApiResource() batch_status_endpoint = BatchStatusEndpoint(webpublish_resource) server_manager.add_route( "GET", "/api/batch_status/{batch_id}", batch_status_endpoint.dispatch ) user_status_endpoint = UserReportEndpoint(webpublish_resource) server_manager.add_route( "GET", "/api/publishes/{user}", user_status_endpoint.dispatch ) batch_reprocess_endpoint = BatchReprocessEndpoint(webpublish_resource) server_manager.add_route( "POST", "/api/webpublish/reprocess/{batch_id}", batch_reprocess_endpoint.dispatch ) server_manager.start_server() last_reprocessed = time.time() while True: if time.time() - last_reprocessed > 20: reprocess_failed(upload_dir, webserver_url) last_reprocessed = time.time() if studio_task_queue: args = studio_task_queue.popleft() subprocess.call(args) # blocking call time.sleep(1.0) def reprocess_failed(upload_dir, webserver_url): # log.info("check_reprocesable_records") mongo_client = OpenPypeMongoConnection.get_mongo_client() database_name = os.environ["OPENPYPE_DATABASE_NAME"] dbcon = mongo_client[database_name]["webpublishes"] results = dbcon.find({"status": REPROCESS_STATUS}) reprocessed_batches = set() for batch in results: if batch["batch_id"] in reprocessed_batches: continue batch_url = os.path.join(upload_dir, batch["batch_id"], "manifest.json") log.info("batch:: {} {}".format(os.path.exists(batch_url), batch_url)) if not os.path.exists(batch_url): msg = "Manifest {} not found".format(batch_url) print(msg) dbcon.update_one( {"_id": batch["_id"]}, {"$set": { "finish_date": datetime.now(), "status": ERROR_STATUS, "progress": 100, "log": batch.get("log") + msg }} ) continue server_url = "{}/api/webpublish/batch".format(webserver_url) with open(batch_url) as f: data = json.loads(f.read()) dbcon.update_many( { "batch_id": batch["batch_id"], "status": {"$in": [ERROR_STATUS, REPROCESS_STATUS]} }, { "$set": { "finish_date": datetime.now(), "status": SENT_REPROCESSING_STATUS, "progress": 100 } } ) try: r = requests.post(server_url, json=data) log.info("response{}".format(r)) except Exception: log.info("exception", exc_info=True) reprocessed_batches.add(batch["batch_id"]) ================================================ FILE: openpype/lib/__init__.py ================================================ # -*- coding: utf-8 -*- # flake8: noqa E402 """OpenPype lib functions.""" # add vendor to sys path based on Python version import sys import os import site from openpype import PACKAGE_DIR # Add Python version specific vendor folder python_version_dir = os.path.join( PACKAGE_DIR, "vendor", "python", "python_{}".format(sys.version[0]) ) # Prepend path in sys paths sys.path.insert(0, python_version_dir) site.addsitedir(python_version_dir) from .events import ( emit_event, register_event_callback ) from .vendor_bin_utils import ( ToolNotFoundError, find_executable, get_vendor_bin_path, get_oiio_tools_path, get_oiio_tool_args, get_ffmpeg_tool_path, get_ffmpeg_tool_args, is_oiio_supported, ) from .attribute_definitions import ( AbstractAttrDef, UIDef, UISeparatorDef, UILabelDef, UnknownDef, NumberDef, TextDef, EnumDef, BoolDef, FileDef, FileDefItem, ) from .env_tools import ( env_value_to_bool, get_paths_from_environ, ) from .terminal import Terminal from .execute import ( get_ayon_launcher_args, get_openpype_execute_args, get_linux_launcher_args, execute, run_subprocess, run_detached_process, run_ayon_launcher_process, run_openpype_process, clean_envs_for_openpype_process, path_to_subprocess_arg, CREATE_NO_WINDOW ) from .log import ( Logger, ) from .path_templates import ( merge_dict, TemplateMissingKey, TemplateUnsolved, StringTemplate, TemplatesDict, FormatObject, ) from .dateutils import ( get_datetime_data, get_timestamp, get_formatted_current_time ) from .python_module_tools import ( import_filepath, modules_from_path, recursive_bases_from_class, classes_from_module, import_module_from_dirpath, is_func_signature_supported, ) from .profiles_filtering import ( compile_list_of_regexes, filter_profiles ) from .transcoding import ( get_transcode_temp_directory, should_convert_for_ffmpeg, convert_for_ffmpeg, convert_input_paths_for_ffmpeg, get_ffprobe_data, get_ffprobe_streams, get_ffmpeg_codec_args, get_ffmpeg_format_args, convert_ffprobe_fps_value, convert_ffprobe_fps_to_float, get_rescaled_command_arguments, ) from .local_settings import ( IniSettingRegistry, JSONSettingRegistry, OpenPypeSecureRegistry, OpenPypeSettingsRegistry, get_local_site_id, change_openpype_mongo_url, get_openpype_username, is_admin_password_required ) from .applications import ( ApplicationLaunchFailed, ApplictionExecutableNotFound, ApplicationNotFound, ApplicationManager, PreLaunchHook, PostLaunchHook, EnvironmentPrepData, prepare_app_environments, prepare_context_environments, get_app_environments_for_context, apply_project_environments_value ) from .plugin_tools import ( prepare_template_data, source_hash, ) from .path_tools import ( format_file_size, collect_frames, create_hard_link, version_up, get_version_from_path, get_last_version_from_path, ) from .openpype_version import ( op_version_control_available, get_openpype_version, get_build_version, get_expected_version, is_running_from_build, is_running_staging, is_current_version_studio_latest, is_current_version_higher_than_expected ) from .connections import ( requests_get, requests_post ) terminal = Terminal __all__ = [ "emit_event", "register_event_callback", "get_ayon_launcher_args", "get_openpype_execute_args", "get_linux_launcher_args", "execute", "run_subprocess", "run_detached_process", "run_ayon_launcher_process", "run_openpype_process", "clean_envs_for_openpype_process", "path_to_subprocess_arg", "CREATE_NO_WINDOW", "env_value_to_bool", "get_paths_from_environ", "ToolNotFoundError", "find_executable", "get_vendor_bin_path", "get_oiio_tools_path", "get_oiio_tool_args", "get_ffmpeg_tool_path", "get_ffmpeg_tool_args", "is_oiio_supported", "AbstractAttrDef", "UIDef", "UISeparatorDef", "UILabelDef", "UnknownDef", "NumberDef", "TextDef", "EnumDef", "BoolDef", "FileDef", "FileDefItem", "import_filepath", "modules_from_path", "recursive_bases_from_class", "classes_from_module", "import_module_from_dirpath", "is_func_signature_supported", "get_transcode_temp_directory", "should_convert_for_ffmpeg", "convert_for_ffmpeg", "convert_input_paths_for_ffmpeg", "get_ffprobe_data", "get_ffprobe_streams", "get_ffmpeg_codec_args", "get_ffmpeg_format_args", "convert_ffprobe_fps_value", "convert_ffprobe_fps_to_float", "get_rescaled_command_arguments", "IniSettingRegistry", "JSONSettingRegistry", "OpenPypeSecureRegistry", "OpenPypeSettingsRegistry", "get_local_site_id", "change_openpype_mongo_url", "get_openpype_username", "is_admin_password_required", "ApplicationLaunchFailed", "ApplictionExecutableNotFound", "ApplicationNotFound", "ApplicationManager", "PreLaunchHook", "PostLaunchHook", "EnvironmentPrepData", "prepare_app_environments", "prepare_context_environments", "get_app_environments_for_context", "apply_project_environments_value", "compile_list_of_regexes", "filter_profiles", "prepare_template_data", "source_hash", "format_file_size", "collect_frames", "create_hard_link", "version_up", "get_version_from_path", "get_last_version_from_path", "merge_dict", "TemplateMissingKey", "TemplateUnsolved", "StringTemplate", "TemplatesDict", "FormatObject", "terminal", "get_datetime_data", "get_formatted_current_time", "Logger", "op_version_control_available", "get_openpype_version", "get_build_version", "get_expected_version", "is_running_from_build", "is_running_staging", "is_current_version_studio_latest", "requests_get", "requests_post" ] ================================================ FILE: openpype/lib/applications.py ================================================ import os import sys import copy import json import tempfile import platform import collections import inspect import subprocess from abc import ABCMeta, abstractmethod import six from openpype import AYON_SERVER_ENABLED, PACKAGE_DIR from openpype.client import get_asset_name_identifier from openpype.settings import ( get_system_settings, get_project_settings, get_local_settings ) from openpype.settings.constants import ( METADATA_KEYS, M_DYNAMIC_KEY_LABEL ) from .log import Logger from .profiles_filtering import filter_profiles from .local_settings import get_openpype_username from .python_module_tools import ( modules_from_path, classes_from_module ) from .execute import ( find_executable, get_linux_launcher_args ) _logger = None PLATFORM_NAMES = {"windows", "linux", "darwin"} DEFAULT_ENV_SUBGROUP = "standard" CUSTOM_LAUNCH_APP_GROUPS = { "djvview" } class LaunchTypes: """Launch types are filters for pre/post-launch hooks. Please use these variables in case they'll change values. """ # Local launch - application is launched on local machine local = "local" # Farm render job - application is on farm farm_render = "farm-render" # Farm publish job - integration post-render job farm_publish = "farm-publish" # Remote launch - application is launched on remote machine from which # can be started publishing remote = "remote" # Automated launch - application is launched with automated publishing automated = "automated" def parse_environments(env_data, env_group=None, platform_name=None): """Parse environment values from settings byt group and platform. Data may contain up to 2 hierarchical levels of dictionaries. At the end of the last level must be string or list. List is joined using platform specific joiner (';' for windows and ':' for linux and mac). Hierarchical levels can contain keys for subgroups and platform name. Platform specific values must be always last level of dictionary. Platform names are "windows" (MS Windows), "linux" (any linux distribution) and "darwin" (any MacOS distribution). Subgroups are helpers added mainly for standard and on farm usage. Farm may require different environments for e.g. licence related values or plugins. Default subgroup is "standard". Examples: ``` { # Unchanged value "ENV_KEY1": "value", # Empty values are kept (unset environment variable) "ENV_KEY2": "", # Join list values with ':' or ';' "ENV_KEY3": ["value1", "value2"], # Environment groups "ENV_KEY4": { "standard": "DEMO_SERVER_URL", "farm": "LICENCE_SERVER_URL" }, # Platform specific (and only for windows and mac) "ENV_KEY5": { "windows": "windows value", "darwin": ["value 1", "value 2"] }, # Environment groups and platform combination "ENV_KEY6": { "farm": "FARM_VALUE", "standard": { "windows": ["value1", "value2"], "linux": "value1", "darwin": "" } } } ``` """ output = {} if not env_data: return output if not env_group: env_group = DEFAULT_ENV_SUBGROUP if not platform_name: platform_name = platform.system().lower() for key, value in env_data.items(): if isinstance(value, dict): # Look if any key is platform key # - expect that represents environment group if does not contain # platform keys if not PLATFORM_NAMES.intersection(set(value.keys())): # Skip the key if group is not available if env_group not in value: continue value = value[env_group] # Check again if value is dictionary # - this time there should be only platform keys if isinstance(value, dict): value = value.get(platform_name) # Check if value is list and join it's values # QUESTION Should empty values be skipped? if isinstance(value, (list, tuple)): value = os.pathsep.join(value) # Set key to output if value is string if isinstance(value, six.string_types): output[key] = value return output def get_logger(): """Global lib.applications logger getter.""" global _logger if _logger is None: _logger = Logger.get_logger(__name__) return _logger class ApplicationNotFound(Exception): """Application was not found in ApplicationManager by name.""" def __init__(self, app_name): self.app_name = app_name super(ApplicationNotFound, self).__init__( "Application \"{}\" was not found.".format(app_name) ) class ApplictionExecutableNotFound(Exception): """Defined executable paths are not available on the machine.""" def __init__(self, application): self.application = application details = None if not application.executables: msg = ( "Executable paths for application \"{}\"({}) are not set." ) else: msg = ( "Defined executable paths for application \"{}\"({})" " are not available on this machine." ) details = "Defined paths:" for executable in application.executables: details += "\n- " + executable.executable_path self.msg = msg.format(application.full_label, application.full_name) self.details = details exc_mgs = str(self.msg) if details: # Is good idea to pass new line symbol to exception message? exc_mgs += "\n" + details self.exc_msg = exc_mgs super(ApplictionExecutableNotFound, self).__init__(exc_mgs) class ApplicationLaunchFailed(Exception): """Application launch failed due to known reason. Message should be self explanatory as traceback won't be shown. """ pass class ApplicationGroup: """Hold information about application group. Application group wraps different versions(variants) of application. e.g. "maya" is group and "maya_2020" is variant. Group hold `host_name` which is implementation name used in pype. Also holds `enabled` if whole app group is enabled or `icon` for application icon path in resources. Group has also `environment` which hold same environments for all variants. Args: name (str): Groups' name. data (dict): Group defying data loaded from settings. manager (ApplicationManager): Manager that created the group. """ def __init__(self, name, data, manager): self.name = name self.manager = manager self._data = data self.enabled = data.get("enabled", True) self.label = data.get("label") or None self.icon = data.get("icon") or None self._environment = data.get("environment") or {} host_name = data.get("host_name", None) self.is_host = host_name is not None self.host_name = host_name variants = data.get("variants") or {} key_label_mapping = variants.pop(M_DYNAMIC_KEY_LABEL, {}) for variant_name, variant_data in variants.items(): if variant_name in METADATA_KEYS: continue if "variant_label" not in variant_data: variant_label = key_label_mapping.get(variant_name) if variant_label: variant_data["variant_label"] = variant_label variants[variant_name] = Application( variant_name, variant_data, self ) self.variants = variants def __repr__(self): return "<{}> - {}".format(self.__class__.__name__, self.name) def __iter__(self): for variant in self.variants.values(): yield variant @property def environment(self): return copy.deepcopy(self._environment) class Application: """Hold information about application. Object by itself does nothing special. Args: name (str): Specific version (or variant) of application. e.g. "maya2020", "nuke11.3", etc. data (dict): Data for the version containing information about executables, variant label or if is enabled. Only required key is `executables`. group (ApplicationGroup): App group object that created the application and under which application belongs. """ def __init__(self, name, data, group): self.name = name self.group = group self._data = data enabled = False if group.enabled: enabled = data.get("enabled", True) self.enabled = enabled self.use_python_2 = data.get("use_python_2", False) self.label = data.get("variant_label") or name self.full_name = "/".join((group.name, name)) if group.label: full_label = " ".join((group.label, self.label)) else: full_label = self.label self.full_label = full_label self._environment = data.get("environment") or {} arguments = data.get("arguments") if isinstance(arguments, dict): arguments = arguments.get(platform.system().lower()) if not arguments: arguments = [] self.arguments = arguments if "executables" not in data: self.executables = [ UndefinedApplicationExecutable() ] return _executables = data["executables"] if isinstance(_executables, dict): _executables = _executables.get(platform.system().lower()) if not _executables: _executables = [] executables = [] for executable in _executables: executables.append(ApplicationExecutable(executable)) self.executables = executables def __repr__(self): return "<{}> - {}".format(self.__class__.__name__, self.full_name) @property def environment(self): return copy.deepcopy(self._environment) @property def manager(self): return self.group.manager @property def host_name(self): return self.group.host_name @property def icon(self): return self.group.icon @property def is_host(self): return self.group.is_host def find_executable(self): """Try to find existing executable for application. Returns (str): Path to executable from `executables` or None if any exists. """ for executable in self.executables: if executable.exists(): return executable return None def launch(self, *args, **kwargs): """Launch the application. For this purpose is used manager's launch method to keep logic at one place. Arguments must match with manager's launch method. That's why *args **kwargs are used. Returns: subprocess.Popen: Return executed process as Popen object. """ return self.manager.launch(self.full_name, *args, **kwargs) class ApplicationManager: """Load applications and tools and store them by their full name. Args: system_settings (dict): Preloaded system settings. When passed manager will always use these values. Gives ability to create manager using different settings. """ def __init__(self, system_settings=None): self.log = Logger.get_logger(self.__class__.__name__) self.app_groups = {} self.applications = {} self.tool_groups = {} self.tools = {} self._system_settings = system_settings self.refresh() def set_system_settings(self, system_settings): """Ability to change init system settings. This will trigger refresh of manager. """ self._system_settings = system_settings self.refresh() def refresh(self): """Refresh applications from settings.""" self.app_groups.clear() self.applications.clear() self.tool_groups.clear() self.tools.clear() if self._system_settings is not None: settings = copy.deepcopy(self._system_settings) else: settings = get_system_settings( clear_metadata=False, exclude_locals=False ) all_app_defs = {} # Prepare known applications app_defs = settings["applications"] additional_apps = {} for group_name, variant_defs in app_defs.items(): if group_name in METADATA_KEYS: continue if group_name == "additional_apps": additional_apps = variant_defs else: all_app_defs[group_name] = variant_defs # Prepare additional applications # - First find dynamic keys that can be used as labels of group dynamic_keys = {} for group_name, variant_defs in additional_apps.items(): if group_name == M_DYNAMIC_KEY_LABEL: dynamic_keys = variant_defs break # Add additional apps to known applications for group_name, variant_defs in additional_apps.items(): if group_name in METADATA_KEYS: continue # Determine group label label = variant_defs.get("label") if not label: # Look for label set in dynamic labels label = dynamic_keys.get(group_name) if not label: label = group_name variant_defs["label"] = label all_app_defs[group_name] = variant_defs for group_name, variant_defs in all_app_defs.items(): if group_name in METADATA_KEYS: continue group = ApplicationGroup(group_name, variant_defs, self) self.app_groups[group_name] = group for app in group: self.applications[app.full_name] = app tools_definitions = settings["tools"]["tool_groups"] tool_label_mapping = tools_definitions.pop(M_DYNAMIC_KEY_LABEL, {}) for tool_group_name, tool_group_data in tools_definitions.items(): if not tool_group_name or tool_group_name in METADATA_KEYS: continue tool_group_label = ( tool_label_mapping.get(tool_group_name) or tool_group_name ) group = EnvironmentToolGroup( tool_group_name, tool_group_label, tool_group_data, self ) self.tool_groups[tool_group_name] = group for tool in group: self.tools[tool.full_name] = tool def find_latest_available_variant_for_group(self, group_name): group = self.app_groups.get(group_name) if group is None or not group.enabled: return None output = None for _, variant in reversed(sorted(group.variants.items())): executable = variant.find_executable() if executable: output = variant break return output def create_launch_context(self, app_name, **data): """Prepare launch context for application. Args: app_name (str): Name of application that should be launched. **data (Any): Any additional data. Data may be used during Returns: ApplicationLaunchContext: Launch context for application. Raises: ApplicationNotFound: Application was not found by entered name. """ app = self.applications.get(app_name) if not app: raise ApplicationNotFound(app_name) executable = app.find_executable() return ApplicationLaunchContext( app, executable, **data ) def launch_with_context(self, launch_context): """Launch application using existing launch context. Args: launch_context (ApplicationLaunchContext): Prepared launch context. """ if not launch_context.executable: raise ApplictionExecutableNotFound(launch_context.application) return launch_context.launch() def launch(self, app_name, **data): """Launch procedure. For host application it's expected to contain "project_name", "asset_name" and "task_name". Args: app_name (str): Name of application that should be launched. **data (dict): Any additional data. Data may be used during preparation to store objects usable in multiple places. Raises: ApplicationNotFound: Application was not found by entered argument `app_name`. ApplictionExecutableNotFound: Executables in application definition were not found on this machine. ApplicationLaunchFailed: Something important for application launch failed. Exception should contain explanation message, traceback should not be needed. """ context = self.create_launch_context(app_name, **data) return self.launch_with_context(context) class EnvironmentToolGroup: """Hold information about environment tool group. Environment tool group may hold different variants of same tool and set environments that are same for all of them. e.g. "mtoa" may have different versions but all environments except one are same. Args: name (str): Name of the tool group. data (dict): Group's information with it's variants. manager (ApplicationManager): Manager that creates the group. """ def __init__(self, name, label, data, manager): self.name = name self.label = label self._data = data self.manager = manager self._environment = data["environment"] variants = data.get("variants") or {} label_by_key = variants.pop(M_DYNAMIC_KEY_LABEL, {}) variants_by_name = {} for variant_name, variant_data in variants.items(): if variant_name in METADATA_KEYS: continue variant_label = label_by_key.get(variant_name) or variant_name tool = EnvironmentTool( variant_name, variant_label, variant_data, self ) variants_by_name[variant_name] = tool self.variants = variants_by_name def __repr__(self): return "<{}> - {}".format(self.__class__.__name__, self.name) def __iter__(self): for variant in self.variants.values(): yield variant @property def environment(self): return copy.deepcopy(self._environment) class EnvironmentTool: """Hold information about application tool. Structure of tool information. Args: name (str): Name of the tool. variant_data (dict): Variant data with environments and host and app variant filters. group (str): Name of group which wraps tool. """ def __init__(self, name, label, variant_data, group): # Backwards compatibility 3.9.1 - 3.9.2 # - 'variant_data' contained only environments but contain also host # and application variant filters host_names = variant_data.get("host_names", []) app_variants = variant_data.get("app_variants", []) if "environment" in variant_data: environment = variant_data["environment"] else: environment = variant_data self.host_names = host_names self.app_variants = app_variants self.name = name self.variant_label = label self.label = " ".join((group.label, label)) self.group = group self._environment = environment self.full_name = "/".join((group.name, name)) def __repr__(self): return "<{}> - {}".format(self.__class__.__name__, self.full_name) @property def environment(self): return copy.deepcopy(self._environment) def is_valid_for_app(self, app): """Is tool valid for application. Args: app (Application): Application for which are prepared environments. """ if self.app_variants and app.full_name not in self.app_variants: return False if self.host_names and app.host_name not in self.host_names: return False return True class ApplicationExecutable: """Representation of executable loaded from settings.""" def __init__(self, executable): # Try to format executable with environments try: executable = executable.format(**os.environ) except Exception: pass # On MacOS check if exists path to executable when ends with `.app` # - it is common that path will lead to "/Applications/Blender" but # real path is "/Applications/Blender.app" if platform.system().lower() == "darwin": executable = self.macos_executable_prep(executable) self.executable_path = executable def __str__(self): return self.executable_path def __repr__(self): return "<{}> {}".format(self.__class__.__name__, self.executable_path) @staticmethod def macos_executable_prep(executable): """Try to find full path to executable file. Real executable is stored in '*.app/Contents/MacOS/'. Having path to '*.app' gives ability to read it's plist info and use "CFBundleExecutable" key from plist to know what is "executable." Plist is stored in '*.app/Contents/Info.plist'. This is because some '*.app' directories don't have same permissions as real executable. """ # Try to find if there is `.app` file if not os.path.exists(executable): _executable = executable + ".app" if os.path.exists(_executable): executable = _executable # Try to find real executable if executable has `Contents` subfolder contents_dir = os.path.join(executable, "Contents") if os.path.exists(contents_dir): executable_filename = None # Load plist file and check for bundle executable plist_filepath = os.path.join(contents_dir, "Info.plist") if os.path.exists(plist_filepath): import plistlib if hasattr(plistlib, "load"): with open(plist_filepath, "rb") as stream: parsed_plist = plistlib.load(stream) else: parsed_plist = plistlib.readPlist(plist_filepath) executable_filename = parsed_plist.get("CFBundleExecutable") if executable_filename: executable = os.path.join( contents_dir, "MacOS", executable_filename ) return executable def as_args(self): return [self.executable_path] def _realpath(self): """Check if path is valid executable path.""" # Check for executable in PATH result = find_executable(self.executable_path) if result is not None: return result # This is not 100% validation but it is better than remove ability to # launch .bat, .sh or extentionless files if os.path.exists(self.executable_path): return self.executable_path return None def exists(self): if not self.executable_path: return False return bool(self._realpath()) class UndefinedApplicationExecutable(ApplicationExecutable): """Some applications do not require executable path from settings. In that case this class is used to "fake" existing executable. """ def __init__(self): pass def __str__(self): return self.__class__.__name__ def __repr__(self): return "<{}>".format(self.__class__.__name__) def as_args(self): return [] def exists(self): return True @six.add_metaclass(ABCMeta) class LaunchHook: """Abstract base class of launch hook.""" # Order of prelaunch hook, will be executed as last if set to None. order = None # List of host implementations, skipped if empty. hosts = set() # Set of application groups app_groups = set() # Set of specific application names app_names = set() # Set of platform availability platforms = set() # Set of launch types for which is available # - if empty then is available for all launch types # - by default has 'local' which is most common reason for launc hooks launch_types = {LaunchTypes.local} def __init__(self, launch_context): """Constructor of launch hook. Always should be called """ self.log = Logger.get_logger(self.__class__.__name__) self.launch_context = launch_context is_valid = self.class_validation(launch_context) if is_valid: is_valid = self.validate() self.is_valid = is_valid @classmethod def class_validation(cls, launch_context): """Validation of class attributes by launch context. Args: launch_context (ApplicationLaunchContext): Context of launching application. Returns: bool: Is launch hook valid for the context by class attributes. """ if cls.platforms: low_platforms = tuple( _platform.lower() for _platform in cls.platforms ) if platform.system().lower() not in low_platforms: return False if cls.hosts: if launch_context.host_name not in cls.hosts: return False if cls.app_groups: if launch_context.app_group.name not in cls.app_groups: return False if cls.app_names: if launch_context.app_name not in cls.app_names: return False if cls.launch_types: if launch_context.launch_type not in cls.launch_types: return False return True @property def data(self): return self.launch_context.data @property def application(self): return getattr(self.launch_context, "application", None) @property def manager(self): return getattr(self.application, "manager", None) @property def host_name(self): return getattr(self.application, "host_name", None) @property def app_group(self): return getattr(self.application, "group", None) @property def app_name(self): return getattr(self.application, "full_name", None) @property def modules_manager(self): return getattr(self.launch_context, "modules_manager", None) def validate(self): """Optional validation of launch hook on initialization. Returns: bool: Hook is valid (True) or invalid (False). """ # QUESTION Not sure if this method has any usable potential. # - maybe result can be based on settings return True @abstractmethod def execute(self, *args, **kwargs): """Abstract execute method where logic of hook is.""" pass class PreLaunchHook(LaunchHook): """Abstract class of prelaunch hook. This launch hook will be processed before application is launched. If any exception will happen during processing the application won't be launched. """ class PostLaunchHook(LaunchHook): """Abstract class of postlaunch hook. This launch hook will be processed after application is launched. Nothing will happen if any exception will happen during processing. And processing of other postlaunch hooks won't stop either. """ class ApplicationLaunchContext: """Context of launching application. Main purpose of context is to prepare launch arguments and keyword arguments for new process. Most important part of keyword arguments preparations are environment variables. During the whole process is possible to use `data` attribute to store object usable in multiple places. Launch arguments are strings in list. It is possible to "chain" argument when order of them matters. That is possible to do with adding list where order is right and should not change. NOTE: This is recommendation, not requirement. e.g.: `["nuke.exe", "--NukeX"]` -> In this case any part of process may insert argument between `nuke.exe` and `--NukeX`. To keep them together it is better to wrap them in another list: `[["nuke.exe", "--NukeX"]]`. Notes: It is possible to use launch context only to prepare environment variables. In that case `executable` may be None and can be used 'run_prelaunch_hooks' method to run prelaunch hooks which prepare them. Args: application (Application): Application definition. executable (ApplicationExecutable): Object with path to executable. env_group (Optional[str]): Environment variable group. If not set 'DEFAULT_ENV_SUBGROUP' is used. launch_type (Optional[str]): Launch type. If not set 'local' is used. **data (dict): Any additional data. Data may be used during preparation to store objects usable in multiple places. """ def __init__( self, application, executable, env_group=None, launch_type=None, **data ): from openpype.modules import ModulesManager # Application object self.application = application self.modules_manager = ModulesManager() # Logger logger_name = "{}-{}".format(self.__class__.__name__, self.application.full_name) self.log = Logger.get_logger(logger_name) self.executable = executable if launch_type is None: launch_type = LaunchTypes.local self.launch_type = launch_type if env_group is None: env_group = DEFAULT_ENV_SUBGROUP self.env_group = env_group self.data = dict(data) launch_args = [] if executable is not None: launch_args = executable.as_args() # subprocess.Popen launch arguments (first argument in constructor) self.launch_args = launch_args self.launch_args.extend(application.arguments) if self.data.get("app_args"): self.launch_args.extend(self.data.pop("app_args")) # Handle launch environemtns src_env = self.data.pop("env", None) if src_env is not None and not isinstance(src_env, dict): self.log.warning(( "Passed `env` kwarg has invalid type: {}. Expected: `dict`." " Using `os.environ` instead." ).format(str(type(src_env)))) src_env = None if src_env is None: src_env = os.environ ignored_env = {"QT_API", } env = { key: str(value) for key, value in src_env.items() if key not in ignored_env } # subprocess.Popen keyword arguments self.kwargs = {"env": env} if platform.system().lower() == "windows": # Detach new process from currently running process on Windows flags = ( subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS ) self.kwargs["creationflags"] = flags if not sys.stdout: self.kwargs["stdout"] = subprocess.DEVNULL self.kwargs["stderr"] = subprocess.DEVNULL self.prelaunch_hooks = None self.postlaunch_hooks = None self.process = None self._prelaunch_hooks_executed = False @property def env(self): if ( "env" not in self.kwargs or self.kwargs["env"] is None ): self.kwargs["env"] = {} return self.kwargs["env"] @env.setter def env(self, value): if not isinstance(value, dict): raise ValueError( "'env' attribute expect 'dict' object. Got: {}".format( str(type(value)) ) ) self.kwargs["env"] = value def _collect_addons_launch_hook_paths(self): """Helper to collect application launch hooks from addons. Module have to have implemented 'get_launch_hook_paths' method which can expect application as argument or nothing. Returns: List[str]: Paths to launch hook directories. """ expected_types = (list, tuple, set) output = [] for module in self.modules_manager.get_enabled_modules(): # Skip module if does not have implemented 'get_launch_hook_paths' func = getattr(module, "get_launch_hook_paths", None) if func is None: continue func = module.get_launch_hook_paths if hasattr(inspect, "signature"): sig = inspect.signature(func) expect_args = len(sig.parameters) > 0 else: expect_args = len(inspect.getargspec(func)[0]) > 0 # Pass application argument if method expect it. try: if expect_args: hook_paths = func(self.application) else: hook_paths = func() except Exception: self.log.warning( "Failed to call 'get_launch_hook_paths'", exc_info=True ) continue if not hook_paths: continue # Convert string to list if isinstance(hook_paths, six.string_types): hook_paths = [hook_paths] # Skip invalid types if not isinstance(hook_paths, expected_types): self.log.warning(( "Result of `get_launch_hook_paths`" " has invalid type {}. Expected {}" ).format(type(hook_paths), expected_types)) continue output.extend(hook_paths) return output def paths_to_launch_hooks(self): """Directory paths where to look for launch hooks.""" # This method has potential to be part of application manager (maybe). paths = [] # TODO load additional studio paths from settings import openpype openpype_dir = os.path.dirname(os.path.abspath(openpype.__file__)) global_hooks_dir = os.path.join(openpype_dir, "hooks") hooks_dirs = [ global_hooks_dir ] if self.host_name: # If host requires launch hooks and is module then launch hooks # should be collected using 'collect_launch_hook_paths' # - module have to implement 'get_launch_hook_paths' host_module = self.modules_manager.get_host_module(self.host_name) if not host_module: hooks_dirs.append(os.path.join( openpype_dir, "hosts", self.host_name, "hooks" )) for path in hooks_dirs: if ( os.path.exists(path) and os.path.isdir(path) and path not in paths ): paths.append(path) # Load modules paths paths.extend(self._collect_addons_launch_hook_paths()) return paths def discover_launch_hooks(self, force=False): """Load and prepare launch hooks.""" if ( self.prelaunch_hooks is not None or self.postlaunch_hooks is not None ): if not force: self.log.info("Launch hooks were already discovered.") return self.prelaunch_hooks.clear() self.postlaunch_hooks.clear() self.log.debug("Discovery of launch hooks started.") paths = self.paths_to_launch_hooks() self.log.debug("Paths searched for launch hooks:\n{}".format( "\n".join("- {}".format(path) for path in paths) )) all_classes = { "pre": [], "post": [] } for path in paths: if not os.path.exists(path): self.log.info( "Path to launch hooks does not exist: \"{}\"".format(path) ) continue modules, _crashed = modules_from_path(path) for _filepath, module in modules: all_classes["pre"].extend( classes_from_module(PreLaunchHook, module) ) all_classes["post"].extend( classes_from_module(PostLaunchHook, module) ) for launch_type, classes in all_classes.items(): hooks_with_order = [] hooks_without_order = [] for klass in classes: try: hook = klass(self) if not hook.is_valid: self.log.debug( "Skipped hook invalid for current launch context: " "{}".format(klass.__name__) ) continue if inspect.isabstract(hook): self.log.debug("Skipped abstract hook: {}".format( klass.__name__ )) continue # Separate hooks by pre/post class if hook.order is None: hooks_without_order.append(hook) else: hooks_with_order.append(hook) except Exception: self.log.warning( "Initialization of hook failed: " "{}".format(klass.__name__), exc_info=True ) # Sort hooks with order by order ordered_hooks = list(sorted( hooks_with_order, key=lambda obj: obj.order )) # Extend ordered hooks with hooks without defined order ordered_hooks.extend(hooks_without_order) if launch_type == "pre": self.prelaunch_hooks = ordered_hooks else: self.postlaunch_hooks = ordered_hooks self.log.debug("Found {} prelaunch and {} postlaunch hooks.".format( len(self.prelaunch_hooks), len(self.postlaunch_hooks) )) @property def app_name(self): return self.application.name @property def host_name(self): return self.application.host_name @property def app_group(self): return self.application.group @property def manager(self): return self.application.manager def _run_process(self): # Windows and MacOS have easier process start low_platform = platform.system().lower() if low_platform in ("windows", "darwin"): return subprocess.Popen(self.launch_args, **self.kwargs) # Linux uses mid process # - it is possible that the mid process executable is not # available for this version of OpenPype in that case use standard # launch launch_args = get_linux_launcher_args() if launch_args is None: return subprocess.Popen(self.launch_args, **self.kwargs) # Prepare data that will be passed to midprocess # - store arguments to a json and pass path to json as last argument # - pass environments to set app_env = self.kwargs.pop("env", {}) json_data = { "args": self.launch_args, "env": app_env } if app_env: # Filter environments of subprocess self.kwargs["env"] = { key: value for key, value in os.environ.items() if key in app_env } # Create temp file json_temp = tempfile.NamedTemporaryFile( mode="w", prefix="op_app_args", suffix=".json", delete=False ) json_temp.close() json_temp_filpath = json_temp.name with open(json_temp_filpath, "w") as stream: json.dump(json_data, stream) launch_args.append(json_temp_filpath) # Create mid-process which will launch application process = subprocess.Popen(launch_args, **self.kwargs) # Wait until the process finishes # - This is important! The process would stay in "open" state. process.wait() # Remove the temp file os.remove(json_temp_filpath) # Return process which is already terminated return process def run_prelaunch_hooks(self): """Run prelaunch hooks. This method will be executed only once, any future calls will skip the processing. """ if self._prelaunch_hooks_executed: self.log.warning("Prelaunch hooks were already executed.") return # Discover launch hooks self.discover_launch_hooks() # Execute prelaunch hooks for prelaunch_hook in self.prelaunch_hooks: self.log.debug("Executing prelaunch hook: {}".format( str(prelaunch_hook.__class__.__name__) )) prelaunch_hook.execute() self._prelaunch_hooks_executed = True def launch(self): """Collect data for new process and then create it. This method must not be executed more than once. Returns: subprocess.Popen: Created process as Popen object. """ if self.process is not None: self.log.warning("Application was already launched.") return if not self._prelaunch_hooks_executed: self.run_prelaunch_hooks() self.log.debug("All prelaunch hook executed. Starting new process.") # Prepare subprocess args args_len_str = "" if isinstance(self.launch_args, str): args = self.launch_args else: args = self.clear_launch_args(self.launch_args) args_len_str = " ({})".format(len(args)) self.log.info( "Launching \"{}\" with args{}: {}".format( self.application.full_name, args_len_str, args ) ) self.launch_args = args # Run process self.process = self._run_process() # Process post launch hooks for postlaunch_hook in self.postlaunch_hooks: self.log.debug("Executing postlaunch hook: {}".format( str(postlaunch_hook.__class__.__name__) )) # TODO how to handle errors? # - store to variable to let them accessible? try: postlaunch_hook.execute() except Exception: self.log.warning( "After launch procedures were not successful.", exc_info=True ) self.log.debug("Launch of {} finished.".format( self.application.full_name )) return self.process @staticmethod def clear_launch_args(args): """Collect launch arguments to final order. Launch argument should be list that may contain another lists this function will upack inner lists and keep ordering. ``` # source [ [ arg1, [ arg2, arg3 ] ], arg4, [arg5, arg6]] # result [ arg1, arg2, arg3, arg4, arg5, arg6] Args: args (list): Source arguments in list may contain inner lists. Return: list: Unpacked arguments. """ if isinstance(args, str): return args all_cleared = False while not all_cleared: all_cleared = True new_args = [] for arg in args: if isinstance(arg, (list, tuple, set)): all_cleared = False for _arg in arg: new_args.append(_arg) else: new_args.append(arg) args = new_args return args class MissingRequiredKey(KeyError): pass class EnvironmentPrepData(dict): """Helper dictionary for storin temp data during environment prep. Args: data (dict): Data must contain required keys. """ required_keys = ( "project_doc", "asset_doc", "task_name", "app", "anatomy" ) def __init__(self, data): for key in self.required_keys: if key not in data: raise MissingRequiredKey(key) if not data.get("log"): data["log"] = get_logger() if data.get("env") is None: data["env"] = os.environ.copy() if "system_settings" not in data: data["system_settings"] = get_system_settings() super(EnvironmentPrepData, self).__init__(data) def get_app_environments_for_context( project_name, asset_name, task_name, app_name, env_group=None, launch_type=None, env=None, modules_manager=None ): """Prepare environment variables by context. Args: project_name (str): Name of project. asset_name (str): Name of asset. task_name (str): Name of task. app_name (str): Name of application that is launched and can be found by ApplicationManager. env_group (Optional[str]): Name of environment group. If not passed default group is used. launch_type (Optional[str]): Type for which prelaunch hooks are executed. env (Optional[dict[str, str]]): Initial environment variables. `os.environ` is used when not passed. modules_manager (Optional[ModulesManager]): Initialized modules manager. Returns: dict: Environments for passed context and application. """ # Prepare app object which can be obtained only from ApplicationManager app_manager = ApplicationManager() context = app_manager.create_launch_context( app_name, project_name=project_name, asset_name=asset_name, task_name=task_name, env_group=env_group, launch_type=launch_type, env=env, modules_manager=modules_manager, ) context.run_prelaunch_hooks() return context.env def _merge_env(env, current_env): """Modified function(merge) from acre module.""" import acre result = current_env.copy() for key, value in env.items(): # Keep missing keys by not filling `missing` kwarg value = acre.lib.partial_format(value, data=current_env) result[key] = value return result def _add_python_version_paths(app, env, logger, modules_manager): """Add vendor packages specific for a Python version.""" for module in modules_manager.get_enabled_modules(): module.modify_application_launch_arguments(app, env) # Skip adding if host name is not set if not app.host_name: return # Add Python 2/3 modules python_vendor_dir = os.path.join( PACKAGE_DIR, "vendor", "python" ) if app.use_python_2: pythonpath = os.path.join(python_vendor_dir, "python_2") else: pythonpath = os.path.join(python_vendor_dir, "python_3") if not os.path.exists(pythonpath): return logger.debug("Adding Python version specific paths to PYTHONPATH") python_paths = [pythonpath] # Load PYTHONPATH from current launch context python_path = env.get("PYTHONPATH") if python_path: python_paths.append(python_path) # Set new PYTHONPATH to launch context environments env["PYTHONPATH"] = os.pathsep.join(python_paths) def prepare_app_environments( data, env_group=None, implementation_envs=True, modules_manager=None ): """Modify launch environments based on launched app and context. Args: data (EnvironmentPrepData): Dictionary where result and intermediate result will be stored. """ import acre app = data["app"] log = data["log"] source_env = data["env"].copy() if modules_manager is None: from openpype.modules import ModulesManager modules_manager = ModulesManager() _add_python_version_paths(app, source_env, log, modules_manager) # Use environments from local settings filtered_local_envs = {} system_settings = data["system_settings"] whitelist_envs = system_settings["general"].get("local_env_white_list") if whitelist_envs: local_settings = get_local_settings() local_envs = local_settings.get("environments") or {} filtered_local_envs = { key: value for key, value in local_envs.items() if key in whitelist_envs } # Apply local environment variables for already existing values for key, value in filtered_local_envs.items(): if key in source_env: source_env[key] = value # `app_and_tool_labels` has debug purpose app_and_tool_labels = [app.full_name] # Environments for application environments = [ app.group.environment, app.environment ] asset_doc = data.get("asset_doc") # Add tools environments groups_by_name = {} tool_by_group_name = collections.defaultdict(dict) if asset_doc: # Make sure each tool group can be added only once for key in asset_doc["data"].get("tools_env") or []: tool = app.manager.tools.get(key) if not tool or not tool.is_valid_for_app(app): continue groups_by_name[tool.group.name] = tool.group tool_by_group_name[tool.group.name][tool.name] = tool for group_name in sorted(groups_by_name.keys()): group = groups_by_name[group_name] environments.append(group.environment) for tool_name in sorted(tool_by_group_name[group_name].keys()): tool = tool_by_group_name[group_name][tool_name] environments.append(tool.environment) app_and_tool_labels.append(tool.full_name) log.debug( "Will add environments for apps and tools: {}".format( ", ".join(app_and_tool_labels) ) ) env_values = {} for _env_values in environments: if not _env_values: continue # Choose right platform tool_env = parse_environments(_env_values, env_group) # Apply local environment variables # - must happen between all values because they may be used during # merge for key, value in filtered_local_envs.items(): if key in tool_env: tool_env[key] = value # Merge dictionaries env_values = _merge_env(tool_env, env_values) merged_env = _merge_env(env_values, source_env) loaded_env = acre.compute(merged_env, cleanup=False) final_env = None # Add host specific environments if app.host_name and implementation_envs: host_module = modules_manager.get_host_module(app.host_name) if not host_module: module = __import__("openpype.hosts", fromlist=[app.host_name]) host_module = getattr(module, app.host_name, None) add_implementation_envs = None if host_module: add_implementation_envs = getattr( host_module, "add_implementation_envs", None ) if add_implementation_envs: # Function may only modify passed dict without returning value final_env = add_implementation_envs(loaded_env, app) if final_env is None: final_env = loaded_env keys_to_remove = set(source_env.keys()) - set(final_env.keys()) # Update env data["env"].update(final_env) for key in keys_to_remove: data["env"].pop(key, None) def apply_project_environments_value( project_name, env, project_settings=None, env_group=None ): """Apply project specific environments on passed environments. The environments are applied on passed `env` argument value so it is not required to apply changes back. Args: project_name (str): Name of project for which environments should be received. env (dict): Environment values on which project specific environments will be applied. project_settings (dict): Project settings for passed project name. Optional if project settings are already prepared. Returns: dict: Passed env values with applied project environments. Raises: KeyError: If project settings do not contain keys for project specific environments. """ import acre if project_settings is None: project_settings = get_project_settings(project_name) env_value = project_settings["global"]["project_environments"] if env_value: parsed_value = parse_environments(env_value, env_group) env.update(acre.compute( _merge_env(parsed_value, env), cleanup=False )) return env def prepare_context_environments(data, env_group=None, modules_manager=None): """Modify launch environments with context data for launched host. Args: data (EnvironmentPrepData): Dictionary where result and intermediate result will be stored. """ from openpype.pipeline.template_data import get_template_data # Context environments log = data["log"] project_doc = data["project_doc"] asset_doc = data["asset_doc"] task_name = data["task_name"] if not project_doc: log.info( "Skipping context environments preparation." " Launch context does not contain required data." ) return # Load project specific environments project_name = project_doc["name"] project_settings = get_project_settings(project_name) system_settings = get_system_settings() data["project_settings"] = project_settings data["system_settings"] = system_settings app = data["app"] context_env = { "AVALON_PROJECT": project_doc["name"], "AVALON_APP_NAME": app.full_name } if asset_doc: asset_name = get_asset_name_identifier(asset_doc) context_env["AVALON_ASSET"] = asset_name if task_name: context_env["AVALON_TASK"] = task_name log.debug( "Context environments set:\n{}".format( json.dumps(context_env, indent=4) ) ) data["env"].update(context_env) # Apply project specific environments on current env value # - apply them once the context environments are set apply_project_environments_value( project_name, data["env"], project_settings, env_group ) if not app.is_host: return data["env"]["AVALON_APP"] = app.host_name if not asset_doc or not task_name: # QUESTION replace with log.info and skip workfile discovery? # - technically it should be possible to launch host without context raise ApplicationLaunchFailed( "Host launch require asset and task context." ) workdir_data = get_template_data( project_doc, asset_doc, task_name, app.host_name, system_settings ) data["workdir_data"] = workdir_data anatomy = data["anatomy"] task_type = workdir_data["task"]["type"] # Temp solution how to pass task type to `_prepare_last_workfile` data["task_type"] = task_type try: from openpype.pipeline.workfile import get_workdir_with_workdir_data workdir = get_workdir_with_workdir_data( workdir_data, anatomy.project_name, anatomy, project_settings=project_settings ) except Exception as exc: raise ApplicationLaunchFailed( "Error in anatomy.format: {}".format(str(exc)) ) if not os.path.exists(workdir): log.debug( "Creating workdir folder: \"{}\"".format(workdir) ) try: os.makedirs(workdir) except Exception as exc: raise ApplicationLaunchFailed( "Couldn't create workdir because: {}".format(str(exc)) ) data["env"]["AVALON_WORKDIR"] = workdir _prepare_last_workfile(data, workdir, modules_manager) def _prepare_last_workfile(data, workdir, modules_manager): """last workfile workflow preparation. Function check if should care about last workfile workflow and tries to find the last workfile. Both information are stored to `data` and environments. Last workfile is filled always (with version 1) even if any workfile exists yet. Args: data (EnvironmentPrepData): Dictionary where result and intermediate result will be stored. workdir (str): Path to folder where workfiles should be stored. """ from openpype.modules import ModulesManager from openpype.pipeline import HOST_WORKFILE_EXTENSIONS if not modules_manager: modules_manager = ModulesManager() log = data["log"] _workdir_data = data.get("workdir_data") if not _workdir_data: log.info( "Skipping last workfile preparation." " Key `workdir_data` not filled." ) return app = data["app"] workdir_data = copy.deepcopy(_workdir_data) project_name = data["project_name"] task_name = data["task_name"] task_type = data["task_type"] start_last_workfile = data.get("start_last_workfile") if start_last_workfile is None: start_last_workfile = should_start_last_workfile( project_name, app.host_name, task_name, task_type ) else: log.info("Opening of last workfile was disabled by user") data["start_last_workfile"] = start_last_workfile workfile_startup = should_workfile_tool_start( project_name, app.host_name, task_name, task_type ) data["workfile_startup"] = workfile_startup # Store boolean as "0"(False) or "1"(True) data["env"]["AVALON_OPEN_LAST_WORKFILE"] = ( str(int(bool(start_last_workfile))) ) data["env"]["OPENPYPE_WORKFILE_TOOL_ON_START"] = ( str(int(bool(workfile_startup))) ) _sub_msg = "" if start_last_workfile else " not" log.debug( "Last workfile should{} be opened on start.".format(_sub_msg) ) # Last workfile path last_workfile_path = data.get("last_workfile_path") or "" if not last_workfile_path: host_module = modules_manager.get_host_module(app.host_name) if host_module: extensions = host_module.get_workfile_extensions() else: extensions = HOST_WORKFILE_EXTENSIONS.get(app.host_name) if extensions: from openpype.pipeline.workfile import ( get_workfile_template_key, get_last_workfile ) anatomy = data["anatomy"] project_settings = data["project_settings"] task_type = workdir_data["task"]["type"] template_key = get_workfile_template_key( task_type, app.host_name, project_name, project_settings=project_settings ) # Find last workfile file_template = str(anatomy.templates[template_key]["file"]) workdir_data.update({ "version": 1, "user": get_openpype_username(), "ext": extensions[0] }) last_workfile_path = get_last_workfile( workdir, file_template, workdir_data, extensions, True ) if os.path.exists(last_workfile_path): log.debug(( "Workfiles for launch context does not exists" " yet but path will be set." )) log.debug( "Setting last workfile path: {}".format(last_workfile_path) ) data["env"]["AVALON_LAST_WORKFILE"] = last_workfile_path data["last_workfile_path"] = last_workfile_path def should_start_last_workfile( project_name, host_name, task_name, task_type, default_output=False ): """Define if host should start last version workfile if possible. Default output is `False`. Can be overridden with environment variable `AVALON_OPEN_LAST_WORKFILE`, valid values without case sensitivity are `"0", "1", "true", "false", "yes", "no"`. Args: project_name (str): Name of project. host_name (str): Name of host which is launched. In avalon's application context it's value stored in app definition under key `"application_dir"`. Is not case sensitive. task_name (str): Name of task which is used for launching the host. Task name is not case sensitive. Returns: bool: True if host should start workfile. """ project_settings = get_project_settings(project_name) profiles = ( project_settings ["global"] ["tools"] ["Workfiles"] ["last_workfile_on_startup"] ) if not profiles: return default_output filter_data = { "tasks": task_name, "task_types": task_type, "hosts": host_name } matching_item = filter_profiles(profiles, filter_data) output = None if matching_item: output = matching_item.get("enabled") if output is None: return default_output return output def should_workfile_tool_start( project_name, host_name, task_name, task_type, default_output=False ): """Define if host should start workfile tool at host launch. Default output is `False`. Can be overridden with environment variable `OPENPYPE_WORKFILE_TOOL_ON_START`, valid values without case sensitivity are `"0", "1", "true", "false", "yes", "no"`. Args: project_name (str): Name of project. host_name (str): Name of host which is launched. In avalon's application context it's value stored in app definition under key `"application_dir"`. Is not case sensitive. task_name (str): Name of task which is used for launching the host. Task name is not case sensitive. Returns: bool: True if host should start workfile. """ project_settings = get_project_settings(project_name) profiles = ( project_settings ["global"] ["tools"] ["Workfiles"] ["open_workfile_tool_on_startup"] ) if not profiles: return default_output filter_data = { "tasks": task_name, "task_types": task_type, "hosts": host_name } matching_item = filter_profiles(profiles, filter_data) output = None if matching_item: output = matching_item.get("enabled") if output is None: return default_output return output def get_non_python_host_kwargs(kwargs, allow_console=True): """Explicit setting of kwargs for Popen for AE/PS/Harmony. Expected behavior - openpype_console opens window with logs - openpype_gui has stdout/stderr available for capturing Args: kwargs (dict) or None allow_console (bool): use False for inner Popen opening app itself or it will open additional console (at least for Harmony) """ if kwargs is None: kwargs = {} if platform.system().lower() != "windows": return kwargs if AYON_SERVER_ENABLED: executable_path = os.environ.get("AYON_EXECUTABLE") else: executable_path = os.environ.get("OPENPYPE_EXECUTABLE") executable_filename = "" if executable_path: executable_filename = os.path.basename(executable_path) if AYON_SERVER_ENABLED: is_gui_executable = "ayon_console" not in executable_filename else: is_gui_executable = "openpype_gui" in executable_filename if is_gui_executable: kwargs.update({ "creationflags": subprocess.CREATE_NO_WINDOW, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL }) elif allow_console: kwargs.update({ "creationflags": subprocess.CREATE_NEW_CONSOLE }) return kwargs ================================================ FILE: openpype/lib/attribute_definitions.py ================================================ import os import re import collections import uuid import json import copy from abc import ABCMeta, abstractmethod, abstractproperty import six import clique # Global variable which store attribute definitions by type # - default types are registered on import _attr_defs_by_type = {} def register_attr_def_class(cls): """Register attribute definition. Currently are registered definitions used to deserialize data to objects. Attrs: cls (AbstractAttrDef): Non-abstract class to be registered with unique 'type' attribute. Raises: KeyError: When type was already registered. """ if cls.type in _attr_defs_by_type: raise KeyError("Type \"{}\" was already registered".format(cls.type)) _attr_defs_by_type[cls.type] = cls def get_attributes_keys(attribute_definitions): """Collect keys from list of attribute definitions. Args: attribute_definitions (List[AbstractAttrDef]): Objects of attribute definitions. Returns: Set[str]: Keys that will be created using passed attribute definitions. """ keys = set() if not attribute_definitions: return keys for attribute_def in attribute_definitions: if not isinstance(attribute_def, UIDef): keys.add(attribute_def.key) return keys def get_default_values(attribute_definitions): """Receive default values for attribute definitions. Args: attribute_definitions (List[AbstractAttrDef]): Attribute definitions for which default values should be collected. Returns: Dict[str, Any]: Default values for passet attribute definitions. """ output = {} if not attribute_definitions: return output for attr_def in attribute_definitions: # Skip UI definitions if not isinstance(attr_def, UIDef): output[attr_def.key] = attr_def.default return output class AbstractAttrDefMeta(ABCMeta): """Metaclass to validate existence of 'key' attribute. Each object of `AbstractAttrDef` mus have defined 'key' attribute. """ def __call__(self, *args, **kwargs): obj = super(AbstractAttrDefMeta, self).__call__(*args, **kwargs) init_class = getattr(obj, "__init__class__", None) if init_class is not AbstractAttrDef: raise TypeError("{} super was not called in __init__.".format( type(obj) )) return obj @six.add_metaclass(AbstractAttrDefMeta) class AbstractAttrDef(object): """Abstraction of attribute definition. Each attribute definition must have implemented validation and conversion method. Attribute definition should have ability to return "default" value. That can be based on passed data into `__init__` so is not abstracted to attribute. QUESTION: How to force to set `key` attribute? Args: key (str): Under which key will be attribute value stored. default (Any): Default value of an attribute. label (str): Attribute label. tooltip (str): Attribute tooltip. is_label_horizontal (bool): UI specific argument. Specify if label is next to value input or ahead. hidden (bool): Will be item hidden (for UI purposes). disabled (bool): Item will be visible but disabled (for UI purposes). """ type_attributes = [] is_value_def = True def __init__( self, key, default, label=None, tooltip=None, is_label_horizontal=None, hidden=False, disabled=False ): if is_label_horizontal is None: is_label_horizontal = True if hidden is None: hidden = False self.key = key self.label = label self.tooltip = tooltip self.default = default self.is_label_horizontal = is_label_horizontal self.hidden = hidden self.disabled = disabled self._id = uuid.uuid4().hex self.__init__class__ = AbstractAttrDef @property def id(self): return self._id def __eq__(self, other): if not isinstance(other, self.__class__): return False return ( self.key == other.key and self.hidden == other.hidden and self.default == other.default and self.disabled == other.disabled ) def __ne__(self, other): return not self.__eq__(other) @abstractproperty def type(self): """Attribute definition type also used as identifier of class. Returns: str: Type of attribute definition. """ pass @abstractmethod def convert_value(self, value): """Convert value to a valid one. Convert passed value to a valid type. Use default if value can't be converted. """ pass def serialize(self): """Serialize object to data so it's possible to recreate it. Returns: Dict[str, Any]: Serialized object that can be passed to 'deserialize' method. """ data = { "type": self.type, "key": self.key, "label": self.label, "tooltip": self.tooltip, "default": self.default, "is_label_horizontal": self.is_label_horizontal, "hidden": self.hidden, "disabled": self.disabled } for attr in self.type_attributes: data[attr] = getattr(self, attr) return data @classmethod def deserialize(cls, data): """Recreate object from data. Data can be received using 'serialize' method. """ return cls(**data) # ----------------------------------------- # UI attribute definitoins won't hold value # ----------------------------------------- class UIDef(AbstractAttrDef): is_value_def = False def __init__(self, key=None, default=None, *args, **kwargs): super(UIDef, self).__init__(key, default, *args, **kwargs) def convert_value(self, value): return value class UISeparatorDef(UIDef): type = "separator" class UILabelDef(UIDef): type = "label" def __init__(self, label, key=None): super(UILabelDef, self).__init__(label=label, key=key) def __eq__(self, other): if not super(UILabelDef, self).__eq__(other): return False return self.label == other.label # --------------------------------------- # Attribute defintioins should hold value # --------------------------------------- class UnknownDef(AbstractAttrDef): """Definition is not known because definition is not available. This attribute can be used to keep existing data unchanged but does not have known definition of type. """ type = "unknown" def __init__(self, key, default=None, **kwargs): kwargs["default"] = default super(UnknownDef, self).__init__(key, **kwargs) def convert_value(self, value): return value class HiddenDef(AbstractAttrDef): """Hidden value of Any type. This attribute can be used for UI purposes to pass values related to other attributes (e.g. in multi-page UIs). Keep in mind the value should be possible to parse by json parser. """ type = "hidden" def __init__(self, key, default=None, **kwargs): kwargs["default"] = default kwargs["hidden"] = True super(UnknownDef, self).__init__(key, **kwargs) def convert_value(self, value): return value class NumberDef(AbstractAttrDef): """Number definition. Number can have defined minimum/maximum value and decimal points. Value is integer if decimals are 0. Args: minimum(int, float): Minimum possible value. maximum(int, float): Maximum possible value. decimals(int): Maximum decimal points of value. default(int, float): Default value for conversion. """ type = "number" type_attributes = [ "minimum", "maximum", "decimals" ] def __init__( self, key, minimum=None, maximum=None, decimals=None, default=None, **kwargs ): minimum = 0 if minimum is None else minimum maximum = 999999 if maximum is None else maximum # Swap min/max when are passed in opposited order if minimum > maximum: maximum, minimum = minimum, maximum if default is None: default = 0 elif not isinstance(default, (int, float)): raise TypeError(( "'default' argument must be 'int' or 'float', not '{}'" ).format(type(default))) # Fix default value by mim/max values if default < minimum: default = minimum elif default > maximum: default = maximum super(NumberDef, self).__init__(key, default=default, **kwargs) self.minimum = minimum self.maximum = maximum self.decimals = 0 if decimals is None else decimals def __eq__(self, other): if not super(NumberDef, self).__eq__(other): return False return ( self.decimals == other.decimals and self.maximum == other.maximum and self.maximum == other.maximum ) def convert_value(self, value): if isinstance(value, six.string_types): try: value = float(value) except Exception: pass if not isinstance(value, (int, float)): return self.default if self.decimals == 0: return int(value) return round(float(value), self.decimals) class TextDef(AbstractAttrDef): """Text definition. Text can have multiline option so endline characters are allowed regex validation can be applied placeholder for UI purposes and default value. Regex validation is not part of attribute implemntentation. Args: multiline(bool): Text has single or multiline support. regex(str, re.Pattern): Regex validation. placeholder(str): UI placeholder for attribute. default(str, None): Default value. Empty string used when not defined. """ type = "text" type_attributes = [ "multiline", "placeholder", ] def __init__( self, key, multiline=None, regex=None, placeholder=None, default=None, **kwargs ): if default is None: default = "" super(TextDef, self).__init__(key, default=default, **kwargs) if multiline is None: multiline = False elif not isinstance(default, six.string_types): raise TypeError(( "'default' argument must be a {}, not '{}'" ).format(six.string_types, type(default))) if isinstance(regex, six.string_types): regex = re.compile(regex) self.multiline = multiline self.placeholder = placeholder self.regex = regex def __eq__(self, other): if not super(TextDef, self).__eq__(other): return False return ( self.multiline == other.multiline and self.regex == other.regex ) def convert_value(self, value): if isinstance(value, six.string_types): return value return self.default def serialize(self): data = super(TextDef, self).serialize() data["regex"] = self.regex.pattern return data class EnumDef(AbstractAttrDef): """Enumeration of items. Enumeration of single item from items. Or list of items if multiselection is enabled. Args: items (Union[list[str], list[dict[str, Any]]): Items definition that can be converted using 'prepare_enum_items'. default (Optional[Any]): Default value. Must be one key(value) from passed items or list of values for multiselection. multiselection (Optional[bool]): If True, multiselection is allowed. Output is list of selected items. """ type = "enum" def __init__( self, key, items, default=None, multiselection=False, **kwargs ): if not items: raise ValueError(( "Empty 'items' value. {} must have" " defined values on initialization." ).format(self.__class__.__name__)) items = self.prepare_enum_items(items) item_values = [item["value"] for item in items] item_values_set = set(item_values) if multiselection: if default is None: default = [] default = list(item_values_set.intersection(default)) elif default not in item_values: default = next(iter(item_values), None) super(EnumDef, self).__init__(key, default=default, **kwargs) self.items = items self._item_values = item_values_set self.multiselection = multiselection def __eq__(self, other): if not super(EnumDef, self).__eq__(other): return False return ( self.items == other.items and self.multiselection == other.multiselection ) def convert_value(self, value): if not self.multiselection: if value in self._item_values: return value return self.default if value is None: return copy.deepcopy(self.default) return list(self._item_values.intersection(value)) def serialize(self): data = super(EnumDef, self).serialize() data["items"] = copy.deepcopy(self.items) data["multiselection"] = self.multiselection return data @staticmethod def prepare_enum_items(items): """Convert items to unified structure. Output is a list where each item is dictionary with 'value' and 'label'. ```python # Example output [ {"label": "Option 1", "value": 1}, {"label": "Option 2", "value": 2}, {"label": "Option 3", "value": 3} ] ``` Args: items (Union[Dict[str, Any], List[Any], List[Dict[str, Any]]): The items to convert. Returns: List[Dict[str, Any]]: Unified structure of items. """ output = [] if isinstance(items, dict): for value, label in items.items(): output.append({"label": label, "value": value}) elif isinstance(items, (tuple, list, set)): for item in items: if isinstance(item, dict): # Validate if 'value' is available if "value" not in item: raise KeyError("Item does not contain 'value' key.") if "label" not in item: item["label"] = str(item["value"]) elif isinstance(item, (list, tuple)): if len(item) == 2: value, label = item elif len(item) == 1: value = item[0] label = str(value) else: raise ValueError(( "Invalid items count {}." " Expected 1 or 2. Value: {}" ).format(len(item), str(item))) item = {"label": label, "value": value} else: item = {"label": str(item), "value": item} output.append(item) else: raise TypeError( "Unknown type for enum items '{}'".format(type(items)) ) return output class BoolDef(AbstractAttrDef): """Boolean representation. Args: default(bool): Default value. Set to `False` if not defined. """ type = "bool" def __init__(self, key, default=None, **kwargs): if default is None: default = False super(BoolDef, self).__init__(key, default=default, **kwargs) def convert_value(self, value): if isinstance(value, bool): return value return self.default class FileDefItem(object): def __init__( self, directory, filenames, frames=None, template=None ): self.directory = directory self.filenames = [] self.is_sequence = False self.template = None self.frames = [] self.is_empty = True self.set_filenames(filenames, frames, template) def __str__(self): return json.dumps(self.to_dict()) def __repr__(self): if self.is_empty: filename = "< empty >" elif self.is_sequence: filename = self.template else: filename = self.filenames[0] return "<{}: \"{}\">".format( self.__class__.__name__, os.path.join(self.directory, filename) ) @property def label(self): if self.is_empty: return None if not self.is_sequence: return self.filenames[0] frame_start = self.frames[0] filename_template = os.path.basename(self.template) if len(self.frames) == 1: return "{} [{}]".format(filename_template, frame_start) frame_end = self.frames[-1] expected_len = (frame_end - frame_start) + 1 if expected_len == len(self.frames): return "{} [{}-{}]".format( filename_template, frame_start, frame_end ) ranges = [] _frame_start = None _frame_end = None for frame in range(frame_start, frame_end + 1): if frame not in self.frames: add_to_ranges = _frame_start is not None elif _frame_start is None: _frame_start = _frame_end = frame add_to_ranges = frame == frame_end else: _frame_end = frame add_to_ranges = frame == frame_end if add_to_ranges: if _frame_start != _frame_end: _range = "{}-{}".format(_frame_start, _frame_end) else: _range = str(_frame_start) ranges.append(_range) _frame_start = _frame_end = None return "{} [{}]".format( filename_template, ",".join(ranges) ) def split_sequence(self): if not self.is_sequence: raise ValueError("Cannot split single file item") paths = [ os.path.join(self.directory, filename) for filename in self.filenames ] return self.from_paths(paths, False) @property def ext(self): if self.is_empty: return None _, ext = os.path.splitext(self.filenames[0]) if ext: return ext return None @property def lower_ext(self): ext = self.ext if ext is not None: return ext.lower() return ext @property def is_dir(self): if self.is_empty: return False # QUESTION a better way how to define folder (in init argument?) if self.ext: return False return True def set_directory(self, directory): self.directory = directory def set_filenames(self, filenames, frames=None, template=None): if frames is None: frames = [] is_sequence = False if frames: is_sequence = True if is_sequence and not template: raise ValueError("Missing template for sequence") self.is_empty = len(filenames) == 0 self.filenames = filenames self.template = template self.frames = frames self.is_sequence = is_sequence @classmethod def create_empty_item(cls): return cls("", "") @classmethod def from_value(cls, value, allow_sequences): """Convert passed value to FileDefItem objects. Returns: list: Created FileDefItem objects. """ # Convert single item to iterable if not isinstance(value, (list, tuple, set)): value = [value] output = [] str_filepaths = [] for item in value: if isinstance(item, dict): item = cls.from_dict(item) if isinstance(item, FileDefItem): if not allow_sequences and item.is_sequence: output.extend(item.split_sequence()) else: output.append(item) elif isinstance(item, six.string_types): str_filepaths.append(item) else: raise TypeError( "Unknown type \"{}\". Can't convert to {}".format( str(type(item)), cls.__name__ ) ) if str_filepaths: output.extend(cls.from_paths(str_filepaths, allow_sequences)) return output @classmethod def from_dict(cls, data): return cls( data["directory"], data["filenames"], data.get("frames"), data.get("template") ) @classmethod def from_paths(cls, paths, allow_sequences): filenames_by_dir = collections.defaultdict(list) for path in paths: normalized = os.path.normpath(path) directory, filename = os.path.split(normalized) filenames_by_dir[directory].append(filename) output = [] for directory, filenames in filenames_by_dir.items(): if allow_sequences: cols, remainders = clique.assemble(filenames) else: cols = [] remainders = filenames for remainder in remainders: output.append(cls(directory, [remainder])) for col in cols: frames = list(col.indexes) paths = [filename for filename in col] template = col.format("{head}{padding}{tail}") output.append(cls( directory, paths, frames, template )) return output def to_dict(self): output = { "is_sequence": self.is_sequence, "directory": self.directory, "filenames": list(self.filenames), } if self.is_sequence: output.update({ "template": self.template, "frames": list(sorted(self.frames)), }) return output class FileDef(AbstractAttrDef): """File definition. It is possible to define filters of allowed file extensions and if supports folders. Args: single_item(bool): Allow only single path item. folders(bool): Allow folder paths. extensions(List[str]): Allow files with extensions. Empty list will allow all extensions and None will disable files completely. extensions_label(str): Custom label shown instead of extensions in UI. default(str, List[str]): Default value. """ type = "path" type_attributes = [ "single_item", "folders", "extensions", "allow_sequences", "extensions_label", ] def __init__( self, key, single_item=True, folders=None, extensions=None, allow_sequences=True, extensions_label=None, default=None, **kwargs ): if folders is None and extensions is None: folders = True extensions = [] if default is None: if single_item: default = FileDefItem.create_empty_item().to_dict() else: default = [] else: if single_item: if isinstance(default, dict): FileDefItem.from_dict(default) elif isinstance(default, six.string_types): default = FileDefItem.from_paths([default.strip()])[0] else: raise TypeError(( "'default' argument must be 'str' or 'dict' not '{}'" ).format(type(default))) else: if not isinstance(default, (tuple, list, set)): raise TypeError(( "'default' argument must be 'list', 'tuple' or 'set'" ", not '{}'" ).format(type(default))) # Change horizontal label is_label_horizontal = kwargs.get("is_label_horizontal") if is_label_horizontal is None: kwargs["is_label_horizontal"] = False self.single_item = single_item self.folders = folders self.extensions = set(extensions) self.allow_sequences = allow_sequences self.extensions_label = extensions_label super(FileDef, self).__init__(key, default=default, **kwargs) def __eq__(self, other): if not super(FileDef, self).__eq__(other): return False return ( self.single_item == other.single_item and self.folders == other.folders and self.extensions == other.extensions and self.allow_sequences == other.allow_sequences ) def convert_value(self, value): if isinstance(value, six.string_types) or isinstance(value, dict): value = [value] if isinstance(value, (tuple, list, set)): string_paths = [] dict_items = [] for item in value: if isinstance(item, six.string_types): string_paths.append(item.strip()) elif isinstance(item, dict): try: FileDefItem.from_dict(item) dict_items.append(item) except (ValueError, KeyError): pass if string_paths: file_items = FileDefItem.from_paths(string_paths) dict_items.extend([ file_item.to_dict() for file_item in file_items ]) if not self.single_item: return dict_items if not dict_items: return self.default return dict_items[0] if self.single_item: return FileDefItem.create_empty_item().to_dict() return [] def serialize_attr_def(attr_def): """Serialize attribute definition to data. Args: attr_def (AbstractAttrDef): Attribute definition to serialize. Returns: Dict[str, Any]: Serialized data. """ return attr_def.serialize() def serialize_attr_defs(attr_defs): """Serialize attribute definitions to data. Args: attr_defs (List[AbstractAttrDef]): Attribute definitions to serialize. Returns: List[Dict[str, Any]]: Serialized data. """ return [ serialize_attr_def(attr_def) for attr_def in attr_defs ] def deserialize_attr_def(attr_def_data): """Deserialize attribute definition from data. Args: attr_def (Dict[str, Any]): Attribute definition data to deserialize. """ attr_type = attr_def_data.pop("type") cls = _attr_defs_by_type[attr_type] return cls.deserialize(attr_def_data) def deserialize_attr_defs(attr_defs_data): """Deserialize attribute definitions. Args: List[Dict[str, Any]]: List of attribute definitions. """ return [ deserialize_attr_def(attr_def_data) for attr_def_data in attr_defs_data ] # Register attribute definitions for _attr_class in ( UISeparatorDef, UILabelDef, UnknownDef, NumberDef, TextDef, EnumDef, BoolDef, FileDef ): register_attr_def_class(_attr_class) ================================================ FILE: openpype/lib/connections.py ================================================ import requests import os def requests_post(*args, **kwargs): """Wrap request post method. Disabling SSL certificate validation if ``DONT_VERIFY_SSL`` environment variable is found. This is useful when Deadline server is running with self-signed certificates and its certificate is not added to trusted certificates on client machines. Warning: Disabling SSL certificate validation is defeating one line of defense SSL is providing, and it is not recommended. """ if "verify" not in kwargs: kwargs["verify"] = not os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) return requests.post(*args, **kwargs) def requests_get(*args, **kwargs): """Wrap request get method. Disabling SSL certificate validation if ``DONT_VERIFY_SSL`` environment variable is found. This is useful when Deadline server is running with self-signed certificates and its certificate is not added to trusted certificates on client machines. Warning: Disabling SSL certificate validation is defeating one line of defense SSL is providing, and it is not recommended. """ if "verify" not in kwargs: kwargs["verify"] = not os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) return requests.get(*args, **kwargs) ================================================ FILE: openpype/lib/dateutils.py ================================================ # -*- coding: utf-8 -*- """Get configuration data.""" import datetime def get_datetime_data(datetime_obj=None): """Returns current datetime data as dictionary. Args: datetime_obj (datetime): Specific datetime object Returns: dict: prepared date & time data Available keys: "d" - in shortest possible way. "dd" - with 2 digits. "ddd" - shortened week day. e.g.: `Mon`, ... "dddd" - full name of week day. e.g.: `Monday`, ... "m" - in shortest possible way. e.g.: `1` if January "mm" - with 2 digits. "mmm" - shortened month name. e.g.: `Jan`, ... "mmmm" - full month name. e.g.: `January`, ... "yy" - shortened year. e.g.: `19`, `20`, ... "yyyy" - full year. e.g.: `2019`, `2020`, ... "H" - shortened hours. "HH" - with 2 digits. "h" - shortened hours. "hh" - with 2 digits. "ht" - AM or PM. "M" - shortened minutes. "MM" - with 2 digits. "S" - shortened seconds. "SS" - with 2 digits. """ if not datetime_obj: datetime_obj = datetime.datetime.now() year = datetime_obj.strftime("%Y") month = datetime_obj.strftime("%m") month_name_full = datetime_obj.strftime("%B") month_name_short = datetime_obj.strftime("%b") day = datetime_obj.strftime("%d") weekday_full = datetime_obj.strftime("%A") weekday_short = datetime_obj.strftime("%a") hours = datetime_obj.strftime("%H") hours_midday = datetime_obj.strftime("%I") hour_midday_type = datetime_obj.strftime("%p") minutes = datetime_obj.strftime("%M") seconds = datetime_obj.strftime("%S") return { "d": str(int(day)), "dd": str(day), "ddd": weekday_short, "dddd": weekday_full, "m": str(int(month)), "mm": str(month), "mmm": month_name_short, "mmmm": month_name_full, "yy": str(year[2:]), "yyyy": str(year), "H": str(int(hours)), "HH": str(hours), "h": str(int(hours_midday)), "hh": str(hours_midday), "ht": hour_midday_type, "M": str(int(minutes)), "MM": str(minutes), "S": str(int(seconds)), "SS": str(seconds), } def get_timestamp(datetime_obj=None): """Get standardized timestamp from datetime object. Args: datetime_obj (datetime.datetime): Object of datetime. Current time is used if not passed. """ if datetime_obj is None: datetime_obj = datetime.datetime.now() return datetime_obj.strftime( "%Y%m%dT%H%M%SZ" ) def get_formatted_current_time(): return get_timestamp() ================================================ FILE: openpype/lib/env_tools.py ================================================ import os def env_value_to_bool(env_key=None, value=None, default=False): """Convert environment variable value to boolean. Function is based on value of the environemt variable. Value is lowered so function is not case sensitive. Returns: bool: If value match to one of ["true", "yes", "1"] result if True but if value match to ["false", "no", "0"] result is False else default value is returned. """ if value is None and env_key is None: return default if value is None: value = os.environ.get(env_key) if value is not None: value = str(value).lower() if value in ("true", "yes", "1", "on"): return True elif value in ("false", "no", "0", "off"): return False return default def get_paths_from_environ(env_key=None, env_value=None, return_first=False): """Return existing paths from specific environment variable. Args: env_key (str): Environment key where should look for paths. env_value (str): Value of environment variable. Argument `env_key` is skipped if this argument is entered. return_first (bool): Return first found value or return list of found paths. `None` or empty list returned if nothing found. Returns: str, list, None: Result of found path/s. """ existing_paths = [] if not env_key and not env_value: if return_first: return None return existing_paths if env_value is None: env_value = os.environ.get(env_key) or "" path_items = env_value.split(os.pathsep) for path in path_items: # Skip empty string if not path: continue # Normalize path path = os.path.normpath(path) # Check if path exists if os.path.exists(path): # Return path if `return_first` is set to True if return_first: return path # Store path existing_paths.append(path) # Return None if none of paths exists if return_first: return None # Return all existing paths from environment variable return existing_paths ================================================ FILE: openpype/lib/events.py ================================================ """Events holding data about specific event.""" import os import re import copy import inspect import collections import logging import weakref from uuid import uuid4 from .python_2_comp import WeakMethod from .python_module_tools import is_func_signature_supported class MissingEventSystem(Exception): pass def _get_func_ref(func): if inspect.ismethod(func): return WeakMethod(func) return weakref.ref(func) def _get_func_info(func): path = "" if func is None: return "", path if hasattr(func, "__name__"): name = func.__name__ else: name = str(func) # Get path to file and fallback to '' if fails # NOTE This was added because of 'partial' functions which is handled, # but who knows what else can cause this to fail? try: path = os.path.abspath(inspect.getfile(func)) except TypeError: pass return name, path class weakref_partial: """Partial function with weak reference to the wrapped function. Can be used as 'functools.partial' but it will store weak reference to function. That means that the function must be reference counted to avoid garbage collecting the function itself. When the referenced functions is garbage collected then calling the weakref partial (no matter the args/kwargs passed) will do nothing. It will fail silently, returning `None`. The `is_valid()` method can be used to detect whether the reference is still valid. Is useful for object methods. In that case the callback is deregistered when object is destroyed. Warnings: Values passed as *args and **kwargs are stored strongly in memory. That may "keep alive" objects that should be already destroyed. It is recommended to pass only immutable objects like 'str', 'bool', 'int' etc. Args: func (Callable): Function to wrap. *args: Arguments passed to the wrapped function. **kwargs: Keyword arguments passed to the wrapped function. """ def __init__(self, func, *args, **kwargs): self._func_ref = _get_func_ref(func) self._args = args self._kwargs = kwargs def __call__(self, *args, **kwargs): func = self._func_ref() if func is None: return new_args = tuple(list(self._args) + list(args)) new_kwargs = dict(self._kwargs) new_kwargs.update(kwargs) return func(*new_args, **new_kwargs) def get_func(self): """Get wrapped function. Returns: Union[Callable, None]: Wrapped function or None if it was destroyed. """ return self._func_ref() def is_valid(self): """Check if wrapped function is still valid. Returns: bool: Is wrapped function still valid. """ return self._func_ref() is not None def validate_signature(self, *args, **kwargs): """Validate if passed arguments are supported by wrapped function. Returns: bool: Are passed arguments supported by wrapped function. """ func = self._func_ref() if func is None: return False new_args = tuple(list(self._args) + list(args)) new_kwargs = dict(self._kwargs) new_kwargs.update(kwargs) return is_func_signature_supported( func, *new_args, **new_kwargs ) class EventCallback(object): """Callback registered to a topic. The callback function is registered to a topic. Topic is a string which may contain '*' that will be handled as "any characters". # Examples: - "workfile.save" Callback will be triggered if the event topic is exactly "workfile.save" . - "workfile.*" Callback will be triggered an event topic starts with "workfile." so "workfile.save" and "workfile.open" will trigger the callback. - "*" Callback will listen to all events. Callback can be function or method. In both cases it should expect one or none arguments. When 1 argument is expected then the processed 'Event' object is passed in. The callbacks are validated against their reference counter, that is achieved using 'weakref' module. That means that the callback must be stored in memory somewhere. e.g. lambda functions are not supported as valid callback. You can use 'weakref_partial' functions. In that case is partial object stored in the callback object and reference counter is checked for the wrapped function. Args: topic (str): Topic which will be listened. func (Callable): Callback to a topic. order (Union[int, None]): Order of callback. Lower number means higher priority. Raises: TypeError: When passed function is not a callable object. """ def __init__(self, topic, func, order): if not callable(func): raise TypeError(( "Registered callback is not callable. \"{}\"" ).format(str(func))) self._validate_order(order) self._log = None self._topic = topic self._order = order self._enabled = True # Replace '*' with any character regex and escape rest of text # - when callback is registered for '*' topic it will receive all # events # - it is possible to register to a partial topis 'my.event.*' # - it will receive all matching event topics # e.g. 'my.event.start' and 'my.event.end' topic_regex_str = "^{}$".format( ".+".join( re.escape(part) for part in topic.split("*") ) ) topic_regex = re.compile(topic_regex_str) self._topic_regex = topic_regex # Callback function prep if isinstance(func, weakref_partial): partial_func = func (name, path) = _get_func_info(func.get_func()) func_ref = None expect_args = partial_func.validate_signature("fake") expect_kwargs = partial_func.validate_signature(event="fake") else: partial_func = None (name, path) = _get_func_info(func) # Convert callback into references # - deleted functions won't cause crashes func_ref = _get_func_ref(func) # Get expected arguments from function spec # - positional arguments are always preferred expect_args = is_func_signature_supported(func, "fake") expect_kwargs = is_func_signature_supported(func, event="fake") self._func_ref = func_ref self._partial_func = partial_func self._ref_is_valid = True self._expect_args = expect_args self._expect_kwargs = expect_kwargs self._name = name self._path = path def __repr__(self): return "< {} - {} > {}".format( self.__class__.__name__, self._name, self._path ) @property def log(self): if self._log is None: self._log = logging.getLogger(self.__class__.__name__) return self._log @property def is_ref_valid(self): """ Returns: bool: Is reference to callback valid. """ self._validate_ref() return self._ref_is_valid def validate_ref(self): """Validate if reference to callback is valid. Deprecated: Reference is always live checkd with 'is_ref_valid'. """ # Trigger validate by getting 'is_valid' _ = self.is_ref_valid @property def enabled(self): """Is callback enabled. Returns: bool: Is callback enabled. """ return self._enabled def set_enabled(self, enabled): """Change if callback is enabled. Args: enabled (bool): Change enabled state of the callback. """ self._enabled = enabled def deregister(self): """Calling this function will cause that callback will be removed.""" self._ref_is_valid = False self._partial_func = None self._func_ref = None def get_order(self): """Get callback order. Returns: Union[int, None]: Callback order. """ return self._order def set_order(self, order): """Change callback order. Args: order (Union[int, None]): Order of callback. Lower number means higher priority. """ self._validate_order(order) self._order = order order = property(get_order, set_order) def topic_matches(self, topic): """Check if event topic matches callback's topic. Args: topic (str): Topic name. Returns: bool: Topic matches callback's topic. """ return self._topic_regex.match(topic) def process_event(self, event): """Process event. Args: event(Event): Event that was triggered. """ # Skip if callback is not enabled if not self._enabled: return # Get reference and skip if is not available callback = self._get_callback() if callback is None: return if not self.topic_matches(event.topic): return # Try to execute callback try: if self._expect_args: callback(event) elif self._expect_kwargs: callback(event=event) else: callback() except Exception: self.log.warning( "Failed to execute event callback {}".format( str(repr(self)) ), exc_info=True ) def _validate_order(self, order): if isinstance(order, int): return raise TypeError( "Expected type 'int' got '{}'.".format(str(type(order))) ) def _get_callback(self): if self._partial_func is not None: return self._partial_func if self._func_ref is not None: return self._func_ref() return None def _validate_ref(self): if self._ref_is_valid is False: return if self._func_ref is not None: self._ref_is_valid = self._func_ref() is not None elif self._partial_func is not None: self._ref_is_valid = self._partial_func.is_valid() else: self._ref_is_valid = False if not self._ref_is_valid: self._func_ref = None self._partial_func = None # Inherit from 'object' for Python 2 hosts class Event(object): """Base event object. Can be used for any event because is not specific. Only required argument is topic which defines why event is happening and may be used for filtering. Arg: topic (str): Identifier of event. data (Any): Data specific for event. Dictionary is recommended. source (str): Identifier of source. event_system (EventSystem): Event system in which can be event triggered. """ _data = {} def __init__(self, topic, data=None, source=None, event_system=None): self._id = str(uuid4()) self._topic = topic if data is None: data = {} self._data = data self._source = source self._event_system = event_system def __getitem__(self, key): return self._data[key] def get(self, key, *args, **kwargs): return self._data.get(key, *args, **kwargs) @property def id(self): return self._id @property def source(self): """Event's source used for triggering callbacks. Returns: Union[str, None]: Source string or None. Source is optional. """ return self._source @property def data(self): return self._data @property def topic(self): """Event's topic used for triggering callbacks. Returns: str: Topic string. """ return self._topic def emit(self): """Emit event and trigger callbacks.""" if self._event_system is None: raise MissingEventSystem( "Can't emit event {}. Does not have set event system.".format( str(repr(self)) ) ) self._event_system.emit_event(self) def to_data(self): """Convert Event object to data. Returns: Dict[str, Any]: Event data. """ return { "id": self.id, "topic": self.topic, "source": self.source, "data": copy.deepcopy(self.data) } @classmethod def from_data(cls, event_data, event_system=None): """Create event from data. Args: event_data (Dict[str, Any]): Event data with defined keys. Can be created using 'to_data' method. event_system (EventSystem): System to which the event belongs. Returns: Event: Event with attributes from passed data. """ obj = cls( event_data["topic"], event_data["data"], event_data["source"], event_system ) obj._id = event_data["id"] return obj class EventSystem(object): """Encapsulate event handling into an object. System wraps registered callbacks and triggered events into single object, so it is possible to create multiple independent systems that have their topics and callbacks. Callbacks are stored by order of their registration, but it is possible to manually define order of callbacks using 'order' argument within 'add_callback'. """ default_order = 100 def __init__(self): self._registered_callbacks = [] def add_callback(self, topic, callback, order=None): """Register callback in event system. Args: topic (str): Topic for EventCallback. callback (Union[Callable, weakref_partial]): Function or method that will be called when topic is triggered. order (Optional[int]): Order of callback. Lower number means higher priority. Returns: EventCallback: Created callback object which can be used to stop listening. """ if order is None: order = self.default_order callback = EventCallback(topic, callback, order) self._registered_callbacks.append(callback) return callback def create_event(self, topic, data, source): """Create new event which is bound to event system. Args: topic (str): Event topic. data (dict): Data related to event. source (str): Source of event. Returns: Event: Object of event. """ return Event(topic, data, source, self) def emit(self, topic, data, source): """Create event based on passed data and emit it. This is easiest way how to trigger event in an event system. Args: topic (str): Event topic. data (dict): Data related to event. source (str): Source of event. Returns: Event: Created and emitted event. """ event = self.create_event(topic, data, source) event.emit() return event def emit_event(self, event): """Emit event object. Args: event (Event): Prepared event with topic and data. """ self._process_event(event) def _process_event(self, event): """Process event topic and trigger callbacks. Args: event (Event): Prepared event with topic and data. """ callbacks = tuple(sorted( self._registered_callbacks, key=lambda x: x.order )) for callback in callbacks: callback.process_event(event) if not callback.is_ref_valid: self._registered_callbacks.remove(callback) class QueuedEventSystem(EventSystem): """Events are automatically processed in queue. If callback triggers another event, the event is not processed until all callbacks of previous event are processed. Allows to implement custom event process loop by changing 'auto_execute'. Note: This probably should be default behavior of 'EventSystem'. Changing it now could cause problems in existing code. Args: auto_execute (Optional[bool]): If 'True', events are processed automatically. Custom loop calling 'process_next_event' must be implemented when set to 'False'. """ def __init__(self, auto_execute=True): super(QueuedEventSystem, self).__init__() self._event_queue = collections.deque() self._current_event = None self._auto_execute = auto_execute def __len__(self): return self.count() def count(self): """Get number of events in queue. Returns: int: Number of events in queue. """ return len(self._event_queue) def process_next_event(self): """Process next event in queue. Should be used only if 'auto_execute' is set to 'False'. Only single event is processed. Returns: Union[Event, None]: Processed event. """ if self._current_event is not None: raise ValueError("An event is already in progress.") if not self._event_queue: return None event = self._event_queue.popleft() self._current_event = event self._process_event(event) self._current_event = None return event def emit_event(self, event): """Emit event object. Args: event (Event): Prepared event with topic and data. """ if not self._auto_execute or self._current_event is not None: self._event_queue.append(event) return self._event_queue.append(event) while self._event_queue: event = self._event_queue.popleft() self._current_event = event self._process_event(event) self._current_event = None class GlobalEventSystem: """Event system living in global scope of process. This is primarily used in host implementation to trigger events related to DCC changes or changes of context in the host implementation. """ _global_event_system = None @classmethod def get_global_event_system(cls): if cls._global_event_system is None: cls._global_event_system = EventSystem() return cls._global_event_system @classmethod def add_callback(cls, topic, callback): event_system = cls.get_global_event_system() return event_system.add_callback(topic, callback) @classmethod def emit(cls, topic, data, source): event_system = cls.get_global_event_system() return event_system.emit(topic, data, source) def register_event_callback(topic, callback): """Add callback that will be executed on specific topic. Args: topic(str): Topic on which will callback be triggered. callback(function): Callback that will be triggered when a topic is triggered. Callback should expect none or 1 argument where `Event` object is passed. Returns: EventCallback: Object wrapping the callback. It can be used to enable/disable listening to a topic or remove the callback from the topic completely. """ return GlobalEventSystem.add_callback(topic, callback) def emit_event(topic, data=None, source=None): """Emit event with topic and data. Arg: topic(str): Event's topic. data(dict): Event's additional data. Optional. source(str): Who emitted the topic. Optional. Returns: Event: Object of event that was emitted. """ return GlobalEventSystem.emit(topic, data, source) ================================================ FILE: openpype/lib/execute.py ================================================ import os import sys import subprocess import platform import json import tempfile from openpype import AYON_SERVER_ENABLED from .log import Logger from .vendor_bin_utils import find_executable from .openpype_version import is_running_from_build # MSDN process creation flag (Windows only) CREATE_NO_WINDOW = 0x08000000 def execute(args, silent=False, cwd=None, env=None, shell=None): """Execute command as process. This will execute given command as process, monitor its output and log it appropriately. .. seealso:: :mod:`subprocess` module in Python. Args: args (list): list of arguments passed to process. silent (bool): control output of executed process. cwd (str): current working directory for process. env (dict): environment variables for process. shell (bool): use shell to execute, default is no. Returns: int: return code of process """ log_levels = ['DEBUG:', 'INFO:', 'ERROR:', 'WARNING:', 'CRITICAL:'] log = Logger.get_logger('execute') log.info("Executing ({})".format(" ".join(args))) popen = subprocess.Popen( args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, bufsize=1, cwd=cwd, env=env or os.environ, shell=shell ) # Blocks until finished while True: line = popen.stdout.readline() if line == '': break if silent: continue line_test = False for test_string in log_levels: if line.startswith(test_string): line_test = True break if not line_test: print(line[:-1]) log.info("Execution is finishing up ...") popen.wait() return popen.returncode def run_subprocess(*args, **kwargs): """Convenience method for getting output errors for subprocess. Output logged when process finish. Entered arguments and keyword arguments are passed to subprocess Popen. On windows are 'creationflags' filled with flags that should cause ignore creation of new window. Args: *args: Variable length argument list passed to Popen. **kwargs : Arbitrary keyword arguments passed to Popen. Is possible to pass `logging.Logger` object under "logger" to use custom logger for output. Returns: str: Full output of subprocess concatenated stdout and stderr. Raises: RuntimeError: Exception is raised if process finished with nonzero return code. """ # Modify creation flags on windows to hide console window if in UI mode if ( platform.system().lower() == "windows" and "creationflags" not in kwargs # shell=True already tries to hide the console window # and passing these creationflags then shows the window again # so we avoid it for shell=True cases and kwargs.get("shell") is not True ): kwargs["creationflags"] = ( subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NO_WINDOW", 0) ) # Get environents from kwarg or use current process environments if were # not passed. env = kwargs.get("env") or os.environ # Make sure environment contains only strings filtered_env = {str(k): str(v) for k, v in env.items()} # Use lib's logger if was not passed with kwargs. logger = kwargs.pop("logger", None) if logger is None: logger = Logger.get_logger("run_subprocess") # set overrides kwargs["stdout"] = kwargs.get("stdout", subprocess.PIPE) kwargs["stderr"] = kwargs.get("stderr", subprocess.PIPE) kwargs["stdin"] = kwargs.get("stdin", subprocess.PIPE) kwargs["env"] = filtered_env proc = subprocess.Popen(*args, **kwargs) full_output = "" _stdout, _stderr = proc.communicate() if _stdout: _stdout = _stdout.decode("utf-8", errors="backslashreplace") full_output += _stdout logger.debug(_stdout) if _stderr: _stderr = _stderr.decode("utf-8", errors="backslashreplace") # Add additional line break if output already contains stdout if full_output: full_output += "\n" full_output += _stderr logger.info(_stderr) if proc.returncode != 0: exc_msg = "Executing arguments was not successful: \"{}\"".format(args) if _stdout: exc_msg += "\n\nOutput:\n{}".format(_stdout) if _stderr: exc_msg += "Error:\n{}".format(_stderr) raise RuntimeError(exc_msg) return full_output def clean_envs_for_ayon_process(env=None): """Modify environments that may affect ayon-launcher process. Main reason to implement this function is to pop PYTHONPATH which may be affected by in-host environments. Args: env (Optional[dict[str, str]]): Environment variables to modify. Returns: dict[str, str]: Environment variables for ayon process. """ if env is None: env = os.environ # Exclude some environment variables from a copy of the environment env = env.copy() for key in ["PYTHONPATH", "PYTHONHOME"]: env.pop(key, None) return env def clean_envs_for_openpype_process(env=None): """Modify environments that may affect OpenPype process. Main reason to implement this function is to pop PYTHONPATH which may be affected by in-host environments. """ if AYON_SERVER_ENABLED: return clean_envs_for_ayon_process(env=env) if env is None: env = os.environ # Exclude some environment variables from a copy of the environment env = env.copy() for key in ["PYTHONPATH", "PYTHONHOME"]: env.pop(key, None) return env def run_ayon_launcher_process(*args, **kwargs): """Execute OpenPype process with passed arguments and wait. Wrapper for 'run_process' which prepends OpenPype 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_openpype_process' function. Example: ``` run_ayon_process("run", "") ``` 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) env = kwargs.pop("env", None) # Keep env untouched if are passed and not empty if not env: # Skip envs that can affect OpenPype process # - fill more if you find more env = clean_envs_for_openpype_process(os.environ) # Only keep OpenPype version if we are running from build. if not is_running_from_build(): env.pop("OPENPYPE_VERSION", None) return run_subprocess(args, env=env, **kwargs) def run_openpype_process(*args, **kwargs): """Execute OpenPype process with passed arguments and wait. Wrapper for 'run_process' which prepends OpenPype 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_openpype_process' function. Example: >>> run_openpype_process("version") Args: *args (tuple): OpenPype cli arguments. **kwargs (dict): Keyword arguments for subprocess.Popen. """ if AYON_SERVER_ENABLED: return run_ayon_launcher_process(*args, **kwargs) args = get_openpype_execute_args(*args) env = kwargs.pop("env", None) # Keep env untouched if are passed and not empty if not env: # Skip envs that can affect OpenPype process # - fill more if you find more env = clean_envs_for_openpype_process(os.environ) # Only keep OpenPype version if we are running from build. if not is_running_from_build(): env.pop("OPENPYPE_VERSION", None) return run_subprocess(args, env=env, **kwargs) def run_detached_process(args, **kwargs): """Execute process with passed arguments as separated process. Values from 'os.environ' are used for environments if are not passed. They are cleaned using 'clean_envs_for_openpype_process' function. Example: >>> run_detached_process("run", "./path_to.py") Args: *args (tuple): OpenPype cli arguments. **kwargs (dict): Keyword arguments for subprocess.Popen. Returns: subprocess.Popen: Pointer to launched process but it is possible that launched process is already killed (on linux). """ env = kwargs.pop("env", None) # Keep env untouched if are passed and not empty if not env: env = os.environ # Create copy of passed env kwargs["env"] = {k: v for k, v in env.items()} low_platform = platform.system().lower() if low_platform == "darwin": new_args = ["open", "-na", args.pop(0), "--args"] new_args.extend(args) args = new_args elif low_platform == "windows": flags = ( subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS ) kwargs["creationflags"] = flags if not sys.stdout: kwargs["stdout"] = subprocess.DEVNULL kwargs["stderr"] = subprocess.DEVNULL elif low_platform == "linux" and get_linux_launcher_args() is not None: json_data = { "args": args, "env": kwargs.pop("env") } json_temp = tempfile.NamedTemporaryFile( mode="w", prefix="op_app_args", suffix=".json", delete=False ) json_temp.close() json_temp_filpath = json_temp.name with open(json_temp_filpath, "w") as stream: json.dump(json_data, stream) new_args = get_linux_launcher_args() new_args.append(json_temp_filpath) # Create mid-process which will launch application process = subprocess.Popen(new_args, **kwargs) # Wait until the process finishes # - This is important! The process would stay in "open" state. process.wait() # Remove the temp file os.remove(json_temp_filpath) # Return process which is already terminated return process process = subprocess.Popen(args, **kwargs) return process def path_to_subprocess_arg(path): """Prepare path for subprocess arguments. Returned path can be wrapped with quotes or kept as is. """ return subprocess.list2cmdline([path]) def get_ayon_launcher_args(*args): """Arguments to run ayon-launcher process. Arguments for subprocess when need to spawn new pype process. Which may be needed when new python process for pype scripts must be executed in build pype. Reasons: Ayon-launcher started from code has different executable set to virtual env python and must have path to script as first argument which is not needed for built application. Args: *args (str): Any arguments that will be added after executables. Returns: list[str]: List of arguments to run ayon-launcher process. """ executable = os.environ["AYON_EXECUTABLE"] launch_args = [executable] executable_filename = os.path.basename(executable) if "python" in executable_filename.lower(): filepath = os.path.join(os.environ["AYON_ROOT"], "start.py") launch_args.append(filepath) if args: launch_args.extend(args) return launch_args def get_openpype_execute_args(*args): """Arguments to run pype command. Arguments for subprocess when need to spawn new pype process. Which may be needed when new python process for pype scripts must be executed in build pype. ## Why is this needed? Pype executed from code has different executable set to virtual env python and must have path to script as first argument which is not needed for build pype. It is possible to pass any arguments that will be added after pype executables. """ if AYON_SERVER_ENABLED: return get_ayon_launcher_args(*args) executable = os.environ["OPENPYPE_EXECUTABLE"] launch_args = [executable] executable_filename = os.path.basename(executable) if "python" in executable_filename.lower(): filepath = os.path.join(os.environ["OPENPYPE_ROOT"], "start.py") launch_args.append(filepath) if args: launch_args.extend(args) return launch_args def get_linux_launcher_args(*args): """Path to application mid process executable. This function should be able as arguments are different when used from code and build. It is possible that this function is used in OpenPype build which does not have yet the new executable. In that case 'None' is returned. Todos: Replace by script in scripts for ayon-launcher. Args: args (iterable): List of additional arguments added after executable argument. Returns: list: Executables with possible positional argument to script when called from code. """ filename = "app_launcher" if AYON_SERVER_ENABLED: executable = os.environ["AYON_EXECUTABLE"] else: executable = os.environ["OPENPYPE_EXECUTABLE"] executable_filename = os.path.basename(executable) if "python" in executable_filename.lower(): if AYON_SERVER_ENABLED: root = os.environ["AYON_ROOT"] else: root = os.environ["OPENPYPE_ROOT"] script_path = os.path.join(root, "{}.py".format(filename)) launch_args = [executable, script_path] else: new_executable = os.path.join( os.path.dirname(executable), filename ) executable_path = find_executable(new_executable) if executable_path is None: return None launch_args = [executable_path] if args: launch_args.extend(args) return launch_args ================================================ FILE: openpype/lib/file_transaction.py ================================================ import os import logging import sys import errno import six from openpype.lib import create_hard_link # this is needed until speedcopy for linux is fixed if sys.platform == "win32": from speedcopy import copyfile else: from shutil import copyfile class DuplicateDestinationError(ValueError): """Error raised when transfer destination already exists in queue. The error is only raised if `allow_queue_replacements` is False on the FileTransaction instance and the added file to transfer is of a different src file than the one already detected in the queue. """ class FileTransaction(object): """File transaction with rollback options. The file transaction is a three-step process. 1) Rename any existing files to a "temporary backup" during `process()` 2) Copy the files to final destination during `process()` 3) Remove any backed up files (*no rollback possible!) during `finalize()` Step 3 is done during `finalize()`. If not called the .bak files will remain on disk. These steps try to ensure that we don't overwrite half of any existing files e.g. if they are currently in use. Note: A regular filesystem is *not* a transactional file system and even though this implementation tries to produce a 'safe copy' with a potential rollback do keep in mind that it's inherently unsafe due to how filesystem works and a myriad of things could happen during the transaction that break the logic. A file storage could go down, permissions could be changed, other machines could be moving or writing files. A lot can happen. Warning: Any folders created during the transfer will not be removed. """ MODE_COPY = 0 MODE_HARDLINK = 1 def __init__(self, log=None, allow_queue_replacements=False): if log is None: log = logging.getLogger("FileTransaction") self.log = log # The transfer queue # todo: make this an actual FIFO queue? self._transfers = {} # Destination file paths that a file was transferred to self._transferred = [] # Backup file location mapping to original locations self._backup_to_original = {} self._allow_queue_replacements = allow_queue_replacements def add(self, src, dst, mode=MODE_COPY): """Add a new file to transfer queue. Args: src (str): Source path. dst (str): Destination path. mode (MODE_COPY, MODE_HARDLINK): Transfer mode. """ opts = {"mode": mode} src = os.path.normpath(os.path.abspath(src)) dst = os.path.normpath(os.path.abspath(dst)) if dst in self._transfers: queued_src = self._transfers[dst][0] if src == queued_src: self.log.debug( "File transfer was already in queue: {} -> {}".format( src, dst)) return else: if not self._allow_queue_replacements: raise DuplicateDestinationError( "Transfer to destination is already in queue: " "{} -> {}. It's not allowed to be replaced by " "a new transfer from {}".format( queued_src, dst, src )) self.log.warning("File transfer in queue replaced..") self.log.debug( "Removed from queue: {} -> {} replaced by {} -> {}".format( queued_src, dst, src, dst)) self._transfers[dst] = (src, opts) def process(self): # Backup any existing files for dst, (src, _) in self._transfers.items(): self.log.debug("Checking file ... {} -> {}".format(src, dst)) path_same = self._same_paths(src, dst) if path_same or not os.path.exists(dst): continue # Backup original file # todo: add timestamp or uuid to ensure unique backup = dst + ".bak" self._backup_to_original[backup] = dst self.log.debug( "Backup existing file: {} -> {}".format(dst, backup)) os.rename(dst, backup) # Copy the files to transfer for dst, (src, opts) in self._transfers.items(): path_same = self._same_paths(src, dst) if path_same: self.log.debug( "Source and destination are same files {} -> {}".format( src, dst)) continue self._create_folder_for_file(dst) if opts["mode"] == self.MODE_COPY: self.log.debug("Copying file ... {} -> {}".format(src, dst)) copyfile(src, dst) elif opts["mode"] == self.MODE_HARDLINK: self.log.debug("Hardlinking file ... {} -> {}".format( src, dst)) create_hard_link(src, dst) self._transferred.append(dst) def finalize(self): # Delete any backed up files for backup in self._backup_to_original.keys(): try: os.remove(backup) except OSError: self.log.error( "Failed to remove backup file: {}".format(backup), exc_info=True) def rollback(self): errors = 0 # Rollback any transferred files for path in self._transferred: try: os.remove(path) except OSError: errors += 1 self.log.error( "Failed to rollback created file: {}".format(path), exc_info=True) # Rollback the backups for backup, original in self._backup_to_original.items(): try: os.rename(backup, original) except OSError: errors += 1 self.log.error( "Failed to restore original file: {} -> {}".format( backup, original), exc_info=True) if errors: self.log.error( "{} errors occurred during rollback.".format(errors), exc_info=True) six.reraise(*sys.exc_info()) @property def transferred(self): """Return the processed transfers destination paths""" return list(self._transferred) @property def backups(self): """Return the backup file paths""" return list(self._backup_to_original.keys()) def _create_folder_for_file(self, path): dirname = os.path.dirname(path) try: os.makedirs(dirname) except OSError as e: if e.errno == errno.EEXIST: pass else: self.log.critical("An unexpected error occurred.") six.reraise(*sys.exc_info()) def _same_paths(self, src, dst): # handles same paths but with C:/project vs c:/project if os.path.exists(src) and os.path.exists(dst): return os.stat(src) == os.stat(dst) return src == dst ================================================ FILE: openpype/lib/local_settings.py ================================================ # -*- coding: utf-8 -*- """Package to deal with saving and retrieving user specific settings.""" import os import json import getpass import platform from datetime import datetime from abc import ABCMeta, abstractmethod # TODO Use pype igniter logic instead of using duplicated code # disable lru cache in Python 2 try: from functools import lru_cache except ImportError: def lru_cache(maxsize): def max_size(func): def wrapper(*args, **kwargs): value = func(*args, **kwargs) return value return wrapper return max_size # ConfigParser was renamed in python3 to configparser try: import configparser except ImportError: import ConfigParser as configparser import six import appdirs from openpype import AYON_SERVER_ENABLED from openpype.settings import ( get_local_settings, get_system_settings ) from openpype.client.mongo import validate_mongo_connection from openpype.client import get_ayon_server_api_connection _PLACEHOLDER = object() class OpenPypeSecureRegistry: """Store information using keyring. Registry should be used for private data that should be available only for user. All passed registry names will have added prefix `OpenPype/` to easier identify which data were created by OpenPype. Args: name(str): Name of registry used as identifier for data. """ def __init__(self, name): try: import keyring except Exception: raise NotImplementedError( "Python module `keyring` is not available." ) # hack for cx_freeze and Windows keyring backend if platform.system().lower() == "windows": from keyring.backends import Windows keyring.set_keyring(Windows.WinVaultKeyring()) # Force "OpenPype" prefix self._name = "/".join(("OpenPype", name)) def set_item(self, name, value): # type: (str, str) -> None """Set sensitive item into system's keyring. This uses `Keyring module`_ to save sensitive stuff into system's keyring. Args: name (str): Name of the item. value (str): Value of the item. .. _Keyring module: https://github.com/jaraco/keyring """ import keyring keyring.set_password(self._name, name, value) @lru_cache(maxsize=32) def get_item(self, name, default=_PLACEHOLDER): """Get value of sensitive item from system's keyring. See also `Keyring module`_ Args: name (str): Name of the item. default (Any): Default value if item is not available. Returns: value (str): Value of the item. Raises: ValueError: If item doesn't exist and default is not defined. .. _Keyring module: https://github.com/jaraco/keyring """ import keyring value = keyring.get_password(self._name, name) if value is not None: return value if default is not _PLACEHOLDER: return default # NOTE Should raise `KeyError` raise ValueError( "Item {}:{} does not exist in keyring.".format(self._name, name) ) def delete_item(self, name): # type: (str) -> None """Delete value stored in system's keyring. See also `Keyring module`_ Args: name (str): Name of the item to be deleted. .. _Keyring module: https://github.com/jaraco/keyring """ import keyring self.get_item.cache_clear() keyring.delete_password(self._name, name) @six.add_metaclass(ABCMeta) class ASettingRegistry(): """Abstract class defining structure of **SettingRegistry** class. It is implementing methods to store secure items into keyring, otherwise mechanism for storing common items must be implemented in abstract methods. Attributes: _name (str): Registry names. """ def __init__(self, name): # type: (str) -> ASettingRegistry super(ASettingRegistry, self).__init__() self._name = name self._items = {} def set_item(self, name, value): # type: (str, str) -> None """Set item to settings registry. Args: name (str): Name of the item. value (str): Value of the item. """ self._set_item(name, value) @abstractmethod def _set_item(self, name, value): # type: (str, str) -> None # Implement it pass def __setitem__(self, name, value): self._items[name] = value self._set_item(name, value) def get_item(self, name): # type: (str) -> str """Get item from settings registry. Args: name (str): Name of the item. Returns: value (str): Value of the item. Raises: ValueError: If item doesn't exist. """ return self._get_item(name) @abstractmethod def _get_item(self, name): # type: (str) -> str # Implement it pass def __getitem__(self, name): return self._get_item(name) def delete_item(self, name): # type: (str) -> None """Delete item from settings registry. Args: name (str): Name of the item. """ self._delete_item(name) @abstractmethod def _delete_item(self, name): # type: (str) -> None """Delete item from settings. Note: see :meth:`openpype.lib.user_settings.ARegistrySettings.delete_item` """ pass def __delitem__(self, name): del self._items[name] self._delete_item(name) class IniSettingRegistry(ASettingRegistry): """Class using :mod:`configparser`. This class is using :mod:`configparser` (ini) files to store items. """ def __init__(self, name, path): # type: (str, str) -> IniSettingRegistry super(IniSettingRegistry, self).__init__(name) # get registry file version = os.getenv("OPENPYPE_VERSION", "N/A") self._registry_file = os.path.join(path, "{}.ini".format(name)) if not os.path.exists(self._registry_file): with open(self._registry_file, mode="w") as cfg: print("# Settings registry", cfg) print("# Generated by OpenPype {}".format(version), cfg) now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") print("# {}".format(now), cfg) def set_item_section( self, section, name, value): # type: (str, str, str) -> None """Set item to specific section of ini registry. If section doesn't exists, it is created. Args: section (str): Name of section. name (str): Name of the item. value (str): Value of the item. """ value = str(value) config = configparser.ConfigParser() config.read(self._registry_file) if not config.has_section(section): config.add_section(section) current = config[section] current[name] = value with open(self._registry_file, mode="w") as cfg: config.write(cfg) def _set_item(self, name, value): # type: (str, str) -> None self.set_item_section("MAIN", name, value) def set_item(self, name, value): # type: (str, str) -> None """Set item to settings ini file. This saves item to ``DEFAULT`` section of ini as each item there must reside in some section. Args: name (str): Name of the item. value (str): Value of the item. """ # this does the some, overridden just for different docstring. # we cast value to str as ini options values must be strings. super(IniSettingRegistry, self).set_item(name, str(value)) def get_item(self, name): # type: (str) -> str """Gets item from settings ini file. This gets settings from ``DEFAULT`` section of ini file as each item there must reside in some section. Args: name (str): Name of the item. Returns: str: Value of item. Raises: ValueError: If value doesn't exist. """ return super(IniSettingRegistry, self).get_item(name) @lru_cache(maxsize=32) def get_item_from_section(self, section, name): # type: (str, str) -> str """Get item from section of ini file. This will read ini file and try to get item value from specified section. If that section or item doesn't exist, :exc:`ValueError` is risen. Args: section (str): Name of ini section. name (str): Name of the item. Returns: str: Item value. Raises: ValueError: If value doesn't exist. """ config = configparser.ConfigParser() config.read(self._registry_file) try: value = config[section][name] except KeyError: raise ValueError( "Registry doesn't contain value {}:{}".format(section, name)) return value def _get_item(self, name): # type: (str) -> str return self.get_item_from_section("MAIN", name) def delete_item_from_section(self, section, name): # type: (str, str) -> None """Delete item from section in ini file. Args: section (str): Section name. name (str): Name of the item. Raises: ValueError: If item doesn't exist. """ self.get_item_from_section.cache_clear() config = configparser.ConfigParser() config.read(self._registry_file) try: _ = config[section][name] except KeyError: raise ValueError( "Registry doesn't contain value {}:{}".format(section, name)) config.remove_option(section, name) # if section is empty, delete it if len(config[section].keys()) == 0: config.remove_section(section) with open(self._registry_file, mode="w") as cfg: config.write(cfg) def _delete_item(self, name): """Delete item from default section. Note: See :meth:`~openpype.lib.IniSettingsRegistry.delete_item_from_section` """ self.delete_item_from_section("MAIN", name) class JSONSettingRegistry(ASettingRegistry): """Class using json file as storage.""" def __init__(self, name, path): # type: (str, str) -> JSONSettingRegistry super(JSONSettingRegistry, self).__init__(name) #: str: name of registry file self._registry_file = os.path.join(path, "{}.json".format(name)) now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") header = { "__metadata__": { "openpype-version": os.getenv("OPENPYPE_VERSION", "N/A"), "generated": now }, "registry": {} } if not os.path.exists(os.path.dirname(self._registry_file)): os.makedirs(os.path.dirname(self._registry_file), exist_ok=True) if not os.path.exists(self._registry_file): with open(self._registry_file, mode="w") as cfg: json.dump(header, cfg, indent=4) @lru_cache(maxsize=32) def _get_item(self, name): # type: (str) -> object """Get item value from registry json. Note: See :meth:`openpype.lib.JSONSettingRegistry.get_item` """ with open(self._registry_file, mode="r") as cfg: data = json.load(cfg) try: value = data["registry"][name] except KeyError: raise ValueError( "Registry doesn't contain value {}".format(name)) return value def get_item(self, name): # type: (str) -> object """Get item value from registry json. Args: name (str): Name of the item. Returns: value of the item Raises: ValueError: If item is not found in registry file. """ return self._get_item(name) def _set_item(self, name, value): # type: (str, object) -> None """Set item value to registry json. Note: See :meth:`openpype.lib.JSONSettingRegistry.set_item` """ with open(self._registry_file, "r+") as cfg: data = json.load(cfg) data["registry"][name] = value cfg.truncate(0) cfg.seek(0) json.dump(data, cfg, indent=4) def set_item(self, name, value): # type: (str, object) -> None """Set item and its value into json registry file. Args: name (str): name of the item. value (Any): value of the item. """ self._set_item(name, value) def _delete_item(self, name): # type: (str) -> None self._get_item.cache_clear() with open(self._registry_file, "r+") as cfg: data = json.load(cfg) del data["registry"][name] cfg.truncate(0) cfg.seek(0) json.dump(data, cfg, indent=4) class OpenPypeSettingsRegistry(JSONSettingRegistry): """Class handling OpenPype general settings registry. Attributes: vendor (str): Name used for path construction. product (str): Additional name used for path construction. """ def __init__(self, name=None): if AYON_SERVER_ENABLED: vendor = "Ynput" product = "AYON" default_name = "AYON_settings" else: vendor = "pypeclub" product = "openpype" default_name = "openpype_settings" self.vendor = vendor self.product = product if not name: name = default_name path = appdirs.user_data_dir(self.product, self.vendor) super(OpenPypeSettingsRegistry, self).__init__(name, path) def _create_local_site_id(registry=None): """Create a local site identifier.""" from coolname import generate_slug if registry is None: registry = OpenPypeSettingsRegistry() new_id = generate_slug(3) print("Created local site id \"{}\"".format(new_id)) registry.set_item("localId", new_id) return new_id def get_ayon_appdirs(*args): """Local app data directory of AYON client. Args: *args (Iterable[str]): Subdirectories/files in local app data dir. Returns: str: Path to directory/file in local app data dir. """ return os.path.join( appdirs.user_data_dir("AYON", "Ynput"), *args ) def _get_ayon_local_site_id(): # used for background syncing site_id = os.environ.get("AYON_SITE_ID") if site_id: return site_id site_id_path = get_ayon_appdirs("site_id") if os.path.exists(site_id_path): with open(site_id_path, "r") as stream: site_id = stream.read() if site_id: return site_id try: from ayon_common.utils import get_local_site_id as _get_local_site_id site_id = _get_local_site_id() except ImportError: raise ValueError("Couldn't access local site id") return site_id def get_local_site_id(): """Get local site identifier. Identifier is created if does not exists yet. """ if AYON_SERVER_ENABLED: return _get_ayon_local_site_id() # override local id from environment # used for background syncing if os.environ.get("OPENPYPE_LOCAL_ID"): return os.environ["OPENPYPE_LOCAL_ID"] registry = OpenPypeSettingsRegistry() try: return registry.get_item("localId") except ValueError: return _create_local_site_id() def change_openpype_mongo_url(new_mongo_url): """Change mongo url in pype registry. Change of OpenPype mongo URL require restart of running pype processes or processes using pype. """ validate_mongo_connection(new_mongo_url) key = "openPypeMongo" registry = OpenPypeSecureRegistry("mongodb") existing_value = registry.get_item(key, None) if existing_value is not None: registry.delete_item(key) registry.set_item(key, new_mongo_url) def get_openpype_username(): """OpenPype username used for templates and publishing. May be different than machine's username. Always returns "OPENPYPE_USERNAME" environment if is set then tries local settings and last option is to use `getpass.getuser()` which returns machine username. """ if AYON_SERVER_ENABLED: con = get_ayon_server_api_connection() return con.get_user()["name"] username = os.environ.get("OPENPYPE_USERNAME") if not username: local_settings = get_local_settings() username = ( local_settings .get("general", {}) .get("username") ) if not username: username = getpass.getuser() return username def is_admin_password_required(): system_settings = get_system_settings() password = system_settings["general"].get("admin_password") if not password: return False local_settings = get_local_settings() is_admin = local_settings.get("general", {}).get("is_admin", False) if is_admin: return False return True ================================================ FILE: openpype/lib/log.py ================================================ """ Logging to console and to mongo. For mongo logging, you need to set either ``OPENPYPE_LOG_MONGO_URL`` to something like: .. example:: mongo://user:password@hostname:port/database/collection?authSource=avalon or set ``OPENPYPE_LOG_MONGO_HOST`` and other variables. See :func:`_mongo_settings` Best place for it is in ``repos/pype-config/environments/global.json`` """ import datetime import getpass import logging import os import platform import socket import sys import time import traceback import threading import copy from openpype import AYON_SERVER_ENABLED from openpype.client.mongo import ( MongoEnvNotSet, get_default_components, OpenPypeMongoConnection, ) from . import Terminal try: import log4mongo from log4mongo.handlers import MongoHandler except ImportError: log4mongo = None MongoHandler = type("NOT_SET", (), {}) # Check for `unicode` in builtins USE_UNICODE = hasattr(__builtins__, "unicode") class LogStreamHandler(logging.StreamHandler): """ StreamHandler class designed to handle utf errors in python 2.x hosts. """ def __init__(self, stream=None): super(LogStreamHandler, self).__init__(stream) self.enabled = True def enable(self): """ Enable StreamHandler Used to silence output """ self.enabled = True def disable(self): """ Disable StreamHandler Make StreamHandler output again """ self.enabled = False def emit(self, record): if not self.enable: return try: msg = self.format(record) msg = Terminal.log(msg) stream = self.stream if stream is None: return fs = "%s\n" # if no unicode support... if not USE_UNICODE: stream.write(fs % msg) else: try: if (isinstance(msg, unicode) and # noqa: F821 getattr(stream, 'encoding', None)): ufs = u'%s\n' try: stream.write(ufs % msg) except UnicodeEncodeError: stream.write((ufs % msg).encode(stream.encoding)) else: if (getattr(stream, 'encoding', 'utf-8')): ufs = u'%s\n' stream.write(ufs % unicode(msg)) # noqa: F821 else: stream.write(fs % msg) except UnicodeError: stream.write(fs % msg.encode("UTF-8")) self.flush() except (KeyboardInterrupt, SystemExit): raise except OSError: self.handleError(record) except ValueError: # this is raised when logging during interpreter shutdown # or it real edge cases where logging stream is already closed. # In particular, it happens a lot in 3DEqualizer. # TODO: remove this condition when the cause is found. pass except Exception: print(repr(record)) self.handleError(record) class LogFormatter(logging.Formatter): DFT = '%(levelname)s >>> { %(name)s }: [ %(message)s ]' default_formatter = logging.Formatter(DFT) def __init__(self, formats): super(LogFormatter, self).__init__() self.formatters = {} for loglevel in formats: self.formatters[loglevel] = logging.Formatter(formats[loglevel]) def format(self, record): formatter = self.formatters.get(record.levelno, self.default_formatter) _exc_info = record.exc_info record.exc_info = None out = formatter.format(record) record.exc_info = _exc_info if record.exc_info is not None: line_len = len(str(record.exc_info[1])) if line_len > 30: line_len = 30 out = "{}\n{}\n{}\n{}\n{}".format( out, line_len * "=", str(record.exc_info[1]), line_len * "=", self.formatException(record.exc_info) ) return out class MongoFormatter(logging.Formatter): DEFAULT_PROPERTIES = logging.LogRecord( '', '', '', '', '', '', '', '').__dict__.keys() def format(self, record): """Formats LogRecord into python dictionary.""" # Standard document document = { 'timestamp': datetime.datetime.now(), 'level': record.levelname, 'thread': record.thread, 'threadName': record.threadName, 'message': record.getMessage(), 'loggerName': record.name, 'fileName': record.pathname, 'module': record.module, 'method': record.funcName, 'lineNumber': record.lineno } document.update(Logger.get_process_data()) # Standard document decorated with exception info if record.exc_info is not None: document['exception'] = { 'message': str(record.exc_info[1]), 'code': 0, 'stackTrace': self.formatException(record.exc_info) } # Standard document decorated with extra contextual information if len(self.DEFAULT_PROPERTIES) != len(record.__dict__): contextual_extra = set(record.__dict__).difference( set(self.DEFAULT_PROPERTIES)) if contextual_extra: for key in contextual_extra: document[key] = record.__dict__[key] return document class Logger: DFT = '%(levelname)s >>> { %(name)s }: [ %(message)s ] ' DBG = " - { %(name)s }: [ %(message)s ] " INF = ">>> [ %(message)s ] " WRN = "*** WRN: >>> { %(name)s }: [ %(message)s ] " ERR = "!!! ERR: %(asctime)s >>> { %(name)s }: [ %(message)s ] " CRI = "!!! CRI: %(asctime)s >>> { %(name)s }: [ %(message)s ] " FORMAT_FILE = { logging.INFO: INF, logging.DEBUG: DBG, logging.WARNING: WRN, logging.ERROR: ERR, logging.CRITICAL: CRI, } # Is static class initialized bootstraped = False initialized = False _init_lock = threading.Lock() # Defines if mongo logging should be used use_mongo_logging = None mongo_process_id = None # Backwards compatibility - was used in start.py # TODO remove when all old builds are replaced with new one # not using 'log_mongo_url_components' log_mongo_url_components = None # Database name in Mongo log_database_name = os.environ.get("OPENPYPE_DATABASE_NAME") # Collection name under database in Mongo log_collection_name = "logs" # Logging level - OPENPYPE_LOG_LEVEL log_level = None # Data same for all record documents process_data = None # Cached process name or ability to set different process name _process_name = None @classmethod def get_logger(cls, name=None, _host=None): if not cls.initialized: cls.initialize() logger = logging.getLogger(name or "__main__") logger.setLevel(cls.log_level) add_mongo_handler = cls.use_mongo_logging add_console_handler = True for handler in logger.handlers: if isinstance(handler, MongoHandler): add_mongo_handler = False elif isinstance(handler, LogStreamHandler): add_console_handler = False if add_console_handler: logger.addHandler(cls._get_console_handler()) if add_mongo_handler: try: handler = cls._get_mongo_handler() if handler: logger.addHandler(handler) except MongoEnvNotSet: # Skip if mongo environments are not set yet cls.use_mongo_logging = False except Exception: lines = traceback.format_exception(*sys.exc_info()) for line in lines: if line.endswith("\n"): line = line[:-1] Terminal.echo(line) cls.use_mongo_logging = False # Do not propagate logs to root logger logger.propagate = False if _host is not None: # Warn about deprecated argument # TODO remove backwards compatibility of host argument which is # not used for more than a year logger.warning( "Logger \"{}\" is using argument `host` on `get_logger`" " which is deprecated. Please remove as backwards" " compatibility will be removed soon." ) return logger @classmethod def _get_mongo_handler(cls): cls.bootstrap_mongo_log() if not cls.use_mongo_logging: return components = get_default_components() kwargs = { "host": components["host"], "database_name": cls.log_database_name, "collection": cls.log_collection_name, "username": components["username"], "password": components["password"], "capped": True, "formatter": MongoFormatter() } if components["port"] is not None: kwargs["port"] = int(components["port"]) if components["auth_db"]: kwargs["authentication_db"] = components["auth_db"] return MongoHandler(**kwargs) @classmethod def _get_console_handler(cls): formatter = LogFormatter(cls.FORMAT_FILE) console_handler = LogStreamHandler() console_handler.set_name("LogStreamHandler") console_handler.setFormatter(formatter) return console_handler @classmethod def initialize(cls): # TODO update already created loggers on re-initialization if not cls._init_lock.locked(): with cls._init_lock: cls._initialize() else: # If lock is locked wait until is finished while cls._init_lock.locked(): time.sleep(0.1) @classmethod def _initialize(cls): # Change initialization state to prevent runtime changes # if is executed during runtime cls.initialized = False if not AYON_SERVER_ENABLED: cls.log_mongo_url_components = get_default_components() # Define if should logging to mongo be used if AYON_SERVER_ENABLED: use_mongo_logging = False else: use_mongo_logging = ( log4mongo is not None and os.environ.get("OPENPYPE_LOG_TO_SERVER") == "1" ) # Set mongo id for process (ONLY ONCE) if use_mongo_logging and cls.mongo_process_id is None: try: from bson.objectid import ObjectId except Exception: use_mongo_logging = False # Check if mongo id was passed with environments and pop it # - This is for subprocesses that are part of another process # like Ftrack event server has 3 other subprocesses that should # use same mongo id if use_mongo_logging: mongo_id = os.environ.pop("OPENPYPE_PROCESS_MONGO_ID", None) if not mongo_id: # Create new object id mongo_id = ObjectId() else: # Convert string to ObjectId object mongo_id = ObjectId(mongo_id) cls.mongo_process_id = mongo_id # Store result to class definition cls.use_mongo_logging = use_mongo_logging # Define what is logging level log_level = os.getenv("OPENPYPE_LOG_LEVEL") if not log_level: # Check OPENPYPE_DEBUG for backwards compatibility op_debug = os.getenv("OPENPYPE_DEBUG") if op_debug and int(op_debug) > 0: log_level = 10 else: log_level = 20 cls.log_level = int(log_level) if not os.environ.get("OPENPYPE_MONGO"): cls.use_mongo_logging = False # Mark as initialized cls.initialized = True @classmethod def get_process_data(cls): """Data about current process which should be same for all records. Process data are used for each record sent to mongo database. """ if cls.process_data is not None: return copy.deepcopy(cls.process_data) if not cls.initialized: cls.initialize() host_name = socket.gethostname() try: host_ip = socket.gethostbyname(host_name) except socket.gaierror: host_ip = "127.0.0.1" process_name = cls.get_process_name() cls.process_data = { "process_id": cls.mongo_process_id, "hostname": host_name, "hostip": host_ip, "username": getpass.getuser(), "system_name": platform.system(), "process_name": process_name } return copy.deepcopy(cls.process_data) @classmethod def set_process_name(cls, process_name): """Set process name for mongo logs.""" # Just change the attribute cls._process_name = process_name # Update process data if are already set if cls.process_data is not None: cls.process_data["process_name"] = process_name @classmethod def get_process_name(cls): """Process name that is like "label" of a process. OpenPype's logging can be used from OpenPyppe itself of from hosts. Even in OpenPype process it's good to know if logs are from tray or from other cli commands. This should help to identify that information. """ if cls._process_name is not None: return cls._process_name # Get process name process_name = os.environ.get("AVALON_APP_NAME") if not process_name: try: import psutil process = psutil.Process(os.getpid()) process_name = process.name() except ImportError: pass if not process_name: process_name = os.path.basename(sys.executable) cls._process_name = process_name return cls._process_name @classmethod def bootstrap_mongo_log(cls): """Prepare mongo logging.""" if cls.bootstraped: return if not cls.initialized: cls.initialize() if not cls.use_mongo_logging: return if not cls.log_database_name: raise ValueError("Database name for logs is not set") client = log4mongo.handlers._connection if not client: client = cls.get_log_mongo_connection() # Set the client inside log4mongo handlers to not create another # mongo db connection. log4mongo.handlers._connection = client logdb = client[cls.log_database_name] collist = logdb.list_collection_names() if cls.log_collection_name not in collist: logdb.create_collection( cls.log_collection_name, capped=True, max=5000, size=1073741824 ) cls.bootstraped = True @classmethod def get_log_mongo_connection(cls): """Mongo connection that allows to get to log collection. This is implemented to prevent multiple connections to mongo from same process. """ if not cls.initialized: cls.initialize() return OpenPypeMongoConnection.get_mongo_client() ================================================ FILE: openpype/lib/openpype_version.py ================================================ """Lib access to OpenPypeVersion from igniter. Access to logic from igniter is available only for OpenPype processes. Is meant to be able check OpenPype versions for studio. The logic is dependent on igniter's inner logic of versions. Keep in mind that all functions except 'get_installed_version' does not return OpenPype version located in build but versions available in remote versions repository or locally available. """ import os import sys import openpype.version from openpype import AYON_SERVER_ENABLED from .python_module_tools import import_filepath # ---------------------------------------- # Functions independent on OpenPypeVersion # ---------------------------------------- def get_openpype_version(): """Version of pype that is currently used.""" return openpype.version.__version__ def get_ayon_launcher_version(): version_filepath = os.path.join( os.environ["AYON_ROOT"], "version.py" ) if not os.path.exists(version_filepath): return None content = {} with open(version_filepath, "r") as stream: exec(stream.read(), content) return content["__version__"] def get_build_version(): """OpenPype version of build.""" if AYON_SERVER_ENABLED: return get_ayon_launcher_version() # Return OpenPype version if is running from code if not is_running_from_build(): return get_openpype_version() # Import `version.py` from build directory version_filepath = os.path.join( os.environ["OPENPYPE_ROOT"], "openpype", "version.py" ) if not os.path.exists(version_filepath): return None module = import_filepath(version_filepath, "openpype_build_version") return getattr(module, "__version__", None) def is_running_from_build(): """Determine if current process is running from build or code. Returns: bool: True if running from build. """ if AYON_SERVER_ENABLED: executable_path = os.environ["AYON_EXECUTABLE"] else: executable_path = os.environ["OPENPYPE_EXECUTABLE"] executable_filename = os.path.basename(executable_path) if "python" in executable_filename.lower(): return False return True def is_staging_enabled(): if AYON_SERVER_ENABLED: return os.getenv("AYON_USE_STAGING") == "1" return os.environ.get("OPENPYPE_USE_STAGING") == "1" def is_running_staging(): """Currently used OpenPype is staging version. This function is not 100% proper check of staging version. It is possible to have enabled to use staging version but be in different one. The function is based on 4 factors: - env 'OPENPYPE_IS_STAGING' is set - current production version - current staging version - use staging is enabled First checks for 'OPENPYPE_IS_STAGING' environment which can be set to '1'. The value should be set only when a process without access to OpenPypeVersion is launched (e.g. in DCCs). If current version is same as production version it is expected that it is not staging, and it doesn't matter what would 'is_staging_enabled' return. If current version is same as staging version it is expected we're in staging. In all other cases 'is_staging_enabled' is used as source of outpu value. The function is used to decide which icon is used. To check e.g. updates the output should be combined with other functions from this file. Returns: bool: Using staging version or not. """ if AYON_SERVER_ENABLED: return is_staging_enabled() if os.environ.get("OPENPYPE_IS_STAGING") == "1": return True if not op_version_control_available(): return False from openpype.settings import get_global_settings global_settings = get_global_settings() production_version = global_settings["production_version"] latest_version = None if not production_version or production_version == "latest": latest_version = get_latest_version(local=False, remote=True) production_version = latest_version current_version = get_openpype_version() if current_version == production_version: return False staging_version = global_settings["staging_version"] if not staging_version or staging_version == "latest": if latest_version is None: latest_version = get_latest_version(local=False, remote=True) staging_version = latest_version if current_version == staging_version: return True return is_staging_enabled() # ---------------------------------------- # Functions dependent on OpenPypeVersion # - Make sense to call only in OpenPype process # ---------------------------------------- def get_OpenPypeVersion(): """Access to OpenPypeVersion class stored in sys modules.""" return sys.modules.get("OpenPypeVersion") def op_version_control_available(): """Check if current process has access to OpenPypeVersion.""" if get_OpenPypeVersion() is None: return False return True def get_installed_version(): """Get OpenPype version inside build. This version is not returned by any other functions here. """ if op_version_control_available(): return get_OpenPypeVersion().get_installed_version() return None def get_available_versions(*args, **kwargs): """Get list of available versions.""" if op_version_control_available(): return get_OpenPypeVersion().get_available_versions( *args, **kwargs ) return None def openpype_path_is_set(): """OpenPype repository path is set in settings.""" if op_version_control_available(): return get_OpenPypeVersion().openpype_path_is_set() return None def openpype_path_is_accessible(): """OpenPype version repository path can be accessed.""" if op_version_control_available(): return get_OpenPypeVersion().openpype_path_is_accessible() return None def get_local_versions(*args, **kwargs): """OpenPype versions available on this workstation.""" if op_version_control_available(): return get_OpenPypeVersion().get_local_versions(*args, **kwargs) return None def get_remote_versions(*args, **kwargs): """OpenPype versions in repository path.""" if op_version_control_available(): return get_OpenPypeVersion().get_remote_versions(*args, **kwargs) return None def get_latest_version(local=None, remote=None): """Get latest version from repository path.""" if op_version_control_available(): return get_OpenPypeVersion().get_latest_version( local=local, remote=remote ) return None def get_expected_studio_version(staging=None): """Expected production or staging version in studio.""" if op_version_control_available(): if staging is None: staging = is_staging_enabled() return get_OpenPypeVersion().get_expected_studio_version(staging) return None def get_expected_version(staging=None): expected_version = get_expected_studio_version(staging) if expected_version is None: # Look for latest if expected version is not set in settings expected_version = get_latest_version( local=False, remote=True ) return expected_version def is_current_version_studio_latest(): """Is currently running OpenPype version which is defined by studio. It is not recommended to ask in each process as there may be situations when older OpenPype should be used. For example on farm. But it does make sense in processes that can run for a long time. Returns: None: Can't determine. e.g. when running from code or the build is too old. bool: True when is using studio """ output = None # Skip if is not running from build or build does not support version # control or path to folder with zip files is not accessible if ( not is_running_from_build() or not op_version_control_available() or not openpype_path_is_accessible() ): return output # Get OpenPypeVersion class OpenPypeVersion = get_OpenPypeVersion() # Convert current version to OpenPypeVersion object current_version = OpenPypeVersion(version=get_openpype_version()) # Get expected version (from settings) expected_version = get_expected_version() # Check if current version is expected version return current_version == expected_version def is_current_version_higher_than_expected(): """Is current OpenPype version higher than version defined by studio. Returns: None: Can't determine. e.g. when running from code or the build is too old. bool: True when is higher than studio version. """ output = None # Skip if is not running from build or build does not support version # control or path to folder with zip files is not accessible if ( not is_running_from_build() or not op_version_control_available() or not openpype_path_is_accessible() ): return output # Get OpenPypeVersion class OpenPypeVersion = get_OpenPypeVersion() # Convert current version to OpenPypeVersion object current_version = OpenPypeVersion(version=get_openpype_version()) # Get expected version (from settings) expected_version = get_expected_version() # Check if current version is expected version return current_version > expected_version ================================================ FILE: openpype/lib/path_templates.py ================================================ import os import re import copy import numbers import collections import six KEY_PATTERN = re.compile(r"(\{.*?[^{0]*\})") KEY_PADDING_PATTERN = re.compile(r"([^:]+)\S+[><]\S+") SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)") OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") def merge_dict(main_dict, enhance_dict): """Merges dictionaries by keys. Function call itself if value on key is again dictionary. Args: main_dict (dict): First dict to merge second one into. enhance_dict (dict): Second dict to be merged. Returns: dict: Merged result. .. note:: does not overrides whole value on first found key but only values differences from enhance_dict """ for key, value in enhance_dict.items(): if key not in main_dict: main_dict[key] = value elif isinstance(value, dict) and isinstance(main_dict[key], dict): main_dict[key] = merge_dict(main_dict[key], value) else: main_dict[key] = value return main_dict class TemplateMissingKey(Exception): """Exception for cases when key does not exist in template.""" msg = "Template key does not exist: `{}`." def __init__(self, parents): parent_join = "".join(["[\"{0}\"]".format(key) for key in parents]) super(TemplateMissingKey, self).__init__( self.msg.format(parent_join) ) class TemplateUnsolved(Exception): """Exception for unsolved template when strict is set to True.""" msg = "Template \"{0}\" is unsolved.{1}{2}" invalid_types_msg = " Keys with invalid DataType: `{0}`." missing_keys_msg = " Missing keys: \"{0}\"." def __init__(self, template, missing_keys, invalid_types): invalid_type_items = [] for _key, _type in invalid_types.items(): invalid_type_items.append( "\"{0}\" {1}".format(_key, str(_type)) ) invalid_types_msg = "" if invalid_type_items: invalid_types_msg = self.invalid_types_msg.format( ", ".join(invalid_type_items) ) missing_keys_msg = "" if missing_keys: missing_keys_msg = self.missing_keys_msg.format( ", ".join(missing_keys) ) super(TemplateUnsolved, self).__init__( self.msg.format(template, missing_keys_msg, invalid_types_msg) ) class StringTemplate(object): """String that can be formatted.""" def __init__(self, template): if not isinstance(template, six.string_types): raise TypeError("<{}> argument must be a string, not {}.".format( self.__class__.__name__, str(type(template)) )) self._template = template parts = [] last_end_idx = 0 for item in KEY_PATTERN.finditer(template): start, end = item.span() if start > last_end_idx: parts.append(template[last_end_idx:start]) parts.append(FormattingPart(template[start:end])) last_end_idx = end if last_end_idx < len(template): parts.append(template[last_end_idx:len(template)]) new_parts = [] for part in parts: if not isinstance(part, six.string_types): new_parts.append(part) continue substr = "" for char in part: if char not in ("<", ">"): substr += char else: if substr: new_parts.append(substr) new_parts.append(char) substr = "" if substr: new_parts.append(substr) self._parts = self.find_optional_parts(new_parts) def __str__(self): return self.template def __repr__(self): return "<{}> {}".format(self.__class__.__name__, self.template) def __contains__(self, other): return other in self.template def replace(self, *args, **kwargs): self._template = self.template.replace(*args, **kwargs) return self @property def template(self): return self._template def format(self, data): """ Figure out with whole formatting. Separate advanced keys (*Like '{project[name]}') from string which must be formatted separatelly in case of missing or incomplete keys in data. Args: data (dict): Containing keys to be filled into template. Returns: TemplateResult: Filled or partially filled template containing all data needed or missing for filling template. """ result = TemplatePartResult() for part in self._parts: if isinstance(part, six.string_types): result.add_output(part) else: part.format(data, result) invalid_types = result.invalid_types invalid_types.update(result.invalid_optional_types) invalid_types = result.split_keys_to_subdicts(invalid_types) missing_keys = result.missing_keys missing_keys |= result.missing_optional_keys solved = result.solved used_values = result.get_clean_used_values() return TemplateResult( result.output, self.template, solved, used_values, missing_keys, invalid_types ) def format_strict(self, *args, **kwargs): result = self.format(*args, **kwargs) result.validate() return result @classmethod def format_template(cls, template, data): objected_template = cls(template) return objected_template.format(data) @classmethod def format_strict_template(cls, template, data): objected_template = cls(template) return objected_template.format_strict(data) @staticmethod def find_optional_parts(parts): new_parts = [] tmp_parts = {} counted_symb = -1 for part in parts: if part == "<": counted_symb += 1 tmp_parts[counted_symb] = [] elif part == ">": if counted_symb > -1: parts = tmp_parts.pop(counted_symb) counted_symb -= 1 # If part contains only single string keep value # unchanged if parts: # Remove optional start char parts.pop(0) if not parts: value = "<>" elif ( len(parts) == 1 and isinstance(parts[0], six.string_types) ): value = "<{}>".format(parts[0]) else: value = OptionalPart(parts) if counted_symb < 0: out_parts = new_parts else: out_parts = tmp_parts[counted_symb] # Store value out_parts.append(value) continue if counted_symb < 0: new_parts.append(part) else: tmp_parts[counted_symb].append(part) if tmp_parts: for idx in sorted(tmp_parts.keys()): new_parts.extend(tmp_parts[idx]) return new_parts class TemplatesDict(object): def __init__(self, templates=None): self._raw_templates = None self._templates = None self._objected_templates = None self.set_templates(templates) def set_templates(self, templates): if templates is None: self._raw_templates = None self._templates = None self._objected_templates = None elif isinstance(templates, dict): self._raw_templates = copy.deepcopy(templates) self._templates = templates self._objected_templates = self.create_objected_templates( templates) else: raise TypeError("<{}> argument must be a dict, not {}.".format( self.__class__.__name__, str(type(templates)) )) def __getitem__(self, key): return self.objected_templates[key] def get(self, key, *args, **kwargs): return self.objected_templates.get(key, *args, **kwargs) @property def raw_templates(self): return self._raw_templates @property def templates(self): return self._templates @property def objected_templates(self): return self._objected_templates def _create_template_object(self, template): """Create template object from a template string. Separated into method to give option change class of templates. Args: template (str): Template string. Returns: StringTemplate: Object of template. """ return StringTemplate(template) def create_objected_templates(self, templates): if not isinstance(templates, dict): raise TypeError("Expected dict object, got {}".format( str(type(templates)) )) objected_templates = copy.deepcopy(templates) inner_queue = collections.deque() inner_queue.append(objected_templates) while inner_queue: item = inner_queue.popleft() if not isinstance(item, dict): continue for key in tuple(item.keys()): value = item[key] if isinstance(value, six.string_types): item[key] = self._create_template_object(value) elif isinstance(value, dict): inner_queue.append(value) return objected_templates def _format_value(self, value, data): if isinstance(value, StringTemplate): return value.format(data) if isinstance(value, dict): return self._solve_dict(value, data) return value def _solve_dict(self, templates, data): """ Solves templates with entered data. Args: templates (dict): All templates which will be formatted. data (dict): Containing keys to be filled into template. Returns: dict: With `TemplateResult` in values containing filled or partially filled templates. """ output = collections.defaultdict(dict) for key, value in templates.items(): output[key] = self._format_value(value, data) return output def format(self, in_data, only_keys=True, strict=True): """ Solves templates based on entered data. Args: data (dict): Containing keys to be filled into template. only_keys (bool, optional): Decides if environ will be used to fill templates or only keys in data. Returns: TemplatesResultDict: Output `TemplateResult` have `strict` attribute set to True so accessing unfilled keys in templates will raise exceptions with explaned error. """ # Create a copy of inserted data data = copy.deepcopy(in_data) # Add environment variable to data if only_keys is False: for key, val in os.environ.items(): env_key = "$" + key if env_key not in data: data[env_key] = val solved = self._solve_dict(self.objected_templates, data) output = TemplatesResultDict(solved) output.strict = strict return output class TemplateResult(str): """Result of template format with most of information in. Args: used_values (dict): Dictionary of template filling data with only used keys. solved (bool): For check if all required keys were filled. template (str): Original template. missing_keys (list): Missing keys that were not in the data. Include missing optional keys. invalid_types (dict): When key was found in data, but value had not allowed DataType. Allowed data types are `numbers`, `str`(`basestring`) and `dict`. Dictionary may cause invalid type when value of key in data is dictionary but template expect string of number. """ used_values = None solved = None template = None missing_keys = None invalid_types = None def __new__( cls, filled_template, template, solved, used_values, missing_keys, invalid_types ): new_obj = super(TemplateResult, cls).__new__(cls, filled_template) new_obj.used_values = used_values new_obj.solved = solved new_obj.template = template new_obj.missing_keys = list(set(missing_keys)) new_obj.invalid_types = invalid_types return new_obj def __copy__(self, *args, **kwargs): return self.copy() def __deepcopy__(self, *args, **kwargs): return self.copy() def validate(self): if not self.solved: raise TemplateUnsolved( self.template, self.missing_keys, self.invalid_types ) def copy(self): cls = self.__class__ return cls( str(self), self.template, self.solved, self.used_values, self.missing_keys, self.invalid_types ) def normalized(self): """Convert to normalized path.""" cls = self.__class__ return cls( os.path.normpath(self.replace("\\", "/")), self.template, self.solved, self.used_values, self.missing_keys, self.invalid_types ) class TemplatesResultDict(dict): """Holds and wrap TemplateResults for easy bug report.""" def __init__(self, in_data, key=None, parent=None, strict=None): super(TemplatesResultDict, self).__init__() for _key, _value in in_data.items(): if isinstance(_value, dict): _value = self.__class__(_value, _key, self) self[_key] = _value self.key = key self.parent = parent self.strict = strict if self.parent is None and strict is None: self.strict = True def __getitem__(self, key): if key not in self.keys(): hier = self.hierarchy() hier.append(key) raise TemplateMissingKey(hier) value = super(TemplatesResultDict, self).__getitem__(key) if isinstance(value, self.__class__): return value # Raise exception when expected solved templates and it is not. if self.raise_on_unsolved and hasattr(value, "validate"): value.validate() return value @property def raise_on_unsolved(self): """To affect this change `strict` attribute.""" if self.strict is not None: return self.strict return self.parent.raise_on_unsolved def hierarchy(self): """Return dictionary keys one by one to root parent.""" if self.parent is None: return [] hier_keys = [] par_hier = self.parent.hierarchy() if par_hier: hier_keys.extend(par_hier) hier_keys.append(self.key) return hier_keys @property def missing_keys(self): """Return missing keys of all children templates.""" missing_keys = set() for value in self.values(): missing_keys |= value.missing_keys return missing_keys @property def invalid_types(self): """Return invalid types of all children templates.""" invalid_types = {} for value in self.values(): invalid_types = merge_dict(invalid_types, value.invalid_types) return invalid_types @property def used_values(self): """Return used values for all children templates.""" used_values = {} for value in self.values(): used_values = merge_dict(used_values, value.used_values) return used_values def get_solved(self): """Get only solved key from templates.""" result = {} for key, value in self.items(): if isinstance(value, self.__class__): value = value.get_solved() if not value: continue result[key] = value elif ( not hasattr(value, "solved") or value.solved ): result[key] = value return self.__class__(result, key=self.key, parent=self.parent) class TemplatePartResult: """Result to store result of template parts.""" def __init__(self, optional=False): # Missing keys or invalid value types of required keys self._missing_keys = set() self._invalid_types = {} # Missing keys or invalid value types of optional keys self._missing_optional_keys = set() self._invalid_optional_types = {} # Used values stored by key with origin type # - key without any padding or key modifiers # - value from filling data # Example: {"version": 1} self._used_values = {} # Used values stored by key with all modifirs # - value is already formatted string # Example: {"version:0>3": "001"} self._realy_used_values = {} # Concatenated string output after formatting self._output = "" # Is this result from optional part self._optional = True def add_output(self, other): if isinstance(other, six.string_types): self._output += other elif isinstance(other, TemplatePartResult): self._output += other.output self._missing_keys |= other.missing_keys self._missing_optional_keys |= other.missing_optional_keys self._invalid_types.update(other.invalid_types) self._invalid_optional_types.update(other.invalid_optional_types) if other.optional and not other.solved: return self._used_values.update(other.used_values) self._realy_used_values.update(other.realy_used_values) else: raise TypeError("Cannot add data from \"{}\" to \"{}\"".format( str(type(other)), self.__class__.__name__) ) @property def solved(self): if self.optional: if ( len(self.missing_optional_keys) > 0 or len(self.invalid_optional_types) > 0 ): return False return ( len(self.missing_keys) == 0 and len(self.invalid_types) == 0 ) @property def optional(self): return self._optional @property def output(self): return self._output @property def missing_keys(self): return self._missing_keys @property def missing_optional_keys(self): return self._missing_optional_keys @property def invalid_types(self): return self._invalid_types @property def invalid_optional_types(self): return self._invalid_optional_types @property def realy_used_values(self): return self._realy_used_values @property def used_values(self): return self._used_values @staticmethod def split_keys_to_subdicts(values): output = {} for key, value in values.items(): key_padding = list(KEY_PADDING_PATTERN.findall(key)) if key_padding: key = key_padding[0] key_subdict = list(SUB_DICT_PATTERN.findall(key)) data = output last_key = key_subdict.pop(-1) for subkey in key_subdict: if subkey not in data: data[subkey] = {} data = data[subkey] data[last_key] = value return output def get_clean_used_values(self): new_used_values = {} for key, value in self.used_values.items(): if isinstance(value, FormatObject): value = str(value) new_used_values[key] = value return self.split_keys_to_subdicts(new_used_values) def add_realy_used_value(self, key, value): self._realy_used_values[key] = value def add_used_value(self, key, value): self._used_values[key] = value def add_missing_key(self, key): if self._optional: self._missing_optional_keys.add(key) else: self._missing_keys.add(key) def add_invalid_type(self, key, value): if self._optional: self._invalid_optional_types[key] = type(value) else: self._invalid_types[key] = type(value) class FormatObject(object): """Object that can be used for formatting. This is base that is valid for to be used in 'StringTemplate' value. """ def __init__(self): self.value = "" def __format__(self, *args, **kwargs): return self.value.__format__(*args, **kwargs) def __str__(self): return str(self.value) def __repr__(self): return self.__str__() class FormattingPart: """String with formatting template. Containt only single key to format e.g. "{project[name]}". Args: template(str): String containing the formatting key. """ def __init__(self, template): self._template = template @property def template(self): return self._template def __repr__(self): return "".format(self._template) def __str__(self): return self._template @staticmethod def validate_value_type(value): """Check if value can be used for formatting of single key.""" if isinstance(value, (numbers.Number, FormatObject)): return True for inh_class in type(value).mro(): if inh_class in six.string_types: return True return False def format(self, data, result): """Format the formattings string. Args: data(dict): Data that should be used for formatting. result(TemplatePartResult): Object where result is stored. """ key = self.template[1:-1] if key in result.realy_used_values: result.add_output(result.realy_used_values[key]) return result # check if key expects subdictionary keys (e.g. project[name]) existence_check = key key_padding = list(KEY_PADDING_PATTERN.findall(existence_check)) if key_padding: existence_check = key_padding[0] key_subdict = list(SUB_DICT_PATTERN.findall(existence_check)) value = data missing_key = False invalid_type = False used_keys = [] for sub_key in key_subdict: if ( value is None or (hasattr(value, "items") and sub_key not in value) ): missing_key = True used_keys.append(sub_key) break if not hasattr(value, "items"): invalid_type = True break used_keys.append(sub_key) value = value.get(sub_key) if missing_key or invalid_type: if len(used_keys) == 0: invalid_key = key_subdict[0] else: invalid_key = used_keys[0] for idx, sub_key in enumerate(used_keys): if idx == 0: continue invalid_key += "[{0}]".format(sub_key) if missing_key: result.add_missing_key(invalid_key) elif invalid_type: result.add_invalid_type(invalid_key, value) result.add_output(self.template) return result if self.validate_value_type(value): fill_data = {} first_value = True for used_key in reversed(used_keys): if first_value: first_value = False fill_data[used_key] = value else: _fill_data = {used_key: fill_data} fill_data = _fill_data formatted_value = self.template.format(**fill_data) result.add_realy_used_value(key, formatted_value) result.add_used_value(existence_check, formatted_value) result.add_output(formatted_value) return result result.add_invalid_type(key, value) result.add_output(self.template) return result class OptionalPart: """Template part which contains optional formatting strings. If this part can't be filled the result is empty string. Args: parts(list): Parts of template. Can contain 'str', 'OptionalPart' or 'FormattingPart'. """ def __init__(self, parts): self._parts = parts @property def parts(self): return self._parts def __str__(self): return "<{}>".format("".join([str(p) for p in self._parts])) def __repr__(self): return "".format("".join([str(p) for p in self._parts])) def format(self, data, result): new_result = TemplatePartResult(True) for part in self._parts: if isinstance(part, six.string_types): new_result.add_output(part) else: part.format(data, new_result) if new_result.solved: result.add_output(new_result) return result ================================================ FILE: openpype/lib/path_tools.py ================================================ import os import re import logging import platform import clique log = logging.getLogger(__name__) def format_file_size(file_size, suffix=None): """Returns formatted string with size in appropriate unit. Args: file_size (int): Size of file in bytes. suffix (str): Suffix for formatted size. Default is 'B' (as bytes). Returns: str: Formatted size using proper unit and passed suffix (e.g. 7 MiB). """ if suffix is None: suffix = "B" for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]: if abs(file_size) < 1024.0: return "%3.1f%s%s" % (file_size, unit, suffix) file_size /= 1024.0 return "%.1f%s%s" % (file_size, "Yi", suffix) def create_hard_link(src_path, dst_path): """Create hardlink of file. Args: src_path(str): Full path to a file which is used as source for hardlink. dst_path(str): Full path to a file where a link of source will be added. """ # Use `os.link` if is available # - should be for all platforms with newer python versions if hasattr(os, "link"): os.link(src_path, dst_path) return # Windows implementation of hardlinks # - used in Python 2 if platform.system().lower() == "windows": import ctypes from ctypes.wintypes import BOOL CreateHardLink = ctypes.windll.kernel32.CreateHardLinkW CreateHardLink.argtypes = [ ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_void_p ] CreateHardLink.restype = BOOL res = CreateHardLink(dst_path, src_path, None) if res == 0: raise ctypes.WinError() return # Raises not implemented error if gets here raise NotImplementedError( "Implementation of hardlink for current environment is missing." ) def collect_frames(files): """Returns dict of source path and its frame, if from sequence Uses clique as most precise solution, used when anatomy template that created files is not known. Assumption is that frames are separated by '.', negative frames are not allowed. Args: files(list) or (set with single value): list of source paths Returns: (dict): {'/asset/subset_v001.0001.png': '0001', ....} """ patterns = [clique.PATTERNS["frames"]] collections, remainder = clique.assemble( files, minimum_items=1, patterns=patterns) sources_and_frames = {} if collections: for collection in collections: src_head = collection.head src_tail = collection.tail for index in collection.indexes: src_frame = collection.format("{padding}") % index src_file_name = "{}{}{}".format( src_head, src_frame, src_tail) sources_and_frames[src_file_name] = src_frame else: sources_and_frames[remainder.pop()] = None return sources_and_frames def _rreplace(s, a, b, n=1): """Replace a with b in string s from right side n times.""" return b.join(s.rsplit(a, n)) def version_up(filepath): """Version up filepath to a new non-existing version. Parses for a version identifier like `_v001` or `.v001` When no version present _v001 is appended as suffix. Args: filepath (str): full url Returns: (str): filepath with increased version number """ dirname = os.path.dirname(filepath) basename, ext = os.path.splitext(os.path.basename(filepath)) regex = r"[._]v\d+" matches = re.findall(regex, str(basename), re.IGNORECASE) if not matches: log.info("Creating version...") new_label = "_v{version:03d}".format(version=1) new_basename = "{}{}".format(basename, new_label) else: label = matches[-1] version = re.search(r"\d+", label).group() padding = len(version) new_version = int(version) + 1 new_version = '{version:0{padding}d}'.format(version=new_version, padding=padding) new_label = label.replace(version, new_version, 1) new_basename = _rreplace(basename, label, new_label) new_filename = "{}{}".format(new_basename, ext) new_filename = os.path.join(dirname, new_filename) new_filename = os.path.normpath(new_filename) if new_filename == filepath: raise RuntimeError("Created path is the same as current file," "this is a bug") # We check for version clashes against the current file for any file # that matches completely in name up to the {version} label found. Thus # if source file was test_v001_test.txt we want to also check clashes # against test_v002.txt but do want to preserve the part after the version # label for our new filename clash_basename = new_basename if not clash_basename.endswith(new_label): index = (clash_basename.find(new_label)) index += len(new_label) clash_basename = clash_basename[:index] for file in os.listdir(dirname): if file.endswith(ext) and file.startswith(clash_basename): log.info("Skipping existing version %s" % new_label) return version_up(new_filename) log.info("New version %s" % new_label) return new_filename def get_version_from_path(file): """Find version number in file path string. Args: file (str): file path Returns: str: version number in string ('001') """ pattern = re.compile(r"[\._]v([0-9]+)", re.IGNORECASE) try: return pattern.findall(file)[-1] except IndexError: log.error( "templates:get_version_from_workfile:" "`{}` missing version string." "Example `v004`".format(file) ) def get_last_version_from_path(path_dir, filter): """Find last version of given directory content. Args: path_dir (str): directory path filter (list): list of strings used as file name filter Returns: str: file name with last version Example: last_version_file = get_last_version_from_path( "/project/shots/shot01/work", ["shot01", "compositing", "nk"]) """ assert os.path.isdir(path_dir), "`path_dir` argument needs to be directory" assert isinstance(filter, list) and ( len(filter) != 0), "`filter` argument needs to be list and not empty" filtred_files = list() # form regex for filtering pattern = r".*".join(filter) for file in os.listdir(path_dir): if not re.findall(pattern, file): continue filtred_files.append(file) if filtred_files: sorted(filtred_files) return filtred_files[-1] return None ================================================ FILE: openpype/lib/plugin_tools.py ================================================ # -*- coding: utf-8 -*- """Avalon/Pyblish plugin tools.""" import os import logging import re log = logging.getLogger(__name__) def prepare_template_data(fill_pairs): """ Prepares formatted data for filling template. It produces multiple variants of keys (key, Key, KEY) to control format of filled template. Args: fill_pairs (iterable) of tuples (key, value) Returns: (dict) ('host', 'maya') > {'host':'maya', 'Host': 'Maya', 'HOST': 'MAYA'} """ fill_data = {} regex = re.compile(r"[a-zA-Z0-9]") for key, value in dict(fill_pairs).items(): # Handle cases when value is `None` (standalone publisher) if value is None: continue # Keep value as it is fill_data[key] = value # Both key and value are with upper case fill_data[key.upper()] = value.upper() # Capitalize only first char of value # - conditions are because of possible index errors # - regex is to skip symbols that are not chars or numbers # - e.g. "{key}" which starts with curly bracket capitalized = "" for idx in range(len(value or "")): char = value[idx] if not regex.match(char): capitalized += char else: capitalized += char.upper() capitalized += value[idx + 1:] break fill_data[key.capitalize()] = capitalized return fill_data def source_hash(filepath, *args): """Generate simple identifier for a source file. This is used to identify whether a source file has previously been processe into the pipeline, e.g. a texture. The hash is based on source filepath, modification time and file size. This is only used to identify whether a specific source file was already published before from the same location with the same modification date. We opt to do it this way as opposed to Avalanch C4 hash as this is much faster and predictable enough for all our production use cases. Args: filepath (str): The source file path. You can specify additional arguments in the function to allow for specific 'processing' values to be included. """ # We replace dots with comma because . cannot be a key in a pymongo dict. file_name = os.path.basename(filepath) time = str(os.path.getmtime(filepath)) size = str(os.path.getsize(filepath)) return "|".join([file_name, time, size] + list(args)).replace(".", ",") ================================================ FILE: openpype/lib/profiles_filtering.py ================================================ import re import logging log = logging.getLogger(__name__) def compile_list_of_regexes(in_list): """Convert strings in entered list to compiled regex objects.""" regexes = list() if not in_list: return regexes for item in in_list: if not item: continue try: regexes.append(re.compile(item)) except TypeError: print(( "Invalid type \"{}\" value \"{}\"." " Expected string based object. Skipping." ).format(str(type(item)), str(item))) return regexes def _profile_exclusion(matching_profiles, logger): """Find out most matching profile byt host, task and family match. Profiles are selectively filtered. Each item in passed argument must contain tuple of (profile, profile's score) where score is list of booleans. Each boolean represents existence of filter for specific key. Profiles are looped in sequence. In each sequence are profiles split into true_list and false_list. For next sequence loop are used profiles in true_list if there are any profiles else false_list is used. Filtering ends when only one profile left in true_list. Or when all existence booleans loops passed, in that case first profile from remainded profiles is returned. Args: matching_profiles (list): Profiles with same scores. Each item is tuple with (profile, profile values) Returns: dict: Most matching profile. """ if not matching_profiles: return None if len(matching_profiles) == 1: return matching_profiles[0][0] scores_len = len(matching_profiles[0][1]) for idx in range(scores_len): profiles_true = [] profiles_false = [] for profile, score in matching_profiles: if score[idx]: profiles_true.append((profile, score)) else: profiles_false.append((profile, score)) if profiles_true: matching_profiles = profiles_true else: matching_profiles = profiles_false if len(matching_profiles) == 1: return matching_profiles[0][0] return matching_profiles[0][0] def fullmatch(regex, string, flags=0): """Emulate python-3.4 re.fullmatch().""" matched = re.match(regex, string, flags=flags) if matched and matched.span()[1] == len(string): return matched return None def validate_value_by_regexes(value, in_list): """Validates in any regex from list match entered value. Args: value (str): String where regexes is checked. in_list (list): List with regexes. Returns: int: Returns `0` when list is not set, is empty or contain "*". Returns `1` when any regex match value and returns `-1` when none of regexes match entered value. """ if not in_list: return 0 if not isinstance(in_list, (list, tuple, set)): in_list = [in_list] if "*" in in_list: return 0 # If value is not set and in list has specific values then resolve value # as not matching. if not value: return -1 regexes = compile_list_of_regexes(in_list) for regex in regexes: if hasattr(regex, "fullmatch"): result = regex.fullmatch(value) else: result = fullmatch(regex, value) if result: return 1 return -1 def filter_profiles(profiles_data, key_values, keys_order=None, logger=None): """ Filter profiles by entered key -> values. Profile if marked with score for each key/value from `key_values` with points -1, 0 or 1. - if profile contain the key and profile's value contain value from `key_values` then profile gets 1 point - if profile does not contain the key or profile's value is empty or contain "*" then got 0 point - if profile contain the key, profile's value is not empty and does not contain "*" and value from `key_values` is not available in the value then got -1 point If profile gets -1 point at any time then is skipped and not used for output. Profile with higher score is returned. If there are multiple profiles with same score then first in order is used (order of profiles matter). Args: profiles_data (list): Profile definitions as dictionaries. key_values (dict): Mapping of Key <-> Value. Key is checked if is available in profile and if Value is matching it's values. keys_order (list, tuple): Order of keys from `key_values` which matters only when multiple profiles have same score. logger (logging.Logger): Optionally can be passed different logger. Returns: dict/None: Return most matching profile or None if none of profiles match at least one criteria. """ if not profiles_data: return None if not logger: logger = log if not keys_order: keys_order = tuple(key_values.keys()) else: _keys_order = list(keys_order) # Make all keys from `key_values` are passed for key in key_values.keys(): if key not in _keys_order: _keys_order.append(key) keys_order = tuple(_keys_order) log_parts = " | ".join([ "{}: \"{}\"".format(*item) for item in key_values.items() ]) logger.debug( "Looking for matching profile for: {}".format(log_parts) ) matching_profiles = None highest_profile_points = -1 # Each profile get 1 point for each matching filter. Profile with most # points is returned. For cases when more than one profile will match # are also stored ordered lists of matching values. for profile in profiles_data: profile_points = 0 profile_scores = [] for key in keys_order: value = key_values[key] match = validate_value_by_regexes(value, profile.get(key)) if match == -1: profile_value = profile.get(key) or [] logger.debug( "\"{}\" not found in \"{}\": {}".format(value, key, profile_value) ) profile_points = -1 break profile_points += match profile_scores.append(bool(match)) if ( profile_points < 0 or profile_points < highest_profile_points ): continue if profile_points > highest_profile_points: matching_profiles = [] highest_profile_points = profile_points if profile_points == highest_profile_points: matching_profiles.append((profile, profile_scores)) if not matching_profiles: logger.debug( "None of profiles match your setup. {}".format(log_parts) ) return None if len(matching_profiles) > 1: logger.debug( "More than one profile match your setup. {}".format(log_parts) ) profile = _profile_exclusion(matching_profiles, logger) if profile: logger.debug( "Profile selected: {}".format(profile) ) return profile ================================================ FILE: openpype/lib/profiling.py ================================================ # -*- coding: utf-8 -*- """Provide profiling decorator.""" import os import cProfile def do_profile(fn, to_file=None): """Wraps function in profiler run and print stat after it is done. Args: to_file (str, optional): If specified, dumps stats into the file instead of printing. """ if to_file: to_file = to_file.format(pid=os.getpid()) def profiled(*args, **kwargs): profiler = cProfile.Profile() try: profiler.enable() res = fn(*args, **kwargs) profiler.disable() return res finally: if to_file: profiler.dump_stats(to_file) else: profiler.print_stats() ================================================ FILE: openpype/lib/project_backpack.py ================================================ """These lib functions are for development purposes. WARNING: This is not meant for production data. Please don't write code which is dependent on functionality here. Goal is to be able to create package of current state of project with related documents from mongo and files from disk to zip file and then be able to recreate the project based on the zip. This gives ability to create project where a changes and tests can be done. Keep in mind that to be able to create a package of project has few requirements. Possible requirement should be listed in 'pack_project' function. """ import os import json import platform import tempfile import shutil import datetime import zipfile from openpype.client.mongo import ( load_json_file, get_project_connection, replace_project_documents, store_project_documents, ) DOCUMENTS_FILE_NAME = "database" METADATA_FILE_NAME = "metadata" PROJECT_FILES_DIR = "project_files" def add_timestamp(filepath): """Add timestamp string to a file.""" base, ext = os.path.splitext(filepath) timestamp = datetime.datetime.now().strftime("%y%m%d_%H%M%S") new_base = "{}_{}".format(base, timestamp) return new_base + ext def get_project_document(project_name, database_name=None): """Query project document. Function 'get_project' from client api cannot be used as it does not allow to change which 'database_name' is used. Args: project_name (str): Name of project. database_name (Optional[str]): Name of mongo database where to look for project. Returns: Union[dict[str, Any], None]: Project document or None. """ col = get_project_connection(project_name, database_name) return col.find_one({"type": "project"}) def _pack_files_to_zip(zip_stream, source_path, root_path): """Pack files to a zip stream. Args: zip_stream (zipfile.ZipFile): Stream to a zipfile. source_path (str): Path to a directory where files are. root_path (str): Path to a directory which is used for calculation of relative path. """ for root, _, filenames in os.walk(source_path): for filename in filenames: filepath = os.path.join(root, filename) # TODO add one more folder archive_name = os.path.join( PROJECT_FILES_DIR, os.path.relpath(filepath, root_path) ) zip_stream.write(filepath, archive_name) def pack_project( project_name, destination_dir=None, only_documents=False, database_name=None ): """Make a package of a project with mongo documents and files. This function has few restrictions: - project must have only one root - project must have all templates starting with "{root[...]}/{project[name]}" Args: project_name (str): Project that should be packaged. destination_dir (Optional[str]): Optional path where zip will be stored. Project's root is used if not passed. only_documents (Optional[bool]): Pack only Mongo documents and skip files. database_name (Optional[str]): Custom database name from which is project queried. """ print("Creating package of project \"{}\"".format(project_name)) # Validate existence of project project_doc = get_project_document(project_name, database_name) if not project_doc: raise ValueError("Project \"{}\" was not found in database".format( project_name )) if only_documents and not destination_dir: raise ValueError(( "Destination directory must be defined" " when only documents should be packed." )) root_path = None source_root = {} project_source_path = None if not only_documents: roots = project_doc["config"]["roots"] # Determine root directory of project source_root = None source_root_name = None for root_name, root_value in roots.items(): if source_root is not None: raise ValueError( "Packaging is supported only for single root projects" ) source_root = root_value source_root_name = root_name root_path = source_root[platform.system().lower()] print("Using root \"{}\" with path \"{}\"".format( source_root_name, root_path )) project_source_path = os.path.join(root_path, project_name) if not os.path.exists(project_source_path): raise ValueError("Didn't find source of project files") # Determine zip filepath where data will be stored if not destination_dir: destination_dir = root_path if not destination_dir: raise ValueError( "Project {} does not have any roots.".format(project_name) ) destination_dir = os.path.normpath(destination_dir) if not os.path.exists(destination_dir): os.makedirs(destination_dir) zip_path = os.path.join(destination_dir, project_name + ".zip") print("Project will be packaged into \"{}\"".format(zip_path)) # Rename already existing zip if os.path.exists(zip_path): dst_filepath = add_timestamp(zip_path) os.rename(zip_path, dst_filepath) # We can add more data metadata = { "project_name": project_name, "root": source_root, "version": 1 } # Create temp json file where metadata are stored with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as s: temp_metadata_json = s.name with open(temp_metadata_json, "w") as stream: json.dump(metadata, stream) # Create temp json file where database documents are stored with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as s: temp_docs_json = s.name # Query all project documents and store them to temp json store_project_documents(project_name, temp_docs_json, database_name) print("Packing files into zip") # Write all to zip file with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zip_stream: # Add metadata file zip_stream.write(temp_metadata_json, METADATA_FILE_NAME + ".json") # Add database documents zip_stream.write(temp_docs_json, DOCUMENTS_FILE_NAME + ".json") # Add project files to zip if not only_documents: _pack_files_to_zip(zip_stream, project_source_path, root_path) print("Cleaning up") # Cleanup os.remove(temp_docs_json) os.remove(temp_metadata_json) print("*** Packing finished ***") def _unpack_project_files(unzip_dir, root_path, project_name): """Move project files from unarchived temp folder to new root. Unpack is skipped if source files are not available in the zip. That can happen if nothing was published yet or only documents were stored to package. Args: unzip_dir (str): Location where zip was unzipped. root_path (str): Path to new root. project_name (str): Name of project. """ src_project_files_dir = os.path.join( unzip_dir, PROJECT_FILES_DIR, project_name ) # Skip if files are not in the zip if not os.path.exists(src_project_files_dir): return # Make sure root path exists if not os.path.exists(root_path): os.makedirs(root_path) dst_project_files_dir = os.path.normpath( os.path.join(root_path, project_name) ) if os.path.exists(dst_project_files_dir): new_path = add_timestamp(dst_project_files_dir) print("Project folder already exists. Renamed \"{}\" -> \"{}\"".format( dst_project_files_dir, new_path )) os.rename(dst_project_files_dir, new_path) print("Moving project files from temp \"{}\" -> \"{}\"".format( src_project_files_dir, dst_project_files_dir )) shutil.move(src_project_files_dir, dst_project_files_dir) def unpack_project( path_to_zip, new_root=None, database_only=None, database_name=None ): """Unpack project zip file to recreate project. Args: path_to_zip (str): Path to zip which was created using 'pack_project' function. new_root (str): Optional way how to set different root path for unpacked project. database_only (Optional[bool]): Unpack only database from zip. database_name (str): Name of database where project will be recreated. """ if database_only is None: database_only = False print("Unpacking project from zip {}".format(path_to_zip)) if not os.path.exists(path_to_zip): print("Zip file does not exists: {}".format(path_to_zip)) return tmp_dir = tempfile.mkdtemp(prefix="unpack_") print("Zip is extracted to temp: {}".format(tmp_dir)) with zipfile.ZipFile(path_to_zip, "r") as zip_stream: if database_only: for filename in ( "{}.json".format(METADATA_FILE_NAME), "{}.json".format(DOCUMENTS_FILE_NAME), ): zip_stream.extract(filename, tmp_dir) else: zip_stream.extractall(tmp_dir) metadata_json_path = os.path.join(tmp_dir, METADATA_FILE_NAME + ".json") with open(metadata_json_path, "r") as stream: metadata = json.load(stream) docs_json_path = os.path.join(tmp_dir, DOCUMENTS_FILE_NAME + ".json") docs = load_json_file(docs_json_path) low_platform = platform.system().lower() project_name = metadata["project_name"] root_path = metadata["root"].get(low_platform) # Drop existing collection replace_project_documents(project_name, docs, database_name) print("Creating project documents ({})".format(len(docs))) # Skip change of root if is the same as the one stored in metadata if ( new_root and (os.path.normpath(new_root) == os.path.normpath(root_path)) ): new_root = None if new_root: print("Using different root path {}".format(new_root)) root_path = new_root project_doc = get_project_document(project_name) roots = project_doc["config"]["roots"] key = tuple(roots.keys())[0] update_key = "config.roots.{}.{}".format(key, low_platform) collection = get_project_connection(project_name, database_name) collection.update_one( {"_id": project_doc["_id"]}, {"$set": { update_key: new_root }} ) _unpack_project_files(tmp_dir, root_path, project_name) # CLeanup print("Cleaning up") shutil.rmtree(tmp_dir) print("*** Unpack finished ***") ================================================ FILE: openpype/lib/pype_info.py ================================================ import os import json import datetime import platform import getpass import socket from openpype import AYON_SERVER_ENABLED from openpype.settings.lib import get_local_settings from .execute import get_openpype_execute_args from .local_settings import get_local_site_id from .openpype_version import ( is_running_from_build, get_openpype_version, get_build_version ) def get_openpype_info(): """Information about currently used Pype process.""" executable_args = get_openpype_execute_args() if is_running_from_build(): version_type = "build" else: version_type = "code" return { "build_verison": get_build_version(), "version": get_openpype_version(), "version_type": version_type, "executable": executable_args[-1], "pype_root": os.environ["OPENPYPE_REPOS_ROOT"], "mongo_url": os.environ["OPENPYPE_MONGO"] } def get_ayon_info(): executable_args = get_openpype_execute_args() if is_running_from_build(): version_type = "build" else: version_type = "code" return { "build_verison": get_build_version(), "version_type": version_type, "executable": executable_args[-1], "ayon_root": os.environ["AYON_ROOT"], "server_url": os.environ["AYON_SERVER_URL"] } def get_workstation_info(): """Basic information about workstation.""" host_name = socket.gethostname() try: host_ip = socket.gethostbyname(host_name) except socket.gaierror: host_ip = "127.0.0.1" return { "hostname": host_name, "hostip": host_ip, "username": getpass.getuser(), "system_name": platform.system(), "local_id": get_local_site_id() } def get_all_current_info(): """All information about current process in one dictionary.""" output = { "workstation": get_workstation_info(), "env": os.environ.copy(), "local_settings": get_local_settings() } if AYON_SERVER_ENABLED: output["ayon"] = get_ayon_info() else: output["openpype"] = get_openpype_info() return output def extract_pype_info_to_file(dirpath): """Extract all current info to a file. It is possible to define onpy directory path. Filename is concatenated with pype version, workstation site id and timestamp. Args: dirpath (str): Path to directory where file will be stored. Returns: filepath (str): Full path to file where data were extracted. """ filename = "{}_{}_{}.json".format( get_openpype_version(), get_local_site_id(), datetime.datetime.now().strftime("%y%m%d%H%M%S") ) filepath = os.path.join(dirpath, filename) data = get_all_current_info() if not os.path.exists(dirpath): os.makedirs(dirpath) with open(filepath, "w") as file_stream: json.dump(data, file_stream, indent=4) return filepath ================================================ FILE: openpype/lib/python_2_comp.py ================================================ import weakref WeakMethod = getattr(weakref, "WeakMethod", None) if WeakMethod is None: class _WeakCallable: def __init__(self, obj, func): self.im_self = obj self.im_func = func def __call__(self, *args, **kws): if self.im_self is None: return self.im_func(*args, **kws) else: return self.im_func(self.im_self, *args, **kws) class WeakMethod: """ Wraps a function or, more importantly, a bound method in a way that allows a bound method's object to be GCed, while providing the same interface as a normal weak reference. """ def __init__(self, fn): try: self._obj = weakref.ref(fn.im_self) self._meth = fn.im_func except AttributeError: # It's not a bound method self._obj = None self._meth = fn def __call__(self): if self._dead(): return None return _WeakCallable(self._getobj(), self._meth) def _dead(self): return self._obj is not None and self._obj() is None def _getobj(self): if self._obj is None: return None return self._obj() ================================================ FILE: openpype/lib/python_module_tools.py ================================================ import os import sys import types import importlib import inspect import logging import six log = logging.getLogger(__name__) def import_filepath(filepath, module_name=None): """Import python file as python module. Python 2 and Python 3 compatibility. Args: filepath(str): Path to python file. module_name(str): Name of loaded module. Only for Python 3. By default is filled with filename of filepath. """ if module_name is None: module_name = os.path.splitext(os.path.basename(filepath))[0] # Make sure it is not 'unicode' in Python 2 module_name = str(module_name) # Prepare module object where content of file will be parsed module = types.ModuleType(module_name) module.__file__ = filepath if six.PY3: # Use loader so module has full specs module_loader = importlib.machinery.SourceFileLoader( module_name, filepath ) module_loader.exec_module(module) else: # Execute module code and store content to module with open(filepath) as _stream: # Execute content and store it to module object six.exec_(_stream.read(), module.__dict__) return module def modules_from_path(folder_path): """Get python scripts as modules from a path. Arguments: path (str): Path to folder containing python scripts. Returns: tuple: First list contains successfully imported modules and second list contains tuples of path and exception. """ crashed = [] modules = [] output = (modules, crashed) # Just skip and return empty list if path is not set if not folder_path: return output # Do not allow relative imports if folder_path.startswith("."): log.warning(( "BUG: Relative paths are not allowed for security reasons. {}" ).format(folder_path)) return output folder_path = os.path.normpath(folder_path) if not os.path.isdir(folder_path): log.warning("Not a directory path: {}".format(folder_path)) return output for filename in os.listdir(folder_path): # Ignore files which start with underscore if filename.startswith("_"): continue mod_name, mod_ext = os.path.splitext(filename) if not mod_ext == ".py": continue full_path = os.path.join(folder_path, filename) if not os.path.isfile(full_path): continue try: module = import_filepath(full_path, mod_name) modules.append((full_path, module)) except Exception: crashed.append((full_path, sys.exc_info())) log.warning( "Failed to load path: \"{0}\"".format(full_path), exc_info=True ) continue return output def recursive_bases_from_class(klass): """Extract all bases from entered class.""" result = [] bases = klass.__bases__ result.extend(bases) for base in bases: result.extend(recursive_bases_from_class(base)) return result def classes_from_module(superclass, module): """Return plug-ins from module Arguments: superclass (superclass): Superclass of subclasses to look for module (types.ModuleType): Imported module from which to parse valid Avalon plug-ins. Returns: List of plug-ins, or empty list if none is found. """ classes = list() for name in dir(module): # It could be anything at this point obj = getattr(module, name) if not inspect.isclass(obj) or obj is superclass: continue if issubclass(obj, superclass): classes.append(obj) return classes def _import_module_from_dirpath_py2(dirpath, module_name, dst_module_name): """Import passed dirpath as python module using `imp`.""" if dst_module_name: full_module_name = "{}.{}".format(dst_module_name, module_name) dst_module = sys.modules[dst_module_name] else: full_module_name = module_name dst_module = None if full_module_name in sys.modules: return sys.modules[full_module_name] import imp fp, pathname, description = imp.find_module(module_name, [dirpath]) module = imp.load_module(full_module_name, fp, pathname, description) if dst_module is not None: setattr(dst_module, module_name, module) return module def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name): """Import passed dirpath as python module using Python 3 modules.""" if dst_module_name: full_module_name = "{}.{}".format(dst_module_name, module_name) dst_module = sys.modules[dst_module_name] else: full_module_name = module_name dst_module = None # Skip import if is already imported if full_module_name in sys.modules: return sys.modules[full_module_name] import importlib.util from importlib._bootstrap_external import PathFinder # Find loader for passed path and name loader = PathFinder.find_module(full_module_name, [dirpath]) # Load specs of module spec = importlib.util.spec_from_loader( full_module_name, loader, origin=dirpath ) # Create module based on specs module = importlib.util.module_from_spec(spec) # Store module to destination module and `sys.modules` # WARNING this mus be done before module execution if dst_module is not None: setattr(dst_module, module_name, module) sys.modules[full_module_name] = module # Execute module import loader.exec_module(module) return module def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None): """Import passed directory as a python module. Python 2 and 3 compatible. Imported module can be assigned as a child attribute of already loaded module from `sys.modules` if has support of `setattr`. That is not default behavior of python modules so parent module must be a custom module with that ability. It is not possible to reimport already cached module. If you need to reimport module you have to remove it from caches manually. Args: dirpath(str): Parent directory path of loaded folder. folder_name(str): Folder name which should be imported inside passed directory. dst_module_name(str): Parent module name under which can be loaded module added. """ if six.PY3: module = _import_module_from_dirpath_py3( dirpath, folder_name, dst_module_name ) else: module = _import_module_from_dirpath_py2( dirpath, folder_name, dst_module_name ) return module def is_func_signature_supported(func, *args, **kwargs): """Check if a function signature supports passed args and kwargs. This check does not actually call the function, just look if function can be called with the arguments. Notes: This does NOT check if the function would work with passed arguments only if they can be passed in. If function have *args, **kwargs in paramaters, this will always return 'True'. Example: >>> def my_function(my_number): ... return my_number + 1 ... >>> is_func_signature_supported(my_function, 1) True >>> is_func_signature_supported(my_function, 1, 2) False >>> is_func_signature_supported(my_function, my_number=1) True >>> is_func_signature_supported(my_function, number=1) False >>> is_func_signature_supported(my_function, "string") True >>> def my_other_function(*args, **kwargs): ... my_function(*args, **kwargs) ... >>> is_func_signature_supported( ... my_other_function, ... "string", ... 1, ... other=None ... ) True Args: func (Callable): A function where the signature should be tested. *args (Any): Positional arguments for function signature. **kwargs (Any): Keyword arguments for function signature. Returns: bool: Function can pass in arguments. """ if hasattr(inspect, "signature"): # Python 3 using 'Signature' object where we try to bind arg # or kwarg. Using signature is recommended approach based on # documentation. sig = inspect.signature(func) try: sig.bind(*args, **kwargs) return True except TypeError: pass else: # In Python 2 'signature' is not available so 'getcallargs' is used # - 'getcallargs' is marked as deprecated since Python 3.0 try: inspect.getcallargs(func, *args, **kwargs) return True except TypeError: pass return False ================================================ FILE: openpype/lib/terminal.py ================================================ # -*- coding: utf-8 -*- """Package helping with colorizing and formatting terminal output.""" # :: # //. ... .. ///. //. # ///\\\ \\\ \\ ///\\\ /// # /// \\ \\\ \\ /// \\ /// // # \\\ // \\\ // \\\ // \\\// ./ # \\\// \\\// \\\// \\\' // # \\\ \\\ \\\ \\\// # ''' ''' ''' ''' # ..---===[[ PyP3 Setup ]]===---... # import re import time import threading class Terminal: """Class formatting messages using colorama to specific visual tokens. If :mod:`Colorama` is not found, it will still work, but without colors. Depends on :mod:`Colorama` Using **OPENPYPE_LOG_NO_COLORS** environment variable. """ # Is Terminal initialized _initialized = False # Thread lock for initialization to avoid race conditions _init_lock = threading.Lock() # Use colorized output use_colors = True # Output message replacements mapping - set on initialization _sdict = {} @staticmethod def _initialize(): """Initialize Terminal class as object. First check if colorized output is disabled by environment variable `OPENPYPE_LOG_NO_COLORS` value. By default is colorized output turned on. Then tries to import python module that do the colors magic and create it's terminal object. Colorized output is not used if import of python module or terminal object creation fails. Set `_initialized` attribute to `True` when is done. """ from openpype.lib import env_value_to_bool log_no_colors = env_value_to_bool( "OPENPYPE_LOG_NO_COLORS", default=None ) if log_no_colors is not None: Terminal.use_colors = not log_no_colors if not Terminal.use_colors: Terminal._initialized = True return try: # Try to import `blessed` module and create `Terminal` object import blessed term = blessed.Terminal() except Exception: # Do not use colors if crashed Terminal.use_colors = False print( "Module `blessed` failed on import or terminal creation." " Pype terminal won't use colors." ) Terminal._initialized = True return # shortcuts for blessed codes _SB = term.bold _RST = "" _LR = term.tomato2 _LG = term.aquamarine3 _LB = term.turquoise2 _LM = term.slateblue2 _LY = term.gold _R = term.red _G = term.green _B = term.blue _C = term.cyan _Y = term.yellow _W = term.white # dictionary replacing string sequences with colorized one Terminal._sdict = { r">>> ": _SB + _LG + r">>> " + _RST, r"!!!(?!\sCRI|\sERR)": _SB + _R + r"!!! " + _RST, r"\-\-\- ": _SB + _C + r"--- " + _RST, r"\*\*\*(?!\sWRN)": _SB + _LY + r"***" + _RST, r"\*\*\* WRN": _SB + _LY + r"*** WRN" + _RST, r" \- ": _SB + _LY + r" - " + _RST, r"\[ ": _SB + _LG + r"[ " + _RST, r" \]": _SB + _LG + r" ]" + _RST, r"{": _LG + r"{", r"}": r"}" + _RST, r"\(": _LY + r"(", r"\)": r")" + _RST, r"^\.\.\. ": _SB + _LR + r"... " + _RST, r"!!! ERR: ": _SB + _LR + r"!!! ERR: " + _RST, r"!!! CRI: ": _SB + _R + r"!!! CRI: " + _RST, r"(?i)failed": _SB + _LR + "FAILED" + _RST, r"(?i)error": _SB + _LR + "ERROR" + _RST } Terminal._SB = _SB Terminal._RST = _RST Terminal._LR = _LR Terminal._LG = _LG Terminal._LB = _LB Terminal._LM = _LM Terminal._LY = _LY Terminal._R = _R Terminal._G = _G Terminal._B = _B Terminal._C = _C Terminal._Y = _Y Terminal._W = _W Terminal._initialized = True @staticmethod def _multiple_replace(text, adict): """Replace multiple tokens defined in dict. Find and replace all occurrences of strings defined in dict is supplied string. Args: text (str): string to be searched adict (dict): dictionary with `{'search': 'replace'}` Returns: str: string with replaced tokens """ for r, v in adict.items(): text = re.sub(r, v, text) return text @staticmethod def echo(message): """Print colorized message to stdout. Args: message (str): Message to be colorized. debug (bool): Returns: str: Colorized message. """ colorized = Terminal.log(message) print(colorized) return colorized @staticmethod def log(message): """Return color formatted message. If environment variable `OPENPYPE_LOG_NO_COLORS` is set to whatever value, message will be formatted but not colorized. Args: message (str): Message to be colorized. Returns: str: Colorized message. """ T = Terminal # Initialize if not yet initialized and use thread lock to avoid race # condition issues if not T._initialized: # Check if lock is already locked to be sure `_initialize` is not # executed multiple times if not T._init_lock.locked(): with T._init_lock: T._initialize() else: # If lock is locked wait until is finished while T._init_lock.locked(): time.sleep(0.1) # if we dont want colors, just print raw message if not T.use_colors: return message message = re.sub(r'\[(.*)\]', '[ ' + T._SB + T._W + r'\1' + T._RST + ' ]', message) message = T._multiple_replace(message + T._RST, T._sdict) return message ================================================ FILE: openpype/lib/transcoding.py ================================================ import os import re import logging import json import collections import tempfile import subprocess import platform import xml.etree.ElementTree from .execute import run_subprocess from .vendor_bin_utils import ( get_ffmpeg_tool_args, get_oiio_tool_args, is_oiio_supported, ) # Max length of string that is supported by ffmpeg MAX_FFMPEG_STRING_LEN = 8196 # Not allowed symbols in attributes for ffmpeg NOT_ALLOWED_FFMPEG_CHARS = ("\"", ) # OIIO known xml tags STRING_TAGS = { "format" } INT_TAGS = { "x", "y", "z", "width", "height", "depth", "full_x", "full_y", "full_z", "full_width", "full_height", "full_depth", "tile_width", "tile_height", "tile_depth", "nchannels", "alpha_channel", "z_channel", "deep", "subimages", } XML_CHAR_REF_REGEX_HEX = re.compile(r"&#x?[0-9a-fA-F]+;") # Regex to parse array attributes ARRAY_TYPE_REGEX = re.compile(r"^(int|float|string)\[\d+\]$") IMAGE_EXTENSIONS = { ".ani", ".anim", ".apng", ".art", ".bmp", ".bpg", ".bsave", ".cal", ".cin", ".cpc", ".cpt", ".dds", ".dpx", ".ecw", ".exr", ".fits", ".flic", ".flif", ".fpx", ".gif", ".hdri", ".hevc", ".icer", ".icns", ".ico", ".cur", ".ics", ".ilbm", ".jbig", ".jbig2", ".jng", ".jpeg", ".jpeg-ls", ".jpeg-hdr", ".2000", ".jpg", ".kra", ".logluv", ".mng", ".miff", ".nrrd", ".ora", ".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf", ".pictor", ".png", ".psd", ".psb", ".psp", ".qtvr", ".ras", ".rgbe", ".sgi", ".tga", ".tif", ".tiff", ".tiff/ep", ".tiff/it", ".ufo", ".ufp", ".wbmp", ".webp", ".xr", ".xt", ".xbm", ".xcf", ".xpm", ".xwd" } VIDEO_EXTENSIONS = { ".3g2", ".3gp", ".amv", ".asf", ".avi", ".drc", ".f4a", ".f4b", ".f4p", ".f4v", ".flv", ".gif", ".gifv", ".m2v", ".m4p", ".m4v", ".mkv", ".mng", ".mov", ".mp2", ".mp4", ".mpe", ".mpeg", ".mpg", ".mpv", ".mxf", ".nsv", ".ogg", ".ogv", ".qt", ".rm", ".rmvb", ".roq", ".svi", ".vob", ".webm", ".wmv", ".yuv" } def get_transcode_temp_directory(): """Creates temporary folder for transcoding. Its local, in case of farm it is 'local' to the farm machine. Should be much faster, needs to be cleaned up later. """ return os.path.normpath( tempfile.mkdtemp(prefix="op_transcoding_") ) def get_oiio_info_for_input(filepath, logger=None, subimages=False): """Call oiiotool to get information about input and return stdout. Stdout should contain xml format string. """ args = get_oiio_tool_args( "oiiotool", "--info", "-v" ) if subimages: args.append("-a") args.extend(["-i:infoformat=xml", filepath]) output = run_subprocess(args, logger=logger) output = output.replace("\r\n", "\n") xml_started = False subimages_lines = [] lines = [] for line in output.split("\n"): if not xml_started: if not line.startswith("<"): continue xml_started = True if xml_started: lines.append(line) if line == "": subimages_lines.append(lines) lines = [] xml_started = False if not subimages_lines: raise ValueError( "Failed to read input file \"{}\".\nOutput:\n{}".format( filepath, output ) ) output = [] for subimage_lines in subimages_lines: xml_text = "\n".join(subimage_lines) output.append(parse_oiio_xml_output(xml_text, logger=logger)) if subimages: return output return output[0] class RationalToInt: """Rational value stored as division of 2 integers using string.""" def __init__(self, string_value): parts = string_value.split("/") top = float(parts[0]) bottom = 1.0 if len(parts) != 1: bottom = float(parts[1]) self._value = float(top) / float(bottom) self._string_value = string_value @property def value(self): return self._value @property def string_value(self): return self._string_value def __format__(self, *args, **kwargs): return self._string_value.__format__(*args, **kwargs) def __float__(self): return self._value def __str__(self): return self._string_value def __repr__(self): return "<{}> {}".format(self.__class__.__name__, self._string_value) def convert_value_by_type_name(value_type, value, logger=None): """Convert value to proper type based on type name. In some cases value types have custom python class. """ if logger is None: logger = logging.getLogger(__name__) # Simple types if value_type == "string": return value if value_type == "int": return int(value) if value_type in ("float", "double"): return float(value) # Vectors will probably have more types if value_type in ("vec2f", "float2", "float2d"): return [float(item) for item in value.split(",")] # Matrix should be always have square size of element 3x3, 4x4 # - are returned as list of lists if value_type in ("matrix", "matrixd"): output = [] current_index = -1 parts = value.split(",") parts_len = len(parts) if parts_len == 1: divisor = 1 elif parts_len == 4: divisor = 2 elif parts_len == 9: divisor = 3 elif parts_len == 16: divisor = 4 else: logger.info("Unknown matrix resolution {}. Value: \"{}\"".format( parts_len, value )) for part in parts: output.append(float(part)) return output for idx, item in enumerate(parts): list_index = idx % divisor if list_index > current_index: current_index = list_index output.append([]) output[list_index].append(float(item)) return output if value_type == "rational2i": return RationalToInt(value) if value_type in ("vector", "vectord"): parts = [part.strip() for part in value.split(",")] output = [] for part in parts: if part == "-nan": output.append(None) continue try: part = float(part) except ValueError: pass output.append(part) return output if value_type == "timecode": return value # Array of other types is converted to list re_result = ARRAY_TYPE_REGEX.findall(value_type) if re_result: array_type = re_result[0] output = [] for item in value.split(","): output.append( convert_value_by_type_name(array_type, item, logger=logger) ) return output logger.debug(( "Dev note (missing implementation):" " Unknown attrib type \"{}\". Value: {}" ).format(value_type, value)) return value def parse_oiio_xml_output(xml_string, logger=None): """Parse xml output from OIIO info command.""" output = {} if not xml_string: return output # Fix values with ampresand (lazy fix) # - oiiotool exports invalid xml which ElementTree can't handle # e.g. "" # WARNING: this will affect even valid character entities. If you need # those values correctly, this must take care of valid character ranges. # See https://github.com/pypeclub/OpenPype/pull/2729 matches = XML_CHAR_REF_REGEX_HEX.findall(xml_string) for match in matches: new_value = match.replace("&", "&") xml_string = xml_string.replace(match, new_value) if logger is None: logger = logging.getLogger("OIIO-xml-parse") tree = xml.etree.ElementTree.fromstring(xml_string) attribs = {} output["attribs"] = attribs for child in tree: tag_name = child.tag if tag_name == "attrib": attrib_def = child.attrib value = convert_value_by_type_name( attrib_def["type"], child.text, logger=logger ) attribs[attrib_def["name"]] = value continue # Channels are stored as tex on each child if tag_name == "channelnames": value = [] for channel in child: value.append(channel.text) # Convert known integer type tags to int elif tag_name in INT_TAGS: value = int(child.text) # Keep value of known string tags elif tag_name in STRING_TAGS: value = child.text # Keep value as text for unknown tags # - feel free to add more tags else: value = child.text logger.debug(( "Dev note (missing implementation):" " Unknown tag \"{}\". Value \"{}\"" ).format(tag_name, value)) output[child.tag] = value return output def get_review_info_by_layer_name(channel_names): """Get channels info grouped by layer name. Finds all layers in channel names and returns list of dictionaries with information about channels in layer. Example output (not real world example): [ { "name": "Main", "review_channels": { "R": "Main.red", "G": "Main.green", "B": "Main.blue", "A": None, } }, { "name": "Composed", "review_channels": { "R": "Composed.R", "G": "Composed.G", "B": "Composed.B", "A": "Composed.A", } }, ... ] Args: channel_names (list[str]): List of channel names. Returns: list[dict]: List of channels information. """ layer_names_order = [] rgba_by_layer_name = collections.defaultdict(dict) channels_by_layer_name = collections.defaultdict(dict) for channel_name in channel_names: layer_name = "" last_part = channel_name if "." in channel_name: layer_name, last_part = channel_name.rsplit(".", 1) channels_by_layer_name[layer_name][channel_name] = last_part if last_part.lower() not in { "r", "red", "g", "green", "b", "blue", "a", "alpha" }: continue if layer_name not in layer_names_order: layer_names_order.append(layer_name) # R, G, B or A channel = last_part[0].upper() rgba_by_layer_name[layer_name][channel] = channel_name # Put empty layer to the beginning of the list # - if input has R, G, B, A channels they should be used for review if "" in layer_names_order: layer_names_order.remove("") layer_names_order.insert(0, "") output = [] for layer_name in layer_names_order: rgba_layer_info = rgba_by_layer_name[layer_name] red = rgba_layer_info.get("R") green = rgba_layer_info.get("G") blue = rgba_layer_info.get("B") if not red or not green or not blue: continue output.append({ "name": layer_name, "review_channels": { "R": red, "G": green, "B": blue, "A": rgba_layer_info.get("A"), } }) return output def get_convert_rgb_channels(channel_names): """Get first available RGB(A) group from channels info. ## Examples ``` # Ideal situation channels_info: [ "R", "G", "B", "A" ] ``` Result will be `("R", "G", "B", "A")` ``` # Not ideal situation channels_info: [ "beauty.red", "beauty.green", "beauty.blue", "depth.Z" ] ``` Result will be `("beauty.red", "beauty.green", "beauty.blue", None)` Args: channel_names (list[str]): List of channel names. Returns: Union[NoneType, tuple[str, str, str, Union[str, None]]]: Tuple of 4 channel names defying channel names for R, G, B, A or None if there is not any layer with RGB combination. """ channels_info = get_review_info_by_layer_name(channel_names) for item in channels_info: review_channels = item["review_channels"] return ( review_channels["R"], review_channels["G"], review_channels["B"], review_channels["A"] ) return None def get_review_layer_name(src_filepath): """Find layer name that could be used for review. Args: src_filepath (str): Path to input file. Returns: Union[str, None]: Layer name of None. """ ext = os.path.splitext(src_filepath)[-1].lower() if ext != ".exr": return None # Load info about file from oiio tool input_info = get_oiio_info_for_input(src_filepath) if not input_info: return None channel_names = input_info["channelnames"] channels_info = get_review_info_by_layer_name(channel_names) for item in channels_info: # Layer name can be '', when review channels are 'R', 'G', 'B' # without layer return item["name"] or None return None def should_convert_for_ffmpeg(src_filepath): """Find out if input should be converted for ffmpeg. Currently cares only about exr inputs and is based on OpenImageIO. Returns: bool/NoneType: True if should be converted, False if should not and None if can't determine. """ # Care only about exr at this moment ext = os.path.splitext(src_filepath)[-1].lower() if ext != ".exr": return False # Can't determine if should convert or not without oiio_tool if not is_oiio_supported(): return None # Load info about file from oiio tool input_info = get_oiio_info_for_input(src_filepath) if not input_info: return None subimages = input_info.get("subimages") if subimages is not None and subimages > 1: return True # Check compression compression = input_info["attribs"].get("compression") if compression in ("dwaa", "dwab"): return True # Check channels channel_names = input_info["channelnames"] review_channels = get_convert_rgb_channels(channel_names) if review_channels is None: return None for attr_value in input_info["attribs"].values(): if not isinstance(attr_value, str): continue if len(attr_value) > MAX_FFMPEG_STRING_LEN: return True for char in NOT_ALLOWED_FFMPEG_CHARS: if char in attr_value: return True return False # Deprecated since 2022 4 20 # - Reason - Doesn't convert sequences right way: Can't handle gaps, reuse # first frame for all frames and changes filenames when input # is sequence. # - use 'convert_input_paths_for_ffmpeg' instead def convert_for_ffmpeg( first_input_path, output_dir, input_frame_start=None, input_frame_end=None, logger=None ): """Convert source file to format supported in ffmpeg. Currently can convert only exrs. Args: first_input_path (str): Path to first file of a sequence or a single file path for non-sequential input. output_dir (str): Path to directory where output will be rendered. Must not be same as input's directory. input_frame_start (int): Frame start of input. input_frame_end (int): Frame end of input. logger (logging.Logger): Logger used for logging. Raises: ValueError: If input filepath has extension not supported by function. Currently is supported only ".exr" extension. """ if logger is None: logger = logging.getLogger(__name__) logger.warning(( "DEPRECATED: 'openpype.lib.transcoding.convert_for_ffmpeg' is" " deprecated function of conversion for FFMpeg. Please replace usage" " with 'openpype.lib.transcoding.convert_input_paths_for_ffmpeg'" )) ext = os.path.splitext(first_input_path)[1].lower() if ext != ".exr": raise ValueError(( "Function 'convert_for_ffmpeg' currently support only" " \".exr\" extension. Got \"{}\"." ).format(ext)) is_sequence = False if input_frame_start is not None and input_frame_end is not None: is_sequence = int(input_frame_end) != int(input_frame_start) input_info = get_oiio_info_for_input(first_input_path, logger=logger) # Change compression only if source compression is "dwaa" or "dwab" # - they're not supported in ffmpeg compression = input_info["attribs"].get("compression") if compression in ("dwaa", "dwab"): compression = "none" # Prepare subprocess arguments oiio_cmd = get_oiio_tool_args( "oiiotool", # Don't add any additional attributes "--nosoftwareattrib", ) # Add input compression if available if compression: oiio_cmd.extend(["--compression", compression]) # Collect channels to export input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) oiio_cmd.extend([ input_arg, first_input_path, # Tell oiiotool which channels should be put to top stack (and output) "--ch", channels_arg, # Use first subimage "--subimage", "0" ]) # Add frame definitions to arguments if is_sequence: oiio_cmd.extend([ "--frames", "{}-{}".format(input_frame_start, input_frame_end) ]) for attr_name, attr_value in input_info["attribs"].items(): if not isinstance(attr_value, str): continue # Remove attributes that have string value longer than allowed length # for ffmpeg or when contain prohibited symbols erase_reason = "Missing reason" erase_attribute = False if len(attr_value) > MAX_FFMPEG_STRING_LEN: erase_reason = "has too long value ({} chars).".format( len(attr_value) ) erase_attribute = True if not erase_attribute: for char in NOT_ALLOWED_FFMPEG_CHARS: if char in attr_value: erase_attribute = True erase_reason = ( "contains unsupported character \"{}\"." ).format(char) break if erase_attribute: # Set attribute to empty string logger.info(( "Removed attribute \"{}\" from metadata because {}." ).format(attr_name, erase_reason)) oiio_cmd.extend(["--eraseattrib", attr_name]) # Add last argument - path to output if is_sequence: ext = os.path.splitext(first_input_path)[1] base_filename = "tmp.%{:0>2}d{}".format( len(str(input_frame_end)), ext ) else: base_filename = os.path.basename(first_input_path) output_path = os.path.join(output_dir, base_filename) oiio_cmd.extend([ "-o", output_path ]) logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) run_subprocess(oiio_cmd, logger=logger) def convert_input_paths_for_ffmpeg( input_paths, output_dir, logger=None ): """Convert source file to format supported in ffmpeg. Currently can convert only exrs. The input filepaths should be files with same type. Information about input is loaded only from first found file. Filenames of input files are kept so make sure that output directory is not the same directory as input files have. - This way it can handle gaps and can keep input filenames without handling frame template Args: input_paths (str): Paths that should be converted. It is expected that contains single file or image sequence of same type. output_dir (str): Path to directory where output will be rendered. Must not be same as input's directory. logger (logging.Logger): Logger used for logging. Raises: ValueError: If input filepath has extension not supported by function. Currently is supported only ".exr" extension. """ if logger is None: logger = logging.getLogger(__name__) first_input_path = input_paths[0] ext = os.path.splitext(first_input_path)[1].lower() if ext != ".exr": raise ValueError(( "Function 'convert_for_ffmpeg' currently support only" " \".exr\" extension. Got \"{}\"." ).format(ext)) input_info = get_oiio_info_for_input(first_input_path, logger=logger) # Change compression only if source compression is "dwaa" or "dwab" # - they're not supported in ffmpeg compression = input_info["attribs"].get("compression") if compression in ("dwaa", "dwab"): compression = "none" # Collect channels to export input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) for input_path in input_paths: # Prepare subprocess arguments oiio_cmd = get_oiio_tool_args( "oiiotool", # Don't add any additional attributes "--nosoftwareattrib", ) # Add input compression if available if compression: oiio_cmd.extend(["--compression", compression]) oiio_cmd.extend([ input_arg, input_path, # Tell oiiotool which channels should be put to top stack # (and output) "--ch", channels_arg, # Use first subimage "--subimage", "0" ]) for attr_name, attr_value in input_info["attribs"].items(): if not isinstance(attr_value, str): continue # Remove attributes that have string value longer than allowed # length for ffmpeg or when containing prohibited symbols erase_reason = "Missing reason" erase_attribute = False if len(attr_value) > MAX_FFMPEG_STRING_LEN: erase_reason = "has too long value ({} chars).".format( len(attr_value) ) erase_attribute = True if not erase_attribute: for char in NOT_ALLOWED_FFMPEG_CHARS: if char in attr_value: erase_attribute = True erase_reason = ( "contains unsupported character \"{}\"." ).format(char) break if erase_attribute: # Set attribute to empty string logger.info(( "Removed attribute \"{}\" from metadata because {}." ).format(attr_name, erase_reason)) oiio_cmd.extend(["--eraseattrib", attr_name]) # Add last argument - path to output base_filename = os.path.basename(input_path) output_path = os.path.join(output_dir, base_filename) oiio_cmd.extend([ "-o", output_path ]) logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) run_subprocess(oiio_cmd, logger=logger) # FFMPEG functions def get_ffprobe_data(path_to_file, logger=None): """Load data about entered filepath via ffprobe. Args: path_to_file (str): absolute path logger (logging.Logger): injected logger, if empty new is created """ if not logger: logger = logging.getLogger(__name__) logger.debug( "Getting information about input \"{}\".".format(path_to_file) ) ffprobe_args = get_ffmpeg_tool_args("ffprobe") args = ffprobe_args + [ "-hide_banner", "-loglevel", "fatal", "-show_error", "-show_format", "-show_streams", "-show_programs", "-show_chapters", "-show_private_data", "-print_format", "json", path_to_file ] logger.debug("FFprobe command: {}".format( subprocess.list2cmdline(args) )) kwargs = { "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, } if platform.system().lower() == "windows": kwargs["creationflags"] = ( subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NO_WINDOW", 0) ) popen = subprocess.Popen(args, **kwargs) popen_stdout, popen_stderr = popen.communicate() if popen_stdout: logger.debug("FFprobe stdout:\n{}".format( popen_stdout.decode("utf-8") )) if popen_stderr: logger.warning("FFprobe stderr:\n{}".format( popen_stderr.decode("utf-8") )) return json.loads(popen_stdout) def get_ffprobe_streams(path_to_file, logger=None): """Load streams from entered filepath via ffprobe. Args: path_to_file (str): absolute path logger (logging.Logger): injected logger, if empty new is created """ return get_ffprobe_data(path_to_file, logger)["streams"] def get_ffmpeg_format_args(ffprobe_data, source_ffmpeg_cmd=None): """Copy format from input metadata for output. Args: ffprobe_data(dict): Data received from ffprobe. source_ffmpeg_cmd(str): Command that created input if available. """ input_format = ffprobe_data.get("format") or {} if input_format.get("format_name") == "mxf": return _ffmpeg_mxf_format_args(ffprobe_data, source_ffmpeg_cmd) return [] def _ffmpeg_mxf_format_args(ffprobe_data, source_ffmpeg_cmd): input_format = ffprobe_data["format"] format_tags = input_format.get("tags") or {} operational_pattern_ul = format_tags.get("operational_pattern_ul") or "" output = [] if operational_pattern_ul == "060e2b34.04010102.0d010201.10030000": output.extend(["-f", "mxf_opatom"]) return output def get_ffmpeg_codec_args(ffprobe_data, source_ffmpeg_cmd=None, logger=None): """Copy codec from input metadata for output. Args: ffprobe_data(dict): Data received from ffprobe. source_ffmpeg_cmd(str): Command that created input if available. """ if logger is None: logger = logging.getLogger(__name__) video_stream = None no_audio_stream = None for stream in ffprobe_data["streams"]: codec_type = stream["codec_type"] if codec_type == "video": video_stream = stream break elif no_audio_stream is None and codec_type != "audio": no_audio_stream = stream if video_stream is None: if no_audio_stream is None: logger.warning( "Couldn't find stream that is not an audio file." ) return [] logger.info( "Didn't find video stream. Using first non audio stream." ) video_stream = no_audio_stream codec_name = video_stream.get("codec_name") # Codec "prores" if codec_name == "prores": return _ffmpeg_prores_codec_args(video_stream, source_ffmpeg_cmd) # Codec "h264" if codec_name == "h264": return _ffmpeg_h264_codec_args(video_stream, source_ffmpeg_cmd) # Coded DNxHD if codec_name == "dnxhd": return _ffmpeg_dnxhd_codec_args(video_stream, source_ffmpeg_cmd) output = [] if codec_name: output.extend(["-codec:v", codec_name]) bit_rate = video_stream.get("bit_rate") if bit_rate: output.extend(["-b:v", bit_rate]) pix_fmt = video_stream.get("pix_fmt") if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) output.extend(["-g", "1"]) return output def _ffmpeg_prores_codec_args(stream_data, source_ffmpeg_cmd): output = [] tags = stream_data.get("tags") or {} encoder = tags.get("encoder") or "" if encoder.endswith("prores_ks"): codec_name = "prores_ks" elif encoder.endswith("prores_aw"): codec_name = "prores_aw" else: codec_name = "prores" output.extend(["-codec:v", codec_name]) pix_fmt = stream_data.get("pix_fmt") if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) # Rest of arguments is prores_kw specific if codec_name == "prores_ks": codec_tag_to_profile_map = { "apco": "proxy", "apcs": "lt", "apcn": "standard", "apch": "hq", "ap4h": "4444", "ap4x": "4444xq" } codec_tag_str = stream_data.get("codec_tag_string") if codec_tag_str: profile = codec_tag_to_profile_map.get(codec_tag_str) if profile: output.extend(["-profile:v", profile]) return output def _ffmpeg_h264_codec_args(stream_data, source_ffmpeg_cmd): output = ["-codec:v", "h264"] # Use arguments from source if are available source arguments if source_ffmpeg_cmd: copy_args = ( "-crf", "-b:v", "-vb", "-minrate", "-minrate:", "-maxrate", "-maxrate:", "-bufsize", "-bufsize:" ) args = source_ffmpeg_cmd.split(" ") for idx, arg in enumerate(args): if arg in copy_args: output.extend([arg, args[idx + 1]]) pix_fmt = stream_data.get("pix_fmt") if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) output.extend(["-intra", "-g", "1"]) return output def _ffmpeg_dnxhd_codec_args(stream_data, source_ffmpeg_cmd): output = ["-codec:v", "dnxhd"] # Use source profile (profiles in metadata are not usable in args directly) profile = stream_data.get("profile") or "" # Lower profile and replace space with underscore cleaned_profile = profile.lower().replace(" ", "_") # TODO validate this statement # Looks like using 'dnxhd' profile must have set bit rate and in that case # should be used bitrate from source. # - related attributes 'bit_rate_defined', 'bit_rate_must_be_defined' bit_rate_must_be_defined = True dnx_profiles = { "dnxhd", "dnxhr_lb", "dnxhr_sq", "dnxhr_hq", "dnxhr_hqx", "dnxhr_444" } if cleaned_profile in dnx_profiles: if cleaned_profile != "dnxhd": bit_rate_must_be_defined = False output.extend(["-profile:v", cleaned_profile]) pix_fmt = stream_data.get("pix_fmt") if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) # Use arguments from source if are available source arguments bit_rate_defined = False if source_ffmpeg_cmd: # Define bitrate arguments bit_rate_args = ("-b:v", "-vb",) # Separate the two variables in case something else should be copied # from source command copy_args = [] copy_args.extend(bit_rate_args) args = source_ffmpeg_cmd.split(" ") for idx, arg in enumerate(args): if arg in copy_args: if arg in bit_rate_args: bit_rate_defined = True output.extend([arg, args[idx + 1]]) # Add bitrate if needed if bit_rate_must_be_defined and not bit_rate_defined: src_bit_rate = stream_data.get("bit_rate") if src_bit_rate: output.extend(["-b:v", src_bit_rate]) output.extend(["-g", "1"]) return output def convert_ffprobe_fps_value(str_value): """Returns (str) value of fps from ffprobe frame format (120/1)""" if str_value == "0/0": print("WARNING: Source has \"r_frame_rate\" value set to \"0/0\".") return "Unknown" items = str_value.split("/") if len(items) == 1: fps = float(items[0]) elif len(items) == 2: fps = float(items[0]) / float(items[1]) # Check if fps is integer or float number if int(fps) == fps: fps = int(fps) return str(fps) def convert_ffprobe_fps_to_float(value): """Convert string value of frame rate to float. Copy of 'convert_ffprobe_fps_value' which raises exceptions on invalid value, does not convert value to string and does not return "Unknown" string. Args: value (str): Value to be converted. Returns: Float: Converted frame rate in float. If divisor in value is '0' then '0.0' is returned. Raises: ValueError: Passed value is invalid for conversion. """ if not value: raise ValueError("Got empty value.") items = value.split("/") if len(items) == 1: return float(items[0]) if len(items) > 2: raise ValueError(( "FPS expression contains multiple dividers \"{}\"." ).format(value)) dividend = float(items.pop(0)) divisor = float(items.pop(0)) if divisor == 0.0: return 0.0 return dividend / divisor def convert_colorspace( input_path, output_path, config_path, source_colorspace, target_colorspace=None, view=None, display=None, additional_command_args=None, logger=None, ): """Convert source file from one color space to another. Args: input_path (str): Path that should be converted. It is expected that contains single file or image sequence of same type (sequence in format 'file.FRAMESTART-FRAMEEND#.ext', see oiio docs, eg `big.1-3#.tif`) output_path (str): Path to output filename. (must follow format of 'input_path', eg. single file or sequence in 'file.FRAMESTART-FRAMEEND#.ext', `output.1-3#.tif`) config_path (str): path to OCIO config file source_colorspace (str): ocio valid color space of source files target_colorspace (str): ocio valid target color space if filled, 'view' and 'display' must be empty view (str): name for viewer space (ocio valid) both 'view' and 'display' must be filled (if 'target_colorspace') display (str): name for display-referred reference space (ocio valid) both 'view' and 'display' must be filled (if 'target_colorspace') additional_command_args (list): arguments for oiiotool (like binary depth for .dpx) logger (logging.Logger): Logger used for logging. Raises: ValueError: if misconfigured """ if logger is None: logger = logging.getLogger(__name__) input_info = get_oiio_info_for_input(input_path, logger=logger) # Collect channels to export input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) # Prepare subprocess arguments oiio_cmd = get_oiio_tool_args( "oiiotool", # Don't add any additional attributes "--nosoftwareattrib", "--colorconfig", config_path ) oiio_cmd.extend([ input_arg, input_path, # Tell oiiotool which channels should be put to top stack # (and output) "--ch", channels_arg, # Use first subimage "--subimage", "0" ]) if all([target_colorspace, view, display]): raise ValueError("Colorspace and both screen and display" " cannot be set together." "Choose colorspace or screen and display") if not target_colorspace and not all([view, display]): raise ValueError("Both screen and display must be set.") if additional_command_args: oiio_cmd.extend(additional_command_args) if target_colorspace: oiio_cmd.extend(["--colorconvert", source_colorspace, target_colorspace]) if view and display: oiio_cmd.extend(["--iscolorspace", source_colorspace]) oiio_cmd.extend(["--ociodisplay", display, view]) oiio_cmd.extend(["-o", output_path]) logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) run_subprocess(oiio_cmd, logger=logger) def split_cmd_args(in_args): """Makes sure all entered arguments are separated in individual items. Split each argument string with " -" to identify if string contains one or more arguments. Args: in_args (list): of arguments ['-n', '-d uint10'] Returns (list): ['-n', '-d', 'unint10'] """ splitted_args = [] for arg in in_args: if not arg.strip(): continue splitted_args.extend(arg.split(" ")) return splitted_args def get_rescaled_command_arguments( application, input_path, target_width, target_height, target_par=None, bg_color=None, log=None ): """Get command arguments for rescaling input to target size. Args: application (str): Application for which command should be created. Currently supported are "ffmpeg" and "oiiotool". input_path (str): Path to input file. target_width (int): Width of target. target_height (int): Height of target. target_par (Optional[float]): Pixel aspect ratio of target. bg_color (Optional[list[int]]): List of 8bit int values for background color. Should be in range 0 - 255. log (Optional[logging.Logger]): Logger used for logging. Returns: list[str]: List of command arguments. """ command_args = [] target_par = target_par or 1.0 input_par = 1.0 input_height, input_width, stream_input_par = _get_image_dimensions( application, input_path, log) if stream_input_par: input_par = ( float(stream_input_par.split(":")[0]) / float(stream_input_par.split(":")[1]) ) # recalculating input and target width input_width = int(input_width * input_par) target_width = int(target_width * target_par) # calculate aspect ratios target_aspect = float(target_width) / target_height input_aspect = float(input_width) / input_height # calculate scale size scale_size = float(input_width) / target_width if input_aspect < target_aspect: scale_size = float(input_height) / target_height # calculate rescaled width and height rescaled_width = int(input_width / scale_size) rescaled_height = int(input_height / scale_size) # calculate width and height shift rescaled_width_shift = int((target_width - rescaled_width) / 2) rescaled_height_shift = int((target_height - rescaled_height) / 2) if application == "ffmpeg": # create scale command scale = "scale={0}:{1}".format(input_width, input_height) pad = "pad={0}:{1}:({2}-iw)/2:({3}-ih)/2".format( target_width, target_height, target_width, target_height ) if input_width > target_width or input_height > target_height: scale = "scale={0}:{1}".format(rescaled_width, rescaled_height) pad = "pad={0}:{1}:{2}:{3}".format( target_width, target_height, rescaled_width_shift, rescaled_height_shift ) if bg_color: color = convert_color_values(application, bg_color) pad += ":{0}".format(color) command_args.extend(["-vf", "{0},{1}".format(scale, pad)]) elif application == "oiiotool": input_info = get_oiio_info_for_input(input_path, logger=log) # Collect channels to export _, channels_arg = get_oiio_input_and_channel_args( input_info, alpha_default=1.0) command_args.extend([ # Tell oiiotool which channels should be put to top stack # (and output) "--ch", channels_arg, # Use first subimage "--subimage", "0" ]) if input_par != 1.0: command_args.extend(["--pixelaspect", "1"]) width_shift = int((target_width - input_width) / 2) height_shift = int((target_height - input_height) / 2) # default resample is not scaling source image resample = [ "--resize", "{0}x{1}".format(input_width, input_height), "--origin", "+{0}+{1}".format(width_shift, height_shift), ] # scaled source image to target size if input_width > target_width or input_height > target_height: # form resample command resample = [ "--resize:filter=lanczos3", "{0}x{1}".format(rescaled_width, rescaled_height), "--origin", "+{0}+{1}".format(rescaled_width_shift, rescaled_height_shift), ] command_args.extend(resample) fullsize = [ "--fullsize", "{0}x{1}".format(target_width, target_height) ] if bg_color: color = convert_color_values(application, bg_color) fullsize.extend([ "--pattern", "constant:color={0}".format(color), "{0}x{1}".format(target_width, target_height), "4", # 4 channels "--over" ]) command_args.extend(fullsize) else: raise ValueError( "\"application\" input argument should " "be either \"ffmpeg\" or \"oiiotool\"" ) return command_args def _get_image_dimensions(application, input_path, log): """Uses 'ffprobe' first and then 'oiiotool' if available to get dim. Args: application (str): "oiiotool"|"ffmpeg" input_path (str): path to image file log (Optional[logging.Logger]): Logger used for logging. Returns: (tuple) (int, int, dict) - (height, width, sample_aspect_ratio) Raises: RuntimeError if image dimensions couldn't be parsed out. """ # ffmpeg command input_file_metadata = get_ffprobe_data(input_path, logger=log) input_width = input_height = 0 stream = next( ( s for s in input_file_metadata["streams"] if s.get("codec_type") == "video" ), {} ) if stream: input_width = int(stream["width"]) input_height = int(stream["height"]) # fallback for weird files with width=0, height=0 if (input_width == 0 or input_height == 0) and application == "oiiotool": # Load info about file from oiio tool input_info = get_oiio_info_for_input(input_path, logger=log) if input_info: input_width = int(input_info["width"]) input_height = int(input_info["height"]) if input_width == 0 or input_height == 0: raise RuntimeError("Couldn't read {} either " "with ffprobe or oiiotool".format(input_path)) stream_input_par = stream.get("sample_aspect_ratio") return input_height, input_width, stream_input_par def convert_color_values(application, color_value): """Get color mapping for ffmpeg and oiiotool. Args: application (str): Application for which command should be created. color_value (list[int]): List of 8bit int values for RGBA. Returns: str: ffmpeg returns hex string, oiiotool is string with floats. """ red, green, blue, alpha = color_value if application == "ffmpeg": return "{0:0>2X}{1:0>2X}{2:0>2X}@{3}".format( red, green, blue, (alpha / 255.0) ) elif application == "oiiotool": red = float(red / 255) green = float(green / 255) blue = float(blue / 255) alpha = float(alpha / 255) return "{0:.3f},{1:.3f},{2:.3f},{3:.3f}".format( red, green, blue, alpha) else: raise ValueError( "\"application\" input argument should " "be either \"ffmpeg\" or \"oiiotool\"" ) def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None): """Get input and channel arguments for oiiotool. Args: oiio_input_info (dict): Information about input from oiio tool. Should be output of function `get_oiio_info_for_input`. alpha_default (float, optional): Default value for alpha channel. Returns: tuple[str, str]: Tuple of input and channel arguments. """ channel_names = oiio_input_info["channelnames"] review_channels = get_convert_rgb_channels(channel_names) if review_channels is None: raise ValueError( "Couldn't find channels that can be used for conversion." ) red, green, blue, alpha = review_channels input_channels = [red, green, blue] channels_arg = "R={0},G={1},B={2}".format(red, green, blue) if alpha is not None: channels_arg += ",A={}".format(alpha) input_channels.append(alpha) elif alpha_default: channels_arg += ",A={}".format(float(alpha_default)) input_channels.append("A") input_channels_str = ",".join(input_channels) subimages = oiio_input_info.get("subimages") input_arg = "-i" if subimages is None or subimages == 1: # Tell oiiotool which channels should be loaded # - other channels are not loaded to memory so helps to avoid memory # leak issues # - this option is crashing if used on multipart exrs input_arg += ":ch={}".format(input_channels_str) return input_arg, channels_arg ================================================ FILE: openpype/lib/usdlib.py ================================================ import os import re import logging try: from pxr import Usd, UsdGeom, Sdf, Kind except ImportError: # Allow to fall back on Multiverse 6.3.0+ pxr usd library from mvpxr import Usd, UsdGeom, Sdf, Kind from openpype.client import get_project, get_asset_by_name from openpype.pipeline import Anatomy, get_current_project_name log = logging.getLogger(__name__) # The predefined steps order used for bootstrapping USD Shots and Assets. # These are ordered in order from strongest to weakest opinions, like in USD. PIPELINE = { "shot": [ "usdLighting", "usdFx", "usdSimulation", "usdAnimation", "usdLayout", ], "asset": ["usdShade", "usdModel"], } def create_asset( filepath, asset_name, reference_layers, kind=Kind.Tokens.component ): """ Creates an asset file that consists of a top level layer and sublayers for shading and geometry. Args: filepath (str): Filepath where the asset.usd file will be saved. reference_layers (list): USD Files to reference in the asset. Note that the bottom layer (first file, like a model) would be last in the list. The strongest layer will be the first index. asset_name (str): The name for the Asset identifier and default prim. kind (pxr.Kind): A USD Kind for the root asset. """ # Also see create_asset.py in PixarAnimationStudios/USD endToEnd example log.info("Creating asset at %s", filepath) # Make the layer ascii - good for readability, plus the file is small root_layer = Sdf.Layer.CreateNew(filepath, args={"format": "usda"}) stage = Usd.Stage.Open(root_layer) # Define a prim for the asset and make it the default for the stage. asset_prim = UsdGeom.Xform.Define(stage, "/%s" % asset_name).GetPrim() stage.SetDefaultPrim(asset_prim) # Let viewing applications know how to orient a free camera properly UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.y) # Usually we will "loft up" the kind authored into the exported geometry # layer rather than re-stamping here; we'll leave that for a later # tutorial, and just be explicit here. model = Usd.ModelAPI(asset_prim) if kind: model.SetKind(kind) model.SetAssetName(asset_name) model.SetAssetIdentifier("%s/%s.usd" % (asset_name, asset_name)) # Add references to the asset prim references = asset_prim.GetReferences() for reference_filepath in reference_layers: references.AddReference(reference_filepath) stage.GetRootLayer().Save() def create_shot(filepath, layers, create_layers=False): """Create a shot with separate layers for departments. Args: filepath (str): Filepath where the asset.usd file will be saved. layers (str): When provided this will be added verbatim in the subLayerPaths layers. When the provided layer paths do not exist they are generated using Sdf.Layer.CreateNew create_layers (bool): Whether to create the stub layers on disk if they do not exist yet. Returns: str: The saved shot file path """ # Also see create_shot.py in PixarAnimationStudios/USD endToEnd example stage = Usd.Stage.CreateNew(filepath) log.info("Creating shot at %s" % filepath) for layer_path in layers: if create_layers and not os.path.exists(layer_path): # We use the Sdf API here to quickly create layers. Also, we're # using it as a way to author the subLayerPaths as there is no # way to do that directly in the Usd API. layer_folder = os.path.dirname(layer_path) if not os.path.exists(layer_folder): os.makedirs(layer_folder) Sdf.Layer.CreateNew(layer_path) stage.GetRootLayer().subLayerPaths.append(layer_path) # Lets viewing applications know how to orient a free camera properly UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.y) stage.GetRootLayer().Save() return filepath def create_model(filename, asset, variant_subsets): """Create a USD Model file. For each of the variation paths it will payload the path and set its relevant variation name. """ project_name = get_current_project_name() asset_doc = get_asset_by_name(project_name, asset) assert asset_doc, "Asset not found: %s" % asset variants = [] for subset in variant_subsets: prefix = "usdModel" if subset.startswith(prefix): # Strip off `usdModel_` variant = subset[len(prefix):] else: raise ValueError( "Model subsets must start " "with usdModel: %s" % subset ) path = get_usd_master_path( asset=asset_doc, subset=subset, representation="usd" ) variants.append((variant, path)) stage = _create_variants_file( filename, variants=variants, variantset="model", variant_prim="/root", reference_prim="/root/geo", as_payload=True, ) UsdGeom.SetStageMetersPerUnit(stage, 1) UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.y) # modelAPI = Usd.ModelAPI(root_prim) # modelAPI.SetKind(Kind.Tokens.component) # See http://openusd.org/docs/api/class_usd_model_a_p_i.html#details # for more on assetInfo # modelAPI.SetAssetName(asset) # modelAPI.SetAssetIdentifier(asset) stage.GetRootLayer().Save() def create_shade(filename, asset, variant_subsets): """Create a master USD shade file for an asset. For each available model variation this should generate a reference to a `usdShade_{modelVariant}` subset. """ project_name = get_current_project_name() asset_doc = get_asset_by_name(project_name, asset) assert asset_doc, "Asset not found: %s" % asset variants = [] for subset in variant_subsets: prefix = "usdModel" if subset.startswith(prefix): # Strip off `usdModel_` variant = subset[len(prefix):] else: raise ValueError( "Model subsets must start " "with usdModel: %s" % subset ) shade_subset = re.sub("^usdModel", "usdShade", subset) path = get_usd_master_path( asset=asset_doc, subset=shade_subset, representation="usd" ) variants.append((variant, path)) stage = _create_variants_file( filename, variants=variants, variantset="model", variant_prim="/root" ) stage.GetRootLayer().Save() def create_shade_variation(filename, asset, model_variant, shade_variants): """Create the master Shade file for a specific model variant. This should reference all shade variants for the specific model variant. """ project_name = get_current_project_name() asset_doc = get_asset_by_name(project_name, asset) assert asset_doc, "Asset not found: %s" % asset variants = [] for variant in shade_variants: subset = "usdShade_{model}_{shade}".format( model=model_variant, shade=variant ) path = get_usd_master_path( asset=asset_doc, subset=subset, representation="usd" ) variants.append((variant, path)) stage = _create_variants_file( filename, variants=variants, variantset="shade", variant_prim="/root" ) stage.GetRootLayer().Save() def _create_variants_file( filename, variants, variantset, default_variant=None, variant_prim="/root", reference_prim=None, set_default_variant=True, as_payload=False, skip_variant_on_single_file=True, ): root_layer = Sdf.Layer.CreateNew(filename, args={"format": "usda"}) stage = Usd.Stage.Open(root_layer) root_prim = stage.DefinePrim(variant_prim) stage.SetDefaultPrim(root_prim) def _reference(path): """Reference/Payload path depending on function arguments""" if reference_prim: prim = stage.DefinePrim(reference_prim) else: prim = root_prim if as_payload: # Payload prim.GetPayloads().AddPayload(Sdf.Payload(path)) else: # Reference prim.GetReferences().AddReference(Sdf.Reference(path)) assert variants, "Must have variants, got: %s" % variants log.info(filename) if skip_variant_on_single_file and len(variants) == 1: # Reference directly, no variants variant_path = variants[0][1] _reference(variant_path) log.info("Non-variants..") log.info("Path: %s" % variant_path) else: # Variants append = Usd.ListPositionBackOfAppendList variant_set = root_prim.GetVariantSets().AddVariantSet( variantset, append ) for variant, variant_path in variants: if default_variant is None: default_variant = variant variant_set.AddVariant(variant, append) variant_set.SetVariantSelection(variant) with variant_set.GetVariantEditContext(): _reference(variant_path) log.info("Variants..") log.info("Variant: %s" % variant) log.info("Path: %s" % variant_path) if set_default_variant: variant_set.SetVariantSelection(default_variant) return stage def get_usd_master_path(asset, subset, representation): """Get the filepath for a .usd file of a subset. This will return the path to an unversioned master file generated by `usd_master_file.py`. """ project_name = get_current_project_name() anatomy = Anatomy(project_name) project_doc = get_project( project_name, fields=["name", "data.code"] ) if isinstance(asset, dict) and "name" in asset: # Allow explicitly passing asset document asset_doc = asset else: asset_doc = get_asset_by_name(project_name, asset, fields=["name"]) template_obj = anatomy.templates_obj["publish"]["path"] path = template_obj.format_strict( { "project": { "name": project_name, "code": project_doc.get("data", {}).get("code") }, "folder": { "name": asset_doc["name"], }, "asset": asset_doc["name"], "subset": subset, "representation": representation, "version": 0, # stub version zero } ) # Remove the version folder subset_folder = os.path.dirname(os.path.dirname(path)) master_folder = os.path.join(subset_folder, "master") fname = "{0}.{1}".format(subset, representation) return os.path.join(master_folder, fname).replace("\\", "/") def parse_avalon_uri(uri): # URI Pattern: avalon://{asset}/{subset}.{ext} pattern = r"avalon://(?P[^/.]*)/(?P[^/]*)\.(?P.*)" if uri.startswith("avalon://"): match = re.match(pattern, uri) if match: return match.groupdict() ================================================ FILE: openpype/lib/vendor_bin_utils.py ================================================ import os import logging import platform import subprocess from openpype import AYON_SERVER_ENABLED log = logging.getLogger("Vendor utils") class ToolNotFoundError(Exception): """Raised when tool arguments are not found.""" class CachedToolPaths: """Cache already used and discovered tools and their executables. Discovering path can take some time and can trigger subprocesses so it's better to cache the paths on first get. """ _cached_paths = {} @classmethod def is_tool_cached(cls, tool): return tool in cls._cached_paths @classmethod def get_executable_path(cls, tool): return cls._cached_paths.get(tool) @classmethod def cache_executable_path(cls, tool, path): cls._cached_paths[tool] = path def is_file_executable(filepath): """Filepath lead to executable file. Args: filepath(str): Full path to file. """ if not filepath: return False if os.path.isfile(filepath): if os.access(filepath, os.X_OK): return True log.info( "Filepath is not available for execution \"{}\"".format(filepath) ) return False def find_executable(executable): """Find full path to executable. Also tries additional extensions if passed executable does not contain one. Paths where it is looked for executable is defined by 'PATH' environment variable, 'os.confstr("CS_PATH")' or 'os.defpath'. Args: executable(str): Name of executable with or without extension. Can be path to file. Returns: Union[str, None]: Full path to executable with extension which was found otherwise None. """ # Skip if passed path is file if is_file_executable(executable): return executable low_platform = platform.system().lower() _, ext = os.path.splitext(executable) # Prepare extensions to check exts = set() if ext: exts.add(ext.lower()) else: # Add other possible extension variants only if passed executable # does not have any if low_platform == "windows": exts |= {".exe", ".ps1", ".bat"} for ext in os.getenv("PATHEXT", "").split(os.pathsep): exts.add(ext.lower()) else: exts |= {".sh"} # Executable is a path but there may be missing extension # - this can happen primarily on windows where # e.g. "ffmpeg" should be "ffmpeg.exe" exe_dir, exe_filename = os.path.split(executable) if exe_dir and os.path.isdir(exe_dir): for filename in os.listdir(exe_dir): filepath = os.path.join(exe_dir, filename) basename, ext = os.path.splitext(filename) if ( basename == exe_filename and ext.lower() in exts and is_file_executable(filepath) ): return filepath # Get paths where to look for executable path_str = os.environ.get("PATH", None) if path_str is None: if hasattr(os, "confstr"): path_str = os.confstr("CS_PATH") elif hasattr(os, "defpath"): path_str = os.defpath if not path_str: return None paths = path_str.split(os.pathsep) for path in paths: if not os.path.isdir(path): continue for filename in os.listdir(path): filepath = os.path.abspath(os.path.join(path, filename)) # Filename matches executable exactly if filename == executable and is_file_executable(filepath): return filepath basename, ext = os.path.splitext(filename) if ( basename == executable and ext.lower() in exts and is_file_executable(filepath) ): return filepath return None def get_vendor_bin_path(bin_app): """Path to OpenPype vendorized binaries. Vendorized executables are expected in specific hierarchy inside build or in code source. "{OPENPYPE_ROOT}/vendor/bin/{name of vendorized app}/{platform}" Args: bin_app (str): Name of vendorized application. Returns: str: Path to vendorized binaries folder. """ return os.path.join( os.environ["OPENPYPE_ROOT"], "vendor", "bin", bin_app, platform.system().lower() ) def find_tool_in_custom_paths(paths, tool, validation_func=None): """Find a tool executable in custom paths. Args: paths (Iterable[str]): Iterable of paths where to look for tool. tool (str): Name of tool (binary file) to find in passed paths. validation_func (Function): Custom validation function of path. Function must expect one argument which is path to executable. If not passed only 'find_executable' is used to be able identify if path is valid. Reuturns: Union[str, None]: Path to validated executable or None if was not found. """ for path in paths: # Skip empty strings if not path: continue # Handle cases when path is just an executable # - it allows to use executable from PATH # - basename must match 'tool' value (without extension) extless_path, ext = os.path.splitext(path) if extless_path == tool: executable_path = find_executable(tool) if executable_path and ( validation_func is None or validation_func(executable_path) ): return executable_path continue # Normalize path because it should be a path and check if exists normalized = os.path.normpath(path) if not os.path.exists(normalized): continue # Note: Path can be both file and directory # If path is a file validate it if os.path.isfile(normalized): basename, ext = os.path.splitext(os.path.basename(path)) # Check if the filename has actually the sane bane as 'tool' if basename == tool: executable_path = find_executable(normalized) if executable_path and ( validation_func is None or validation_func(executable_path) ): return executable_path # Check if path is a directory and look for tool inside the dir if os.path.isdir(normalized): executable_path = find_executable(os.path.join(normalized, tool)) if executable_path and ( validation_func is None or validation_func(executable_path) ): return executable_path return None def _check_args_returncode(args): try: kwargs = {} if platform.system().lower() == "windows": kwargs["creationflags"] = ( subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NO_WINDOW", 0) ) if hasattr(subprocess, "DEVNULL"): proc = subprocess.Popen( args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs ) proc.wait() else: with open(os.devnull, "w") as devnull: proc = subprocess.Popen( args, stdout=devnull, stderr=devnull, **kwargs ) proc.wait() except Exception: return False return proc.returncode == 0 def _oiio_executable_validation(args): """Validate oiio tool executable if can be executed. Validation has 2 steps. First is using 'find_executable' to fill possible missing extension or fill directory then launch executable and validate that it can be executed. For that is used '--help' argument which is fast and does not need any other inputs. Any possible crash of missing libraries or invalid build should be caught. Main reason is to validate if executable can be executed on OS just running which can be issue ob linux machines. Note: It does not validate if the executable is really a oiio tool which should be used. Args: args (Union[str, list[str]]): Arguments to launch tool or path to tool executable. Returns: bool: Filepath is valid executable. """ if not args: return False if not isinstance(args, list): filepath = find_executable(args) if not filepath: return False args = [filepath] return _check_args_returncode(args + ["--help"]) def _get_ayon_oiio_tool_args(tool_name): try: # Use 'ayon-third-party' addon to get oiio arguments from ayon_third_party import get_oiio_arguments except Exception: print("!!! Failed to import 'ayon_third_party' addon.") return None try: return get_oiio_arguments(tool_name) except Exception as exc: print("!!! Failed to get OpenImageIO args. Reason: {}".format(exc)) return None def get_oiio_tools_path(tool="oiiotool"): """Path to OpenImageIO tool executables. On Windows it adds .exe extension if missing from tool argument. Args: tool (string): Tool name 'oiiotool', 'maketx', etc. Default is "oiiotool". """ if CachedToolPaths.is_tool_cached(tool): return CachedToolPaths.get_executable_path(tool) if AYON_SERVER_ENABLED: args = _get_ayon_oiio_tool_args(tool) if args: if len(args) > 1: raise ValueError( "AYON oiio arguments consist of multiple arguments." ) tool_executable_path = args[0] CachedToolPaths.cache_executable_path(tool, tool_executable_path) return tool_executable_path custom_paths_str = os.environ.get("OPENPYPE_OIIO_PATHS") or "" tool_executable_path = find_tool_in_custom_paths( custom_paths_str.split(os.pathsep), tool, _oiio_executable_validation ) if not tool_executable_path: oiio_dir = get_vendor_bin_path("oiio") if platform.system().lower() == "linux": oiio_dir = os.path.join(oiio_dir, "bin") default_path = find_executable(os.path.join(oiio_dir, tool)) if default_path and _oiio_executable_validation(default_path): tool_executable_path = default_path # Look to PATH for the tool if not tool_executable_path: from_path = find_executable(tool) if from_path and _oiio_executable_validation(from_path): tool_executable_path = from_path CachedToolPaths.cache_executable_path(tool, tool_executable_path) return tool_executable_path def get_oiio_tool_args(tool_name, *extra_args): """Arguments to launch OpenImageIO tool. Args: tool_name (str): Tool name 'oiiotool', 'maketx', etc. *extra_args (str): Extra arguments to add to after tool arguments. Returns: list[str]: List of arguments. """ extra_args = list(extra_args) if AYON_SERVER_ENABLED: args = _get_ayon_oiio_tool_args(tool_name) if args: return args + extra_args path = get_oiio_tools_path(tool_name) if path: return [path] + extra_args raise ToolNotFoundError( "OIIO '{}' tool not found.".format(tool_name) ) def _ffmpeg_executable_validation(args): """Validate ffmpeg tool executable if can be executed. Validation has 2 steps. First is using 'find_executable' to fill possible missing extension or fill directory then launch executable and validate that it can be executed. For that is used '-version' argument which is fast and does not need any other inputs. Any possible crash of missing libraries or invalid build should be caught. Main reason is to validate if executable can be executed on OS just running which can be issue ob linux machines. Note: It does not validate if the executable is really a ffmpeg tool. Args: args (Union[str, list[str]]): Arguments to launch tool or path to tool executable. Returns: bool: Filepath is valid executable. """ if not args: return False if not isinstance(args, list): filepath = find_executable(args) if not filepath: return False args = [filepath] return _check_args_returncode(args + ["--help"]) def _get_ayon_ffmpeg_tool_args(tool_name): try: # Use 'ayon-third-party' addon to get ffmpeg arguments from ayon_third_party import get_ffmpeg_arguments except Exception: print("!!! Failed to import 'ayon_third_party' addon.") return None try: return get_ffmpeg_arguments(tool_name) except Exception as exc: print("!!! Failed to get FFmpeg args. Reason: {}".format(exc)) return None def get_ffmpeg_tool_path(tool="ffmpeg"): """Path to vendorized FFmpeg executable. Args: tool (str): Tool name 'ffmpeg', 'ffprobe', etc. Default is "ffmpeg". Returns: str: Full path to ffmpeg executable. """ if CachedToolPaths.is_tool_cached(tool): return CachedToolPaths.get_executable_path(tool) if AYON_SERVER_ENABLED: args = _get_ayon_ffmpeg_tool_args(tool) if args is not None: if len(args) > 1: raise ValueError( "AYON ffmpeg arguments consist of multiple arguments." ) tool_executable_path = args[0] CachedToolPaths.cache_executable_path(tool, tool_executable_path) return tool_executable_path custom_paths_str = os.environ.get("OPENPYPE_FFMPEG_PATHS") or "" tool_executable_path = find_tool_in_custom_paths( custom_paths_str.split(os.pathsep), tool, _ffmpeg_executable_validation ) if not tool_executable_path: ffmpeg_dir = get_vendor_bin_path("ffmpeg") if platform.system().lower() == "windows": ffmpeg_dir = os.path.join(ffmpeg_dir, "bin") tool_path = find_executable(os.path.join(ffmpeg_dir, tool)) if tool_path and _ffmpeg_executable_validation(tool_path): tool_executable_path = tool_path # Look to PATH for the tool if not tool_executable_path: from_path = find_executable(tool) if from_path and _ffmpeg_executable_validation(from_path): tool_executable_path = from_path CachedToolPaths.cache_executable_path(tool, tool_executable_path) return tool_executable_path def get_ffmpeg_tool_args(tool_name, *extra_args): """Arguments to launch FFmpeg tool. Args: tool_name (str): Tool name 'ffmpeg', 'ffprobe', exc. *extra_args (str): Extra arguments to add to after tool arguments. Returns: list[str]: List of arguments. """ extra_args = list(extra_args) if AYON_SERVER_ENABLED: args = _get_ayon_ffmpeg_tool_args(tool_name) if args: return args + extra_args executable_path = get_ffmpeg_tool_path(tool_name) if executable_path: return [executable_path] + extra_args raise ToolNotFoundError( "FFmpeg '{}' tool not found.".format(tool_name) ) def is_oiio_supported(): """Checks if oiiotool is configured for this platform. Returns: bool: OIIO tool executable is available. """ try: args = get_oiio_tool_args("oiiotool") except ToolNotFoundError: args = None if not args: log.debug("OIIOTool is not configured or not present.") return False return _oiio_executable_validation(args) ================================================ FILE: openpype/modules/README.md ================================================ # OpenPype modules/addons OpenPype modules should contain separated logic of specific kind of implementation, such as Ftrack connection and its usage code, Deadline farm rendering or may contain only special plugins. Addons work the same way currently, there is no difference between module and addon functionality. ## Modules concept - modules and addons are dynamically imported to virtual python module `openpype_modules` from which it is possible to import them no matter where is the module located - modules or addons should never be imported directly, even if you know possible full import path - it is because all of their content must be imported in specific order and should not be imported without defined functions as it may also break few implementation parts ### TODOs - add module/addon manifest - definition of module (not 100% defined content e.g. minimum required OpenPype version etc.) - defining a folder as a content of a module or an addon ## Base class `OpenPypeModule` - abstract class as base for each module - implementation should contain module's api without GUI parts - may implement `get_global_environments` method which should return dictionary of environments that are globally applicable and value is the same for whole studio if launched at any workstation (except os specific paths) - abstract parts: - `name` attribute - name of a module - `initialize` method - method for own initialization of a module (should not override `__init__`) - `connect_with_modules` method - where module may look for it's interfaces implementations or check for other modules - `__init__` should not be overridden and `initialize` should not do time consuming part but only prepare base data about module - also keep in mind that they may be initialized in headless mode - connection with other modules is made with help of interfaces - `cli` method - add cli commands specific for the module - command line arguments are handled using `click` python module - `cli` method should expect single argument which is click group on which can be called any group specific methods (e.g. `add_command` to add another click group as children see `ExampleAddon`) - it is possible to add trigger cli commands using `./openpype_console module *args` ## Addon class `OpenPypeAddOn` - inherits from `OpenPypeModule` but is enabled by default and doesn't have to implement `initialize` and `connect_with_modules` methods - that is because it is expected that addons don't need to have system settings and `enabled` value on it (but it is possible...) ## How to add addons/modules - in System settings go to `modules/addon_paths` (`Modules/OpenPype AddOn Paths`) where you have to add path to addon root folder - for openpype example addons use `{OPENPYPE_REPOS_ROOT}/openpype/modules/example_addons` ## Addon/module settings - addons/modules may have defined custom settings definitions with default values - it is based on settings type `dynamic_schema` which has `name` - that item defines that it can be replaced dynamically with any schemas from module or module which won't be saved to openpype core defaults - they can't be added to any schema hierarchy - item must not be in settings group (under overrides) or in dynamic item (e.g. `list` of `dict-modifiable`) - addons may define it's dynamic schema items - they can be defined with class which inherits from `BaseModuleSettingsDef` - it is recommended to use pre implemented `JsonFilesSettingsDef` which defined structure and use json files to define dynamic schemas, schemas and default values - check it's docstring and check for `example_addon` in example addons - settings definition returns schemas by dynamic schemas names # Interfaces - interface is class that has defined abstract methods to implement and may contain pre implemented helper methods - module that inherit from an interface must implement those abstract methods otherwise won't be initialized - it is easy to find which module object inherited from which interfaces with 100% chance they have implemented required methods - interfaces can be defined in `interfaces.py` inside module directory - the file can't use relative imports or import anything from other parts of module itself at the header of file - this is one of reasons why modules/addons can't be imported directly without using defined functions in OpenPype modules implementation ## Base class `OpenPypeInterface` - has nothing implemented - has ABCMeta as metaclass - is defined to be able find out classes which inherit from this base to be able tell this is an Interface ## Global interfaces - few interfaces are implemented for global usage ### IPluginPaths - module wants to add directory path/s to avalon or publish plugins - module must implement `get_plugin_paths` which must return dictionary with possible keys `"publish"`, `"load"`, `"create"` or `"actions"` - each key may contain list or string with a path to directory with plugins ### ITrayModule - module has more logic when used in a tray - it is possible that module can be used only in the tray - abstract methods - `tray_init` - initialization triggered after `initialize` when used in `TrayModulesManager` and before `connect_with_modules` - `tray_menu` - add actions to tray widget's menu that represent the module - `tray_start` - start of module's login in tray - module is initialized and connected with other modules - `tray_exit` - module's cleanup like stop and join threads etc. - order of calling is based on implementation this order is how it works with `TrayModulesManager` - it is recommended to import and use GUI implementation only in these methods - has attribute `tray_initialized` (bool) which is set to False by default and is set by `TrayModulesManager` to True after `tray_init` - if module has logic only in tray or for both then should be checking for `tray_initialized` attribute to decide how should handle situations ### ITrayService - inherits from `ITrayModule` and implements `tray_menu` method for you - adds action to submenu "Services" in tray widget menu with icon and label - abstract attribute `label` - label shown in menu - interface has pre implemented methods to change icon color - `set_service_running` - green icon - `set_service_failed` - red icon - `set_service_idle` - orange icon - these states must be set by module itself `set_service_running` is default state on initialization ### ITrayAction - inherits from `ITrayModule` and implements `tray_menu` method for you - adds action to tray widget menu with label - abstract attribute `label` - label shown in menu - abstract method `on_action_trigger` - what should happen when an action is triggered - NOTE: It is a good idea to implement logic in `on_action_trigger` to the api method and trigger that method on callbacks. This gives ability to trigger that method outside tray ## Modules interfaces - modules may have defined their own interfaces to be able to recognize other modules that would want to use their features ### Example: - Ftrack module has `IFtrackEventHandlerPaths` which helps to tell Ftrack module which other modules want to add paths to server/user event handlers - Clockify module use `IFtrackEventHandlerPaths` and returns paths to clockify ftrack synchronizers - Clockify inherits from more interfaces. It's class definition looks like: ``` class ClockifyModule( OpenPypeModule, # Says it's Pype module so ModulesManager will try to initialize. ITrayModule, # Says has special implementation when used in tray. IPluginPaths, # Says has plugin paths that want to register (paths to clockify actions for launcher). IFtrackEventHandlerPaths, # Says has Ftrack actions/events for user/server. ITimersManager # Listen to other modules with timer and can trigger changes in other module timers through `TimerManager` module. ): ``` ### ModulesManager - collects module classes and tries to initialize them - important attributes - `modules` - list of available attributes - `modules_by_id` - dictionary of modules mapped by their ids - `modules_by_name` - dictionary of modules mapped by their names - all these attributes contain all found modules even if are not enabled - helper methods - `collect_global_environments` to collect all global environments from enabled modules with calling `get_global_environments` on each of them - `collect_plugin_paths` collects plugin paths from all enabled modules - output is always dictionary with all keys and values as an list ``` { "publish": [], "create": [], "load": [], "actions": [], "inventory": [] } ``` ### TrayModulesManager - inherits from `ModulesManager` - has specific implementation for Pype Tray tool and handle `ITrayModule` methods ================================================ FILE: openpype/modules/__init__.py ================================================ # -*- coding: utf-8 -*- from . import click_wrap from .interfaces import ( ILaunchHookPaths, IPluginPaths, ITrayModule, ITrayAction, ITrayService, ISettingsChangeListener, IHostAddon, ) from .base import ( AYONAddon, OpenPypeModule, OpenPypeAddOn, load_modules, ModulesManager, TrayModulesManager, BaseModuleSettingsDef, ModuleSettingsDef, JsonFilesSettingsDef, get_module_settings_defs ) __all__ = ( "click_wrap", "ILaunchHookPaths", "IPluginPaths", "ITrayModule", "ITrayAction", "ITrayService", "ISettingsChangeListener", "IHostAddon", "AYONAddon", "OpenPypeModule", "OpenPypeAddOn", "load_modules", "ModulesManager", "TrayModulesManager", "BaseModuleSettingsDef", "ModuleSettingsDef", "JsonFilesSettingsDef", "get_module_settings_defs" ) ================================================ FILE: openpype/modules/asset_reporter/__init__.py ================================================ from .module import ( AssetReporterAction ) __all__ = ( "AssetReporterAction", ) ================================================ FILE: openpype/modules/asset_reporter/module.py ================================================ import os.path from openpype import AYON_SERVER_ENABLED from openpype.modules import OpenPypeModule, ITrayAction from openpype.lib import run_detached_process, get_openpype_execute_args class AssetReporterAction(OpenPypeModule, ITrayAction): label = "Asset Usage Report" name = "asset_reporter" def tray_init(self): pass def initialize(self, modules_settings): self.enabled = not AYON_SERVER_ENABLED def on_action_trigger(self): args = get_openpype_execute_args() args += ["run", os.path.join( os.path.dirname(__file__), "window.py")] print(" ".join(args)) run_detached_process(args) ================================================ FILE: openpype/modules/asset_reporter/window.py ================================================ """Tool for generating asset usage report. This tool is used to generate asset usage report for a project. It is using links between published version to find out where the asset is used. """ import csv import time import appdirs import qtawesome from pymongo.collection import Collection from qtpy import QtCore, QtWidgets from qtpy.QtGui import QClipboard, QColor from openpype import style from openpype.client import OpenPypeMongoConnection from openpype.lib import JSONSettingRegistry from openpype.tools.utils import PlaceholderLineEdit, get_openpype_qt_app from openpype.tools.utils.constants import PROJECT_NAME_ROLE from openpype.tools.utils.models import ProjectModel, ProjectSortFilterProxy class AssetReporterRegistry(JSONSettingRegistry): """Class handling OpenPype general settings registry. This is used to store last selected project. Attributes: vendor (str): Name used for path construction. product (str): Additional name used for path construction. """ def __init__(self): self.vendor = "ynput" self.product = "openpype" name = "asset_usage_reporter" path = appdirs.user_data_dir(self.product, self.vendor) super(AssetReporterRegistry, self).__init__(name, path) class OverlayWidget(QtWidgets.QFrame): """Overlay widget for choosing project. This code is taken from the Tray Publisher tool. """ project_selected = QtCore.Signal(str) def __init__(self, publisher_window): super(OverlayWidget, self).__init__(publisher_window) self.setObjectName("OverlayFrame") middle_frame = QtWidgets.QFrame(self) middle_frame.setObjectName("ChooseProjectFrame") content_widget = QtWidgets.QWidget(middle_frame) header_label = QtWidgets.QLabel("Choose project", content_widget) header_label.setObjectName("ChooseProjectLabel") # Create project models and view projects_model = ProjectModel() projects_proxy = ProjectSortFilterProxy() projects_proxy.setSourceModel(projects_model) projects_proxy.setFilterKeyColumn(0) projects_view = QtWidgets.QListView(content_widget) projects_view.setObjectName("ChooseProjectView") projects_view.setModel(projects_proxy) projects_view.setEditTriggers( QtWidgets.QAbstractItemView.NoEditTriggers ) confirm_btn = QtWidgets.QPushButton("Confirm", content_widget) cancel_btn = QtWidgets.QPushButton("Cancel", content_widget) cancel_btn.setVisible(False) btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) btns_layout.addWidget(cancel_btn, 0) btns_layout.addWidget(confirm_btn, 0) txt_filter = PlaceholderLineEdit(content_widget) txt_filter.setPlaceholderText("Quick filter projects..") txt_filter.setClearButtonEnabled(True) txt_filter.addAction(qtawesome.icon("fa.filter", color="gray"), QtWidgets.QLineEdit.LeadingPosition) content_layout = QtWidgets.QVBoxLayout(content_widget) content_layout.setContentsMargins(0, 0, 0, 0) content_layout.setSpacing(20) content_layout.addWidget(header_label, 0) content_layout.addWidget(txt_filter, 0) content_layout.addWidget(projects_view, 1) content_layout.addLayout(btns_layout, 0) middle_layout = QtWidgets.QHBoxLayout(middle_frame) middle_layout.setContentsMargins(30, 30, 10, 10) middle_layout.addWidget(content_widget) main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(10, 10, 10, 10) main_layout.addStretch(1) main_layout.addWidget(middle_frame, 2) main_layout.addStretch(1) projects_view.doubleClicked.connect(self._on_double_click) confirm_btn.clicked.connect(self._on_confirm_click) cancel_btn.clicked.connect(self._on_cancel_click) txt_filter.textChanged.connect(self._on_text_changed) self._projects_view = projects_view self._projects_model = projects_model self._projects_proxy = projects_proxy self._cancel_btn = cancel_btn self._confirm_btn = confirm_btn self._txt_filter = txt_filter self._publisher_window = publisher_window self._project_name = None def showEvent(self, event): self._projects_model.refresh() # Sort projects after refresh self._projects_proxy.sort(0) setting_registry = AssetReporterRegistry() try: project_name = str(setting_registry.get_item("project_name")) except ValueError: project_name = None if project_name: index = None src_index = self._projects_model.find_project(project_name) if src_index is not None: index = self._projects_proxy.mapFromSource(src_index) if index is not None: selection_model = self._projects_view.selectionModel() selection_model.select( index, QtCore.QItemSelectionModel.SelectCurrent ) self._projects_view.setCurrentIndex(index) self._cancel_btn.setVisible(self._project_name is not None) super(OverlayWidget, self).showEvent(event) def _on_double_click(self): self.set_selected_project() def _on_confirm_click(self): self.set_selected_project() def _on_cancel_click(self): self._set_project(self._project_name) def _on_text_changed(self): self._projects_proxy.setFilterRegularExpression( self._txt_filter.text()) def set_selected_project(self): index = self._projects_view.currentIndex() if project_name := index.data(PROJECT_NAME_ROLE): self._set_project(project_name) def _set_project(self, project_name): self._project_name = project_name self.setVisible(False) self.project_selected.emit(project_name) setting_registry = AssetReporterRegistry() setting_registry.set_item("project_name", project_name) class AssetReporterWindow(QtWidgets.QDialog): default_width = 1000 default_height = 800 _content = None def __init__(self, parent=None, controller=None, reset_on_show=None): super(AssetReporterWindow, self).__init__(parent) self._result = {} self.setObjectName("AssetReporterWindow") self.setWindowTitle("Asset Usage Reporter") if parent is None: on_top_flag = QtCore.Qt.WindowStaysOnTopHint else: on_top_flag = QtCore.Qt.Dialog self.setWindowFlags( QtCore.Qt.WindowTitleHint | QtCore.Qt.WindowMaximizeButtonHint | QtCore.Qt.WindowMinimizeButtonHint | QtCore.Qt.WindowCloseButtonHint | on_top_flag ) self.table = QtWidgets.QTableWidget(self) self.table.setColumnCount(3) self.table.setColumnWidth(0, 400) self.table.setColumnWidth(1, 300) self.table.setHorizontalHeaderLabels(["Subset", "Used in", "Version"]) # self.text_area = QtWidgets.QTextEdit(self) self.copy_button = QtWidgets.QPushButton('Copy to Clipboard', self) self.save_button = QtWidgets.QPushButton('Save to CSV File', self) self.copy_button.clicked.connect(self.copy_to_clipboard) self.save_button.clicked.connect(self.save_to_file) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(self.table) # layout.addWidget(self.text_area) layout.addWidget(self.copy_button) layout.addWidget(self.save_button) self.resize(self.default_width, self.default_height) self.setStyleSheet(style.load_stylesheet()) overlay_widget = OverlayWidget(self) overlay_widget.project_selected.connect(self._on_project_select) self._overlay_widget = overlay_widget def _on_project_select(self, project_name: str): """Generate table when project is selected. This will generate the table and fill it with data. Source data are held in memory in `_result` attribute that is used to transform them into clipboard or csv file. """ self._project_name = project_name self.process() if not self._result: self.set_content("no result generated") return rows = sum(len(value) for key, value in self._result.items()) self.table.setRowCount(rows) row = 0 content = [] for key, value in self._result.items(): item = QtWidgets.QTableWidgetItem(key) # this doesn't work as it is probably overriden by stylesheet? # item.setBackground(QColor(32, 32, 32)) self.table.setItem(row, 0, item) for source in value: self.table.setItem( row, 1, QtWidgets.QTableWidgetItem(source["name"])) self.table.setItem( row, 2, QtWidgets.QTableWidgetItem( str(source["version"]))) row += 1 # generate clipboard content content.append(key) content.extend( f"\t{source['name']} (v{source['version']})" for source in value # noqa: E501 ) self.set_content("\n".join(content)) def copy_to_clipboard(self): clipboard = QtWidgets.QApplication.clipboard() clipboard.setText(self._content, QClipboard.Clipboard) def save_to_file(self): file_name, _ = QtWidgets.QFileDialog.getSaveFileName(self, 'Save File') if file_name: self._write_csv(file_name) def set_content(self, content): self._content = content def get_content(self): return self._content def _resize_overlay(self): self._overlay_widget.resize( self.width(), self.height() ) def resizeEvent(self, event): super(AssetReporterWindow, self).resizeEvent(event) self._resize_overlay() def _get_subset(self, version_id, project: Collection): pipeline = [ { "$match": { "_id": version_id }, }, { "$lookup": { "from": project.name, "localField": "parent", "foreignField": "_id", "as": "parents" } } ] result = project.aggregate(pipeline) doc = next(result) # print(doc) return { "name": f'{"/".join(doc["parents"][0]["data"]["parents"])}/{doc["parents"][0]["name"]}/{doc["name"]}', # noqa: E501 "family": doc["data"].get("family") or doc["data"].get("families")[0] # noqa: E501 } def process(self): """Generate asset usage report data. This is the main method of the tool. It is using MongoDB aggregation pipeline to find all published versions that are used as input for other published versions. Then it generates a map of assets and their usage. """ start = time.perf_counter() project = self._project_name # get all versions of published workfiles that has non-empty # inputLinks and connect it with their respective documents # using ID. pipeline = [ { "$match": { "data.inputLinks": { "$exists": True, "$ne": [] }, "data.families": {"$in": ["workfile"]} } }, { "$lookup": { "from": project, "localField": "data.inputLinks.id", "foreignField": "_id", "as": "linked_docs" } } ] client = OpenPypeMongoConnection.get_mongo_client() db = client["avalon"] result = db[project].aggregate(pipeline) asset_map = [] # this is creating the map - for every workfile and its linked # documents, create a dictionary with "source" and "refs" keys # and resolve the subset name and version from the document for doc in result: source = { "source": self._get_subset(doc["parent"], db[project]), } source["source"].update({"version": doc["name"]}) refs = [] version = '' for linked in doc["linked_docs"]: try: version = f'v{linked["name"]}' except KeyError: if linked["type"] == "hero_version": version = "hero" finally: refs.append({ "subset": self._get_subset( linked["parent"], db[project]), "version": version }) source["refs"] = refs asset_map.append(source) grouped = {} # this will group the assets by subset name and version for asset in asset_map: for ref in asset["refs"]: key = f'{ref["subset"]["name"]} ({ref["version"]})' if key in grouped: grouped[key].append(asset["source"]) else: grouped[key] = [asset["source"]] self._result = grouped end = time.perf_counter() print(f"Finished in {end - start:0.4f} seconds", 2) def _write_csv(self, file_name: str) -> None: """Write CSV file with results.""" with open(file_name, "w", newline="") as csvfile: writer = csv.writer(csvfile, delimiter=";") writer.writerow(["Subset", "Used in", "Version"]) for key, value in self._result.items(): writer.writerow([key, "", ""]) for source in value: writer.writerow(["", source["name"], source["version"]]) def main(): app_instance = get_openpype_qt_app() window = AssetReporterWindow() window.show() app_instance.exec_() if __name__ == "__main__": main() ================================================ FILE: openpype/modules/avalon_apps/__init__.py ================================================ from .avalon_app import AvalonModule __all__ = ( "AvalonModule", ) ================================================ FILE: openpype/modules/avalon_apps/avalon_app.py ================================================ import os from openpype import AYON_SERVER_ENABLED from openpype.modules import OpenPypeModule, ITrayModule class AvalonModule(OpenPypeModule, ITrayModule): name = "avalon" def initialize(self, modules_settings): # This module is always enabled self.enabled = True avalon_settings = modules_settings[self.name] thumbnail_root = os.environ.get("AVALON_THUMBNAIL_ROOT") if not thumbnail_root: thumbnail_root = avalon_settings["AVALON_THUMBNAIL_ROOT"] # Mongo timeout avalon_mongo_timeout = os.environ.get("AVALON_TIMEOUT") if not avalon_mongo_timeout: avalon_mongo_timeout = avalon_settings["AVALON_TIMEOUT"] self.thumbnail_root = thumbnail_root self.avalon_mongo_timeout = avalon_mongo_timeout # Tray attributes self._library_loader_imported = None self._library_loader_window = None self.rest_api_obj = None def get_global_environments(self): """Avalon global environments for pype implementation.""" return { # TODO thumbnails root should be multiplafrom # - thumbnails root "AVALON_THUMBNAIL_ROOT": self.thumbnail_root, # - mongo timeout in ms "AVALON_TIMEOUT": str(self.avalon_mongo_timeout), } def tray_init(self): # Add library tool self._library_loader_imported = False try: from openpype.tools.libraryloader import LibraryLoaderWindow self._library_loader_imported = True except Exception: self.log.warning( "Couldn't load Library loader tool for tray.", exc_info=True ) # Definition of Tray menu def tray_menu(self, tray_menu): if not self._library_loader_imported: return from qtpy import QtWidgets # Actions action_library_loader = QtWidgets.QAction( "Loader", tray_menu ) action_library_loader.triggered.connect(self.show_library_loader) tray_menu.addAction(action_library_loader) def tray_start(self, *_a, **_kw): return def tray_exit(self, *_a, **_kw): return def show_library_loader(self): if self._library_loader_window is None: from openpype.pipeline import install_openpype_plugins if AYON_SERVER_ENABLED: self._init_ayon_loader() else: self._init_library_loader() install_openpype_plugins() self._library_loader_window.show() # Raise and activate the window # for MacOS self._library_loader_window.raise_() # for Windows self._library_loader_window.activateWindow() # Webserver module implementation def webserver_initialization(self, server_manager): """Add routes for webserver.""" if self.tray_initialized: from .rest_api import AvalonRestApiResource self.rest_api_obj = AvalonRestApiResource(self, server_manager) def _init_library_loader(self): from qtpy import QtCore from openpype.tools.libraryloader import LibraryLoaderWindow libraryloader = LibraryLoaderWindow( show_projects=True, show_libraries=True ) # Remove always on top flag for tray window_flags = libraryloader.windowFlags() if window_flags | QtCore.Qt.WindowStaysOnTopHint: window_flags ^= QtCore.Qt.WindowStaysOnTopHint libraryloader.setWindowFlags(window_flags) self._library_loader_window = libraryloader def _init_ayon_loader(self): from openpype.tools.ayon_loader.ui import LoaderWindow libraryloader = LoaderWindow() self._library_loader_window = libraryloader ================================================ FILE: openpype/modules/avalon_apps/rest_api.py ================================================ import json import datetime from bson.objectid import ObjectId from aiohttp.web_response import Response from openpype.client import ( get_projects, get_project, get_assets, get_asset_by_name, ) from openpype_modules.webserver.base_routes import RestApiEndpoint class _RestApiEndpoint(RestApiEndpoint): def __init__(self, resource): self.resource = resource super(_RestApiEndpoint, self).__init__() class AvalonProjectsEndpoint(_RestApiEndpoint): async def get(self) -> Response: output = [ project_doc for project_doc in get_projects() ] return Response( status=200, body=self.resource.encode(output), content_type="application/json" ) class AvalonProjectEndpoint(_RestApiEndpoint): async def get(self, project_name) -> Response: project_doc = get_project(project_name) if project_doc: return Response( status=200, body=self.resource.encode(project_doc), content_type="application/json" ) return Response( status=404, reason="Project name {} not found".format(project_name) ) class AvalonAssetsEndpoint(_RestApiEndpoint): async def get(self, project_name) -> Response: asset_docs = list(get_assets(project_name)) return Response( status=200, body=self.resource.encode(asset_docs), content_type="application/json" ) class AvalonAssetEndpoint(_RestApiEndpoint): async def get(self, project_name, asset_name) -> Response: asset_doc = get_asset_by_name(project_name, asset_name) if asset_doc: return Response( status=200, body=self.resource.encode(asset_doc), content_type="application/json" ) return Response( status=404, reason="Asset name {} not found in project {}".format( asset_name, project_name ) ) class AvalonRestApiResource: def __init__(self, avalon_module, server_manager): self.module = avalon_module self.server_manager = server_manager self.prefix = "/avalon" self.endpoint_defs = ( ( "GET", "/projects", AvalonProjectsEndpoint(self) ), ( "GET", "/projects/{project_name}", AvalonProjectEndpoint(self) ), ( "GET", "/projects/{project_name}/assets", AvalonAssetsEndpoint(self) ), ( "GET", "/projects/{project_name}/assets/{asset_name}", AvalonAssetEndpoint(self) ) ) self.register() def register(self): for methods, url, endpoint in self.endpoint_defs: final_url = self.prefix + url self.server_manager.add_route( methods, final_url, endpoint.dispatch ) @staticmethod def json_dump_handler(value): if isinstance(value, datetime.datetime): return value.isoformat() if isinstance(value, ObjectId): return str(value) raise TypeError(value) @classmethod def encode(cls, data): return json.dumps( data, indent=4, default=cls.json_dump_handler ).encode("utf-8") ================================================ FILE: openpype/modules/base.py ================================================ # -*- coding: utf-8 -*- """Base class for AYON addons.""" import copy import os import sys import json import time import inspect import logging import platform import threading import collections import traceback from uuid import uuid4 from abc import ABCMeta, abstractmethod import six import appdirs from openpype import AYON_SERVER_ENABLED from openpype.client import get_ayon_server_api_connection from openpype.settings import ( get_system_settings, SYSTEM_SETTINGS_KEY, PROJECT_SETTINGS_KEY, SCHEMA_KEY_SYSTEM_SETTINGS, SCHEMA_KEY_PROJECT_SETTINGS ) from openpype.settings.lib import ( get_studio_system_settings_overrides, load_json_file, ) from openpype.settings.ayon_settings import ( is_dev_mode_enabled, get_ayon_settings, ) from openpype.lib import ( Logger, import_filepath, import_module_from_dirpath, ) from .interfaces import ( OpenPypeInterface, IPluginPaths, IHostAddon, ITrayModule, ITrayService ) # Files that will be always ignored on addons import IGNORED_FILENAMES = ( "__pycache__", ) # Files ignored on addons import from "./openpype/modules" IGNORED_DEFAULT_FILENAMES = ( "__init__.py", "base.py", "interfaces.py", "example_addons", "default_modules", ) # Addons that won't be loaded in AYON mode from "./openpype/modules" # - the same addons are ignored in "./server_addon/create_ayon_addons.py" IGNORED_FILENAMES_IN_AYON = { "ftrack", "shotgrid", "sync_server", "slack", "kitsu", } IGNORED_HOSTS_IN_AYON = { "flame", "harmony", } # Inherit from `object` for Python 2 hosts class _ModuleClass(object): """Fake module class for storing OpenPype modules. Object of this class can be stored to `sys.modules` and used for storing dynamically imported modules. """ def __init__(self, name): # Call setattr on super class super(_ModuleClass, self).__setattr__("name", name) super(_ModuleClass, self).__setattr__("__name__", name) # Where modules and interfaces are stored super(_ModuleClass, self).__setattr__("__attributes__", dict()) super(_ModuleClass, self).__setattr__("__defaults__", set()) super(_ModuleClass, self).__setattr__("_log", None) def __getattr__(self, attr_name): if attr_name not in self.__attributes__: if attr_name in ("__path__", "__file__"): return None raise AttributeError("'{}' has not attribute '{}'".format( self.name, attr_name )) return self.__attributes__[attr_name] def __iter__(self): for module in self.values(): yield module def __setattr__(self, attr_name, value): if attr_name in self.__attributes__: self.log.warning( "Duplicated name \"{}\" in {}. Overriding.".format( attr_name, self.name ) ) self.__attributes__[attr_name] = value def __setitem__(self, key, value): self.__setattr__(key, value) def __getitem__(self, key): return getattr(self, key) @property def log(self): if self._log is None: super(_ModuleClass, self).__setattr__( "_log", Logger.get_logger(self.name) ) return self._log def get(self, key, default=None): return self.__attributes__.get(key, default) def keys(self): return self.__attributes__.keys() def values(self): return self.__attributes__.values() def items(self): return self.__attributes__.items() class _InterfacesClass(_ModuleClass): """Fake module class for storing OpenPype interfaces. MissingInterface object is returned if interfaces does not exists. - this is because interfaces must be available even if are missing implementation """ def __getattr__(self, attr_name): if attr_name not in self.__attributes__: if attr_name in ("__path__", "__file__"): return None raise AttributeError(( "cannot import name '{}' from 'openpype_interfaces'" ).format(attr_name)) if _LoadCache.interfaces_loaded and attr_name != "log": stack = list(traceback.extract_stack()) stack.pop(-1) self.log.warning(( "Using deprecated import of \"{}\" from 'openpype_interfaces'." " Please switch to use import" " from 'openpype.modules.interfaces'" " (will be removed after 3.16.x).{}" ).format(attr_name, "".join(traceback.format_list(stack)))) return self.__attributes__[attr_name] class _LoadCache: interfaces_lock = threading.Lock() modules_lock = threading.Lock() interfaces_loaded = False modules_loaded = False def get_default_modules_dir(): """Path to default OpenPype modules.""" current_dir = os.path.dirname(os.path.abspath(__file__)) output = [] for folder_name in ("default_modules", ): path = os.path.join(current_dir, folder_name) if os.path.exists(path) and os.path.isdir(path): output.append(path) return output def get_dynamic_modules_dirs(): """Possible paths to OpenPype Addons of Modules. Paths are loaded from studio settings under: `modules -> addon_paths -> {platform name}` Path may contain environment variable as a formatting string. They are not validated or checked their existence. Returns: list: Paths loaded from studio overrides. """ output = [] if AYON_SERVER_ENABLED: return output value = get_studio_system_settings_overrides() for key in ("modules", "addon_paths", platform.system().lower()): if key not in value: return output value = value[key] for path in value: if not path: continue try: path = path.format(**os.environ) except Exception: pass output.append(path) return output def get_module_dirs(): """List of paths where OpenPype modules can be found.""" _dirpaths = [] _dirpaths.extend(get_default_modules_dir()) _dirpaths.extend(get_dynamic_modules_dirs()) dirpaths = [] for path in _dirpaths: if not path: continue normalized = os.path.normpath(path) if normalized not in dirpaths: dirpaths.append(normalized) return dirpaths def load_interfaces(force=False): """Load interfaces from modules into `openpype_interfaces`. Only classes which inherit from `OpenPypeInterface` are loaded and stored. Args: force(bool): Force to load interfaces even if are already loaded. This won't update already loaded and used (cached) interfaces. """ if _LoadCache.interfaces_loaded and not force: return if not _LoadCache.interfaces_lock.locked(): with _LoadCache.interfaces_lock: _load_interfaces() _LoadCache.interfaces_loaded = True else: # If lock is locked wait until is finished while _LoadCache.interfaces_lock.locked(): time.sleep(0.1) def _load_interfaces(): # Key under which will be modules imported in `sys.modules` modules_key = "openpype_interfaces" sys.modules[modules_key] = openpype_interfaces = ( _InterfacesClass(modules_key) ) from . import interfaces for attr_name in dir(interfaces): attr = getattr(interfaces, attr_name) if ( not inspect.isclass(attr) or attr is OpenPypeInterface or not issubclass(attr, OpenPypeInterface) ): continue setattr(openpype_interfaces, attr_name, attr) def load_modules(force=False): """Load OpenPype modules as python modules. Modules does not load only classes (like in Interfaces) because there must be ability to use inner code of module and be able to import it from one defined place. With this it is possible to import module's content from predefined module. Function makes sure that `load_interfaces` was triggered. Modules import has specific order which can't be changed. Args: force(bool): Force to load modules even if are already loaded. This won't update already loaded and used (cached) modules. """ if _LoadCache.modules_loaded and not force: return # First load interfaces # - modules must not be imported before interfaces load_interfaces(force) if not _LoadCache.modules_lock.locked(): with _LoadCache.modules_lock: _load_modules() _LoadCache.modules_loaded = True else: # If lock is locked wait until is finished while _LoadCache.modules_lock.locked(): time.sleep(0.1) def _get_ayon_bundle_data(): con = get_ayon_server_api_connection() bundles = con.get_bundles()["bundles"] bundle_name = os.getenv("AYON_BUNDLE_NAME") return next( ( bundle for bundle in bundles if bundle["name"] == bundle_name ), None ) def _get_ayon_addons_information(bundle_info): """Receive information about addons to use from server. Todos: Actually ask server for the information. Allow project name as optional argument to be able to query information about used addons for specific project. Returns: List[Dict[str, Any]]: List of addon information to use. """ output = [] bundle_addons = bundle_info["addons"] con = get_ayon_server_api_connection() addons = con.get_addons_info()["addons"] for addon in addons: name = addon["name"] versions = addon.get("versions") addon_version = bundle_addons.get(name) if addon_version is None or not versions: continue version = versions.get(addon_version) if version: version = copy.deepcopy(version) version["name"] = name version["version"] = addon_version output.append(version) return output def _load_ayon_addons(openpype_modules, modules_key, log): """Load AYON addons based on information from server. This function should not trigger downloading of any addons but only use what is already available on the machine (at least in first stages of development). Args: openpype_modules (_ModuleClass): Module object where modules are stored. log (logging.Logger): Logger object. Returns: List[str]: List of v3 addons to skip to load because v4 alternative is imported. """ v3_addons_to_skip = [] bundle_info = _get_ayon_bundle_data() addons_info = _get_ayon_addons_information(bundle_info) if not addons_info: return v3_addons_to_skip addons_dir = os.environ.get("AYON_ADDONS_DIR") if not addons_dir: addons_dir = os.path.join( appdirs.user_data_dir("AYON", "Ynput"), "addons" ) dev_mode_enabled = is_dev_mode_enabled() dev_addons_info = {} if dev_mode_enabled: # Get dev addons info only when dev mode is enabled dev_addons_info = bundle_info.get("addonDevelopment", dev_addons_info) addons_dir_exists = os.path.exists(addons_dir) if not addons_dir_exists: log.warning("Addons directory does not exists. Path \"{}\"".format( addons_dir )) for addon_info in addons_info: addon_name = addon_info["name"] addon_version = addon_info["version"] # OpenPype addon does not have any addon object if addon_name == "openpype": continue dev_addon_info = dev_addons_info.get(addon_name, {}) use_dev_path = dev_addon_info.get("enabled", False) addon_dir = None if use_dev_path: addon_dir = dev_addon_info["path"] if not addon_dir or not os.path.exists(addon_dir): log.warning(( "Dev addon {} {} path does not exists. Path \"{}\"" ).format(addon_name, addon_version, addon_dir)) continue elif addons_dir_exists: folder_name = "{}_{}".format(addon_name, addon_version) addon_dir = os.path.join(addons_dir, folder_name) if not os.path.exists(addon_dir): log.debug(( "No localized client code found for addon {} {}." ).format(addon_name, addon_version)) continue if not addon_dir: continue sys.path.insert(0, addon_dir) imported_modules = [] for name in os.listdir(addon_dir): # Ignore of files is implemented to be able to run code from code # where usually is more files than just the addon # Ignore start and setup scripts if name in ("setup.py", "start.py", "__pycache__"): continue path = os.path.join(addon_dir, name) basename, ext = os.path.splitext(name) # Ignore folders/files with dot in name # - dot names cannot be imported in Python if "." in basename: continue is_dir = os.path.isdir(path) is_py_file = ext.lower() == ".py" if not is_py_file and not is_dir: continue try: mod = __import__(basename, fromlist=("",)) for attr_name in dir(mod): attr = getattr(mod, attr_name) if ( inspect.isclass(attr) and issubclass(attr, AYONAddon) ): imported_modules.append(mod) break except BaseException: log.warning( "Failed to import \"{}\"".format(basename), exc_info=True ) if not imported_modules: log.warning("Addon {} {} has no content to import".format( addon_name, addon_version )) continue if len(imported_modules) > 1: log.warning(( "Skipping addon '{}'." " Multiple modules were found ({}) in dir {}." ).format( addon_name, ", ".join([m.__name__ for m in imported_modules]), addon_dir, )) continue mod = imported_modules[0] addon_alias = getattr(mod, "V3_ALIAS", None) if not addon_alias: addon_alias = addon_name v3_addons_to_skip.append(addon_alias) new_import_str = "{}.{}".format(modules_key, addon_alias) sys.modules[new_import_str] = mod setattr(openpype_modules, addon_alias, mod) return v3_addons_to_skip def _load_modules(): # Key under which will be modules imported in `sys.modules` modules_key = "openpype_modules" # Change `sys.modules` sys.modules[modules_key] = openpype_modules = _ModuleClass(modules_key) log = Logger.get_logger("ModulesLoader") ignore_addon_names = [] if AYON_SERVER_ENABLED: ignore_addon_names = _load_ayon_addons( openpype_modules, modules_key, log ) # Look for OpenPype modules in paths defined with `get_module_dirs` # - dynamically imported OpenPype modules and addons module_dirs = get_module_dirs() # Add current directory at first place # - has small differences in import logic current_dir = os.path.abspath(os.path.dirname(__file__)) hosts_dir = os.path.join(os.path.dirname(current_dir), "hosts") module_dirs.insert(0, hosts_dir) module_dirs.insert(0, current_dir) addons_dir = os.path.join(os.path.dirname(current_dir), "addons") if os.path.exists(addons_dir): module_dirs.append(addons_dir) ignored_host_names = set(IGNORED_HOSTS_IN_AYON) ignored_current_dir_filenames = set(IGNORED_DEFAULT_FILENAMES) if AYON_SERVER_ENABLED: ignored_current_dir_filenames |= IGNORED_FILENAMES_IN_AYON processed_paths = set() for dirpath in frozenset(module_dirs): # Skip already processed paths if dirpath in processed_paths: continue processed_paths.add(dirpath) if not os.path.exists(dirpath): log.warning(( "Could not find path when loading OpenPype modules \"{}\"" ).format(dirpath)) continue is_in_current_dir = dirpath == current_dir is_in_host_dir = dirpath == hosts_dir for filename in os.listdir(dirpath): # Ignore filenames if filename in IGNORED_FILENAMES: continue if ( is_in_current_dir and filename in ignored_current_dir_filenames ): continue if ( is_in_host_dir and filename in ignored_host_names ): continue fullpath = os.path.join(dirpath, filename) basename, ext = os.path.splitext(filename) if basename in ignore_addon_names: continue # Validations if os.path.isdir(fullpath): # Check existence of init file init_path = os.path.join(fullpath, "__init__.py") if not os.path.exists(init_path): log.debug(( "Module directory does not contain __init__.py" " file {}" ).format(fullpath)) continue elif ext not in (".py", ): continue # TODO add more logic how to define if folder is module or not # - check manifest and content of manifest try: # Don't import dynamically current directory modules if is_in_current_dir: import_str = "openpype.modules.{}".format(basename) new_import_str = "{}.{}".format(modules_key, basename) default_module = __import__(import_str, fromlist=("", )) sys.modules[new_import_str] = default_module setattr(openpype_modules, basename, default_module) elif is_in_host_dir: import_str = "openpype.hosts.{}".format(basename) new_import_str = "{}.{}".format(modules_key, basename) # Until all hosts are converted to be able use them as # modules is this error check needed try: default_module = __import__( import_str, fromlist=("", ) ) sys.modules[new_import_str] = default_module setattr(openpype_modules, basename, default_module) except Exception: log.warning( "Failed to import host folder {}".format(basename), exc_info=True ) elif os.path.isdir(fullpath): import_module_from_dirpath(dirpath, filename, modules_key) else: module = import_filepath(fullpath) setattr(openpype_modules, basename, module) except Exception: if is_in_current_dir: msg = "Failed to import default module '{}'.".format( basename ) else: msg = "Failed to import module '{}'.".format(fullpath) log.error(msg, exc_info=True) @six.add_metaclass(ABCMeta) class AYONAddon(object): """Base class of AYON addon. Attributes: id (UUID): Addon object id. enabled (bool): Is addon enabled. name (str): Addon name. Args: manager (ModulesManager): Manager object who discovered addon. settings (dict[str, Any]): AYON settings. """ enabled = True _id = None def __init__(self, manager, settings): self.manager = manager self.log = Logger.get_logger(self.name) self.initialize(settings) @property def id(self): """Random id of addon object. Returns: str: Object id. """ if self._id is None: self._id = uuid4() return self._id @property @abstractmethod def name(self): """Addon name. Returns: str: Addon name. """ pass def initialize(self, settings): """Initialization of module attributes. It is not recommended to override __init__ that's why specific method was implemented. Args: settings (dict[str, Any]): Settings. """ pass def connect_with_modules(self, enabled_addons): """Connect with other enabled addons. Args: enabled_addons (list[AYONAddon]): Addons that are enabled. """ pass def get_global_environments(self): """Get global environments values of module. Environment variables that can be get only from system settings. Returns: dict[str, str]: Environment variables. """ return {} def modify_application_launch_arguments(self, application, env): """Give option to modify launch environments before application launch. Implementation is optional. To change environments modify passed dictionary of environments. Args: application (Application): Application that is launched. env (dict[str, str]): Current environment variables. """ pass def on_host_install(self, host, host_name, project_name): """Host was installed which gives option to handle in-host logic. It is a good option to register in-host event callbacks which are specific for the module. The module is kept in memory for rest of the process. Arguments may change in future. E.g. 'host_name' should be possible to receive from 'host' object. Args: host (Union[ModuleType, HostBase]): Access to installed/registered host object. host_name (str): Name of host. project_name (str): Project name which is main part of host context. """ pass def cli(self, module_click_group): """Add commands to click group. The best practise is to create click group for whole module which is used to separate commands. Example: class MyPlugin(AYONAddon): ... def cli(self, module_click_group): module_click_group.add_command(cli_main) @click.group(, help="") def cli_main(): pass @cli_main.command() def mycommand(): print("my_command") Args: module_click_group (click.Group): Group to which can be added commands. """ pass class OpenPypeModule(AYONAddon): """Base class of OpenPype module. Instead of 'AYONAddon' are passed in module settings. Args: manager (ModulesManager): Manager object who discovered addon. settings (dict[str, Any]): OpenPype settings. """ # Disable by default enabled = False class OpenPypeAddOn(OpenPypeModule): # Enable Addon by default enabled = True class ModulesManager: """Manager of Pype modules helps to load and prepare them to work. Args: system_settings (Optional[dict[str, Any]]): OpenPype system settings. ayon_settings (Optional[dict[str, Any]]): AYON studio settings. """ # Helper attributes for report _report_total_key = "Total" _system_settings = None _ayon_settings = None def __init__(self, system_settings=None, ayon_settings=None): self.log = logging.getLogger(self.__class__.__name__) self._system_settings = system_settings self._ayon_settings = ayon_settings self.modules = [] self.modules_by_id = {} self.modules_by_name = {} # For report of time consumption self._report = {} self.initialize_modules() self.connect_modules() def __getitem__(self, module_name): return self.modules_by_name[module_name] def get(self, module_name, default=None): """Access module by name. Args: module_name (str): Name of module which should be returned. default (Any): Default output if module is not available. Returns: Union[AYONAddon, None]: Module found by name or None. """ return self.modules_by_name.get(module_name, default) def get_enabled_module(self, module_name, default=None): """Fast access to enabled module. If module is available but is not enabled default value is returned. Args: module_name (str): Name of module which should be returned. default (Any): Default output if module is not available or is not enabled. Returns: Union[AYONAddon, None]: Enabled module found by name or None. """ module = self.get(module_name) if module is not None and module.enabled: return module return default def initialize_modules(self): """Import and initialize modules.""" # Make sure modules are loaded load_modules() import openpype_modules self.log.debug("*** {} initialization.".format( "AYON addons" if AYON_SERVER_ENABLED else "OpenPype modules" )) # Prepare settings for modules system_settings = self._system_settings if system_settings is None: system_settings = get_system_settings() ayon_settings = self._ayon_settings if AYON_SERVER_ENABLED and ayon_settings is None: ayon_settings = get_ayon_settings() modules_settings = system_settings["modules"] report = {} time_start = time.time() prev_start_time = time_start module_classes = [] for module in openpype_modules: # Go through globals in `pype.modules` for name in dir(module): modules_item = getattr(module, name, None) # Filter globals that are not classes which inherit from # AYONAddon if ( not inspect.isclass(modules_item) or modules_item is AYONAddon or modules_item is OpenPypeModule or modules_item is OpenPypeAddOn or not issubclass(modules_item, AYONAddon) ): continue # Check if class is abstract (Developing purpose) if inspect.isabstract(modules_item): # Find abstract attributes by convention on `abc` module not_implemented = [] for attr_name in dir(modules_item): attr = getattr(modules_item, attr_name, None) abs_method = getattr( attr, "__isabstractmethod__", None ) if attr and abs_method: not_implemented.append(attr_name) # Log missing implementations self.log.warning(( "Skipping abstract Class: {}." " Missing implementations: {}" ).format(name, ", ".join(not_implemented))) continue module_classes.append(modules_item) for modules_item in module_classes: is_openpype_module = issubclass(modules_item, OpenPypeModule) settings = ( modules_settings if is_openpype_module else ayon_settings ) name = modules_item.__name__ try: # Try initialize module module = modules_item(self, settings) # Store initialized object self.modules.append(module) self.modules_by_id[module.id] = module self.modules_by_name[module.name] = module enabled_str = "X" if not module.enabled: enabled_str = " " self.log.debug("[{}] {}".format(enabled_str, name)) now = time.time() report[module.__class__.__name__] = now - prev_start_time prev_start_time = now except Exception: self.log.warning( "Initialization of module {} failed.".format(name), exc_info=True ) if self._report is not None: report[self._report_total_key] = time.time() - time_start self._report["Initialization"] = report def connect_modules(self): """Trigger connection with other enabled modules. Modules should handle their interfaces in `connect_with_modules`. """ report = {} time_start = time.time() prev_start_time = time_start enabled_modules = self.get_enabled_modules() self.log.debug("Has {} enabled modules.".format(len(enabled_modules))) for module in enabled_modules: try: module.connect_with_modules(enabled_modules) except Exception: self.log.error( "BUG: Module failed on connection with other modules.", exc_info=True ) now = time.time() report[module.__class__.__name__] = now - prev_start_time prev_start_time = now if self._report is not None: report[self._report_total_key] = time.time() - time_start self._report["Connect modules"] = report def get_enabled_modules(self): """Enabled modules initialized by the manager. Returns: list[AYONAddon]: Initialized and enabled modules. """ return [ module for module in self.modules if module.enabled ] def collect_global_environments(self): """Helper to collect global environment variabled from modules. Returns: dict: Global environment variables from enabled modules. Raises: AssertionError: Global environment variables must be unique for all modules. """ module_envs = {} for module in self.get_enabled_modules(): # Collect global module's global environments _envs = module.get_global_environments() for key, value in _envs.items(): if key in module_envs: # TODO better error message raise AssertionError( "Duplicated environment key {}".format(key) ) module_envs[key] = value return module_envs def collect_plugin_paths(self): """Helper to collect all plugins from modules inherited IPluginPaths. Unknown keys are logged out. Returns: dict: Output is dictionary with keys "publish", "create", "load", "actions" and "inventory" each containing list of paths. """ # Output structure output = { "publish": [], "create": [], "load": [], "actions": [], "inventory": [] } unknown_keys_by_module = {} for module in self.get_enabled_modules(): # Skip module that do not inherit from `IPluginPaths` if not isinstance(module, IPluginPaths): continue plugin_paths = module.get_plugin_paths() for key, value in plugin_paths.items(): # Filter unknown keys if key not in output: if module.name not in unknown_keys_by_module: unknown_keys_by_module[module.name] = [] unknown_keys_by_module[module.name].append(key) continue # Skip if value is empty if not value: continue # Convert to list if value is not list if not isinstance(value, (list, tuple, set)): value = [value] output[key].extend(value) # Report unknown keys (Developing purposes) if unknown_keys_by_module: expected_keys = ", ".join([ "\"{}\"".format(key) for key in output.keys() ]) msg_template = "Module: \"{}\" - got key {}" msg_items = [] for module_name, keys in unknown_keys_by_module.items(): joined_keys = ", ".join([ "\"{}\"".format(key) for key in keys ]) msg_items.append(msg_template.format(module_name, joined_keys)) self.log.warning(( "Expected keys from `get_plugin_paths` are {}. {}" ).format(expected_keys, " | ".join(msg_items))) return output def _collect_plugin_paths(self, method_name, *args, **kwargs): output = [] for module in self.get_enabled_modules(): # Skip module that do not inherit from `IPluginPaths` if not isinstance(module, IPluginPaths): continue method = getattr(module, method_name) try: paths = method(*args, **kwargs) except Exception: self.log.warning( ( "Failed to get plugin paths from module" " '{}' using '{}'." ).format(module.__class__.__name__, method_name), exc_info=True ) continue if paths: # Convert to list if value is not list if not isinstance(paths, (list, tuple, set)): paths = [paths] output.extend(paths) return output def collect_create_plugin_paths(self, host_name): """Helper to collect creator plugin paths from modules. Args: host_name (str): For which host are creators meant. Returns: list: List of creator plugin paths. """ return self._collect_plugin_paths( "get_create_plugin_paths", host_name ) collect_creator_plugin_paths = collect_create_plugin_paths def collect_load_plugin_paths(self, host_name): """Helper to collect load plugin paths from modules. Args: host_name (str): For which host are load plugins meant. Returns: list: List of load plugin paths. """ return self._collect_plugin_paths( "get_load_plugin_paths", host_name ) def collect_publish_plugin_paths(self, host_name): """Helper to collect load plugin paths from modules. Args: host_name (str): For which host are load plugins meant. Returns: list: List of pyblish plugin paths. """ return self._collect_plugin_paths( "get_publish_plugin_paths", host_name ) def collect_inventory_action_paths(self, host_name): """Helper to collect load plugin paths from modules. Args: host_name (str): For which host are load plugins meant. Returns: list: List of pyblish plugin paths. """ return self._collect_plugin_paths( "get_inventory_action_paths", host_name ) def get_host_module(self, host_name): """Find host module by host name. Args: host_name (str): Host name for which is found host module. Returns: AYONAddon: Found host module by name. None: There was not found module inheriting IHostAddon which has host name set to passed 'host_name'. """ for module in self.get_enabled_modules(): if ( isinstance(module, IHostAddon) and module.host_name == host_name ): return module return None def get_host_names(self): """List of available host names based on host modules. Returns: Iterable[str]: All available host names based on enabled modules inheriting 'IHostAddon'. """ return { module.host_name for module in self.get_enabled_modules() if isinstance(module, IHostAddon) } def print_report(self): """Print out report of time spent on modules initialization parts. Reporting is not automated must be implemented for each initialization part separatelly. Reports must be stored to `_report` attribute. Print is skipped if `_report` is empty. Attribute `_report` is dictionary where key is "label" describing the processed part and value is dictionary where key is module's class name and value is time delta of it's processing. It is good idea to add total time delta on processed part under key which is defined in attribute `_report_total_key`. By default has value `"Total"` but use the attribute please. ```javascript { "Initialization": { "FtrackModule": 0.003, ... "Total": 1.003, }, ... } ``` """ if not self._report: return available_col_names = set() for module_names in self._report.values(): available_col_names |= set(module_names.keys()) # Prepare ordered dictionary for columns cols = collections.OrderedDict() # Add module names to first columnt cols["Module name"] = list(sorted( module.__class__.__name__ for module in self.modules if module.__class__.__name__ in available_col_names )) # Add total key (as last module) cols["Module name"].append(self._report_total_key) # Add columns from report for label in self._report.keys(): cols[label] = [] total_module_times = {} for module_name in cols["Module name"]: total_module_times[module_name] = 0 for label, reported in self._report.items(): for module_name in cols["Module name"]: col_time = reported.get(module_name) if col_time is None: cols[label].append("N/A") continue cols[label].append("{:.3f}".format(col_time)) total_module_times[module_name] += col_time # Add to also total column that should sum the row cols[self._report_total_key] = [] for module_name in cols["Module name"]: cols[self._report_total_key].append( "{:.3f}".format(total_module_times[module_name]) ) # Prepare column widths and total row count # - column width is by col_widths = {} total_rows = None for key, values in cols.items(): if total_rows is None: total_rows = 1 + len(values) max_width = len(key) for value in values: value_length = len(value) if value_length > max_width: max_width = value_length col_widths[key] = max_width rows = [] for _idx in range(total_rows): rows.append([]) for key, values in cols.items(): width = col_widths[key] idx = 0 rows[idx].append(key.ljust(width)) for value in values: idx += 1 rows[idx].append(value.ljust(width)) filler_parts = [] for width in col_widths.values(): filler_parts.append(width * "-") filler = "+".join(filler_parts) formatted_rows = [filler] last_row_idx = len(rows) - 1 for idx, row in enumerate(rows): # Add filler before last row if idx == last_row_idx: formatted_rows.append(filler) formatted_rows.append("|".join(row)) # Add filler after first row if idx == 0: formatted_rows.append(filler) # Join rows with newline char and add new line at the end output = "\n".join(formatted_rows) + "\n" print(output) class TrayModulesManager(ModulesManager): # Define order of modules in menu modules_menu_order = ( "user", "ftrack", "kitsu", "launcher_tool", "avalon", "clockify", "standalonepublish_tool", "traypublish_tool", "log_viewer", "local_settings", "settings" ) def __init__(self): self.log = Logger.get_logger(self.__class__.__name__) self.modules = [] self.modules_by_id = {} self.modules_by_name = {} self._report = {} self.tray_manager = None self.doubleclick_callbacks = {} self.doubleclick_callback = None def add_doubleclick_callback(self, module, callback): """Register doubleclick callbacks on tray icon. Currently there is no way how to determine which is launched. Name of callback can be defined with `doubleclick_callback` attribute. Missing feature how to define default callback. Args: addon (AYONAddon): Addon object. callback (FunctionType): Function callback. """ callback_name = "_".join([module.name, callback.__name__]) if callback_name not in self.doubleclick_callbacks: self.doubleclick_callbacks[callback_name] = callback if self.doubleclick_callback is None: self.doubleclick_callback = callback_name return self.log.warning(( "Callback with name \"{}\" is already registered." ).format(callback_name)) def initialize(self, tray_manager, tray_menu): self.tray_manager = tray_manager self.initialize_modules() self.tray_init() self.connect_modules() self.tray_menu(tray_menu) def get_enabled_tray_modules(self): """Enabled tray modules. Returns: list[AYONAddon]: Enabled addons that inherit from tray interface. """ return [ module for module in self.modules if module.enabled and isinstance(module, ITrayModule) ] def restart_tray(self): if self.tray_manager: self.tray_manager.restart() def tray_init(self): report = {} time_start = time.time() prev_start_time = time_start for module in self.get_enabled_tray_modules(): try: module._tray_manager = self.tray_manager module.tray_init() module.tray_initialized = True except Exception: self.log.warning( "Module \"{}\" crashed on `tray_init`.".format( module.name ), exc_info=True ) now = time.time() report[module.__class__.__name__] = now - prev_start_time prev_start_time = now if self._report is not None: report[self._report_total_key] = time.time() - time_start self._report["Tray init"] = report def tray_menu(self, tray_menu): ordered_modules = [] enabled_by_name = { module.name: module for module in self.get_enabled_tray_modules() } for name in self.modules_menu_order: module_by_name = enabled_by_name.pop(name, None) if module_by_name: ordered_modules.append(module_by_name) ordered_modules.extend(enabled_by_name.values()) report = {} time_start = time.time() prev_start_time = time_start for module in ordered_modules: if not module.tray_initialized: continue try: module.tray_menu(tray_menu) except Exception: # Unset initialized mark module.tray_initialized = False self.log.warning( "Module \"{}\" crashed on `tray_menu`.".format( module.name ), exc_info=True ) now = time.time() report[module.__class__.__name__] = now - prev_start_time prev_start_time = now if self._report is not None: report[self._report_total_key] = time.time() - time_start self._report["Tray menu"] = report def start_modules(self): report = {} time_start = time.time() prev_start_time = time_start for module in self.get_enabled_tray_modules(): if not module.tray_initialized: if isinstance(module, ITrayService): module.set_service_failed_icon() continue try: module.tray_start() except Exception: self.log.warning( "Module \"{}\" crashed on `tray_start`.".format( module.name ), exc_info=True ) now = time.time() report[module.__class__.__name__] = now - prev_start_time prev_start_time = now if self._report is not None: report[self._report_total_key] = time.time() - time_start self._report["Modules start"] = report def on_exit(self): for module in self.get_enabled_tray_modules(): if module.tray_initialized: try: module.tray_exit() except Exception: self.log.warning( "Module \"{}\" crashed on `tray_exit`.".format( module.name ), exc_info=True ) def get_module_settings_defs(): """Check loaded addons/modules for existence of their settings definition. Check if OpenPype addon/module as python module has class that inherit from `ModuleSettingsDef` in python module variables (imported in `__init__py`). Returns: list: All valid and not abstract settings definitions from imported openpype addons and modules. """ # Make sure modules are loaded load_modules() import openpype_modules settings_defs = [] log = Logger.get_logger("ModuleSettingsLoad") for raw_module in openpype_modules: for attr_name in dir(raw_module): attr = getattr(raw_module, attr_name) if ( not inspect.isclass(attr) or attr is ModuleSettingsDef or not issubclass(attr, ModuleSettingsDef) ): continue if inspect.isabstract(attr): # Find missing implementations by convention on `abc` module not_implemented = [] for attr_name in dir(attr): attr = getattr(attr, attr_name, None) abs_method = getattr( attr, "__isabstractmethod__", None ) if attr and abs_method: not_implemented.append(attr_name) # Log missing implementations log.warning(( "Skipping abstract Class: {} in module {}." " Missing implementations: {}" ).format( attr_name, raw_module.__name__, ", ".join(not_implemented) )) continue settings_defs.append(attr) return settings_defs @six.add_metaclass(ABCMeta) class BaseModuleSettingsDef: """Definition of settings for OpenPype module or AddOn.""" _id = None @property def id(self): """ID created on initialization. ID should be per created object. Helps to store objects. """ if self._id is None: self._id = uuid4() return self._id @abstractmethod def get_settings_schemas(self, schema_type): """Setting schemas for passed schema type. These are main schemas by dynamic schema keys. If they're using sub schemas or templates they should be loaded with `get_dynamic_schemas`. Returns: dict: Schema by `dynamic_schema` keys. """ pass @abstractmethod def get_dynamic_schemas(self, schema_type): """Settings schemas and templates that can be used anywhere. It is recommended to add prefix specific for addon/module to keys (e.g. "my_addon/real_schema_name"). Returns: dict: Schemas and templates by their keys. """ pass @abstractmethod def get_defaults(self, top_key): """Default values for passed top key. Top keys are (currently) "system_settings" or "project_settings". Should return exactly what was passed with `save_defaults`. Returns: dict: Default values by path to first key in OpenPype defaults. """ pass @abstractmethod def save_defaults(self, top_key, data): """Save default values for passed top key. Top keys are (currently) "system_settings" or "project_settings". Passed data are by path to first key defined in main schemas. """ pass class ModuleSettingsDef(BaseModuleSettingsDef): """Settings definition with separated system and procect settings parts. Reduce conditions that must be checked and adds predefined methods for each case. """ def get_defaults(self, top_key): """Split method into 2 methods by top key.""" if top_key == SYSTEM_SETTINGS_KEY: return self.get_default_system_settings() or {} elif top_key == PROJECT_SETTINGS_KEY: return self.get_default_project_settings() or {} return {} def save_defaults(self, top_key, data): """Split method into 2 methods by top key.""" if top_key == SYSTEM_SETTINGS_KEY: self.save_system_defaults(data) elif top_key == PROJECT_SETTINGS_KEY: self.save_project_defaults(data) def get_settings_schemas(self, schema_type): """Split method into 2 methods by schema type.""" if schema_type == SCHEMA_KEY_SYSTEM_SETTINGS: return self.get_system_settings_schemas() or {} elif schema_type == SCHEMA_KEY_PROJECT_SETTINGS: return self.get_project_settings_schemas() or {} return {} def get_dynamic_schemas(self, schema_type): """Split method into 2 methods by schema type.""" if schema_type == SCHEMA_KEY_SYSTEM_SETTINGS: return self.get_system_dynamic_schemas() or {} elif schema_type == SCHEMA_KEY_PROJECT_SETTINGS: return self.get_project_dynamic_schemas() or {} return {} @abstractmethod def get_system_settings_schemas(self): """Schemas and templates usable in system settings schemas. Returns: dict: Schemas and templates by it's names. Names must be unique across whole OpenPype. """ pass @abstractmethod def get_project_settings_schemas(self): """Schemas and templates usable in project settings schemas. Returns: dict: Schemas and templates by it's names. Names must be unique across whole OpenPype. """ pass @abstractmethod def get_system_dynamic_schemas(self): """System schemas by dynamic schema name. If dynamic schema name is not available in then schema will not used. Returns: dict: Schemas or list of schemas by dynamic schema name. """ pass @abstractmethod def get_project_dynamic_schemas(self): """Project schemas by dynamic schema name. If dynamic schema name is not available in then schema will not used. Returns: dict: Schemas or list of schemas by dynamic schema name. """ pass @abstractmethod def get_default_system_settings(self): """Default system settings values. Returns: dict: Default values by path to first key. """ pass @abstractmethod def get_default_project_settings(self): """Default project settings values. Returns: dict: Default values by path to first key. """ pass @abstractmethod def save_system_defaults(self, data): """Save default system settings values. Passed data are by path to first key defined in main schemas. """ pass @abstractmethod def save_project_defaults(self, data): """Save default project settings values. Passed data are by path to first key defined in main schemas. """ pass class JsonFilesSettingsDef(ModuleSettingsDef): """Preimplemented settings definition using json files and file structure. Expected file structure: ┕ root │ │ # Default values ┝ defaults │ ┝ system_settings.json │ ┕ project_settings.json │ │ # Schemas for `dynamic_template` type ┝ dynamic_schemas │ ┝ system_dynamic_schemas.json │ ┕ project_dynamic_schemas.json │ │ # Schemas that can be used anywhere (enhancement for `dynamic_schemas`) ┕ schemas ┝ system_schemas │ ┝ # Any schema or template files │ ┕ ... ┕ project_schemas ┝ # Any schema or template files ┕ ... Schemas can be loaded with prefix to avoid duplicated schema/template names across all OpenPype addons/modules. Prefix can be defined with class attribute `schema_prefix`. Only think which must be implemented in `get_settings_root_path` which should return directory path to `root` (in structure graph above). """ # Possible way how to define `schemas` prefix schema_prefix = "" @abstractmethod def get_settings_root_path(self): """Directory path where settings and it's schemas are located.""" pass def __init__(self): settings_root_dir = self.get_settings_root_path() defaults_dir = os.path.join( settings_root_dir, "defaults" ) dynamic_schemas_dir = os.path.join( settings_root_dir, "dynamic_schemas" ) schemas_dir = os.path.join( settings_root_dir, "schemas" ) self.system_defaults_filepath = os.path.join( defaults_dir, "system_settings.json" ) self.project_defaults_filepath = os.path.join( defaults_dir, "project_settings.json" ) self.system_dynamic_schemas_filepath = os.path.join( dynamic_schemas_dir, "system_dynamic_schemas.json" ) self.project_dynamic_schemas_filepath = os.path.join( dynamic_schemas_dir, "project_dynamic_schemas.json" ) self.system_schemas_dir = os.path.join( schemas_dir, "system_schemas" ) self.project_schemas_dir = os.path.join( schemas_dir, "project_schemas" ) def _load_json_file_data(self, path): if os.path.exists(path): return load_json_file(path) return {} def get_default_system_settings(self): """Default system settings values. Returns: dict: Default values by path to first key. """ return self._load_json_file_data(self.system_defaults_filepath) def get_default_project_settings(self): """Default project settings values. Returns: dict: Default values by path to first key. """ return self._load_json_file_data(self.project_defaults_filepath) def _save_data_to_filepath(self, path, data): dirpath = os.path.dirname(path) if not os.path.exists(dirpath): os.makedirs(dirpath) with open(path, "w") as file_stream: json.dump(data, file_stream, indent=4) def save_system_defaults(self, data): """Save default system settings values. Passed data are by path to first key defined in main schemas. """ self._save_data_to_filepath(self.system_defaults_filepath, data) def save_project_defaults(self, data): """Save default project settings values. Passed data are by path to first key defined in main schemas. """ self._save_data_to_filepath(self.project_defaults_filepath, data) def get_system_dynamic_schemas(self): """System schemas by dynamic schema name. If dynamic schema name is not available in then schema will not used. Returns: dict: Schemas or list of schemas by dynamic schema name. """ return self._load_json_file_data(self.system_dynamic_schemas_filepath) def get_project_dynamic_schemas(self): """Project schemas by dynamic schema name. If dynamic schema name is not available in then schema will not used. Returns: dict: Schemas or list of schemas by dynamic schema name. """ return self._load_json_file_data(self.project_dynamic_schemas_filepath) def _load_files_from_path(self, path): output = {} if not path or not os.path.exists(path): return output if os.path.isfile(path): filename = os.path.basename(path) basename, ext = os.path.splitext(filename) if ext == ".json": if self.schema_prefix: key = "{}/{}".format(self.schema_prefix, basename) else: key = basename output[key] = self._load_json_file_data(path) return output path = os.path.normpath(path) for root, _, files in os.walk(path, topdown=False): for filename in files: basename, ext = os.path.splitext(filename) if ext != ".json": continue json_path = os.path.join(root, filename) store_key = os.path.join( root.replace(path, ""), basename ).replace("\\", "/") if self.schema_prefix: store_key = "{}/{}".format(self.schema_prefix, store_key) output[store_key] = self._load_json_file_data(json_path) return output def get_system_settings_schemas(self): """Schemas and templates usable in system settings schemas. Returns: dict: Schemas and templates by it's names. Names must be unique across whole OpenPype. """ return self._load_files_from_path(self.system_schemas_dir) def get_project_settings_schemas(self): """Schemas and templates usable in project settings schemas. Returns: dict: Schemas and templates by it's names. Names must be unique across whole OpenPype. """ return self._load_files_from_path(self.project_schemas_dir) ================================================ FILE: openpype/modules/click_wrap.py ================================================ """Simplified wrapper for 'click' python module. Module 'click' is used as main cli handler in AYON/OpenPype. Addons can register their own subcommands with options. This wrapper allows to define commands and options as with 'click', but without any dependency. Why not to use 'click' directly? Version of 'click' used in AYON/OpenPype is not compatible with 'click' version used in some DCCs (e.g. Houdini 20+). And updating 'click' would break other DCCs. How to use it? If you already have cli commands defined in addon, just replace 'click' with 'click_wrap' and it should work and modify your addon's cli method to convert 'click_wrap' object to 'click' object. Before ```python import click from openpype.modules import OpenPypeModule class ExampleAddon(OpenPypeModule): name = "example" def cli(self, click_group): click_group.add_command(cli_main) @click.group(ExampleAddon.name, help="Example addon") def cli_main(): pass @cli_main.command(help="Example command") @click.option("--arg1", help="Example argument 1", default="default1") @click.option("--arg2", help="Example argument 2", is_flag=True) def mycommand(arg1, arg2): print(arg1, arg2) ``` Now ``` from openpype import click_wrap from openpype.modules import OpenPypeModule class ExampleAddon(OpenPypeModule): name = "example" def cli(self, click_group): click_group.add_command(cli_main.to_click_obj()) @click_wrap.group(ExampleAddon.name, help="Example addon") def cli_main(): pass @cli_main.command(help="Example command") @click_wrap.option("--arg1", help="Example argument 1", default="default1") @click_wrap.option("--arg2", help="Example argument 2", is_flag=True) def mycommand(arg1, arg2): print(arg1, arg2) ``` Added small enhancements: - most of the methods can be used as chained calls - functions/methods 'command' and 'group' can be used in a way that first argument is callback function and the rest are arguments for click Example: ```python from openpype import click_wrap from openpype.modules import OpenPypeModule class ExampleAddon(OpenPypeModule): name = "example" def cli(self, click_group): # Define main command (name 'example') main = click_wrap.group( self._cli_main, name=self.name, help="Example addon" ) # Add subcommand (name 'mycommand') ( main.command( self._cli_command, name="mycommand", help="Example command" ) .option( "--arg1", help="Example argument 1", default="default1" ) .option( "--arg2", help="Example argument 2", is_flag=True, ) ) # Convert main command to click object and add it to parent group click_group.add_command(main.to_click_obj()) def _cli_main(self): pass def _cli_command(self, arg1, arg2): print(arg1, arg2) ``` ```shell openpype_console addon example mycommand --arg1 value1 --arg2 ``` """ import collections FUNC_ATTR_NAME = "__ayon_cli_options__" class Command(object): def __init__(self, func, *args, **kwargs): # Command function self._func = func # Command definition arguments self._args = args # Command definition kwargs self._kwargs = kwargs # Both 'options' and 'arguments' are stored to the same variable # - keep order of options and arguments self._options = getattr(func, FUNC_ATTR_NAME, []) def to_click_obj(self): """Converts this object to click object. Returns: click.Command: Click command object. """ return convert_to_click(self) # --- Methods for 'convert_to_click' function --- def get_args(self): """ Returns: tuple: Command definition arguments. """ return self._args def get_kwargs(self): """ Returns: dict[str, Any]: Command definition kwargs. """ return self._kwargs def get_func(self): """ Returns: Function: Function to invoke on command trigger. """ return self._func def iter_options(self): """ Yields: tuple[str, tuple, dict]: Option type name with args and kwargs. """ for item in self._options: yield item # ----------------------------------------------- def add_option(self, *args, **kwargs): return self.add_option_by_type("option", *args, **kwargs) def add_argument(self, *args, **kwargs): return self.add_option_by_type("argument", *args, **kwargs) option = add_option argument = add_argument def add_option_by_type(self, option_name, *args, **kwargs): self._options.append((option_name, args, kwargs)) return self class Group(Command): def __init__(self, func, *args, **kwargs): super(Group, self).__init__(func, *args, **kwargs) # Store sub-groupd and sub-commands to the same variable self._commands = [] # --- Methods for 'convert_to_click' function --- def iter_commands(self): for command in self._commands: yield command # ----------------------------------------------- def add_command(self, command): """Add prepared command object as child. Args: command (Command): Prepared command object. """ if command not in self._commands: self._commands.append(command) def add_group(self, group): """Add prepared group object as child. Args: group (Group): Prepared group object. """ if group not in self._commands: self._commands.append(group) def command(self, *args, **kwargs): """Add child command. Returns: Union[Command, Function]: New command object, or wrapper function. """ return self._add_new(Command, *args, **kwargs) def group(self, *args, **kwargs): """Add child group. Returns: Union[Group, Function]: New group object, or wrapper function. """ return self._add_new(Group, *args, **kwargs) def _add_new(self, target_cls, *args, **kwargs): func = None if args and callable(args[0]): args = list(args) func = args.pop(0) args = tuple(args) def decorator(_func): out = target_cls(_func, *args, **kwargs) self._commands.append(out) return out if func is not None: return decorator(func) return decorator def convert_to_click(obj_to_convert): """Convert wrapped object to click object. Args: obj_to_convert (Command): Object to convert to click object. Returns: click.Command: Click command object. """ import click commands_queue = collections.deque() commands_queue.append((obj_to_convert, None)) top_obj = None while commands_queue: item = commands_queue.popleft() command_obj, parent_obj = item if not isinstance(command_obj, Command): raise TypeError( "Invalid type '{}' expected 'Command'".format( type(command_obj) ) ) if isinstance(command_obj, Group): click_obj = ( click.group( *command_obj.get_args(), **command_obj.get_kwargs() )(command_obj.get_func()) ) else: click_obj = ( click.command( *command_obj.get_args(), **command_obj.get_kwargs() )(command_obj.get_func()) ) for item in command_obj.iter_options(): option_name, args, kwargs = item if option_name == "option": click.option(*args, **kwargs)(click_obj) elif option_name == "argument": click.argument(*args, **kwargs)(click_obj) else: raise ValueError( "Invalid option name '{}'".format(option_name) ) if top_obj is None: top_obj = click_obj if parent_obj is not None: parent_obj.add_command(click_obj) if isinstance(command_obj, Group): for command in command_obj.iter_commands(): commands_queue.append((command, click_obj)) return top_obj def group(*args, **kwargs): func = None if args and callable(args[0]): args = list(args) func = args.pop(0) args = tuple(args) def decorator(_func): return Group(_func, *args, **kwargs) if func is not None: return decorator(func) return decorator def command(*args, **kwargs): func = None if args and callable(args[0]): args = list(args) func = args.pop(0) args = tuple(args) def decorator(_func): return Command(_func, *args, **kwargs) if func is not None: return decorator(func) return decorator def argument(*args, **kwargs): def decorator(func): return _add_option_to_func( func, "argument", *args, **kwargs ) return decorator def option(*args, **kwargs): def decorator(func): return _add_option_to_func( func, "option", *args, **kwargs ) return decorator def _add_option_to_func(func, option_name, *args, **kwargs): if isinstance(func, Command): func.add_option_by_type(option_name, *args, **kwargs) return func if not hasattr(func, FUNC_ATTR_NAME): setattr(func, FUNC_ATTR_NAME, []) cli_options = getattr(func, FUNC_ATTR_NAME) cli_options.append((option_name, args, kwargs)) return func ================================================ FILE: openpype/modules/clockify/__init__.py ================================================ from .clockify_module import ClockifyModule __all__ = ( "ClockifyModule", ) ================================================ FILE: openpype/modules/clockify/clockify_api.py ================================================ import os import re import time import json import datetime import requests from .constants import ( CLOCKIFY_ENDPOINT, ADMIN_PERMISSION_NAMES, ) from openpype.lib.local_settings import OpenPypeSecureRegistry from openpype.lib import Logger class ClockifyAPI: log = Logger.get_logger(__name__) def __init__(self, api_key=None, master_parent=None): self.workspace_name = None self.master_parent = master_parent self.api_key = api_key self._workspace_id = None self._user_id = None self._secure_registry = None @property def secure_registry(self): if self._secure_registry is None: self._secure_registry = OpenPypeSecureRegistry("clockify") return self._secure_registry @property def headers(self): return {"x-api-key": self.api_key} @property def workspace_id(self): return self._workspace_id @property def user_id(self): return self._user_id def verify_api(self): for key, value in self.headers.items(): if value is None or value.strip() == "": return False return True def set_api(self, api_key=None): if api_key is None: api_key = self.get_api_key() if api_key is not None and self.validate_api_key(api_key) is True: self.api_key = api_key self.set_workspace() self.set_user_id() if self.master_parent: self.master_parent.signed_in() return True return False def validate_api_key(self, api_key): test_headers = {"x-api-key": api_key} action_url = "user" response = requests.get( CLOCKIFY_ENDPOINT + action_url, headers=test_headers ) if response.status_code != 200: return False return True def validate_workspace_permissions(self, workspace_id=None, user_id=None): if user_id is None: self.log.info("No user_id found during validation") return False if workspace_id is None: workspace_id = self.workspace_id action_url = f"workspaces/{workspace_id}/users?includeRoles=1" response = requests.get( CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) data = response.json() for user in data: if user.get("id") == user_id: roles_data = user.get("roles") for entities in roles_data: if entities.get("role") in ADMIN_PERMISSION_NAMES: return True return False def get_user_id(self): action_url = "user" response = requests.get( CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) result = response.json() user_id = result.get("id", None) return user_id def set_workspace(self, name=None): if name is None: name = os.environ.get("CLOCKIFY_WORKSPACE", None) self.workspace_name = name if self.workspace_name is None: return try: result = self.validate_workspace() except Exception: result = False if result is not False: self._workspace_id = result if self.master_parent is not None: self.master_parent.start_timer_check() return True return False def validate_workspace(self, name=None): if name is None: name = self.workspace_name all_workspaces = self.get_workspaces() if name in all_workspaces: return all_workspaces[name] return False def set_user_id(self): try: user_id = self.get_user_id() except Exception: user_id = None if user_id is not None: self._user_id = user_id def get_api_key(self): return self.secure_registry.get_item("api_key", None) def save_api_key(self, api_key): self.secure_registry.set_item("api_key", api_key) def get_workspaces(self): action_url = "workspaces/" response = requests.get( CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return { workspace["name"]: workspace["id"] for workspace in response.json() } def get_projects(self, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id action_url = f"workspaces/{workspace_id}/projects" response = requests.get( CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) if response.status_code != 403: result = response.json() return {project["name"]: project["id"] for project in result} def get_project_by_id(self, project_id, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id action_url = "workspaces/{}/projects/{}".format( workspace_id, project_id ) response = requests.get( CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return response.json() def get_tags(self, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id action_url = "workspaces/{}/tags".format(workspace_id) response = requests.get( CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return {tag["name"]: tag["id"] for tag in response.json()} def get_tasks(self, project_id, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id action_url = "workspaces/{}/projects/{}/tasks".format( workspace_id, project_id ) response = requests.get( CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return {task["name"]: task["id"] for task in response.json()} def get_workspace_id(self, workspace_name): all_workspaces = self.get_workspaces() if workspace_name not in all_workspaces: return None return all_workspaces[workspace_name] def get_project_id(self, project_name, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id all_projects = self.get_projects(workspace_id) if project_name not in all_projects: return None return all_projects[project_name] def get_tag_id(self, tag_name, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id all_tasks = self.get_tags(workspace_id) if tag_name not in all_tasks: return None return all_tasks[tag_name] def get_task_id(self, task_name, project_id, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id all_tasks = self.get_tasks(project_id, workspace_id) if task_name not in all_tasks: return None return all_tasks[task_name] def get_current_time(self): return str(datetime.datetime.utcnow().isoformat()) + "Z" def start_time_entry( self, description, project_id, task_id=None, tag_ids=None, workspace_id=None, user_id=None, billable=True, ): # Workspace if workspace_id is None: workspace_id = self.workspace_id # User ID if user_id is None: user_id = self._user_id # get running timer to check if we need to start it current_timer = self.get_in_progress() # Check if is currently run another times and has same values # DO not restart the timer, if it is already running for current task if current_timer: current_timer_hierarchy = current_timer.get("description") current_project_id = current_timer.get("projectId") current_task_id = current_timer.get("taskId") if ( description == current_timer_hierarchy and project_id == current_project_id and task_id == current_task_id ): self.log.info( "Timer for the current project is already running" ) self.bool_timer_run = True return self.bool_timer_run self.finish_time_entry() # Convert billable to strings if billable: billable = "true" else: billable = "false" # Rest API Action action_url = "workspaces/{}/user/{}/time-entries".format( workspace_id, user_id ) start = self.get_current_time() body = { "start": start, "billable": billable, "description": description, "projectId": project_id, "taskId": task_id, "tagIds": tag_ids, } response = requests.post( CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) if response.status_code < 300: return True return False def _get_current_timer_values(self, response): if response is None: return try: output = response.json() except json.decoder.JSONDecodeError: return None if output and isinstance(output, list): return output[0] return None def get_in_progress(self, user_id=None, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id if user_id is None: user_id = self.user_id action_url = ( f"workspaces/{workspace_id}/user/" f"{user_id}/time-entries?in-progress=1" ) response = requests.get( CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return self._get_current_timer_values(response) def finish_time_entry(self, workspace_id=None, user_id=None): if workspace_id is None: workspace_id = self.workspace_id if user_id is None: user_id = self.user_id current_timer = self.get_in_progress() if not current_timer: return action_url = "workspaces/{}/user/{}/time-entries".format( workspace_id, user_id ) body = {"end": self.get_current_time()} response = requests.patch( CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) return response.json() def get_time_entries(self, workspace_id=None, user_id=None, quantity=10): if workspace_id is None: workspace_id = self.workspace_id if user_id is None: user_id = self.user_id action_url = "workspaces/{}/user/{}/time-entries".format( workspace_id, user_id ) response = requests.get( CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return response.json()[:quantity] def remove_time_entry(self, tid, workspace_id=None, user_id=None): if workspace_id is None: workspace_id = self.workspace_id action_url = "workspaces/{}/user/{}/time-entries/{}".format( workspace_id, user_id, tid ) response = requests.delete( CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return response.json() def add_project(self, name, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id action_url = "workspaces/{}/projects".format(workspace_id) body = { "name": name, "clientId": "", "isPublic": "false", "estimate": {"estimate": 0, "type": "AUTO"}, "color": "#f44336", "billable": "true", } response = requests.post( CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) return response.json() def add_workspace(self, name): action_url = "workspaces/" body = {"name": name} response = requests.post( CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) return response.json() def add_task(self, name, project_id, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id action_url = "workspaces/{}/projects/{}/tasks".format( workspace_id, project_id ) body = {"name": name, "projectId": project_id} response = requests.post( CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) return response.json() def add_tag(self, name, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id action_url = "workspaces/{}/tags".format(workspace_id) body = {"name": name} response = requests.post( CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) return response.json() def delete_project(self, project_id, workspace_id=None): if workspace_id is None: workspace_id = self.workspace_id action_url = "/workspaces/{}/projects/{}".format( workspace_id, project_id ) response = requests.delete( CLOCKIFY_ENDPOINT + action_url, headers=self.headers, ) return response.json() def convert_input( self, entity_id, entity_name, mode="Workspace", project_id=None ): if entity_id is None: error = False error_msg = 'Missing information "{}"' if mode.lower() == "workspace": if entity_id is None and entity_name is None: if self.workspace_id is not None: entity_id = self.workspace_id else: error = True else: entity_id = self.get_workspace_id(entity_name) else: if entity_id is None and entity_name is None: error = True elif mode.lower() == "project": entity_id = self.get_project_id(entity_name) elif mode.lower() == "task": entity_id = self.get_task_id( task_name=entity_name, project_id=project_id ) else: raise TypeError("Unknown type") # Raise error if error: raise ValueError(error_msg.format(mode)) return entity_id ================================================ FILE: openpype/modules/clockify/clockify_module.py ================================================ import os import threading import time from openpype.modules import OpenPypeModule, ITrayModule, IPluginPaths from openpype.client import get_asset_by_name from .constants import CLOCKIFY_FTRACK_USER_PATH, CLOCKIFY_FTRACK_SERVER_PATH class ClockifyModule(OpenPypeModule, ITrayModule, IPluginPaths): name = "clockify" def initialize(self, modules_settings): clockify_settings = modules_settings[self.name] self.enabled = clockify_settings["enabled"] self.workspace_name = clockify_settings["workspace_name"] if self.enabled and not self.workspace_name: raise Exception("Clockify Workspace is not set in settings.") self.timer_manager = None self.MessageWidgetClass = None self.message_widget = None self._clockify_api = None # TimersManager attributes # - set `timers_manager_connector` only in `tray_init` self.timers_manager_connector = None self._timers_manager_module = None @property def clockify_api(self): if self._clockify_api is None: from .clockify_api import ClockifyAPI self._clockify_api = ClockifyAPI(master_parent=self) return self._clockify_api def get_global_environments(self): return {"CLOCKIFY_WORKSPACE": self.workspace_name} def tray_init(self): from .widgets import ClockifySettings, MessageWidget self.MessageWidgetClass = MessageWidget self.message_widget = None self.widget_settings = ClockifySettings(self.clockify_api) self.widget_settings_required = None self.thread_timer_check = None # Bools self.bool_thread_check_running = False self.bool_api_key_set = False self.bool_workspace_set = False self.bool_timer_run = False self.bool_api_key_set = self.clockify_api.set_api() # Define itself as TimersManager connector self.timers_manager_connector = self def tray_start(self): if self.bool_api_key_set is False: self.show_settings() return self.bool_workspace_set = self.clockify_api.workspace_id is not None if self.bool_workspace_set is False: return self.start_timer_check() self.set_menu_visibility() def tray_exit(self, *_a, **_kw): return def get_plugin_paths(self): """Implementation of IPluginPaths to get plugin paths.""" actions_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), "launcher_actions" ) return {"actions": [actions_path]} def get_ftrack_event_handler_paths(self): """Function for Ftrack module to add ftrack event handler paths.""" return { "user": [CLOCKIFY_FTRACK_USER_PATH], "server": [CLOCKIFY_FTRACK_SERVER_PATH], } def clockify_timer_stopped(self): self.bool_timer_run = False self.timer_stopped() def start_timer_check(self): self.bool_thread_check_running = True if self.thread_timer_check is None: self.thread_timer_check = threading.Thread( target=self.check_running ) self.thread_timer_check.daemon = True self.thread_timer_check.start() def stop_timer_check(self): self.bool_thread_check_running = True if self.thread_timer_check is not None: self.thread_timer_check.join() self.thread_timer_check = None def check_running(self): while self.bool_thread_check_running is True: bool_timer_run = False if self.clockify_api.get_in_progress() is not None: bool_timer_run = True if self.bool_timer_run != bool_timer_run: if self.bool_timer_run is True: self.clockify_timer_stopped() elif self.bool_timer_run is False: current_timer = self.clockify_api.get_in_progress() if current_timer is None: continue current_proj_id = current_timer.get("projectId") if not current_proj_id: continue project = self.clockify_api.get_project_by_id( current_proj_id ) if project and project.get("code") == 501: continue project_name = project.get("name") current_timer_hierarchy = current_timer.get("description") if not current_timer_hierarchy: continue hierarchy_items = current_timer_hierarchy.split("/") # Each pype timer must have at least 2 items! if len(hierarchy_items) < 2: continue task_name = hierarchy_items[-1] hierarchy = hierarchy_items[:-1] data = { "task_name": task_name, "hierarchy": hierarchy, "project_name": project_name, } self.timer_started(data) self.bool_timer_run = bool_timer_run self.set_menu_visibility() time.sleep(5) def signed_in(self): if not self.timer_manager: return if not self.timer_manager.last_task: return if self.timer_manager.is_running: self.start_timer_manager(self.timer_manager.last_task) def on_message_widget_close(self): self.message_widget = None # Definition of Tray menu def tray_menu(self, parent_menu): # Menu for Tray App from qtpy import QtWidgets menu = QtWidgets.QMenu("Clockify", parent_menu) menu.setProperty("submenu", "on") # Actions action_show_settings = QtWidgets.QAction("Settings", menu) action_stop_timer = QtWidgets.QAction("Stop timer", menu) menu.addAction(action_show_settings) menu.addAction(action_stop_timer) action_show_settings.triggered.connect(self.show_settings) action_stop_timer.triggered.connect(self.stop_timer) self.action_stop_timer = action_stop_timer self.set_menu_visibility() parent_menu.addMenu(menu) def show_settings(self): self.widget_settings.input_api_key.setText( self.clockify_api.get_api_key() ) self.widget_settings.show() def set_menu_visibility(self): self.action_stop_timer.setVisible(self.bool_timer_run) # --- TimersManager connection methods --- def register_timers_manager(self, timer_manager_module): """Store TimersManager for future use.""" self._timers_manager_module = timer_manager_module def timer_started(self, data): """Tell TimersManager that timer started.""" if self._timers_manager_module is not None: self._timers_manager_module.timer_started(self.id, data) def timer_stopped(self): """Tell TimersManager that timer stopped.""" if self._timers_manager_module is not None: self._timers_manager_module.timer_stopped(self.id) def stop_timer(self): """Called from TimersManager to stop timer.""" self.clockify_api.finish_time_entry() def _verify_project_exists(self, project_name): project_id = self.clockify_api.get_project_id(project_name) if not project_id: self.log.warning( 'Project "{}" was not found in Clockify. Timer won\'t start.' ).format(project_name) if not self.MessageWidgetClass: return msg = ( 'Project "{}" is not' ' in Clockify Workspace "{}".' "

Please inform your Project Manager." ).format(project_name, str(self.clockify_api.workspace_name)) self.message_widget = self.MessageWidgetClass( msg, "Clockify - Info Message" ) self.message_widget.closed.connect(self.on_message_widget_close) self.message_widget.show() return False return project_id def start_timer(self, input_data): """Called from TimersManager to start timer.""" # If not api key is not entered then skip if not self.clockify_api.get_api_key(): return task_name = input_data.get("task_name") # Concatenate hierarchy and task to get description description_items = list(input_data.get("hierarchy", [])) description_items.append(task_name) description = "/".join(description_items) # Check project existence project_name = input_data.get("project_name") project_id = self._verify_project_exists(project_name) if not project_id: return # Setup timer tags tag_ids = [] tag_name = input_data.get("task_type") if not tag_name: # no task_type found in the input data # if the timer is restarted by idle time (bug?) asset_name = input_data["hierarchy"][-1] asset_doc = get_asset_by_name(project_name, asset_name) task_info = asset_doc["data"]["tasks"][task_name] tag_name = task_info.get("type", "") if not tag_name: self.log.info("No tag information found for the timer") task_tag_id = self.clockify_api.get_tag_id(tag_name) if task_tag_id is not None: tag_ids.append(task_tag_id) # Start timer self.clockify_api.start_time_entry( description, project_id, tag_ids=tag_ids, workspace_id=self.clockify_api.workspace_id, user_id=self.clockify_api.user_id, ) ================================================ FILE: openpype/modules/clockify/constants.py ================================================ import os CLOCKIFY_FTRACK_SERVER_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), "ftrack", "server" ) CLOCKIFY_FTRACK_USER_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), "ftrack", "user" ) ADMIN_PERMISSION_NAMES = ["WORKSPACE_OWN", "WORKSPACE_ADMIN"] CLOCKIFY_ENDPOINT = "https://api.clockify.me/api/v1/" ================================================ FILE: openpype/modules/clockify/ftrack/server/action_clockify_sync_server.py ================================================ import os import json from openpype_modules.ftrack.lib import ServerAction from openpype_modules.clockify.clockify_api import ClockifyAPI class SyncClockifyServer(ServerAction): '''Synchronise project names and task types.''' identifier = "clockify.sync.server" label = "Sync To Clockify (server)" description = "Synchronise data to Clockify workspace" role_list = ["Pypeclub", "Administrator", "project Manager"] def __init__(self, *args, **kwargs): super(SyncClockifyServer, self).__init__(*args, **kwargs) workspace_name = os.environ.get("CLOCKIFY_WORKSPACE") api_key = os.environ.get("CLOCKIFY_API_KEY") self.clockify_api = ClockifyAPI(api_key) self.clockify_api.set_workspace(workspace_name) if api_key is None: modified_key = "None" else: str_len = int(len(api_key) / 2) start_replace = int(len(api_key) / 4) modified_key = "" for idx in range(len(api_key)): if idx >= start_replace and idx < start_replace + str_len: replacement = "X" else: replacement = api_key[idx] modified_key += replacement self.log.info( "Clockify info. Workspace: \"{}\" API key: \"{}\"".format( str(workspace_name), str(modified_key) ) ) def discover(self, session, entities, event): if ( len(entities) != 1 or entities[0].entity_type.lower() != "project" ): return False return True def launch(self, session, entities, event): self.clockify_api.set_api() if self.clockify_api.workspace_id is None: return { "success": False, "message": "Clockify Workspace or API key are not set!" } if not self.clockify_api.validate_workspace_permissions( self.clockify_api.workspace_id, self.clockify_api.user_id ): return { "success": False, "message": "Missing permissions for this action!" } # JOB SETTINGS user_id = event["source"]["user"]["id"] user = session.query("User where id is " + user_id).one() job = session.create("Job", { "user": user, "status": "running", "data": json.dumps({"description": "Sync Ftrack to Clockify"}) }) session.commit() project_entity = entities[0] if project_entity.entity_type.lower() != "project": project_entity = self.get_project_from_entity(project_entity) project_name = project_entity["full_name"] self.log.info( "Synchronization of project \"{}\" to clockify begins.".format( project_name ) ) task_types = ( project_entity["project_schema"]["_task_type_schema"]["types"] ) task_type_names = [ task_type["name"] for task_type in task_types ] try: clockify_projects = self.clockify_api.get_projects() if project_name not in clockify_projects: response = self.clockify_api.add_project(project_name) if "id" not in response: self.log.warning( "Project \"{}\" can't be created. Response: {}".format( project_name, response ) ) return { "success": False, "message": ( "Can't create clockify project \"{}\"." " Unexpected error." ).format(project_name) } clockify_workspace_tags = self.clockify_api.get_tags() for task_type_name in task_type_names: if task_type_name in clockify_workspace_tags: self.log.debug( "Task \"{}\" already exist".format(task_type_name) ) continue response = self.clockify_api.add_tag(task_type_name) if "id" not in response: self.log.warning( "Task \"{}\" can't be created. Response: {}".format( task_type_name, response ) ) job["status"] = "done" except Exception: self.log.warning( "Synchronization to clockify failed.", exc_info=True ) finally: if job["status"] != "done": job["status"] = "failed" session.commit() return True def register(session, **kw): SyncClockifyServer(session).register() ================================================ FILE: openpype/modules/clockify/ftrack/user/action_clockify_sync_local.py ================================================ import json from openpype_modules.ftrack.lib import BaseAction, statics_icon from openpype_modules.clockify.clockify_api import ClockifyAPI class SyncClockifyLocal(BaseAction): '''Synchronise project names and task types.''' #: Action identifier. identifier = 'clockify.sync.local' #: Action label. label = 'Sync To Clockify (local)' #: Action description. description = 'Synchronise data to Clockify workspace' #: roles that are allowed to register this action role_list = ["Pypeclub", "Administrator", "project Manager"] #: icon icon = statics_icon("app_icons", "clockify-white.png") def __init__(self, *args, **kwargs): super(SyncClockifyLocal, self).__init__(*args, **kwargs) #: CLockifyApi self.clockify_api = ClockifyAPI() def discover(self, session, entities, event): if ( len(entities) == 1 and entities[0].entity_type.lower() == "project" ): return True return False def launch(self, session, entities, event): self.clockify_api.set_api() if self.clockify_api.workspace_id is None: return { "success": False, "message": "Clockify Workspace or API key are not set!" } if ( self.clockify_api.validate_workspace_permissions( self.clockify_api.workspace_id, self.clockify_api.user_id) is False ): return { "success": False, "message": "Missing permissions for this action!" } # JOB SETTINGS userId = event['source']['user']['id'] user = session.query('User where id is ' + userId).one() job = session.create('Job', { 'user': user, 'status': 'running', 'data': json.dumps({ 'description': 'Sync Ftrack to Clockify' }) }) session.commit() project_entity = entities[0] if project_entity.entity_type.lower() != "project": project_entity = self.get_project_from_entity(project_entity) project_name = project_entity["full_name"] self.log.info( "Synchronization of project \"{}\" to clockify begins.".format( project_name ) ) task_types = ( project_entity["project_schema"]["_task_type_schema"]["types"] ) task_type_names = [ task_type["name"] for task_type in task_types ] try: clockify_projects = self.clockify_api.get_projects() if project_name not in clockify_projects: response = self.clockify_api.add_project(project_name) if "id" not in response: self.log.warning( "Project \"{}\" can't be created. Response: {}".format( project_name, response ) ) return { "success": False, "message": ( "Can't create clockify project \"{}\"." " Unexpected error." ).format(project_name) } clockify_workspace_tags = self.clockify_api.get_tags() for task_type_name in task_type_names: if task_type_name in clockify_workspace_tags: self.log.debug( "Task \"{}\" already exist".format(task_type_name) ) continue response = self.clockify_api.add_tag(task_type_name) if "id" not in response: self.log.warning( "Task \"{}\" can't be created. Response: {}".format( task_type_name, response ) ) job["status"] = "done" except Exception: pass finally: if job["status"] != "done": job["status"] = "failed" session.commit() return True def register(session, **kw): SyncClockifyLocal(session).register() ================================================ FILE: openpype/modules/clockify/launcher_actions/ClockifyStart.py ================================================ from openpype.client import get_asset_by_name from openpype.pipeline import LauncherAction from openpype_modules.clockify.clockify_api import ClockifyAPI class ClockifyStart(LauncherAction): name = "clockify_start_timer" label = "Clockify - Start Timer" icon = "app_icons/clockify.png" order = 500 clockify_api = ClockifyAPI() def is_compatible(self, session): """Return whether the action is compatible with the session""" if "AVALON_TASK" in session: return True return False def process(self, session, **kwargs): self.clockify_api.set_api() user_id = self.clockify_api.user_id workspace_id = self.clockify_api.workspace_id project_name = session["AVALON_PROJECT"] asset_name = session["AVALON_ASSET"] task_name = session["AVALON_TASK"] description = asset_name # fetch asset docs asset_doc = get_asset_by_name(project_name, asset_name) # get task type to fill the timer tag task_info = asset_doc["data"]["tasks"][task_name] task_type = task_info["type"] # check if the task has hierarchy and fill the parents_data = asset_doc["data"] if parents_data is not None: description_items = parents_data.get("parents", []) description_items.append(asset_name) description_items.append(task_name) description = "/".join(description_items) project_id = self.clockify_api.get_project_id( project_name, workspace_id ) tag_ids = [] tag_name = task_type tag_ids.append(self.clockify_api.get_tag_id(tag_name, workspace_id)) self.clockify_api.start_time_entry( description, project_id, tag_ids=tag_ids, workspace_id=workspace_id, user_id=user_id, ) ================================================ FILE: openpype/modules/clockify/launcher_actions/ClockifySync.py ================================================ from openpype.client import get_projects, get_project from openpype_modules.clockify.clockify_api import ClockifyAPI from openpype.pipeline import LauncherAction class ClockifyPermissionsCheckFailed(Exception): """Timer start failed due to user permissions check. Message should be self explanatory as traceback won't be shown. """ pass class ClockifySync(LauncherAction): name = "sync_to_clockify" label = "Sync to Clockify" icon = "app_icons/clockify-white.png" order = 500 clockify_api = ClockifyAPI() def is_compatible(self, session): """Check if there's some projects to sync""" try: next(get_projects()) return True except StopIteration: return False def process(self, session, **kwargs): self.clockify_api.set_api() workspace_id = self.clockify_api.workspace_id user_id = self.clockify_api.user_id if not self.clockify_api.validate_workspace_permissions( workspace_id, user_id ): raise ClockifyPermissionsCheckFailed( "Current CLockify user is missing permissions for this action!" ) project_name = session.get("AVALON_PROJECT") or "" projects_to_sync = [] if project_name.strip(): projects_to_sync = [get_project(project_name)] else: projects_to_sync = get_projects() projects_info = {} for project in projects_to_sync: task_types = project["config"]["tasks"].keys() projects_info[project["name"]] = task_types clockify_projects = self.clockify_api.get_projects(workspace_id) for project_name, task_types in projects_info.items(): if project_name in clockify_projects: continue response = self.clockify_api.add_project( project_name, workspace_id ) if "id" not in response: self.log.error( "Project {} can't be created".format(project_name) ) continue clockify_workspace_tags = self.clockify_api.get_tags(workspace_id) for task_type in task_types: if task_type not in clockify_workspace_tags: response = self.clockify_api.add_tag( task_type, workspace_id ) if "id" not in response: self.log.error( "Task {} can't be created".format(task_type) ) continue ================================================ FILE: openpype/modules/clockify/widgets.py ================================================ from qtpy import QtCore, QtGui, QtWidgets from openpype import resources, style class MessageWidget(QtWidgets.QWidget): SIZE_W = 300 SIZE_H = 130 closed = QtCore.Signal() def __init__(self, messages, title): super(MessageWidget, self).__init__() # Icon icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( QtCore.Qt.WindowCloseButtonHint | QtCore.Qt.WindowMinimizeButtonHint ) # Size setting self.resize(self.SIZE_W, self.SIZE_H) self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H)) self.setMaximumSize(QtCore.QSize(self.SIZE_W+100, self.SIZE_H+100)) # Style self.setStyleSheet(style.load_stylesheet()) self.setLayout(self._ui_layout(messages)) self.setWindowTitle(title) def _ui_layout(self, messages): if not messages: messages = ["*Missing messages (This is a bug)*", ] elif not isinstance(messages, (tuple, list)): messages = [messages, ] main_layout = QtWidgets.QVBoxLayout(self) labels = [] for message in messages: label = QtWidgets.QLabel(message) label.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor)) label.setTextFormat(QtCore.Qt.RichText) label.setWordWrap(True) labels.append(label) main_layout.addWidget(label) btn_close = QtWidgets.QPushButton("Close") btn_close.setToolTip('Close this window') btn_close.clicked.connect(self.on_close_clicked) btn_group = QtWidgets.QHBoxLayout() btn_group.addStretch(1) btn_group.addWidget(btn_close) main_layout.addLayout(btn_group) self.labels = labels self.btn_group = btn_group self.btn_close = btn_close self.main_layout = main_layout return main_layout def on_close_clicked(self): self.close() def close(self, *args, **kwargs): self.closed.emit() super(MessageWidget, self).close(*args, **kwargs) class ClockifySettings(QtWidgets.QWidget): SIZE_W = 500 SIZE_H = 130 loginSignal = QtCore.Signal(object, object, object) def __init__(self, clockify_api, optional=True): super(ClockifySettings, self).__init__() self.clockify_api = clockify_api self.optional = optional self.validated = False # Icon icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowTitle("Clockify settings") self.setWindowFlags( QtCore.Qt.WindowCloseButtonHint | QtCore.Qt.WindowMinimizeButtonHint ) # Size setting self.resize(self.SIZE_W, self.SIZE_H) self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H)) self.setMaximumSize(QtCore.QSize(self.SIZE_W+100, self.SIZE_H+100)) self.setStyleSheet(style.load_stylesheet()) self._ui_init() def _ui_init(self): label_api_key = QtWidgets.QLabel("Clockify API key:") input_api_key = QtWidgets.QLineEdit() input_api_key.setFrame(True) input_api_key.setPlaceholderText("e.g. XX1XxXX2x3x4xXxx") error_label = QtWidgets.QLabel("") error_label.setTextFormat(QtCore.Qt.RichText) error_label.setWordWrap(True) error_label.hide() form_layout = QtWidgets.QFormLayout() form_layout.setContentsMargins(10, 15, 10, 5) form_layout.addRow(label_api_key, input_api_key) form_layout.addRow(error_label) btn_ok = QtWidgets.QPushButton("Ok") btn_ok.setToolTip('Sets Clockify API Key so can Start/Stop timer') btn_cancel = QtWidgets.QPushButton("Cancel") cancel_tooltip = 'Application won\'t start' if self.optional: cancel_tooltip = 'Close this window' btn_cancel.setToolTip(cancel_tooltip) btn_group = QtWidgets.QHBoxLayout() btn_group.addStretch(1) btn_group.addWidget(btn_ok) btn_group.addWidget(btn_cancel) main_layout = QtWidgets.QVBoxLayout(self) main_layout.addLayout(form_layout) main_layout.addLayout(btn_group) btn_ok.clicked.connect(self.click_ok) btn_cancel.clicked.connect(self._close_widget) self.label_api_key = label_api_key self.input_api_key = input_api_key self.error_label = error_label self.btn_ok = btn_ok self.btn_cancel = btn_cancel def setError(self, msg): self.error_label.setText(msg) self.error_label.show() def invalid_input(self, entity): entity.setStyleSheet("border: 1px solid red;") def click_ok(self): api_key = self.input_api_key.text().strip() if self.optional is True and api_key == '': self.clockify_api.save_api_key(None) self.clockify_api.set_api(api_key) self.validated = False self._close_widget() return validation = self.clockify_api.validate_api_key(api_key) if validation: self.clockify_api.save_api_key(api_key) self.clockify_api.set_api(api_key) self.validated = True self._close_widget() else: self.invalid_input(self.input_api_key) self.validated = False self.setError( "Entered invalid API key" ) def showEvent(self, event): super(ClockifySettings, self).showEvent(event) # Make btns same width max_width = max( self.btn_ok.sizeHint().width(), self.btn_cancel.sizeHint().width() ) self.btn_ok.setMinimumWidth(max_width) self.btn_cancel.setMinimumWidth(max_width) def closeEvent(self, event): if self.optional is True: event.ignore() self._close_widget() else: self.validated = False def _close_widget(self): if self.optional is True: self.hide() else: self.close() ================================================ FILE: openpype/modules/deadline/__init__.py ================================================ from .deadline_module import DeadlineModule __all__ = ( "DeadlineModule", ) ================================================ FILE: openpype/modules/deadline/abstract_submit_deadline.py ================================================ # -*- coding: utf-8 -*- """Abstract package for submitting jobs to Deadline. It provides Deadline JobInfo data class. """ import json.decoder import os from abc import abstractmethod import platform import getpass from functools import partial from collections import OrderedDict import six import attr import requests import pyblish.api from openpype.pipeline.publish import ( AbstractMetaInstancePlugin, KnownPublishError, OpenPypePyblishPluginMixin ) from openpype.pipeline.publish.lib import ( replace_with_published_scene_path ) from openpype import AYON_SERVER_ENABLED JSONDecodeError = getattr(json.decoder, "JSONDecodeError", ValueError) def requests_post(*args, **kwargs): """Wrap request post method. Disabling SSL certificate validation if ``DONT_VERIFY_SSL`` environment variable is found. This is useful when Deadline server is running with self-signed certificates and its certificate is not added to trusted certificates on client machines. Warning: Disabling SSL certificate validation is defeating one line of defense SSL is providing, and it is not recommended. """ if 'verify' not in kwargs: kwargs['verify'] = False if os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) else True # noqa # add 10sec timeout before bailing out kwargs['timeout'] = 10 return requests.post(*args, **kwargs) def requests_get(*args, **kwargs): """Wrap request get method. Disabling SSL certificate validation if ``DONT_VERIFY_SSL`` environment variable is found. This is useful when Deadline server is running with self-signed certificates and its certificate is not added to trusted certificates on client machines. Warning: Disabling SSL certificate validation is defeating one line of defense SSL is providing, and it is not recommended. """ if 'verify' not in kwargs: kwargs['verify'] = False if os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) else True # noqa # add 10sec timeout before bailing out kwargs['timeout'] = 10 return requests.get(*args, **kwargs) class DeadlineKeyValueVar(dict): """ Serializes dictionary key values as "{key}={value}" like Deadline uses for EnvironmentKeyValue. As an example: EnvironmentKeyValue0="A_KEY=VALUE_A" EnvironmentKeyValue1="OTHER_KEY=VALUE_B" The keys are serialized in alphabetical order (sorted). Example: >>> var = DeadlineKeyValueVar("EnvironmentKeyValue") >>> var["my_var"] = "hello" >>> var["my_other_var"] = "hello2" >>> var.serialize() """ def __init__(self, key): super(DeadlineKeyValueVar, self).__init__() self.__key = key def serialize(self): key = self.__key # Allow custom location for index in serialized string if "{}" not in key: key = key + "{}" return { key.format(index): "{}={}".format(var_key, var_value) for index, (var_key, var_value) in enumerate(sorted(self.items())) } class DeadlineIndexedVar(dict): """ Allows to set and query values by integer indices: Query: var[1] or var.get(1) Set: var[1] = "my_value" Append: var += "value" Note: Iterating the instance is not guarantueed to be the order of the indices. To do so iterate with `sorted()` """ def __init__(self, key): super(DeadlineIndexedVar, self).__init__() self.__key = key def serialize(self): key = self.__key # Allow custom location for index in serialized string if "{}" not in key: key = key + "{}" return { key.format(index): value for index, value in sorted(self.items()) } def next_available_index(self): # Add as first unused entry i = 0 while i in self.keys(): i += 1 return i def update(self, data): # Force the integer key check for key, value in data.items(): self.__setitem__(key, value) def __iadd__(self, other): index = self.next_available_index() self[index] = other return self def __setitem__(self, key, value): if not isinstance(key, int): raise TypeError("Key must be an integer: {}".format(key)) if key < 0: raise ValueError("Negative index can't be set: {}".format(key)) dict.__setitem__(self, key, value) @attr.s class DeadlineJobInfo(object): """Mapping of all Deadline *JobInfo* attributes. This contains all JobInfo attributes plus their default values. Those attributes set to `None` shouldn't be posted to Deadline as the only required one is `Plugin`. Their default values used by Deadline are stated in comments. ..seealso: https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/manual-submission.html """ # Required # ---------------------------------------------- Plugin = attr.ib() # General Frames = attr.ib(default=None) # default: 0 Name = attr.ib(default="Untitled") Comment = attr.ib(default=None) # default: empty Department = attr.ib(default=None) # default: empty BatchName = attr.ib(default=None) # default: empty UserName = attr.ib(default=getpass.getuser()) MachineName = attr.ib(default=platform.node()) Pool = attr.ib(default=None) # default: "none" SecondaryPool = attr.ib(default=None) Group = attr.ib(default=None) # default: "none" Priority = attr.ib(default=50) ChunkSize = attr.ib(default=1) ConcurrentTasks = attr.ib(default=1) LimitConcurrentTasksToNumberOfCpus = attr.ib( default=None) # default: "true" OnJobComplete = attr.ib(default="Nothing") SynchronizeAllAuxiliaryFiles = attr.ib(default=None) # default: false ForceReloadPlugin = attr.ib(default=None) # default: false Sequential = attr.ib(default=None) # default: false SuppressEvents = attr.ib(default=None) # default: false Protected = attr.ib(default=None) # default: false InitialStatus = attr.ib(default="Active") NetworkRoot = attr.ib(default=None) # Timeouts # ---------------------------------------------- MinRenderTimeSeconds = attr.ib(default=None) # Default: 0 MinRenderTimeMinutes = attr.ib(default=None) # Default: 0 TaskTimeoutSeconds = attr.ib(default=None) # Default: 0 TaskTimeoutMinutes = attr.ib(default=None) # Default: 0 StartJobTimeoutSeconds = attr.ib(default=None) # Default: 0 StartJobTimeoutMinutes = attr.ib(default=None) # Default: 0 InitializePluginTimeoutSeconds = attr.ib(default=None) # Default: 0 # can be one of OnTaskTimeout = attr.ib(default=None) # Default: Error EnableTimeoutsForScriptTasks = attr.ib(default=None) # Default: false EnableFrameTimeouts = attr.ib(default=None) # Default: false EnableAutoTimeout = attr.ib(default=None) # Default: false # Interruptible # ---------------------------------------------- Interruptible = attr.ib(default=None) # Default: false InterruptiblePercentage = attr.ib(default=None) RemTimeThreshold = attr.ib(default=None) # Notifications # ---------------------------------------------- # can be comma separated list of users NotificationTargets = attr.ib(default=None) # Default: blank ClearNotificationTargets = attr.ib(default=None) # Default: false # A comma separated list of additional email addresses NotificationEmails = attr.ib(default=None) # Default: blank OverrideNotificationMethod = attr.ib(default=None) # Default: false EmailNotification = attr.ib(default=None) # Default: false PopupNotification = attr.ib(default=None) # Default: false # String with `[EOL]` used for end of line NotificationNote = attr.ib(default=None) # Default: blank # Machine Limit # ---------------------------------------------- MachineLimit = attr.ib(default=None) # Default: 0 MachineLimitProgress = attr.ib(default=None) # Default: -1.0 Whitelist = attr.ib(default=None) # Default: blank Blacklist = attr.ib(default=None) # Default: blank # Limits # ---------------------------------------------- # comma separated list of limit groups LimitGroups = attr.ib(default=None) # Default: blank # Dependencies # ---------------------------------------------- # comma separated list of job IDs JobDependencies = attr.ib(default=None) # Default: blank JobDependencyPercentage = attr.ib(default=None) # Default: -1 IsFrameDependent = attr.ib(default=None) # Default: false FrameDependencyOffsetStart = attr.ib(default=None) # Default: 0 FrameDependencyOffsetEnd = attr.ib(default=None) # Default: 0 ResumeOnCompleteDependencies = attr.ib(default=None) # Default: true ResumeOnDeletedDependencies = attr.ib(default=None) # Default: false ResumeOnFailedDependencies = attr.ib(default=None) # Default: false # comma separated list of asset paths RequiredAssets = attr.ib(default=None) # Default: blank # comma separated list of script paths ScriptDependencies = attr.ib(default=None) # Default: blank # Failure Detection # ---------------------------------------------- OverrideJobFailureDetection = attr.ib(default=None) # Default: false FailureDetectionJobErrors = attr.ib(default=None) # 0..x OverrideTaskFailureDetection = attr.ib(default=None) # Default: false FailureDetectionTaskErrors = attr.ib(default=None) # 0..x IgnoreBadJobDetection = attr.ib(default=None) # Default: false SendJobErrorWarning = attr.ib(default=None) # Default: false # Cleanup # ---------------------------------------------- DeleteOnComplete = attr.ib(default=None) # Default: false ArchiveOnComplete = attr.ib(default=None) # Default: false OverrideAutoJobCleanup = attr.ib(default=None) # Default: false OverrideJobCleanup = attr.ib(default=None) JobCleanupDays = attr.ib(default=None) # Default: false # OverrideJobCleanupType = attr.ib(default=None) # Scheduling # ---------------------------------------------- # ScheduledType = attr.ib(default=None) # Default: None #
ScheduledStartDateTime = attr.ib(default=None) ScheduledDays = attr.ib(default=None) # Default: 1 # JobDelay = attr.ib(default=None) # Time= Scheduled = attr.ib(default=None) # Scripts # ---------------------------------------------- # all accept path to script PreJobScript = attr.ib(default=None) # Default: blank PostJobScript = attr.ib(default=None) # Default: blank PreTaskScript = attr.ib(default=None) # Default: blank PostTaskScript = attr.ib(default=None) # Default: blank # Event Opt-Ins # ---------------------------------------------- # comma separated list of plugins EventOptIns = attr.ib(default=None) # Default: blank # Environment # ---------------------------------------------- EnvironmentKeyValue = attr.ib(factory=partial(DeadlineKeyValueVar, "EnvironmentKeyValue")) IncludeEnvironment = attr.ib(default=None) # Default: false UseJobEnvironmentOnly = attr.ib(default=None) # Default: false CustomPluginDirectory = attr.ib(default=None) # Default: blank # Job Extra Info # ---------------------------------------------- ExtraInfo = attr.ib(factory=partial(DeadlineIndexedVar, "ExtraInfo")) ExtraInfoKeyValue = attr.ib(factory=partial(DeadlineKeyValueVar, "ExtraInfoKeyValue")) # Task Extra Info Names # ---------------------------------------------- OverrideTaskExtraInfoNames = attr.ib(default=None) # Default: false TaskExtraInfoName = attr.ib(factory=partial(DeadlineIndexedVar, "TaskExtraInfoName")) # Output # ---------------------------------------------- OutputFilename = attr.ib(factory=partial(DeadlineIndexedVar, "OutputFilename")) OutputFilenameTile = attr.ib(factory=partial(DeadlineIndexedVar, "OutputFilename{}Tile")) OutputDirectory = attr.ib(factory=partial(DeadlineIndexedVar, "OutputDirectory")) # Asset Dependency # ---------------------------------------------- AssetDependency = attr.ib(factory=partial(DeadlineIndexedVar, "AssetDependency")) # Tile Job # ---------------------------------------------- TileJob = attr.ib(default=None) # Default: false TileJobFrame = attr.ib(default=None) # Default: 0 TileJobTilesInX = attr.ib(default=None) # Default: 0 TileJobTilesInY = attr.ib(default=None) # Default: 0 TileJobTileCount = attr.ib(default=None) # Default: 0 # Maintenance Job # ---------------------------------------------- MaintenanceJob = attr.ib(default=None) # Default: false MaintenanceJobStartFrame = attr.ib(default=None) # Default: 0 MaintenanceJobEndFrame = attr.ib(default=None) # Default: 0 def serialize(self): """Return all data serialized as dictionary. Returns: OrderedDict: all serialized data. """ def filter_data(a, v): if isinstance(v, (DeadlineIndexedVar, DeadlineKeyValueVar)): return False if v is None: return False return True serialized = attr.asdict( self, dict_factory=OrderedDict, filter=filter_data) # Custom serialize these attributes for attribute in [ self.EnvironmentKeyValue, self.ExtraInfo, self.ExtraInfoKeyValue, self.TaskExtraInfoName, self.OutputFilename, self.OutputFilenameTile, self.OutputDirectory, self.AssetDependency ]: serialized.update(attribute.serialize()) return serialized def update(self, data): """Update instance with data dict""" for key, value in data.items(): setattr(self, key, value) def add_render_job_env_var(self): """Check if in OP or AYON mode and use appropriate env var.""" if AYON_SERVER_ENABLED: self.EnvironmentKeyValue["AYON_RENDER_JOB"] = "1" self.EnvironmentKeyValue["AYON_BUNDLE_NAME"] = ( os.environ["AYON_BUNDLE_NAME"]) else: self.EnvironmentKeyValue["OPENPYPE_RENDER_JOB"] = "1" @six.add_metaclass(AbstractMetaInstancePlugin) class AbstractSubmitDeadline(pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin): """Class abstracting access to Deadline.""" label = "Submit to Deadline" order = pyblish.api.IntegratorOrder + 0.1 import_reference = False use_published = True asset_dependencies = False default_priority = 50 def __init__(self, *args, **kwargs): super(AbstractSubmitDeadline, self).__init__(*args, **kwargs) self._instance = None self._deadline_url = None self.scene_path = None self.job_info = None self.plugin_info = None self.aux_files = None def process(self, instance): """Plugin entry point.""" self._instance = instance context = instance.context self._deadline_url = context.data.get("defaultDeadline") self._deadline_url = instance.data.get( "deadlineUrl", self._deadline_url) assert self._deadline_url, "Requires Deadline Webservice URL" file_path = None if self.use_published: if not self.import_reference: file_path = self.from_published_scene() else: self.log.info("use the scene with imported reference for rendering") # noqa file_path = context.data["currentFile"] # fallback if nothing was set if not file_path: self.log.warning("Falling back to workfile") file_path = context.data["currentFile"] self.scene_path = file_path self.log.info("Using {} for render/export.".format(file_path)) self.job_info = self.get_job_info() self.plugin_info = self.get_plugin_info() self.aux_files = self.get_aux_files() job_id = self.process_submission() self.log.info("Submitted job to Deadline: {}.".format(job_id)) # TODO: Find a way that's more generic and not render type specific if instance.data.get("splitRender"): self.log.info("Splitting export and render in two jobs") self.log.info("Export job id: %s", job_id) render_job_info = self.get_job_info(dependency_job_ids=[job_id]) render_plugin_info = self.get_plugin_info(job_type="render") payload = self.assemble_payload( job_info=render_job_info, plugin_info=render_plugin_info ) render_job_id = self.submit(payload) self.log.info("Render job id: %s", render_job_id) def process_submission(self): """Process data for submission. This takes Deadline JobInfo, PluginInfo, AuxFile, creates payload from them and submit it do Deadline. Returns: str: Deadline job ID """ payload = self.assemble_payload() return self.submit(payload) @abstractmethod def get_job_info(self): """Return filled Deadline JobInfo. This is host/plugin specific implementation of how to fill data in. See: :class:`DeadlineJobInfo` Returns: :class:`DeadlineJobInfo`: Filled Deadline JobInfo. """ pass @abstractmethod def get_plugin_info(self): """Return filled Deadline PluginInfo. This is host/plugin specific implementation of how to fill data in. See: :class:`DeadlineJobInfo` Returns: dict: Filled Deadline JobInfo. """ pass def get_aux_files(self): """Return list of auxiliary files for Deadline job. If needed this should be overridden, otherwise return empty list as that field even empty must be present on Deadline submission. Returns: list: List of files. """ return [] def from_published_scene(self, replace_in_path=True): """Switch work scene for published scene. If rendering/exporting from published scenes is enabled, this will replace paths from working scene to published scene. Args: replace_in_path (bool): if True, it will try to find old scene name in path of expected files and replace it with name of published scene. Returns: str: Published scene path. None: if no published scene is found. Note: Published scene path is actually determined from project Anatomy as at the time this plugin is running scene can still no be published. """ return replace_with_published_scene_path( self._instance, replace_in_path=replace_in_path) def assemble_payload( self, job_info=None, plugin_info=None, aux_files=None): """Assemble payload data from its various parts. Args: job_info (DeadlineJobInfo): Deadline JobInfo. You can use :class:`DeadlineJobInfo` for it. plugin_info (dict): Deadline PluginInfo. Plugin specific options. aux_files (list, optional): List of auxiliary file to submit with the job. Returns: dict: Deadline Payload. """ job = job_info or self.job_info return { "JobInfo": job.serialize(), "PluginInfo": plugin_info or self.plugin_info, "AuxFiles": aux_files or self.aux_files } def submit(self, payload): """Submit payload to Deadline API end-point. This takes payload in the form of JSON file and POST it to Deadline jobs end-point. Args: payload (dict): dict to become json in deadline submission. Returns: str: resulting Deadline job id. Throws: KnownPublishError: if submission fails. """ url = "{}/api/jobs".format(self._deadline_url) response = requests_post(url, json=payload) if not response.ok: self.log.error("Submission failed!") self.log.error(response.status_code) self.log.error(response.content) self.log.debug(payload) raise KnownPublishError(response.text) try: result = response.json() except JSONDecodeError: msg = "Broken response {}. ".format(response) msg += "Try restarting the Deadline Webservice." self.log.warning(msg, exc_info=True) raise KnownPublishError("Broken response from DL") # for submit publish job self._instance.data["deadlineSubmissionJob"] = result return result["_id"] ================================================ FILE: openpype/modules/deadline/deadline_module.py ================================================ import os import requests import six import sys from openpype.lib import requests_get, Logger from openpype.modules import OpenPypeModule, IPluginPaths class DeadlineWebserviceError(Exception): """ Exception to throw when connection to Deadline server fails. """ class DeadlineModule(OpenPypeModule, IPluginPaths): name = "deadline" def __init__(self, manager, settings): self.deadline_urls = {} super(DeadlineModule, self).__init__(manager, settings) def initialize(self, modules_settings): # This module is always enabled deadline_settings = modules_settings[self.name] self.enabled = deadline_settings["enabled"] deadline_url = deadline_settings.get("DEADLINE_REST_URL") if deadline_url: self.deadline_urls = {"default": deadline_url} else: self.deadline_urls = deadline_settings.get("deadline_urls") # noqa: E501 if not self.deadline_urls: self.enabled = False self.log.warning(("default Deadline Webservice URL " "not specified. Disabling module.")) return def get_plugin_paths(self): """Deadline plugin paths.""" current_dir = os.path.dirname(os.path.abspath(__file__)) return { "publish": [os.path.join(current_dir, "plugins", "publish")] } @staticmethod def get_deadline_pools(webservice, log=None): # type: (str) -> list """Get pools from Deadline. Args: webservice (str): Server url. log (Logger) Returns: list: Pools. Throws: RuntimeError: If deadline webservice is unreachable. """ if not log: log = Logger.get_logger(__name__) argument = "{}/api/pools?NamesOnly=true".format(webservice) try: response = requests_get(argument) except requests.exceptions.ConnectionError as exc: msg = 'Cannot connect to DL web service {}'.format(webservice) log.error(msg) six.reraise( DeadlineWebserviceError, DeadlineWebserviceError('{} - {}'.format(msg, exc)), sys.exc_info()[2]) if not response.ok: log.warning("No pools retrieved") return [] return response.json() ================================================ FILE: openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py ================================================ # -*- coding: utf-8 -*- """Collect Deadline servers from instance. This is resolving index of server lists stored in `deadlineServers` instance attribute or using default server if that attribute doesn't exists. """ import pyblish.api from openpype.pipeline.publish import KnownPublishError class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): """Collect Deadline Webservice URL from instance.""" # Run before collect_render. order = pyblish.api.CollectorOrder + 0.005 label = "Deadline Webservice from the Instance" families = ["rendering", "renderlayer"] hosts = ["maya"] def process(self, instance): instance.data["deadlineUrl"] = self._collect_deadline_url(instance) instance.data["deadlineUrl"] = \ instance.data["deadlineUrl"].strip().rstrip("/") self.log.debug( "Using {} for submission.".format(instance.data["deadlineUrl"])) def _collect_deadline_url(self, render_instance): # type: (pyblish.api.Instance) -> str """Get Deadline Webservice URL from render instance. This will get all configured Deadline Webservice URLs and create subset of them based upon project configuration. It will then take `deadlineServers` from render instance that is now basically `int` index of that list. Args: render_instance (pyblish.api.Instance): Render instance created by Creator in Maya. Returns: str: Selected Deadline Webservice URL. """ # Not all hosts can import this module. from maya import cmds deadline_settings = ( render_instance.context.data ["system_settings"] ["modules"] ["deadline"] ) default_server = render_instance.context.data["defaultDeadline"] instance_server = render_instance.data.get("deadlineServers") if not instance_server: self.log.debug("Using default server.") return default_server # Get instance server as sting. if isinstance(instance_server, int): instance_server = cmds.getAttr( "{}.deadlineServers".format(render_instance.data["objset"]), asString=True ) default_servers = deadline_settings["deadline_urls"] project_servers = ( render_instance.context.data ["project_settings"] ["deadline"] ["deadline_servers"] ) if not project_servers: self.log.debug("Not project servers found. Using default servers.") return default_servers[instance_server] project_enabled_servers = { k: default_servers[k] for k in project_servers if k in default_servers } if instance_server not in project_enabled_servers: msg = ( "\"{}\" server on instance is not enabled in project settings." " Enabled project servers:\n{}".format( instance_server, project_enabled_servers ) ) raise KnownPublishError(msg) self.log.debug("Using project approved server.") return project_enabled_servers[instance_server] ================================================ FILE: openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py ================================================ # -*- coding: utf-8 -*- """Collect default Deadline server.""" import pyblish.api from openpype import AYON_SERVER_ENABLED class CollectDefaultDeadlineServer(pyblish.api.ContextPlugin): """Collect default Deadline Webservice URL. DL webservice addresses must be configured first in System Settings for project settings enum to work. Default webservice could be overriden by `project_settings/deadline/deadline_servers`. Currently only single url is expected. This url could be overriden by some hosts directly on instances with `CollectDeadlineServerFromInstance`. """ # Run before collect_deadline_server_instance. order = pyblish.api.CollectorOrder + 0.0025 label = "Default Deadline Webservice" pass_mongo_url = False def process(self, context): try: deadline_module = context.data.get("openPypeModules")["deadline"] except AttributeError: self.log.error("Cannot get OpenPype Deadline module.") raise AssertionError("OpenPype Deadline module not found.") deadline_settings = context.data["project_settings"]["deadline"] deadline_server_name = None if AYON_SERVER_ENABLED: deadline_server_name = deadline_settings["deadline_server"] else: deadline_servers = deadline_settings["deadline_servers"] if deadline_servers: deadline_server_name = deadline_servers[0] context.data["deadlinePassMongoUrl"] = self.pass_mongo_url deadline_webservice = None if deadline_server_name: deadline_webservice = deadline_module.deadline_urls.get( deadline_server_name) default_deadline_webservice = deadline_module.deadline_urls["default"] deadline_webservice = ( deadline_webservice or default_deadline_webservice ) context.data["defaultDeadline"] = deadline_webservice.strip().rstrip("/") # noqa ================================================ FILE: openpype/modules/deadline/plugins/publish/collect_pools.py ================================================ # -*- coding: utf-8 -*- import pyblish.api from openpype.lib import TextDef from openpype.pipeline.publish import OpenPypePyblishPluginMixin class CollectDeadlinePools(pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin): """Collect pools from instance or Publisher attributes, from Setting otherwise. Pools are used to control which DL workers could render the job. Pools might be set: - directly on the instance (set directly in DCC) - from Publisher attributes - from defaults from Settings. Publisher attributes could be shown even for instances that should be rendered locally as visibility is driven by product type of the instance (which will be `render` most likely). (Might be resolved in the future and class attribute 'families' should be cleaned up.) """ order = pyblish.api.CollectorOrder + 0.420 label = "Collect Deadline Pools" hosts = ["aftereffects", "fusion", "harmony" "nuke", "maya", "max", "houdini"] families = ["render", "rendering", "render.farm", "renderFarm", "renderlayer", "maxrender", "usdrender", "redshift_rop", "arnold_rop", "mantra_rop", "karma_rop", "vray_rop", "publish.hou"] primary_pool = None secondary_pool = None @classmethod def apply_settings(cls, project_settings, system_settings): # deadline.publish.CollectDeadlinePools settings = project_settings["deadline"]["publish"]["CollectDeadlinePools"] # noqa cls.primary_pool = settings.get("primary_pool", None) cls.secondary_pool = settings.get("secondary_pool", None) def process(self, instance): attr_values = self.get_attr_values_from_data(instance.data) if not instance.data.get("primaryPool"): instance.data["primaryPool"] = ( attr_values.get("primaryPool") or self.primary_pool or "none" ) if instance.data["primaryPool"] == "-": instance.data["primaryPool"] = None if not instance.data.get("secondaryPool"): instance.data["secondaryPool"] = ( attr_values.get("secondaryPool") or self.secondary_pool or "none" # noqa ) if instance.data["secondaryPool"] == "-": instance.data["secondaryPool"] = None @classmethod def get_attribute_defs(cls): # TODO: Preferably this would be an enum for the user # but the Deadline server URL can be dynamic and # can be set per render instance. Since get_attribute_defs # can't be dynamic unfortunately EnumDef isn't possible (yet?) # pool_names = self.deadline_module.get_deadline_pools(deadline_url, # self.log) # secondary_pool_names = ["-"] + pool_names return [ TextDef("primaryPool", label="Primary Pool", default=cls.primary_pool, tooltip="Deadline primary pool, " "applicable for farm rendering"), TextDef("secondaryPool", label="Secondary Pool", default=cls.secondary_pool, tooltip="Deadline secondary pool, " "applicable for farm rendering") ] ================================================ FILE: openpype/modules/deadline/plugins/publish/collect_publishable_instances.py ================================================ # -*- coding: utf-8 -*- """Collect instances that should be processed and published on DL. """ import os import pyblish.api from openpype.pipeline import PublishValidationError class CollectDeadlinePublishableInstances(pyblish.api.InstancePlugin): """Collect instances that should be processed and published on DL. Some long running publishes (not just renders) could be offloaded to DL, this plugin compares theirs name against env variable, marks only publishable by farm. Triggered only when running only in headless mode, eg on a farm. """ order = pyblish.api.CollectorOrder + 0.499 label = "Collect Deadline Publishable Instance" targets = ["remote"] def process(self, instance): self.log.debug("CollectDeadlinePublishableInstances") publish_inst = os.environ.get("OPENPYPE_PUBLISH_SUBSET", '') if not publish_inst: raise PublishValidationError("OPENPYPE_PUBLISH_SUBSET env var " "required for remote publishing") subset_name = instance.data["subset"] if subset_name == publish_inst: self.log.debug("Publish {}".format(subset_name)) instance.data["publish"] = True instance.data["farm"] = False else: self.log.debug("Skipping {}".format(subset_name)) instance.data["publish"] = False ================================================ FILE: openpype/modules/deadline/plugins/publish/help/validate_deadline_pools.xml ================================================ Deadline Pools ## Invalid Deadline pools found Configured pools don't match available pools in Deadline. ### How to repair? If your instance had deadline pools set on creation, remove or change them. In other cases inform admin to change them in Settings. Available deadline pools: {pools_str} ### __Detailed Info__ This error is shown when a configured pool is not available on Deadline. It can happen when publishing old workfiles which were created with previous deadline pools, or someone changed the available pools in Deadline, but didn't modify Openpype Settings to match the changes. ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py ================================================ import os import attr import getpass import pyblish.api from datetime import datetime from openpype.lib import ( env_value_to_bool, collect_frames, ) from openpype.pipeline import legacy_io from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype.tests.lib import is_in_tests from openpype.lib import is_running_from_build @attr.s class DeadlinePluginInfo(): Comp = attr.ib(default=None) SceneFile = attr.ib(default=None) OutputFilePath = attr.ib(default=None) Output = attr.ib(default=None) StartupDirectory = attr.ib(default=None) Arguments = attr.ib(default=None) ProjectPath = attr.ib(default=None) AWSAssetFile0 = attr.ib(default=None) Version = attr.ib(default=None) MultiProcess = attr.ib(default=None) class AfterEffectsSubmitDeadline( abstract_submit_deadline.AbstractSubmitDeadline ): label = "Submit AE to Deadline" order = pyblish.api.IntegratorOrder + 0.1 hosts = ["aftereffects"] families = ["render.farm"] # cannot be "render' as that is integrated use_published = True targets = ["local"] priority = 50 chunk_size = 1000000 group = None department = None multiprocess = True def get_job_info(self): dln_job_info = DeadlineJobInfo(Plugin="AfterEffects") context = self._instance.context batch_name = os.path.basename(self._instance.data["source"]) if is_in_tests(): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") dln_job_info.Name = self._instance.data["name"] dln_job_info.BatchName = batch_name dln_job_info.Plugin = "AfterEffects" dln_job_info.UserName = context.data.get( "deadlineUser", getpass.getuser()) if self._instance.data["frameEnd"] > self._instance.data["frameStart"]: # Deadline requires integers in frame range frame_range = "{}-{}".format( int(round(self._instance.data["frameStart"])), int(round(self._instance.data["frameEnd"]))) dln_job_info.Frames = frame_range dln_job_info.Priority = self.priority dln_job_info.Pool = self._instance.data.get("primaryPool") dln_job_info.SecondaryPool = self._instance.data.get("secondaryPool") dln_job_info.Group = self.group dln_job_info.Department = self.department dln_job_info.ChunkSize = self.chunk_size dln_job_info.OutputFilename += \ os.path.basename(self._instance.data["expectedFiles"][0]) dln_job_info.OutputDirectory += \ os.path.dirname(self._instance.data["expectedFiles"][0]) dln_job_info.JobDelay = "00:00:00" keys = [ "FTRACK_API_KEY", "FTRACK_API_USER", "FTRACK_SERVER", "AVALON_DB", "AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK", "AVALON_APP_NAME", "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS", "IS_TEST" ] # Add OpenPype version if we are running from build. if is_running_from_build(): keys.append("OPENPYPE_VERSION") # Add mongo url if it's enabled if self._instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) for key in keys: value = environment.get(key) if value: dln_job_info.EnvironmentKeyValue[key] = value # to recognize render jobs dln_job_info.add_render_job_env_var() return dln_job_info def get_plugin_info(self): deadline_plugin_info = DeadlinePluginInfo() render_path = self._instance.data["expectedFiles"][0] file_name, frame = list(collect_frames([render_path]).items())[0] if frame: # replace frame ('000001') with Deadline's required '[#######]' # expects filename in format project_asset_subset_version.FRAME.ext render_dir = os.path.dirname(render_path) file_name = os.path.basename(render_path) hashed = '[{}]'.format(len(frame) * "#") file_name = file_name.replace(frame, hashed) render_path = os.path.join(render_dir, file_name) deadline_plugin_info.Comp = self._instance.data["comp_name"] deadline_plugin_info.Version = self._instance.data["app_version"] # must be here because of DL AE plugin # added override of multiprocess by env var, if shouldn't be used for # some app variant use MULTIPROCESS:false in Settings, default is True env_multi = env_value_to_bool("MULTIPROCESS", default=True) deadline_plugin_info.MultiProcess = env_multi and self.multiprocess deadline_plugin_info.SceneFile = self.scene_path deadline_plugin_info.Output = render_path.replace("\\", "/") return attr.asdict(deadline_plugin_info) def from_published_scene(self): """ Do not overwrite expected files. Use published is set to True, so rendering will be triggered from published scene (in 'publish' folder). Default implementation of abstract class renames expected (eg. rendered) files accordingly which is not needed here. """ return super().from_published_scene(False) ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_blender_deadline.py ================================================ # -*- coding: utf-8 -*- """Submitting render job to Deadline.""" import os import getpass import attr from datetime import datetime from openpype.lib import ( is_running_from_build, BoolDef, NumberDef, TextDef, ) from openpype.pipeline import legacy_io from openpype.pipeline.publish import OpenPypePyblishPluginMixin from openpype.pipeline.farm.tools import iter_expected_files from openpype.tests.lib import is_in_tests from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo @attr.s class BlenderPluginInfo(): SceneFile = attr.ib(default=None) # Input Version = attr.ib(default=None) # Mandatory for Deadline SaveFile = attr.ib(default=True) class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, OpenPypePyblishPluginMixin): label = "Submit Render to Deadline" hosts = ["blender"] families = ["render"] use_published = True priority = 50 chunk_size = 1 jobInfo = {} pluginInfo = {} group = None job_delay = "00:00:00:00" def get_job_info(self): job_info = DeadlineJobInfo(Plugin="Blender") job_info.update(self.jobInfo) instance = self._instance context = instance.context # Always use the original work file name for the Job name even when # rendering is done from the published Work File. The original work # file name is clearer because it can also have subversion strings, # etc. which are stripped for the published file. src_filepath = context.data["currentFile"] src_filename = os.path.basename(src_filepath) if is_in_tests(): src_filename += datetime.now().strftime("%d%m%Y%H%M%S") job_info.Name = f"{src_filename} - {instance.name}" job_info.BatchName = src_filename instance.data.get("blenderRenderPlugin", "Blender") job_info.UserName = context.data.get("deadlineUser", getpass.getuser()) # Deadline requires integers in frame range frames = "{start}-{end}x{step}".format( start=int(instance.data["frameStartHandle"]), end=int(instance.data["frameEndHandle"]), step=int(instance.data["byFrameStep"]), ) job_info.Frames = frames job_info.Pool = instance.data.get("primaryPool") job_info.SecondaryPool = instance.data.get("secondaryPool") job_info.Comment = instance.data.get("comment") if self.group != "none" and self.group: job_info.Group = self.group attr_values = self.get_attr_values_from_data(instance.data) render_globals = instance.data.setdefault("renderGlobals", {}) machine_list = attr_values.get("machineList", "") if machine_list: if attr_values.get("whitelist", True): machine_list_key = "Whitelist" else: machine_list_key = "Blacklist" render_globals[machine_list_key] = machine_list job_info.ChunkSize = attr_values.get("chunkSize", self.chunk_size) job_info.Priority = attr_values.get("priority", self.priority) job_info.ScheduledType = "Once" job_info.JobDelay = attr_values.get("job_delay", self.job_delay) # Add options from RenderGlobals render_globals = instance.data.get("renderGlobals", {}) job_info.update(render_globals) keys = [ "FTRACK_API_KEY", "FTRACK_API_USER", "FTRACK_SERVER", "OPENPYPE_SG_USER", "AVALON_DB", "AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK", "AVALON_APP_NAME", "OPENPYPE_DEV" "IS_TEST" ] # Add OpenPype version if we are running from build. if is_running_from_build(): keys.append("OPENPYPE_VERSION") # Add mongo url if it's enabled if self._instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) for key in keys: value = environment.get(key) if not value: continue job_info.EnvironmentKeyValue[key] = value # to recognize job from PYPE for turning Event On/Off job_info.add_render_job_env_var() job_info.EnvironmentKeyValue["OPENPYPE_LOG_NO_COLORS"] = "1" # Adding file dependencies. if self.asset_dependencies: dependencies = instance.context.data["fileDependencies"] for dependency in dependencies: job_info.AssetDependency += dependency # Add list of expected files to job # --------------------------------- exp = instance.data.get("expectedFiles") for filepath in iter_expected_files(exp): job_info.OutputDirectory += os.path.dirname(filepath) job_info.OutputFilename += os.path.basename(filepath) return job_info def get_plugin_info(self): # Not all hosts can import this module. import bpy plugin_info = BlenderPluginInfo( SceneFile=self.scene_path, Version=bpy.app.version_string, SaveFile=True, ) plugin_payload = attr.asdict(plugin_info) # Patching with pluginInfo from settings for key, value in self.pluginInfo.items(): plugin_payload[key] = value return plugin_payload def process_submission(self): instance = self._instance expected_files = instance.data["expectedFiles"] if not expected_files: raise RuntimeError("No Render Elements found!") first_file = next(iter_expected_files(expected_files)) output_dir = os.path.dirname(first_file) instance.data["outputDir"] = output_dir instance.data["toBeRenderedOn"] = "deadline" payload = self.assemble_payload() return self.submit(payload) def from_published_scene(self): """ This is needed to set the correct path for the json metadata. Because the rendering path is set in the blend file during the collection, and the path is adjusted to use the published scene, this ensures that the metadata and the rendered files are in the same location. """ return super().from_published_scene(False) @classmethod def get_attribute_defs(cls): defs = super(BlenderSubmitDeadline, cls).get_attribute_defs() defs.extend([ BoolDef("use_published", default=cls.use_published, label="Use Published Scene"), NumberDef("priority", minimum=1, maximum=250, decimals=0, default=cls.priority, label="Priority"), NumberDef("chunkSize", minimum=1, maximum=50, decimals=0, default=cls.chunk_size, label="Frame Per Task"), TextDef("group", default=cls.group, label="Group Name"), TextDef("job_delay", default=cls.job_delay, label="Job Delay", placeholder="dd:hh:mm:ss", tooltip="Delay the job by the specified amount of time. " "Timecode: dd:hh:mm:ss."), ]) return defs ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py ================================================ import os import re import json import getpass import requests import pyblish.api class CelactionSubmitDeadline(pyblish.api.InstancePlugin): """Submit CelAction2D scene to Deadline Renders are submitted to a Deadline Web Service. """ label = "Submit CelAction to Deadline" order = pyblish.api.IntegratorOrder + 0.1 hosts = ["celaction"] families = ["render.farm"] deadline_department = "" deadline_priority = 50 deadline_pool = "" deadline_pool_secondary = "" deadline_group = "" deadline_chunk_size = 1 deadline_job_delay = "00:00:08:00" def process(self, instance): context = instance.context # get default deadline webservice url from deadline module deadline_url = instance.context.data["defaultDeadline"] # if custom one is set in instance, use that if instance.data.get("deadlineUrl"): deadline_url = instance.data.get("deadlineUrl") assert deadline_url, "Requires Deadline Webservice URL" self.deadline_url = "{}/api/jobs".format(deadline_url) self._comment = instance.data["comment"] self._deadline_user = context.data.get( "deadlineUser", getpass.getuser()) self._frame_start = int(instance.data["frameStart"]) self._frame_end = int(instance.data["frameEnd"]) # get output path render_path = instance.data['path'] script_path = context.data["currentFile"] response = self.payload_submit(instance, script_path, render_path ) # Store output dir for unified publisher (filesequence) instance.data["deadlineSubmissionJob"] = response.json() instance.data["outputDir"] = os.path.dirname( render_path).replace("\\", "/") instance.data["publishJobState"] = "Suspended" # adding 2d render specific family for version identification in Loader instance.data["families"] = ["render2d"] def payload_submit(self, instance, script_path, render_path ): resolution_width = instance.data["resolutionWidth"] resolution_height = instance.data["resolutionHeight"] render_dir = os.path.normpath(os.path.dirname(render_path)) render_path = os.path.normpath(render_path) script_name = os.path.basename(script_path) for item in instance.context: if "workfile" in item.data["family"]: msg = "Workfile (scene) must be published along" assert item.data["publish"] is True, msg template_data = item.data.get("anatomyData") rep = item.data.get("representations")[0].get("name") template_data["representation"] = rep template_data["ext"] = rep template_data["comment"] = None anatomy_filled = instance.context.data["anatomy"].format( template_data) template_filled = anatomy_filled["publish"]["path"] script_path = os.path.normpath(template_filled) self.log.info( "Using published scene for render {}".format(script_path) ) jobname = "%s - %s" % (script_name, instance.name) output_filename_0 = self.preview_fname(render_path) try: # Ensure render folder exists os.makedirs(render_dir) except OSError: pass # define chunk and priority chunk_size = instance.context.data.get("chunk") if not chunk_size: chunk_size = self.deadline_chunk_size # search for %02d pattern in name, and padding number search_results = re.search(r"(%0)(\d)(d)[._]", render_path).groups() split_patern = "".join(search_results) padding_number = int(search_results[1]) args = [ f"{script_path}", "-a", "-16", "-s ", "-e ", f"-d {render_dir}", f"-x {resolution_width}", f"-y {resolution_height}", f"-r {render_path.replace(split_patern, '')}", f"-= AbsoluteFrameNumber=on -= PadDigits={padding_number}", "-= ClearAttachment=on", ] payload = { "JobInfo": { # Job name, as seen in Monitor "Name": jobname, # plugin definition "Plugin": "CelAction", # Top-level group name "BatchName": script_name, # Arbitrary username, for visualisation in Monitor "UserName": self._deadline_user, "Department": self.deadline_department, "Priority": self.deadline_priority, "Group": self.deadline_group, "Pool": self.deadline_pool, "SecondaryPool": self.deadline_pool_secondary, "ChunkSize": chunk_size, "Frames": f"{self._frame_start}-{self._frame_end}", "Comment": self._comment, # Optional, enable double-click to preview rendered # frames from Deadline Monitor "OutputFilename0": output_filename_0.replace("\\", "/"), # # Asset dependency to wait for at least # the scene file to sync. # "AssetDependency0": script_path "ScheduledType": "Once", "JobDelay": self.deadline_job_delay }, "PluginInfo": { # Input "SceneFile": script_path, # Output directory "OutputFilePath": render_dir.replace("\\", "/"), # Plugin attributes "StartupDirectory": "", "Arguments": " ".join(args), # Resolve relative references "ProjectPath": script_path, "AWSAssetFile0": render_path, }, # Mandatory for Deadline, may be empty "AuxFiles": [] } plugin = payload["JobInfo"]["Plugin"] self.log.debug("using render plugin : {}".format(plugin)) self.log.debug("Submitting..") self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # adding expectied files to instance.data self.expected_files(instance, render_path) self.log.debug("__ expectedFiles: `{}`".format( instance.data["expectedFiles"])) response = requests.post(self.deadline_url, json=payload) if not response.ok: self.log.error( "Submission failed! [{}] {}".format( response.status_code, response.content)) self.log.debug(payload) raise SystemExit(response.text) return response def preflight_check(self, instance): """Ensure the startFrame, endFrame and byFrameStep are integers""" for key in ("frameStart", "frameEnd"): value = instance.data[key] if int(value) == value: continue self.log.warning( "%f=%d was rounded off to nearest integer" % (value, int(value)) ) def preview_fname(self, path): """Return output file path with #### for padding. Deadline requires the path to be formatted with # in place of numbers. For example `/path/to/render.####.png` Args: path (str): path to rendered images Returns: str """ self.log.debug("_ path: `{}`".format(path)) if "%" in path: search_results = re.search(r"[._](%0)(\d)(d)[._]", path).groups() split_patern = "".join(search_results) split_path = path.split(split_patern) hashes = "#" * int(search_results[1]) return "".join([split_path[0], hashes, split_path[-1]]) self.log.debug("_ path: `{}`".format(path)) return path def expected_files(self, instance, filepath): """ Create expected files in instance data """ if not instance.data.get("expectedFiles"): instance.data["expectedFiles"] = [] dirpath = os.path.dirname(filepath) filename = os.path.basename(filepath) if "#" in filename: pparts = filename.split("#") padding = "%0{}d".format(len(pparts) - 1) filename = pparts[0] + padding + pparts[-1] if "%" not in filename: instance.data["expectedFiles"].append(filepath) return for i in range(self._frame_start, (self._frame_end + 1)): instance.data["expectedFiles"].append( os.path.join(dirpath, (filename % i)).replace("\\", "/") ) ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py ================================================ import os import json import getpass import requests import pyblish.api from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( OpenPypePyblishPluginMixin ) from openpype.lib import ( BoolDef, NumberDef, is_running_from_build ) class FusionSubmitDeadline( pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin ): """Submit current Comp to Deadline Renders are submitted to a Deadline Web Service as supplied via settings key "DEADLINE_REST_URL". """ label = "Submit Fusion to Deadline" order = pyblish.api.IntegratorOrder hosts = ["fusion"] families = ["render"] targets = ["local"] # presets plugin = None priority = 50 chunk_size = 1 concurrent_tasks = 1 group = "" @classmethod def get_attribute_defs(cls): return [ NumberDef( "priority", label="Priority", default=cls.priority, decimals=0 ), NumberDef( "chunk", label="Frames Per Task", default=cls.chunk_size, decimals=0, minimum=1, maximum=1000 ), NumberDef( "concurrency", label="Concurrency", default=cls.concurrent_tasks, decimals=0, minimum=1, maximum=10 ), BoolDef( "suspend_publish", default=False, label="Suspend publish" ) ] def process(self, instance): if not instance.data.get("farm"): self.log.debug("Skipping local instance.") return attribute_values = self.get_attr_values_from_data( instance.data) # add suspend_publish attributeValue to instance data instance.data["suspend_publish"] = attribute_values[ "suspend_publish"] context = instance.context key = "__hasRun{}".format(self.__class__.__name__) if context.data.get(key, False): return else: context.data[key] = True from openpype.hosts.fusion.api.lib import get_frame_path # get default deadline webservice url from deadline module deadline_url = instance.context.data["defaultDeadline"] # if custom one is set in instance, use that if instance.data.get("deadlineUrl"): deadline_url = instance.data.get("deadlineUrl") assert deadline_url, "Requires Deadline Webservice URL" # Collect all saver instances in context that are to be rendered saver_instances = [] for instance in context: if instance.data["family"] != "render": # Allow only saver family instances continue if not instance.data.get("publish", True): # Skip inactive instances continue self.log.debug(instance.data["name"]) saver_instances.append(instance) if not saver_instances: raise RuntimeError("No instances found for Deadline submission") comment = instance.data.get("comment", "") deadline_user = context.data.get("deadlineUser", getpass.getuser()) script_path = context.data["currentFile"] for item in context: if "workfile" in item.data["families"]: msg = "Workfile (scene) must be published along" assert item.data["publish"] is True, msg template_data = item.data.get("anatomyData") rep = item.data.get("representations")[0].get("name") template_data["representation"] = rep template_data["ext"] = rep template_data["comment"] = None anatomy_filled = context.data["anatomy"].format(template_data) template_filled = anatomy_filled["publish"]["path"] script_path = os.path.normpath(template_filled) self.log.info( "Using published scene for render {}".format(script_path) ) filename = os.path.basename(script_path) # Documentation for keys available at: # https://docs.thinkboxsoftware.com # /products/deadline/8.0/1_User%20Manual/manual # /manual-submission.html#job-info-file-options payload = { "JobInfo": { # Top-level group name "BatchName": filename, # Asset dependency to wait for at least the scene file to sync. "AssetDependency0": script_path, # Job name, as seen in Monitor "Name": filename, "Priority": attribute_values.get( "priority", self.priority), "ChunkSize": attribute_values.get( "chunk", self.chunk_size), "ConcurrentTasks": attribute_values.get( "concurrency", self.concurrent_tasks ), # User, as seen in Monitor "UserName": deadline_user, "Pool": instance.data.get("primaryPool"), "SecondaryPool": instance.data.get("secondaryPool"), "Group": self.group, "Plugin": self.plugin, "Frames": "{start}-{end}".format( start=int(instance.data["frameStartHandle"]), end=int(instance.data["frameEndHandle"]) ), "Comment": comment, }, "PluginInfo": { # Input "FlowFile": script_path, # Mandatory for Deadline "Version": str(instance.data["app_version"]), # Render in high quality "HighQuality": True, # Whether saver output should be checked after rendering # is complete "CheckOutput": True, # Proxy: higher numbers smaller images for faster test renders # 1 = no proxy quality "Proxy": 1 }, # Mandatory for Deadline, may be empty "AuxFiles": [] } # Enable going to rendered frames from Deadline Monitor for index, instance in enumerate(saver_instances): head, padding, tail = get_frame_path( instance.data["expectedFiles"][0] ) path = "{}{}{}".format(head, "#" * padding, tail) folder, filename = os.path.split(path) payload["JobInfo"]["OutputDirectory%d" % index] = folder payload["JobInfo"]["OutputFilename%d" % index] = filename # Include critical variables with submission keys = [ "FTRACK_API_KEY", "FTRACK_API_USER", "FTRACK_SERVER", "AVALON_DB", "AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK", "AVALON_APP_NAME", "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS", "IS_TEST" ] # Add OpenPype version if we are running from build. if is_running_from_build(): keys.append("OPENPYPE_VERSION") environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) # to recognize render jobs if AYON_SERVER_ENABLED: environment["AYON_BUNDLE_NAME"] = os.environ["AYON_BUNDLE_NAME"] render_job_label = "AYON_RENDER_JOB" else: render_job_label = "OPENPYPE_RENDER_JOB" environment[render_job_label] = "1" payload["JobInfo"].update({ "EnvironmentKeyValue%d" % index: "{key}={value}".format( key=key, value=environment[key] ) for index, key in enumerate(environment) }) self.log.debug("Submitting..") self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # E.g. http://192.168.0.1:8082/api/jobs url = "{}/api/jobs".format(deadline_url) response = requests.post(url, json=payload) if not response.ok: raise Exception(response.text) # Store the response for dependent job submission plug-ins for instance in saver_instances: instance.data["deadlineSubmissionJob"] = response.json() ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py ================================================ # -*- coding: utf-8 -*- """Submitting render job to Deadline.""" import os from pathlib import Path from collections import OrderedDict from zipfile import ZipFile, is_zipfile import re from datetime import datetime import attr import pyblish.api from openpype.pipeline import legacy_io from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype.tests.lib import is_in_tests from openpype.lib import is_running_from_build class _ZipFile(ZipFile): """Extended check for windows invalid characters.""" # this is extending default zipfile table for few invalid characters # that can come from Mac _windows_illegal_characters = ":<>|\"?*\r\n\x00" _windows_illegal_name_trans_table = str.maketrans( _windows_illegal_characters, "_" * len(_windows_illegal_characters) ) @attr.s class PluginInfo(object): """Plugin info structure for Harmony Deadline plugin.""" SceneFile = attr.ib() # Harmony version Version = attr.ib() Camera = attr.ib(default="") FieldOfView = attr.ib(default=41.11) IsDatabase = attr.ib(default=False) ResolutionX = attr.ib(default=1920) ResolutionY = attr.ib(default=1080) # Resolution name preset, default UsingResPreset = attr.ib(default=False) ResolutionName = attr.ib(default="HDTV_1080p24") PreRenderInlineScript = attr.ib(default=None) # -------------------------------------------------- _outputNode = attr.ib(factory=list) @property def OutputNode(self): # noqa: N802 """Return all output nodes formatted for Deadline. Returns: dict: as `{'Output0Node', 'Top/renderFarmDefault'}` """ out = {} for index, v in enumerate(self._outputNode): out["Output{}Node".format(index)] = v return out @OutputNode.setter def OutputNode(self, val): # noqa: N802 self._outputNode.append(val) # -------------------------------------------------- _outputType = attr.ib(factory=list) @property def OutputType(self): # noqa: N802 """Return output nodes type formatted for Deadline. Returns: dict: as `{'Output0Type', 'Image'}` """ out = {} for index, v in enumerate(self._outputType): out["Output{}Type".format(index)] = v return out @OutputType.setter def OutputType(self, val): # noqa: N802 self._outputType.append(val) # -------------------------------------------------- _outputLeadingZero = attr.ib(factory=list) @property def OutputLeadingZero(self): # noqa: N802 """Return output nodes type formatted for Deadline. Returns: dict: as `{'Output0LeadingZero', '3'}` """ out = {} for index, v in enumerate(self._outputLeadingZero): out["Output{}LeadingZero".format(index)] = v return out @OutputLeadingZero.setter def OutputLeadingZero(self, val): # noqa: N802 self._outputLeadingZero.append(val) # -------------------------------------------------- _outputFormat = attr.ib(factory=list) @property def OutputFormat(self): # noqa: N802 """Return output nodes format formatted for Deadline. Returns: dict: as `{'Output0Type', 'PNG4'}` """ out = {} for index, v in enumerate(self._outputFormat): out["Output{}Format".format(index)] = v return out @OutputFormat.setter def OutputFormat(self, val): # noqa: N802 self._outputFormat.append(val) # -------------------------------------------------- _outputStartFrame = attr.ib(factory=list) @property def OutputStartFrame(self): # noqa: N802 """Return start frame for output nodes formatted for Deadline. Returns: dict: as `{'Output0StartFrame', '1'}` """ out = {} for index, v in enumerate(self._outputStartFrame): out["Output{}StartFrame".format(index)] = v return out @OutputStartFrame.setter def OutputStartFrame(self, val): # noqa: N802 self._outputStartFrame.append(val) # -------------------------------------------------- _outputPath = attr.ib(factory=list) @property def OutputPath(self): # noqa: N802 """Return output paths for nodes formatted for Deadline. Returns: dict: as `{'Output0Path', '/output/path'}` """ out = {} for index, v in enumerate(self._outputPath): out["Output{}Path".format(index)] = v return out @OutputPath.setter def OutputPath(self, val): # noqa: N802 self._outputPath.append(val) def set_output(self, node, image_format, output, output_type="Image", zeros=3, start_frame=1): """Helper to set output. This should be used instead of setting properties individually as so index remain consistent. Args: node (str): harmony write node name image_format (str): format of output (PNG4, TIF, ...) output (str): output path output_type (str, optional): "Image" or "Movie" (not supported). zeros (int, optional): Leading zeros (for 0001 = 3) start_frame (int, optional): Sequence offset. """ self.OutputNode = node self.OutputFormat = image_format self.OutputPath = output self.OutputType = output_type self.OutputLeadingZero = zeros self.OutputStartFrame = start_frame def serialize(self): """Return all data serialized as dictionary. Returns: OrderedDict: all serialized data. """ def filter_data(a, v): if a.name.startswith("_"): return False if v is None: return False return True serialized = attr.asdict( self, dict_factory=OrderedDict, filter=filter_data) serialized.update(self.OutputNode) serialized.update(self.OutputFormat) serialized.update(self.OutputPath) serialized.update(self.OutputType) serialized.update(self.OutputLeadingZero) serialized.update(self.OutputStartFrame) return serialized class HarmonySubmitDeadline( abstract_submit_deadline.AbstractSubmitDeadline ): """Submit render write of Harmony scene to Deadline. Renders are submitted to a Deadline Web Service as supplied via the environment variable ``DEADLINE_REST_URL``. Note: If Deadline configuration is not detected, this plugin will be disabled. Attributes: use_published (bool): Use published scene to render instead of the one in work area. """ label = "Submit to Deadline" order = pyblish.api.IntegratorOrder + 0.1 hosts = ["harmony"] families = ["render.farm"] targets = ["local"] optional = True use_published = False priority = 50 chunk_size = 1000000 group = "none" department = "" def get_job_info(self): job_info = DeadlineJobInfo("Harmony") job_info.Name = self._instance.data["name"] job_info.Plugin = "HarmonyOpenPype" job_info.Frames = "{}-{}".format( self._instance.data["frameStartHandle"], self._instance.data["frameEndHandle"] ) # for now, get those from presets. Later on it should be # configurable in Harmony UI directly. job_info.Priority = self.priority job_info.Pool = self._instance.data.get("primaryPool") job_info.SecondaryPool = self._instance.data.get("secondaryPool") job_info.ChunkSize = self.chunk_size batch_name = os.path.basename(self._instance.data["source"]) if is_in_tests(): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") job_info.BatchName = batch_name job_info.Department = self.department job_info.Group = self.group keys = [ "FTRACK_API_KEY", "FTRACK_API_USER", "FTRACK_SERVER", "AVALON_DB", "AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK", "AVALON_APP_NAME", "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS" "IS_TEST" ] # Add OpenPype version if we are running from build. if is_running_from_build(): keys.append("OPENPYPE_VERSION") # Add mongo url if it's enabled if self._instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) for key in keys: value = environment.get(key) if value: job_info.EnvironmentKeyValue[key] = value # to recognize render jobs job_info.add_render_job_env_var() return job_info def _unzip_scene_file(self, published_scene: Path) -> Path: """Unzip scene zip file to its directory. Unzip scene file (if it is zip file) to its current directory and return path to xstage file there. Xstage file is determined by its name. Args: published_scene (Path): path to zip file. Returns: Path: The path to unzipped xstage. """ # if not zip, bail out. if "zip" not in published_scene.suffix or not is_zipfile( published_scene.as_posix() ): self.log.error("Published scene is not in zip.") self.log.error(published_scene) raise AssertionError("invalid scene format") xstage_path = ( published_scene.parent / published_scene.stem / f"{published_scene.stem}.xstage" ) unzip_dir = (published_scene.parent / published_scene.stem) with _ZipFile(published_scene, "r") as zip_ref: # UNC path (//?/) added to minimalize risk with extracting # to large file paths zip_ref.extractall("//?/" + str(unzip_dir.as_posix())) # find any xstage files in directory, prefer the one with the same name # as directory (plus extension) xstage_files = [] for scene in unzip_dir.iterdir(): if scene.suffix == ".xstage": xstage_files.append(scene) # there must be at least one (but maybe not more?) xstage file if not xstage_files: self.log.error("No xstage files found in zip") raise AssertionError("Invalid scene archive") ideal_scene = False # find the one with the same name as zip. In case there can be more # then one xtage file. for scene in xstage_files: # if /foo/bar/baz.zip == /foo/bar/baz/baz.xstage # ^^^ ^^^ if scene.stem == published_scene.stem: xstage_path = scene ideal_scene = True # but sometimes xstage file has different name then zip - in that case # use that one. if not ideal_scene: xstage_path = xstage_files[0] return xstage_path def get_plugin_info(self): # this is path to published scene workfile _ZIP_. Before # rendering, we need to unzip it. published_scene = Path( self.from_published_scene(False)) self.log.debug(f"Processing {published_scene.as_posix()}") xstage_path = self._unzip_scene_file(published_scene) render_path = xstage_path.parent / "renders" # for submit_publish job to create .json file in self._instance.data["outputDir"] = render_path new_expected_files = [] render_path_str = str(render_path.as_posix()) for file in self._instance.data["expectedFiles"]: _file = str(Path(file).as_posix()) expected_dir_str = os.path.dirname(_file) new_expected_files.append( _file.replace(expected_dir_str, render_path_str) ) audio_file = self._instance.data.get("audioFile") if audio_file: abs_path = xstage_path.parent / audio_file self._instance.context.data["audioFile"] = str(abs_path) self._instance.data["source"] = str(published_scene.as_posix()) self._instance.data["expectedFiles"] = new_expected_files harmony_plugin_info = PluginInfo( SceneFile=xstage_path.as_posix(), Version=( self._instance.context.data["harmonyVersion"].split(".")[0]), FieldOfView=self._instance.context.data["FOV"], ResolutionX=self._instance.data["resolutionWidth"], ResolutionY=self._instance.data["resolutionHeight"] ) pattern = '[0]{' + str(self._instance.data["leadingZeros"]) + \ '}1\.[a-zA-Z]{3}' render_prefix = re.sub(pattern, '', self._instance.data["expectedFiles"][0]) harmony_plugin_info.set_output( self._instance.data["setMembers"][0], self._instance.data["outputFormat"], render_prefix, self._instance.data["outputType"], self._instance.data["leadingZeros"], self._instance.data["outputStartFrame"] ) all_write_nodes = self._instance.context.data["all_write_nodes"] disable_nodes = [] for node in all_write_nodes: # disable all other write nodes if node != self._instance.data["setMembers"][0]: disable_nodes.append("node.setEnable('{}', false)" .format(node)) harmony_plugin_info.PreRenderInlineScript = ';'.join(disable_nodes) return harmony_plugin_info.serialize() ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_houdini_cache_deadline.py ================================================ import os import getpass from datetime import datetime import attr import pyblish.api from openpype.lib import ( TextDef, NumberDef, ) from openpype.pipeline import ( legacy_io, OpenPypePyblishPluginMixin ) from openpype.tests.lib import is_in_tests from openpype.lib import is_running_from_build from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo @attr.s class HoudiniPluginInfo(object): Build = attr.ib(default=None) IgnoreInputs = attr.ib(default=True) ScriptJob = attr.ib(default=True) SceneFile = attr.ib(default=None) # Input SaveFile = attr.ib(default=True) ScriptFilename = attr.ib(default=None) OutputDriver = attr.ib(default=None) Version = attr.ib(default=None) # Mandatory for Deadline ProjectPath = attr.ib(default=None) class HoudiniCacheSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, # noqa OpenPypePyblishPluginMixin): """Submit Houdini scene to perform a local publish in Deadline. Publishing in Deadline can be helpful for scenes that publish very slow. This way it can process in the background on another machine without the Artist having to wait for the publish to finish on their local machine. Submission is done through the Deadline Web Service as supplied via the environment variable AVALON_DEADLINE. """ label = "Submit Scene to Deadline" order = pyblish.api.IntegratorOrder hosts = ["houdini"] families = ["publish.hou"] targets = ["local"] priority = 50 chunk_size = 999999 group = None jobInfo = {} pluginInfo = {} def get_job_info(self): job_info = DeadlineJobInfo(Plugin="Houdini") job_info.update(self.jobInfo) instance = self._instance context = instance.context assert all( result["success"] for result in context.data["results"] ), "Errors found, aborting integration.." # Deadline connection AVALON_DEADLINE = legacy_io.Session.get( "AVALON_DEADLINE", "http://localhost:8082" ) assert AVALON_DEADLINE, "Requires AVALON_DEADLINE" project_name = instance.context.data["projectName"] filepath = context.data["currentFile"] scenename = os.path.basename(filepath) job_name = "{scene} - {instance} [PUBLISH]".format( scene=scenename, instance=instance.name) batch_name = "{code} - {scene}".format(code=project_name, scene=scenename) if is_in_tests(): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") job_info.Name = job_name job_info.BatchName = batch_name job_info.Plugin = instance.data["plugin"] job_info.UserName = context.data.get("deadlineUser", getpass.getuser()) rop_node = self.get_rop_node(instance) if rop_node.type().name() != "alembic": frames = "{start}-{end}x{step}".format( start=int(instance.data["frameStart"]), end=int(instance.data["frameEnd"]), step=int(instance.data["byFrameStep"]), ) job_info.Frames = frames job_info.Pool = instance.data.get("primaryPool") job_info.SecondaryPool = instance.data.get("secondaryPool") attr_values = self.get_attr_values_from_data(instance.data) job_info.ChunkSize = instance.data.get("chunkSize", self.chunk_size) job_info.Comment = context.data.get("comment") job_info.Priority = attr_values.get("priority", self.priority) job_info.Group = attr_values.get("group", self.group) keys = [ "FTRACK_API_KEY", "FTRACK_API_USER", "FTRACK_SERVER", "OPENPYPE_SG_USER", "AVALON_DB", "AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK", "AVALON_APP_NAME", "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS", ] # Add OpenPype version if we are running from build. if is_running_from_build(): keys.append("OPENPYPE_VERSION") # Add mongo url if it's enabled if self._instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) for key in keys: value = environment.get(key) if not value: continue job_info.EnvironmentKeyValue[key] = value # to recognize render jobs job_info.add_render_job_env_var() return job_info def get_plugin_info(self): # Not all hosts can import this module. import hou instance = self._instance version = hou.applicationVersionString() version = ".".join(version.split(".")[:2]) rop = self.get_rop_node(instance) plugin_info = HoudiniPluginInfo( Build=None, IgnoreInputs=True, ScriptJob=True, SceneFile=self.scene_path, SaveFile=True, OutputDriver=rop.path(), Version=version, ProjectPath=os.path.dirname(self.scene_path) ) plugin_payload = attr.asdict(plugin_info) return plugin_payload def process(self, instance): super(HoudiniCacheSubmitDeadline, self).process(instance) output_dir = os.path.dirname(instance.data["files"][0]) instance.data["outputDir"] = output_dir instance.data["toBeRenderedOn"] = "deadline" def get_rop_node(self, instance): # Not all hosts can import this module. import hou rop = instance.data.get("instance_node") rop_node = hou.node(rop) return rop_node @classmethod def get_attribute_defs(cls): defs = super(HoudiniCacheSubmitDeadline, cls).get_attribute_defs() defs.extend([ NumberDef("priority", minimum=1, maximum=250, decimals=0, default=cls.priority, label="Priority"), TextDef("group", default=cls.group, label="Group Name"), ]) return defs ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py ================================================ import os import json from datetime import datetime import requests import pyblish.api from openpype.pipeline import legacy_io from openpype.tests.lib import is_in_tests from openpype.lib import is_running_from_build class HoudiniSubmitPublishDeadline(pyblish.api.ContextPlugin): """Submit Houdini scene to perform a local publish in Deadline. Publishing in Deadline can be helpful for scenes that publish very slow. This way it can process in the background on another machine without the Artist having to wait for the publish to finish on their local machine. Submission is done through the Deadline Web Service as supplied via the environment variable AVALON_DEADLINE. """ label = "Submit Scene to Deadline" order = pyblish.api.IntegratorOrder hosts = ["houdini"] families = ["*"] targets = ["deadline"] def process(self, context): # Not all hosts can import this module. import hou # Ensure no errors so far assert all( result["success"] for result in context.data["results"] ), "Errors found, aborting integration.." # Deadline connection AVALON_DEADLINE = legacy_io.Session.get( "AVALON_DEADLINE", "http://localhost:8082" ) assert AVALON_DEADLINE, "Requires AVALON_DEADLINE" # Note that `publish` data member might change in the future. # See: https://github.com/pyblish/pyblish-base/issues/307 actives = [i for i in context if i.data["publish"]] instance_names = sorted(instance.name for instance in actives) if not instance_names: self.log.warning( "No active instances found. " "Skipping submission.." ) return scene = context.data["currentFile"] scenename = os.path.basename(scene) # Get project code project = context.data["projectEntity"] code = project["data"].get("code", project["name"]) job_name = "{scene} [PUBLISH]".format(scene=scenename) batch_name = "{code} - {scene}".format(code=code, scene=scenename) if is_in_tests(): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") deadline_user = "roy" # todo: get deadline user dynamically # Get only major.minor version of Houdini, ignore patch version version = hou.applicationVersionString() version = ".".join(version.split(".")[:2]) # Generate the payload for Deadline submission payload = { "JobInfo": { "Plugin": "Houdini", "Pool": "houdini", # todo: remove hardcoded pool "BatchName": batch_name, "Comment": context.data.get("comment", ""), "Priority": 50, "Frames": "1-1", # Always trigger a single frame "IsFrameDependent": False, "Name": job_name, "UserName": deadline_user, # "Comment": instance.context.data.get("comment", ""), # "InitialStatus": state }, "PluginInfo": { "Build": None, # Don't force build "IgnoreInputs": True, # Inputs "SceneFile": scene, "OutputDriver": "/out/REMOTE_PUBLISH", # Mandatory for Deadline "Version": version, }, # Mandatory for Deadline, may be empty "AuxFiles": [], } # Process submission per individual instance if the submission # is set to publish each instance as a separate job. Else submit # a single job to process all instances. per_instance = context.data.get("separateJobPerInstance", False) if per_instance: # Submit a job per instance job_name = payload["JobInfo"]["Name"] for instance in instance_names: # Clarify job name per submission (include instance name) payload["JobInfo"]["Name"] = job_name + " - %s" % instance self.submit_job( context, payload, instances=[instance], deadline=AVALON_DEADLINE ) else: # Submit a single job self.submit_job( context, payload, instances=instance_names, deadline=AVALON_DEADLINE ) def submit_job(self, context, payload, instances, deadline): # Ensure we operate on a copy, a shallow copy is fine. payload = payload.copy() # Include critical environment variables with submission + api.Session keys = [ # Submit along the current Avalon tool setup that we launched # this application with so the Render Slave can build its own # similar environment using it, e.g. "houdini17.5;pluginx2.3" "AVALON_TOOLS" ] # Add OpenPype version if we are running from build. if is_running_from_build(): keys.append("OPENPYPE_VERSION") # Add mongo url if it's enabled if context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") environment = dict( {key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session ) environment["PYBLISH_ACTIVE_INSTANCES"] = ",".join(instances) payload["JobInfo"].update( { "EnvironmentKeyValue%d" % index: "{key}={value}".format( key=key, value=environment[key] ) for index, key in enumerate(environment) } ) # Submit self.log.debug("Submitting..") self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # E.g. http://192.168.0.1:8082/api/jobs url = "{}/api/jobs".format(deadline) response = requests.post(url, json=payload) if not response.ok: raise Exception(response.text) ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py ================================================ import os import attr import getpass from datetime import datetime import pyblish.api from openpype.pipeline import legacy_io, OpenPypePyblishPluginMixin from openpype.tests.lib import is_in_tests from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype.lib import ( is_running_from_build, BoolDef, TextDef, NumberDef ) @attr.s class DeadlinePluginInfo(): SceneFile = attr.ib(default=None) OutputDriver = attr.ib(default=None) Version = attr.ib(default=None) IgnoreInputs = attr.ib(default=True) @attr.s class ArnoldRenderDeadlinePluginInfo(): InputFile = attr.ib(default=None) Verbose = attr.ib(default=4) @attr.s class MantraRenderDeadlinePluginInfo(): SceneFile = attr.ib(default=None) Version = attr.ib(default=None) @attr.s class VrayRenderPluginInfo(): InputFilename = attr.ib(default=None) SeparateFilesPerFrame = attr.ib(default=True) @attr.s class RedshiftRenderPluginInfo(): SceneFile = attr.ib(default=None) Version = attr.ib(default=None) class HoudiniSubmitDeadline( abstract_submit_deadline.AbstractSubmitDeadline, OpenPypePyblishPluginMixin ): """Submit Render ROPs to Deadline. Renders are submitted to a Deadline Web Service as supplied via the environment variable AVALON_DEADLINE. Target "local": Even though this does *not* render locally this is seen as a 'local' submission as it is the regular way of submitting a Houdini render locally. """ label = "Submit Render to Deadline" order = pyblish.api.IntegratorOrder hosts = ["houdini"] families = ["usdrender", "redshift_rop", "arnold_rop", "mantra_rop", "karma_rop", "vray_rop"] targets = ["local"] use_published = True # presets export_priority = 50 export_chunk_size = 10 export_group = "" priority = 50 chunk_size = 1 group = "" @classmethod def get_attribute_defs(cls): return [ BoolDef( "suspend_publish", default=False, label="Suspend publish" ), NumberDef( "priority", label="Priority", default=cls.priority, decimals=0 ), NumberDef( "chunk", label="Frames Per Task", default=cls.chunk_size, decimals=0, minimum=1, maximum=1000 ), TextDef( "group", default=cls.group, label="Group Name" ), NumberDef( "export_priority", label="Export Priority", default=cls.export_priority, decimals=0 ), NumberDef( "export_chunk", label="Export Frames Per Task", default=cls.export_chunk_size, decimals=0, minimum=1, maximum=1000 ), TextDef( "export_group", default=cls.export_group, label="Export Group Name" ), BoolDef( "suspend_publish", default=False, label="Suspend publish" ) ] def get_job_info(self, dependency_job_ids=None): instance = self._instance context = instance.context attribute_values = self.get_attr_values_from_data(instance.data) # Whether Deadline render submission is being split in two # (extract + render) split_render_job = instance.data.get("splitRender") # If there's some dependency job ids we can assume this is a render job # and not an export job is_export_job = True if dependency_job_ids: is_export_job = False job_type = "[RENDER]" if split_render_job and not is_export_job: # Convert from family to Deadline plugin name # i.e., arnold_rop -> Arnold plugin = instance.data["family"].replace("_rop", "").capitalize() else: plugin = "Houdini" if split_render_job: job_type = "[EXPORT IFD]" job_info = DeadlineJobInfo(Plugin=plugin) filepath = context.data["currentFile"] filename = os.path.basename(filepath) job_info.Name = "{} - {} {}".format(filename, instance.name, job_type) job_info.BatchName = filename job_info.UserName = context.data.get( "deadlineUser", getpass.getuser()) if is_in_tests(): job_info.BatchName += datetime.now().strftime("%d%m%Y%H%M%S") # Deadline requires integers in frame range start = instance.data["frameStartHandle"] end = instance.data["frameEndHandle"] frames = "{start}-{end}x{step}".format( start=int(start), end=int(end), step=int(instance.data["byFrameStep"]), ) job_info.Frames = frames # Make sure we make job frame dependent so render tasks pick up a soon # as export tasks are done if split_render_job and not is_export_job: job_info.IsFrameDependent = True job_info.Pool = instance.data.get("primaryPool") job_info.SecondaryPool = instance.data.get("secondaryPool") if split_render_job and is_export_job: job_info.Priority = attribute_values.get( "export_priority", self.export_priority ) job_info.ChunkSize = attribute_values.get( "export_chunk", self.export_chunk_size ) job_info.Group = self.export_group else: job_info.Priority = attribute_values.get( "priority", self.priority ) job_info.ChunkSize = attribute_values.get( "chunk", self.chunk_size ) job_info.Group = self.group job_info.Comment = context.data.get("comment") keys = [ "FTRACK_API_KEY", "FTRACK_API_USER", "FTRACK_SERVER", "OPENPYPE_SG_USER", "AVALON_DB", "AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK", "AVALON_APP_NAME", "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS", ] # Add OpenPype version if we are running from build. if is_running_from_build(): keys.append("OPENPYPE_VERSION") # Add mongo url if it's enabled if self._instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) for key in keys: value = environment.get(key) if value: job_info.EnvironmentKeyValue[key] = value # to recognize render jobs job_info.add_render_job_env_var() for i, filepath in enumerate(instance.data["files"]): dirname = os.path.dirname(filepath) fname = os.path.basename(filepath) job_info.OutputDirectory += dirname.replace("\\", "/") job_info.OutputFilename += fname # Add dependencies if given if dependency_job_ids: job_info.JobDependencies = ",".join(dependency_job_ids) return job_info def get_plugin_info(self, job_type=None): # Not all hosts can import this module. import hou instance = self._instance context = instance.context hou_major_minor = hou.applicationVersionString().rsplit(".", 1)[0] # Output driver to render if job_type == "render": family = instance.data.get("family") if family == "arnold_rop": plugin_info = ArnoldRenderDeadlinePluginInfo( InputFile=instance.data["ifdFile"] ) elif family == "mantra_rop": plugin_info = MantraRenderDeadlinePluginInfo( SceneFile=instance.data["ifdFile"], Version=hou_major_minor, ) elif family == "vray_rop": plugin_info = VrayRenderPluginInfo( InputFilename=instance.data["ifdFile"], ) elif family == "redshift_rop": plugin_info = RedshiftRenderPluginInfo( SceneFile=instance.data["ifdFile"] ) # Note: To use different versions of Redshift on Deadline # set the `REDSHIFT_VERSION` env variable in the Tools # settings in the AYON Application plugin. You will also # need to set that version in `Redshift.param` file # of the Redshift Deadline plugin: # [Redshift_Executable_*] # where * is the version number. if os.getenv("REDSHIFT_VERSION"): plugin_info.Version = os.getenv("REDSHIFT_VERSION") else: self.log.warning(( "REDSHIFT_VERSION env variable is not set" " - using version configured in Deadline" )) else: self.log.error( "Family '%s' not supported yet to split render job", family ) return else: driver = hou.node(instance.data["instance_node"]) plugin_info = DeadlinePluginInfo( SceneFile=context.data["currentFile"], OutputDriver=driver.path(), Version=hou_major_minor, IgnoreInputs=True ) return attr.asdict(plugin_info) def process(self, instance): super(HoudiniSubmitDeadline, self).process(instance) # TODO: Avoid the need for this logic here, needed for submit publish # Store output dir for unified publisher (filesequence) output_dir = os.path.dirname(instance.data["files"][0]) instance.data["outputDir"] = output_dir ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_max_deadline.py ================================================ import os import getpass import copy import attr from openpype.lib import ( TextDef, BoolDef, NumberDef, ) from openpype.pipeline import ( legacy_io, OpenPypePyblishPluginMixin ) from openpype.pipeline.publish.lib import ( replace_with_published_scene_path ) from openpype.pipeline.publish import KnownPublishError from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype.lib import is_running_from_build @attr.s class MaxPluginInfo(object): SceneFile = attr.ib(default=None) # Input Version = attr.ib(default=None) # Mandatory for Deadline SaveFile = attr.ib(default=True) IgnoreInputs = attr.ib(default=True) class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, OpenPypePyblishPluginMixin): label = "Submit Render to Deadline" hosts = ["max"] families = ["maxrender"] targets = ["local"] use_published = True priority = 50 chunk_size = 1 jobInfo = {} pluginInfo = {} group = None @classmethod def apply_settings(cls, project_settings, system_settings): settings = project_settings["deadline"]["publish"]["MaxSubmitDeadline"] # noqa # Take some defaults from settings cls.use_published = settings.get("use_published", cls.use_published) cls.priority = settings.get("priority", cls.priority) cls.chuck_size = settings.get("chunk_size", cls.chunk_size) cls.group = settings.get("group", cls.group) # TODO: multiple camera instance, separate job infos def get_job_info(self): job_info = DeadlineJobInfo(Plugin="3dsmax") # todo: test whether this works for existing production cases # where custom jobInfo was stored in the project settings job_info.update(self.jobInfo) instance = self._instance context = instance.context # Always use the original work file name for the Job name even when # rendering is done from the published Work File. The original work # file name is clearer because it can also have subversion strings, # etc. which are stripped for the published file. src_filepath = context.data["currentFile"] src_filename = os.path.basename(src_filepath) job_info.Name = "%s - %s" % (src_filename, instance.name) job_info.BatchName = src_filename job_info.Plugin = instance.data["plugin"] job_info.UserName = context.data.get("deadlineUser", getpass.getuser()) job_info.EnableAutoTimeout = True # Deadline requires integers in frame range frames = "{start}-{end}".format( start=int(instance.data["frameStart"]), end=int(instance.data["frameEnd"]) ) job_info.Frames = frames job_info.Pool = instance.data.get("primaryPool") job_info.SecondaryPool = instance.data.get("secondaryPool") attr_values = self.get_attr_values_from_data(instance.data) job_info.ChunkSize = attr_values.get("chunkSize", 1) job_info.Comment = context.data.get("comment") job_info.Priority = attr_values.get("priority", self.priority) job_info.Group = attr_values.get("group", self.group) # Add options from RenderGlobals render_globals = instance.data.get("renderGlobals", {}) job_info.update(render_globals) keys = [ "FTRACK_API_KEY", "FTRACK_API_USER", "FTRACK_SERVER", "OPENPYPE_SG_USER", "AVALON_DB", "AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK", "AVALON_APP_NAME", "OPENPYPE_DEV", "IS_TEST" ] # Add OpenPype version if we are running from build. if is_running_from_build(): keys.append("OPENPYPE_VERSION") # Add mongo url if it's enabled if self._instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) for key in keys: value = environment.get(key) if not value: continue job_info.EnvironmentKeyValue[key] = value # to recognize render jobs job_info.add_render_job_env_var() job_info.EnvironmentKeyValue["OPENPYPE_LOG_NO_COLORS"] = "1" # Add list of expected files to job # --------------------------------- if not instance.data.get("multiCamera"): exp = instance.data.get("expectedFiles") for filepath in self._iter_expected_files(exp): job_info.OutputDirectory += os.path.dirname(filepath) job_info.OutputFilename += os.path.basename(filepath) return job_info def get_plugin_info(self): instance = self._instance plugin_info = MaxPluginInfo( SceneFile=self.scene_path, Version=instance.data["maxversion"], SaveFile=True, IgnoreInputs=True ) plugin_payload = attr.asdict(plugin_info) # Patching with pluginInfo from settings for key, value in self.pluginInfo.items(): plugin_payload[key] = value return plugin_payload def process_submission(self): instance = self._instance filepath = instance.context.data["currentFile"] files = instance.data["expectedFiles"] if not files: raise KnownPublishError("No Render Elements found!") first_file = next(self._iter_expected_files(files)) output_dir = os.path.dirname(first_file) instance.data["outputDir"] = output_dir filename = os.path.basename(filepath) payload_data = { "filename": filename, "dirname": output_dir } self.log.debug("Submitting 3dsMax render..") project_settings = instance.context.data["project_settings"] if instance.data.get("multiCamera"): self.log.debug("Submitting jobs for multiple cameras..") payload = self._use_published_name_for_multiples( payload_data, project_settings) job_infos, plugin_infos = payload for job_info, plugin_info in zip(job_infos, plugin_infos): self.submit(self.assemble_payload(job_info, plugin_info)) else: payload = self._use_published_name(payload_data, project_settings) job_info, plugin_info = payload self.submit(self.assemble_payload(job_info, plugin_info)) def _use_published_name(self, data, project_settings): # Not all hosts can import these modules. from openpype.hosts.max.api.lib import ( get_current_renderer, get_multipass_setting ) from openpype.hosts.max.api.lib_rendersettings import RenderSettings instance = self._instance job_info = copy.deepcopy(self.job_info) plugin_info = copy.deepcopy(self.plugin_info) plugin_data = {} multipass = get_multipass_setting(project_settings) if multipass: plugin_data["DisableMultipass"] = 0 else: plugin_data["DisableMultipass"] = 1 files = instance.data.get("expectedFiles") if not files: raise KnownPublishError("No render elements found") first_file = next(self._iter_expected_files(files)) old_output_dir = os.path.dirname(first_file) output_beauty = RenderSettings().get_render_output(instance.name, old_output_dir) rgb_bname = os.path.basename(output_beauty) dir = os.path.dirname(first_file) beauty_name = f"{dir}/{rgb_bname}" beauty_name = beauty_name.replace("\\", "/") plugin_data["RenderOutput"] = beauty_name # as 3dsmax has version with different languages plugin_data["Language"] = "ENU" renderer_class = get_current_renderer() renderer = str(renderer_class).split(":")[0] if renderer in [ "ART_Renderer", "Redshift_Renderer", "V_Ray_6_Hotfix_3", "V_Ray_GPU_6_Hotfix_3", "Default_Scanline_Renderer", "Quicksilver_Hardware_Renderer", ]: render_elem_list = RenderSettings().get_render_element() for i, element in enumerate(render_elem_list): elem_bname = os.path.basename(element) new_elem = f"{dir}/{elem_bname}" new_elem = new_elem.replace("/", "\\") plugin_data["RenderElementOutputFilename%d" % i] = new_elem # noqa if renderer == "Redshift_Renderer": plugin_data["redshift_SeparateAovFiles"] = instance.data.get( "separateAovFiles") if instance.data["cameras"]: camera = instance.data["cameras"][0] plugin_info["Camera0"] = camera plugin_info["Camera"] = camera plugin_info["Camera1"] = camera self.log.debug("plugin data:{}".format(plugin_data)) plugin_info.update(plugin_data) return job_info, plugin_info def get_job_info_through_camera(self, camera): """Get the job parameters for deadline submission when multi-camera is enabled. Args: infos(dict): a dictionary with job info. """ instance = self._instance context = instance.context job_info = copy.deepcopy(self.job_info) exp = instance.data.get("expectedFiles") src_filepath = context.data["currentFile"] src_filename = os.path.basename(src_filepath) job_info.Name = "%s - %s - %s" % ( src_filename, instance.name, camera) for filepath in self._iter_expected_files(exp): if camera not in filepath: continue job_info.OutputDirectory += os.path.dirname(filepath) job_info.OutputFilename += os.path.basename(filepath) return job_info # set the output filepath with the relative camera def get_plugin_info_through_camera(self, camera): """Get the plugin parameters for deadline submission when multi-camera is enabled. Args: infos(dict): a dictionary with plugin info. """ from openpype.hosts.max.api.lib import get_current_renderer from openpype.hosts.max.api.lib_rendersettings import RenderSettings instance = self._instance # set the target camera plugin_info = copy.deepcopy(self.plugin_info) plugin_data = {} # set the output filepath with the relative camera if instance.data.get("multiCamera"): scene_filepath = instance.context.data["currentFile"] scene_filename = os.path.basename(scene_filepath) scene_directory = os.path.dirname(scene_filepath) current_filename, ext = os.path.splitext(scene_filename) camera_scene_name = f"{current_filename}_{camera}{ext}" camera_scene_filepath = os.path.join( scene_directory, f"_{current_filename}", camera_scene_name) plugin_data["SceneFile"] = camera_scene_filepath files = instance.data.get("expectedFiles") if not files: raise KnownPublishError("No render elements found") first_file = next(self._iter_expected_files(files)) old_output_dir = os.path.dirname(first_file) rgb_output = RenderSettings().get_batch_render_output(camera) # noqa rgb_bname = os.path.basename(rgb_output) dir = os.path.dirname(first_file) beauty_name = f"{dir}/{rgb_bname}" beauty_name = beauty_name.replace("\\", "/") plugin_info["RenderOutput"] = beauty_name renderer_class = get_current_renderer() renderer = str(renderer_class).split(":")[0] if renderer in [ "ART_Renderer", "Redshift_Renderer", "V_Ray_6_Hotfix_3", "V_Ray_GPU_6_Hotfix_3", "Default_Scanline_Renderer", "Quicksilver_Hardware_Renderer", ]: render_elem_list = RenderSettings().get_batch_render_elements( instance.name, old_output_dir, camera ) for i, element in enumerate(render_elem_list): if camera in element: elem_bname = os.path.basename(element) new_elem = f"{dir}/{elem_bname}" new_elem = new_elem.replace("/", "\\") plugin_info["RenderElementOutputFilename%d" % i] = new_elem # noqa if camera: # set the default camera and target camera # (weird parameters from max) plugin_data["Camera"] = camera plugin_data["Camera1"] = camera plugin_data["Camera0"] = None plugin_info.update(plugin_data) return plugin_info def _use_published_name_for_multiples(self, data, project_settings): """Process the parameters submission for deadline when user enables multi-cameras option. Args: job_info_list (list): A list of multiple job infos plugin_info_list (list): A list of multiple plugin infos """ from openpype.hosts.max.api.lib import get_multipass_setting job_info_list = [] plugin_info_list = [] instance = self._instance cameras = instance.data.get("cameras", []) plugin_data = {} multipass = get_multipass_setting(project_settings) if multipass: plugin_data["DisableMultipass"] = 0 else: plugin_data["DisableMultipass"] = 1 for cam in cameras: job_info = self.get_job_info_through_camera(cam) plugin_info = self.get_plugin_info_through_camera(cam) plugin_info.update(plugin_data) job_info_list.append(job_info) plugin_info_list.append(plugin_info) return job_info_list, plugin_info_list def from_published_scene(self, replace_in_path=True): instance = self._instance if instance.data["renderer"] == "Redshift_Renderer": self.log.debug("Using Redshift...published scene wont be used..") replace_in_path = False return replace_with_published_scene_path( instance, replace_in_path) @staticmethod def _iter_expected_files(exp): if isinstance(exp[0], dict): for _aov, files in exp[0].items(): for file in files: yield file else: for file in exp: yield file @classmethod def get_attribute_defs(cls): defs = super(MaxSubmitDeadline, cls).get_attribute_defs() defs.extend([ BoolDef("use_published", default=cls.use_published, label="Use Published Scene"), NumberDef("priority", minimum=1, maximum=250, decimals=0, default=cls.priority, label="Priority"), NumberDef("chunkSize", minimum=1, maximum=50, decimals=0, default=cls.chunk_size, label="Frame Per Task"), TextDef("group", default=cls.group, label="Group Name"), ]) return defs ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_maya_deadline.py ================================================ # -*- coding: utf-8 -*- """Submitting render job to Deadline. This module is taking care of submitting job from Maya to Deadline. It creates job and set correct environments. Its behavior is controlled by ``DEADLINE_REST_URL`` environment variable - pointing to Deadline Web Service and :data:`MayaSubmitDeadline.use_published` property telling Deadline to use published scene workfile or not. If ``vrscene`` or ``assscene`` are detected in families, it will first submit job to export these files and then dependent job to render them. Attributes: payload_skeleton (dict): Skeleton payload data sent as job to Deadline. Default values are for ``MayaBatch`` plugin. """ from __future__ import print_function import os import getpass import copy import re import hashlib from datetime import datetime import itertools from collections import OrderedDict import attr from openpype.pipeline import ( legacy_io, OpenPypePyblishPluginMixin ) from openpype.lib import ( BoolDef, NumberDef, TextDef, EnumDef ) from openpype.hosts.maya.api.lib_rendersettings import RenderSettings from openpype.hosts.maya.api.lib import get_attr_in_layer from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype.tests.lib import is_in_tests from openpype.lib import is_running_from_build from openpype.pipeline.farm.tools import iter_expected_files def _validate_deadline_bool_value(instance, attribute, value): if not isinstance(value, (str, bool)): raise TypeError( "Attribute {} must be str or bool.".format(attribute)) if value not in {"1", "0", True, False}: raise ValueError( ("Value of {} must be one of " "'0', '1', True, False").format(attribute) ) @attr.s class MayaPluginInfo(object): SceneFile = attr.ib(default=None) # Input OutputFilePath = attr.ib(default=None) # Output directory and filename OutputFilePrefix = attr.ib(default=None) Version = attr.ib(default=None) # Mandatory for Deadline UsingRenderLayers = attr.ib(default=True) RenderLayer = attr.ib(default=None) # Render only this layer Renderer = attr.ib(default=None) ProjectPath = attr.ib(default=None) # Resolve relative references # Include all lights flag RenderSetupIncludeLights = attr.ib( default="1", validator=_validate_deadline_bool_value) StrictErrorChecking = attr.ib(default=True) @attr.s class PythonPluginInfo(object): ScriptFile = attr.ib() Version = attr.ib(default="3.6") Arguments = attr.ib(default=None) SingleFrameOnly = attr.ib(default=None) @attr.s class VRayPluginInfo(object): InputFilename = attr.ib(default=None) # Input SeparateFilesPerFrame = attr.ib(default=None) VRayEngine = attr.ib(default="V-Ray") Width = attr.ib(default=None) Height = attr.ib(default=None) # Mandatory for Deadline OutputFilePath = attr.ib(default=True) OutputFileName = attr.ib(default=None) # Render only this layer @attr.s class ArnoldPluginInfo(object): ArnoldFile = attr.ib(default=None) class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, OpenPypePyblishPluginMixin): label = "Submit Render to Deadline" hosts = ["maya"] families = ["renderlayer"] targets = ["local"] tile_assembler_plugin = "OpenPypeTileAssembler" priority = 50 tile_priority = 50 limit = [] # limit groups jobInfo = {} pluginInfo = {} group = "none" strict_error_checking = True @classmethod def apply_settings(cls, project_settings, system_settings): settings = project_settings["deadline"]["publish"]["MayaSubmitDeadline"] # noqa # Take some defaults from settings cls.asset_dependencies = settings.get("asset_dependencies", cls.asset_dependencies) cls.import_reference = settings.get("import_reference", cls.import_reference) cls.use_published = settings.get("use_published", cls.use_published) cls.priority = settings.get("priority", cls.priority) cls.tile_priority = settings.get("tile_priority", cls.tile_priority) cls.limit = settings.get("limit", cls.limit) cls.group = settings.get("group", cls.group) cls.strict_error_checking = settings.get("strict_error_checking", cls.strict_error_checking) cls.jobInfo = settings.get("jobInfo", cls.jobInfo) cls.pluginInfo = settings.get("pluginInfo", cls.pluginInfo) def get_job_info(self): job_info = DeadlineJobInfo(Plugin="MayaBatch") # todo: test whether this works for existing production cases # where custom jobInfo was stored in the project settings job_info.update(self.jobInfo) instance = self._instance context = instance.context # Always use the original work file name for the Job name even when # rendering is done from the published Work File. The original work # file name is clearer because it can also have subversion strings, # etc. which are stripped for the published file. src_filepath = context.data["currentFile"] src_filename = os.path.basename(src_filepath) if is_in_tests(): src_filename += datetime.now().strftime("%d%m%Y%H%M%S") job_info.Name = "%s - %s" % (src_filename, instance.name) job_info.BatchName = src_filename job_info.Plugin = instance.data.get("mayaRenderPlugin", "MayaBatch") job_info.UserName = context.data.get("deadlineUser", getpass.getuser()) # Deadline requires integers in frame range frames = "{start}-{end}x{step}".format( start=int(instance.data["frameStartHandle"]), end=int(instance.data["frameEndHandle"]), step=int(instance.data["byFrameStep"]), ) job_info.Frames = frames job_info.Pool = instance.data.get("primaryPool") job_info.SecondaryPool = instance.data.get("secondaryPool") job_info.Comment = context.data.get("comment") job_info.Priority = instance.data.get("priority", self.priority) if self.group != "none" and self.group: job_info.Group = self.group if self.limit: job_info.LimitGroups = ",".join(self.limit) attr_values = self.get_attr_values_from_data(instance.data) render_globals = instance.data.setdefault("renderGlobals", dict()) machine_list = attr_values.get("machineList", "") if machine_list: if attr_values.get("whitelist", True): machine_list_key = "Whitelist" else: machine_list_key = "Blacklist" render_globals[machine_list_key] = machine_list job_info.Priority = attr_values.get("priority") job_info.ChunkSize = attr_values.get("chunkSize") # Add options from RenderGlobals render_globals = instance.data.get("renderGlobals", {}) job_info.update(render_globals) keys = [ "FTRACK_API_KEY", "FTRACK_API_USER", "FTRACK_SERVER", "OPENPYPE_SG_USER", "AVALON_DB", "AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK", "AVALON_APP_NAME", "OPENPYPE_DEV" "IS_TEST" ] # Add OpenPype version if we are running from build. if is_running_from_build(): keys.append("OPENPYPE_VERSION") # Add mongo url if it's enabled if self._instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) for key in keys: value = environment.get(key) if not value: continue job_info.EnvironmentKeyValue[key] = value # to recognize render jobs job_info.add_render_job_env_var() job_info.EnvironmentKeyValue["OPENPYPE_LOG_NO_COLORS"] = "1" # Adding file dependencies. if not bool(os.environ.get("IS_TEST")) and self.asset_dependencies: dependencies = instance.context.data["fileDependencies"] for dependency in dependencies: job_info.AssetDependency += dependency # Add list of expected files to job # --------------------------------- exp = instance.data.get("expectedFiles") for filepath in iter_expected_files(exp): job_info.OutputDirectory += os.path.dirname(filepath) job_info.OutputFilename += os.path.basename(filepath) return job_info def get_plugin_info(self): # Not all hosts can import this module. from maya import cmds instance = self._instance context = instance.context # Set it to default Maya behaviour if it cannot be determined # from instance (but it should be, by the Collector). default_rs_include_lights = ( instance.context.data['project_settings'] ['maya'] ['RenderSettings'] ['enable_all_lights'] ) rs_include_lights = instance.data.get( "renderSetupIncludeLights", default_rs_include_lights) if rs_include_lights not in {"1", "0", True, False}: rs_include_lights = default_rs_include_lights attr_values = self.get_attr_values_from_data(instance.data) strict_error_checking = attr_values.get("strict_error_checking", self.strict_error_checking) plugin_info = MayaPluginInfo( SceneFile=self.scene_path, Version=cmds.about(version=True), RenderLayer=instance.data['setMembers'], Renderer=instance.data["renderer"], RenderSetupIncludeLights=rs_include_lights, # noqa ProjectPath=context.data["workspaceDir"], UsingRenderLayers=True, StrictErrorChecking=strict_error_checking ) plugin_payload = attr.asdict(plugin_info) # Patching with pluginInfo from settings for key, value in self.pluginInfo.items(): plugin_payload[key] = value return plugin_payload def process_submission(self): from maya import cmds instance = self._instance filepath = self.scene_path # publish if `use_publish` else workfile # TODO: Avoid the need for this logic here, needed for submit publish # Store output dir for unified publisher (filesequence) expected_files = instance.data["expectedFiles"] first_file = next(iter_expected_files(expected_files)) output_dir = os.path.dirname(first_file) instance.data["outputDir"] = output_dir # Patch workfile (only when use_published is enabled) if self.use_published: self._patch_workfile() # Gather needed data ------------------------------------------------ filename = os.path.basename(filepath) dirname = os.path.join( cmds.workspace(query=True, rootDirectory=True), cmds.workspace(fileRuleEntry="images") ) # Fill in common data to payload ------------------------------------ # TODO: Replace these with collected data from CollectRender payload_data = { "filename": filename, "dirname": dirname, } # Submit preceding export jobs ------------------------------------- export_job = None assert not all(x in instance.data["families"] for x in ['vrayscene', 'assscene']), ( "Vray Scene and Ass Scene options are mutually exclusive") if "vrayscene" in instance.data["families"]: self.log.debug("Submitting V-Ray scene render..") vray_export_payload = self._get_vray_export_payload(payload_data) export_job = self.submit(vray_export_payload) payload = self._get_vray_render_payload(payload_data) else: self.log.debug("Submitting MayaBatch render..") payload = self._get_maya_payload(payload_data) # Add export job as dependency -------------------------------------- if export_job: job_info, _ = payload job_info.JobDependencies = export_job if instance.data.get("tileRendering"): # Prepare tiles data self._tile_render(payload) else: # Submit main render job job_info, plugin_info = payload self.submit(self.assemble_payload(job_info, plugin_info)) def _tile_render(self, payload): """Submit as tile render per frame with dependent assembly jobs.""" # As collected by super process() instance = self._instance payload_job_info, payload_plugin_info = payload job_info = copy.deepcopy(payload_job_info) plugin_info = copy.deepcopy(payload_plugin_info) # Force plugin reload for vray cause the region does not get flushed # between tile renders. if plugin_info["Renderer"] == "vray": job_info.ForceReloadPlugin = True # if we have sequence of files, we need to create tile job for # every frame job_info.TileJob = True job_info.TileJobTilesInX = instance.data.get("tilesX") job_info.TileJobTilesInY = instance.data.get("tilesY") tiles_count = job_info.TileJobTilesInX * job_info.TileJobTilesInY plugin_info["ImageHeight"] = instance.data.get("resolutionHeight") plugin_info["ImageWidth"] = instance.data.get("resolutionWidth") plugin_info["RegionRendering"] = True R_FRAME_NUMBER = re.compile( r".+\.(?P[0-9]+)\..+") # noqa: N806, E501 REPL_FRAME_NUMBER = re.compile( r"(.+\.)([0-9]+)(\..+)") # noqa: N806, E501 exp = instance.data["expectedFiles"] if isinstance(exp[0], dict): # we have aovs and we need to iterate over them # get files from `beauty` files = exp[0].get("beauty") # assembly files are used for assembly jobs as we need to put # together all AOVs assembly_files = list( itertools.chain.from_iterable( [f for _, f in exp[0].items()])) if not files: # if beauty doesn't exist, use first aov we found files = exp[0].get(list(exp[0].keys())[0]) else: files = exp assembly_files = files # Define frame tile jobs frame_file_hash = {} frame_payloads = {} file_index = 1 for file in files: frame = re.search(R_FRAME_NUMBER, file).group("frame") new_job_info = copy.deepcopy(job_info) new_job_info.Name += " (Frame {} - {} tiles)".format(frame, tiles_count) new_job_info.TileJobFrame = frame new_plugin_info = copy.deepcopy(plugin_info) # Add tile data into job info and plugin info tiles_data = _format_tiles( file, 0, instance.data.get("tilesX"), instance.data.get("tilesY"), instance.data.get("resolutionWidth"), instance.data.get("resolutionHeight"), payload_plugin_info["OutputFilePrefix"] )[0] new_job_info.update(tiles_data["JobInfo"]) new_plugin_info.update(tiles_data["PluginInfo"]) self.log.debug("hashing {} - {}".format(file_index, file)) job_hash = hashlib.sha256( ("{}_{}".format(file_index, file)).encode("utf-8")) file_hash = job_hash.hexdigest() frame_file_hash[frame] = file_hash new_job_info.ExtraInfo[0] = file_hash new_job_info.ExtraInfo[1] = file frame_payloads[frame] = self.assemble_payload( job_info=new_job_info, plugin_info=new_plugin_info ) file_index += 1 self.log.debug( "Submitting tile job(s) [{}] ...".format(len(frame_payloads))) # Submit frame tile jobs frame_tile_job_id = {} for frame, tile_job_payload in frame_payloads.items(): job_id = self.submit(tile_job_payload) frame_tile_job_id[frame] = job_id # Define assembly payloads assembly_job_info = copy.deepcopy(job_info) assembly_job_info.Plugin = self.tile_assembler_plugin assembly_job_info.Name += " - Tile Assembly Job" assembly_job_info.Frames = 1 assembly_job_info.MachineLimit = 1 attr_values = self.get_attr_values_from_data(instance.data) assembly_job_info.Priority = attr_values.get("tile_priority", self.tile_priority) assembly_job_info.TileJob = False # TODO: This should be a new publisher attribute definition pool = instance.context.data["project_settings"]["deadline"] pool = pool["publish"]["ProcessSubmittedJobOnFarm"]["deadline_pool"] assembly_job_info.Pool = pool or instance.data.get("primaryPool", "") assembly_plugin_info = { "CleanupTiles": 1, "ErrorOnMissing": True, "Renderer": self._instance.data["renderer"] } assembly_payloads = [] output_dir = self.job_info.OutputDirectory[0] config_files = [] for file in assembly_files: frame = re.search(R_FRAME_NUMBER, file).group("frame") frame_assembly_job_info = copy.deepcopy(assembly_job_info) frame_assembly_job_info.Name += " (Frame {})".format(frame) frame_assembly_job_info.OutputFilename[0] = re.sub( REPL_FRAME_NUMBER, "\\1{}\\3".format("#" * len(frame)), file) file_hash = frame_file_hash[frame] tile_job_id = frame_tile_job_id[frame] frame_assembly_job_info.ExtraInfo[0] = file_hash frame_assembly_job_info.ExtraInfo[1] = file frame_assembly_job_info.JobDependencies = tile_job_id frame_assembly_job_info.Frames = frame # write assembly job config files config_file = os.path.join( output_dir, "{}_config_{}.txt".format( os.path.splitext(file)[0], datetime.now().strftime("%Y_%m_%d_%H_%M_%S") ) ) config_files.append(config_file) try: if not os.path.isdir(output_dir): os.makedirs(output_dir) except OSError: # directory is not available self.log.warning("Path is unreachable: " "`{}`".format(output_dir)) with open(config_file, "w") as cf: print("TileCount={}".format(tiles_count), file=cf) print("ImageFileName={}".format(file), file=cf) print("ImageWidth={}".format( instance.data.get("resolutionWidth")), file=cf) print("ImageHeight={}".format( instance.data.get("resolutionHeight")), file=cf) reversed_y = False if plugin_info["Renderer"] == "arnold": reversed_y = True with open(config_file, "a") as cf: # Need to reverse the order of the y tiles, because image # coordinates are calculated from bottom left corner. tiles = _format_tiles( file, 0, instance.data.get("tilesX"), instance.data.get("tilesY"), instance.data.get("resolutionWidth"), instance.data.get("resolutionHeight"), payload_plugin_info["OutputFilePrefix"], reversed_y=reversed_y )[1] for k, v in sorted(tiles.items()): print("{}={}".format(k, v), file=cf) assembly_payloads.append( self.assemble_payload( job_info=frame_assembly_job_info, plugin_info=assembly_plugin_info.copy(), # This would fail if the client machine and webserice are # using different storage paths. aux_files=[config_file] ) ) # Submit assembly jobs assembly_job_ids = [] num_assemblies = len(assembly_payloads) for i, payload in enumerate(assembly_payloads): self.log.debug( "submitting assembly job {} of {}".format(i + 1, num_assemblies) ) assembly_job_id = self.submit(payload) assembly_job_ids.append(assembly_job_id) instance.data["assemblySubmissionJobs"] = assembly_job_ids # Remove config files to avoid confusion about where data is coming # from in Deadline. for config_file in config_files: os.remove(config_file) def _get_maya_payload(self, data): job_info = copy.deepcopy(self.job_info) if not bool(os.environ.get("IS_TEST")) and self.asset_dependencies: # Asset dependency to wait for at least the scene file to sync. job_info.AssetDependency += self.scene_path # Get layer prefix renderlayer = self._instance.data["setMembers"] renderer = self._instance.data["renderer"] layer_prefix_attr = RenderSettings.get_image_prefix_attr(renderer) layer_prefix = get_attr_in_layer(layer_prefix_attr, layer=renderlayer) plugin_info = copy.deepcopy(self.plugin_info) plugin_info.update({ # Output directory and filename "OutputFilePath": data["dirname"].replace("\\", "/"), "OutputFilePrefix": layer_prefix, }) # This hack is here because of how Deadline handles Renderman version. # it considers everything with `renderman` set as version older than # Renderman 22, and so if we are using renderman > 21 we need to set # renderer string on the job to `renderman22`. We will have to change # this when Deadline releases new version handling this. renderer = self._instance.data["renderer"] if renderer == "renderman": try: from rfm2.config import cfg # noqa except ImportError: raise Exception("Cannot determine renderman version") rman_version = cfg().build_info.version() # type: str if int(rman_version.split(".")[0]) > 22: renderer = "renderman22" plugin_info["Renderer"] = renderer # this is needed because renderman plugin in Deadline # handles directory and file prefixes separately plugin_info["OutputFilePath"] = job_info.OutputDirectory[0] return job_info, plugin_info def _get_vray_export_payload(self, data): job_info = copy.deepcopy(self.job_info) job_info.Name = self._job_info_label("Export") # Get V-Ray settings info to compute output path vray_scene = self.format_vray_output_filename() plugin_info = { "Renderer": "vray", "SkipExistingFrames": True, "UseLegacyRenderLayers": True, "OutputFilePath": os.path.dirname(vray_scene) } return job_info, attr.asdict(plugin_info) def _get_vray_render_payload(self, data): # Job Info job_info = copy.deepcopy(self.job_info) job_info.Name = self._job_info_label("Render") job_info.Plugin = "Vray" job_info.OverrideTaskExtraInfoNames = False # Plugin Info plugin_info = VRayPluginInfo( InputFilename=self.format_vray_output_filename(), SeparateFilesPerFrame=False, VRayEngine="V-Ray", Width=self._instance.data["resolutionWidth"], Height=self._instance.data["resolutionHeight"], OutputFilePath=job_info.OutputDirectory[0], OutputFileName=job_info.OutputFilename[0] ) return job_info, attr.asdict(plugin_info) def _get_arnold_render_payload(self, data): from maya import cmds # Job Info job_info = copy.deepcopy(self.job_info) job_info.Name = self._job_info_label("Render") job_info.Plugin = "Arnold" job_info.OverrideTaskExtraInfoNames = False # Plugin Info ass_file, _ = os.path.splitext(data["output_filename_0"]) ass_filepath = ass_file + ".ass" plugin_info = ArnoldPluginInfo( ArnoldFile=ass_filepath ) return job_info, attr.asdict(plugin_info) def format_vray_output_filename(self): """Format the expected output file of the Export job. Example: /_/ "shot010_v006/shot010_v006_CHARS/CHARS_0001.vrscene" Returns: str """ from maya import cmds # "vrayscene//_/" vray_settings = cmds.ls(type="VRaySettingsNode") node = vray_settings[0] template = cmds.getAttr("{}.vrscene_filename".format(node)) scene, _ = os.path.splitext(self.scene_path) def smart_replace(string, key_values): new_string = string for key, value in key_values.items(): new_string = new_string.replace(key, value) return new_string # Get workfile scene path without extension to format vrscene_filename scene_filename = os.path.basename(self.scene_path) scene_filename_no_ext, _ = os.path.splitext(scene_filename) layer = self._instance.data['setMembers'] # Reformat without tokens output_path = smart_replace( template, {"": scene_filename_no_ext, "": layer}) start_frame = int(self._instance.data["frameStartHandle"]) workspace = self._instance.context.data["workspace"] filename_zero = "{}_{:04d}.vrscene".format(output_path, start_frame) filepath_zero = os.path.join(workspace, filename_zero) return filepath_zero.replace("\\", "/") def _patch_workfile(self): """Patch Maya scene. This will take list of patches (lines to add) and apply them to *published* Maya scene file (that is used later for rendering). Patches are dict with following structure:: { "name": "Name of patch", "regex": "regex of line before patch", "line": "line to insert" } """ project_settings = self._instance.context.data["project_settings"] patches = ( project_settings.get( "deadline", {}).get( "publish", {}).get( "MayaSubmitDeadline", {}).get( "scene_patches", {}) ) if not patches: return if not os.path.splitext(self.scene_path)[1].lower() != ".ma": self.log.debug("Skipping workfile patch since workfile is not " ".ma file") return compiled_regex = [re.compile(p["regex"]) for p in patches] with open(self.scene_path, "r+") as pf: scene_data = pf.readlines() for ln, line in enumerate(scene_data): for i, r in enumerate(compiled_regex): if re.match(r, line): scene_data.insert(ln + 1, patches[i]["line"]) pf.seek(0) pf.writelines(scene_data) pf.truncate() self.log.info("Applied {} patch to scene.".format( patches[i]["name"] )) def _job_info_label(self, label): return "{label} {job.Name} [{start}-{end}]".format( label=label, job=self.job_info, start=int(self._instance.data["frameStartHandle"]), end=int(self._instance.data["frameEndHandle"]), ) @classmethod def get_attribute_defs(cls): defs = super(MayaSubmitDeadline, cls).get_attribute_defs() defs.extend([ NumberDef("priority", label="Priority", default=cls.default_priority, decimals=0), NumberDef("chunkSize", label="Frames Per Task", default=1, decimals=0, minimum=1, maximum=1000), TextDef("machineList", label="Machine List", default="", placeholder="machine1,machine2"), EnumDef("whitelist", label="Machine List (Allow/Deny)", items={ True: "Allow List", False: "Deny List", }, default=False), NumberDef("tile_priority", label="Tile Assembler Priority", decimals=0, default=cls.tile_priority), BoolDef("strict_error_checking", label="Strict Error Checking", default=cls.strict_error_checking), ]) return defs def _format_tiles( filename, index, tiles_x, tiles_y, width, height, prefix, reversed_y=False ): """Generate tile entries for Deadline tile job. Returns two dictionaries - one that can be directly used in Deadline job, second that can be used for Deadline Assembly job configuration file. This will format tile names: Example:: { "OutputFilename0Tile0": "_tile_1x1_4x4_Main_beauty.1001.exr", "OutputFilename0Tile1": "_tile_2x1_4x4_Main_beauty.1001.exr" } And add tile prefixes like: Example:: Image prefix is: `//_` Result for tile 0 for 4x4 will be: `//_tile_1x1_4x4__` Calculating coordinates is tricky as in Job they are defined as top, left, bottom, right with zero being in top-left corner. But Assembler configuration file takes tile coordinates as X, Y, Width and Height and zero is bottom left corner. Args: filename (str): Filename to process as tiles. index (int): Index of that file if it is sequence. tiles_x (int): Number of tiles in X. tiles_y (int): Number of tiles in Y. width (int): Width resolution of final image. height (int): Height resolution of final image. prefix (str): Image prefix. reversed_y (bool): Reverses the order of the y tiles. Returns: (dict, dict): Tuple of two dictionaries - first can be used to extend JobInfo, second has tiles x, y, width and height used for assembler configuration. """ # Math used requires integers for correct output - as such # we ensure our inputs are correct. assert type(tiles_x) is int, "tiles_x must be an integer" assert type(tiles_y) is int, "tiles_y must be an integer" assert type(width) is int, "width must be an integer" assert type(height) is int, "height must be an integer" out = {"JobInfo": {}, "PluginInfo": {}} cfg = OrderedDict() w_space = width // tiles_x h_space = height // tiles_y cfg["TilesCropped"] = "False" tile = 0 range_y = range(1, tiles_y + 1) reversed_y_range = list(reversed(range_y)) for tile_x in range(1, tiles_x + 1): for i, tile_y in enumerate(range_y): tile_y_index = tile_y if reversed_y: tile_y_index = reversed_y_range[i] tile_prefix = "_tile_{}x{}_{}x{}_".format( tile_x, tile_y_index, tiles_x, tiles_y ) new_filename = "{}/{}{}".format( os.path.dirname(filename), tile_prefix, os.path.basename(filename) ) top = height - (tile_y * h_space) bottom = height - ((tile_y - 1) * h_space) - 1 left = (tile_x - 1) * w_space right = (tile_x * w_space) - 1 # Job info key = "OutputFilename{}".format(index) out["JobInfo"][key] = new_filename # Plugin Info key = "RegionPrefix{}".format(str(tile)) out["PluginInfo"][key] = "/{}".format( tile_prefix ).join(prefix.rsplit("/", 1)) out["PluginInfo"]["RegionTop{}".format(tile)] = top out["PluginInfo"]["RegionBottom{}".format(tile)] = bottom out["PluginInfo"]["RegionLeft{}".format(tile)] = left out["PluginInfo"]["RegionRight{}".format(tile)] = right # Tile config cfg["Tile{}FileName".format(tile)] = new_filename cfg["Tile{}X".format(tile)] = left cfg["Tile{}Y".format(tile)] = top cfg["Tile{}Width".format(tile)] = w_space cfg["Tile{}Height".format(tile)] = h_space tile += 1 return out, cfg ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py ================================================ import os import attr from datetime import datetime from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io, PublishXmlValidationError from openpype.tests.lib import is_in_tests from openpype.lib import is_running_from_build from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo import pyblish.api @attr.s class MayaPluginInfo(object): Build = attr.ib(default=None) # Don't force build StrictErrorChecking = attr.ib(default=True) SceneFile = attr.ib(default=None) # Input scene Version = attr.ib(default=None) # Mandatory for Deadline ProjectPath = attr.ib(default=None) ScriptJob = attr.ib(default=True) ScriptFilename = attr.ib(default=None) class MayaSubmitRemotePublishDeadline( abstract_submit_deadline.AbstractSubmitDeadline): """Submit Maya scene to perform a local publish in Deadline. Publishing in Deadline can be helpful for scenes that publish very slow. This way it can process in the background on another machine without the Artist having to wait for the publish to finish on their local machine. Submission is done through the Deadline Web Service. DL then triggers `openpype/scripts/remote_publish.py`. Each publishable instance creates its own full publish job. Different from `ProcessSubmittedJobOnFarm` which creates publish job depending on metadata json containing context and instance data of rendered files. """ label = "Submit Scene to Deadline" order = pyblish.api.IntegratorOrder hosts = ["maya"] families = ["publish.farm"] targets = ["local"] def process(self, instance): # Ensure no errors so far if not (all(result["success"] for result in instance.context.data["results"])): raise PublishXmlValidationError("Publish process has errors") if not instance.data["publish"]: self.log.warning("No active instances found. " "Skipping submission..") return super(MayaSubmitRemotePublishDeadline, self).process(instance) def get_job_info(self): instance = self._instance context = instance.context project_name = instance.context.data["projectName"] scene = instance.context.data["currentFile"] scenename = os.path.basename(scene) job_name = "{scene} [PUBLISH]".format(scene=scenename) batch_name = "{code} - {scene}".format(code=project_name, scene=scenename) if is_in_tests(): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") job_info = DeadlineJobInfo(Plugin="MayaBatch") job_info.BatchName = batch_name job_info.Name = job_name job_info.UserName = context.data.get("user") job_info.Comment = context.data.get("comment", "") # use setting for publish job on farm, no reason to have it separately project_settings = context.data["project_settings"] deadline_publish_job_sett = project_settings["deadline"]["publish"]["ProcessSubmittedJobOnFarm"] # noqa job_info.Department = deadline_publish_job_sett["deadline_department"] job_info.ChunkSize = deadline_publish_job_sett["deadline_chunk_size"] job_info.Priority = deadline_publish_job_sett["deadline_priority"] job_info.Group = deadline_publish_job_sett["deadline_group"] job_info.Pool = deadline_publish_job_sett["deadline_pool"] # Include critical environment variables with submission + Session keys = [ "FTRACK_API_USER", "FTRACK_API_KEY", "FTRACK_SERVER" ] # Add OpenPype version if we are running from build. if is_running_from_build(): keys.append("OPENPYPE_VERSION") environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) # TODO replace legacy_io with context.data environment["AVALON_PROJECT"] = project_name environment["AVALON_ASSET"] = instance.context.data["asset"] environment["AVALON_TASK"] = instance.context.data["task"] environment["AVALON_APP_NAME"] = os.environ.get("AVALON_APP_NAME") environment["OPENPYPE_LOG_NO_COLORS"] = "1" environment["OPENPYPE_USERNAME"] = instance.context.data["user"] environment["OPENPYPE_PUBLISH_SUBSET"] = instance.data["subset"] environment["OPENPYPE_REMOTE_PUBLISH"] = "1" if AYON_SERVER_ENABLED: environment["AYON_REMOTE_PUBLISH"] = "1" else: environment["OPENPYPE_REMOTE_PUBLISH"] = "1" environment["AVALON_DB"] = os.environ.get("AVALON_DB") for key, value in environment.items(): job_info.EnvironmentKeyValue[key] = value def get_plugin_info(self): # Not all hosts can import this module. from maya import cmds scene = self._instance.context.data["currentFile"] plugin_info = MayaPluginInfo() plugin_info.SceneFile = scene plugin_info.ScriptFilename = "{OPENPYPE_REPOS_ROOT}/openpype/scripts/remote_publish.py" # noqa plugin_info.Version = cmds.about(version=True) plugin_info.ProjectPath = cmds.workspace(query=True, rootDirectory=True) return attr.asdict(plugin_info) ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py ================================================ import os import re import json import getpass from datetime import datetime import requests import pyblish.api from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( OpenPypePyblishPluginMixin ) from openpype.tests.lib import is_in_tests from openpype.lib import ( is_running_from_build, BoolDef, NumberDef ) class NukeSubmitDeadline(pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin): """Submit write to Deadline Renders are submitted to a Deadline Web Service as supplied via settings key "DEADLINE_REST_URL". """ label = "Submit Nuke to Deadline" order = pyblish.api.IntegratorOrder + 0.1 hosts = ["nuke"] families = ["render", "prerender"] optional = True targets = ["local"] # presets priority = 50 chunk_size = 1 concurrent_tasks = 1 group = "" department = "" limit_groups = {} use_gpu = False env_allowed_keys = [] env_search_replace_values = {} workfile_dependency = True use_published_workfile = True @classmethod def get_attribute_defs(cls): return [ NumberDef( "priority", label="Priority", default=cls.priority, decimals=0 ), NumberDef( "chunk", label="Frames Per Task", default=cls.chunk_size, decimals=0, minimum=1, maximum=1000 ), NumberDef( "concurrency", label="Concurrency", default=cls.concurrent_tasks, decimals=0, minimum=1, maximum=10 ), BoolDef( "use_gpu", default=cls.use_gpu, label="Use GPU" ), BoolDef( "suspend_publish", default=False, label="Suspend publish" ), BoolDef( "workfile_dependency", default=cls.workfile_dependency, label="Workfile Dependency" ), BoolDef( "use_published_workfile", default=cls.use_published_workfile, label="Use Published Workfile" ) ] def process(self, instance): if not instance.data.get("farm"): self.log.debug("Skipping local instance.") return instance.data["attributeValues"] = self.get_attr_values_from_data( instance.data) # add suspend_publish attributeValue to instance data instance.data["suspend_publish"] = instance.data["attributeValues"][ "suspend_publish"] families = instance.data["families"] node = instance.data["transientData"]["node"] context = instance.context # get default deadline webservice url from deadline module deadline_url = instance.context.data["defaultDeadline"] # if custom one is set in instance, use that if instance.data.get("deadlineUrl"): deadline_url = instance.data.get("deadlineUrl") assert deadline_url, "Requires Deadline Webservice URL" self.deadline_url = "{}/api/jobs".format(deadline_url) self._comment = context.data.get("comment", "") self._ver = re.search(r"\d+\.\d+", context.data.get("hostVersion")) self._deadline_user = context.data.get( "deadlineUser", getpass.getuser()) submit_frame_start = int(instance.data["frameStartHandle"]) submit_frame_end = int(instance.data["frameEndHandle"]) # get output path render_path = instance.data['path'] script_path = context.data["currentFile"] use_published_workfile = instance.data["attributeValues"].get( "use_published_workfile", self.use_published_workfile ) if use_published_workfile: script_path = self._get_published_workfile_path(context) # only add main rendering job if target is not frames_farm r_job_response_json = None if instance.data["render_target"] != "frames_farm": r_job_response = self.payload_submit( instance, script_path, render_path, node.name(), submit_frame_start, submit_frame_end ) r_job_response_json = r_job_response.json() instance.data["deadlineSubmissionJob"] = r_job_response_json # Store output dir for unified publisher (filesequence) instance.data["outputDir"] = os.path.dirname( render_path).replace("\\", "/") instance.data["publishJobState"] = "Suspended" if instance.data.get("bakingNukeScripts"): for baking_script in instance.data["bakingNukeScripts"]: render_path = baking_script["bakeRenderPath"] script_path = baking_script["bakeScriptPath"] exe_node_name = baking_script["bakeWriteNodeName"] b_job_response = self.payload_submit( instance, script_path, render_path, exe_node_name, submit_frame_start, submit_frame_end, r_job_response_json, baking_submission=True ) # Store output dir for unified publisher (filesequence) instance.data["deadlineSubmissionJob"] = b_job_response.json() instance.data["publishJobState"] = "Suspended" # add to list of job Id if not instance.data.get("bakingSubmissionJobs"): instance.data["bakingSubmissionJobs"] = [] instance.data["bakingSubmissionJobs"].append( b_job_response.json()["_id"]) # redefinition of families if "render" in instance.data["family"]: instance.data['family'] = 'write' families.insert(0, "render2d") elif "prerender" in instance.data["family"]: instance.data['family'] = 'write' families.insert(0, "prerender") instance.data["families"] = families def _get_published_workfile_path(self, context): """This method is temporary while the class is not inherited from AbstractSubmitDeadline""" for instance in context: if ( instance.data["family"] != "workfile" # Disabled instances won't be integrated or instance.data("publish") is False ): continue template_data = instance.data["anatomyData"] # Expect workfile instance has only one representation representation = instance.data["representations"][0] # Get workfile extension repre_file = representation["files"] self.log.info(repre_file) ext = os.path.splitext(repre_file)[1].lstrip(".") # Fill template data template_data["representation"] = representation["name"] template_data["ext"] = ext template_data["comment"] = None anatomy = context.data["anatomy"] # WARNING Hardcoded template name 'publish' > may not be used template_obj = anatomy.templates_obj["publish"]["path"] template_filled = template_obj.format(template_data) script_path = os.path.normpath(template_filled) self.log.info( "Using published scene for render {}".format( script_path ) ) return script_path return None def payload_submit( self, instance, script_path, render_path, exe_node_name, start_frame, end_frame, response_data=None, baking_submission=False, ): """Submit payload to Deadline Args: instance (pyblish.api.Instance): pyblish instance script_path (str): path to nuke script render_path (str): path to rendered images exe_node_name (str): name of the node to render start_frame (int): start frame end_frame (int): end frame response_data Optional[dict]: response data from previous submission baking_submission Optional[bool]: if it's baking submission Returns: requests.Response """ render_dir = os.path.normpath(os.path.dirname(render_path)) # batch name src_filepath = instance.context.data["currentFile"] batch_name = os.path.basename(src_filepath) job_name = os.path.basename(render_path) if is_in_tests(): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") output_filename_0 = self.preview_fname(render_path) if not response_data: response_data = {} try: # Ensure render folder exists os.makedirs(render_dir) except OSError: pass # resolve any limit groups limit_groups = self.get_limit_groups() self.log.debug("Limit groups: `{}`".format(limit_groups)) payload = { "JobInfo": { # Top-level group name "BatchName": batch_name, # Job name, as seen in Monitor "Name": job_name, # Arbitrary username, for visualisation in Monitor "UserName": self._deadline_user, "Priority": instance.data["attributeValues"].get( "priority", self.priority), "ChunkSize": instance.data["attributeValues"].get( "chunk", self.chunk_size), "ConcurrentTasks": instance.data["attributeValues"].get( "concurrency", self.concurrent_tasks ), "Department": self.department, "Pool": instance.data.get("primaryPool"), "SecondaryPool": instance.data.get("secondaryPool"), "Group": self.group, "Plugin": "Nuke", "Frames": "{start}-{end}".format( start=start_frame, end=end_frame ), "Comment": self._comment, # Optional, enable double-click to preview rendered # frames from Deadline Monitor "OutputFilename0": output_filename_0.replace("\\", "/"), # limiting groups "LimitGroups": ",".join(limit_groups) }, "PluginInfo": { # Input "SceneFile": script_path, # Output directory and filename "OutputFilePath": render_dir.replace("\\", "/"), # "OutputFilePrefix": render_variables["filename_prefix"], # Mandatory for Deadline "Version": self._ver.group(), # Resolve relative references "ProjectPath": script_path, "AWSAssetFile0": render_path, # using GPU by default "UseGpu": instance.data["attributeValues"].get( "use_gpu", self.use_gpu), # Only the specific write node is rendered. "WriteNode": exe_node_name }, # Mandatory for Deadline, may be empty "AuxFiles": [] } # Add workfile dependency. workfile_dependency = instance.data["attributeValues"].get( "workfile_dependency", self.workfile_dependency ) if workfile_dependency: payload["JobInfo"].update({"AssetDependency0": script_path}) # TODO: rewrite for baking with sequences if baking_submission: payload["JobInfo"].update({ "JobType": "Normal", "ChunkSize": 99999999 }) if response_data.get("_id"): payload["JobInfo"].update({ "BatchName": response_data["Props"]["Batch"], "JobDependency0": response_data["_id"], }) # Include critical environment variables with submission keys = [ "PYTHONPATH", "PATH", "AVALON_DB", "AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK", "AVALON_APP_NAME", "FTRACK_API_KEY", "FTRACK_API_USER", "FTRACK_SERVER", "PYBLISHPLUGINPATH", "NUKE_PATH", "TOOL_ENV", "FOUNDRY_LICENSE", "OPENPYPE_SG_USER", ] # Add OpenPype version if we are running from build. if is_running_from_build(): keys.append("OPENPYPE_VERSION") # Add mongo url if it's enabled if instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") # add allowed keys from preset if any if self.env_allowed_keys: keys += self.env_allowed_keys environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) # to recognize render jobs if AYON_SERVER_ENABLED: environment["AYON_BUNDLE_NAME"] = os.environ["AYON_BUNDLE_NAME"] render_job_label = "AYON_RENDER_JOB" else: render_job_label = "OPENPYPE_RENDER_JOB" environment[render_job_label] = "1" # finally search replace in values of any key if self.env_search_replace_values: for key, value in environment.items(): for _k, _v in self.env_search_replace_values.items(): environment[key] = value.replace(_k, _v) payload["JobInfo"].update({ "EnvironmentKeyValue%d" % index: "{key}={value}".format( key=key, value=environment[key] ) for index, key in enumerate(environment) }) plugin = payload["JobInfo"]["Plugin"] self.log.debug("using render plugin : {}".format(plugin)) self.log.debug("Submitting..") self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # adding expected files to instance.data self.expected_files( instance, render_path, start_frame, end_frame ) self.log.debug("__ expectedFiles: `{}`".format( instance.data["expectedFiles"])) response = requests.post(self.deadline_url, json=payload, timeout=10) if not response.ok: raise Exception(response.text) return response def preflight_check(self, instance): """Ensure the startFrame, endFrame and byFrameStep are integers""" for key in ("frameStart", "frameEnd"): value = instance.data[key] if int(value) == value: continue self.log.warning( "%f=%d was rounded off to nearest integer" % (value, int(value)) ) def preview_fname(self, path): """Return output file path with #### for padding. Deadline requires the path to be formatted with # in place of numbers. For example `/path/to/render.####.png` Args: path (str): path to rendered images Returns: str """ self.log.debug("_ path: `{}`".format(path)) if "%" in path: search_results = re.search(r"(%0)(\d)(d.)", path).groups() self.log.debug("_ search_results: `{}`".format(search_results)) return int(search_results[1]) if "#" in path: self.log.debug("_ path: `{}`".format(path)) return path def expected_files( self, instance, filepath, start_frame, end_frame ): """ Create expected files in instance data """ if not instance.data.get("expectedFiles"): instance.data["expectedFiles"] = [] dirname = os.path.dirname(filepath) file = os.path.basename(filepath) # since some files might be already tagged as publish_on_farm # we need to avoid adding them to expected files since those would be # duplicated into metadata.json file representations = instance.data.get("representations", []) # check if file is not in representations with publish_on_farm tag for repre in representations: # Skip if 'publish_on_farm' not available if "publish_on_farm" not in repre.get("tags", []): continue # in case where single file (video, image) is already in # representation file. Will be added to expected files via # submit_publish_job.py if file in repre.get("files", []): self.log.debug( "Skipping expected file: {}".format(filepath)) return # in case path is hashed sequence expression # (e.g. /path/to/file.####.png) if "#" in file: pparts = file.split("#") padding = "%0{}d".format(len(pparts) - 1) file = pparts[0] + padding + pparts[-1] # in case input path was single file (video or image) if "%" not in file: instance.data["expectedFiles"].append(filepath) return # shift start frame by 1 if slate is present if instance.data.get("slate"): start_frame -= 1 # add sequence files to expected files for i in range(start_frame, (end_frame + 1)): instance.data["expectedFiles"].append( os.path.join(dirname, (file % i)).replace("\\", "/")) def get_limit_groups(self): """Search for limit group nodes and return group name. Limit groups will be defined as pairs in Nuke deadline submitter presents where the key will be name of limit group and value will be a list of plugin's node class names. Thus, when a plugin uses more than one node, these will be captured and the triggered process will add the appropriate limit group to the payload jobinfo attributes. Returning: list: captured groups list """ # Not all hosts can import this module. import nuke captured_groups = [] for lg_name, list_node_class in self.limit_groups.items(): for node_class in list_node_class: for node in nuke.allNodes(recurseGroups=True): # ignore all nodes not member of defined class if node.Class() not in node_class: continue # ignore all disabled nodes if node["disable"].value(): continue # add group name if not already added if lg_name not in captured_groups: captured_groups.append(lg_name) return captured_groups ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_publish_cache_job.py ================================================ # -*- coding: utf-8 -*- """Submit publishing job to farm.""" import os import json import re from copy import deepcopy import requests import pyblish.api from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_last_version_by_subset_name, ) from openpype.pipeline import publish, legacy_io from openpype.lib import EnumDef, is_running_from_build from openpype.tests.lib import is_in_tests from openpype.pipeline.version_start import get_versioning_start from openpype.pipeline.farm.pyblish_functions import ( create_skeleton_instance_cache, create_instances_for_cache, attach_instances_to_subset, prepare_cache_representations, create_metadata_path ) class ProcessSubmittedCacheJobOnFarm(pyblish.api.InstancePlugin, publish.OpenPypePyblishPluginMixin, publish.ColormanagedPyblishPluginMixin): """Process Cache Job submitted on farm This is replicated version of submit publish job specifically for cache(s). These jobs are dependent on a deadline job submission prior to this plug-in. - In case of Deadline, it creates dependent job on farm publishing rendered image sequence. Options in instance.data: - deadlineSubmissionJob (dict, Required): The returned .json data from the job submission to deadline. - outputDir (str, Required): The output directory where the metadata file should be generated. It's assumed that this will also be final folder containing the output files. - ext (str, Optional): The extension (including `.`) that is required in the output filename to be picked up for image sequence publishing. - expectedFiles (list or dict): explained below """ label = "Submit cache jobs to Deadline" order = pyblish.api.IntegratorOrder + 0.2 icon = "tractor" targets = ["local"] hosts = ["houdini"] families = ["publish.hou"] environ_job_filter = [ "OPENPYPE_METADATA_FILE" ] environ_keys = [ "FTRACK_API_USER", "FTRACK_API_KEY", "FTRACK_SERVER", "AVALON_APP_NAME", "OPENPYPE_USERNAME", "OPENPYPE_SG_USER", "KITSU_LOGIN", "KITSU_PWD" ] # custom deadline attributes deadline_department = "" deadline_pool = "" deadline_pool_secondary = "" deadline_group = "" deadline_chunk_size = 1 deadline_priority = None # regex for finding frame number in string R_FRAME_NUMBER = re.compile(r'.+\.(?P[0-9]+)\..+') plugin_pype_version = "3.0" # script path for publish_filesequence.py publishing_script = None def _submit_deadline_post_job(self, instance, job): """Submit publish job to Deadline. Returns: (str): deadline_publish_job_id """ data = instance.data.copy() subset = data["subset"] job_name = "Publish - {subset}".format(subset=subset) anatomy = instance.context.data['anatomy'] # instance.data.get("subset") != instances[0]["subset"] # 'Main' vs 'renderMain' override_version = None instance_version = instance.data.get("version") # take this if exists if instance_version != 1: override_version = instance_version output_dir = self._get_publish_folder( anatomy, deepcopy(instance.data["anatomyData"]), instance.data.get("asset"), instance.data["subset"], instance.context, instance.data["family"], override_version ) # Transfer the environment from the original job to this dependent # job so they use the same environment metadata_path, rootless_metadata_path = \ create_metadata_path(instance, anatomy) environment = { "AVALON_PROJECT": instance.context.data["projectName"], "AVALON_ASSET": instance.context.data["asset"], "AVALON_TASK": instance.context.data["task"], "OPENPYPE_USERNAME": instance.context.data["user"], "OPENPYPE_LOG_NO_COLORS": "1", "IS_TEST": str(int(is_in_tests())) } if AYON_SERVER_ENABLED: environment["AYON_PUBLISH_JOB"] = "1" environment["AYON_RENDER_JOB"] = "0" environment["AYON_REMOTE_PUBLISH"] = "0" environment["AYON_BUNDLE_NAME"] = os.environ["AYON_BUNDLE_NAME"] deadline_plugin = "Ayon" else: environment["AVALON_DB"] = os.environ["AVALON_DB"] environment["OPENPYPE_PUBLISH_JOB"] = "1" environment["OPENPYPE_RENDER_JOB"] = "0" environment["OPENPYPE_REMOTE_PUBLISH"] = "0" deadline_plugin = "OpenPype" # Add OpenPype version if we are running from build. if is_running_from_build(): self.environ_keys.append("OPENPYPE_VERSION") # add environments from self.environ_keys for env_key in self.environ_keys: if os.getenv(env_key): environment[env_key] = os.environ[env_key] # pass environment keys from self.environ_job_filter job_environ = job["Props"].get("Env", {}) for env_j_key in self.environ_job_filter: if job_environ.get(env_j_key): environment[env_j_key] = job_environ[env_j_key] # Add mongo url if it's enabled if instance.context.data.get("deadlinePassMongoUrl"): mongo_url = os.environ.get("OPENPYPE_MONGO") if mongo_url: environment["OPENPYPE_MONGO"] = mongo_url priority = self.deadline_priority or instance.data.get("priority", 50) instance_settings = self.get_attr_values_from_data(instance.data) initial_status = instance_settings.get("publishJobState", "Active") # TODO: Remove this backwards compatibility of `suspend_publish` if instance.data.get("suspend_publish"): initial_status = "Suspended" args = [ "--headless", 'publish', '"{}"'.format(rootless_metadata_path), "--targets", "deadline", "--targets", "farm" ] if is_in_tests(): args.append("--automatic-tests") # Generate the payload for Deadline submission secondary_pool = ( self.deadline_pool_secondary or instance.data.get("secondaryPool") ) payload = { "JobInfo": { "Plugin": deadline_plugin, "BatchName": job["Props"]["Batch"], "Name": job_name, "UserName": job["Props"]["User"], "Comment": instance.context.data.get("comment", ""), "Department": self.deadline_department, "ChunkSize": self.deadline_chunk_size, "Priority": priority, "InitialStatus": initial_status, "Group": self.deadline_group, "Pool": self.deadline_pool or instance.data.get("primaryPool"), "SecondaryPool": secondary_pool, # ensure the outputdirectory with correct slashes "OutputDirectory0": output_dir.replace("\\", "/") }, "PluginInfo": { "Version": self.plugin_pype_version, "Arguments": " ".join(args), "SingleFrameOnly": "True", }, # Mandatory for Deadline, may be empty "AuxFiles": [], } if job.get("_id"): payload["JobInfo"]["JobDependency0"] = job["_id"] for index, (key_, value_) in enumerate(environment.items()): payload["JobInfo"].update( { "EnvironmentKeyValue%d" % index: "{key}={value}".format( key=key_, value=value_ ) } ) # remove secondary pool payload["JobInfo"].pop("SecondaryPool", None) self.log.debug("Submitting Deadline publish job ...") url = "{}/api/jobs".format(self.deadline_url) response = requests.post(url, json=payload, timeout=10) if not response.ok: raise Exception(response.text) deadline_publish_job_id = response.json()["_id"] return deadline_publish_job_id def process(self, instance): # type: (pyblish.api.Instance) -> None """Process plugin. Detect type of render farm submission and create and post dependent job in case of Deadline. It creates json file with metadata needed for publishing in directory of render. Args: instance (pyblish.api.Instance): Instance data. """ if not instance.data.get("farm"): self.log.debug("Skipping local instance.") return anatomy = instance.context.data["anatomy"] instance_skeleton_data = create_skeleton_instance_cache(instance) """ if content of `expectedFiles` list are dictionaries, we will handle it as list of AOVs, creating instance for every one of them. Example: -------- expectedFiles = [ { "beauty": [ "foo_v01.0001.exr", "foo_v01.0002.exr" ], "Z": [ "boo_v01.0001.exr", "boo_v01.0002.exr" ] } ] This will create instances for `beauty` and `Z` subset adding those files to their respective representations. If we have only list of files, we collect all file sequences. More then one doesn't probably make sense, but we'll handle it like creating one instance with multiple representations. Example: -------- expectedFiles = [ "foo_v01.0001.exr", "foo_v01.0002.exr", "xxx_v01.0001.exr", "xxx_v01.0002.exr" ] This will result in one instance with two representations: `foo` and `xxx` """ if isinstance(instance.data.get("expectedFiles")[0], dict): instances = create_instances_for_cache( instance, instance_skeleton_data) else: representations = prepare_cache_representations( instance_skeleton_data, instance.data.get("expectedFiles"), anatomy ) if "representations" not in instance_skeleton_data.keys(): instance_skeleton_data["representations"] = [] # add representation instance_skeleton_data["representations"] += representations instances = [instance_skeleton_data] # attach instances to subset if instance.data.get("attachTo"): instances = attach_instances_to_subset( instance.data.get("attachTo"), instances ) r''' SUBMiT PUBLiSH JOB 2 D34DLiN3 ____ ' ' .---. .---. .--. .---. .--..--..--..--. .---. | | --= \ | . \/ _|/ \| . \ || || \ |/ _| | JOB | --= / | | || __| .. | | | |;_ || \ || __| | | |____./ \.__|._||_.|___./|_____|||__|\__|\.___| ._____. ''' render_job = None submission_type = "" if instance.data.get("toBeRenderedOn") == "deadline": render_job = instance.data.pop("deadlineSubmissionJob", None) submission_type = "deadline" if not render_job: import getpass render_job = {} self.log.debug("Faking job data ...") render_job["Props"] = {} # Render job doesn't exist because we do not have prior submission. # We still use data from it so lets fake it. # # Batch name reflect original scene name if instance.data.get("assemblySubmissionJobs"): render_job["Props"]["Batch"] = instance.data.get( "jobBatchName") else: batch = os.path.splitext(os.path.basename( instance.context.data.get("currentFile")))[0] render_job["Props"]["Batch"] = batch # User is deadline user render_job["Props"]["User"] = instance.context.data.get( "deadlineUser", getpass.getuser()) deadline_publish_job_id = None if submission_type == "deadline": # get default deadline webservice url from deadline module self.deadline_url = instance.context.data["defaultDeadline"] # if custom one is set in instance, use that if instance.data.get("deadlineUrl"): self.deadline_url = instance.data.get("deadlineUrl") assert self.deadline_url, "Requires Deadline Webservice URL" deadline_publish_job_id = \ self._submit_deadline_post_job(instance, render_job) # Inject deadline url to instances. for inst in instances: inst["deadlineUrl"] = self.deadline_url # publish job file publish_job = { "asset": instance_skeleton_data["asset"], "frameStart": instance_skeleton_data["frameStart"], "frameEnd": instance_skeleton_data["frameEnd"], "fps": instance_skeleton_data["fps"], "source": instance_skeleton_data["source"], "user": instance.context.data["user"], "version": instance.context.data["version"], # workfile version "intent": instance.context.data.get("intent"), "comment": instance.context.data.get("comment"), "job": render_job or None, "session": legacy_io.Session.copy(), "instances": instances } if deadline_publish_job_id: publish_job["deadline_publish_job_id"] = deadline_publish_job_id metadata_path, rootless_metadata_path = \ create_metadata_path(instance, anatomy) with open(metadata_path, "w") as f: json.dump(publish_job, f, indent=4, sort_keys=True) def _get_publish_folder(self, anatomy, template_data, asset, subset, context, family, version=None): """ Extracted logic to pre-calculate real publish folder, which is calculated in IntegrateNew inside of Deadline process. This should match logic in: 'collect_anatomy_instance_data' - to get correct anatomy, family, version for subset and 'collect_resources_path' get publish_path Args: anatomy (openpype.pipeline.anatomy.Anatomy): template_data (dict): pre-calculated collected data for process asset (string): asset name subset (string): subset name (actually group name of subset) family (string): for current deadline process it's always 'render' TODO - for generic use family needs to be dynamically calculated like IntegrateNew does version (int): override version from instance if exists Returns: (string): publish folder where rendered and published files will be stored based on 'publish' template """ project_name = context.data["projectName"] if not version: version = get_last_version_by_subset_name( project_name, subset, asset_name=asset ) if version: version = int(version["name"]) + 1 else: version = get_versioning_start( project_name, template_data["app"], task_name=template_data["task"]["name"], task_type=template_data["task"]["type"], family="render", subset=subset, project_settings=context.data["project_settings"] ) host_name = context.data["hostName"] task_info = template_data.get("task") or {} template_name = publish.get_publish_template_name( project_name, host_name, family, task_info.get("name"), task_info.get("type"), ) template_data["subset"] = subset template_data["family"] = family template_data["version"] = version render_templates = anatomy.templates_obj[template_name] if "folder" in render_templates: publish_folder = render_templates["folder"].format_strict( template_data ) else: # solve deprecated situation when `folder` key is not underneath # `publish` anatomy self.log.warning(( "Deprecation warning: Anatomy does not have set `folder`" " key underneath `publish` (in global of for project `{}`)." ).format(project_name)) file_path = render_templates["path"].format_strict(template_data) publish_folder = os.path.dirname(file_path) return publish_folder @classmethod def get_attribute_defs(cls): return [ EnumDef("publishJobState", label="Publish Job State", items=["Active", "Suspended"], default="Active") ] ================================================ FILE: openpype/modules/deadline/plugins/publish/submit_publish_job.py ================================================ # -*- coding: utf-8 -*- """Submit publishing job to farm.""" import os import json import re from copy import deepcopy import requests import clique import pyblish.api from openpype import AYON_SERVER_ENABLED from openpype.client import ( get_last_version_by_subset_name, ) from openpype.pipeline import publish, legacy_io from openpype.lib import EnumDef, is_running_from_build from openpype.tests.lib import is_in_tests from openpype.pipeline.version_start import get_versioning_start from openpype.pipeline.farm.pyblish_functions import ( create_skeleton_instance, create_instances_for_aov, attach_instances_to_subset, prepare_representations, create_metadata_path ) def get_resource_files(resources, frame_range=None): """Get resource files at given path. If `frame_range` is specified those outside will be removed. Arguments: resources (list): List of resources frame_range (list): Frame range to apply override Returns: list of str: list of collected resources """ res_collections, _ = clique.assemble(resources) assert len(res_collections) == 1, "Multiple collections found" res_collection = res_collections[0] # Remove any frames if frame_range is not None: for frame in frame_range: if frame not in res_collection.indexes: continue res_collection.indexes.remove(frame) return list(res_collection) class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, publish.OpenPypePyblishPluginMixin, publish.ColormanagedPyblishPluginMixin): """Process Job submitted on farm. These jobs are dependent on a deadline job submission prior to this plug-in. It creates dependent job on farm publishing rendered image sequence. Options in instance.data: - deadlineSubmissionJob (dict, Required): The returned .json data from the job submission to deadline. - outputDir (str, Required): The output directory where the metadata file should be generated. It's assumed that this will also be final folder containing the output files. - ext (str, Optional): The extension (including `.`) that is required in the output filename to be picked up for image sequence publishing. - publishJobState (str, Optional): "Active" or "Suspended" This defaults to "Suspended" - expectedFiles (list or dict): explained below """ label = "Submit Image Publishing job to Deadline" order = pyblish.api.IntegratorOrder + 0.2 icon = "tractor" targets = ["local"] hosts = ["fusion", "max", "maya", "nuke", "houdini", "celaction", "aftereffects", "harmony", "blender"] families = ["render.farm", "render.frames_farm", "prerender.farm", "prerender.frames_farm", "renderlayer", "imagesequence", "vrayscene", "maxrender", "arnold_rop", "mantra_rop", "karma_rop", "vray_rop", "redshift_rop"] aov_filter = {"maya": [r".*([Bb]eauty).*"], "blender": [r".*([Bb]eauty).*"], "aftereffects": [r".*"], # for everything from AE "harmony": [r".*"], # for everything from AE "celaction": [r".*"], "max": [r".*"]} environ_job_filter = [ "OPENPYPE_METADATA_FILE" ] environ_keys = [ "FTRACK_API_USER", "FTRACK_API_KEY", "FTRACK_SERVER", "AVALON_APP_NAME", "OPENPYPE_USERNAME", "OPENPYPE_SG_USER", "KITSU_LOGIN", "KITSU_PWD" ] # custom deadline attributes deadline_department = "" deadline_pool = "" deadline_pool_secondary = "" deadline_group = "" deadline_chunk_size = 1 deadline_priority = None # regex for finding frame number in string R_FRAME_NUMBER = re.compile(r'.+\.(?P[0-9]+)\..+') # mapping of instance properties to be transferred to new instance # for every specified family instance_transfer = { "slate": ["slateFrames", "slate"], "review": ["lutPath"], "render2d": ["bakingNukeScripts", "version"], "renderlayer": ["convertToScanline"] } # list of family names to transfer to new family if present families_transfer = ["render3d", "render2d", "ftrack", "slate"] plugin_pype_version = "3.0" # script path for publish_filesequence.py publishing_script = None # poor man exclusion skip_integration_repre_list = [] def _submit_deadline_post_job(self, instance, job, instances): """Submit publish job to Deadline. Returns: (str): deadline_publish_job_id """ data = instance.data.copy() subset = data["subset"] job_name = "Publish - {subset}".format(subset=subset) anatomy = instance.context.data['anatomy'] # instance.data.get("subset") != instances[0]["subset"] # 'Main' vs 'renderMain' override_version = None instance_version = instance.data.get("version") # take this if exists if instance_version != 1: override_version = instance_version output_dir = self._get_publish_folder( anatomy, deepcopy(instance.data["anatomyData"]), instance.data.get("asset"), instances[0]["subset"], instance.context, instances[0]["family"], override_version ) # Transfer the environment from the original job to this dependent # job so they use the same environment metadata_path, rootless_metadata_path = \ create_metadata_path(instance, anatomy) environment = { "AVALON_PROJECT": instance.context.data["projectName"], "AVALON_ASSET": instance.context.data["asset"], "AVALON_TASK": instance.context.data["task"], "OPENPYPE_USERNAME": instance.context.data["user"], "OPENPYPE_LOG_NO_COLORS": "1", "IS_TEST": str(int(is_in_tests())) } if AYON_SERVER_ENABLED: environment["AYON_PUBLISH_JOB"] = "1" environment["AYON_RENDER_JOB"] = "0" environment["AYON_REMOTE_PUBLISH"] = "0" environment["AYON_BUNDLE_NAME"] = os.environ["AYON_BUNDLE_NAME"] deadline_plugin = "Ayon" else: environment["AVALON_DB"] = os.environ["AVALON_DB"] environment["OPENPYPE_PUBLISH_JOB"] = "1" environment["OPENPYPE_RENDER_JOB"] = "0" environment["OPENPYPE_REMOTE_PUBLISH"] = "0" deadline_plugin = "OpenPype" # Add OpenPype version if we are running from build. if is_running_from_build(): self.environ_keys.append("OPENPYPE_VERSION") # add environments from self.environ_keys for env_key in self.environ_keys: if os.getenv(env_key): environment[env_key] = os.environ[env_key] # pass environment keys from self.environ_job_filter job_environ = job["Props"].get("Env", {}) for env_j_key in self.environ_job_filter: if job_environ.get(env_j_key): environment[env_j_key] = job_environ[env_j_key] # Add mongo url if it's enabled if instance.context.data.get("deadlinePassMongoUrl"): mongo_url = os.environ.get("OPENPYPE_MONGO") if mongo_url: environment["OPENPYPE_MONGO"] = mongo_url priority = self.deadline_priority or instance.data.get("priority", 50) instance_settings = self.get_attr_values_from_data(instance.data) initial_status = instance_settings.get("publishJobState", "Active") # TODO: Remove this backwards compatibility of `suspend_publish` if instance.data.get("suspend_publish"): initial_status = "Suspended" args = [ "--headless", 'publish', '"{}"'.format(rootless_metadata_path), "--targets", "deadline", "--targets", "farm" ] if is_in_tests(): args.append("--automatic-tests") # Generate the payload for Deadline submission secondary_pool = ( self.deadline_pool_secondary or instance.data.get("secondaryPool") ) payload = { "JobInfo": { "Plugin": deadline_plugin, "BatchName": job["Props"]["Batch"], "Name": job_name, "UserName": job["Props"]["User"], "Comment": instance.context.data.get("comment", ""), "Department": self.deadline_department, "ChunkSize": self.deadline_chunk_size, "Priority": priority, "InitialStatus": initial_status, "Group": self.deadline_group, "Pool": self.deadline_pool or instance.data.get("primaryPool"), "SecondaryPool": secondary_pool, # ensure the outputdirectory with correct slashes "OutputDirectory0": output_dir.replace("\\", "/") }, "PluginInfo": { "Version": self.plugin_pype_version, "Arguments": " ".join(args), "SingleFrameOnly": "True", }, # Mandatory for Deadline, may be empty "AuxFiles": [], } # add assembly jobs as dependencies if instance.data.get("tileRendering"): self.log.info("Adding tile assembly jobs as dependencies...") job_index = 0 for assembly_id in instance.data.get("assemblySubmissionJobs"): payload["JobInfo"]["JobDependency{}".format( job_index)] = assembly_id # noqa: E501 job_index += 1 elif instance.data.get("bakingSubmissionJobs"): self.log.info( "Adding baking submission jobs as dependencies..." ) job_index = 0 for assembly_id in instance.data["bakingSubmissionJobs"]: payload["JobInfo"]["JobDependency{}".format( job_index)] = assembly_id # noqa: E501 job_index += 1 elif job.get("_id"): payload["JobInfo"]["JobDependency0"] = job["_id"] for index, (key_, value_) in enumerate(environment.items()): payload["JobInfo"].update( { "EnvironmentKeyValue%d" % index: "{key}={value}".format( key=key_, value=value_ ) } ) # remove secondary pool payload["JobInfo"].pop("SecondaryPool", None) self.log.debug("Submitting Deadline publish job ...") url = "{}/api/jobs".format(self.deadline_url) response = requests.post(url, json=payload, timeout=10) if not response.ok: raise Exception(response.text) deadline_publish_job_id = response.json()["_id"] return deadline_publish_job_id def process(self, instance): # type: (pyblish.api.Instance) -> None """Process plugin. Detect type of render farm submission and create and post dependent job in case of Deadline. It creates json file with metadata needed for publishing in directory of render. Args: instance (pyblish.api.Instance): Instance data. """ if not instance.data.get("farm"): self.log.debug("Skipping local instance.") return anatomy = instance.context.data["anatomy"] instance_skeleton_data = create_skeleton_instance( instance, families_transfer=self.families_transfer, instance_transfer=self.instance_transfer) """ if content of `expectedFiles` list are dictionaries, we will handle it as list of AOVs, creating instance for every one of them. Example: -------- expectedFiles = [ { "beauty": [ "foo_v01.0001.exr", "foo_v01.0002.exr" ], "Z": [ "boo_v01.0001.exr", "boo_v01.0002.exr" ] } ] This will create instances for `beauty` and `Z` subset adding those files to their respective representations. If we have only list of files, we collect all file sequences. More then one doesn't probably make sense, but we'll handle it like creating one instance with multiple representations. Example: -------- expectedFiles = [ "foo_v01.0001.exr", "foo_v01.0002.exr", "xxx_v01.0001.exr", "xxx_v01.0002.exr" ] This will result in one instance with two representations: `foo` and `xxx` """ do_not_add_review = False if instance.data.get("review") is False: self.log.debug("Instance has review explicitly disabled.") do_not_add_review = True if isinstance(instance.data.get("expectedFiles")[0], dict): instances = create_instances_for_aov( instance, instance_skeleton_data, self.aov_filter, self.skip_integration_repre_list, do_not_add_review) else: representations = prepare_representations( instance_skeleton_data, instance.data.get("expectedFiles"), anatomy, self.aov_filter, self.skip_integration_repre_list, do_not_add_review, instance.context, self ) if "representations" not in instance_skeleton_data.keys(): instance_skeleton_data["representations"] = [] # add representation instance_skeleton_data["representations"] += representations instances = [instance_skeleton_data] # attach instances to subset if instance.data.get("attachTo"): instances = attach_instances_to_subset( instance.data.get("attachTo"), instances ) r''' SUBMiT PUBLiSH JOB 2 D34DLiN3 ____ ' ' .---. .---. .--. .---. .--..--..--..--. .---. | | --= \ | . \/ _|/ \| . \ || || \ |/ _| | JOB | --= / | | || __| .. | | | |;_ || \ || __| | | |____./ \.__|._||_.|___./|_____|||__|\__|\.___| ._____. ''' render_job = instance.data.pop("deadlineSubmissionJob", None) if not render_job and instance.data.get("tileRendering") is False: raise AssertionError(("Cannot continue without valid " "Deadline submission.")) if not render_job: import getpass render_job = {} self.log.debug("Faking job data ...") render_job["Props"] = {} # Render job doesn't exist because we do not have prior submission. # We still use data from it so lets fake it. # # Batch name reflect original scene name if instance.data.get("assemblySubmissionJobs"): render_job["Props"]["Batch"] = instance.data.get( "jobBatchName") else: batch = os.path.splitext(os.path.basename( instance.context.data.get("currentFile")))[0] render_job["Props"]["Batch"] = batch # User is deadline user render_job["Props"]["User"] = instance.context.data.get( "deadlineUser", getpass.getuser()) render_job["Props"]["Env"] = { "FTRACK_API_USER": os.environ.get("FTRACK_API_USER"), "FTRACK_API_KEY": os.environ.get("FTRACK_API_KEY"), "FTRACK_SERVER": os.environ.get("FTRACK_SERVER"), } # get default deadline webservice url from deadline module self.deadline_url = instance.context.data["defaultDeadline"] # if custom one is set in instance, use that if instance.data.get("deadlineUrl"): self.deadline_url = instance.data.get("deadlineUrl") assert self.deadline_url, "Requires Deadline Webservice URL" deadline_publish_job_id = \ self._submit_deadline_post_job(instance, render_job, instances) # Inject deadline url to instances. for inst in instances: inst["deadlineUrl"] = self.deadline_url # publish job file publish_job = { "asset": instance_skeleton_data["asset"], "frameStart": instance_skeleton_data["frameStart"], "frameEnd": instance_skeleton_data["frameEnd"], "fps": instance_skeleton_data["fps"], "source": instance_skeleton_data["source"], "user": instance.context.data["user"], "version": instance.context.data["version"], # workfile version "intent": instance.context.data.get("intent"), "comment": instance.context.data.get("comment"), "job": render_job or None, "session": legacy_io.Session.copy(), "instances": instances } if deadline_publish_job_id: publish_job["deadline_publish_job_id"] = deadline_publish_job_id # add audio to metadata file if available audio_file = instance.context.data.get("audioFile") if audio_file and os.path.isfile(audio_file): publish_job.update({"audio": audio_file}) metadata_path, rootless_metadata_path = \ create_metadata_path(instance, anatomy) with open(metadata_path, "w") as f: json.dump(publish_job, f, indent=4, sort_keys=True) def _get_publish_folder(self, anatomy, template_data, asset, subset, context, family, version=None): """ Extracted logic to pre-calculate real publish folder, which is calculated in IntegrateNew inside of Deadline process. This should match logic in: 'collect_anatomy_instance_data' - to get correct anatomy, family, version for subset and 'collect_resources_path' get publish_path Args: anatomy (openpype.pipeline.anatomy.Anatomy): template_data (dict): pre-calculated collected data for process asset (string): asset name subset (string): subset name (actually group name of subset) family (string): for current deadline process it's always 'render' TODO - for generic use family needs to be dynamically calculated like IntegrateNew does version (int): override version from instance if exists Returns: (string): publish folder where rendered and published files will be stored based on 'publish' template """ project_name = context.data["projectName"] host_name = context.data["hostName"] if not version: version = get_last_version_by_subset_name( project_name, subset, asset_name=asset ) if version: version = int(version["name"]) + 1 else: version = get_versioning_start( project_name, host_name, task_name=template_data["task"]["name"], task_type=template_data["task"]["type"], family="render", subset=subset, project_settings=context.data["project_settings"] ) host_name = context.data["hostName"] task_info = template_data.get("task") or {} template_name = publish.get_publish_template_name( project_name, host_name, family, task_info.get("name"), task_info.get("type"), ) template_data["subset"] = subset template_data["family"] = family template_data["version"] = version render_templates = anatomy.templates_obj[template_name] if "folder" in render_templates: publish_folder = render_templates["folder"].format_strict( template_data ) else: # solve deprecated situation when `folder` key is not underneath # `publish` anatomy self.log.warning(( "Deprecation warning: Anatomy does not have set `folder`" " key underneath `publish` (in global of for project `{}`)." ).format(project_name)) file_path = render_templates["path"].format_strict(template_data) publish_folder = os.path.dirname(file_path) return publish_folder @classmethod def get_attribute_defs(cls): return [ EnumDef("publishJobState", label="Publish Job State", items=["Active", "Suspended"], default="Active") ] ================================================ FILE: openpype/modules/deadline/plugins/publish/validate_deadline_connection.py ================================================ import pyblish.api from openpype_modules.deadline.abstract_submit_deadline import requests_get class ValidateDeadlineConnection(pyblish.api.InstancePlugin): """Validate Deadline Web Service is running""" label = "Validate Deadline Web Service" order = pyblish.api.ValidatorOrder hosts = ["maya", "nuke"] families = ["renderlayer", "render"] # cache responses = {} def process(self, instance): # get default deadline webservice url from deadline module deadline_url = instance.context.data["defaultDeadline"] # if custom one is set in instance, use that if instance.data.get("deadlineUrl"): deadline_url = instance.data.get("deadlineUrl") self.log.debug( "We have deadline URL on instance {}".format(deadline_url) ) assert deadline_url, "Requires Deadline Webservice URL" if deadline_url not in self.responses: self.responses[deadline_url] = requests_get(deadline_url) response = self.responses[deadline_url] assert response.ok, "Response must be ok" assert response.text.startswith("Deadline Web Service "), ( "Web service did not respond with 'Deadline Web Service'" ) ================================================ FILE: openpype/modules/deadline/plugins/publish/validate_deadline_pools.py ================================================ import pyblish.api from openpype.pipeline import ( PublishXmlValidationError, OptionalPyblishPluginMixin ) from openpype.modules.deadline.deadline_module import DeadlineModule class ValidateDeadlinePools(OptionalPyblishPluginMixin, pyblish.api.InstancePlugin): """Validate primaryPool and secondaryPool on instance. Values are on instance based on value insertion when Creating instance or by Settings in CollectDeadlinePools. """ label = "Validate Deadline Pools" order = pyblish.api.ValidatorOrder families = ["rendering", "render.farm", "render.frames_farm", "renderFarm", "renderlayer", "maxrender", "publish.hou"] optional = True # cache pools_per_url = {} def process(self, instance): if not self.is_active(instance.data): return if not instance.data.get("farm"): self.log.debug("Skipping local instance.") return deadline_url = self.get_deadline_url(instance) pools = self.get_pools(deadline_url) invalid_pools = {} primary_pool = instance.data.get("primaryPool") if primary_pool and primary_pool not in pools: invalid_pools["primary"] = primary_pool secondary_pool = instance.data.get("secondaryPool") if secondary_pool and secondary_pool not in pools: invalid_pools["secondary"] = secondary_pool if invalid_pools: message = "\n".join( "{} pool '{}' not available on Deadline".format(key.title(), pool) for key, pool in invalid_pools.items() ) raise PublishXmlValidationError( plugin=self, message=message, formatting_data={"pools_str": ", ".join(pools)} ) def get_deadline_url(self, instance): # get default deadline webservice url from deadline module deadline_url = instance.context.data["defaultDeadline"] if instance.data.get("deadlineUrl"): # if custom one is set in instance, use that deadline_url = instance.data.get("deadlineUrl") return deadline_url def get_pools(self, deadline_url): if deadline_url not in self.pools_per_url: self.log.debug( "Querying available pools for Deadline url: {}".format( deadline_url) ) pools = DeadlineModule.get_deadline_pools(deadline_url, log=self.log) self.log.info("Available pools: {}".format(pools)) self.pools_per_url[deadline_url] = pools return self.pools_per_url[deadline_url] ================================================ FILE: openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py ================================================ import os import requests import pyblish.api from openpype.lib import collect_frames from openpype_modules.deadline.abstract_submit_deadline import requests_get class ValidateExpectedFiles(pyblish.api.InstancePlugin): """Compare rendered and expected files""" label = "Validate rendered files from Deadline" order = pyblish.api.ValidatorOrder families = ["render"] targets = ["deadline"] # check if actual frame range on render job wasn't different # case when artists wants to render only subset of frames allow_user_override = True def process(self, instance): """Process all the nodes in the instance""" # get dependency jobs ids for retrieving frame list dependent_job_ids = self._get_dependent_job_ids(instance) if not dependent_job_ids: self.log.warning("No dependent jobs found for instance: {}" "".format(instance)) return # get list of frames from dependent jobs frame_list = self._get_dependent_jobs_frames( instance, dependent_job_ids) for repre in instance.data["representations"]: expected_files = self._get_expected_files(repre) staging_dir = repre["stagingDir"] existing_files = self._get_existing_files(staging_dir) if self.allow_user_override: # We always check for user override because the user might have # also overridden the Job frame list to be longer than the # originally submitted frame range # todo: We should first check if Job frame range was overridden # at all so we don't unnecessarily override anything file_name_template, frame_placeholder = \ self._get_file_name_template_and_placeholder( expected_files) if not file_name_template: raise RuntimeError("Unable to retrieve file_name template" "from files: {}".format(expected_files)) job_expected_files = self._get_job_expected_files( file_name_template, frame_placeholder, frame_list) job_files_diff = job_expected_files.difference(expected_files) if job_files_diff: self.log.debug( "Detected difference in expected output files from " "Deadline job. Assuming an updated frame list by the " "user. Difference: {}".format(sorted(job_files_diff)) ) # Update the representation expected files self.log.info("Update range from actual job range " "to frame list: {}".format(frame_list)) # single item files must be string not list repre["files"] = (sorted(job_expected_files) if len(job_expected_files) > 1 else list(job_expected_files)[0]) # Update the expected files expected_files = job_expected_files # We don't use set.difference because we do allow other existing # files to be in the folder that we might not want to use. missing = expected_files - existing_files if missing: raise RuntimeError( "Missing expected files: {}\n" "Expected files: {}\n" "Existing files: {}".format( sorted(missing), sorted(expected_files), sorted(existing_files) ) ) def _get_dependent_job_ids(self, instance): """Returns list of dependent job ids from instance metadata.json Args: instance (pyblish.api.Instance): pyblish instance Returns: (list): list of dependent job ids """ dependent_job_ids = [] # job_id collected from metadata.json original_job_id = instance.data["render_job_id"] dependent_job_ids_env = os.environ.get("RENDER_JOB_IDS") if dependent_job_ids_env: dependent_job_ids = dependent_job_ids_env.split(',') elif original_job_id: dependent_job_ids = [original_job_id] return dependent_job_ids def _get_dependent_jobs_frames(self, instance, dependent_job_ids): """Returns list of frame ranges from all render job. Render job might be re-submitted so job_id in metadata.json could be invalid. GlobalJobPreload injects current job id to RENDER_JOB_IDS. Args: instance (pyblish.api.Instance): pyblish instance dependent_job_ids (list): list of dependent job ids Returns: (list) """ all_frame_lists = [] for job_id in dependent_job_ids: job_info = self._get_job_info(instance, job_id) frame_list = job_info["Props"].get("Frames") if frame_list: all_frame_lists.extend(frame_list.split(',')) return all_frame_lists def _get_job_expected_files(self, file_name_template, frame_placeholder, frame_list): """Calculates list of names of expected rendered files. Might be different from expected files from submission if user explicitly and manually changed the frame list on the Deadline job. """ # no frames in file name at all, eg 'renderCompositingMain.withLut.mov' if not frame_placeholder: return set([file_name_template]) real_expected_rendered = set() src_padding_exp = "%0{}d".format(len(frame_placeholder)) for frames in frame_list: if '-' not in frames: # single frame frames = "{}-{}".format(frames, frames) start, end = frames.split('-') for frame in range(int(start), int(end) + 1): ren_name = file_name_template.replace( frame_placeholder, src_padding_exp % frame) real_expected_rendered.add(ren_name) return real_expected_rendered def _get_file_name_template_and_placeholder(self, files): """Returns file name with frame replaced with # and this placeholder""" sources_and_frames = collect_frames(files) file_name_template = frame_placeholder = None for file_name, frame in sources_and_frames.items(): # There might be cases where clique was unable to collect # collections in `collect_frames` - thus we capture that case if frame is not None: frame_placeholder = "#" * len(frame) file_name_template = os.path.basename( file_name.replace(frame, frame_placeholder)) else: file_name_template = file_name break return file_name_template, frame_placeholder def _get_job_info(self, instance, job_id): """Calls DL for actual job info for 'job_id' Might be different than job info saved in metadata.json if user manually changes job pre/during rendering. Args: instance (pyblish.api.Instance): pyblish instance job_id (str): Deadline job id Returns: (dict): Job info from Deadline """ # get default deadline webservice url from deadline module deadline_url = instance.context.data["defaultDeadline"] # if custom one is set in instance, use that if instance.data.get("deadlineUrl"): deadline_url = instance.data.get("deadlineUrl") assert deadline_url, "Requires Deadline Webservice URL" url = "{}/api/jobs?JobID={}".format(deadline_url, job_id) try: response = requests_get(url) except requests.exceptions.ConnectionError: self.log.error("Deadline is not accessible at " "{}".format(deadline_url)) return {} if not response.ok: self.log.error("Submission failed!") self.log.error(response.status_code) self.log.error(response.content) raise RuntimeError(response.text) json_content = response.json() if json_content: return json_content.pop() return {} def _get_existing_files(self, staging_dir): """Returns set of existing file names from 'staging_dir'""" existing_files = set() for file_name in os.listdir(staging_dir): existing_files.add(file_name) return existing_files def _get_expected_files(self, repre): """Returns set of file names in representation['files'] The representations are collected from `CollectRenderedFiles` using the metadata.json file submitted along with the render job. Args: repre (dict): The representation containing 'files' Returns: set: Set of expected file_names in the staging directory. """ expected_files = set() files = repre["files"] if not isinstance(files, list): files = [files] for file_name in files: expected_files.add(file_name) return expected_files ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.options ================================================ [Arguments] Type=string Label=Arguments Category=Python Options CategoryOrder=0 Index=1 Description=The arguments to pass to the script. If no arguments are required, leave this blank. Required=false DisableIfBlank=true ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.param ================================================ [About] Type=label Label=About Category=About Plugin CategoryOrder=-1 Index=0 Default=Ayon Plugin for Deadline Description=Not configurable [AyonExecutable] Type=multilinemultifilename Label=Ayon Executable Category=Ayon Executables CategoryOrder=1 Index=0 Default= Description=The path to the Ayon executable. Enter alternative paths on separate lines. [AyonServerUrl] Type=string Label=Ayon Server Url Category=Ayon Credentials CategoryOrder=2 Index=0 Default= Description=Url to Ayon server [AyonApiKey] Type=password Label=Ayon API key Category=Ayon Credentials CategoryOrder=2 Index=0 Default= Description=API key for service account on Ayon Server ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py ================================================ #!/usr/bin/env python3 from System.IO import Path from System.Text.RegularExpressions import Regex from Deadline.Plugins import PluginType, DeadlinePlugin from Deadline.Scripting import ( StringUtils, FileUtils, DirectoryUtils, RepositoryUtils ) import re import os import platform ###################################################################### # This is the function that Deadline calls to get an instance of the # main DeadlinePlugin class. ###################################################################### def GetDeadlinePlugin(): return AyonDeadlinePlugin() def CleanupDeadlinePlugin(deadlinePlugin): deadlinePlugin.Cleanup() class AyonDeadlinePlugin(DeadlinePlugin): """ Standalone plugin for publishing from Ayon Calls Ayonexecutable 'ayon_console' from first correctly found file based on plugin configuration. Uses 'publish' command and passes path to metadata json file, which contains all needed information for publish process. """ def __init__(self): super().__init__() self.InitializeProcessCallback += self.InitializeProcess self.RenderExecutableCallback += self.RenderExecutable self.RenderArgumentCallback += self.RenderArgument def Cleanup(self): for stdoutHandler in self.StdoutHandlers: del stdoutHandler.HandleCallback del self.InitializeProcessCallback del self.RenderExecutableCallback del self.RenderArgumentCallback def InitializeProcess(self): self.PluginType = PluginType.Simple self.StdoutHandling = True self.SingleFramesOnly = self.GetBooleanPluginInfoEntryWithDefault( "SingleFramesOnly", False) self.LogInfo("Single Frames Only: %s" % self.SingleFramesOnly) self.AddStdoutHandlerCallback( ".*Progress: (\d+)%.*").HandleCallback += self.HandleProgress def RenderExecutable(self): job = self.GetJob() # set required env vars for Ayon # cannot be in InitializeProcess as it is too soon config = RepositoryUtils.GetPluginConfig("Ayon") ayon_server_url = ( job.GetJobEnvironmentKeyValue("AYON_SERVER_URL") or config.GetConfigEntryWithDefault("AyonServerUrl", "") ) ayon_api_key = ( job.GetJobEnvironmentKeyValue("AYON_API_KEY") or config.GetConfigEntryWithDefault("AyonApiKey", "") ) ayon_bundle_name = job.GetJobEnvironmentKeyValue("AYON_BUNDLE_NAME") environment = { "AYON_SERVER_URL": ayon_server_url, "AYON_API_KEY": ayon_api_key, "AYON_BUNDLE_NAME": ayon_bundle_name, } for env, val in environment.items(): self.SetEnvironmentVariable(env, val) exe_list = self.GetConfigEntry("AyonExecutable") # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": exe_list = exe_list.replace("\\ ", " ") expanded_paths = [] for path in exe_list.split(";"): if path.startswith("~"): path = os.path.expanduser(path) expanded_paths.append(path) exe = FileUtils.SearchFileList(";".join(expanded_paths)) if exe == "": self.FailRender( "Ayon executable was not found in the semicolon separated " "list: \"{}\". The path to the render executable can be " "configured from the Plugin Configuration in the Deadline " "Monitor.".format(exe_list) ) return exe def RenderArgument(self): arguments = str(self.GetPluginInfoEntryWithDefault("Arguments", "")) arguments = RepositoryUtils.CheckPathMapping(arguments) arguments = re.sub(r"<(?i)STARTFRAME>", str(self.GetStartFrame()), arguments) arguments = re.sub(r"<(?i)ENDFRAME>", str(self.GetEndFrame()), arguments) arguments = re.sub(r"<(?i)QUOTE>", "\"", arguments) arguments = self.ReplacePaddedFrame(arguments, "<(?i)STARTFRAME%([0-9]+)>", self.GetStartFrame()) arguments = self.ReplacePaddedFrame(arguments, "<(?i)ENDFRAME%([0-9]+)>", self.GetEndFrame()) count = 0 for filename in self.GetAuxiliaryFilenames(): localAuxFile = Path.Combine(self.GetJobsDataDirectory(), filename) arguments = re.sub(r"<(?i)AUXFILE" + str(count) + r">", localAuxFile.replace("\\", "/"), arguments) count += 1 return arguments def ReplacePaddedFrame(self, arguments, pattern, frame): frameRegex = Regex(pattern) while True: frameMatch = frameRegex.Match(arguments) if not frameMatch.Success: break paddingSize = int(frameMatch.Groups[1].Value) if paddingSize > 0: padding = StringUtils.ToZeroPaddedString( frame, paddingSize, False) else: padding = str(frame) arguments = arguments.replace( frameMatch.Groups[0].Value, padding) return arguments def HandleProgress(self): progress = float(self.GetRegexMatch(1)) self.SetProgress(progress) ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/CelAction/CelAction.param ================================================ [About] Type=label Label=About Category=About Plugin CategoryOrder=-1 Index=0 Default=Celaction Plugin for Deadline Description=Not configurable [ConcurrentTasks] Type=label Label=ConcurrentTasks Category=About Plugin CategoryOrder=-1 Index=0 Default=True Description=Not configurable [Executable] Type=filename Label=Executable Category=Config CategoryOrder=0 CategoryIndex=0 Description=The command executable to run Required=false DisableIfBlank=true [RenderNameSeparator] Type=string Label=RenderNameSeparator Category=Config CategoryOrder=0 CategoryIndex=1 Description=The separator to use for naming Required=false DisableIfBlank=true Default=. ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/CelAction/CelAction.py ================================================ from System.Text.RegularExpressions import * from Deadline.Plugins import * from Deadline.Scripting import * import _winreg ###################################################################### # This is the function that Deadline calls to get an instance of the # main DeadlinePlugin class. ###################################################################### def GetDeadlinePlugin(): return CelActionPlugin() def CleanupDeadlinePlugin(deadlinePlugin): deadlinePlugin.Cleanup() ###################################################################### # This is the main DeadlinePlugin class for the CelAction plugin. ###################################################################### class CelActionPlugin(DeadlinePlugin): def __init__(self): self.InitializeProcessCallback += self.InitializeProcess self.RenderExecutableCallback += self.RenderExecutable self.RenderArgumentCallback += self.RenderArgument self.StartupDirectoryCallback += self.StartupDirectory def Cleanup(self): for stdoutHandler in self.StdoutHandlers: del stdoutHandler.HandleCallback del self.InitializeProcessCallback del self.RenderExecutableCallback del self.RenderArgumentCallback del self.StartupDirectoryCallback def GetCelActionRegistryKey(self): # Modify registry for frame separation path = r'Software\CelAction\CelAction2D\User Settings' _winreg.CreateKey(_winreg.HKEY_CURRENT_USER, path) regKey = _winreg.OpenKey(_winreg.HKEY_CURRENT_USER, path, 0, _winreg.KEY_ALL_ACCESS) return regKey def GetSeparatorValue(self, regKey): useSeparator, _ = _winreg.QueryValueEx( regKey, 'RenderNameUseSeparator') separator, _ = _winreg.QueryValueEx(regKey, 'RenderNameSeparator') return useSeparator, separator def SetSeparatorValue(self, regKey, useSeparator, separator): _winreg.SetValueEx(regKey, 'RenderNameUseSeparator', 0, _winreg.REG_DWORD, useSeparator) _winreg.SetValueEx(regKey, 'RenderNameSeparator', 0, _winreg.REG_SZ, separator) def InitializeProcess(self): # Set the plugin specific settings. self.SingleFramesOnly = False # Set the process specific settings. self.StdoutHandling = True self.PopupHandling = True # Ignore 'celaction' Pop-up dialog self.AddPopupIgnorer(".*Rendering.*") self.AddPopupIgnorer(".*AutoRender.*") # Ignore 'celaction' Pop-up dialog self.AddPopupIgnorer(".*Wait.*") # Ignore 'celaction' Pop-up dialog self.AddPopupIgnorer(".*Timeline Scrub.*") celActionRegKey = self.GetCelActionRegistryKey() self.SetSeparatorValue(celActionRegKey, 1, self.GetConfigEntryWithDefault( "RenderNameSeparator", ".").strip()) def RenderExecutable(self): return RepositoryUtils.CheckPathMapping(self.GetConfigEntry("Executable").strip()) def RenderArgument(self): arguments = RepositoryUtils.CheckPathMapping( self.GetPluginInfoEntry("Arguments").strip()) arguments = arguments.replace( "", str(self.GetStartFrame())) arguments = arguments.replace("", str(self.GetEndFrame())) arguments = self.ReplacePaddedFrame( arguments, "", self.GetStartFrame()) arguments = self.ReplacePaddedFrame( arguments, "", self.GetEndFrame()) arguments = arguments.replace("", "\"") return arguments def StartupDirectory(self): return self.GetPluginInfoEntryWithDefault("StartupDirectory", "").strip() def ReplacePaddedFrame(self, arguments, pattern, frame): frameRegex = Regex(pattern) while True: frameMatch = frameRegex.Match(arguments) if frameMatch.Success: paddingSize = int(frameMatch.Groups[1].Value) if paddingSize > 0: padding = StringUtils.ToZeroPaddedString( frame, paddingSize, False) else: padding = str(frame) arguments = arguments.replace( frameMatch.Groups[0].Value, padding) else: break return arguments ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py ================================================ # /usr/bin/env python3 # -*- coding: utf-8 -*- import os import tempfile from datetime import datetime import subprocess import json import platform import uuid import re from Deadline.Scripting import ( RepositoryUtils, FileUtils, DirectoryUtils, ProcessUtils, ) VERSION_REGEX = re.compile( r"(?P0|[1-9]\d*)" r"\.(?P0|[1-9]\d*)" r"\.(?P0|[1-9]\d*)" r"(?:-(?P[a-zA-Z\d\-.]*))?" r"(?:\+(?P[a-zA-Z\d\-.]*))?" ) class OpenPypeVersion: """Fake semver version class for OpenPype version purposes. The version """ def __init__(self, major, minor, patch, prerelease, origin=None): self.major = major self.minor = minor self.patch = patch self.prerelease = prerelease is_valid = True if major is None or minor is None or patch is None: is_valid = False self.is_valid = is_valid if origin is None: base = "{}.{}.{}".format(str(major), str(minor), str(patch)) if not prerelease: origin = base else: origin = "{}-{}".format(base, str(prerelease)) self.origin = origin @classmethod def from_string(cls, version): """Create an object of version from string. Args: version (str): Version as a string. Returns: Union[OpenPypeVersion, None]: Version object if input is nonempty string otherwise None. """ if not version: return None valid_parts = VERSION_REGEX.findall(version) if len(valid_parts) != 1: # Return invalid version with filled 'origin' attribute return cls(None, None, None, None, origin=str(version)) # Unpack found version major, minor, patch, pre, post = valid_parts[0] prerelease = pre # Post release is not important anymore and should be considered as # part of prerelease # - comparison is implemented to find suitable build and builds should # never contain prerelease part so "not proper" parsing is # acceptable for this use case. if post: prerelease = "{}+{}".format(pre, post) return cls( int(major), int(minor), int(patch), prerelease, origin=version ) def has_compatible_release(self, other): """Version has compatible release as other version. Both major and minor versions must be exactly the same. In that case a build can be considered as release compatible with any version. Args: other (OpenPypeVersion): Other version. Returns: bool: Version is release compatible with other version. """ if self.is_valid and other.is_valid: return self.major == other.major and self.minor == other.minor return False def __bool__(self): return self.is_valid def __repr__(self): return "<{} {}>".format(self.__class__.__name__, self.origin) def __eq__(self, other): if not isinstance(other, self.__class__): return self.origin == other return self.origin == other.origin def __lt__(self, other): if not isinstance(other, self.__class__): return None if not self.is_valid: return True if not other.is_valid: return False if self.origin == other.origin: return None same_major = self.major == other.major if not same_major: return self.major < other.major same_minor = self.minor == other.minor if not same_minor: return self.minor < other.minor same_patch = self.patch == other.patch if not same_patch: return self.patch < other.patch if not self.prerelease: return False if not other.prerelease: return True pres = [self.prerelease, other.prerelease] pres.sort() return pres[0] == self.prerelease def get_openpype_version_from_path(path, build=True): """Get OpenPype version from provided path. path (str): Path to scan. build (bool, optional): Get only builds, not sources Returns: Union[OpenPypeVersion, None]: version of OpenPype if found. """ # fix path for application bundle on macos if platform.system().lower() == "darwin": path = os.path.join(path, "MacOS") version_file = os.path.join(path, "openpype", "version.py") if not os.path.isfile(version_file): return None # skip if the version is not build exe = os.path.join(path, "openpype_console.exe") if platform.system().lower() in ["linux", "darwin"]: exe = os.path.join(path, "openpype_console") # if only builds are requested if build and not os.path.isfile(exe): # noqa: E501 print(" ! path is not a build: {}".format(path)) return None version = {} with open(version_file, "r") as vf: exec(vf.read(), version) version_str = version.get("__version__") if version_str: return OpenPypeVersion.from_string(version_str) return None def get_openpype_executable(): """Return OpenPype Executable from Event Plug-in Settings""" config = RepositoryUtils.GetPluginConfig("OpenPype") exe_list = config.GetConfigEntryWithDefault("OpenPypeExecutable", "") dir_list = config.GetConfigEntryWithDefault( "OpenPypeInstallationDirs", "") # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": exe_list = exe_list.replace("\\ ", " ") dir_list = dir_list.replace("\\ ", " ") return exe_list, dir_list def get_openpype_versions(dir_list): print(">>> Getting OpenPype executable ...") openpype_versions = [] # special case of multiple install dirs for dir_list in dir_list.split(","): install_dir = DirectoryUtils.SearchDirectoryList(dir_list) if install_dir: print("--- Looking for OpenPype at: {}".format(install_dir)) sub_dirs = [ f.path for f in os.scandir(install_dir) if f.is_dir() ] for subdir in sub_dirs: version = get_openpype_version_from_path(subdir) if not version: continue print(" - found: {} - {}".format(version, subdir)) openpype_versions.append((version, subdir)) return openpype_versions def get_requested_openpype_executable( exe, dir_list, requested_version ): requested_version_obj = OpenPypeVersion.from_string(requested_version) if not requested_version_obj: print(( ">>> Requested version '{}' does not match version regex '{}'" ).format(requested_version, VERSION_REGEX)) return None print(( ">>> Scanning for compatible requested version {}" ).format(requested_version)) openpype_versions = get_openpype_versions(dir_list) if not openpype_versions: return None # if looking for requested compatible version, # add the implicitly specified to the list too. if exe: exe_dir = os.path.dirname(exe) print("Looking for OpenPype at: {}".format(exe_dir)) version = get_openpype_version_from_path(exe_dir) if version: print(" - found: {} - {}".format(version, exe_dir)) openpype_versions.append((version, exe_dir)) matching_item = None compatible_versions = [] for version_item in openpype_versions: version, version_dir = version_item if requested_version_obj.has_compatible_release(version): compatible_versions.append(version_item) if version == requested_version_obj: # Store version item if version match exactly # - break if is found matching version matching_item = version_item break if not compatible_versions: return None compatible_versions.sort(key=lambda item: item[0]) if matching_item: version, version_dir = matching_item print(( "*** Found exact match build version {} in {}" ).format(version_dir, version)) else: version, version_dir = compatible_versions[-1] print(( "*** Latest compatible version found is {} in {}" ).format(version_dir, version)) # create list of executables for different platform and let # Deadline decide. exe_list = [ os.path.join(version_dir, "openpype_console.exe"), os.path.join(version_dir, "openpype_console"), os.path.join(version_dir, "MacOS", "openpype_console") ] return FileUtils.SearchFileList(";".join(exe_list)) def inject_openpype_environment(deadlinePlugin): """ Pull env vars from OpenPype and push them to rendering process. Used for correct paths, configuration from OpenPype etc. """ job = deadlinePlugin.GetJob() print(">>> Injecting OpenPype environments ...") try: exe_list, dir_list = get_openpype_executable() exe = FileUtils.SearchFileList(exe_list) requested_version = job.GetJobEnvironmentKeyValue("OPENPYPE_VERSION") if requested_version: exe = get_requested_openpype_executable( exe, dir_list, requested_version ) if exe is None: raise RuntimeError(( "Cannot find compatible version available for version {}" " requested by the job. Please add it through plugin" " configuration in Deadline or install it to configured" " directory." ).format(requested_version)) if not exe: raise RuntimeError(( "OpenPype executable was not found in the semicolon " "separated list \"{}\"." "The path to the render executable can be configured" " from the Plugin Configuration in the Deadline Monitor." ).format(";".join(exe_list))) print("--- OpenPype executable: {}".format(exe)) # tempfile.TemporaryFile cannot be used because of locking temp_file_name = "{}_{}.json".format( datetime.utcnow().strftime('%Y%m%d%H%M%S%f'), str(uuid.uuid1()) ) export_url = os.path.join(tempfile.gettempdir(), temp_file_name) print(">>> Temporary path: {}".format(export_url)) args = [ "--headless", "extractenvironments", export_url ] add_kwargs = { "project": job.GetJobEnvironmentKeyValue("AVALON_PROJECT"), "asset": job.GetJobEnvironmentKeyValue("AVALON_ASSET"), "task": job.GetJobEnvironmentKeyValue("AVALON_TASK"), "app": job.GetJobEnvironmentKeyValue("AVALON_APP_NAME"), "envgroup": "farm" } if job.GetJobEnvironmentKeyValue('IS_TEST'): args.append("--automatic-tests") if all(add_kwargs.values()): for key, value in add_kwargs.items(): args.extend(["--{}".format(key), value]) else: raise RuntimeError(( "Missing required env vars: AVALON_PROJECT, AVALON_ASSET," " AVALON_TASK, AVALON_APP_NAME" )) openpype_mongo = job.GetJobEnvironmentKeyValue("OPENPYPE_MONGO") if openpype_mongo: # inject env var for OP extractenvironments # SetEnvironmentVariable is important, not SetProcessEnv... deadlinePlugin.SetEnvironmentVariable("OPENPYPE_MONGO", openpype_mongo) if not os.environ.get("OPENPYPE_MONGO"): print(">>> Missing OPENPYPE_MONGO env var, process won't work") os.environ["AVALON_TIMEOUT"] = "5000" args_str = subprocess.list2cmdline(args) print(">>> Executing: {} {}".format(exe, args_str)) process_exitcode = deadlinePlugin.RunProcess( exe, args_str, os.path.dirname(exe), -1 ) if process_exitcode != 0: raise RuntimeError( "Failed to run OpenPype process to extract environments." ) print(">>> Loading file ...") with open(export_url) as fp: contents = json.load(fp) for key, value in contents.items(): deadlinePlugin.SetProcessEnvironmentVariable(key, value) if "PATH" in contents: # Set os.environ[PATH] so studio settings' path entries # can be used to define search path for executables. print(f">>> Setting 'PATH' Environment to: {contents['PATH']}") os.environ["PATH"] = contents["PATH"] script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") if script_url: script_url = script_url.format(**contents).replace("\\", "/") print(">>> Setting script path {}".format(script_url)) job.SetJobPluginInfoKeyValue("ScriptFilename", script_url) print(">>> Removing temporary file") os.remove(export_url) print(">> Injection end.") except Exception as e: if hasattr(e, "output"): print(">>> Exception {}".format(e.output)) import traceback print(traceback.format_exc()) print("!!! Injection failed.") RepositoryUtils.FailJob(job) raise def inject_ayon_environment(deadlinePlugin): """ Pull env vars from Ayon and push them to rendering process. Used for correct paths, configuration from OpenPype etc. """ job = deadlinePlugin.GetJob() print(">>> Injecting Ayon environments ...") try: exe_list = get_ayon_executable() exe = FileUtils.SearchFileList(exe_list) if not exe: raise RuntimeError(( "Ayon executable was not found in the semicolon " "separated list \"{}\"." "The path to the render executable can be configured" " from the Plugin Configuration in the Deadline Monitor." ).format(exe_list)) print("--- Ayon executable: {}".format(exe)) ayon_bundle_name = job.GetJobEnvironmentKeyValue("AYON_BUNDLE_NAME") if not ayon_bundle_name: raise RuntimeError("Missing env var in job properties " "AYON_BUNDLE_NAME") config = RepositoryUtils.GetPluginConfig("Ayon") ayon_server_url = ( job.GetJobEnvironmentKeyValue("AYON_SERVER_URL") or config.GetConfigEntryWithDefault("AyonServerUrl", "") ) ayon_api_key = ( job.GetJobEnvironmentKeyValue("AYON_API_KEY") or config.GetConfigEntryWithDefault("AyonApiKey", "") ) if not all([ayon_server_url, ayon_api_key]): raise RuntimeError(( "Missing required values for server url and api key. " "Please fill in Ayon Deadline plugin or provide by " "AYON_SERVER_URL and AYON_API_KEY" )) # tempfile.TemporaryFile cannot be used because of locking temp_file_name = "{}_{}.json".format( datetime.utcnow().strftime('%Y%m%d%H%M%S%f'), str(uuid.uuid1()) ) export_url = os.path.join(tempfile.gettempdir(), temp_file_name) print(">>> Temporary path: {}".format(export_url)) args = [ "--headless", "extractenvironments", export_url ] add_kwargs = { "project": job.GetJobEnvironmentKeyValue("AVALON_PROJECT"), "asset": job.GetJobEnvironmentKeyValue("AVALON_ASSET"), "task": job.GetJobEnvironmentKeyValue("AVALON_TASK"), "app": job.GetJobEnvironmentKeyValue("AVALON_APP_NAME"), "envgroup": "farm", } if job.GetJobEnvironmentKeyValue('IS_TEST'): args.append("--automatic-tests") if all(add_kwargs.values()): for key, value in add_kwargs.items(): args.extend(["--{}".format(key), value]) else: raise RuntimeError(( "Missing required env vars: AVALON_PROJECT, AVALON_ASSET," " AVALON_TASK, AVALON_APP_NAME" )) environment = { "AYON_SERVER_URL": ayon_server_url, "AYON_API_KEY": ayon_api_key, "AYON_BUNDLE_NAME": ayon_bundle_name, } for env, val in environment.items(): # Add the env var for the Render Plugin that is about to render deadlinePlugin.SetEnvironmentVariable(env, val) # Add the env var for current calls to `DeadlinePlugin.RunProcess` deadlinePlugin.SetProcessEnvironmentVariable(env, val) args_str = subprocess.list2cmdline(args) print(">>> Executing: {} {}".format(exe, args_str)) process_exitcode = deadlinePlugin.RunProcess( exe, args_str, os.path.dirname(exe), -1 ) if process_exitcode != 0: raise RuntimeError( "Failed to run Ayon process to extract environments." ) print(">>> Loading file ...") with open(export_url) as fp: contents = json.load(fp) for key, value in contents.items(): deadlinePlugin.SetProcessEnvironmentVariable(key, value) if "PATH" in contents: # Set os.environ[PATH] so studio settings' path entries # can be used to define search path for executables. print(f">>> Setting 'PATH' Environment to: {contents['PATH']}") os.environ["PATH"] = contents["PATH"] script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") if script_url: script_url = script_url.format(**contents).replace("\\", "/") print(">>> Setting script path {}".format(script_url)) job.SetJobPluginInfoKeyValue("ScriptFilename", script_url) print(">>> Removing temporary file") os.remove(export_url) print(">> Injection end.") except Exception as e: if hasattr(e, "output"): print(">>> Exception {}".format(e.output)) import traceback print(traceback.format_exc()) print("!!! Injection failed.") RepositoryUtils.FailJob(job) raise def get_ayon_executable(): """Return OpenPype Executable from Event Plug-in Settings Returns: (list) of paths Raises: (RuntimeError) if no path configured at all """ config = RepositoryUtils.GetPluginConfig("Ayon") exe_list = config.GetConfigEntryWithDefault("AyonExecutable", "") if not exe_list: raise RuntimeError("Path to Ayon executable not configured." "Please set it in Ayon Deadline Plugin.") # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": exe_list = exe_list.replace("\\ ", " ") # Expand user paths expanded_paths = [] for path in exe_list.split(";"): if path.startswith("~"): path = os.path.expanduser(path) expanded_paths.append(path) return ";".join(expanded_paths) def inject_render_job_id(deadlinePlugin): """Inject dependency ids to publish process as env var for validation.""" print(">>> Injecting render job id ...") job = deadlinePlugin.GetJob() dependency_ids = job.JobDependencyIDs print(">>> Dependency IDs: {}".format(dependency_ids)) render_job_ids = ",".join(dependency_ids) deadlinePlugin.SetProcessEnvironmentVariable("RENDER_JOB_IDS", render_job_ids) print(">>> Injection end.") def __main__(deadlinePlugin): print("*** GlobalJobPreload start ...") print(">>> Getting job ...") job = deadlinePlugin.GetJob() openpype_render_job = \ job.GetJobEnvironmentKeyValue('OPENPYPE_RENDER_JOB') or '0' openpype_publish_job = \ job.GetJobEnvironmentKeyValue('OPENPYPE_PUBLISH_JOB') or '0' openpype_remote_job = \ job.GetJobEnvironmentKeyValue('OPENPYPE_REMOTE_PUBLISH') or '0' if openpype_publish_job == '1' and openpype_render_job == '1': raise RuntimeError("Misconfiguration. Job couldn't be both " + "render and publish.") if openpype_publish_job == '1': inject_render_job_id(deadlinePlugin) if openpype_render_job == '1' or openpype_remote_job == '1': inject_openpype_environment(deadlinePlugin) ayon_render_job = \ job.GetJobEnvironmentKeyValue('AYON_RENDER_JOB') or '0' ayon_publish_job = \ job.GetJobEnvironmentKeyValue('AYON_PUBLISH_JOB') or '0' ayon_remote_job = \ job.GetJobEnvironmentKeyValue('AYON_REMOTE_PUBLISH') or '0' if ayon_publish_job == '1' and ayon_render_job == '1': raise RuntimeError("Misconfiguration. Job couldn't be both " + "render and publish.") if ayon_publish_job == '1': inject_render_job_id(deadlinePlugin) if ayon_render_job == '1' or ayon_remote_job == '1': inject_ayon_environment(deadlinePlugin) ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.options ================================================ [SceneFile] Type=filename Label=Scene Filename Category=Global Settings CategoryOrder=0 Index=0 Description=The scene filename as it exists on the network. Required=false DisableIfBlank=true [Environment] Type=filename Label=Scene Environment Category=Global Settings CategoryOrder=0 Index=1 Description=The Environment for the scene. Required=false DisableIfBlank=true [Job] Type=filename Label=Scene Job Category=Global Settings CategoryOrder=0 Index=2 Description=The Job that the scene belongs to. Required=false DisableIfBlank=true [SceneName] Type=filename Label=Scene Name Category=Global Settings CategoryOrder=0 Index=3 Description=The name of the scene to render Required=false DisableIfBlank=true [SceneVersion] Type=filename Label=Scene Version Category=Global Settings CategoryOrder=0 Index=4 Description=The version of the scene to render. Required=false DisableIfBlank=true [Version] Type=enum Values=10;11;12 Label=Harmony Version Category=Global Settings CategoryOrder=0 Index=5 Description=The version of Harmony to use. Required=false DisableIfBlank=true [IsDatabase] Type=Boolean Label=Is Database Scene Category=Global Settings CategoryOrder=0 Index=6 Description=Whether or not the scene is in the database or not Required=false DisableIfBlank=true [Camera] Type=string Label=Camera Category=Render Settings CategoryOrder=1 Index=0 Description=Specifies the camera to use for rendering images. If Blank, the scene will be rendered with the current Camera. Required=false DisableIfBlank=true [UsingResPreset] Type=Boolean Label=Use Resolution Preset Category=Render Settings CategoryOrder=1 Index=1 Description=Whether or not you are using a resolution preset. Required=false DisableIfBlank=true [ResolutionName] Type=enum Values=HDTV_1080p24;HDTV_1080p25;HDTV_720p24;4K_UHD;8K_UHD;DCI_2K;DCI_4K;film-2K;film-4K;film-1.33_H;film-1.66_H;film-1.66_V;Cineon;NTSC;PAL;2160p;1440p;1080p;720p;480p;360p;240p;low;Web_Video;Game_512;Game_512_Ortho;WebCC_Preview;Custom Label=Resolution Preset Category=Render Settings CategoryOrder=1 Index=2 Description=The resolution preset to use. Required=true Default=HDTV_1080p24 [PresetName] Type=string Label=Preset Name Category=Render Settings CategoryOrder=1 Index=3 Description=Specify the custom resolution name. Required=true Default= [ResolutionX] Type=integer Label=Resolution X Minimum=0 Maximum=1000000 Category=Render Settings CategoryOrder=1 Index=4 Description=Specifies the width of the rendered images. If 0, then the current resolution and Field of view will be used. Required=true Default=1920 [ResolutionY] Type=integer Label=Resolution Y Minimum=0 Maximum=1000000 Category=Render Settings CategoryOrder=1 Index=5 Description=Specifies the height of the rendered images. If 0, then the current resolution and Field of view will be used. Required=true Default=1080 [FieldOfView] Type=float Label=Field Of View Minimum=0 Maximum=89 DecimalPlaces=2 Category=Render Settings CategoryOrder=1 Index=6 Description=Specifies the field of view of the rendered images. If 0, then the current resolution and Field of view will be used. Required=true Default=41.11 [Output0Node] Type=string Label=Render Node 0 Name Category=Output Settings CategoryOrder=2 Index=0 Description=The name of the render node. Required=false DisableIfBlank=true [Output0Type] Type=enum Values=Image;Movie Label=Render Node 0 Type Category=Output Settings CategoryOrder=2 Index=1 Description=The type of output that the render node is producing. Required=false DisableIfBlank=true [Output0Path] Type=string Label=Render Node 0 Path Category=Output Settings CategoryOrder=2 Index=2 Description=The output path and file name of the output files. Required=false DisableIfBlank=true [Output0LeadingZero] Type=integer Label=Render Node 0 Leading Zeroes Category=Output Settings CategoryOrder=2 Minimum=0 Maximum=5 Index=3 Description=The number of leading zeroes for a 1 digit frame number. (1 less then the full padded length) Required=false DisableIfBlank=true [Output0Format] Type=string Label=Render Node 0 Format Category=Output Settings CategoryOrder=2 Index=4 Description=The format for the rendered output images. Required=false DisableIfBlank=true [Output0StartFrame] Type=integer Label=Render Node 0 Start Frame Category=Output Settings CategoryOrder=2 Minimum=1 Index=5 Description=The frame that will correspond to frame one when numbering. If this value is not 1 then the monitor's job output features will not work properly. Required=false DisableIfBlank=true [Output1Node] Type=string Label=Render Node 1 Name Category=Output Settings CategoryOrder=2 Index=6 Description=The name of the render node. Required=false DisableIfBlank=true [Output1Type] Type=enum Values=Image;Movie Label=Render Node 1 Type Category=Output Settings CategoryOrder=2 Index=7 Description=The type of output that the render node is producing. Required=false DisableIfBlank=true [Output1Path] Type=string Label=Render Node 1 Path Category=Output Settings CategoryOrder=2 Index=8 Description=The output path and file name of the output files. Required=false DisableIfBlank=true [Output1LeadingZero] Type=integer Label=Render Node 1 Leading Zeroes Category=Output Settings CategoryOrder=2 Minimum=0 Maximum=5 Index=9 Description=The number of leading zeroes for a 1 digit frame number. (1 less then the full padded length) Required=false DisableIfBlank=true [Output1Format] Type=string Label=Render Node 1 Format Category=Output Settings CategoryOrder=2 Index=10 Description=The format for the rendered output images. Required=false DisableIfBlank=true [Output1StartFrame] Type=integer Label=Render Node 1 Start Frame Category=Output Settings CategoryOrder=2 Minimum=1 Index=11 Description=The frame that will correspond to frame one when numbering. If this value is not 1 then the monitor's job output features will not work properly. Required=false DisableIfBlank=true [Output2Node] Type=string Label=Render Node 2 Name Category=Output Settings CategoryOrder=2 Index=12 Description=The name of the render node. Required=false DisableIfBlank=true [Output2Type] Type=enum Values=Image;Movie Label=Render Node 2 Type Category=Output Settings CategoryOrder=2 Index=13 Description=The type of output that the render node is producing. Required=false DisableIfBlank=true [Output2Path] Type=string Label=Render Node 2 Path Category=Output Settings CategoryOrder=2 Index=14 Description=The output path and file name of the output files. Required=false DisableIfBlank=true [Output2LeadingZero] Type=integer Label=Render Node 2 Leading Zeroes Category=Output Settings CategoryOrder=2 Minimum=0 Maximum=5 Index=15 Description=The number of leading zeroes for a 1 digit frame number. (1 less then the full padded length) Required=false DisableIfBlank=true [Output2Format] Type=string Label=Render Node 2 Format Category=Output Settings CategoryOrder=2 Index=16 Description=The format for the rendered output images. Required=false DisableIfBlank=true [Output2StartFrame] Type=integer Label=Render Node 2 Start Frame Category=Output Settings CategoryOrder=2 Minimum=1 Index=17 Description=The frame that will correspond to frame one when numbering. If this value is not 1 then the monitor's job output features will not work properly. Required=false DisableIfBlank=true [Output3Node] Type=string Label=Render Node 3 Name Category=Output Settings CategoryOrder=2 Index=18 Description=The name of the render node. Required=false DisableIfBlank=true [Output3Type] Type=enum Values=Image;Movie Label=Render Node 3 Type Category=Output Settings CategoryOrder=2 Index=19 Description=The type of output that the render node is producing. Required=false DisableIfBlank=true [Output3Path] Type=string Label=Render Node 3 Path Category=Output Settings CategoryOrder=2 Index=20 Description=The output path and file name of the output files. Required=false DisableIfBlank=true [Output3LeadingZero] Type=integer Label=Render Node 3 Leading Zeroes Category=Output Settings CategoryOrder=2 Minimum=0 Maximum=5 Index=21 Description=The number of leading zeroes for a 1 digit frame number. (1 less then the full padded length) Required=false DisableIfBlank=true [Output3Format] Type=string Label=Render Node 3 Format Category=Output Settings CategoryOrder=2 Index=22 Description=The format for the rendered output images. Required=false DisableIfBlank=true [Output3StartFrame] Type=integer Label=Render Node 3 Start Frame Category=Output Settings CategoryOrder=2 Minimum=1 Index=23 Description=The frame that will correspond to frame one when numbering. If this value is not 1 then the monitor's job output features will not work properly. Required=false DisableIfBlank=true [Output4Node] Type=string Label=Render Node 4 Name Category=Output Settings CategoryOrder=2 Index=24 Description=The name of the render node. Required=false DisableIfBlank=true [Output4Type] Type=enum Values=Image;Movie Label=Render Node 4 Type Category=Output Settings CategoryOrder=2 Index=25 Description=The type of output that the render node is producing. Required=false DisableIfBlank=true [Output4Path] Type=string Label=Render Node 4 Path Category=Output Settings CategoryOrder=2 Index=26 Description=The output path and file name of the output files. Required=false DisableIfBlank=true [Output4LeadingZero] Type=integer Label=Render Node 4 Leading Zeroes Category=Output Settings CategoryOrder=2 Minimum=0 Maximum=5 Index=27 Description=The number of leading zeroes for a 1 digit frame number. (1 less then the full padded length) Required=false DisableIfBlank=true [Output4Format] Type=string Label=Render Node 4 Format Category=Output Settings CategoryOrder=2 Index=28 Description=The format for the rendered output images. Required=false DisableIfBlank=true [Output4StartFrame] Type=integer Label=Render Node 4 Start Frame Category=Output Settings CategoryOrder=2 Minimum=1 Index=29 Description=The frame that will correspond to frame one when numbering. If this value is not 1 then the monitor's job output features will not work properly. Required=false DisableIfBlank=true [Output5Node] Type=string Label=Render Node 5 Name Category=Output Settings CategoryOrder=2 Index=30 Description=The name of the render node. Required=false DisableIfBlank=true [Output5Type] Type=enum Values=Image;Movie Label=Render Node 5 Type Category=Output Settings CategoryOrder=2 Index=31 Description=The type of output that the render node is producing. Required=false DisableIfBlank=true [Output5Path] Type=string Label=Render Node 5 Path Category=Output Settings CategoryOrder=2 Index=32 Description=The output path and file name of the output files. Required=false DisableIfBlank=true [Output5LeadingZero] Type=integer Label=Render Node 5 Leading Zeroes Category=Output Settings CategoryOrder=2 Minimum=0 Maximum=5 Index=33 Description=The number of leading zeroes for a 1 digit frame number. (1 less then the full padded length) Required=false DisableIfBlank=true [Output5Format] Type=string Label=Render Node 5 Format Category=Output Settings CategoryOrder=2 Index=34 Description=The format for the rendered output images. Required=false DisableIfBlank=true [Output5StartFrame] Type=integer Label=Render Node 5 Start Frame Category=Output Settings CategoryOrder=2 Minimum=1 Index=35 Description=The frame that will correspond to frame one when numbering. If this value is not 1 then the monitor's job output features will not work properly. Required=false DisableIfBlank=true ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param ================================================ [About] Type=label Label=About Category=About Plugin CategoryOrder=-1 Index=0 Default=Harmony Render Plugin for Deadline Description=Not configurable [ConcurrentTasks] Type=label Label=ConcurrentTasks Category=About Plugin CategoryOrder=-1 Index=0 Default=True Description=Not configurable [Harmony_RenderExecutable_10] Type=multilinemultifilename Category=Render Executables CategoryOrder=0 Index=0 Label=Harmony 10 Render Executable Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. Default=C:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 10.0\win64\bin\Stage.exe [Harmony_RenderExecutable_11] Type=multilinemultifilename Category=Render Executables CategoryOrder=0 Index=1 Label=Harmony 11 Render Executable Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. Default=C:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 11.0\win64\bin\Stage.exe [Harmony_RenderExecutable_12] Type=multilinemultifilename Category=Render Executables CategoryOrder=0 Index=2 Label=Harmony 12 Render Executable Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. Default=C:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 12.0 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 12.0 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_12/lnx86_64/bin/HarmonyPremium [Harmony_RenderExecutable_14] Type=multilinemultifilename Category=Render Executables CategoryOrder=0 Index=3 Label=Harmony 14 Render Executable Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. Default=C:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 14.0 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 14.0 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_14/lnx86_64/bin/HarmonyPremium [Harmony_RenderExecutable_15] Type=multilinemultifilename Category=Render Executables CategoryOrder=0 Index=4 Label=Harmony 15 Render Executable Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. Default=C:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 15.0 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 15.0 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_15.0/lnx86_64/bin/HarmonyPremium [Harmony_RenderExecutable_17] Type=multilinemultifilename Category=Render Executables CategoryOrder=0 Index=4 Label=Harmony 17 Render Executable Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 17 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 17 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_17/lnx86_64/bin/HarmonyPremium [Harmony_RenderExecutable_20] Type=multilinemultifilename Category=Render Executables CategoryOrder=0 Index=4 Label=Harmony 20 Render Executable Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 20 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 20 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_20/lnx86_64/bin/HarmonyPremium [Harmony_RenderExecutable_21] Type=multilinemultifilename Category=Render Executables CategoryOrder=0 Index=4 Label=Harmony 21 Render Executable Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 21 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 21 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_21/lnx86_64/bin/HarmonyPremium [Harmony_RenderExecutable_22] Type=multilinemultifilename Category=Render Executables CategoryOrder=0 Index=4 Label=Harmony 22 Render Executable Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 22 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 22 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_22/lnx86_64/bin/HarmonyPremium ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py ================================================ #!/usr/bin/env python3 from System import * from System.Diagnostics import * from System.IO import * from System.Text import * from Deadline.Plugins import * from Deadline.Scripting import * def GetDeadlinePlugin(): return HarmonyOpenPypePlugin() def CleanupDeadlinePlugin( deadlinePlugin ): deadlinePlugin.Cleanup() class HarmonyOpenPypePlugin( DeadlinePlugin ): def __init__( self ): super().__init__() self.InitializeProcessCallback += self.InitializeProcess self.RenderExecutableCallback += self.RenderExecutable self.RenderArgumentCallback += self.RenderArgument self.CheckExitCodeCallback += self.CheckExitCode def Cleanup( self ): print("Cleanup") for stdoutHandler in self.StdoutHandlers: del stdoutHandler.HandleCallback del self.InitializeProcessCallback del self.RenderExecutableCallback del self.RenderArgumentCallback def CheckExitCode( self, exitCode ): print("check code") if exitCode != 0: if exitCode == 100: self.LogInfo( "Renderer reported an error with error code 100. This will be ignored, since the option to ignore it is specified in the Job Properties." ) else: self.FailRender( "Renderer returned non-zero error code %d. Check the renderer's output." % exitCode ) def InitializeProcess( self ): self.PluginType = PluginType.Simple self.StdoutHandling = True self.PopupHandling = True self.AddStdoutHandlerCallback( "Rendered frame ([0-9]+)" ).HandleCallback += self.HandleStdoutProgress def HandleStdoutProgress( self ): startFrame = self.GetStartFrame() endFrame = self.GetEndFrame() if( endFrame - startFrame + 1 != 0 ): self.SetProgress( 100 * ( int(self.GetRegexMatch(1)) - startFrame + 1 ) / ( endFrame - startFrame + 1 ) ) def RenderExecutable( self ): version = int( self.GetPluginInfoEntry( "Version" ) ) exe = "" exeList = self.GetConfigEntry( "Harmony_RenderExecutable_" + str(version) ) exe = FileUtils.SearchFileList( exeList ) if( exe == "" ): self.FailRender( "Harmony render executable was not found in the configured separated list \"" + exeList + "\". The path to the render executable can be configured from the Plugin Configuration in the Deadline Monitor." ) return exe def RenderArgument( self ): renderArguments = "-batch" if self.GetBooleanPluginInfoEntryWithDefault( "UsingResPreset", False ): resName = self.GetPluginInfoEntryWithDefault( "ResolutionName", "HDTV_1080p24" ) if resName == "Custom": renderArguments += " -res " + self.GetPluginInfoEntryWithDefault( "PresetName", "HDTV_1080p24" ) else: renderArguments += " -res " + resName else: resolutionX = self.GetIntegerPluginInfoEntryWithDefault( "ResolutionX", -1 ) resolutionY = self.GetIntegerPluginInfoEntryWithDefault( "ResolutionY", -1 ) fov = self.GetFloatPluginInfoEntryWithDefault( "FieldOfView", -1 ) if resolutionX > 0 and resolutionY > 0 and fov > 0: renderArguments += " -res " + str( resolutionX ) + " " + str( resolutionY ) + " " + str( fov ) camera = self.GetPluginInfoEntryWithDefault( "Camera", "" ) if not camera == "": renderArguments += " -camera " + camera startFrame = str( self.GetStartFrame() ) endFrame = str( self.GetEndFrame() ) renderArguments += " -frames " + startFrame + " " + endFrame if not self.GetBooleanPluginInfoEntryWithDefault( "IsDatabase", False ): sceneFilename = self.GetPluginInfoEntryWithDefault( "SceneFile", self.GetDataFilename() ) sceneFilename = RepositoryUtils.CheckPathMapping( sceneFilename ) renderArguments += " \"" + sceneFilename + "\"" else: environment = self.GetPluginInfoEntryWithDefault( "Environment", "" ) renderArguments += " -env " + environment job = self.GetPluginInfoEntryWithDefault( "Job", "" ) renderArguments += " -job " + job scene = self.GetPluginInfoEntryWithDefault( "SceneName", "" ) renderArguments += " -scene " + scene version = self.GetPluginInfoEntryWithDefault( "SceneVersion", "" ) renderArguments += " -version " + version #tempSceneDirectory = self.CreateTempDirectory( "thread" + str(self.GetThreadNumber()) ) #preRenderScript = rendernodeNum = 0 scriptBuilder = StringBuilder() while True: nodeName = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Node", "" ) if nodeName == "": break nodeType = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Type", "Image" ) if nodeType == "Image": nodePath = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Path", "" ) nodeLeadingZero = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "LeadingZero", "" ) nodeFormat = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Format", "" ) nodeStartFrame = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "StartFrame", "" ) if not nodePath == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"drawingName\", 1, \"" + nodePath + "\" );") if not nodeLeadingZero == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"leadingZeros\", 1, \"" + nodeLeadingZero + "\" );") if not nodeFormat == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"drawingType\", 1, \"" + nodeFormat + "\" );") if not nodeStartFrame == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"start\", 1, \"" + nodeStartFrame + "\" );") if nodeType == "Movie": nodePath = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Path", "" ) if not nodePath == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"moviePath\", 1, \"" + nodePath + "\" );") rendernodeNum += 1 tempDirectory = self.CreateTempDirectory( "thread" + str(self.GetThreadNumber()) ) preRenderScriptName = Path.Combine( tempDirectory, "preRenderScript.txt" ) File.WriteAllText( preRenderScriptName, scriptBuilder.ToString() ) preRenderInlineScript = self.GetPluginInfoEntryWithDefault( "PreRenderInlineScript", "" ) if preRenderInlineScript: renderArguments += " -preRenderInlineScript \"" + preRenderInlineScript +"\"" renderArguments += " -preRenderScript \"" + preRenderScriptName +"\"" return renderArguments ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.options ================================================ [Arguments] Type=string Label=Arguments Category=Python Options CategoryOrder=0 Index=1 Description=The arguments to pass to the script. If no arguments are required, leave this blank. Required=false DisableIfBlank=true ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.param ================================================ [About] Type=label Label=About Category=About Plugin CategoryOrder=-1 Index=0 Default=OpenPype Plugin for Deadline Description=Not configurable [OpenPypeInstallationDirs] Type=multilinemultifolder Label=Directories where OpenPype versions are installed Category=OpenPype Installation Directories CategoryOrder=0 Index=0 Default=C:\Program Files (x86)\OpenPype Description=Path or paths to directories where multiple versions of OpenPype might be installed. Enter every such path on separate lines. [OpenPypeExecutable] Type=multilinemultifilename Label=OpenPype Executable Category=OpenPype Executables CategoryOrder=1 Index=0 Default= Description=The path to the OpenPype executable. Enter alternative paths on separate lines. ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py ================================================ #!/usr/bin/env python3 from System.IO import Path from System.Text.RegularExpressions import Regex from Deadline.Plugins import PluginType, DeadlinePlugin from Deadline.Scripting import ( StringUtils, FileUtils, DirectoryUtils, RepositoryUtils ) import re import os import platform ###################################################################### # This is the function that Deadline calls to get an instance of the # main DeadlinePlugin class. ###################################################################### def GetDeadlinePlugin(): return OpenPypeDeadlinePlugin() def CleanupDeadlinePlugin(deadlinePlugin): deadlinePlugin.Cleanup() class OpenPypeDeadlinePlugin(DeadlinePlugin): """ Standalone plugin for publishing from OpenPype. Calls OpenPype executable 'openpype_console' from first correctly found file based on plugin configuration. Uses 'publish' command and passes path to metadata json file, which contains all needed information for publish process. """ def __init__(self): super().__init__() self.InitializeProcessCallback += self.InitializeProcess self.RenderExecutableCallback += self.RenderExecutable self.RenderArgumentCallback += self.RenderArgument def Cleanup(self): for stdoutHandler in self.StdoutHandlers: del stdoutHandler.HandleCallback del self.InitializeProcessCallback del self.RenderExecutableCallback del self.RenderArgumentCallback def InitializeProcess(self): self.PluginType = PluginType.Simple self.StdoutHandling = True self.SingleFramesOnly = self.GetBooleanPluginInfoEntryWithDefault( "SingleFramesOnly", False) self.LogInfo("Single Frames Only: %s" % self.SingleFramesOnly) self.AddStdoutHandlerCallback( ".*Progress: (\d+)%.*").HandleCallback += self.HandleProgress @staticmethod def get_openpype_version_from_path(path, build=True): """Get OpenPype version from provided path. path (str): Path to scan. build (bool, optional): Get only builds, not sources Returns: str or None: version of OpenPype if found. """ # fix path for application bundle on macos if platform.system().lower() == "darwin": path = os.path.join(path, "MacOS") version_file = os.path.join(path, "openpype", "version.py") if not os.path.isfile(version_file): return None # skip if the version is not build exe = os.path.join(path, "openpype_console.exe") if platform.system().lower() in ["linux", "darwin"]: exe = os.path.join(path, "openpype_console") # if only builds are requested if build and not os.path.isfile(exe): # noqa: E501 print(f" ! path is not a build: {path}") return None version = {} with open(version_file, "r") as vf: exec(vf.read(), version) version_match = re.search(r"(\d+\.\d+.\d+).*", version["__version__"]) return version_match[1] def RenderExecutable(self): job = self.GetJob() openpype_versions = [] # if the job requires specific OpenPype version, # lets go over all available and find compatible build. requested_version = job.GetJobEnvironmentKeyValue("OPENPYPE_VERSION") if requested_version: self.LogInfo(( "Scanning for compatible requested " f"version {requested_version}")) dir_list = self.GetConfigEntry("OpenPypeInstallationDirs") # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": dir_list = dir_list.replace("\\ ", " ") for dir_list in dir_list.split(","): install_dir = DirectoryUtils.SearchDirectoryList(dir_list) if install_dir: sub_dirs = [ f.path for f in os.scandir(install_dir) if f.is_dir() ] for subdir in sub_dirs: version = self.get_openpype_version_from_path(subdir) if not version: continue openpype_versions.append((version, subdir)) exe_list = self.GetConfigEntry("OpenPypeExecutable") # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": exe_list = exe_list.replace("\\ ", " ") exe = FileUtils.SearchFileList(exe_list) if openpype_versions: # if looking for requested compatible version, # add the implicitly specified to the list too. version = self.get_openpype_version_from_path( os.path.dirname(exe)) if version: openpype_versions.append((version, os.path.dirname(exe))) if requested_version: # sort detected versions if openpype_versions: openpype_versions.sort( key=lambda ver: [ int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", ver[0]) ]) requested_major, requested_minor, _ = requested_version.split(".")[:3] # noqa: E501 compatible_versions = [] for version in openpype_versions: v = version[0].split(".")[:3] if v[0] == requested_major and v[1] == requested_minor: compatible_versions.append(version) if not compatible_versions: self.FailRender(("Cannot find compatible version available " "for version {} requested by the job. " "Please add it through plugin configuration " "in Deadline or install it to configured " "directory.").format(requested_version)) # sort compatible versions nad pick the last one compatible_versions.sort( key=lambda ver: [ int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", ver[0]) ]) # create list of executables for different platform and let # Deadline decide. exe_list = [ os.path.join( compatible_versions[-1][1], "openpype_console.exe"), os.path.join( compatible_versions[-1][1], "openpype_console"), os.path.join( compatible_versions[-1][1], "MacOS", "openpype_console") ] exe = FileUtils.SearchFileList(";".join(exe_list)) if exe == "": self.FailRender( "OpenPype executable was not found " + "in the semicolon separated list " + "\"" + ";".join(exe_list) + "\". " + "The path to the render executable can be configured " + "from the Plugin Configuration in the Deadline Monitor.") return exe def RenderArgument(self): arguments = str(self.GetPluginInfoEntryWithDefault("Arguments", "")) arguments = RepositoryUtils.CheckPathMapping(arguments) arguments = re.sub(r"<(?i)STARTFRAME>", str(self.GetStartFrame()), arguments) arguments = re.sub(r"<(?i)ENDFRAME>", str(self.GetEndFrame()), arguments) arguments = re.sub(r"<(?i)QUOTE>", "\"", arguments) arguments = self.ReplacePaddedFrame(arguments, "<(?i)STARTFRAME%([0-9]+)>", self.GetStartFrame()) arguments = self.ReplacePaddedFrame(arguments, "<(?i)ENDFRAME%([0-9]+)>", self.GetEndFrame()) count = 0 for filename in self.GetAuxiliaryFilenames(): localAuxFile = Path.Combine(self.GetJobsDataDirectory(), filename) arguments = re.sub(r"<(?i)AUXFILE" + str(count) + r">", localAuxFile.replace("\\", "/"), arguments) count += 1 return arguments def ReplacePaddedFrame(self, arguments, pattern, frame): frameRegex = Regex(pattern) while True: frameMatch = frameRegex.Match(arguments) if frameMatch.Success: paddingSize = int(frameMatch.Groups[1].Value) if paddingSize > 0: padding = StringUtils.ToZeroPaddedString(frame, paddingSize, False) else: padding = str(frame) arguments = arguments.replace(frameMatch.Groups[0].Value, padding) else: break return arguments def HandleProgress(self): progress = float(self.GetRegexMatch(1)) self.SetProgress(progress) ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.options ================================================ [OIIOToolPath] Type=filename Label=OIIO Tool location Category=OIIO Index=0 Description=OIIO Tool executable to use. Required=false DisableIfBlank=true [OutputFile] Type=filenamesave Label=Output File Category=Output Index=0 Description=The scene filename as it exists on the network Required=false DisableIfBlank=true [CleanupTiles] Type=boolean Category=Options Index=0 Label=Cleanup Tiles Required=false DisableIfBlank=true Description=If enabled, the OpenPype Tile Assembler will cleanup all tiles after assembly. [Renderer] Type=string Label=Renderer Category=Quicktime Info Index=0 Description=Renderer name Required=false DisableIfBlank=true ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.param ================================================ [About] Type=label Label=About Category=About Plugin CategoryOrder=-1 Index=0 Default=OpenPype Tile Assembler Plugin for Deadline Description=Not configurable [OIIOTool_RenderExecutable] Type=multilinemultifilename Label=OIIO Tool Executable Category=Render Executables CategoryOrder=0 Default=C:\Program Files\OIIO\bin\oiiotool.exe;/usr/bin/oiiotool Description=The path to the Open Image IO Tool executable file used for rendering. Enter alternative paths on separate lines. W ================================================ FILE: openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py ================================================ # -*- coding: utf-8 -*- """Tile Assembler Plugin using Open Image IO tool. Todo: Currently we support only EXRs with their data window set. """ import os import re import subprocess import xml.etree.ElementTree from System.IO import Path from Deadline.Plugins import DeadlinePlugin from Deadline.Scripting import ( FileUtils, RepositoryUtils, SystemUtils) version_major = 1 version_minor = 0 version_patch = 0 version_string = "{}.{}.{}".format(version_major, version_minor, version_patch) STRING_TAGS = { "format" } INT_TAGS = { "x", "y", "z", "width", "height", "depth", "full_x", "full_y", "full_z", "full_width", "full_height", "full_depth", "tile_width", "tile_height", "tile_depth", "nchannels", "alpha_channel", "z_channel", "deep", "subimages", } XML_CHAR_REF_REGEX_HEX = re.compile(r"&#x?[0-9a-fA-F]+;") # Regex to parse array attributes ARRAY_TYPE_REGEX = re.compile(r"^(int|float|string)\[\d+\]$") def convert_value_by_type_name(value_type, value): """Convert value to proper type based on type name. In some cases value types have custom python class. """ # Simple types if value_type == "string": return value if value_type == "int": return int(value) if value_type == "float": return float(value) # Vectors will probably have more types if value_type in ("vec2f", "float2"): return [float(item) for item in value.split(",")] # Matrix should be always have square size of element 3x3, 4x4 # - are returned as list of lists if value_type == "matrix": output = [] current_index = -1 parts = value.split(",") parts_len = len(parts) if parts_len == 1: divisor = 1 elif parts_len == 4: divisor = 2 elif parts_len == 9: divisor = 3 elif parts_len == 16: divisor = 4 else: print("Unknown matrix resolution {}. Value: \"{}\"".format( parts_len, value )) for part in parts: output.append(float(part)) return output for idx, item in enumerate(parts): list_index = idx % divisor if list_index > current_index: current_index = list_index output.append([]) output[list_index].append(float(item)) return output if value_type == "rational2i": parts = value.split("/") top = float(parts[0]) bottom = 1.0 if len(parts) != 1: bottom = float(parts[1]) return float(top) / float(bottom) if value_type == "vector": parts = [part.strip() for part in value.split(",")] output = [] for part in parts: if part == "-nan": output.append(None) continue try: part = float(part) except ValueError: pass output.append(part) return output if value_type == "timecode": return value # Array of other types is converted to list re_result = ARRAY_TYPE_REGEX.findall(value_type) if re_result: array_type = re_result[0] output = [] for item in value.split(","): output.append( convert_value_by_type_name(array_type, item) ) return output print(( "Dev note (missing implementation):" " Unknown attrib type \"{}\". Value: {}" ).format(value_type, value)) return value def parse_oiio_xml_output(xml_string): """Parse xml output from OIIO info command.""" output = {} if not xml_string: return output # Fix values with ampresand (lazy fix) # - oiiotool exports invalid xml which ElementTree can't handle # e.g. "" # WARNING: this will affect even valid character entities. If you need # those values correctly, this must take care of valid character ranges. # See https://github.com/pypeclub/OpenPype/pull/2729 matches = XML_CHAR_REF_REGEX_HEX.findall(xml_string) for match in matches: new_value = match.replace("&", "&") xml_string = xml_string.replace(match, new_value) tree = xml.etree.ElementTree.fromstring(xml_string) attribs = {} output["attribs"] = attribs for child in tree: tag_name = child.tag if tag_name == "attrib": attrib_def = child.attrib value = convert_value_by_type_name( attrib_def["type"], child.text ) attribs[attrib_def["name"]] = value continue # Channels are stored as tex on each child if tag_name == "channelnames": value = [] for channel in child: value.append(channel.text) # Convert known integer type tags to int elif tag_name in INT_TAGS: value = int(child.text) # Keep value of known string tags elif tag_name in STRING_TAGS: value = child.text # Keep value as text for unknown tags # - feel free to add more tags else: value = child.text print(( "Dev note (missing implementation):" " Unknown tag \"{}\". Value \"{}\"" ).format(tag_name, value)) output[child.tag] = value return output def info_about_input(oiiotool_path, filepath): args = [ oiiotool_path, "--info", "-v", "-i:infoformat=xml", filepath ] popen = subprocess.Popen(args, stdout=subprocess.PIPE) _stdout, _stderr = popen.communicate() output = "" if _stdout: output += _stdout.decode("utf-8", errors="backslashreplace") if _stderr: output += _stderr.decode("utf-8", errors="backslashreplace") output = output.replace("\r\n", "\n") xml_started = False lines = [] for line in output.split("\n"): if not xml_started: if not line.startswith("<"): continue xml_started = True if xml_started: lines.append(line) if not xml_started: raise ValueError( "Failed to read input file \"{}\".\nOutput:\n{}".format( filepath, output ) ) xml_text = "\n".join(lines) return parse_oiio_xml_output(xml_text) def GetDeadlinePlugin(): # noqa: N802 """Helper.""" return OpenPypeTileAssembler() def CleanupDeadlinePlugin(deadlinePlugin): # noqa: N802, N803 """Helper.""" deadlinePlugin.cleanup() class OpenPypeTileAssembler(DeadlinePlugin): """Deadline plugin for assembling tiles using OIIO.""" def __init__(self): """Init.""" super().__init__() self.InitializeProcessCallback += self.initialize_process self.RenderExecutableCallback += self.render_executable self.RenderArgumentCallback += self.render_argument self.PreRenderTasksCallback += self.pre_render_tasks self.PostRenderTasksCallback += self.post_render_tasks def cleanup(self): """Cleanup function.""" for stdoutHandler in self.StdoutHandlers: del stdoutHandler.HandleCallback del self.InitializeProcessCallback del self.RenderExecutableCallback del self.RenderArgumentCallback del self.PreRenderTasksCallback del self.PostRenderTasksCallback def initialize_process(self): """Initialization.""" self.LogInfo("Plugin version: {}".format(version_string)) self.SingleFramesOnly = True self.StdoutHandling = True self.renderer = self.GetPluginInfoEntryWithDefault( "Renderer", "undefined") self.AddStdoutHandlerCallback( ".*Error.*").HandleCallback += self.handle_stdout_error def render_executable(self): """Get render executable name. Get paths from plugin configuration, find executable and return it. Returns: (str): Render executable. """ oiiotool_exe_list = self.GetConfigEntry("OIIOTool_RenderExecutable") oiiotool_exe = FileUtils.SearchFileList(oiiotool_exe_list) if oiiotool_exe == "": self.FailRender(("No file found in the semicolon separated " "list \"{}\". The path to the render executable " "can be configured from the Plugin Configuration " "in the Deadline Monitor.").format( oiiotool_exe_list)) return oiiotool_exe def render_argument(self): """Generate command line arguments for render executable. Returns: (str): arguments to add to render executable. """ # Read tile config file. This file is in compatible format with # Draft Tile Assembler data = {} with open(self.config_file, "rU") as f: for text in f: # Parsing key-value pair and removing white-space # around the entries info = [x.strip() for x in text.split("=", 1)] if len(info) > 1: try: data[str(info[0])] = info[1] except Exception as e: # should never be called self.FailRender( "Cannot parse config file: {}".format(e)) # Get output file. We support only EXRs now. output_file = data["ImageFileName"] output_file = RepositoryUtils.CheckPathMapping(output_file) output_file = self.process_path(output_file) tile_info = [] for tile in range(int(data["TileCount"])): tile_info.append({ "filepath": data["Tile{}".format(tile)], "pos_x": int(data["Tile{}X".format(tile)]), "pos_y": int(data["Tile{}Y".format(tile)]), "height": int(data["Tile{}Height".format(tile)]), "width": int(data["Tile{}Width".format(tile)]) }) arguments = self.tile_oiio_args( int(data["ImageWidth"]), int(data["ImageHeight"]), tile_info, output_file) self.LogInfo( "Using arguments: {}".format(" ".join(arguments))) self.tiles = tile_info return " ".join(arguments) def process_path(self, filepath): """Handle slashes in file paths.""" if SystemUtils.IsRunningOnWindows(): filepath = filepath.replace("/", "\\") if filepath.startswith("\\") and not filepath.startswith("\\\\"): filepath = "\\" + filepath else: filepath = filepath.replace("\\", "/") return filepath def pre_render_tasks(self): """Load config file and do remapping.""" self.LogInfo("OpenPype Tile Assembler starting...") config_file = self.GetPluginInfoEntry("ConfigFile") temp_scene_directory = self.CreateTempDirectory( "thread" + str(self.GetThreadNumber())) temp_scene_filename = Path.GetFileName(config_file) self.config_file = Path.Combine( temp_scene_directory, temp_scene_filename) if SystemUtils.IsRunningOnWindows(): RepositoryUtils.CheckPathMappingInFileAndReplaceSeparator( config_file, self.config_file, "/", "\\") else: RepositoryUtils.CheckPathMappingInFileAndReplaceSeparator( config_file, self.config_file, "\\", "/") os.chmod(self.config_file, os.stat(self.config_file).st_mode) def post_render_tasks(self): """Cleanup tiles if required.""" if self.GetBooleanPluginInfoEntryWithDefault("CleanupTiles", False): self.LogInfo("Cleaning up Tiles...") for tile in self.tiles: try: self.LogInfo("Deleting: {}".format(tile["filepath"])) os.remove(tile["filepath"]) # By this time we would have errored out # if error on missing was enabled except KeyError: pass except OSError: self.LogInfo("Failed to delete: {}".format( tile["filepath"])) pass self.LogInfo("OpenPype Tile Assembler Job finished.") def handle_stdout_error(self): """Handle errors in stdout.""" self.FailRender(self.GetRegexMatch(0)) def tile_oiio_args( self, output_width, output_height, tile_info, output_path): """Generate oiio tool arguments for tile assembly. Args: output_width (int): Width of output image. output_height (int): Height of output image. tiles_info (list): List of tile items, each item must be dictionary with `filepath`, `pos_x` and `pos_y` keys representing path to file and x, y coordinates on output image where top-left point of tile item should start. output_path (str): Path to file where should be output stored. Returns: (list): oiio tools arguments. """ args = [] # Create new image with output resolution, and with same type and # channels as input oiiotool_path = self.render_executable() first_tile_path = tile_info[0]["filepath"] first_tile_info = info_about_input(oiiotool_path, first_tile_path) create_arg_template = "--create{} {}x{} {}" image_type = "" image_format = first_tile_info.get("format") if image_format: image_type = ":type={}".format(image_format) create_arg = create_arg_template.format( image_type, output_width, output_height, first_tile_info["nchannels"] ) args.append(create_arg) for tile in tile_info: path = tile["filepath"] pos_x = tile["pos_x"] tile_height = info_about_input(oiiotool_path, path)["height"] if self.renderer == "vray": pos_y = tile["pos_y"] else: pos_y = output_height - tile["pos_y"] - tile_height # Add input path and make sure inputs origin is 0, 0 args.append(path) args.append("--origin +0+0") # Swap to have input as foreground args.append("--swap") # Paste foreground to background args.append("--paste {x:+d}{y:+d}".format(x=pos_x, y=pos_y)) args.append("-o") args.append(output_path) return args ================================================ FILE: openpype/modules/deadline/repository/readme.md ================================================ ## OpenPype Deadline repository overlay This directory is an overlay for Deadline repository. It means that you can copy the whole hierarchy to Deadline repository and it should work. Logic: ----- GlobalJobPreLoad ----- The `GlobalJobPreLoad` will retrieve the OpenPype executable path from the `OpenPype` Deadline Plug-in's settings. Then it will call the executable to retrieve the environment variables needed for the Deadline Job. These environment variables are injected into rendering process. Deadline triggers the `GlobalJobPreLoad.py` for each Worker as it starts the Job. *Note*: It also contains backward compatible logic to preserve functionality for old Pype2 and non-OpenPype triggered jobs. Plugin ------ For each render and publishing job the `OpenPype` Deadline Plug-in is checked for the configured location of the OpenPype executable (needs to be configured in `Deadline's Configure Plugins > OpenPype`) through `GlobalJobPreLoad`. ================================================ FILE: openpype/modules/example_addons/example_addon/__init__.py ================================================ """ Addon class definition and Settings definition must be imported here. If addon class or settings definition won't be here their definition won't be found by OpenPype discovery. """ from .addon import ( AddonSettingsDef, ExampleAddon ) __all__ = ( "AddonSettingsDef", "ExampleAddon" ) ================================================ FILE: openpype/modules/example_addons/example_addon/addon.py ================================================ """Addon definition is located here. Import of python packages that may not be available should not be imported in global space here until are required or used. - Qt related imports - imports of Python 3 packages - we still support Python 2 hosts where addon definition should available """ import os from openpype.modules import ( click_wrap, JsonFilesSettingsDef, OpenPypeAddOn, ModulesManager, IPluginPaths, ITrayAction ) # Settings definition of this addon using `JsonFilesSettingsDef` # - JsonFilesSettingsDef is prepared settings definition using json files # to define settings and store default values class AddonSettingsDef(JsonFilesSettingsDef): # This will add prefixes to every schema and template from `schemas` # subfolder. # - it is not required to fill the prefix but it is highly # recommended as schemas and templates may have name clashes across # multiple addons # - it is also recommended that prefix has addon name in it schema_prefix = "example_addon" def get_settings_root_path(self): """Implemented abstract class of JsonFilesSettingsDef. Return directory path where json files defying addon settings are located. """ return os.path.join( os.path.dirname(os.path.abspath(__file__)), "settings" ) class ExampleAddon(OpenPypeAddOn, IPluginPaths, ITrayAction): """This Addon has defined its settings and interface. This example has system settings with an enabled option. And use few other interfaces: - `IPluginPaths` to define custom plugin paths - `ITrayAction` to be shown in tray tool """ label = "Example Addon" name = "example_addon" def initialize(self, settings): """Initialization of addon.""" module_settings = settings[self.name] # Enabled by settings self.enabled = module_settings.get("enabled", False) # Prepare variables that can be used or set afterwards self._connected_modules = None # UI which must not be created at this time self._dialog = None def tray_init(self): """Implementation of abstract method for `ITrayAction`. We're definitely in tray tool so we can pre create dialog. """ self._create_dialog() def _create_dialog(self): # Don't recreate dialog if already exists if self._dialog is not None: return from .widgets import MyExampleDialog self._dialog = MyExampleDialog() def show_dialog(self): """Show dialog with connected modules. This can be called from anywhere but can also crash in headless mode. There is no way to prevent addon to do invalid operations if he's not handling them. """ # Make sure dialog is created self._create_dialog() # Show dialog self._dialog.open() def get_connected_modules(self): """Custom implementation of addon.""" names = set() if self._connected_modules is not None: for module in self._connected_modules: names.add(module.name) return names def on_action_trigger(self): """Implementation of abstract method for `ITrayAction`.""" self.show_dialog() def get_plugin_paths(self): """Implementation of abstract method for `IPluginPaths`.""" current_dir = os.path.dirname(os.path.abspath(__file__)) return { "publish": [os.path.join(current_dir, "plugins", "publish")] } def cli(self, click_group): click_group.add_command(cli_main.to_click_obj()) @click_wrap.group( ExampleAddon.name, help="Example addon dynamic cli commands.") def cli_main(): pass @cli_main.command() def nothing(): """Does nothing but print a message.""" print("You've triggered \"nothing\" command.") @cli_main.command() def show_dialog(): """Show ExampleAddon dialog. We don't have access to addon directly through cli so we have to create it again. """ from openpype.tools.utils.lib import qt_app_context manager = ModulesManager() example_addon = manager.modules_by_name[ExampleAddon.name] with qt_app_context(): example_addon.show_dialog() ================================================ FILE: openpype/modules/example_addons/example_addon/plugins/publish/example_plugin.py ================================================ import pyblish.api class CollectExampleAddon(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.4 label = "Collect Example Addon" def process(self, context): self.log.info("I'm in example addon's plugin!") ================================================ FILE: openpype/modules/example_addons/example_addon/settings/defaults/project_settings.json ================================================ { "project_settings/example_addon": { "number": 0, "color_1": [ 0.0, 0.0, 0.0 ], "color_2": [ 0.0, 0.0, 0.0 ] } } ================================================ FILE: openpype/modules/example_addons/example_addon/settings/defaults/system_settings.json ================================================ { "modules/example_addon": { "enabled": true } } ================================================ FILE: openpype/modules/example_addons/example_addon/settings/dynamic_schemas/project_dynamic_schemas.json ================================================ { "project_settings/global": { "type": "schema", "name": "example_addon/main" } } ================================================ FILE: openpype/modules/example_addons/example_addon/settings/dynamic_schemas/system_dynamic_schemas.json ================================================ { "system_settings/modules": { "type": "schema", "name": "example_addon/main" } } ================================================ FILE: openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/main.json ================================================ { "type": "dict", "key": "example_addon", "label": "Example addon", "collapsible": true, "children": [ { "type": "number", "key": "number", "label": "This is your lucky number:", "minimum": 7, "maximum": 7, "decimals": 0 }, { "type": "template", "name": "example_addon/the_template", "template_data": [ { "name": "color_1", "label": "Color 1" }, { "name": "color_2", "label": "Color 2" } ] } ] } ================================================ FILE: openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/the_template.json ================================================ [ { "type": "list-strict", "key": "{name}", "label": "{label}", "object_types": [ { "label": "Red", "type": "number", "minimum": 0, "maximum": 1, "decimal": 3 }, { "label": "Green", "type": "number", "minimum": 0, "maximum": 1, "decimal": 3 }, { "label": "Blue", "type": "number", "minimum": 0, "maximum": 1, "decimal": 3 } ] } ] ================================================ FILE: openpype/modules/example_addons/example_addon/settings/schemas/system_schemas/main.json ================================================ { "type": "dict", "key": "example_addon", "label": "Example addon", "collapsible": true, "checkbox_key": "enabled", "children": [ { "type": "boolean", "key": "enabled", "label": "Enabled" } ] } ================================================ FILE: openpype/modules/example_addons/example_addon/widgets.py ================================================ from qtpy import QtWidgets from openpype.style import load_stylesheet class MyExampleDialog(QtWidgets.QDialog): def __init__(self, parent=None): super(MyExampleDialog, self).__init__(parent) self.setWindowTitle("Connected modules") msg = "This is example dialog of example addon." label_widget = QtWidgets.QLabel(msg, self) ok_btn = QtWidgets.QPushButton("OK", self) btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) btns_layout.addWidget(ok_btn) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(label_widget) layout.addLayout(btns_layout) ok_btn.clicked.connect(self._on_ok_clicked) self._label_widget = label_widget self.setStyleSheet(load_stylesheet()) def _on_ok_clicked(self): self.done(1) ================================================ FILE: openpype/modules/example_addons/tiny_addon.py ================================================ from openpype.modules import OpenPypeAddOn class TinyAddon(OpenPypeAddOn): """This is tiniest possible addon. This addon won't do much but will exist in OpenPype modules environment. """ name = "tiniest_addon_ever" ================================================ FILE: openpype/modules/ftrack/__init__.py ================================================ from .ftrack_module import ( FtrackModule, FTRACK_MODULE_DIR, resolve_ftrack_url, ) __all__ = ( "FtrackModule", "FTRACK_MODULE_DIR", "resolve_ftrack_url", ) ================================================ FILE: openpype/modules/ftrack/event_handlers_server/action_clone_review_session.py ================================================ import json from openpype_modules.ftrack.lib import ServerAction def clone_review_session(session, entity): # Create a client review with timestamp. name = entity["name"] review_session = session.create( "ReviewSession", { "name": f"Clone of {name}", "project": entity["project"] } ) # Add all invitees. for invitee in entity["review_session_invitees"]: # Make sure email is not None but string email = invitee["email"] or "" session.create( "ReviewSessionInvitee", { "name": invitee["name"], "email": email, "review_session": review_session } ) # Add all objects to new review session. for obj in entity["review_session_objects"]: session.create( "ReviewSessionObject", { "name": obj["name"], "version": obj["version"], "review_session": review_session, "asset_version": obj["asset_version"] } ) session.commit() class CloneReviewSession(ServerAction): '''Generate Client Review action `label` a descriptive string identifying your action. `varaint` To group actions together, give them the same label and specify a unique variant per action. `identifier` a unique identifier for your action. `description` a verbose descriptive text for you action ''' label = "Clone Review Session" variant = None identifier = "clone-review-session" description = None settings_key = "clone_review_session" def discover(self, session, entities, event): '''Return true if we can handle the selected entities. *session* is a `ftrack_api.Session` instance *entities* is a list of tuples each containing the entity type and the entity id. If the entity is a hierarchical you will always get the entity type TypedContext, once retrieved through a get operation you will have the "real" entity type ie. example Shot, Sequence or Asset Build. *event* the unmodified original event ''' is_valid = ( len(entities) == 1 and entities[0].entity_type == "ReviewSession" ) if is_valid: is_valid = self.valid_roles(session, entities, event) return is_valid def launch(self, session, entities, event): '''Callback method for the custom action. return either a bool ( True if successful or False if the action failed ) or a dictionary with they keys `message` and `success`, the message should be a string and will be displayed as feedback to the user, success should be a bool, True if successful or False if the action failed. *session* is a `ftrack_api.Session` instance *entities* is a list of tuples each containing the entity type and the entity id. If the entity is a hierarchical you will always get the entity type TypedContext, once retrieved through a get operation you will have the "real" entity type ie. example Shot, Sequence or Asset Build. *event* the unmodified original event ''' userId = event['source']['user']['id'] user = session.query('User where id is ' + userId).one() job = session.create( 'Job', { 'user': user, 'status': 'running', 'data': json.dumps({ 'description': 'Cloning Review Session.' }) } ) session.commit() try: clone_review_session(session, entities[0]) job['status'] = 'done' session.commit() except Exception: session.rollback() job["status"] = "failed" session.commit() self.log.error( "Cloning review session failed ({})", exc_info=True ) return { 'success': True, 'message': 'Action completed successfully' } def register(session): '''Register action. Called when used as an event plugin.''' CloneReviewSession(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/action_create_review_session.py ================================================ import threading import datetime import copy import collections import ftrack_api from openpype.lib import get_datetime_data from openpype.settings.lib import ( get_project_settings, get_default_project_settings ) from openpype_modules.ftrack.lib import ServerAction class CreateDailyReviewSessionServerAction(ServerAction): """Create daily review session object per project. Action creates review sessions based on settings. Settings define if is action enabled and what is a template for review session name. Logic works in a way that if review session with the name already exists then skip process. If review session for current day does not exist but yesterdays review exists and is empty then yesterdays is renamed otherwise creates new review session. Also contains cycle creation of dailies which is triggered each morning. This option must be enabled in project settings. Cycle creation is also checked on registration of action. """ identifier = "create.daily.review.session" #: Action label. label = "OpenPype Admin" variant = "- Create Daily Review Session (Server)" #: Action description. description = "Manually create daily review session" role_list = {"Pypeclub", "Administrator", "Project Manager"} settings_key = "create_daily_review_session" default_template = "{yy}{mm}{dd}" def __init__(self, *args, **kwargs): super(CreateDailyReviewSessionServerAction, self).__init__( *args, **kwargs ) self._cycle_timer = None self._last_cyle_time = None self._day_delta = datetime.timedelta(days=1) def discover(self, session, entities, event): """Show action only on AssetVersions.""" valid_selection = False for ent in event["data"]["selection"]: # Ignore entities that are not tasks or projects if ent["entityType"].lower() in ( "show", "task", "reviewsession", "assetversion" ): valid_selection = True break if not valid_selection: return False return self.valid_roles(session, entities, event) def launch(self, session, entities, event): project_entity = self.get_project_from_entity(entities[0], session) project_name = project_entity["full_name"] project_settings = self.get_project_settings_from_event( event, project_name ) action_settings = self._extract_action_settings(project_settings) project_name_by_id = { project_entity["id"]: project_name } settings_by_project_id = { project_entity["id"]: action_settings } self._process_review_session( session, settings_by_project_id, project_name_by_id ) return True def _calculate_next_cycle_delta(self): studio_default_settings = get_default_project_settings() action_settings = ( studio_default_settings ["ftrack"] [self.settings_frack_subkey] [self.settings_key] ) cycle_hour_start = action_settings.get("cycle_hour_start") if not cycle_hour_start: h = m = s = 0 else: h, m, s = cycle_hour_start # Create threading timer which will trigger creation of report # at the 00:00:01 of next day # - callback will trigger another timer which will have 1 day offset now = datetime.datetime.now() # Create object of today morning expected_next_trigger = datetime.datetime( now.year, now.month, now.day, h, m, s ) if expected_next_trigger > now: seconds = (expected_next_trigger - now).total_seconds() else: expected_next_trigger += self._day_delta seconds = (expected_next_trigger - now).total_seconds() return seconds, expected_next_trigger def register(self, *args, **kwargs): """Override register to be able trigger """ # Register server action as would be normally super(CreateDailyReviewSessionServerAction, self).register( *args, **kwargs ) seconds_delta, cycle_time = self._calculate_next_cycle_delta() # Store cycle time which will be used to create next timer self._last_cyle_time = cycle_time # Create timer thread self._cycle_timer = threading.Timer( seconds_delta, self._timer_callback ) self._cycle_timer.start() self._check_review_session() def _timer_callback(self): if ( self._cycle_timer is not None and self._last_cyle_time is not None ): seconds_delta, cycle_time = self._calculate_next_cycle_delta() self._last_cyle_time = cycle_time self._cycle_timer = threading.Timer( seconds_delta, self._timer_callback ) self._cycle_timer.start() self._check_review_session() def _check_review_session(self): session = ftrack_api.Session( server_url=self.session.server_url, api_key=self.session.api_key, api_user=self.session.api_user, auto_connect_event_hub=False ) project_entities = session.query( "select id, full_name from Project" ).all() project_names_by_id = { project_entity["id"]: project_entity["full_name"] for project_entity in project_entities } action_settings_by_project_id = self._get_action_settings( project_names_by_id ) enabled_action_settings_by_project_id = {} for item in action_settings_by_project_id.items(): project_id, action_settings = item if action_settings.get("cycle_enabled"): enabled_action_settings_by_project_id[project_id] = ( action_settings ) if not enabled_action_settings_by_project_id: self.log.info(( "There are no projects that have enabled" " cycle review sesison creation" )) else: self._process_review_session( session, enabled_action_settings_by_project_id, project_names_by_id ) session.close() def _process_review_session( self, session, settings_by_project_id, project_names_by_id ): review_sessions = session.query(( "select id, name, project_id" " from ReviewSession where project_id in ({})" ).format(self.join_query_keys(settings_by_project_id))).all() review_sessions_by_project_id = collections.defaultdict(list) for review_session in review_sessions: project_id = review_session["project_id"] review_sessions_by_project_id[project_id].append(review_session) # Prepare fill data for today's review sesison and yesterdays now = datetime.datetime.now() today_obj = datetime.datetime( now.year, now.month, now.day, 0, 0, 0 ) yesterday_obj = today_obj - self._day_delta today_fill_data = get_datetime_data(today_obj) yesterday_fill_data = get_datetime_data(yesterday_obj) # Loop through projects and try to create daily reviews for project_id, action_settings in settings_by_project_id.items(): review_session_template = ( action_settings["review_session_template"] ).strip() or self.default_template today_project_fill_data = copy.deepcopy(today_fill_data) yesterday_project_fill_data = copy.deepcopy(yesterday_fill_data) project_name = project_names_by_id[project_id] today_project_fill_data["project_name"] = project_name yesterday_project_fill_data["project_name"] = project_name today_session_name = self._fill_review_template( review_session_template, today_project_fill_data ) yesterday_session_name = self._fill_review_template( review_session_template, yesterday_project_fill_data ) # Skip if today's session name could not be filled if not today_session_name: continue # Find matching review session project_review_sessions = review_sessions_by_project_id[project_id] todays_session = None yesterdays_session = None for review_session in project_review_sessions: session_name = review_session["name"] if session_name == today_session_name: todays_session = review_session break elif session_name == yesterday_session_name: yesterdays_session = review_session # Skip if today's session already exist if todays_session is not None: self.log.debug(( "Todays ReviewSession \"{}\"" " in project \"{}\" already exists" ).format(today_session_name, project_name)) continue # Check if there is yesterday's session and is empty # - in that case just rename it if ( yesterdays_session is not None and len(yesterdays_session["review_session_objects"]) == 0 ): self.log.debug(( "Renaming yesterdays empty review session \"{}\" to \"{}\"" " in project \"{}\"" ).format( yesterday_session_name, today_session_name, project_name )) yesterdays_session["name"] = today_session_name session.commit() continue # Create new review session with new name self.log.debug(( "Creating new review session \"{}\" in project \"{}\"" ).format(today_session_name, project_name)) session.create("ReviewSession", { "project_id": project_id, "name": today_session_name }) session.commit() def _get_action_settings(self, project_names_by_id): settings_by_project_id = {} for project_id, project_name in project_names_by_id.items(): project_settings = get_project_settings(project_name) action_settings = self._extract_action_settings(project_settings) settings_by_project_id[project_id] = action_settings return settings_by_project_id def _extract_action_settings(self, project_settings): return ( project_settings .get("ftrack", {}) .get(self.settings_frack_subkey, {}) .get(self.settings_key) ) or {} def _fill_review_template(self, template, data): output = None try: output = template.format(**data) except Exception: self.log.warning( ( "Failed to fill review session template {} with data {}" ).format(template, data), exc_info=True ) return output def register(session): '''Register plugin. Called when used as an plugin.''' CreateDailyReviewSessionServerAction(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/action_multiple_notes.py ================================================ from openpype_modules.ftrack.lib import ServerAction class MultipleNotesServer(ServerAction): """Action adds same note for muliple AssetVersions. Note is added to selection of AssetVersions. Note is created with user who triggered the action. It is possible to define note category of note. """ identifier = "multiple.notes.server" label = "Multiple Notes (Server)" description = "Add same note to multiple Asset Versions" _none_category = "__NONE__" def discover(self, session, entities, event): """Show action only on AssetVersions.""" if not entities: return False for entity in entities: if entity.entity_type.lower() != "assetversion": return False return True def interface(self, session, entities, event): event_source = event["source"] user_info = event_source.get("user") or {} user_id = user_info.get("id") if not user_id: return None values = event["data"].get("values") if values: return None note_label = { "type": "label", "value": "# Enter note: #" } note_value = { "name": "note", "type": "textarea" } category_label = { "type": "label", "value": "## Category: ##" } category_data = [] category_data.append({ "label": "- None -", "value": self._none_category }) all_categories = session.query( "select id, name from NoteCategory" ).all() for cat in all_categories: category_data.append({ "label": cat["name"], "value": cat["id"] }) category_value = { "type": "enumerator", "name": "category", "data": category_data, "value": self._none_category } splitter = { "type": "label", "value": "---" } return [ note_label, note_value, splitter, category_label, category_value ] def launch(self, session, entities, event): if "values" not in event["data"]: return None values = event["data"]["values"] if len(values) <= 0 or "note" not in values: return False # Get Note text note_value = values["note"] if note_value.lower().strip() == "": return { "success": True, "message": "Note was not entered. Skipping" } # Get User event_source = event["source"] user_info = event_source.get("user") or {} user_id = user_info.get("id") user = None if user_id: user = session.query( 'User where id is "{}"'.format(user_id) ).first() if not user: return { "success": False, "message": "Couldn't get user information." } # Logging message preparation # - username username = user.get("username") or "N/A" # - AssetVersion ids asset_version_ids_str = ",".join([entity["id"] for entity in entities]) # Base note data note_data = { "content": note_value, "author": user } # Get category category_id = values["category"] if category_id == self._none_category: category_id = None category_name = None if category_id is not None: category = session.query( "select id, name from NoteCategory where id is \"{}\"".format( category_id ) ).first() if category: note_data["category"] = category category_name = category["name"] category_msg = "" if category_name: category_msg = " with category: \"{}\"".format(category_name) self.log.warning(( "Creating note{} as User \"{}\" on " "AssetVersions: {} with value \"{}\"" ).format(category_msg, username, asset_version_ids_str, note_value)) # Create notes for entities for entity in entities: new_note = session.create("Note", note_data) entity["notes"].append(new_note) session.commit() return True def register(session): '''Register plugin. Called when used as an plugin.''' MultipleNotesServer(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/action_prepare_project.py ================================================ import json import copy from openpype.client import get_project, create_project from openpype.settings import ProjectSettings, SaveWarningExc from openpype_modules.ftrack.lib import ( ServerAction, get_openpype_attr, CUST_ATTR_AUTO_SYNC ) class PrepareProjectServer(ServerAction): """Prepare project attributes in Anatomy.""" identifier = "prepare.project.server" label = "OpenPype Admin" variant = "- Prepare Project (Server)" description = "Set basic attributes on the project" settings_key = "prepare_project" role_list = ["Pypeclub", "Administrator", "Project Manager"] settings_key = "prepare_project" item_splitter = {"type": "label", "value": "---"} _keys_order = ( "fps", "frameStart", "frameEnd", "handleStart", "handleEnd", "clipIn", "clipOut", "resolutionHeight", "resolutionWidth", "pixelAspect", "applications", "tools_env", "library_project", ) def discover(self, session, entities, event): """Show only on project.""" if ( len(entities) != 1 or entities[0].entity_type.lower() != "project" ): return False return self.valid_roles(session, entities, event) def interface(self, session, entities, event): if event['data'].get('values', {}): return # Inform user that this may take a while self.show_message(event, "Preparing data... Please wait", True) self.log.debug("Preparing data which will be shown") self.log.debug("Loading custom attributes") project_entity = entities[0] project_name = project_entity["full_name"] project_settings = ProjectSettings(project_name) project_anatom_settings = project_settings["project_anatomy"] root_items = self.prepare_root_items(project_anatom_settings) ca_items, multiselect_enumerators = ( self.prepare_custom_attribute_items(project_anatom_settings) ) self.log.debug("Heavy items are ready. Preparing last items group.") title = "Prepare Project" items = [] # Add root items items.extend(root_items) items.append(self.item_splitter) items.append({ "type": "label", "value": "

Set basic Attributes:

" }) items.extend(ca_items) # This item will be last before enumerators # Set value of auto synchronization auto_sync_value = project_entity["custom_attributes"].get( CUST_ATTR_AUTO_SYNC, False ) auto_sync_item = { "name": CUST_ATTR_AUTO_SYNC, "type": "boolean", "value": auto_sync_value, "label": "AutoSync to Avalon" } # Add autosync attribute items.append(auto_sync_item) # Add enumerator items at the end for item in multiselect_enumerators: items.append(item) return { "items": items, "title": title } def prepare_root_items(self, project_anatom_settings): self.log.debug("Root items preparation begins.") root_items = [] root_items.append({ "type": "label", "value": "

Check your Project root settings

" }) root_items.append({ "type": "label", "value": ( "

NOTE: Roots are crucial for path filling" " (and creating folder structure).

" ) }) root_items.append({ "type": "label", "value": ( "

WARNING: Do not change roots on running project," " that will cause workflow issues.

" ) }) empty_text = "Enter root path here..." roots_entity = project_anatom_settings["roots"] for root_name, root_entity in roots_entity.items(): root_items.append(self.item_splitter) root_items.append({ "type": "label", "value": "Root: \"{}\"".format(root_name) }) for platform_name, value_entity in root_entity.items(): root_items.append({ "label": platform_name, "name": "__root__{}__{}".format(root_name, platform_name), "type": "text", "value": value_entity.value, "empty_text": empty_text }) root_items.append({ "type": "hidden", "name": "__rootnames__", "value": json.dumps(list(roots_entity.keys())) }) self.log.debug("Root items preparation ended.") return root_items def _attributes_to_set(self, project_anatom_settings): attributes_to_set = {} attribute_values_by_key = {} for key, entity in project_anatom_settings["attributes"].items(): attribute_values_by_key[key] = entity.value cust_attrs, hier_cust_attrs = get_openpype_attr(self.session, True) for attr in hier_cust_attrs: key = attr["key"] if key.startswith("avalon_"): continue attributes_to_set[key] = { "label": attr["label"], "object": attr, "default": attribute_values_by_key.get(key) } for attr in cust_attrs: if attr["entity_type"].lower() != "show": continue key = attr["key"] if key.startswith("avalon_"): continue attributes_to_set[key] = { "label": attr["label"], "object": attr, "default": attribute_values_by_key.get(key) } # Sort by label attributes_to_set = dict(sorted( attributes_to_set.items(), key=lambda x: x[1]["label"] )) return attributes_to_set def prepare_custom_attribute_items(self, project_anatom_settings): items = [] multiselect_enumerators = [] attributes_to_set = self._attributes_to_set(project_anatom_settings) self.log.debug("Preparing interface for keys: \"{}\"".format( str([key for key in attributes_to_set]) )) attribute_keys = set(attributes_to_set.keys()) keys_order = [] for key in self._keys_order: if key in attribute_keys: keys_order.append(key) attribute_keys = attribute_keys - set(keys_order) for key in sorted(attribute_keys): keys_order.append(key) for key in keys_order: in_data = attributes_to_set[key] attr = in_data["object"] # initial item definition item = { "name": key, "label": in_data["label"] } # cust attr type - may have different visualization type_name = attr["type"]["name"].lower() easy_types = ["text", "boolean", "date", "number"] easy_type = False if type_name in easy_types: easy_type = True elif type_name == "enumerator": attr_config = json.loads(attr["config"]) attr_config_data = json.loads(attr_config["data"]) if attr_config["multiSelect"] is True: multiselect_enumerators.append(self.item_splitter) multiselect_enumerators.append({ "type": "label", "value": "

{}

".format(in_data["label"]) }) default = in_data["default"] names = [] for option in sorted( attr_config_data, key=lambda x: x["menu"] ): name = option["value"] new_name = "__{}__{}".format(key, name) names.append(new_name) item = { "name": new_name, "type": "boolean", "label": "- {}".format(option["menu"]) } if default: if isinstance(default, (list, tuple)): if name in default: item["value"] = True else: if name == default: item["value"] = True multiselect_enumerators.append(item) multiselect_enumerators.append({ "type": "hidden", "name": "__hidden__{}".format(key), "value": json.dumps(names) }) else: easy_type = True item["data"] = attr_config_data else: self.log.warning(( "Custom attribute \"{}\" has type \"{}\"." " I don't know how to handle" ).format(key, type_name)) items.append({ "type": "label", "value": ( "!!! Can't handle Custom attritubte type \"{}\"" " (key: \"{}\")" ).format(type_name, key) }) if easy_type: item["type"] = type_name # default value in interface default = in_data["default"] if default is not None: item["value"] = default items.append(item) return items, multiselect_enumerators def launch(self, session, entities, event): in_data = event["data"].get("values") if not in_data: return root_values = {} root_key = "__root__" for key in tuple(in_data.keys()): if key.startswith(root_key): _key = key[len(root_key):] root_values[_key] = in_data.pop(key) root_names = in_data.pop("__rootnames__", None) root_data = {} for root_name in json.loads(root_names): root_data[root_name] = {} for key, value in tuple(root_values.items()): prefix = "{}__".format(root_name) if not key.startswith(prefix): continue _key = key[len(prefix):] root_data[root_name][_key] = value # Find hidden items for multiselect enumerators keys_to_process = [] for key in in_data: if key.startswith("__hidden__"): keys_to_process.append(key) self.log.debug("Preparing data for Multiselect Enumerators") enumerators = {} for key in keys_to_process: new_key = key.replace("__hidden__", "") enumerator_items = in_data.pop(key) enumerators[new_key] = json.loads(enumerator_items) # find values set for multiselect enumerator for key, enumerator_items in enumerators.items(): in_data[key] = [] name = "__{}__".format(key) for item in enumerator_items: value = in_data.pop(item) if value is True: new_key = item.replace(name, "") in_data[key].append(new_key) self.log.debug("Setting Custom Attribute values") project_entity = entities[0] project_name = project_entity["full_name"] # Try to find project document project_doc = get_project(project_name) # Create project if is not available # - creation is required to be able set project anatomy and attributes if not project_doc: project_code = project_entity["name"] self.log.info("Creating project \"{} [{}]\"".format( project_name, project_code )) create_project(project_name, project_code) self.trigger_event( "openpype.project.created", {"project_name": project_name} ) project_settings = ProjectSettings(project_name) project_anatomy_settings = project_settings["project_anatomy"] project_anatomy_settings["roots"] = root_data custom_attribute_values = {} attributes_entity = project_anatomy_settings["attributes"] for key, value in in_data.items(): if key not in attributes_entity: custom_attribute_values[key] = value else: attributes_entity[key] = value try: project_settings.save() except SaveWarningExc as exc: self.log.info("Few warnings happened during settings save:") for warning in exc.warnings: self.log.info(str(warning)) # Change custom attributes on project if custom_attribute_values: for key, value in custom_attribute_values.items(): project_entity["custom_attributes"][key] = value self.log.debug("- Key \"{}\" set to \"{}\"".format(key, value)) session.commit() event_data = copy.deepcopy(in_data) event_data["project_name"] = project_name self.trigger_event("openpype.project.prepared", event_data) return True def register(session): '''Register plugin. Called when used as an plugin.''' PrepareProjectServer(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/action_private_project_detection.py ================================================ from openpype_modules.ftrack.lib import ServerAction class PrivateProjectDetectionAction(ServerAction): """Action helps to identify if does not have access to project.""" identifier = "server.missing.perm.private.project" label = "Missing permissions" description = ( "Main ftrack event server does not have access to this project." ) def _discover(self, event): """Show action only if there is a selection in event data.""" entities = self._translate_event(event) if entities: return None selection = event["data"].get("selection") if not selection: return None return { "items": [{ "label": self.label, "variant": self.variant, "description": self.description, "actionIdentifier": self.discover_identifier, "icon": self.icon, }] } def _launch(self, event): # Ignore if there are values in event data # - somebody clicked on submit button values = event["data"].get("values") if values: return None title = "# Private project (missing permissions) #" msg = ( "User ({}) or API Key used on Ftrack event server" " does not have permissions to access this private project." ).format(self.session.api_user) return { "type": "form", "title": "Missing permissions", "items": [ {"type": "label", "value": title}, {"type": "label", "value": msg}, # Add hidden to be able detect if was clicked on submit {"type": "hidden", "value": "1", "name": "hidden"} ], "submit_button_label": "Got it" } def register(session): '''Register plugin. Called when used as an plugin.''' PrivateProjectDetectionAction(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py ================================================ import sys import json import collections import ftrack_api from openpype_modules.ftrack.lib import ( ServerAction, query_custom_attributes ) class PushHierValuesToNonHier(ServerAction): """Action push hierarchical custom attribute values to non-hierarchical. Hierarchical value is also pushed to their task entities. Action has 3 configurable attributes: - `role_list`: List of use roles that can discover the action. - `interest_attributes`: Keys of custom attributes that will be looking for to push values. Attribute key must have both custom attribute types hierarchical and on specific object type (entity type). - `interest_entity_types`: Entity types that will be in focus of pushing hierarchical to object type's custom attribute. EXAMPLE: * Before action |_ Project |_ Shot1 - hierarchical custom attribute value: `frameStart`: 1001 - custom attribute for `Shot`: frameStart: 1 |_ Task1 - hierarchical custom attribute value: `frameStart`: 10 - custom attribute for `Task`: frameStart: 0 * After action |_ Project |_ Shot1 - hierarchical custom attribute value: `frameStart`: 1001 - custom attribute for `Shot`: frameStart: 1001 |_ Task1 - hierarchical custom attribute value: `frameStart`: 1001 - custom attribute for `Task`: frameStart: 1001 """ identifier = "admin.push_hier_values_to_non_hier" label = "OpenPype Admin" variant = "- Push Hierarchical values To Non-Hierarchical" entities_query_by_project = ( "select id, parent_id, object_type_id from TypedContext" " where project_id is \"{}\"" ) cust_attrs_query = ( "select id, key, object_type_id, is_hierarchical, default" " from CustomAttributeConfiguration" " where key in ({})" ) # configurable settings_key = "sync_hier_entity_attributes" settings_enabled_key = "action_enabled" def discover(self, session, entities, event): """ Validation """ # Check if selection is valid is_valid = False for ent in event["data"]["selection"]: # Ignore entities that are not tasks or projects if ent["entityType"].lower() in ("task", "show"): is_valid = True break if is_valid: is_valid = self.valid_roles(session, entities, event) return is_valid def launch(self, session, entities, event): self.log.debug("{}: Creating job".format(self.label)) user_entity = session.query( "User where id is {}".format(event["source"]["user"]["id"]) ).one() job = session.create("Job", { "user": user_entity, "status": "running", "data": json.dumps({ "description": "Propagation of Frame attribute values to task." }) }) session.commit() try: result = self.propagate_values(session, event, entities) except Exception as exc: msg = "Pushing Custom attribute values to task Failed" self.log.warning(msg, exc_info=True) session.rollback() description = "{} (Download traceback)".format(msg) self.add_traceback_to_job( job, session, sys.exc_info(), description ) return { "success": False, "message": "Error: {}".format(str(exc)) } job["status"] = "done" session.commit() return result def attrs_configurations(self, session, object_ids, interest_attributes): attrs = session.query(self.cust_attrs_query.format( self.join_query_keys(interest_attributes), self.join_query_keys(object_ids) )).all() attrs_by_obj_id = collections.defaultdict(list) hiearchical = [] for attr in attrs: if attr["is_hierarchical"]: hiearchical.append(attr) continue obj_id = attr["object_type_id"] attrs_by_obj_id[obj_id].append(attr) return attrs_by_obj_id, hiearchical def query_attr_value( self, session, hier_attrs, attrs_by_obj_id, dst_object_type_ids, task_entity_ids, non_task_entity_ids, parent_id_by_entity_id ): all_non_task_ids_with_parents = set() for entity_id in non_task_entity_ids: all_non_task_ids_with_parents.add(entity_id) _entity_id = entity_id while True: parent_id = parent_id_by_entity_id.get(_entity_id) if ( parent_id is None or parent_id in all_non_task_ids_with_parents ): break all_non_task_ids_with_parents.add(parent_id) _entity_id = parent_id all_entity_ids = ( set(all_non_task_ids_with_parents) | set(task_entity_ids) ) attr_ids = {attr["id"] for attr in hier_attrs} for obj_id in dst_object_type_ids: attrs = attrs_by_obj_id.get(obj_id) if attrs is not None: for attr in attrs: attr_ids.add(attr["id"]) real_values_by_entity_id = { entity_id: {} for entity_id in all_entity_ids } attr_values = query_custom_attributes( session, attr_ids, all_entity_ids, True ) for item in attr_values: entity_id = item["entity_id"] attr_id = item["configuration_id"] real_values_by_entity_id[entity_id][attr_id] = item["value"] # Fill hierarchical values hier_attrs_key_by_id = { hier_attr["id"]: hier_attr for hier_attr in hier_attrs } hier_values_per_entity_id = {} for entity_id in all_non_task_ids_with_parents: real_values = real_values_by_entity_id[entity_id] hier_values_per_entity_id[entity_id] = {} for attr_id, attr in hier_attrs_key_by_id.items(): key = attr["key"] hier_values_per_entity_id[entity_id][key] = ( real_values.get(attr_id) ) output = {} for entity_id in non_task_entity_ids: output[entity_id] = {} for attr in hier_attrs_key_by_id.values(): key = attr["key"] value = hier_values_per_entity_id[entity_id][key] tried_ids = set() if value is None: tried_ids.add(entity_id) _entity_id = entity_id while value is None: parent_id = parent_id_by_entity_id.get(_entity_id) if not parent_id: break value = hier_values_per_entity_id[parent_id][key] if value is not None: break _entity_id = parent_id tried_ids.add(parent_id) if value is None: value = attr["default"] if value is not None: for ent_id in tried_ids: hier_values_per_entity_id[ent_id][key] = value output[entity_id][key] = value return real_values_by_entity_id, output def propagate_values(self, session, event, selected_entities): ftrack_settings = self.get_ftrack_settings( session, event, selected_entities ) action_settings = ( ftrack_settings[self.settings_frack_subkey][self.settings_key] ) project_entity = self.get_project_from_entity(selected_entities[0]) selected_ids = [entity["id"] for entity in selected_entities] self.log.debug("Querying project's entities \"{}\".".format( project_entity["full_name"] )) interest_entity_types = tuple( ent_type.lower() for ent_type in action_settings["interest_entity_types"] ) all_object_types = session.query("ObjectType").all() object_types_by_low_name = { object_type["name"].lower(): object_type for object_type in all_object_types } task_object_type = object_types_by_low_name["task"] dst_object_type_ids = {task_object_type["id"]} for ent_type in interest_entity_types: obj_type = object_types_by_low_name.get(ent_type) if obj_type: dst_object_type_ids.add(obj_type["id"]) interest_attributes = action_settings["interest_attributes"] # Find custom attributes definitions attrs_by_obj_id, hier_attrs = self.attrs_configurations( session, dst_object_type_ids, interest_attributes ) # Filter destination object types if they have any object specific # custom attribute for obj_id in tuple(dst_object_type_ids): if obj_id not in attrs_by_obj_id: dst_object_type_ids.remove(obj_id) if not dst_object_type_ids: # TODO report that there are not matching custom attributes return { "success": True, "message": "Nothing has changed." } ( parent_id_by_entity_id, filtered_entities ) = self.all_hierarchy_entities( session, selected_ids, project_entity, dst_object_type_ids ) self.log.debug("Preparing whole project hierarchy by ids.") entities_by_obj_id = { obj_id: [] for obj_id in dst_object_type_ids } self.log.debug("Filtering Task entities.") focus_entity_ids = [] non_task_entity_ids = [] task_entity_ids = [] for entity in filtered_entities: entity_id = entity["id"] focus_entity_ids.append(entity_id) if entity.entity_type.lower() == "task": task_entity_ids.append(entity_id) else: non_task_entity_ids.append(entity_id) obj_id = entity["object_type_id"] entities_by_obj_id[obj_id].append(entity_id) if not non_task_entity_ids: return { "success": True, "message": "Nothing to do in your selection." } self.log.debug("Getting Custom attribute values.") ( real_values_by_entity_id, hier_values_by_entity_id ) = self.query_attr_value( session, hier_attrs, attrs_by_obj_id, dst_object_type_ids, task_entity_ids, non_task_entity_ids, parent_id_by_entity_id ) self.log.debug("Setting parents' values to task.") self.set_task_attr_values( session, hier_attrs, task_entity_ids, hier_values_by_entity_id, parent_id_by_entity_id, real_values_by_entity_id ) self.log.debug("Setting values to entities themselves.") self.push_values_to_entities( session, entities_by_obj_id, attrs_by_obj_id, hier_values_by_entity_id, real_values_by_entity_id ) return True def all_hierarchy_entities( self, session, selected_ids, project_entity, destination_object_type_ids ): selected_ids = set(selected_ids) filtered_entities = [] parent_id_by_entity_id = {} # Query is simple if project is in selection if project_entity["id"] in selected_ids: entities = session.query( self.entities_query_by_project.format(project_entity["id"]) ).all() for entity in entities: if entity["object_type_id"] in destination_object_type_ids: filtered_entities.append(entity) entity_id = entity["id"] parent_id_by_entity_id[entity_id] = entity["parent_id"] return parent_id_by_entity_id, filtered_entities # Query selection and get it's link to be able calculate parentings entities_with_link = session.query(( "select id, parent_id, link, object_type_id" " from TypedContext where id in ({})" ).format(self.join_query_keys(selected_ids))).all() # Process and store queried entities and store all lower entities to # `bottom_ids` # - bottom_ids should not contain 2 ids where one is parent of second bottom_ids = set(selected_ids) for entity in entities_with_link: if entity["object_type_id"] in destination_object_type_ids: filtered_entities.append(entity) children_id = None for idx, item in enumerate(reversed(entity["link"])): item_id = item["id"] if idx > 0 and item_id in bottom_ids: bottom_ids.remove(item_id) if children_id is not None: parent_id_by_entity_id[children_id] = item_id children_id = item_id # Query all children of selection per one hierarchy level and process # their data the same way as selection but parents are already known chunk_size = 100 while bottom_ids: child_entities = [] # Query entities in chunks entity_ids = list(bottom_ids) for idx in range(0, len(entity_ids), chunk_size): _entity_ids = entity_ids[idx:idx + chunk_size] child_entities.extend(session.query(( "select id, parent_id, object_type_id from" " TypedContext where parent_id in ({})" ).format(self.join_query_keys(_entity_ids))).all()) bottom_ids = set() for entity in child_entities: entity_id = entity["id"] parent_id_by_entity_id[entity_id] = entity["parent_id"] bottom_ids.add(entity_id) if entity["object_type_id"] in destination_object_type_ids: filtered_entities.append(entity) return parent_id_by_entity_id, filtered_entities def set_task_attr_values( self, session, hier_attrs, task_entity_ids, hier_values_by_entity_id, parent_id_by_entity_id, real_values_by_entity_id ): hier_attr_id_by_key = { attr["key"]: attr["id"] for attr in hier_attrs } filtered_task_ids = set() for task_id in task_entity_ids: parent_id = parent_id_by_entity_id.get(task_id) parent_values = hier_values_by_entity_id.get(parent_id) if parent_values: filtered_task_ids.add(task_id) if not filtered_task_ids: return for task_id in filtered_task_ids: parent_id = parent_id_by_entity_id[task_id] parent_values = hier_values_by_entity_id[parent_id] hier_values_by_entity_id[task_id] = {} real_task_attr_values = real_values_by_entity_id[task_id] for key, value in parent_values.items(): hier_values_by_entity_id[task_id][key] = value if value is None: continue configuration_id = hier_attr_id_by_key[key] _entity_key = collections.OrderedDict([ ("configuration_id", configuration_id), ("entity_id", task_id) ]) op = None if configuration_id not in real_task_attr_values: op = ftrack_api.operation.CreateEntityOperation( "CustomAttributeValue", _entity_key, {"value": value} ) elif real_task_attr_values[configuration_id] != value: op = ftrack_api.operation.UpdateEntityOperation( "CustomAttributeValue", _entity_key, "value", real_task_attr_values[configuration_id], value ) if op is not None: session.recorded_operations.push(op) if len(session.recorded_operations) > 100: session.commit() session.commit() def push_values_to_entities( self, session, entities_by_obj_id, attrs_by_obj_id, hier_values_by_entity_id, real_values_by_entity_id ): """Push values from hierarchical custom attributes to non-hierarchical. Args: session (ftrack_api.Sessison): Session which queried entities, values and which is used for change propagation. entities_by_obj_id (dict[str, list[str]]): TypedContext ftrack entity ids where the attributes are propagated by their object ids. attrs_by_obj_id (dict[str, ftrack_api.Entity]): Objects of 'CustomAttributeConfiguration' by their ids. hier_values_by_entity_id (doc[str, dict[str, Any]]): Attribute values by entity id and by their keys. real_values_by_entity_id (doc[str, dict[str, Any]]): Real attribute values of entities. """ for object_id, entity_ids in entities_by_obj_id.items(): attrs = attrs_by_obj_id.get(object_id) if not attrs or not entity_ids: continue for entity_id in entity_ids: real_values = real_values_by_entity_id.get(entity_id) hier_values = hier_values_by_entity_id.get(entity_id) if hier_values is None: continue for attr in attrs: attr_id = attr["id"] attr_key = attr["key"] value = hier_values.get(attr_key) if value is None: continue _entity_key = collections.OrderedDict([ ("configuration_id", attr_id), ("entity_id", entity_id) ]) op = None if attr_id not in real_values: op = ftrack_api.operation.CreateEntityOperation( "CustomAttributeValue", _entity_key, {"value": value} ) elif real_values[attr_id] != value: op = ftrack_api.operation.UpdateEntityOperation( "CustomAttributeValue", _entity_key, "value", real_values[attr_id], value ) if op is not None: session.recorded_operations.push(op) if len(session.recorded_operations) > 100: session.commit() session.commit() def register(session): PushHierValuesToNonHier(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/action_sync_to_avalon.py ================================================ import time import sys import json import ftrack_api from openpype_modules.ftrack.lib import ServerAction from openpype_modules.ftrack.lib.avalon_sync import SyncEntitiesFactory class SyncToAvalonServer(ServerAction): """ Synchronizing data action - from Ftrack to Avalon DB Stores all information about entity. - Name(string) - Most important information = identifier of entity - Parent(ObjectId) - Avalon Project Id, if entity is not project itself - Data(dictionary): - VisualParent(ObjectId) - Avalon Id of parent asset - Parents(array of string) - All parent names except project - Tasks(dictionary of dictionaries) - Tasks on asset - FtrackId(string) - entityType(string) - entity's type on Ftrack * All Custom attributes in group 'Avalon' - custom attributes that start with 'avalon_' are skipped * These information are stored for entities in whole project. Avalon ID of asset is stored to Ftrack - Custom attribute 'avalon_mongo_id'. - action IS NOT creating this Custom attribute if doesn't exist - run 'Create Custom Attributes' action - or do it manually (Not recommended) """ #: Action identifier. identifier = "sync.to.avalon.server" #: Action label. label = "OpenPype Admin" variant = "- Sync To Avalon (Server)" #: Action description. description = "Send data from Ftrack to Avalon" role_list = {"Pypeclub", "Administrator", "Project Manager"} settings_key = "sync_to_avalon" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.entities_factory = SyncEntitiesFactory(self.log, self.session) def discover(self, session, entities, event): """ Validation """ # Check if selection is valid is_valid = False for ent in event["data"]["selection"]: # Ignore entities that are not tasks or projects if ent["entityType"].lower() in ["show", "task"]: is_valid = True break if is_valid: is_valid = self.valid_roles(session, entities, event) return is_valid def launch(self, session, in_entities, event): self.log.debug("{}: Creating job".format(self.label)) user_entity = session.query( "User where id is {}".format(event["source"]["user"]["id"]) ).one() job_entity = session.create("Job", { "user": user_entity, "status": "running", "data": json.dumps({ "description": "Sync to avalon is running..." }) }) session.commit() project_entity = self.get_project_from_entity(in_entities[0]) project_name = project_entity["full_name"] try: result = self.synchronization(event, project_name) except Exception: self.log.error( "Synchronization failed due to code error", exc_info=True ) description = "Sync to avalon Crashed (Download traceback)" self.add_traceback_to_job( job_entity, session, sys.exc_info(), description ) msg = "An error has happened during synchronization" title = "Synchronization report ({}):".format(project_name) items = [] items.append({ "type": "label", "value": "# {}".format(msg) }) items.append({ "type": "label", "value": ( "

Download report from job for more information.

" ) }) report = {} try: report = self.entities_factory.report() except Exception: pass _items = report.get("items") or [] if _items: items.append(self.entities_factory.report_splitter) items.extend(_items) self.show_interface(items, title, event, submit_btn_label="Ok") return {"success": True, "message": msg} job_entity["status"] = "done" job_entity["data"] = json.dumps({ "description": "Sync to avalon finished." }) session.commit() return result def synchronization(self, event, project_name): time_start = time.time() self.show_message(event, "Synchronization - Preparing data", True) try: output = self.entities_factory.launch_setup(project_name) if output is not None: return output time_1 = time.time() self.entities_factory.set_cutom_attributes() time_2 = time.time() # This must happen before all filtering!!! self.entities_factory.prepare_avalon_entities(project_name) time_3 = time.time() self.entities_factory.filter_by_ignore_sync() time_4 = time.time() self.entities_factory.duplicity_regex_check() time_5 = time.time() self.entities_factory.prepare_ftrack_ent_data() time_6 = time.time() self.entities_factory.synchronize() time_7 = time.time() self.log.debug( "*** Synchronization finished ***" ) self.log.debug( "preparation <{}>".format(time_1 - time_start) ) self.log.debug( "set_cutom_attributes <{}>".format(time_2 - time_1) ) self.log.debug( "prepare_avalon_entities <{}>".format(time_3 - time_2) ) self.log.debug( "filter_by_ignore_sync <{}>".format(time_4 - time_3) ) self.log.debug( "duplicity_regex_check <{}>".format(time_5 - time_4) ) self.log.debug( "prepare_ftrack_ent_data <{}>".format(time_6 - time_5) ) self.log.debug( "synchronize <{}>".format(time_7 - time_6) ) self.log.debug( "* Total time: {}".format(time_7 - time_start) ) if self.entities_factory.project_created: event = ftrack_api.event.base.Event( topic="openpype.project.created", data={"project_name": project_name} ) self.session.event_hub.publish(event) report = self.entities_factory.report() if report and report.get("items"): default_title = "Synchronization report ({}):".format( project_name ) self.show_interface( items=report["items"], title=report.get("title", default_title), event=event ) return { "success": True, "message": "Synchronization Finished" } finally: try: self.entities_factory.dbcon.uninstall() except Exception: pass try: self.entities_factory.session.close() except Exception: pass def register(session): '''Register plugin. Called when used as an plugin.''' SyncToAvalonServer(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/action_tranfer_hierarchical_values.py ================================================ import copy import json import collections import ftrack_api from openpype_modules.ftrack.lib import ( ServerAction, statics_icon, ) from openpype_modules.ftrack.lib.avalon_sync import create_chunks class TransferHierarchicalValues(ServerAction): """Transfer values across hierarchical attributes. Aalso gives ability to convert types meanwhile. That is limited to conversions between numbers and strings - int <-> float - in, float -> string """ identifier = "transfer.hierarchical.values" label = "OpenPype Admin" variant = "- Transfer values between 2 custom attributes" description = ( "Move values from a hierarchical attribute to" " second hierarchical attribute." ) icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") all_project_entities_query = ( "select id, name, parent_id, link" " from TypedContext where project_id is \"{}\"" ) cust_attr_query = ( "select value, entity_id from CustomAttributeValue" " where entity_id in ({}) and configuration_id is \"{}\"" ) settings_key = "transfer_values_of_hierarchical_attributes" def discover(self, session, entities, event): """Show anywhere.""" return self.valid_roles(session, entities, event) def _selection_interface(self, session, event_values=None): title = "Transfer hierarchical values" attr_confs = session.query( ( "select id, key from CustomAttributeConfiguration" " where is_hierarchical is true" ) ).all() attr_items = [] for attr_conf in attr_confs: attr_items.append({ "value": attr_conf["id"], "label": attr_conf["key"] }) if len(attr_items) < 2: return { "title": title, "items": [{ "type": "label", "value": ( "Didn't find custom attributes" " that can be transferred." ) }] } attr_items = sorted(attr_items, key=lambda item: item["label"]) items = [] item_splitter = {"type": "label", "value": "---"} items.append({ "type": "label", "value": ( "

Please select source and destination" " Custom attribute

" ) }) items.append({ "type": "label", "value": ( "WARNING: This will take affect for all projects!" ) }) if event_values: items.append({ "type": "label", "value": ( "Note: Please select 2 different custom attributes." ) }) items.append(item_splitter) src_item = { "type": "enumerator", "label": "Source", "name": "src_attr_id", "data": copy.deepcopy(attr_items) } dst_item = { "type": "enumerator", "label": "Destination", "name": "dst_attr_id", "data": copy.deepcopy(attr_items) } delete_item = { "type": "boolean", "name": "delete_dst_attr_first", "label": "Delete first", "value": False } if event_values: src_item["value"] = event_values["src_attr_id"] dst_item["value"] = event_values["dst_attr_id"] delete_item["value"] = event_values["delete_dst_attr_first"] items.append(src_item) items.append(dst_item) items.append(item_splitter) items.append({ "type": "label", "value": ( "WARNING: All values from destination" " Custom Attribute will be removed if this is enabled." ) }) items.append(delete_item) return { "title": title, "items": items } def interface(self, session, entities, event): if event["data"].get("values", {}): return None return self._selection_interface(session) def launch(self, session, entities, event): values = event["data"].get("values", {}) if not values: return None src_attr_id = values["src_attr_id"] dst_attr_id = values["dst_attr_id"] delete_dst_values = values["delete_dst_attr_first"] if not src_attr_id or not dst_attr_id: self.log.info("Attributes were not filled. Nothing to do.") return { "success": True, "message": "Nothing to do" } if src_attr_id == dst_attr_id: self.log.info(( "Same attributes were selected {}, {}." " Showing interface again." ).format(src_attr_id, dst_attr_id)) return self._selection_interface(session, values) # Query custom attrbutes src_conf = session.query(( "select id from CustomAttributeConfiguration where id is {}" ).format(src_attr_id)).one() dst_conf = session.query(( "select id from CustomAttributeConfiguration where id is {}" ).format(dst_attr_id)).one() src_type_name = src_conf["type"]["name"] dst_type_name = dst_conf["type"]["name"] # Limit conversion to # - same type -> same type (there is no need to do conversion) # - number -> number (int to float and back) # - number -> str (any number can be converted to str) src_type = None dst_type = None if src_type_name == "number" or src_type_name != dst_type_name: src_type = self._get_attr_type(dst_conf) dst_type = self._get_attr_type(dst_conf) valid = False # Can convert numbers if src_type in (int, float) and dst_type in (int, float): valid = True # Can convert numbers to string elif dst_type is str: valid = True if not valid: self.log.info(( "Don't know how to properly convert" " custom attribute types {} > {}" ).format(src_type_name, dst_type_name)) return { "message": ( "Don't know how to properly convert" " custom attribute types {} > {}" ).format(src_type_name, dst_type_name), "success": False } # Query source values src_attr_values = session.query( ( "select value, entity_id" " from CustomAttributeValue" " where configuration_id is {}" ).format(src_attr_id) ).all() self.log.debug("Queried source values.") failed_entity_ids = [] if dst_type is not None: self.log.debug("Converting source values to desctination type") value_by_id = {} for attr_value in src_attr_values: entity_id = attr_value["entity_id"] value = attr_value["value"] if value is not None: try: if dst_type is not None: value = dst_type(value) value_by_id[entity_id] = value except Exception: failed_entity_ids.append(entity_id) if failed_entity_ids: self.log.info( "Couldn't convert some values to destination attribute" ) return { "success": False, "message": ( "Couldn't convert some values to destination attribute" ) } # Delete destination custom attributes first if delete_dst_values: self.log.info("Deleting destination custom attribute values first") self._delete_custom_attribute_values(session, dst_attr_id) self.log.info("Applying source values on destination custom attribute") self._apply_values(session, value_by_id, dst_attr_id) return True def _delete_custom_attribute_values(self, session, dst_attr_id): dst_attr_values = session.query( ( "select configuration_id, entity_id" " from CustomAttributeValue" " where configuration_id is {}" ).format(dst_attr_id) ).all() delete_operations = [] for attr_value in dst_attr_values: entity_id = attr_value["entity_id"] configuration_id = attr_value["configuration_id"] entity_key = collections.OrderedDict(( ("configuration_id", configuration_id), ("entity_id", entity_id) )) delete_operations.append( ftrack_api.operation.DeleteEntityOperation( "CustomAttributeValue", entity_key ) ) if not delete_operations: return for chunk in create_chunks(delete_operations, 500): for operation in chunk: session.recorded_operations.push(operation) session.commit() def _apply_values(self, session, value_by_id, dst_attr_id): dst_attr_values = session.query( ( "select configuration_id, entity_id" " from CustomAttributeValue" " where configuration_id is {}" ).format(dst_attr_id) ).all() dst_entity_ids_with_value = { item["entity_id"] for item in dst_attr_values } operations = [] for entity_id, value in value_by_id.items(): entity_key = collections.OrderedDict(( ("configuration_id", dst_attr_id), ("entity_id", entity_id) )) if entity_id in dst_entity_ids_with_value: operations.append( ftrack_api.operation.UpdateEntityOperation( "CustomAttributeValue", entity_key, "value", ftrack_api.symbol.NOT_SET, value ) ) else: operations.append( ftrack_api.operation.CreateEntityOperation( "CustomAttributeValue", entity_key, {"value": value} ) ) if not operations: return for chunk in create_chunks(operations, 500): for operation in chunk: session.recorded_operations.push(operation) session.commit() def _get_attr_type(self, conf_def): type_name = conf_def["type"]["name"] if type_name == "text": return str if type_name == "number": config = json.loads(conf_def["config"]) if config["isdecimal"]: return float return int return None def register(session): '''Register plugin. Called when used as an plugin.''' TransferHierarchicalValues(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/event_del_avalon_id_from_new.py ================================================ from openpype_modules.ftrack.lib import BaseEvent from openpype_modules.ftrack.lib.avalon_sync import CUST_ATTR_ID_KEY from openpype_modules.ftrack.event_handlers_server.event_sync_to_avalon import ( SyncToAvalonEvent ) class DelAvalonIdFromNew(BaseEvent): ''' This event removes AvalonId from custom attributes of new entities Result: - 'Copy->Pasted' entities won't have same AvalonID as source entity Priority of this event must be less than SyncToAvalon event ''' priority = SyncToAvalonEvent.priority - 1 ignore_me = True def launch(self, session, event): created = [] entities = event['data']['entities'] for entity in entities: try: entity_id = entity['entityId'] if entity.get('action', None) == 'add': id_dict = entity['changes']['id'] if id_dict['new'] is not None and id_dict['old'] is None: created.append(id_dict['new']) elif ( entity.get('action', None) == 'update' and CUST_ATTR_ID_KEY in entity['keys'] and entity_id in created ): ftrack_entity = session.get( self._get_entity_type(entity), entity_id ) cust_attrs = ftrack_entity["custom_attributes"] if cust_attrs[CUST_ATTR_ID_KEY]: cust_attrs[CUST_ATTR_ID_KEY] = "" session.commit() except Exception: session.rollback() continue def register(session): '''Register plugin. Called when used as an plugin.''' DelAvalonIdFromNew(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/event_first_version_status.py ================================================ import collections from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent class FirstVersionStatus(BaseEvent): # WARNING Priority MUST be higher # than handler in `event_version_to_task_statuses.py` priority = 200 keys_enum = ["task", "task_type"] # This should be set with presets task_status_map = [] # EXAMPLE of `task_status_map` __example_status_map__ = [{ # `key` specify where to look for name (is enumerator of `keys_enum`) # By default is set to "task" "key": "task", # speicification of name "name": "compositing", # Status to set to the asset version "status": "Blocking" }] def register(self, *args, **kwargs): result = super(FirstVersionStatus, self).register(*args, **kwargs) valid_task_status_map = [] for item in self.task_status_map: key = (item.get("key") or "task").lower() name = (item.get("name") or "").lower() status = (item.get("status") or "").lower() if not (key and name and status): self.log.warning(( "Invalid item in Task -> Status mapping. {}" ).format(str(item))) continue if key not in self.keys_enum: expected_msg = "" last_key_idx = len(self.keys_enum) - 1 for idx, key in enumerate(self.keys_enum): if idx == 0: joining_part = "`{}`" elif idx == last_key_idx: joining_part = "or `{}`" else: joining_part = ", `{}`" expected_msg += joining_part.format(key) self.log.warning(( "Invalid key `{}`. Expected: {}." ).format(key, expected_msg)) continue valid_task_status_map.append({ "key": key, "name": name, "status": status }) self.task_status_map = valid_task_status_map if not self.task_status_map: self.log.warning(( "Event handler `{}` don't have set presets." ).format(self.__class__.__name__)) return result def launch(self, session, event): """Set task's status for first created Asset Version.""" if not self.task_status_map: return filtered_entities_info = self.filter_entities_info(event) if not filtered_entities_info: return for project_id, entities_info in filtered_entities_info.items(): self.process_by_project(session, event, project_id, entities_info) def process_by_project(self, session, event, project_id, entities_info): project_name = self.get_project_name_from_event( session, event, project_id ) if get_project(project_name) is None: self.log.debug( f"Project '{project_name}' not found in OpenPype. Skipping" ) return entity_ids = [] for entity_info in entities_info: entity_ids.append(entity_info["entityId"]) joined_entity_ids = ",".join( ["\"{}\"".format(entity_id) for entity_id in entity_ids] ) asset_versions = session.query( "AssetVersion where id in ({})".format(joined_entity_ids) ).all() asset_version_statuses = None project_schema = None for asset_version in asset_versions: task_entity = asset_version["task"] found_item = None for item in self.task_status_map: if ( item["key"] == "task" and task_entity["name"].lower() != item["name"] ): continue elif ( item["key"] == "task_type" and task_entity["type"]["name"].lower() != item["name"] ): continue found_item = item break if not found_item: continue if project_schema is None: project_schema = task_entity["project"]["project_schema"] # Get all available statuses for Task if asset_version_statuses is None: statuses = project_schema.get_statuses("AssetVersion") # map lowered status name with it's object asset_version_statuses = { status["name"].lower(): status for status in statuses } ent_path = "/".join( [ent["name"] for ent in task_entity["link"]] + [ str(asset_version["asset"]["name"]), str(asset_version["version"]) ] ) new_status = asset_version_statuses.get(found_item["status"]) if not new_status: self.log.warning(( "AssetVersion doesn't have status `{}`." ).format(found_item["status"])) continue try: asset_version["status"] = new_status session.commit() self.log.debug("[ {} ] Status updated to [ {} ]".format( ent_path, new_status['name'] )) except Exception: session.rollback() self.log.warning( "[ {} ] Status couldn't be set.".format(ent_path), exc_info=True ) def filter_entities_info(self, event): filtered_entities_info = collections.defaultdict(list) for entity_info in event["data"].get("entities", []): # Care only about add actions if entity_info.get("action") != "add": continue # Filter AssetVersions if entity_info["entityType"] != "assetversion": continue entity_changes = entity_info.get("changes") or {} # Check if version of Asset Version is `1` version_num = entity_changes.get("version", {}).get("new") if version_num != 1: continue # Skip in Asset Version don't have task task_id = entity_changes.get("taskid", {}).get("new") if not task_id: continue project_id = None for parent_item in reversed(entity_info["parents"]): if parent_item["entityType"] == "show": project_id = parent_item["entityId"] break if project_id is None: continue filtered_entities_info[project_id].append(entity_info) return filtered_entities_info def register(session): '''Register plugin. Called when used as an plugin.''' FirstVersionStatus(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/event_next_task_update.py ================================================ import collections from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent class NextTaskUpdate(BaseEvent): """Change status on following Task. Handler cares about changes of status id on Task entities. When new status has state "Done" it will try to find following task and change it's status. It is expected following task should be marked as "Ready to work on". By default all tasks with same task type must have state "Done" to do any changes. And when all tasks with same task type are "done" it will change statuses on all tasks with next task type. # Enable Handler is based on settings, handler can be turned on/off with "enabled" key. ``` "enabled": True ``` # Status mappings Must have set mappings of new statuses: ``` "mapping": { # From -> To "Not Ready": "Ready", ... } ``` If current status name is not found then status change is skipped. # Ignored statuses These status names are skipping as they would be in "Done" state. Best example is status "Omitted" which in most of cases is "Blocked" state but it will never change. ``` "ignored_statuses": [ "Omitted", ... ] ``` # Change statuses sorted by task type and by name Change behaviour of task type batching. Statuses are not checked and set by batches of tasks by Task type but one by one. Tasks are sorted by Task type and then by name if all previous tasks are "Done" the following will change status. ``` "name_sorting": True ``` """ settings_key = "next_task_update" def launch(self, session, event): '''Propagates status from version to task when changed''' filtered_entities_info = self.filter_entities_info(event) if not filtered_entities_info: return for project_id, entities_info in filtered_entities_info.items(): self.process_by_project(session, event, project_id, entities_info) def filter_entities_info(self, event): # Filter if event contain relevant data entities_info = event["data"].get("entities") if not entities_info: return filtered_entities_info = collections.defaultdict(list) for entity_info in entities_info: # Care only about Task `entity_type` if entity_info.get("entity_type") != "Task": continue # Care only about changes of status changes = entity_info.get("changes") or {} statusid_changes = changes.get("statusid") or {} if ( statusid_changes.get("new") is None or statusid_changes.get("old") is None ): continue project_id = None for parent_info in reversed(entity_info["parents"]): if parent_info["entityType"] == "show": project_id = parent_info["entityId"] break if project_id: filtered_entities_info[project_id].append(entity_info) return filtered_entities_info def process_by_project(self, session, event, project_id, _entities_info): project_name = self.get_project_name_from_event( session, event, project_id ) if get_project(project_name) is None: self.log.debug("Project not found in OpenPype. Skipping") return # Load settings project_settings = self.get_project_settings_from_event( event, project_name ) # Load status mapping from presets event_settings = ( project_settings["ftrack"]["events"][self.settings_key] ) if not event_settings["enabled"]: self.log.debug("Project \"{}\" has disabled {}.".format( project_name, self.__class__.__name__ )) return statuses = session.query("Status").all() entities_info = self.filter_by_status_state(_entities_info, statuses) if not entities_info: return parent_ids = set() event_task_ids_by_parent_id = collections.defaultdict(list) for entity_info in entities_info: parent_id = entity_info["parentId"] entity_id = entity_info["entityId"] parent_ids.add(parent_id) event_task_ids_by_parent_id[parent_id].append(entity_id) # From now it doesn't matter what was in event data task_entities = session.query( ( "select id, type_id, status_id, parent_id, link from Task" " where parent_id in ({})" ).format(self.join_query_keys(parent_ids)) ).all() tasks_by_parent_id = collections.defaultdict(list) for task_entity in task_entities: tasks_by_parent_id[task_entity["parent_id"]].append(task_entity) project_entity = session.get("Project", project_id) self.set_next_task_statuses( session, tasks_by_parent_id, event_task_ids_by_parent_id, statuses, project_entity, event_settings ) def filter_by_status_state(self, entities_info, statuses): statuses_by_id = { status["id"]: status for status in statuses } # Care only about tasks having status with state `Done` filtered_entities_info = [] for entity_info in entities_info: status_id = entity_info["changes"]["statusid"]["new"] status_entity = statuses_by_id[status_id] if status_entity["state"]["name"].lower() == "done": filtered_entities_info.append(entity_info) return filtered_entities_info def set_next_task_statuses( self, session, tasks_by_parent_id, event_task_ids_by_parent_id, statuses, project_entity, event_settings ): statuses_by_id = { status["id"]: status for status in statuses } # Lower ignored statuses ignored_statuses = set( status_name.lower() for status_name in event_settings["ignored_statuses"] ) # Lower both key and value of mapped statuses mapping = { status_from.lower(): status_to.lower() for status_from, status_to in event_settings["mapping"].items() } # Should use name sorting or not name_sorting = event_settings["name_sorting"] # Collect task type ids from changed entities task_type_ids = set() for task_entities in tasks_by_parent_id.values(): for task_entity in task_entities: task_type_ids.add(task_entity["type_id"]) statusese_by_obj_id = self.statuses_for_tasks( task_type_ids, project_entity ) sorted_task_type_ids = self.get_sorted_task_type_ids(session) for parent_id, _task_entities in tasks_by_parent_id.items(): task_entities_by_type_id = collections.defaultdict(list) for _task_entity in _task_entities: type_id = _task_entity["type_id"] task_entities_by_type_id[type_id].append(_task_entity) event_ids = set(event_task_ids_by_parent_id[parent_id]) if name_sorting: # Sort entities by name self.sort_by_name_task_entities_by_type( task_entities_by_type_id ) # Sort entities by type id sorted_task_entities = [] for type_id in sorted_task_type_ids: task_entities = task_entities_by_type_id.get(type_id) if task_entities: sorted_task_entities.extend(task_entities) next_tasks = self.next_tasks_with_name_sorting( sorted_task_entities, event_ids, statuses_by_id, ignored_statuses ) else: next_tasks = self.next_tasks_with_type_sorting( task_entities_by_type_id, sorted_task_type_ids, event_ids, statuses_by_id, ignored_statuses ) for task_entity in next_tasks: if task_entity["status"]["state"]["name"].lower() == "done": continue task_status = statuses_by_id[task_entity["status_id"]] old_status_name = task_status["name"].lower() if old_status_name in ignored_statuses: continue new_task_name = mapping.get(old_status_name) if not new_task_name: self.log.debug( "Didn't find mapping for status \"{}\".".format( task_status["name"] ) ) continue ent_path = "/".join( [ent["name"] for ent in task_entity["link"]] ) type_id = task_entity["type_id"] new_status = statusese_by_obj_id[type_id].get(new_task_name) if new_status is None: self.log.warning(( "\"{}\" does not have available status name \"{}\"" ).format(ent_path, new_task_name)) continue try: task_entity["status_id"] = new_status["id"] session.commit() self.log.info( "\"{}\" updated status to \"{}\"".format( ent_path, new_status["name"] ) ) except Exception: session.rollback() self.log.warning( "\"{}\" status couldn't be set to \"{}\"".format( ent_path, new_status["name"] ), exc_info=True ) def next_tasks_with_name_sorting( self, sorted_task_entities, event_ids, statuses_by_id, ignored_statuses, ): # Pre sort task entities by name use_next_task = False next_tasks = [] for task_entity in sorted_task_entities: if task_entity["id"] in event_ids: event_ids.remove(task_entity["id"]) use_next_task = True continue if not use_next_task: continue task_status = statuses_by_id[task_entity["status_id"]] low_status_name = task_status["name"].lower() if low_status_name in ignored_statuses: continue next_tasks.append(task_entity) use_next_task = False if not event_ids: break return next_tasks def check_statuses_done( self, task_entities, ignored_statuses, statuses_by_id ): all_are_done = True for task_entity in task_entities: task_status = statuses_by_id[task_entity["status_id"]] low_status_name = task_status["name"].lower() if low_status_name in ignored_statuses: continue low_state_name = task_status["state"]["name"].lower() if low_state_name != "done": all_are_done = False break return all_are_done def next_tasks_with_type_sorting( self, task_entities_by_type_id, sorted_task_type_ids, event_ids, statuses_by_id, ignored_statuses ): # `use_next_task` is used only if `name_sorting` is enabled! next_tasks = [] use_next_tasks = False for type_id in sorted_task_type_ids: if type_id not in task_entities_by_type_id: continue task_entities = task_entities_by_type_id[type_id] # Check if any task was in event event_id_in_tasks = False for task_entity in task_entities: task_id = task_entity["id"] if task_id in event_ids: event_ids.remove(task_id) event_id_in_tasks = True if use_next_tasks: # Check if next tasks are not done already all_in_type_done = self.check_statuses_done( task_entities, ignored_statuses, statuses_by_id ) if all_in_type_done: continue next_tasks.extend(task_entities) use_next_tasks = False if not event_ids: break if not event_id_in_tasks: continue all_in_type_done = self.check_statuses_done( task_entities, ignored_statuses, statuses_by_id ) use_next_tasks = all_in_type_done if all_in_type_done: continue if not event_ids: break use_next_tasks = False return next_tasks def statuses_for_tasks(self, task_type_ids, project_entity): project_schema = project_entity["project_schema"] output = {} for task_type_id in task_type_ids: statuses = project_schema.get_statuses("Task", task_type_id) output[task_type_id] = { status["name"].lower(): status for status in statuses } return output def get_sorted_task_type_ids(self, session): types_by_order = collections.defaultdict(list) for _type in session.query("Type").all(): sort_oder = _type.get("sort") if sort_oder is not None: types_by_order[sort_oder].append(_type["id"]) types = [] for sort_oder in sorted(types_by_order.keys()): types.extend(types_by_order[sort_oder]) return types @staticmethod def sort_by_name_task_entities_by_type(task_entities_by_type_id): _task_entities_by_type_id = {} for type_id, task_entities in task_entities_by_type_id.items(): # Store tasks by name task_entities_by_name = {} for task_entity in task_entities: task_name = task_entity["name"] task_entities_by_name[task_name] = task_entity # Store task entities by sorted names sorted_task_entities = [] for task_name in sorted(task_entities_by_name.keys()): task_entity = task_entities_by_name[task_name] sorted_task_entities.append(task_entity) # Store result to temp dictionary _task_entities_by_type_id[type_id] = sorted_task_entities # Override values in source object for type_id, value in _task_entities_by_type_id.items(): task_entities_by_type_id[type_id] = value def register(session): NextTaskUpdate(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py ================================================ import collections import copy from typing import Any import ftrack_api from openpype.client import get_project from openpype_modules.ftrack.lib import ( BaseEvent, query_custom_attributes, ) class PushHierValuesToNonHierEvent(BaseEvent): """Push value changes between hierarchical and non-hierarchical attributes. Changes of non-hierarchical attributes are pushed to hierarchical and back. The attributes must have same definition of custom attribute. Handler does not handle changes of hierarchical parents. So if entity does not have explicitly set value of hierarchical attribute and any parent would change it the change would not be propagated. The handler also push the value to task entity on task creation and movement. To push values between hierarchical & non-hierarchical add 'Task' to entity types in settings. Todos: Task attribute values push on create/move should be possible to enabled by settings. """ # Ignore event handler by default cust_attrs_query = ( "select id, key, object_type_id, is_hierarchical, default" " from CustomAttributeConfiguration" " where key in ({})" ) _cached_task_object_id = None _cached_interest_object_ids = None _cached_user_id = None _cached_changes = [] _max_delta = 30 settings_key = "sync_hier_entity_attributes" def filter_entities_info( self, event: ftrack_api.event.base.Event ) -> dict[str, list[dict[str, Any]]]: """Basic entities filter info we care about. This filtering is first of many filters. This does not query anything from ftrack nor use settings. Args: event (ftrack_api.event.base.Event): Ftrack event with update information. Returns: dict[str, list[dict[str, Any]]]: Filtered entity changes by project id. """ # Filter if event contain relevant data entities_info = event["data"].get("entities") if not entities_info: return entities_info_by_project_id = collections.defaultdict(list) for entity_info in entities_info: # Ignore removed entities if entity_info.get("action") == "remove": continue # Care only about information with changes of entities changes = entity_info.get("changes") if not changes: continue # Get project id from entity info project_id = None for parent_item in reversed(entity_info["parents"]): if parent_item["entityType"] == "show": project_id = parent_item["entityId"] break if project_id is None: continue entities_info_by_project_id[project_id].append(entity_info) return entities_info_by_project_id def _get_attrs_configurations(self, session, interest_attributes): """Get custom attribute configurations by name. Args: session (ftrack_api.Session): Ftrack sesson. interest_attributes (list[str]): Names of custom attributes that should be synchronized. Returns: tuple[dict[str, list], list]: Attributes by object id and hierarchical attributes. """ attrs = session.query(self.cust_attrs_query.format( self.join_query_keys(interest_attributes) )).all() attrs_by_obj_id = collections.defaultdict(list) hier_attrs = [] for attr in attrs: if attr["is_hierarchical"]: hier_attrs.append(attr) continue obj_id = attr["object_type_id"] attrs_by_obj_id[obj_id].append(attr) return attrs_by_obj_id, hier_attrs def _get_handler_project_settings( self, session: ftrack_api.Session, event: ftrack_api.event.base.Event, project_id: str ) -> tuple[set[str], set[str]]: """Get handler settings based on the project. Args: session (ftrack_api.Session): Ftrack session. event (ftrack_api.event.base.Event): Ftrack event which triggered the changes. project_id (str): Project id where the current changes are handled. Returns: tuple[set[str], set[str]]: Attribute names we care about and entity types we care about. """ project_name: str = self.get_project_name_from_event( session, event, project_id ) if get_project(project_name) is None: self.log.debug("Project not found in OpenPype. Skipping") return set(), set() # Load settings project_settings: dict[str, Any] = ( self.get_project_settings_from_event(event, project_name) ) # Load status mapping from presets event_settings: dict[str, Any] = ( project_settings ["ftrack"] ["events"] [self.settings_key] ) # Skip if event is not enabled if not event_settings["enabled"]: self.log.debug("Project \"{}\" has disabled {}".format( project_name, self.__class__.__name__ )) return set(), set() interest_attributes: list[str] = event_settings["interest_attributes"] if not interest_attributes: self.log.info(( "Project \"{}\" does not have filled 'interest_attributes'," " skipping." )) interest_entity_types: list[str] = ( event_settings["interest_entity_types"]) if not interest_entity_types: self.log.info(( "Project \"{}\" does not have filled 'interest_entity_types'," " skipping." )) # Unify possible issues from settings ('Asset Build' -> 'assetbuild') interest_entity_types: set[str] = { entity_type.replace(" ", "").lower() for entity_type in interest_entity_types } return set(interest_attributes), interest_entity_types def _entities_filter_by_settings( self, entities_info: list[dict[str, Any]], interest_attributes: set[str], interest_entity_types: set[str] ): new_entities_info = [] for entity_info in entities_info: entity_type_low = entity_info["entity_type"].lower() changes = entity_info["changes"] # SPECIAL CASE: Capture changes of task created/moved under # interested entity type if ( entity_type_low == "task" and "parent_id" in changes ): # Direct parent is always second item in 'parents' and 'Task' # must have at least one parent parent_info = entity_info["parents"][1] parent_entity_type = ( parent_info["entity_type"] .replace(" ", "") .lower() ) if parent_entity_type in interest_entity_types: new_entities_info.append(entity_info) continue # Skip if entity type is not enabled for attr value sync if entity_type_low not in interest_entity_types: continue valid_attr_change = entity_info.get("action") == "add" for attr_key in interest_attributes: if valid_attr_change: break if attr_key not in changes: continue if changes[attr_key]["new"] is not None: valid_attr_change = True if not valid_attr_change: continue new_entities_info.append(entity_info) return new_entities_info def propagate_attribute_changes( self, session, interest_attributes, entities_info, attrs_by_obj_id, hier_attrs, real_values_by_entity_id, hier_values_by_entity_id, ): hier_attr_ids_by_key = { attr["key"]: attr["id"] for attr in hier_attrs } filtered_interest_attributes = { attr_name for attr_name in interest_attributes if attr_name in hier_attr_ids_by_key } attrs_keys_by_obj_id = {} for obj_id, attrs in attrs_by_obj_id.items(): attrs_keys_by_obj_id[obj_id] = { attr["key"]: attr["id"] for attr in attrs } op_changes = [] for entity_info in entities_info: entity_id = entity_info["entityId"] obj_id = entity_info["objectTypeId"] # Skip attributes sync if does not have object specific custom # attribute if obj_id not in attrs_keys_by_obj_id: continue attr_keys = attrs_keys_by_obj_id[obj_id] real_values = real_values_by_entity_id[entity_id] hier_values = hier_values_by_entity_id[entity_id] changes = copy.deepcopy(entity_info["changes"]) obj_id_attr_keys = { attr_key for attr_key in filtered_interest_attributes if attr_key in attr_keys } if not obj_id_attr_keys: continue value_by_key = {} is_new_entity = entity_info.get("action") == "add" for attr_key in obj_id_attr_keys: if ( attr_key in changes and changes[attr_key]["new"] is not None ): value_by_key[attr_key] = changes[attr_key]["new"] if not is_new_entity: continue hier_attr_id = hier_attr_ids_by_key[attr_key] attr_id = attr_keys[attr_key] if hier_attr_id in real_values or attr_id in real_values: continue value_by_key[attr_key] = hier_values[hier_attr_id] for key, new_value in value_by_key.items(): if new_value is None: continue hier_id = hier_attr_ids_by_key[key] std_id = attr_keys[key] real_hier_value = real_values.get(hier_id) real_std_value = real_values.get(std_id) hier_value = hier_values[hier_id] # Get right type of value for conversion # - values in event are strings type_value = real_hier_value if type_value is None: type_value = real_std_value if type_value is None: type_value = hier_value # Skip if current values are not set if type_value is None: continue try: new_value = type(type_value)(new_value) except Exception: self.log.warning(( "Couldn't convert from {} to {}." " Skipping update values." ).format(type(new_value), type(type_value))) continue real_std_value_is_same = new_value == real_std_value real_hier_value_is_same = new_value == real_hier_value # New value does not match anything in current entity values if ( not is_new_entity and not real_std_value_is_same and not real_hier_value_is_same ): continue if not real_std_value_is_same: op_changes.append(( std_id, entity_id, new_value, real_values.get(std_id), std_id in real_values )) if not real_hier_value_is_same: op_changes.append(( hier_id, entity_id, new_value, real_values.get(hier_id), hier_id in real_values )) for change in op_changes: ( attr_id, entity_id, new_value, old_value, do_update ) = change entity_key = collections.OrderedDict([ ("configuration_id", attr_id), ("entity_id", entity_id) ]) if do_update: op = ftrack_api.operation.UpdateEntityOperation( "CustomAttributeValue", entity_key, "value", old_value, new_value ) else: op = ftrack_api.operation.CreateEntityOperation( "CustomAttributeValue", entity_key, {"value": new_value} ) session.recorded_operations.push(op) if len(session.recorded_operations) > 100: session.commit() session.commit() def process_by_project( self, session: ftrack_api.Session, event: ftrack_api.event.base.Event, project_id: str, entities_info: list[dict[str, Any]] ): """Process changes in single project. Args: session (ftrack_api.Session): Ftrack session. event (ftrack_api.event.base.Event): Event which has all changes information. project_id (str): Project id related to changes. entities_info (list[dict[str, Any]]): Changes of entities. """ ( interest_attributes, interest_entity_types ) = self._get_handler_project_settings(session, event, project_id) if not interest_attributes or not interest_entity_types: return entities_info: list[dict[str, Any]] = ( self._entities_filter_by_settings( entities_info, interest_attributes, interest_entity_types ) ) if not entities_info: return attrs_by_obj_id, hier_attrs = self._get_attrs_configurations( session, interest_attributes ) # Skip if attributes are not available # - there is nothing to sync if not attrs_by_obj_id or not hier_attrs: return entity_ids_by_parent_id = collections.defaultdict(set) all_entity_ids = set() for entity_info in entities_info: entity_id = None for item in entity_info["parents"]: item_id = item["entityId"] all_entity_ids.add(item_id) if entity_id is not None: entity_ids_by_parent_id[item_id].add(entity_id) entity_id = item_id attr_ids = {attr["id"] for attr in hier_attrs} for attrs in attrs_by_obj_id.values(): attr_ids |= {attr["id"] for attr in attrs} # Query real custom attribute values # - we have to know what are the real values, if are set and to what # value value_items = query_custom_attributes( session, attr_ids, all_entity_ids, True ) real_values_by_entity_id = collections.defaultdict(dict) for item in value_items: entity_id = item["entity_id"] attr_id = item["configuration_id"] real_values_by_entity_id[entity_id][attr_id] = item["value"] hier_values_by_entity_id = {} default_values = { attr["id"]: attr["default"] for attr in hier_attrs } hier_queue = collections.deque() hier_queue.append((default_values, [project_id])) while hier_queue: parent_values, entity_ids = hier_queue.popleft() for entity_id in entity_ids: entity_values = copy.deepcopy(parent_values) real_values = real_values_by_entity_id[entity_id] for attr_id, value in real_values.items(): entity_values[attr_id] = value hier_values_by_entity_id[entity_id] = entity_values hier_queue.append( (entity_values, entity_ids_by_parent_id[entity_id]) ) self.propagate_attribute_changes( session, interest_attributes, entities_info, attrs_by_obj_id, hier_attrs, real_values_by_entity_id, hier_values_by_entity_id, ) def launch(self, session, event): filtered_entities_info = self.filter_entities_info(event) if not filtered_entities_info: return for project_id, entities_info in filtered_entities_info.items(): self.process_by_project(session, event, project_id, entities_info) def register(session): PushHierValuesToNonHierEvent(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/event_radio_buttons.py ================================================ import ftrack_api from openpype_modules.ftrack.lib import BaseEvent class RadioButtons(BaseEvent): ignore_me = True def launch(self, session, event): '''Provides a radio button behaviour to any boolean attribute in radio_button group.''' # start of event procedure ---------------------------------- for entity in event['data'].get('entities', []): if entity['entityType'] == 'assetversion': query = 'CustomAttributeGroup where name is "radio_button"' group = session.query(query).one() radio_buttons = [] for g in group['custom_attribute_configurations']: radio_buttons.append(g['key']) for key in entity['keys']: if (key in radio_buttons and entity['changes'] is not None): if entity['changes'][key]['new'] == '1': version = session.get('AssetVersion', entity['entityId']) asset = session.get('Asset', entity['parentId']) for v in asset['versions']: if version is not v: v['custom_attributes'][key] = 0 session.commit() def register(session): '''Register plugin. Called when used as an plugin.''' RadioButtons(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/event_sync_links.py ================================================ from pymongo import UpdateOne from bson.objectid import ObjectId from openpype.pipeline import AvalonMongoDB from openpype_modules.ftrack.lib import ( CUST_ATTR_ID_KEY, query_custom_attributes, BaseEvent ) class SyncLinksToAvalon(BaseEvent): """Synchronize inpug linkts to avalon documents.""" # Run after sync to avalon event handler priority = 110 def __init__(self, session): self.dbcon = AvalonMongoDB() super(SyncLinksToAvalon, self).__init__(session) def launch(self, session, event): # Try to commit and if any error happen then recreate session entities_info = event["data"]["entities"] dependency_changes = [] removed_entities = set() for entity_info in entities_info: action = entity_info.get("action") entityType = entity_info.get("entityType") if action not in ("remove", "add"): continue if entityType == "task": removed_entities.add(entity_info["entityId"]) elif entityType == "dependency": dependency_changes.append(entity_info) # Care only about dependency changes if not dependency_changes: return project_id = None for entity_info in dependency_changes: for parent_info in entity_info["parents"]: if parent_info["entityType"] == "show": project_id = parent_info["entityId"] if project_id is not None: break changed_to_ids = set() for entity_info in dependency_changes: to_id_change = entity_info["changes"]["to_id"] if to_id_change["new"] is not None: changed_to_ids.add(to_id_change["new"]) if to_id_change["old"] is not None: changed_to_ids.add(to_id_change["old"]) self._update_in_links(session, changed_to_ids, project_id) def _update_in_links(self, session, ftrack_ids, project_id): if not ftrack_ids or project_id is None: return attr_def = session.query(( "select id from CustomAttributeConfiguration where key is \"{}\"" ).format(CUST_ATTR_ID_KEY)).first() if attr_def is None: return project_entity = session.query(( "select full_name from Project where id is \"{}\"" ).format(project_id)).first() if not project_entity: return project_name = project_entity["full_name"] mongo_id_by_ftrack_id = self._get_mongo_ids_by_ftrack_ids( session, attr_def["id"], ftrack_ids ) filtered_ftrack_ids = tuple(mongo_id_by_ftrack_id.keys()) context_links = session.query(( "select from_id, to_id from TypedContextLink where to_id in ({})" ).format(self.join_query_keys(filtered_ftrack_ids))).all() mapping_by_to_id = { ftrack_id: set() for ftrack_id in filtered_ftrack_ids } all_from_ids = set() for context_link in context_links: to_id = context_link["to_id"] from_id = context_link["from_id"] if from_id == to_id: continue all_from_ids.add(from_id) mapping_by_to_id[to_id].add(from_id) mongo_id_by_ftrack_id.update(self._get_mongo_ids_by_ftrack_ids( session, attr_def["id"], all_from_ids )) self.log.info(mongo_id_by_ftrack_id) bulk_writes = [] for to_id, from_ids in mapping_by_to_id.items(): dst_mongo_id = mongo_id_by_ftrack_id[to_id] links = [] for ftrack_id in from_ids: link_mongo_id = mongo_id_by_ftrack_id.get(ftrack_id) if link_mongo_id is None: continue links.append({ "id": ObjectId(link_mongo_id), "linkedBy": "ftrack", "type": "breakdown" }) bulk_writes.append(UpdateOne( {"_id": ObjectId(dst_mongo_id)}, {"$set": {"data.inputLinks": links}} )) if bulk_writes: self.dbcon.database[project_name].bulk_write(bulk_writes) def _get_mongo_ids_by_ftrack_ids(self, session, attr_id, ftrack_ids): output = query_custom_attributes( session, [attr_id], ftrack_ids, True ) mongo_id_by_ftrack_id = {} for item in output: mongo_id = item["value"] if not mongo_id: continue ftrack_id = item["entity_id"] mongo_id_by_ftrack_id[ftrack_id] = mongo_id return mongo_id_by_ftrack_id def register(session): '''Register plugin. Called when used as an plugin.''' SyncLinksToAvalon(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py ================================================ import collections import copy import json import time import datetime import atexit import traceback from bson.objectid import ObjectId from pymongo import UpdateOne import arrow import ftrack_api from openpype.client import ( get_project, get_assets, get_archived_assets, get_asset_ids_with_subsets ) from openpype.client.operations import CURRENT_ASSET_DOC_SCHEMA from openpype.pipeline import AvalonMongoDB, schema from openpype_modules.ftrack.lib import ( get_openpype_attr, query_custom_attributes, CUST_ATTR_ID_KEY, CUST_ATTR_AUTO_SYNC, FPS_KEYS, avalon_sync, BaseEvent ) from openpype_modules.ftrack.lib.avalon_sync import ( convert_to_fps, InvalidFpsValue ) class SyncToAvalonEvent(BaseEvent): interest_entTypes = ["show", "task"] ignore_ent_types = ["Milestone"] ignore_keys = ["statusid", "thumbid"] cust_attr_query_keys = [ "id", "key", "entity_type", "object_type_id", "is_hierarchical", "config", "default" ] project_query = ( "select full_name, name, custom_attributes" ", project_schema._task_type_schema.types.name" " from Project where id is \"{}\"" ) entities_query_by_id = ( "select id, name, parent_id, link, custom_attributes, description" " from TypedContext where project_id is \"{}\" and id in ({})" ) # useful for getting all tasks for asset task_entities_query_by_parent_id = ( "select id, name, parent_id, type_id from Task" " where project_id is \"{}\" and parent_id in ({})" ) task_types_query = ( "select id, name from Type" ) entities_name_query_by_name = ( "select id, name from TypedContext" " where project_id is \"{}\" and name in ({})" ) created_entities = [] report_splitter = {"type": "label", "value": "---"} def __init__(self, session): '''Expects a ftrack_api.Session instance''' # Debug settings # - time expiration in seconds self.debug_print_time_expiration = 5 * 60 # - store current time self.debug_print_time = datetime.datetime.now() # - store synchronize entity types to be able to use # only entityTypes in interest instead of filtering by ignored self.debug_sync_types = collections.defaultdict(list) self.dbcon = AvalonMongoDB() # Set processing session to not use global self.set_process_session(session) super().__init__(session) def debug_logs(self): """This is debug method for printing small debugs messages. """ now_datetime = datetime.datetime.now() delta = now_datetime - self.debug_print_time if delta.total_seconds() < self.debug_print_time_expiration: return self.debug_print_time = now_datetime known_types_items = [] for entityType, entity_type in self.debug_sync_types.items(): ent_types_msg = ", ".join(entity_type) known_types_items.append( "<{}> ({})".format(entityType, ent_types_msg) ) known_entityTypes = ", ".join(known_types_items) self.log.debug( "DEBUG MESSAGE: Known types {}".format(known_entityTypes) ) @property def cur_project(self): if self._cur_project is None: found_id = None for ent_info in self._cur_event["data"]["entities"]: if found_id is not None: break parents = ent_info.get("parents") or [] for parent in parents: if parent.get("entityType") == "show": found_id = parent.get("entityId") break if found_id: self._cur_project = self.process_session.query( self.project_query.format(found_id) ).one() return self._cur_project @property def avalon_cust_attrs(self): if self._avalon_cust_attrs is None: self._avalon_cust_attrs = get_openpype_attr( self.process_session, query_keys=self.cust_attr_query_keys ) return self._avalon_cust_attrs @property def cust_attr_types_by_id(self): if self._cust_attr_types_by_id is None: cust_attr_types = self.process_session.query( "select id, name from CustomAttributeType" ).all() self._cust_attr_types_by_id = { cust_attr_type["id"]: cust_attr_type for cust_attr_type in cust_attr_types } return self._cust_attr_types_by_id @property def avalon_entities(self): if self._avalon_ents is None: project_name = self.cur_project["full_name"] self.dbcon.install() self.dbcon.Session["AVALON_PROJECT"] = project_name avalon_project = get_project(project_name) avalon_entities = list(get_assets(project_name)) self._avalon_ents = (avalon_project, avalon_entities) return self._avalon_ents @property def avalon_ents_by_name(self): if self._avalon_ents_by_name is None: self._avalon_ents_by_name = {} proj, ents = self.avalon_entities for ent in ents: self._avalon_ents_by_name[ent["name"]] = ent return self._avalon_ents_by_name @property def avalon_ents_by_id(self): if self._avalon_ents_by_id is None: self._avalon_ents_by_id = {} proj, ents = self.avalon_entities if proj: self._avalon_ents_by_id[proj["_id"]] = proj for ent in ents: self._avalon_ents_by_id[ent["_id"]] = ent return self._avalon_ents_by_id @property def avalon_ents_by_parent_id(self): if self._avalon_ents_by_parent_id is None: self._avalon_ents_by_parent_id = collections.defaultdict(list) proj, ents = self.avalon_entities for ent in ents: vis_par = ent["data"]["visualParent"] if vis_par is None: vis_par = proj["_id"] self._avalon_ents_by_parent_id[vis_par].append(ent) return self._avalon_ents_by_parent_id @property def avalon_ents_by_ftrack_id(self): if self._avalon_ents_by_ftrack_id is None: self._avalon_ents_by_ftrack_id = {} proj, ents = self.avalon_entities if proj: ftrack_id = proj["data"].get("ftrackId") if ftrack_id is None: self.handle_missing_ftrack_id(proj) ftrack_id = proj["data"]["ftrackId"] self._avalon_ents_by_ftrack_id[ftrack_id] = proj self._avalon_ents_by_ftrack_id[ftrack_id] = proj for ent in ents: ftrack_id = ent["data"].get("ftrackId") if ftrack_id is None: continue self._avalon_ents_by_ftrack_id[ftrack_id] = ent return self._avalon_ents_by_ftrack_id def handle_missing_ftrack_id(self, doc): # TODO handling of missing ftrack id is primarily issue of editorial # publishing it would be better to find out what causes that # ftrack id is removed during the publishing ftrack_id = doc["data"].get("ftrackId") if ftrack_id is not None: return if doc["type"] == "project": ftrack_id = self.cur_project["id"] self.dbcon.update_one( {"type": "project"}, {"$set": { "data.ftrackId": ftrack_id, "data.entityType": self.cur_project.entity_type }} ) doc["data"]["ftrackId"] = ftrack_id doc["data"]["entityType"] = self.cur_project.entity_type self.log.info("Updated ftrack id of project \"{}\"".format( self.cur_project["full_name"] )) return if doc["type"] != "asset": return doc_parents = doc.get("data", {}).get("parents") if doc_parents is None: return entities = self.process_session.query(( "select id, link from TypedContext" " where project_id is \"{}\" and name is \"{}\"" ).format(self.cur_project["id"], doc["name"])).all() self.log.info("Entities: {}".format(str(entities))) matching_entity = None for entity in entities: parents = [] for item in entity["link"]: if item["id"] == entity["id"]: break low_type = item["type"].lower() if low_type == "typedcontext": parents.append(item["name"]) if doc_parents == parents: matching_entity = entity break if matching_entity is None: return ftrack_id = matching_entity["id"] self.dbcon.update_one( {"_id": doc["_id"]}, {"$set": { "data.ftrackId": ftrack_id, "data.entityType": matching_entity.entity_type }} ) doc["data"]["ftrackId"] = ftrack_id doc["data"]["entityType"] = matching_entity.entity_type entity_path_items = [] for item in entity["link"]: entity_path_items.append(item["name"]) self.log.info("Updated ftrack id of entity \"{}\"".format( "/".join(entity_path_items) )) self._avalon_ents_by_ftrack_id[ftrack_id] = doc @property def avalon_asset_ids_with_subsets(self): if self._avalon_asset_ids_with_subsets is None: project_name = self.cur_project["full_name"] self._avalon_asset_ids_with_subsets = get_asset_ids_with_subsets( project_name ) return self._avalon_asset_ids_with_subsets @property def avalon_archived_by_id(self): if self._avalon_archived_by_id is None: self._avalon_archived_by_id = {} project_name = self.cur_project["full_name"] for asset in get_archived_assets(project_name): self._avalon_archived_by_id[asset["_id"]] = asset return self._avalon_archived_by_id @property def avalon_archived_by_name(self): if self._avalon_archived_by_name is None: self._avalon_archived_by_name = {} for asset in self.avalon_archived_by_id.values(): self._avalon_archived_by_name[asset["name"]] = asset return self._avalon_archived_by_name @property def changeability_by_mongo_id(self): """Return info about changeability of entity and it's parents.""" if self._changeability_by_mongo_id is None: self._changeability_by_mongo_id = collections.defaultdict( lambda: True ) avalon_project, avalon_entities = self.avalon_entities self._changeability_by_mongo_id[avalon_project["_id"]] = False self._bubble_changeability( list(self.avalon_asset_ids_with_subsets) ) return self._changeability_by_mongo_id def remove_cached_by_key(self, key, values): if self._avalon_ents is None: return if not isinstance(values, (list, tuple)): values = [values] def get_found_data(entity): if not entity: return None return { "ftrack_id": entity["data"]["ftrackId"], "parent_id": entity["data"]["visualParent"], "_id": entity["_id"], "name": entity["name"], "entity": entity } if key == "id": key = "_id" elif key == "ftrack_id": key = "data.ftrackId" found_data = {} project, entities = self._avalon_ents key_items = key.split(".") for value in values: ent = None if key == "_id": if self._avalon_ents_by_id is not None: ent = self._avalon_ents_by_id.get(value) elif key == "name": if self._avalon_ents_by_name is not None: ent = self._avalon_ents_by_name.get(value) elif key == "data.ftrackId": if self._avalon_ents_by_ftrack_id is not None: ent = self._avalon_ents_by_ftrack_id.get(value) if ent is None: for _ent in entities: _temp = _ent for item in key_items: _temp = _temp[item] if _temp == value: ent = _ent break found_data[value] = get_found_data(ent) for value in values: data = found_data[value] if not data: # TODO logging self.log.warning( "Didn't find entity by key/value \"{}\" / \"{}\"".format( key, value ) ) continue ftrack_id = data["ftrack_id"] parent_id = data["parent_id"] mongo_id = data["_id"] name = data["name"] entity = data["entity"] project, ents = self._avalon_ents ents.remove(entity) self._avalon_ents = project, ents if self._avalon_ents_by_ftrack_id is not None: self._avalon_ents_by_ftrack_id.pop(ftrack_id, None) if self._avalon_ents_by_parent_id is not None: self._avalon_ents_by_parent_id[parent_id].remove(entity) if self._avalon_ents_by_id is not None: self._avalon_ents_by_id.pop(mongo_id, None) if self._avalon_ents_by_name is not None: self._avalon_ents_by_name.pop(name, None) if self._avalon_archived_by_id is not None: self._avalon_archived_by_id[mongo_id] = entity def _bubble_changeability(self, unchangeable_ids): unchangeable_queue = collections.deque() for entity_id in unchangeable_ids: unchangeable_queue.append((entity_id, False)) processed_parents_ids = [] while unchangeable_queue: entity_id, child_is_archived = unchangeable_queue.popleft() # skip if already processed if entity_id in processed_parents_ids: continue entity = self.avalon_ents_by_id.get(entity_id) # if entity is not archived but unchageable child was then skip # - archived entities should not affect not archived? if entity and child_is_archived: continue # set changeability of current entity to False self._changeability_by_mongo_id[entity_id] = False processed_parents_ids.append(entity_id) # if not entity then is probably archived if not entity: entity = self.avalon_archived_by_id.get(entity_id) child_is_archived = True if not entity: # if entity is not found then it is subset without parent if entity_id in unchangeable_ids: self.log.warning(( "Parent <{}> with subsets does not exist" ).format(str(entity_id))) else: self.log.warning(( "In avalon are entities without valid parents that" " lead to Project (should not cause errors)" " - MongoId <{}>" ).format(str(entity_id))) continue # skip if parent is project parent_id = entity["data"]["visualParent"] if parent_id is None: continue unchangeable_queue.append((parent_id, child_is_archived)) def reset_variables(self): """Reset variables so each event callback has clear env.""" self._cur_project = None self._avalon_cust_attrs = None self._cust_attr_types_by_id = None self._avalon_ents = None self._avalon_ents_by_id = None self._avalon_ents_by_parent_id = None self._avalon_ents_by_ftrack_id = None self._avalon_ents_by_name = None self._avalon_asset_ids_with_subsets = None self._changeability_by_mongo_id = None self._avalon_archived_by_id = None self._avalon_archived_by_name = None self._ent_types_by_name = None self.ftrack_ents_by_id = {} self.obj_id_ent_type_map = {} self.ftrack_recreated_mapping = {} self.ftrack_added = {} self.ftrack_moved = {} self.ftrack_renamed = {} self.ftrack_updated = {} self.ftrack_removed = {} # set of ftrack ids with modified tasks # handled separately by full wipeout and replace from FTrack self.modified_tasks_ftrackids = set() self.moved_in_avalon = [] self.renamed_in_avalon = [] self.hier_cust_attrs_changes = collections.defaultdict(list) self.duplicated = [] self.regex_failed = [] self.regex_schemas = {} self.updates = collections.defaultdict(dict) self.report_items = { "info": collections.defaultdict(list), "warning": collections.defaultdict(list), "error": collections.defaultdict(list) } def set_process_session(self, session): try: self.process_session.close() except Exception: pass self.process_session = ftrack_api.Session( server_url=session.server_url, api_key=session.api_key, api_user=session.api_user, auto_connect_event_hub=True ) atexit.register(lambda: self.process_session.close()) def filter_updated(self, updates): filtered_updates = {} for ftrack_id, ent_info in updates.items(): changed_keys = [k for k in (ent_info.get("keys") or [])] changes = { k: v for k, v in (ent_info.get("changes") or {}).items() } entity_type = ent_info["entity_type"] if entity_type == "Task": if "name" in changed_keys: ent_info["keys"] = ["name"] ent_info["changes"] = {"name": changes.pop("name")} filtered_updates[ftrack_id] = ent_info continue for _key in self.ignore_keys: if _key in changed_keys: changed_keys.remove(_key) changes.pop(_key, None) if not changed_keys: continue # Remove custom attributes starting with `avalon_` from changes # - these custom attributes are not synchronized avalon_keys = [] for key in changes: if key.startswith("avalon_"): avalon_keys.append(key) for _key in avalon_keys: changed_keys.remove(_key) changes.pop(_key, None) if not changed_keys: continue ent_info["keys"] = changed_keys ent_info["changes"] = changes filtered_updates[ftrack_id] = ent_info return filtered_updates def get_ent_path(self, ftrack_id): """ Looks for entity in FTrack with 'ftrack_id'. If found returns concatenated paths from its 'link' elemenent's names. Describes location of entity in tree. Args: ftrack_id (string): entityId of FTrack entity Returns: (string) - example : "/test_project/assets/my_asset" """ entity = self.ftrack_ents_by_id.get(ftrack_id) if not entity: entity = self.process_session.query( self.entities_query_by_id.format( self.cur_project["id"], ftrack_id ) ).first() if entity: self.ftrack_ents_by_id[ftrack_id] = entity else: return "unknown hierarchy" return "/".join([ent["name"] for ent in entity["link"]]) def launch(self, session, event): """ Main entry port for synchronization. Goes through event (can contain multiple changes) and decides if the event is interesting for us (interest_entTypes). It separates changes into add|remove|update. All task changes are handled together by refresh from Ftrack. Args: session (object): session to Ftrack event (dictionary): event content Returns: (boolean or None) """ # Try to commit and if any error happen then recreate session try: self.process_session.commit() except Exception: self.set_process_session(session) # Reset object values for each launch self.reset_variables() self._cur_event = event entities_by_action = { "remove": {}, "update": {}, "move": {}, "add": {} } entities_info = event["data"]["entities"] found_actions = set() for ent_info in entities_info: entityType = ent_info["entityType"] if entityType not in self.interest_entTypes: continue entity_type = ent_info.get("entity_type") if not entity_type or entity_type in self.ignore_ent_types: continue if entity_type not in self.debug_sync_types[entityType]: self.debug_sync_types[entityType].append(entity_type) action = ent_info["action"] ftrack_id = ent_info["entityId"] if isinstance(ftrack_id, list): self.log.warning(( "BUG REPORT: Entity info has `entityId` as `list` \"{}\"" ).format(ent_info)) if len(ftrack_id) == 0: continue ftrack_id = ftrack_id[0] # Skip deleted projects if action == "remove" and entityType == "show": return True # task modified, collect parent id of task, handle separately if entity_type.lower() == "task": changes = ent_info.get("changes") or {} if action == "move": parent_changes = changes["parent_id"] self.modified_tasks_ftrackids.add(parent_changes["new"]) self.modified_tasks_ftrackids.add(parent_changes["old"]) elif "typeid" in changes or "name" in changes: self.modified_tasks_ftrackids.add(ent_info["parentId"]) continue if action == "move": ent_keys = ent_info["keys"] # Separate update info from move action if len(ent_keys) > 1: _ent_info = ent_info.copy() for ent_key in ent_keys: if ent_key == "parent_id": _ent_info["changes"].pop(ent_key, None) _ent_info["keys"].remove(ent_key) else: ent_info["changes"].pop(ent_key, None) ent_info["keys"].remove(ent_key) entities_by_action["update"][ftrack_id] = _ent_info # regular change process handles all other than Tasks found_actions.add(action) entities_by_action[action][ftrack_id] = ent_info found_actions = list(found_actions) if not found_actions and not self.modified_tasks_ftrackids: return True # Check if auto sync was turned on/off updated = entities_by_action["update"] for ftrack_id, ent_info in updated.items(): # filter project if ent_info["entityType"] != "show": continue changes = ent_info["changes"] if CUST_ATTR_AUTO_SYNC not in changes: continue auto_sync = changes[CUST_ATTR_AUTO_SYNC]["new"] turned_on = auto_sync == "1" ft_project = self.cur_project username = self._get_username(session, event) message = ( "Auto sync was turned {} for project \"{}\" by \"{}\"." ).format( "on" if turned_on else "off", ft_project["full_name"], username ) if turned_on: message += " Triggering syncToAvalon action." self.log.debug(message) if turned_on: # Trigger sync to avalon action if auto sync was turned on selection = [{ "entityId": ft_project["id"], "entityType": "show" }] self.trigger_action( action_name="sync.to.avalon.server", event=event, selection=selection ) # Exit for both cases return True # Filter updated data by changed keys updated = self.filter_updated(updated) # skip most of events where nothing has changed for avalon if ( len(found_actions) == 1 and found_actions[0] == "update" and not updated and not self.modified_tasks_ftrackids ): return True ft_project = self.cur_project # Check if auto-sync custom attribute exists if CUST_ATTR_AUTO_SYNC not in ft_project["custom_attributes"]: # TODO should we sent message to someone? self.log.error(( "Custom attribute \"{}\" is not created or user \"{}\" used" " for Event server don't have permissions to access it!" ).format(CUST_ATTR_AUTO_SYNC, self.session.api_user)) return True # Skip if auto-sync is not set auto_sync = ft_project["custom_attributes"][CUST_ATTR_AUTO_SYNC] if auto_sync is not True: return True debug_msg = "Updated: {}".format(len(updated)) debug_action_map = { "add": "Created", "remove": "Removed", "move": "Moved" } for action, infos in entities_by_action.items(): if action == "update": continue _action = debug_action_map[action] debug_msg += "| {}: {}".format(_action, len(infos)) self.log.debug("Project changes <{}>: {}".format( ft_project["full_name"], debug_msg )) # Get ftrack entities - find all ftrack ids first ftrack_ids = set(updated.keys()) for action, _ftrack_ids in entities_by_action.items(): # skip updated (already prepared) and removed (not exist in ftrack) if action not in ("remove", "update"): ftrack_ids |= set(_ftrack_ids) # collect entity records data which might not be in event if ftrack_ids: joined_ids = ", ".join(["\"{}\"".format(id) for id in ftrack_ids]) ftrack_entities = self.process_session.query( self.entities_query_by_id.format(ft_project["id"], joined_ids) ).all() for entity in ftrack_entities: self.ftrack_ents_by_id[entity["id"]] = entity # Filter updates where name is changing for ftrack_id, ent_info in updated.items(): ent_keys = ent_info["keys"] # Separate update info from rename if "name" not in ent_keys: continue _ent_info = copy.deepcopy(ent_info) for ent_key in ent_keys: if ent_key == "name": ent_info["changes"].pop(ent_key, None) ent_info["keys"].remove(ent_key) else: _ent_info["changes"].pop(ent_key, None) _ent_info["keys"].remove(ent_key) self.ftrack_renamed[ftrack_id] = _ent_info self.ftrack_removed = entities_by_action["remove"] self.ftrack_moved = entities_by_action["move"] self.ftrack_added = entities_by_action["add"] self.ftrack_updated = updated self.debug_logs() self.log.debug("Synchronization begins") try: time_1 = time.time() # 1.) Process removed - may affect all other actions self.process_removed() time_2 = time.time() # 2.) Process renamed - may affect added self.process_renamed() time_3 = time.time() # 3.) Process added - moved entity may be moved to new entity self.process_added() time_4 = time.time() # 4.) Process moved self.process_moved() time_5 = time.time() # 5.) Process updated self.process_updated() time_6 = time.time() # 6.) Process changes in hierarchy or hier custom attributes self.process_hier_cleanup() time_7 = time.time() self.process_task_updates() if self.updates: self.update_entities() time_8 = time.time() time_removed = time_2 - time_1 time_renamed = time_3 - time_2 time_added = time_4 - time_3 time_moved = time_5 - time_4 time_updated = time_6 - time_5 time_cleanup = time_7 - time_6 time_task_updates = time_8 - time_7 time_total = time_8 - time_1 self.log.debug(( "Process time: {:.2f} <{:.2f}, {:.2f}, {:.2f}, " "{:.2f}, {:.2f}, {:.2f}, {:.2f}>" ).format( time_total, time_removed, time_renamed, time_added, time_moved, time_updated, time_cleanup, time_task_updates )) except Exception: msg = "An error has happened during synchronization" self.report_items["error"][msg].append(( str(traceback.format_exc()).replace("\n", "
") ).replace(" ", " ")) self.report() return True def _get_username(self, session, event): username = "Unknown" event_source = event.get("source") if not event_source: return username user_info = event_source.get("user") if not user_info: return username user_id = user_info.get("id") if not user_id: return username user_entity = session.query( "User where id is {}".format(user_id) ).first() if user_entity: username = user_entity["username"] or username return username def process_removed(self): """ Handles removed entities (not removed tasks - handle separately). """ if not self.ftrack_removed: return ent_infos = self.ftrack_removed self.log.debug( "Processing removed entities: {}".format(str(ent_infos)) ) removable_ids = [] recreate_ents = [] removed_names = [] for ftrack_id, removed in ent_infos.items(): entity_type = removed["entity_type"] if entity_type.lower() == "task": continue removed_name = removed["changes"]["name"]["old"] avalon_ent = self.avalon_ents_by_ftrack_id.get(ftrack_id) if not avalon_ent: continue mongo_id = avalon_ent["_id"] if self.changeability_by_mongo_id[mongo_id]: removable_ids.append(mongo_id) removed_names.append(removed_name) else: recreate_ents.append(avalon_ent) if removable_ids: # TODO logging self.log.debug("Assets marked as archived <{}>".format( ", ".join(removed_names) )) self.dbcon.update_many( {"_id": {"$in": removable_ids}, "type": "asset"}, {"$set": {"type": "archived_asset"}} ) self.remove_cached_by_key("id", removable_ids) if recreate_ents: # sort removed entities by parents len # - length of parents determine hierarchy level recreate_ents = sorted( recreate_ents, key=(lambda item: len( (item.get("data", {}).get("parents") or []) )) ) # TODO logging # TODO report recreate_msg = ( "Deleted entity was recreated||Entity was recreated because" " it or its children contain published data" ) proj, ents = self.avalon_entities for avalon_entity in recreate_ents: old_ftrack_id = avalon_entity["data"]["ftrackId"] vis_par = avalon_entity["data"]["visualParent"] if vis_par is None: vis_par = proj["_id"] parent_ent = self.avalon_ents_by_id[vis_par] parent_ftrack_id = parent_ent["data"].get("ftrackId") if parent_ftrack_id is None: self.handle_missing_ftrack_id(parent_ent) parent_ftrack_id = parent_ent["data"].get("ftrackId") if parent_ftrack_id is None: continue parent_ftrack_ent = self.ftrack_ents_by_id.get( parent_ftrack_id ) if not parent_ftrack_ent: if parent_ent["type"].lower() == "project": parent_ftrack_ent = self.cur_project else: parent_ftrack_ent = self.process_session.query( self.entities_query_by_id.format( self.cur_project["id"], parent_ftrack_id ) ).one() entity_type = avalon_entity["data"]["entityType"] new_entity = self.process_session.create(entity_type, { "name": avalon_entity["name"], "parent": parent_ftrack_ent }) try: self.process_session.commit() except Exception: # TODO logging # TODO report self.process_session.rollback() ent_path_items = [self.cur_project["full_name"]] ent_path_items.extend([ par for par in avalon_entity["data"]["parents"] ]) ent_path_items.append(avalon_entity["name"]) ent_path = "/".join(ent_path_items) error_msg = "Couldn't recreate entity in Ftrack" report_msg = ( "{}||Trying to recreate because it or its children" " contain published data" ).format(error_msg) self.report_items["warning"][report_msg].append(ent_path) self.log.warning( "{}. Process session commit failed! <{}>".format( error_msg, ent_path ), exc_info=True ) continue new_entity_id = new_entity["id"] avalon_entity["data"]["ftrackId"] = new_entity_id for key, val in avalon_entity["data"].items(): if not val: continue if key not in new_entity["custom_attributes"]: continue new_entity["custom_attributes"][key] = val new_entity["custom_attributes"][CUST_ATTR_ID_KEY] = ( str(avalon_entity["_id"]) ) ent_path = self.get_ent_path(new_entity_id) try: self.process_session.commit() except Exception: # TODO logging # TODO report self.process_session.rollback() error_msg = ( "Couldn't update custom attributes after recreation" " of entity in Ftrack" ) report_msg = ( "{}||Entity was recreated because it or its children" " contain published data" ).format(error_msg) self.report_items["warning"][report_msg].append(ent_path) self.log.warning( "{}. Process session commit failed! <{}>".format( error_msg, ent_path ), exc_info=True ) continue self.report_items["info"][recreate_msg].append(ent_path) self.ftrack_recreated_mapping[old_ftrack_id] = new_entity_id self.process_session.commit() found_idx = None proj_doc, asset_docs = self._avalon_ents for idx, asset_doc in enumerate(asset_docs): if asset_doc["_id"] == avalon_entity["_id"]: found_idx = idx break if found_idx is None: continue # Prepare updates dict for mongo update if "data" not in self.updates[avalon_entity["_id"]]: self.updates[avalon_entity["_id"]]["data"] = {} self.updates[avalon_entity["_id"]]["data"]["ftrackId"] = ( new_entity_id ) # Update cached entities asset_docs[found_idx] = avalon_entity self._avalon_ents = proj_doc, asset_docs if self._avalon_ents_by_id is not None: mongo_id = avalon_entity["_id"] self._avalon_ents_by_id[mongo_id] = avalon_entity if self._avalon_ents_by_parent_id is not None: vis_par = avalon_entity["data"]["visualParent"] children = self._avalon_ents_by_parent_id[vis_par] found_idx = None for idx, _entity in enumerate(children): if _entity["_id"] == avalon_entity["_id"]: found_idx = idx break children[found_idx] = avalon_entity self._avalon_ents_by_parent_id[vis_par] = children if self._avalon_ents_by_ftrack_id is not None: self._avalon_ents_by_ftrack_id.pop(old_ftrack_id) self._avalon_ents_by_ftrack_id[new_entity_id] = ( avalon_entity ) if self._avalon_ents_by_name is not None: name = avalon_entity["name"] self._avalon_ents_by_name[name] = avalon_entity # Check if entities with same name can be synchronized if not removed_names: return self.check_names_synchronizable(removed_names) def check_names_synchronizable(self, names): """Check if entities with specific names are importable. This check should happen after removing entity or renaming entity. When entity was removed or renamed then it's name is possible to sync. """ joined_passed_names = ", ".join( ["\"{}\"".format(name) for name in names] ) same_name_entities = self.process_session.query( self.entities_name_query_by_name.format( self.cur_project["id"], joined_passed_names ) ).all() if not same_name_entities: return entities_by_name = collections.defaultdict(list) for entity in same_name_entities: entities_by_name[entity["name"]].append(entity) synchronizable_ents = [] self.log.debug(( "Deleting of entities should allow to synchronize another entities" " with same name." )) for name, ents in entities_by_name.items(): if len(ents) != 1: self.log.debug(( "Name \"{}\" still have more than one entity <{}>" ).format( name, "| ".join( [self.get_ent_path(ent["id"]) for ent in ents] ) )) continue entity = ents[0] ent_path = self.get_ent_path(entity["id"]) # TODO logging self.log.debug( "Checking if can synchronize entity <{}>".format(ent_path) ) # skip if already synchronized ftrack_id = entity["id"] if ftrack_id in self.avalon_ents_by_ftrack_id: # TODO logging self.log.debug( "- Entity is already synchronized (skipping) <{}>".format( ent_path ) ) continue parent_id = entity["parent_id"] if parent_id not in self.avalon_ents_by_ftrack_id: # TODO logging self.log.debug(( "- Entity's parent entity doesn't seems to" " be synchronized (skipping) <{}>" ).format(ent_path)) continue synchronizable_ents.append(entity) if not synchronizable_ents: return synchronizable_ents = sorted( synchronizable_ents, key=(lambda entity: len(entity["link"])) ) children_queue = collections.deque() for entity in synchronizable_ents: parent_avalon_ent = self.avalon_ents_by_ftrack_id[ entity["parent_id"] ] self.create_entity_in_avalon(entity, parent_avalon_ent) for child in entity["children"]: if child.entity_type.lower() != "task": children_queue.append(child) while children_queue: entity = children_queue.popleft() ftrack_id = entity["id"] name = entity["name"] ent_by_ftrack_id = self.avalon_ents_by_ftrack_id.get(ftrack_id) if ent_by_ftrack_id: raise Exception(( "This is bug, parent was just synchronized to avalon" " but entity is already in database {}" ).format(dict(entity))) # Entity has duplicated name with another entity # - may be renamed: in that case renaming method will handle that duplicate_ent = self.avalon_ents_by_name.get(name) if duplicate_ent: continue passed_regex = avalon_sync.check_regex( name, "asset", schema_patterns=self.regex_schemas ) if not passed_regex: continue parent_id = entity["parent_id"] parent_avalon_ent = self.avalon_ents_by_ftrack_id[parent_id] self.create_entity_in_avalon(entity, parent_avalon_ent) for child in entity["children"]: if child.entity_type.lower() == "task": continue children_queue.append(child) def create_entity_in_avalon(self, ftrack_ent, parent_avalon): proj, ents = self.avalon_entities # Parents, Hierarchy ent_path_items = [ent["name"] for ent in ftrack_ent["link"]] parents = ent_path_items[1:len(ent_path_items)-1:] # TODO logging self.log.debug( "Trying to synchronize entity <{}>".format( "/".join(ent_path_items) ) ) # Add entity to modified so tasks are added at the end self.modified_tasks_ftrackids.add(ftrack_ent["id"]) # Visual Parent vis_par = None if parent_avalon["type"].lower() != "project": vis_par = parent_avalon["_id"] mongo_id = ObjectId() name = ftrack_ent["name"] final_entity = { "_id": mongo_id, "name": name, "type": "asset", "schema": CURRENT_ASSET_DOC_SCHEMA, "parent": proj["_id"], "data": { "ftrackId": ftrack_ent["id"], "entityType": ftrack_ent.entity_type, "parents": parents, "tasks": {}, "visualParent": vis_par, "description": ftrack_ent["description"] } } invalid_fps_items = [] cust_attrs = self.get_cust_attr_values(ftrack_ent) for key, val in cust_attrs.items(): if key.startswith("avalon_"): continue if key in FPS_KEYS: try: val = convert_to_fps(val) except InvalidFpsValue: invalid_fps_items.append((ftrack_ent["id"], val)) continue final_entity["data"][key] = val if invalid_fps_items: fps_msg = ( "These entities have invalid fps value in custom attributes" ) items = [] for entity_id, value in invalid_fps_items: ent_path = self.get_ent_path(entity_id) items.append("{} - \"{}\"".format(ent_path, value)) self.report_items["error"][fps_msg] = items _mongo_id_str = cust_attrs.get(CUST_ATTR_ID_KEY) if _mongo_id_str: try: _mongo_id = ObjectId(_mongo_id_str) if _mongo_id not in self.avalon_ents_by_id: mongo_id = _mongo_id final_entity["_id"] = mongo_id except Exception: pass ent_path_items = [self.cur_project["full_name"]] ent_path_items.extend([par for par in parents]) ent_path_items.append(name) ent_path = "/".join(ent_path_items) try: schema.validate(final_entity) except Exception: # TODO logging # TODO report error_msg = ( "Schema validation failed for new entity (This is a bug)" ) error_traceback = ( str(traceback.format_exc()).replace("\n", "
") ).replace(" ", " ") item_msg = ent_path + "
" + error_traceback self.report_items["error"][error_msg].append(item_msg) self.log.error( "{}: \"{}\"".format(error_msg, str(final_entity)), exc_info=True ) return None replaced = False archived = self.avalon_archived_by_name.get(name) if archived: archived_id = archived["_id"] if ( archived["data"]["parents"] == parents or self.changeability_by_mongo_id[archived_id] ): # TODO logging self.log.debug( "Entity was unarchived instead of creation <{}>".format( ent_path ) ) mongo_id = archived_id final_entity["_id"] = mongo_id self.dbcon.replace_one({"_id": mongo_id}, final_entity) replaced = True if not replaced: self.dbcon.insert_one(final_entity) # TODO logging self.log.debug("Entity was synchronized <{}>".format(ent_path)) mongo_id_str = str(mongo_id) if mongo_id_str != ftrack_ent["custom_attributes"][CUST_ATTR_ID_KEY]: ftrack_ent["custom_attributes"][CUST_ATTR_ID_KEY] = mongo_id_str try: self.process_session.commit() except Exception: self.process_session.rollback() # TODO logging # TODO report error_msg = ( "Failed to store MongoID to entity's custom attribute" ) report_msg = ( "{}||SyncToAvalon action may solve this issue" ).format(error_msg) self.report_items["warning"][report_msg].append(ent_path) self.log.error( "{}: \"{}\"".format(error_msg, ent_path), exc_info=True ) # modify cached data # Skip if self._avalon_ents is not set(maybe never happen) if self._avalon_ents is None: return final_entity if self._avalon_ents is not None: proj, ents = self._avalon_ents ents.append(final_entity) self._avalon_ents = (proj, ents) if self._avalon_ents_by_id is not None: self._avalon_ents_by_id[mongo_id] = final_entity if self._avalon_ents_by_parent_id is not None: self._avalon_ents_by_parent_id[vis_par].append(final_entity) if self._avalon_ents_by_ftrack_id is not None: self._avalon_ents_by_ftrack_id[ftrack_ent["id"]] = final_entity if self._avalon_ents_by_name is not None: self._avalon_ents_by_name[ftrack_ent["name"]] = final_entity return final_entity def get_cust_attr_values(self, entity): output = {} custom_attrs, hier_attrs = self.avalon_cust_attrs # Notmal custom attributes for attr in custom_attrs: key = attr["key"] if key in entity["custom_attributes"]: output[key] = entity["custom_attributes"][key] hier_values = avalon_sync.get_hierarchical_attributes_values( self.process_session, entity, hier_attrs, self.cust_attr_types_by_id.values() ) for key, val in hier_values.items(): output[key] = val # Make sure mongo id is not set output.pop(CUST_ATTR_ID_KEY, None) return output def process_renamed(self): ent_infos = self.ftrack_renamed if not ent_infos: return self.log.debug( "Processing renamed entities: {}".format(str(ent_infos)) ) changeable_queue = collections.deque() for ftrack_id, ent_info in ent_infos.items(): entity_type = ent_info["entity_type"] if entity_type == "Task": continue new_name = ent_info["changes"]["name"]["new"] old_name = ent_info["changes"]["name"]["old"] ent_path = self.get_ent_path(ftrack_id) avalon_ent = self.avalon_ents_by_ftrack_id.get(ftrack_id) if not avalon_ent: # TODO logging self.log.debug(( "Entity is not is avalon. Moving to \"add\" process. <{}>" ).format(ent_path)) self.ftrack_added[ftrack_id] = ent_info continue if new_name == avalon_ent["name"]: # TODO logging self.log.debug(( "Avalon entity already has the same name <{}>" ).format(ent_path)) continue mongo_id = avalon_ent["_id"] if self.changeability_by_mongo_id[mongo_id]: changeable_queue.append((ftrack_id, avalon_ent, new_name)) else: ftrack_ent = self.ftrack_ents_by_id[ftrack_id] ftrack_ent["name"] = avalon_ent["name"] try: self.process_session.commit() # TODO logging # TODO report error_msg = "Entity renamed back" report_msg = ( "{}||It is not possible to change" " the name of an entity or it's parents, " " if it already contained published data." ).format(error_msg) self.report_items["info"][report_msg].append(ent_path) self.log.warning("{} <{}>".format(error_msg, ent_path)) except Exception: self.process_session.rollback() # TODO report # TODO logging error_msg = ( "Couldn't rename the entity back to its original name" ) report_msg = ( "{}||Renamed because it is not possible to" " change the name of an entity or it's parents, " " if it already contained published data." ).format(error_msg) error_traceback = ( str(traceback.format_exc()).replace("\n", "
") ).replace(" ", " ") item_msg = ent_path + "
" + error_traceback self.report_items["warning"][report_msg].append(item_msg) self.log.warning( "{}: \"{}\"".format(error_msg, ent_path), exc_info=True ) old_names = [] # Process renaming in Avalon DB while changeable_queue: ftrack_id, avalon_ent, new_name = changeable_queue.popleft() mongo_id = avalon_ent["_id"] old_name = avalon_ent["name"] _entity_type = "asset" if entity_type == "Project": _entity_type = "project" passed_regex = avalon_sync.check_regex( new_name, _entity_type, schema_patterns=self.regex_schemas ) if not passed_regex: self.regex_failed.append(ftrack_id) continue # if avalon does not have same name then can be changed same_name_avalon_ent = self.avalon_ents_by_name.get(new_name) if not same_name_avalon_ent: old_val = self._avalon_ents_by_name.pop(old_name) old_val["name"] = new_name self._avalon_ents_by_name[new_name] = old_val self.updates[mongo_id] = {"name": new_name} self.renamed_in_avalon.append(mongo_id) old_names.append(old_name) if new_name in old_names: old_names.remove(new_name) # TODO logging ent_path = self.get_ent_path(ftrack_id) self.log.debug( "Name of entity will be changed to \"{}\" <{}>".format( new_name, ent_path ) ) continue # Check if same name is in changable_queue # - it's name may be changed in next iteration same_name_ftrack_id = same_name_avalon_ent["data"]["ftrackId"] same_is_unprocessed = False for item in changeable_queue: if same_name_ftrack_id == item[0]: same_is_unprocessed = True break if same_is_unprocessed: changeable_queue.append((ftrack_id, avalon_ent, new_name)) continue self.duplicated.append(ftrack_id) if old_names: self.check_names_synchronizable(old_names) # not_found are not processed since all not found are # not found because they are not synchronizable def process_added(self): ent_infos = self.ftrack_added if not ent_infos: return self.log.debug( "Processing added entities: {}".format(str(ent_infos)) ) cust_attrs, hier_attrs = self.avalon_cust_attrs entity_type_conf_ids = {} # Skip if already exit in avalon db or tasks entities # - happen when was created by any sync event/action pop_out_ents = [] for ftrack_id, ent_info in ent_infos.items(): if self.avalon_ents_by_ftrack_id.get(ftrack_id): pop_out_ents.append(ftrack_id) self.log.warning( "Added entity is already synchronized <{}>".format( self.get_ent_path(ftrack_id) ) ) continue entity_type = ent_info["entity_type"] if entity_type == "Task": continue name = ( ent_info .get("changes", {}) .get("name", {}) .get("new") ) avalon_ent_by_name = self.avalon_ents_by_name.get(name) or {} avalon_ent_by_name_ftrack_id = ( avalon_ent_by_name .get("data", {}) .get("ftrackId") ) if avalon_ent_by_name and avalon_ent_by_name_ftrack_id is None: ftrack_ent = self.ftrack_ents_by_id.get(ftrack_id) if not ftrack_ent: ftrack_ent = self.process_session.query( self.entities_query_by_id.format( self.cur_project["id"], ftrack_id ) ).one() self.ftrack_ents_by_id[ftrack_id] = ftrack_ent ent_path_items = [ent["name"] for ent in ftrack_ent["link"]] parents = ent_path_items[1:len(ent_path_items)-1:] avalon_ent_parents = ( avalon_ent_by_name.get("data", {}).get("parents") ) if parents == avalon_ent_parents: self.dbcon.update_one({ "_id": avalon_ent_by_name["_id"] }, { "$set": { "data.ftrackId": ftrack_id, "data.entityType": entity_type } }) avalon_ent_by_name["data"]["ftrackId"] = ftrack_id avalon_ent_by_name["data"]["entityType"] = entity_type self._avalon_ents_by_ftrack_id[ftrack_id] = ( avalon_ent_by_name ) if self._avalon_ents_by_parent_id: found = None for _parent_id_, _entities_ in ( self._avalon_ents_by_parent_id.items() ): for _idx_, entity in enumerate(_entities_): if entity["_id"] == avalon_ent_by_name["_id"]: found = (_parent_id_, _idx_) break if found: break if found: _parent_id_, _idx_ = found self._avalon_ents_by_parent_id[_parent_id_][ _idx_] = avalon_ent_by_name if self._avalon_ents_by_id: self._avalon_ents_by_id[avalon_ent_by_name["_id"]] = ( avalon_ent_by_name ) if self._avalon_ents_by_name: self._avalon_ents_by_name[name] = avalon_ent_by_name if self._avalon_ents: found = None project, entities = self._avalon_ents for _idx_, _ent_ in enumerate(entities): if _ent_["_id"] != avalon_ent_by_name["_id"]: continue found = _idx_ break if found is not None: entities[found] = avalon_ent_by_name self._avalon_ents = project, entities pop_out_ents.append(ftrack_id) continue mongo_id_configuration_id = self._mongo_id_configuration( ent_info, cust_attrs, hier_attrs, entity_type_conf_ids ) if not mongo_id_configuration_id: self.log.warning(( "BUG REPORT: Missing MongoID configuration for `{} < {} >`" ).format(entity_type, ent_info["entityType"])) continue _entity_key = collections.OrderedDict() _entity_key["configuration_id"] = mongo_id_configuration_id _entity_key["entity_id"] = ftrack_id self.process_session.recorded_operations.push( ftrack_api.operation.UpdateEntityOperation( "ContextCustomAttributeValue", _entity_key, "value", ftrack_api.symbol.NOT_SET, "" ) ) try: # Commit changes of mongo_id to empty string self.process_session.commit() self.log.debug("Committing unsetting") except Exception: self.process_session.rollback() # TODO logging msg = ( "Could not set value of Custom attribute, where mongo id" " is stored, to empty string. Ftrack ids: \"{}\"" ).format(", ".join(ent_infos.keys())) self.log.warning(msg, exc_info=True) for ftrack_id in pop_out_ents: ent_infos.pop(ftrack_id) # sort by parents length (same as by hierarchy level) _ent_infos = sorted( ent_infos.values(), key=(lambda ent_info: len(ent_info.get("parents", []))) ) to_sync_by_id = collections.OrderedDict() for ent_info in _ent_infos: ft_id = ent_info["entityId"] to_sync_by_id[ft_id] = self.ftrack_ents_by_id[ft_id] # cache regex success (for tasks) for ftrack_id, entity in to_sync_by_id.items(): if entity.entity_type.lower() == "project": raise Exception(( "Project can't be created with event handler!" "This is a bug" )) parent_id = entity["parent_id"] parent_avalon = self.avalon_ents_by_ftrack_id.get(parent_id) if not parent_avalon: # TODO logging self.log.debug(( "Skipping synchronization of entity" " because parent was not found in Avalon DB <{}>" ).format(self.get_ent_path(ftrack_id))) continue is_synchonizable = True name = entity["name"] passed_regex = avalon_sync.check_regex( name, "asset", schema_patterns=self.regex_schemas ) if not passed_regex: self.regex_failed.append(ftrack_id) is_synchonizable = False if name in self.avalon_ents_by_name: self.duplicated.append(ftrack_id) is_synchonizable = False if not is_synchonizable: continue self.create_entity_in_avalon(entity, parent_avalon) def process_moved(self): """ Handles moved entities to different place in hierarchy. (Not tasks - handled separately.) """ if not self.ftrack_moved: return self.log.debug( "Processing moved entities: {}".format(str(self.ftrack_moved)) ) ftrack_moved = {k: v for k, v in sorted( self.ftrack_moved.items(), key=(lambda line: len( (line[1].get("data", {}).get("parents") or []) )) )} for ftrack_id, ent_info in ftrack_moved.items(): avalon_ent = self.avalon_ents_by_ftrack_id.get(ftrack_id) if not avalon_ent: continue new_parent_id = ent_info["changes"]["parent_id"]["new"] old_parent_id = ent_info["changes"]["parent_id"]["old"] mongo_id = avalon_ent["_id"] if self.changeability_by_mongo_id[mongo_id]: par_av_ent = self.avalon_ents_by_ftrack_id.get(new_parent_id) if not par_av_ent: # TODO logging # TODO report ent_path_items = [self.cur_project["full_name"]] ent_path_items.extend(avalon_ent["data"]["parents"]) ent_path_items.append(avalon_ent["name"]) ent_path = "/".join(ent_path_items) error_msg = ( "New parent of entity is not synchronized to avalon" ) report_msg = ( "{}||Parent in Avalon can't be changed. That" " may cause issues. Please fix parent or move entity" " under valid entity." ).format(error_msg) self.report_items["warning"][report_msg].append(ent_path) self.log.warning("{} <{}>".format(error_msg, ent_path)) continue # THIS MUST HAPPEN AFTER CREATING NEW ENTITIES !!!! # - because may be moved to new created entity if "data" not in self.updates[mongo_id]: self.updates[mongo_id]["data"] = {} vis_par_id = None ent_path_items = [self.cur_project["full_name"]] if par_av_ent["type"].lower() != "project": vis_par_id = par_av_ent["_id"] ent_path_items.extend(par_av_ent["data"]["parents"]) ent_path_items.append(par_av_ent["name"]) self.updates[mongo_id]["data"]["visualParent"] = vis_par_id self.moved_in_avalon.append(mongo_id) ent_path_items.append(avalon_ent["name"]) ent_path = "/".join(ent_path_items) self.log.debug(( "Parent of entity ({}) was changed in avalon <{}>" ).format(str(mongo_id), ent_path) ) else: avalon_ent = self.avalon_ents_by_id[mongo_id] avalon_parent_id = avalon_ent["data"]["visualParent"] if avalon_parent_id is None: avalon_parent_id = avalon_ent["parent"] avalon_parent = self.avalon_ents_by_id[avalon_parent_id] parent_id = avalon_parent["data"]["ftrackId"] # For cases when parent was deleted at the same time if parent_id in self.ftrack_recreated_mapping: parent_id = ( self.ftrack_recreated_mapping[parent_id] ) ftrack_ent = self.ftrack_ents_by_id.get(ftrack_id) if not ftrack_ent: ftrack_ent = self.process_session.query( self.entities_query_by_id.format( self.cur_project["id"], ftrack_id ) ).one() self.ftrack_ents_by_id[ftrack_id] = ftrack_ent if parent_id == ftrack_ent["parent_id"]: continue ftrack_ent["parent_id"] = parent_id try: self.process_session.commit() # TODO logging # TODO report msg = "Entity was moved back" report_msg = ( "{}||Entity can't be moved when" " it or its children contain published data" ).format(msg) ent_path = self.get_ent_path(ftrack_id) self.report_items["info"][report_msg].append(ent_path) self.log.warning("{} <{}>".format(msg, ent_path)) except Exception: self.process_session.rollback() # TODO logging # TODO report error_msg = ( "Couldn't moved the entity back to its original parent" ) report_msg = ( "{}||Moved back because it is not possible to" " move with an entity or it's parents, " " if it already contained published data." ).format(error_msg) error_traceback = ( str(traceback.format_exc()).replace("\n", "
") ).replace(" ", " ") item_msg = ent_path + "
" + error_traceback self.report_items["warning"][report_msg].append(item_msg) self.log.warning( "{}: \"{}\"".format(error_msg, ent_path), exc_info=True ) def process_updated(self): """ Only custom attributes changes should get here """ if not self.ftrack_updated: return self.log.debug( "Processing updated entities: {}".format(str(self.ftrack_updated)) ) ent_infos = self.ftrack_updated ftrack_mongo_mapping = {} not_found_ids = [] for ftrack_id, ent_info in ent_infos.items(): avalon_ent = self.avalon_ents_by_ftrack_id.get(ftrack_id) if not avalon_ent: not_found_ids.append(ftrack_id) continue ftrack_mongo_mapping[ftrack_id] = avalon_ent["_id"] for ftrack_id in not_found_ids: ent_infos.pop(ftrack_id) if not ent_infos: return cust_attrs, hier_attrs = self.avalon_cust_attrs hier_attrs_by_key = { attr["key"]: attr for attr in hier_attrs } cust_attrs_by_obj_id = collections.defaultdict(dict) for cust_attr in cust_attrs: key = cust_attr["key"] if key.startswith("avalon_"): continue ca_ent_type = cust_attr["entity_type"] if ca_ent_type == "show": cust_attrs_by_obj_id[ca_ent_type][key] = cust_attr elif ca_ent_type == "task": obj_id = cust_attr["object_type_id"] cust_attrs_by_obj_id[obj_id][key] = cust_attr for ftrack_id, ent_info in ent_infos.items(): mongo_id = ftrack_mongo_mapping[ftrack_id] entType = ent_info["entityType"] ent_path = self.get_ent_path(ftrack_id) if entType == "show": ent_cust_attrs = cust_attrs_by_obj_id.get("show") else: obj_type_id = ent_info["objectTypeId"] ent_cust_attrs = cust_attrs_by_obj_id.get(obj_type_id) # Ftrack's entity_type does not have defined custom attributes if ent_cust_attrs is None: ent_cust_attrs = {} ent_changes = ent_info["changes"] if "description" in ent_changes: if "data" not in self.updates[mongo_id]: self.updates[mongo_id]["data"] = {} self.updates[mongo_id]["data"]["description"] = ( ent_changes["description"]["new"] or "" ) for key, values in ent_changes.items(): if key in hier_attrs_by_key: self.hier_cust_attrs_changes[key].append(ftrack_id) continue if key not in ent_cust_attrs: continue value = values["new"] new_value = self.convert_value_by_cust_attr_conf( value, ent_cust_attrs[key] ) if entType == "show" and key == "applications": # Store apps to project't config proj_apps, warnings = ( avalon_sync.get_project_apps(new_value) ) if "config" not in self.updates[mongo_id]: self.updates[mongo_id]["config"] = {} self.updates[mongo_id]["config"]["apps"] = proj_apps for msg, items in warnings.items(): if not msg or not items: continue self.report_items["warning"][msg] = items continue if "data" not in self.updates[mongo_id]: self.updates[mongo_id]["data"] = {} self.updates[mongo_id]["data"][key] = new_value self.log.debug( "Setting data value of \"{}\" to \"{}\" <{}>".format( key, new_value, ent_path ) ) def convert_value_by_cust_attr_conf(self, value, cust_attr_conf): type_id = cust_attr_conf["type_id"] cust_attr_type_name = self.cust_attr_types_by_id[type_id]["name"] ignored = ( "expression", "notificationtype", "dynamic enumerator" ) if cust_attr_type_name in ignored: return None if cust_attr_type_name == "text": return value if cust_attr_type_name == "boolean": if value == "1": return True if value == "0": return False return bool(value) if cust_attr_type_name == "date": return arrow.get(value) cust_attr_config = json.loads(cust_attr_conf["config"]) if cust_attr_type_name == "number": if cust_attr_config["isdecimal"]: return float(value) return int(value) if cust_attr_type_name == "enumerator": if not cust_attr_config["multiSelect"]: return value return value.split(", ") return value def process_hier_cleanup(self): if ( not self.moved_in_avalon and not self.renamed_in_avalon and not self.hier_cust_attrs_changes ): return parent_changes = [] hier_cust_attrs_ids = [] hier_cust_attrs_keys = [] all_keys = False for mongo_id in self.moved_in_avalon: parent_changes.append(mongo_id) hier_cust_attrs_ids.append(mongo_id) all_keys = True for mongo_id in self.renamed_in_avalon: if mongo_id not in parent_changes: parent_changes.append(mongo_id) for key, ftrack_ids in self.hier_cust_attrs_changes.items(): if key.startswith("avalon_"): continue for ftrack_id in ftrack_ids: avalon_ent = self.avalon_ents_by_ftrack_id[ftrack_id] mongo_id = avalon_ent["_id"] if mongo_id in hier_cust_attrs_ids: continue hier_cust_attrs_ids.append(mongo_id) if not all_keys and key not in hier_cust_attrs_keys: hier_cust_attrs_keys.append(key) # Parents preparation *** mongo_to_ftrack_parents = {} missing_ftrack_ents = {} for mongo_id in parent_changes: avalon_ent = self.avalon_ents_by_id[mongo_id] ftrack_id = avalon_ent["data"]["ftrackId"] if ftrack_id not in self.ftrack_ents_by_id: missing_ftrack_ents[ftrack_id] = mongo_id continue ftrack_ent = self.ftrack_ents_by_id[ftrack_id] mongo_to_ftrack_parents[mongo_id] = len(ftrack_ent["link"]) if missing_ftrack_ents: joine_ids = ", ".join( ["\"{}\"".format(id) for id in missing_ftrack_ents.keys()] ) entities = self.process_session.query( self.entities_query_by_id.format( self.cur_project["id"], joine_ids ) ).all() for entity in entities: ftrack_id = entity["id"] self.ftrack_ents_by_id[ftrack_id] = entity mongo_id = missing_ftrack_ents[ftrack_id] mongo_to_ftrack_parents[mongo_id] = len(entity["link"]) stored_parents_by_mongo = {} # sort by hierarchy level mongo_to_ftrack_parents = [k for k, v in sorted( mongo_to_ftrack_parents.items(), key=(lambda item: item[1]) )] self.log.debug( "Updating parents and hieararchy because of name/parenting changes" ) for mongo_id in mongo_to_ftrack_parents: avalon_ent = self.avalon_ents_by_id[mongo_id] vis_par = avalon_ent["data"]["visualParent"] if vis_par in stored_parents_by_mongo: parents = [par for par in stored_parents_by_mongo[vis_par]] if vis_par is not None: parent_ent = self.avalon_ents_by_id[vis_par] parents.append(parent_ent["name"]) stored_parents_by_mongo[mongo_id] = parents continue ftrack_id = avalon_ent["data"]["ftrackId"] ftrack_ent = self.ftrack_ents_by_id[ftrack_id] ent_path_items = [ent["name"] for ent in ftrack_ent["link"]] parents = ent_path_items[1:len(ent_path_items)-1:] stored_parents_by_mongo[mongo_id] = parents for mongo_id, parents in stored_parents_by_mongo.items(): avalon_ent = self.avalon_ents_by_id[mongo_id] cur_par = avalon_ent["data"]["parents"] if cur_par == parents: continue if "data" not in self.updates[mongo_id]: self.updates[mongo_id]["data"] = {} self.updates[mongo_id]["data"]["parents"] = parents # Skip custom attributes if didn't change if not hier_cust_attrs_ids: # TODO logging self.log.debug( "Hierarchical attributes were not changed. Skipping" ) self.update_entities() return _, hier_attrs = self.avalon_cust_attrs # Hierarchical custom attributes preparation *** hier_attr_key_by_id = { attr["id"]: attr["key"] for attr in hier_attrs } hier_attr_id_by_key = { key: attr_id for attr_id, key in hier_attr_key_by_id.items() } if all_keys: hier_cust_attrs_keys = [ key for key in hier_attr_id_by_key.keys() if not key.startswith("avalon_") ] mongo_ftrack_mapping = {} cust_attrs_ftrack_ids = [] # ftrack_parenting = collections.defaultdict(list) entities_dict = collections.defaultdict(dict) children_queue = collections.deque() parent_queue = collections.deque() for mongo_id in hier_cust_attrs_ids: avalon_ent = self.avalon_ents_by_id[mongo_id] parent_queue.append(avalon_ent) ftrack_id = avalon_ent["data"]["ftrackId"] if ftrack_id not in entities_dict: entities_dict[ftrack_id] = { "children": [], "parent_id": None, "hier_attrs": {} } mongo_ftrack_mapping[mongo_id] = ftrack_id cust_attrs_ftrack_ids.append(ftrack_id) children_ents = self.avalon_ents_by_parent_id.get(mongo_id) or [] for children_ent in children_ents: _ftrack_id = children_ent["data"]["ftrackId"] if _ftrack_id in entities_dict: continue entities_dict[_ftrack_id] = { "children": [], "parent_id": None, "hier_attrs": {} } # if _ftrack_id not in ftrack_parenting[ftrack_id]: # ftrack_parenting[ftrack_id].append(_ftrack_id) entities_dict[_ftrack_id]["parent_id"] = ftrack_id if _ftrack_id not in entities_dict[ftrack_id]["children"]: entities_dict[ftrack_id]["children"].append(_ftrack_id) children_queue.append(children_ent) while children_queue: avalon_ent = children_queue.popleft() mongo_id = avalon_ent["_id"] ftrack_id = avalon_ent["data"]["ftrackId"] if ftrack_id in cust_attrs_ftrack_ids: continue mongo_ftrack_mapping[mongo_id] = ftrack_id cust_attrs_ftrack_ids.append(ftrack_id) children_ents = self.avalon_ents_by_parent_id.get(mongo_id) or [] for children_ent in children_ents: _ftrack_id = children_ent["data"]["ftrackId"] if _ftrack_id in entities_dict: continue entities_dict[_ftrack_id] = { "children": [], "parent_id": None, "hier_attrs": {} } entities_dict[_ftrack_id]["parent_id"] = ftrack_id if _ftrack_id not in entities_dict[ftrack_id]["children"]: entities_dict[ftrack_id]["children"].append(_ftrack_id) children_queue.append(children_ent) while parent_queue: avalon_ent = parent_queue.popleft() if avalon_ent["type"].lower() == "project": continue ftrack_id = avalon_ent["data"]["ftrackId"] vis_par = avalon_ent["data"]["visualParent"] if vis_par is None: vis_par = avalon_ent["parent"] parent_ent = self.avalon_ents_by_id[vis_par] parent_ftrack_id = parent_ent["data"].get("ftrackId") if parent_ftrack_id is None: self.handle_missing_ftrack_id(parent_ent) parent_ftrack_id = parent_ent["data"].get("ftrackId") if parent_ftrack_id is None: continue if parent_ftrack_id not in entities_dict: entities_dict[parent_ftrack_id] = { "children": [], "parent_id": None, "hier_attrs": {} } if ftrack_id not in entities_dict[parent_ftrack_id]["children"]: entities_dict[parent_ftrack_id]["children"].append(ftrack_id) entities_dict[ftrack_id]["parent_id"] = parent_ftrack_id if parent_ftrack_id in cust_attrs_ftrack_ids: continue mongo_ftrack_mapping[vis_par] = parent_ftrack_id cust_attrs_ftrack_ids.append(parent_ftrack_id) # if ftrack_id not in ftrack_parenting[parent_ftrack_id]: # ftrack_parenting[parent_ftrack_id].append(ftrack_id) parent_queue.append(parent_ent) # Prepare values to query configuration_ids = set() for key in hier_cust_attrs_keys: configuration_ids.add(hier_attr_id_by_key[key]) values = query_custom_attributes( self.process_session, configuration_ids, cust_attrs_ftrack_ids, True ) ftrack_project_id = self.cur_project["id"] attr_types_by_id = self.cust_attr_types_by_id convert_types_by_id = {} for attr in hier_attrs: key = attr["key"] if key not in hier_cust_attrs_keys: continue type_id = attr["type_id"] attr_id = attr["id"] cust_attr_type_name = attr_types_by_id[type_id]["name"] convert_type = avalon_sync.get_python_type_for_custom_attribute( attr, cust_attr_type_name ) convert_types_by_id[attr_id] = convert_type default_value = attr["default"] if key in FPS_KEYS: try: default_value = convert_to_fps(default_value) except InvalidFpsValue: pass entities_dict[ftrack_project_id]["hier_attrs"][key] = ( attr["default"] ) # PREPARE DATA BEFORE THIS invalid_fps_items = [] avalon_hier = [] for item in values: value = item["value"] if value is None: continue entity_id = item["entity_id"] configuration_id = item["configuration_id"] convert_type = convert_types_by_id[configuration_id] key = hier_attr_key_by_id[configuration_id] if convert_type: value = convert_type(value) if key in FPS_KEYS: try: value = convert_to_fps(value) except InvalidFpsValue: invalid_fps_items.append((entity_id, value)) continue entities_dict[entity_id]["hier_attrs"][key] = value if invalid_fps_items: fps_msg = ( "These entities have invalid fps value in custom attributes" ) items = [] for entity_id, value in invalid_fps_items: ent_path = self.get_ent_path(entity_id) items.append("{} - \"{}\"".format(ent_path, value)) self.report_items["error"][fps_msg] = items # Get dictionary with not None hierarchical values to pull to children project_values = {} for key, value in ( entities_dict[ftrack_project_id]["hier_attrs"].items() ): if value is not None: project_values[key] = value for key in avalon_hier: value = entities_dict[ftrack_project_id]["avalon_attrs"][key] if value is not None: project_values[key] = value hier_down_queue = collections.deque() hier_down_queue.append( (project_values, ftrack_project_id) ) while hier_down_queue: hier_values, parent_id = hier_down_queue.popleft() for child_id in entities_dict[parent_id]["children"]: _hier_values = hier_values.copy() for name in hier_cust_attrs_keys: value = entities_dict[child_id]["hier_attrs"].get(name) if value is not None: _hier_values[name] = value entities_dict[child_id]["hier_attrs"].update(_hier_values) hier_down_queue.append((_hier_values, child_id)) ftrack_mongo_mapping = {} for mongo_id, ftrack_id in mongo_ftrack_mapping.items(): ftrack_mongo_mapping[ftrack_id] = mongo_id for ftrack_id, data in entities_dict.items(): mongo_id = ftrack_mongo_mapping[ftrack_id] avalon_ent = self.avalon_ents_by_id[mongo_id] ent_path = self.get_ent_path(ftrack_id) # TODO logging self.log.debug( "Updating hierarchical attributes <{}>".format(ent_path) ) for key, value in data["hier_attrs"].items(): if ( key in avalon_ent["data"] and avalon_ent["data"][key] == value ): continue self.log.debug("- {}: {}".format(key, value)) if "data" not in self.updates[mongo_id]: self.updates[mongo_id]["data"] = {} self.updates[mongo_id]["data"][key] = value self.update_entities() def process_task_updates(self): """ Pull task information for selected ftrack ids to replace stored existing in Avalon. Solves problem of changing type (even Status in the future) of task without storing ftrack id for task in the DB. (Which doesn't bring much advantage currently and it could be troublesome for all hosts or plugins (for example Nuke) to collect and store. Returns: None """ self.log.debug( "Processing task changes for parents: {}".format( self.modified_tasks_ftrackids ) ) if not self.modified_tasks_ftrackids: return joined_ids = ", ".join([ "\"{}\"".format(ftrack_id) for ftrack_id in self.modified_tasks_ftrackids ]) task_entities = self.process_session.query( self.task_entities_query_by_parent_id.format( self.cur_project["id"], joined_ids ) ).all() ftrack_mongo_mapping_found = {} not_found_ids = [] # Make sure all parents have updated tasks, as they may not have any tasks_per_ftrack_id = { ftrack_id: {} for ftrack_id in self.modified_tasks_ftrackids } # Query all task types at once task_types = self.process_session.query(self.task_types_query).all() task_types_by_id = { task_type["id"]: task_type for task_type in task_types } # prepare all tasks per parentId, eg. Avalon asset record for task_entity in task_entities: task_type = task_types_by_id[task_entity["type_id"]] ftrack_id = task_entity["parent_id"] if ftrack_id not in tasks_per_ftrack_id: tasks_per_ftrack_id[ftrack_id] = {} passed_regex = avalon_sync.check_regex( task_entity["name"], "task", schema_patterns=self.regex_schemas ) if not passed_regex: self.regex_failed.append(task_entity["id"]) continue tasks_per_ftrack_id[ftrack_id][task_entity["name"]] = { "type": task_type["name"] } # find avalon entity by parentId # should be there as create was run first for ftrack_id in tasks_per_ftrack_id.keys(): avalon_entity = self.avalon_ents_by_ftrack_id.get(ftrack_id) if not avalon_entity: not_found_ids.append(ftrack_id) continue ftrack_mongo_mapping_found[ftrack_id] = avalon_entity["_id"] self._update_avalon_tasks( ftrack_mongo_mapping_found, tasks_per_ftrack_id ) def update_entities(self): """ Update Avalon entities by mongo bulk changes. Expects self.updates which are transferred to $set part of update command. Resets self.updates afterwards. """ mongo_changes_bulk = [] for mongo_id, changes in self.updates.items(): avalon_ent = self.avalon_ents_by_id[mongo_id] is_project = avalon_ent["type"] == "project" change_data = avalon_sync.from_dict_to_set(changes, is_project) mongo_changes_bulk.append( UpdateOne({"_id": mongo_id}, change_data) ) if not mongo_changes_bulk: return self.dbcon.bulk_write(mongo_changes_bulk) self.updates = collections.defaultdict(dict) @property def duplicated_report(self): if not self.duplicated: return [] ft_project = self.cur_project duplicated_names = [] for ftrack_id in self.duplicated: ftrack_ent = self.ftrack_ents_by_id.get(ftrack_id) if not ftrack_ent: ftrack_ent = self.process_session.query( self.entities_query_by_id.format( ft_project["id"], ftrack_id ) ).one() self.ftrack_ents_by_id[ftrack_id] = ftrack_ent name = ftrack_ent["name"] if name not in duplicated_names: duplicated_names.append(name) joined_names = ", ".join( ["\"{}\"".format(name) for name in duplicated_names] ) ft_ents = self.process_session.query( self.entities_name_query_by_name.format( ft_project["id"], joined_names ) ).all() ft_ents_by_name = collections.defaultdict(list) for ft_ent in ft_ents: name = ft_ent["name"] ft_ents_by_name[name].append(ft_ent) if not ft_ents_by_name: return [] subtitle = "Duplicated entity names:" items = [] items.append({ "type": "label", "value": "# {}".format(subtitle) }) items.append({ "type": "label", "value": ( "

NOTE: It is not allowed to use the same name" " for multiple entities in the same project

" ) }) for name, ents in ft_ents_by_name.items(): items.append({ "type": "label", "value": "## {}".format(name) }) paths = [] for ent in ents: ftrack_id = ent["id"] ent_path = "/".join([_ent["name"] for _ent in ent["link"]]) avalon_ent = self.avalon_ents_by_id.get(ftrack_id) if avalon_ent: additional = " (synchronized)" if avalon_ent["name"] != name: additional = " (synchronized as {})".format( avalon_ent["name"] ) ent_path += additional paths.append(ent_path) items.append({ "type": "label", "value": '

{}

'.format("
".join(paths)) }) return items @property def regex_report(self): if not self.regex_failed: return [] subtitle = "Entity names contain prohibited symbols:" items = [] items.append({ "type": "label", "value": "# {}".format(subtitle) }) items.append({ "type": "label", "value": ( "

NOTE: You can use Letters( a-Z )," " Numbers( 0-9 ) and Underscore( _ )

" ) }) ft_project = self.cur_project for ftrack_id in self.regex_failed: ftrack_ent = self.ftrack_ents_by_id.get(ftrack_id) if not ftrack_ent: ftrack_ent = self.process_session.query( self.entities_query_by_id.format( ft_project["id"], ftrack_id ) ).one() self.ftrack_ents_by_id[ftrack_id] = ftrack_ent name = ftrack_ent["name"] ent_path_items = [_ent["name"] for _ent in ftrack_ent["link"][:-1]] ent_path_items.append("{}".format(name)) ent_path = "/".join(ent_path_items) items.append({ "type": "label", "value": "

{} - {}

".format(name, ent_path) }) return items def report(self): msg_len = len(self.duplicated) + len(self.regex_failed) for msgs in self.report_items.values(): msg_len += len(msgs) if msg_len == 0: return items = [] project_name = self.cur_project["full_name"] title = "Synchronization report ({}):".format(project_name) keys = ["error", "warning", "info"] for key in keys: subitems = [] if key == "warning": subitems.extend(self.duplicated_report) subitems.extend(self.regex_report) for _msg, _items in self.report_items[key].items(): if not _items: continue msg_items = _msg.split("||") msg = msg_items[0] subitems.append({ "type": "label", "value": "# {}".format(msg) }) if len(msg_items) > 1: for note in msg_items[1:]: subitems.append({ "type": "label", "value": "

NOTE: {}

".format(note) }) if isinstance(_items, str): _items = [_items] subitems.append({ "type": "label", "value": '

{}

'.format("
".join(_items)) }) if items and subitems: items.append(self.report_splitter) items.extend(subitems) self.show_interface( items=items, title=title, event=self._cur_event ) return True def _update_avalon_tasks( self, ftrack_mongo_mapping_found, tasks_per_ftrack_id ): """ Prepare new "tasks" content for existing records in Avalon. Args: ftrack_mongo_mapping_found (dictionary): ftrack parentId to Avalon _id mapping tasks_per_ftrack_id (dictionary): task dictionaries per ftrack parentId Returns: None """ mongo_changes_bulk = [] for ftrack_id, mongo_id in ftrack_mongo_mapping_found.items(): filter = {"_id": mongo_id} change_data = {"$set": {}} change_data["$set"]["data.tasks"] = tasks_per_ftrack_id[ftrack_id] mongo_changes_bulk.append(UpdateOne(filter, change_data)) if mongo_changes_bulk: self.dbcon.bulk_write(mongo_changes_bulk) def _mongo_id_configuration( self, ent_info, cust_attrs, hier_attrs, temp_dict ): # Use hierarchical mongo id attribute if possible. if "_hierarchical" not in temp_dict: hier_mongo_id_configuration_id = None for attr in hier_attrs: if attr["key"] == CUST_ATTR_ID_KEY: hier_mongo_id_configuration_id = attr["id"] break temp_dict["_hierarchical"] = hier_mongo_id_configuration_id hier_mongo_id_configuration_id = temp_dict.get("_hierarchical") if hier_mongo_id_configuration_id is not None: return hier_mongo_id_configuration_id # Legacy part for cases that MongoID attribute is per entity type. entity_type = ent_info["entity_type"] mongo_id_configuration_id = temp_dict.get(entity_type) if mongo_id_configuration_id is not None: return mongo_id_configuration_id for attr in cust_attrs: key = attr["key"] if key != CUST_ATTR_ID_KEY: continue if attr["entity_type"] != ent_info["entityType"]: continue if ( ent_info["entityType"] == "task" and attr["object_type_id"] != ent_info["objectTypeId"] ): continue mongo_id_configuration_id = attr["id"] break temp_dict[entity_type] = mongo_id_configuration_id return mongo_id_configuration_id def register(session): '''Register plugin. Called when used as an plugin.''' SyncToAvalonEvent(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py ================================================ import collections from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent class TaskStatusToParent(BaseEvent): settings_key = "status_task_to_parent" def launch(self, session, event): """Propagates status from task to parent when changed.""" filtered_entities_info = self.filter_entities_info(event) if not filtered_entities_info: return for project_id, entities_info in filtered_entities_info.items(): self.process_by_project(session, event, project_id, entities_info) def filter_entities_info(self, event): # Filter if event contain relevant data entities_info = event["data"].get("entities") if not entities_info: return filtered_entity_info = collections.defaultdict(list) status_ids = set() for entity_info in entities_info: # Care only about tasks if entity_info.get("entityType") != "task": continue # Care only about changes of status changes = entity_info.get("changes") if not changes: continue statusid_changes = changes.get("statusid") if not statusid_changes: continue new_status_id = entity_info["changes"]["statusid"]["new"] if ( statusid_changes.get("old") is None or new_status_id is None ): continue project_id = None for parent_item in reversed(entity_info["parents"]): if parent_item["entityType"] == "show": project_id = parent_item["entityId"] break if project_id: filtered_entity_info[project_id].append(entity_info) status_ids.add(new_status_id) return filtered_entity_info def process_by_project(self, session, event, project_id, entities_info): # Get project name project_name = self.get_project_name_from_event( session, event, project_id ) if get_project(project_name) is None: self.log.debug("Project not found in OpenPype. Skipping") return # Load settings project_settings = self.get_project_settings_from_event( event, project_name ) # Prepare loaded settings and check if can be processed result = self.prepare_settings(project_settings, project_name) if not result: return # Unpack the result parent_object_types, all_match, single_match = result # Prepare valid object type ids for object types from settings object_types = session.query("select id, name from ObjectType").all() object_type_id_by_low_name = { object_type["name"].lower(): object_type["id"] for object_type in object_types } valid_object_type_ids = set() for object_type_name in parent_object_types: if object_type_name in object_type_id_by_low_name: valid_object_type_ids.add( object_type_id_by_low_name[object_type_name] ) else: self.log.warning( "Unknown object type \"{}\" set on project \"{}\".".format( object_type_name, project_name ) ) if not valid_object_type_ids: return # Prepare parent ids parent_ids = set() for entity_info in entities_info: parent_id = entity_info["parentId"] if parent_id: parent_ids.add(parent_id) # Query parent ids by object type ids and parent ids parent_entities = session.query( ( "select id, status_id, object_type_id, link from TypedContext" " where id in ({}) and object_type_id in ({})" ).format( self.join_query_keys(parent_ids), self.join_query_keys(valid_object_type_ids) ) ).all() # Skip if none of parents match the filtering if not parent_entities: return obj_ids = set() for entity in parent_entities: obj_ids.add(entity["object_type_id"]) types_mapping = { _type.lower(): _type for _type in session.types } # Map object type id by lowered and modified object type name object_type_name_by_id = {} for object_type in object_types: mapping_name = object_type["name"].lower().replace(" ", "") obj_id = object_type["id"] object_type_name_by_id[obj_id] = types_mapping[mapping_name] project_entity = session.get("Project", project_id) project_schema = project_entity["project_schema"] available_statuses_by_obj_id = {} for obj_id in obj_ids: obj_name = object_type_name_by_id[obj_id] statuses = project_schema.get_statuses(obj_name) statuses_by_low_name = { status["name"].lower(): status for status in statuses } valid = False for name in all_match.keys(): if name in statuses_by_low_name: valid = True break if not valid: for item in single_match: if item["new_status"] in statuses_by_low_name: valid = True break if valid: available_statuses_by_obj_id[obj_id] = statuses_by_low_name valid_parent_ids = set() status_ids = set() valid_parent_entities = [] for entity in parent_entities: if entity["object_type_id"] not in available_statuses_by_obj_id: continue valid_parent_entities.append(entity) valid_parent_ids.add(entity["id"]) status_ids.add(entity["status_id"]) if not valid_parent_ids: return task_entities = session.query( ( "select id, parent_id, status_id from TypedContext" " where parent_id in ({}) and object_type_id is \"{}\"" ).format( self.join_query_keys(valid_parent_ids), object_type_id_by_low_name["task"] ) ).all() # This should not happen but it is safer if not task_entities: return task_entities_by_parent_id = collections.defaultdict(list) for task_entity in task_entities: status_ids.add(task_entity["status_id"]) parent_id = task_entity["parent_id"] task_entities_by_parent_id[parent_id].append(task_entity) status_entities = session.query(( "select id, name from Status where id in ({})" ).format(self.join_query_keys(status_ids))).all() statuses_by_id = { entity["id"]: entity for entity in status_entities } # New status determination logic new_statuses_by_parent_id = self.new_status_by_all_task_statuses( task_entities_by_parent_id, statuses_by_id, all_match ) task_entities_by_id = { task_entity["id"]: task_entity for task_entity in task_entities } # Check if there are remaining any parents that does not have # determined new status yet remainder_tasks_by_parent_id = collections.defaultdict(list) for entity_info in entities_info: entity_id = entity_info["entityId"] if entity_id not in task_entities_by_id: continue parent_id = entity_info["parentId"] if ( # Skip if already has determined new status parent_id in new_statuses_by_parent_id # Skip if parent is not in parent mapping # - if was not found or parent type is not interesting or parent_id not in task_entities_by_parent_id ): continue remainder_tasks_by_parent_id[parent_id].append( task_entities_by_id[entity_id] ) # Try to find new status for remained parents new_statuses_by_parent_id.update( self.new_status_by_remainders( remainder_tasks_by_parent_id, statuses_by_id, single_match ) ) # If there are not new statuses then just skip if not new_statuses_by_parent_id: return parent_entities_by_id = { parent_entity["id"]: parent_entity for parent_entity in valid_parent_entities } for parent_id, new_status_name in new_statuses_by_parent_id.items(): if not new_status_name: continue parent_entity = parent_entities_by_id[parent_id] ent_path = "/".join( [ent["name"] for ent in parent_entity["link"]] ) obj_id = parent_entity["object_type_id"] statuses_by_low_name = available_statuses_by_obj_id.get(obj_id) if not statuses_by_low_name: continue new_status = statuses_by_low_name.get(new_status_name) if not new_status: self.log.warning(( "\"{}\" Couldn't change status to \"{}\"." " Status is not available for entity type \"{}\"." ).format( ent_path, new_status_name, parent_entity.entity_type )) continue current_status = parent_entity["status"] # Do nothing if status is already set if new_status["id"] == current_status["id"]: self.log.debug( "\"{}\" Status \"{}\" already set.".format( ent_path, current_status["name"] ) ) continue try: parent_entity["status_id"] = new_status["id"] session.commit() self.log.info( "\"{}\" changed status to \"{}\"".format( ent_path, new_status["name"] ) ) except Exception: session.rollback() self.log.warning( "\"{}\" status couldn't be set to \"{}\"".format( ent_path, new_status["name"] ), exc_info=True ) def prepare_settings(self, project_settings, project_name): event_settings = ( project_settings["ftrack"]["events"][self.settings_key] ) if not event_settings["enabled"]: self.log.debug("Project \"{}\" has disabled {}.".format( project_name, self.__class__.__name__ )) return _parent_object_types = event_settings["parent_object_types"] if not _parent_object_types: self.log.debug(( "Project \"{}\" does not have set" " parent object types filtering." ).format(project_name)) return _all_match = ( event_settings["parent_status_match_all_task_statuses"] ) _single_match = ( event_settings["parent_status_by_task_status"] ) if not _all_match and not _single_match: self.log.debug(( "Project \"{}\" does not have set" " parent status mappings." ).format(project_name)) return parent_object_types = [ item.lower() for item in _parent_object_types ] all_match = {} for new_status_name, task_statuses in _all_match.items(): all_match[new_status_name.lower()] = [ status_name.lower() for status_name in task_statuses ] single_match = [] for item in _single_match: single_match.append({ "new_status": item["new_status"].lower(), "task_statuses": [ status_name.lower() for status_name in item["task_statuses"] ] }) return parent_object_types, all_match, single_match def new_status_by_all_task_statuses( self, tasks_by_parent_id, statuses_by_id, all_match ): """All statuses of parent entity must match specific status names. Only if all task statuses match the condition parent's status name is determined. """ output = {} for parent_id, task_entities in tasks_by_parent_id.items(): task_statuses_lowered = set() for task_entity in task_entities: task_status = statuses_by_id[task_entity["status_id"]] low_status_name = task_status["name"].lower() task_statuses_lowered.add(low_status_name) new_status = None for _new_status, task_statuses in all_match.items(): valid_item = True for status_name_low in task_statuses_lowered: if status_name_low not in task_statuses: valid_item = False break if valid_item: new_status = _new_status break if new_status is not None: output[parent_id] = new_status return output def new_status_by_remainders( self, remainder_tasks_by_parent_id, statuses_by_id, single_match ): """By new task status can be determined new status of parent.""" output = {} if not remainder_tasks_by_parent_id: return output for parent_id, task_entities in remainder_tasks_by_parent_id.items(): if not task_entities: continue # For cases there are multiple tasks in changes # - task status which match any new status item by order in the # list `single_match` is preferred best_order = len(single_match) best_order_status = None for task_entity in task_entities: task_status = statuses_by_id[task_entity["status_id"]] low_status_name = task_status["name"].lower() for order, item in enumerate(single_match): if order >= best_order: break if low_status_name in item["task_statuses"]: best_order = order best_order_status = item["new_status"] break if best_order_status: output[parent_id] = best_order_status return output def register(session): TaskStatusToParent(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py ================================================ import collections from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent class TaskToVersionStatus(BaseEvent): """Changes status of task's latest AssetVersions on its status change.""" settings_key = "status_task_to_version" # Attribute for caching session user id _cached_user_id = None def is_event_invalid(self, session, event): """Skip task status changes for session user changes. It is expected that there may be another event handler that set version status to task in that case skip all events caused by same user as session has to avoid infinite loop of status changes. """ # Cache user id of currently running session if self._cached_user_id is None: session_user_entity = session.query( "User where username is \"{}\"".format(session.api_user) ).first() if not session_user_entity: self.log.warning( "Couldn't query Ftrack user with username \"{}\"".format( session.api_user ) ) return False self._cached_user_id = session_user_entity["id"] # Skip processing if current session user was the user who created # the event user_info = event["source"].get("user") or {} user_id = user_info.get("id") # Mark as invalid if user is unknown if user_id is None: return True return user_id == self._cached_user_id def filter_event_entities(self, event): """Filter if event contain relevant data. Event cares only about changes of `statusid` on `entity_type` "Task". """ entities_info = event["data"].get("entities") if not entities_info: return filtered_entity_info = collections.defaultdict(list) for entity_info in entities_info: # Care only about tasks if entity_info.get("entity_type") != "Task": continue # Care only about changes of status changes = entity_info.get("changes") or {} statusid_changes = changes.get("statusid") or {} if ( statusid_changes.get("new") is None or statusid_changes.get("old") is None ): continue # Get project id from entity info project_id = None for parent_item in reversed(entity_info["parents"]): if parent_item["entityType"] == "show": project_id = parent_item["entityId"] break if project_id: filtered_entity_info[project_id].append(entity_info) return filtered_entity_info def _get_ent_path(self, entity): return "/".join( [ent["name"] for ent in entity["link"]] ) def launch(self, session, event): '''Propagates status from version to task when changed''' if self.is_event_invalid(session, event): return filtered_entity_infos = self.filter_event_entities(event) if not filtered_entity_infos: return for project_id, entities_info in filtered_entity_infos.items(): self.process_by_project(session, event, project_id, entities_info) def process_by_project(self, session, event, project_id, entities_info): if not entities_info: return project_name = self.get_project_name_from_event( session, event, project_id ) if get_project(project_name) is None: self.log.debug("Project not found in OpenPype. Skipping") return # Load settings project_settings = self.get_project_settings_from_event( event, project_name ) event_settings = ( project_settings["ftrack"]["events"][self.settings_key] ) _status_mapping = event_settings["mapping"] if not event_settings["enabled"]: self.log.debug("Project \"{}\" has disabled {}.".format( project_name, self.__class__.__name__ )) return if not _status_mapping: self.log.debug(( "Project \"{}\" does not have set status mapping for {}." ).format(project_name, self.__class__.__name__)) return status_mapping = { key.lower(): value for key, value in _status_mapping.items() } asset_types_filter = event_settings["asset_types_filter"] task_ids = [ entity_info["entityId"] for entity_info in entities_info ] last_asset_versions_by_task_id = ( self.find_last_asset_versions_for_task_ids( session, task_ids, asset_types_filter ) ) # Query Task entities for last asset versions joined_filtered_ids = self.join_query_keys( last_asset_versions_by_task_id.keys() ) if not joined_filtered_ids: return task_entities = session.query( "select status_id, link from Task where id in ({})".format( joined_filtered_ids ) ).all() if not task_entities: return status_ids = set() for task_entity in task_entities: status_ids.add(task_entity["status_id"]) task_status_entities = session.query( "select id, name from Status where id in ({})".format( self.join_query_keys(status_ids) ) ).all() task_status_name_by_id = { status_entity["id"]: status_entity["name"] for status_entity in task_status_entities } # Final process of changing statuses project_entity = session.get("Project", project_id) av_statuses_by_low_name, av_statuses_by_id = ( self.get_asset_version_statuses(project_entity) ) asset_ids = set() for asset_versions in last_asset_versions_by_task_id.values(): for asset_version in asset_versions: asset_ids.add(asset_version["asset_id"]) asset_entities = session.query( "select name from Asset where id in ({})".format( self.join_query_keys(asset_ids) ) ).all() asset_names_by_id = { asset_entity["id"]: asset_entity["name"] for asset_entity in asset_entities } for task_entity in task_entities: task_id = task_entity["id"] status_id = task_entity["status_id"] task_path = self._get_ent_path(task_entity) task_status_name = task_status_name_by_id[status_id] task_status_name_low = task_status_name.lower() new_asset_version_status = None mapped_status_names = status_mapping.get(task_status_name_low) if mapped_status_names: for status_name in mapped_status_names: _status = av_statuses_by_low_name.get(status_name.lower()) if _status: new_asset_version_status = _status break if not new_asset_version_status: new_asset_version_status = av_statuses_by_low_name.get( task_status_name_low ) # Skip if tasks status is not available to AssetVersion if not new_asset_version_status: self.log.debug(( "AssetVersion does not have matching status to \"{}\"" ).format(task_status_name)) continue last_asset_versions = last_asset_versions_by_task_id[task_id] for asset_version in last_asset_versions: version = asset_version["version"] self.log.debug(( "Trying to change status of last AssetVersion {}" " for task \"{}\"" ).format(version, task_path)) asset_id = asset_version["asset_id"] asset_type_name = asset_names_by_id[asset_id] av_ent_path = task_path + " Asset {} AssetVersion {}".format( asset_type_name, version ) # Skip if current AssetVersion's status is same status_id = asset_version["status_id"] current_status_name = av_statuses_by_id[status_id]["name"] if current_status_name.lower() == task_status_name_low: self.log.debug(( "AssetVersion already has set status \"{}\". \"{}\"" ).format(current_status_name, av_ent_path)) continue new_status_id = new_asset_version_status["id"] new_status_name = new_asset_version_status["name"] # Skip if status is already same if asset_version["status_id"] == new_status_id: continue # Change the status try: asset_version["status_id"] = new_status_id session.commit() self.log.info("[ {} ] Status updated to [ {} ]".format( av_ent_path, new_status_name )) except Exception: session.rollback() self.log.warning( "[ {} ]Status couldn't be set to \"{}\"".format( av_ent_path, new_status_name ), exc_info=True ) def get_asset_version_statuses(self, project_entity): """Status entities for AssetVersion from project's schema. Load statuses from project's schema and store them by id and name. Args: project_entity (ftrack_api.Entity): Entity of ftrack's project. Returns: tuple: 2 items are returned first are statuses by name second are statuses by id. """ project_schema = project_entity["project_schema"] # Get all available statuses for Task statuses = project_schema.get_statuses("AssetVersion") # map lowered status name with it's object av_statuses_by_low_name = {} av_statuses_by_id = {} for status in statuses: av_statuses_by_low_name[status["name"].lower()] = status av_statuses_by_id[status["id"]] = status return av_statuses_by_low_name, av_statuses_by_id def find_last_asset_versions_for_task_ids( self, session, task_ids, asset_types_filter ): """Find latest AssetVersion entities for task. Find first latest AssetVersion for task and all AssetVersions with same version for the task. Args: asset_versions (list): AssetVersion entities sorted by "version". task_ids (list): Task ids. asset_types_filter (list): Asset types short names that will be used to filter AssetVersions. Filtering is skipped if entered value is empty list. """ # Allow event only on specific asset type names asset_query_part = "" if asset_types_filter: # Query all AssetTypes asset_types = session.query( "select id, short from AssetType" ).all() # Store AssetTypes by id asset_type_short_by_id = { asset_type["id"]: asset_type["short"] for asset_type in asset_types } # Lower asset types from settings # WARNING: not sure if is good idea to lower names as Ftrack may # contain asset type with name "Scene" and "scene"! asset_types_filter_low = set( asset_types_name.lower() for asset_types_name in asset_types_filter ) asset_type_ids = [] for type_id, short in asset_type_short_by_id.items(): # TODO log if asset type name is not found if short.lower() in asset_types_filter_low: asset_type_ids.append(type_id) # TODO log that none of asset type names were found in ftrack if asset_type_ids: asset_query_part = " and asset.type_id in ({})".format( self.join_query_keys(asset_type_ids) ) # Query tasks' AssetVersions asset_versions = session.query(( "select status_id, version, task_id, asset_id" " from AssetVersion where task_id in ({}){}" " order by version descending" ).format(self.join_query_keys(task_ids), asset_query_part)).all() last_asset_versions_by_task_id = collections.defaultdict(list) last_version_by_task_id = {} not_finished_task_ids = set(task_ids) for asset_version in asset_versions: task_id = asset_version["task_id"] # Check if task id is still in `not_finished_task_ids` if task_id not in not_finished_task_ids: continue version = asset_version["version"] # Find last version in `last_version_by_task_id` last_version = last_version_by_task_id.get(task_id) if last_version is None: # If task id does not have version set yet then it's first # AssetVersion for this task last_version_by_task_id[task_id] = version elif last_version > version: # Skip processing if version is lower than last version # and pop task id from `not_finished_task_ids` not_finished_task_ids.remove(task_id) continue # Add AssetVersion entity to output dictionary last_asset_versions_by_task_id[task_id].append(asset_version) return last_asset_versions_by_task_id def register(session): TaskToVersionStatus(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py ================================================ import collections from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent class ThumbnailEvents(BaseEvent): settings_key = "thumbnail_updates" def launch(self, session, event): """Updates thumbnails of entities from new AssetVersion.""" filtered_entities = self.filter_entities(event) if not filtered_entities: return for project_id, entities_info in filtered_entities.items(): self.process_project_entities( session, event, project_id, entities_info ) def process_project_entities( self, session, event, project_id, entities_info ): project_name = self.get_project_name_from_event( session, event, project_id ) if get_project(project_name) is None: self.log.debug("Project not found in OpenPype. Skipping") return # Load settings project_settings = self.get_project_settings_from_event( event, project_name ) event_settings = ( project_settings ["ftrack"] ["events"] [self.settings_key] ) if not event_settings["enabled"]: self.log.debug("Project \"{}\" does not have activated {}.".format( project_name, self.__class__.__name__ )) return self.log.debug("Processing {} on project \"{}\".".format( self.__class__.__name__, project_name )) parent_levels = event_settings["levels"] if parent_levels < 1: self.log.debug( "Project \"{}\" has parent levels set to {}. Skipping".format( project_name, parent_levels ) ) return asset_version_ids = set() for entity in entities_info: asset_version_ids.add(entity["entityId"]) # Do not use attribute `asset_version_entities` will be filtered # to when `asset_versions_by_id` is filled asset_version_entities = session.query(( "select task_id, thumbnail_id from AssetVersion where id in ({})" ).format(self.join_query_keys(asset_version_ids))).all() asset_versions_by_id = {} for asset_version_entity in asset_version_entities: if not asset_version_entity["thumbnail_id"]: continue entity_id = asset_version_entity["id"] asset_versions_by_id[entity_id] = asset_version_entity if not asset_versions_by_id: self.log.debug("None of asset versions has set thumbnail id.") return entity_ids_by_asset_version_id = collections.defaultdict(list) hierarchy_ids = set() for entity_info in entities_info: entity_id = entity_info["entityId"] if entity_id not in asset_versions_by_id: continue parent_ids = [] counter = None for parent_info in entity_info["parents"]: if counter is not None: if counter >= parent_levels: break parent_ids.append(parent_info["entityId"]) counter += 1 elif parent_info["entityType"] == "asset": counter = 0 for parent_id in parent_ids: hierarchy_ids.add(parent_id) entity_ids_by_asset_version_id[entity_id].append(parent_id) for asset_version_entity in asset_versions_by_id.values(): task_id = asset_version_entity["task_id"] if task_id: hierarchy_ids.add(task_id) asset_version_id = asset_version_entity["id"] entity_ids_by_asset_version_id[asset_version_id].append( task_id ) entities = session.query(( "select thumbnail_id, link from TypedContext where id in ({})" ).format(self.join_query_keys(hierarchy_ids))).all() entities_by_id = { entity["id"]: entity for entity in entities } for version_id, version_entity in asset_versions_by_id.items(): for entity_id in entity_ids_by_asset_version_id[version_id]: entity = entities_by_id.get(entity_id) if not entity: continue entity["thumbnail_id"] = version_entity["thumbnail_id"] self.log.info("Updating thumbnail for entity [ {} ]".format( self.get_entity_path(entity) )) try: session.commit() except Exception: session.rollback() def filter_entities(self, event): filtered_entities_info = {} for entity_info in event["data"].get("entities", []): action = entity_info.get("action") if not action: continue if ( action == "remove" or entity_info["entityType"].lower() != "assetversion" or "thumbid" not in (entity_info.get("keys") or []) ): continue # Get project id from entity info project_id = entity_info["parents"][-1]["entityId"] if project_id not in filtered_entities_info: filtered_entities_info[project_id] = [] filtered_entities_info[project_id].append(entity_info) return filtered_entities_info def register(session): ThumbnailEvents(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/event_user_assigment.py ================================================ import re import subprocess from openpype.client import get_asset_by_id, get_asset_by_name from openpype.settings import get_project_settings from openpype.pipeline import Anatomy from openpype_modules.ftrack.lib import BaseEvent from openpype_modules.ftrack.lib.avalon_sync import CUST_ATTR_ID_KEY class UserAssigmentEvent(BaseEvent): """ This script will intercept user assignment / de-assignment event and run shell script, providing as much context as possible. It expects configuration file ``presets/ftrack/user_assigment_event.json``. In it, you define paths to scripts to be run for user assignment event and for user-deassigment:: { "add": [ "/path/to/script1", "/path/to/script2" ], "remove": [ "/path/to/script3", "/path/to/script4" ] } Those scripts are executed in shell. Three arguments will be passed to to them: 1) user name of user (de)assigned 2) path to workfiles of task user was (de)assigned to 3) path to publish files of task user was (de)assigned to """ def error(self, *err): for e in err: self.log.error(e) def _run_script(self, script, args): """ Run shell script with arguments as subprocess :param script: script path :type script: str :param args: list of arguments passed to script :type args: list :returns: return code :rtype: int """ p = subprocess.call([script, args], shell=True) return p def _get_task_and_user(self, session, action, changes): """ Get Task and User entities from Ftrack session :param session: ftrack session :type session: ftrack_api.session :param action: event action :type action: str :param changes: what was changed by event :type changes: dict :returns: User and Task entities :rtype: tuple """ if not changes: return None, None if action == 'add': task_id = changes.get('context_id', {}).get('new') user_id = changes.get('resource_id', {}).get('new') elif action == 'remove': task_id = changes.get('context_id', {}).get('old') user_id = changes.get('resource_id', {}).get('old') if not task_id: return None, None if not user_id: return None, None task = session.query('Task where id is "{}"'.format(task_id)).first() user = session.query('User where id is "{}"'.format(user_id)).first() return task, user def _get_asset(self, task): """ Get asset from task entity :param task: Task entity :type task: dict :returns: Asset entity :rtype: dict """ parent = task['parent'] project_name = task["project"]["full_name"] avalon_entity = None parent_id = parent['custom_attributes'].get(CUST_ATTR_ID_KEY) if parent_id: avalon_entity = get_asset_by_id(project_name, parent_id) if not avalon_entity: avalon_entity = get_asset_by_name(project_name, parent["name"]) if not avalon_entity: msg = 'Entity "{}" not found in avalon database'.format( parent['name'] ) self.error(msg) return { 'success': False, 'message': msg } return avalon_entity def _get_hierarchy(self, asset): """ Get hierarchy from Asset entity :param asset: Asset entity :type asset: dict :returns: hierarchy string :rtype: str """ return asset['data']['hierarchy'] def _get_template_data(self, task): """ Get data to fill template from task .. seealso:: :mod:`openpype.pipeline.Anatomy` :param task: Task entity :type task: dict :returns: data for anatomy template :rtype: dict """ project_name = task['project']['full_name'] project_code = task['project']['name'] # fill in template data asset = self._get_asset(task) t_data = { 'project': { 'name': project_name, 'code': project_code }, 'asset': asset['name'], 'task': task['name'], 'hierarchy': self._get_hierarchy(asset) } return t_data def launch(self, session, event): if not event.get("data"): return entities_info = event["data"].get("entities") if not entities_info: return # load shell scripts presets tmp_by_project_name = {} for entity_info in entities_info: if entity_info.get('entity_type') != 'Appointment': continue task_entity, user_entity = self._get_task_and_user( session, entity_info.get('action'), entity_info.get('changes') ) if not task_entity or not user_entity: self.log.error("Task or User was not found.") continue # format directories to pass to shell script project_name = task_entity["project"]["full_name"] project_data = tmp_by_project_name.get(project_name) or {} if "scripts_by_action" not in project_data: project_settings = get_project_settings(project_name) _settings = ( project_settings["ftrack"]["events"]["user_assignment"] ) project_data["scripts_by_action"] = _settings.get("scripts") tmp_by_project_name[project_name] = project_data scripts_by_action = project_data["scripts_by_action"] if not scripts_by_action: continue if "anatomy" not in project_data: project_data["anatomy"] = Anatomy(project_name) tmp_by_project_name[project_name] = project_data anatomy = project_data["anatomy"] data = self._get_template_data(task_entity) anatomy_filled = anatomy.format(data) # formatting work dir is easiest part as we can use whole path work_dir = anatomy_filled["work"]["folder"] # we also need publish but not whole anatomy_filled.strict = False publish = anatomy_filled["publish"]["folder"] # now find path to {asset} m = re.search( "(^.+?{})".format(data["asset"]), publish ) if not m: msg = 'Cannot get part of publish path {}'.format(publish) self.log.error(msg) return { 'success': False, 'message': msg } publish_dir = m.group(1) username = user_entity["username"] event_entity_action = entity_info["action"] for script in scripts_by_action.get(event_entity_action): self.log.info(( "[{}] : running script for user {}" ).format(event_entity_action, username)) self._run_script(script, [username, work_dir, publish_dir]) return True def register(session): """ Register plugin. Called when used as an plugin. """ UserAssigmentEvent(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py ================================================ from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent class VersionToTaskStatus(BaseEvent): """Propagates status from version to task when changed.""" def launch(self, session, event): # Filter event entities # - output is dictionary where key is project id and event info in # value filtered_entities_info = self.filter_entity_info(event) if not filtered_entities_info: return for project_id, entities_info in filtered_entities_info.items(): self.process_by_project(session, event, project_id, entities_info) def filter_entity_info(self, event): filtered_entity_info = {} for entity_info in event["data"].get("entities", []): # Filter AssetVersions if entity_info["entityType"] != "assetversion": continue # Skip if statusid not in keys (in changes) keys = entity_info.get("keys") if not keys or "statusid" not in keys: continue # Get new version task name version_status_id = ( entity_info .get("changes", {}) .get("statusid", {}) .get("new", {}) ) # Just check that `new` is set to any value if not version_status_id: continue # Get project id from entity info project_id = entity_info["parents"][-1]["entityId"] if project_id not in filtered_entity_info: filtered_entity_info[project_id] = [] filtered_entity_info[project_id].append(entity_info) return filtered_entity_info def process_by_project(self, session, event, project_id, entities_info): # Check for project data if event is enabled for event handler project_name = self.get_project_name_from_event( session, event, project_id ) if get_project(project_name) is None: self.log.debug("Project not found in OpenPype. Skipping") return # Load settings project_settings = self.get_project_settings_from_event( event, project_name ) # Load status mapping from presets event_settings = ( project_settings["ftrack"]["events"]["status_version_to_task"] ) # Skip if event is not enabled or status mapping is not set if not event_settings["enabled"]: self.log.debug("Project \"{}\" has disabled {}".format( project_name, self.__class__.__name__ )) return _status_mapping = event_settings["mapping"] or {} status_mapping = { key.lower(): value for key, value in _status_mapping.items() } asset_types_to_skip = [ short_name.lower() for short_name in event_settings["asset_types_to_skip"] ] # Collect entity ids asset_version_ids = set() for entity_info in entities_info: asset_version_ids.add(entity_info["entityId"]) # Query tasks for AssetVersions _asset_version_entities = session.query( "AssetVersion where task_id != none and id in ({})".format( self.join_query_keys(asset_version_ids) ) ).all() if not _asset_version_entities: return # Filter asset versions by asset type and store their task_ids task_ids = set() asset_version_entities = [] for asset_version in _asset_version_entities: if asset_types_to_skip: short_name = asset_version["asset"]["type"]["short"].lower() if short_name in asset_types_to_skip: continue asset_version_entities.append(asset_version) task_ids.add(asset_version["task_id"]) # Skip if `task_ids` are empty if not task_ids: return task_entities = session.query( "select link from Task where id in ({})".format( self.join_query_keys(task_ids) ) ).all() task_entities_by_id = { task_entiy["id"]: task_entiy for task_entiy in task_entities } # Prepare asset version by their id asset_versions_by_id = { asset_version["id"]: asset_version for asset_version in asset_version_entities } # Query status entities status_ids = set() for entity_info in entities_info: # Skip statuses of asset versions without task if entity_info["entityId"] not in asset_versions_by_id: continue status_ids.add(entity_info["changes"]["statusid"]["new"]) version_status_entities = session.query( "select id, name from Status where id in ({})".format( self.join_query_keys(status_ids) ) ).all() # Qeury statuses statusese_by_obj_id = self.statuses_for_tasks( session, task_entities, project_id ) # Prepare status names by their ids status_name_by_id = { status_entity["id"]: status_entity["name"] for status_entity in version_status_entities } for entity_info in entities_info: entity_id = entity_info["entityId"] status_id = entity_info["changes"]["statusid"]["new"] status_name = status_name_by_id.get(status_id) if not status_name: continue status_name_low = status_name.lower() # Lower version status name and check if has mapping new_status_names = [] mapped = status_mapping.get(status_name_low) if mapped: new_status_names.extend(list(mapped)) new_status_names.append(status_name_low) self.log.debug( "Processing AssetVersion status change: [ {} ]".format( status_name ) ) asset_version = asset_versions_by_id[entity_id] task_entity = task_entities_by_id[asset_version["task_id"]] type_id = task_entity["type_id"] # Lower all names from presets new_status_names = [name.lower() for name in new_status_names] task_statuses_by_low_name = statusese_by_obj_id[type_id] new_status = None for status_name in new_status_names: if status_name not in task_statuses_by_low_name: self.log.debug(( "Task does not have status name \"{}\" available." ).format(status_name)) continue # store object of found status new_status = task_statuses_by_low_name[status_name] self.log.debug("Status to set: [ {} ]".format( new_status["name"] )) break # Skip if status names were not found for paticulat entity if not new_status: self.log.warning( "Any of statuses from presets can be set: {}".format( str(new_status_names) ) ) continue # Get full path to task for logging ent_path = "/".join([ent["name"] for ent in task_entity["link"]]) # Setting task status try: task_entity["status"] = new_status session.commit() self.log.debug("[ {} ] Status updated to [ {} ]".format( ent_path, new_status["name"] )) except Exception: session.rollback() self.log.warning( "[ {} ]Status couldn't be set".format(ent_path), exc_info=True ) def statuses_for_tasks(self, session, task_entities, project_id): task_type_ids = set() for task_entity in task_entities: task_type_ids.add(task_entity["type_id"]) project_entity = session.get("Project", project_id) project_schema = project_entity["project_schema"] output = {} for task_type_id in task_type_ids: statuses = project_schema.get_statuses("Task", task_type_id) output[task_type_id] = { status["name"].lower(): status for status in statuses } return output def register(session): '''Register plugin. Called when used as an plugin.''' VersionToTaskStatus(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_applications.py ================================================ import os from openpype.client import get_project from openpype_modules.ftrack.lib import BaseAction from openpype.lib.applications import ( ApplicationManager, ApplicationLaunchFailed, ApplictionExecutableNotFound, CUSTOM_LAUNCH_APP_GROUPS ) class AppplicationsAction(BaseAction): """Applications Action class.""" type = "Application" label = "Application action" identifier = "openpype_app" _launch_identifier_with_id = None icon_url = os.environ.get("OPENPYPE_STATICS_SERVER") def __init__(self, *args, **kwargs): super(AppplicationsAction, self).__init__(*args, **kwargs) self.application_manager = ApplicationManager() @property def discover_identifier(self): if self._discover_identifier is None: self._discover_identifier = "{}.{}".format( self.identifier, self.process_identifier() ) return self._discover_identifier @property def launch_identifier(self): if self._launch_identifier is None: self._launch_identifier = "{}.*".format(self.identifier) return self._launch_identifier @property def launch_identifier_with_id(self): if self._launch_identifier_with_id is None: self._launch_identifier_with_id = "{}.{}".format( self.identifier, self.process_identifier() ) return self._launch_identifier_with_id def construct_requirements_validations(self): # Override validation as this action does not need them return def register(self): """Registers the action, subscribing the discover and launch topics.""" discovery_subscription = ( "topic=ftrack.action.discover and source.user.username={0}" ).format(self.session.api_user) self.session.event_hub.subscribe( discovery_subscription, self._discover, priority=self.priority ) launch_subscription = ( "topic=ftrack.action.launch" " and data.actionIdentifier={0}" " and source.user.username={1}" ).format( self.launch_identifier, self.session.api_user ) self.session.event_hub.subscribe( launch_subscription, self._launch ) def _discover(self, event): entities = self._translate_event(event) items = self.discover(self.session, entities, event) if items: return {"items": items} def discover(self, session, entities, event): """Return true if we can handle the selected entities. Args: session (ftrack_api.Session): Helps to query necessary data. entities (list): Object of selected entities. event (ftrack_api.Event): Ftrack event causing discover callback. """ if ( len(entities) != 1 or entities[0].entity_type.lower() != "task" ): return False entity = entities[0] if entity["parent"].entity_type.lower() == "project": return False avalon_project_apps = event["data"].get("avalon_project_apps", None) avalon_project_doc = event["data"].get("avalon_project_doc", None) if avalon_project_apps is None: if avalon_project_doc is None: ft_project = self.get_project_from_entity(entity) project_name = ft_project["full_name"] avalon_project_doc = get_project(project_name) or False event["data"]["avalon_project_doc"] = avalon_project_doc if not avalon_project_doc: return False project_apps_config = avalon_project_doc["config"].get("apps", []) avalon_project_apps = [ app["name"] for app in project_apps_config ] or False event["data"]["avalon_project_apps"] = avalon_project_apps if not avalon_project_apps: return False settings = self.get_project_settings_from_event( event, avalon_project_doc["name"]) only_available = settings["applications"]["only_available"] items = [] for app_name in avalon_project_apps: app = self.application_manager.applications.get(app_name) if not app or not app.enabled: continue if app.group.name in CUSTOM_LAUNCH_APP_GROUPS: continue # Skip applications without valid executables if only_available and not app.find_executable(): continue app_icon = app.icon if app_icon and self.icon_url: try: app_icon = app_icon.format(self.icon_url) except Exception: self.log.warning(( "Couldn't fill icon path. Icon template: \"{}\"" " --- Icon url: \"{}\"" ).format(app_icon, self.icon_url)) app_icon = None items.append({ "label": app.group.label, "variant": app.label, "description": None, "actionIdentifier": "{}.{}".format( self.launch_identifier_with_id, app_name ), "icon": app_icon }) return items def _launch(self, event): event_identifier = event["data"]["actionIdentifier"] # Check if identifier is same # - show message that acion may not be triggered on this machine if event_identifier.startswith(self.launch_identifier_with_id): return BaseAction._launch(self, event) return { "success": False, "message": ( "There are running more OpenPype processes" " where Application can be launched." ) } def launch(self, session, entities, event): """Callback method for the custom action. return either a bool (True if successful or False if the action failed) or a dictionary with they keys `message` and `success`, the message should be a string and will be displayed as feedback to the user, success should be a bool, True if successful or False if the action failed. *session* is a `ftrack_api.Session` instance *entities* is a list of tuples each containing the entity type and the entity id. If the entity is a hierarchical you will always get the entity type TypedContext, once retrieved through a get operation you will have the "real" entity type ie. example Shot, Sequence or Asset Build. *event* the unmodified original event """ identifier = event["data"]["actionIdentifier"] id_identifier_len = len(self.launch_identifier_with_id) + 1 app_name = identifier[id_identifier_len:] entity = entities[0] task_name = entity["name"] asset_name = entity["parent"]["name"] project_name = entity["project"]["full_name"] self.log.info(( "Ftrack launch app: \"{}\" on Project/Asset/Task: {}/{}/{}" ).format(app_name, project_name, asset_name, task_name)) try: self.application_manager.launch( app_name, project_name=project_name, asset_name=asset_name, task_name=task_name ) except ApplictionExecutableNotFound as exc: self.log.warning(exc.exc_msg) return { "success": False, "message": exc.msg } except ApplicationLaunchFailed as exc: self.log.error(str(exc)) return { "success": False, "message": str(exc) } except Exception: msg = "Unexpected failure of application launch {}".format( self.label ) self.log.error(msg, exc_info=True) return { "success": False, "message": msg } return { "success": True, "message": "Launching {0}".format(self.label) } def register(session): """Register action. Called when used as an event plugin.""" AppplicationsAction(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_batch_task_creation.py ================================================ """ Taken from https://github.com/tokejepsen/ftrack-hooks/tree/master/batch_tasks """ from openpype_modules.ftrack.lib import BaseAction, statics_icon class BatchTasksAction(BaseAction): '''Batch Tasks action `label` a descriptive string identifying your action. `varaint` To group actions together, give them the same label and specify a unique variant per action. `identifier` a unique identifier for your action. `description` a verbose descriptive text for you action ''' label = "Batch Task Create" variant = None identifier = "batch-tasks" description = None icon = statics_icon("ftrack", "action_icons", "BatchTasks.svg") def discover(self, session, entities, event): '''Return true if we can handle the selected entities. *session* is a `ftrack_api.Session` instance *entities* is a list of tuples each containing the entity type and the entity id. If the entity is a hierarchical you will always get the entity type TypedContext, once retrieved through a get operation you will have the "real" entity type ie. example Shot, Sequence or Asset Build. *event* the unmodified original event ''' not_allowed = ["assetversion", "project", "ReviewSession"] if entities[0].entity_type.lower() in not_allowed: return False return True def get_task_form_items(self, session, number_of_tasks): items = [] task_type_options = [ {'label': task_type["name"], 'value': task_type["id"]} for task_type in session.query("Type") ] for index in range(0, number_of_tasks): items.extend( [ { 'value': '##Template for Task{0}##'.format( index ), 'type': 'label' }, { 'label': 'Type', 'type': 'enumerator', 'name': 'task_{0}_typeid'.format(index), 'data': task_type_options }, { 'label': 'Name', 'type': 'text', 'name': 'task_{0}_name'.format(index) } ] ) return items def ensure_task(self, session, name, task_type, parent): # Query for existing task. query = ( 'Task where type.id is "{0}" and name is "{1}" ' 'and parent.id is "{2}"' ) task = session.query( query.format( task_type["id"], name, parent["id"] ) ).first() # Create task. if not task: session.create( "Task", { "name": name, "type": task_type, "parent": parent } ) def launch(self, session, entities, event): '''Callback method for the custom action. return either a bool ( True if successful or False if the action failed ) or a dictionary with they keys `message` and `success`, the message should be a string and will be displayed as feedback to the user, success should be a bool, True if successful or False if the action failed. *session* is a `ftrack_api.Session` instance *entities* is a list of tuples each containing the entity type and the entity id. If the entity is a hierarchical you will always get the entity type TypedContext, once retrieved through a get operation you will have the "real" entity type ie. example Shot, Sequence or Asset Build. *event* the unmodified original event ''' if 'values' in event['data']: values = event['data']['values'] if 'number_of_tasks' in values: return { 'success': True, 'message': '', 'items': self.get_task_form_items( session, int(values['number_of_tasks']) ) } else: # Create tasks on each entity for entity in entities: for count in range(0, int(len(values.keys()) / 2)): task_type = session.query( 'Type where id is "{0}"'.format( values["task_{0}_typeid".format(count)] ) ).one() # Get name, or assume task type in lower case as name. name = values["task_{0}_name".format(count)] if not name: name = task_type["name"].lower() self.ensure_task(session, name, task_type, entity) session.commit() return { 'success': True, 'message': 'Action completed successfully' } return { 'success': True, 'message': "", 'items': [ { 'label': 'Number of tasks', 'type': 'number', 'name': 'number_of_tasks', 'value': 2 } ] } def register(session): '''Register action. Called when used as an event plugin.''' BatchTasksAction(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py ================================================ import collections import ftrack_api from openpype_modules.ftrack.lib import ( BaseAction, statics_icon, get_openpype_attr ) class CleanHierarchicalAttrsAction(BaseAction): identifier = "clean.hierarchical.attr" label = "OpenPype Admin" variant = "- Clean hierarchical custom attributes" description = "Unset empty hierarchical attribute values." icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") all_project_entities_query = ( "select id, name, parent_id, link" " from TypedContext where project_id is \"{}\"" ) cust_attr_query = ( "select value, entity_id from CustomAttributeValue" " where entity_id in ({}) and configuration_id is \"{}\"" ) settings_key = "clean_hierarchical_attr" def discover(self, session, entities, event): """Show only on project entity.""" if ( len(entities) != 1 or entities[0].entity_type.lower() != "project" ): return False return self.valid_roles(session, entities, event) def launch(self, session, entities, event): project = entities[0] user_message = "This may take some time" self.show_message(event, user_message, result=True) self.log.debug("Preparing entities for cleanup.") all_entities = session.query( self.all_project_entities_query.format(project["id"]) ).all() all_entities_ids = [ "\"{}\"".format(entity["id"]) for entity in all_entities if entity.entity_type.lower() != "task" ] self.log.debug( "Collected {} entities to process.".format(len(all_entities_ids)) ) entity_ids_joined = ", ".join(all_entities_ids) attrs, hier_attrs = get_openpype_attr(session) for attr in hier_attrs: configuration_key = attr["key"] self.log.debug( "Looking for cleanup of custom attribute \"{}\"".format( configuration_key ) ) configuration_id = attr["id"] values = session.query( self.cust_attr_query.format( entity_ids_joined, configuration_id ) ).all() data = {} for item in values: value = item["value"] if value is None: data[item["entity_id"]] = value if not data: self.log.debug( "Nothing to clean for \"{}\".".format(configuration_key) ) continue self.log.debug("Cleaning up {} values for \"{}\".".format( len(data), configuration_key )) for entity_id, value in data.items(): entity_key = collections.OrderedDict(( ("configuration_id", configuration_id), ("entity_id", entity_id) )) session.recorded_operations.push( ftrack_api.operation.DeleteEntityOperation( "CustomAttributeValue", entity_key ) ) session.commit() return True def register(session): '''Register plugin. Called when used as an plugin.''' CleanHierarchicalAttrsAction(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_client_review_sort.py ================================================ from openpype_modules.ftrack.lib import BaseAction, statics_icon try: from functools import cmp_to_key except Exception: cmp_to_key = None def existence_comaprison(item_a, item_b): if not item_a and not item_b: return 0 if not item_a: return 1 if not item_b: return -1 return None def task_name_sorter(item_a, item_b): asset_version_a = item_a["asset_version"] asset_version_b = item_b["asset_version"] asset_version_comp = existence_comaprison(asset_version_a, asset_version_b) if asset_version_comp is not None: return asset_version_comp task_a = asset_version_a["task"] task_b = asset_version_b["task"] task_comp = existence_comaprison(task_a, task_b) if task_comp is not None: return task_comp if task_a["name"] > task_b["name"]: return 1 if task_a["name"] < task_b["name"]: return -1 return 0 if cmp_to_key: task_name_sorter = cmp_to_key(task_name_sorter) task_name_kwarg_key = "key" if cmp_to_key else "cmp" task_name_sort_kwargs = {task_name_kwarg_key: task_name_sorter} class ClientReviewSort(BaseAction): '''Custom action.''' #: Action identifier. identifier = 'client.review.sort' #: Action label. label = 'Sort Review' icon = statics_icon("ftrack", "action_icons", "SortReview.svg") def discover(self, session, entities, event): ''' Validation ''' if (len(entities) == 0 or entities[0].entity_type != 'ReviewSession'): return False return True def launch(self, session, entities, event): entity = entities[0] # Get all objects from Review Session and all 'sort order' possibilities obj_list = [] sort_order_list = [] for obj in entity['review_session_objects']: obj_list.append(obj) sort_order_list.append(obj['sort_order']) # Sort criteria obj_list = sorted(obj_list, key=lambda k: k['version']) obj_list.sort(**task_name_sort_kwargs) obj_list = sorted(obj_list, key=lambda k: k['name']) # Set 'sort order' to sorted list, so they are sorted in Ftrack also for i in range(len(obj_list)): obj_list[i]['sort_order'] = sort_order_list[i] session.commit() return { 'success': True, 'message': 'Client Review sorted!' } def register(session): '''Register action. Called when used as an event plugin.''' ClientReviewSort(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_component_open.py ================================================ import os import sys import subprocess from openpype_modules.ftrack.lib import BaseAction, statics_icon class ComponentOpen(BaseAction): '''Custom action.''' # Action identifier identifier = 'component.open' # Action label label = 'Open File' # Action icon icon = statics_icon("ftrack", "action_icons", "ComponentOpen.svg") def discover(self, session, entities, event): ''' Validation ''' if len(entities) != 1 or entities[0].entity_type != 'FileComponent': return False return True def launch(self, session, entities, event): entity = entities[0] # Return error if component is on ftrack server location_name = entity['component_locations'][0]['location']['name'] if location_name == 'ftrack.server': return { 'success': False, 'message': "This component is stored on ftrack server!" } # Get component filepath # TODO with locations it will be different??? fpath = entity['component_locations'][0]['resource_identifier'] fpath = os.path.normpath(os.path.dirname(fpath)) if os.path.isdir(fpath): if 'win' in sys.platform: # windows subprocess.Popen('explorer "%s"' % fpath) elif sys.platform == 'darwin': # macOS subprocess.Popen(['open', fpath]) else: # linux try: subprocess.Popen(['xdg-open', fpath]) except OSError: raise OSError('unsupported xdg-open call??') else: return { 'success': False, 'message': "Didn't find file: " + fpath } return { 'success': True, 'message': 'Component folder Opened' } def register(session): '''Register action. Called when used as an event plugin.''' ComponentOpen(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py ================================================ import collections import json import arrow import ftrack_api from openpype_modules.ftrack.lib import ( BaseAction, statics_icon, CUST_ATTR_ID_KEY, CUST_ATTR_GROUP, CUST_ATTR_TOOLS, CUST_ATTR_APPLICATIONS, CUST_ATTR_INTENT, FPS_KEYS, default_custom_attributes_definition, app_definitions_from_app_manager, tool_definitions_from_app_manager ) from openpype.settings import get_system_settings from openpype.lib import ApplicationManager """ This action creates/updates custom attributes. ## First part take care about special attributes - `avalon_mongo_id` for storing Avalon MongoID - `applications` based on applications usages - `tools` based on tools usages ## Second part is based on json file in ftrack module. File location: `~/OpenPype/pype/modules/ftrack/ftrack_custom_attributes.json` Data in json file is nested dictionary. Keys in first dictionary level represents Ftrack entity type (task, show, assetversion, user, list, asset) and dictionary value define attribute. There is special key for hierchical attributes `is_hierarchical`. Entity types `task` requires to define task object type (Folder, Shot, Sequence, Task, Library, Milestone, Episode, Asset Build, etc.) at second dictionary level, task's attributes are nested more. *** Not Changeable ********************************************************* group (string) - name of group - based on attribute `openpype_modules.ftrack.lib.CUST_ATTR_GROUP` - "pype" by default *** Required *************************************************************** label (string) - label that will show in ftrack key (string) - must contain only chars [a-z0-9_] type (string) - type of custom attribute - possibilities: text, boolean, date, enumerator, dynamic enumerator, number *** Required with conditions *********************************************** config (dictionary) - for each attribute type different requirements and possibilities: - enumerator: multiSelect = True/False(default: False) data = {key_1:value_1,key_2:value_2,..,key_n:value_n} - 'data' is Required value with enumerator - 'key' must contain only chars [a-z0-9_] - number: isdecimal = True/False(default: False) - text: markdown = True/False(default: False) *** Presetable keys ********************************************************** write_security_roles/read_security_roles (array of strings) - default: ["ALL"] - strings should be role names (e.g.: ["API", "Administrator"]) - if set to ["ALL"] - all roles will be available - if first is 'except' - roles will be set to all except roles in array - Warning: Be careful with except - roles can be different by company - example: write_security_roles = ["except", "User"] read_security_roles = ["ALL"] # (User is can only read) default - default: None - sets default value for custom attribute: - text -> string - number -> integer - enumerator -> array with string of key/s - boolean -> bool true/false - date -> string in format: 'YYYY.MM.DD' or 'YYYY.MM.DD HH:mm:ss' - example: "2018.12.24" / "2018.1.1 6:0:0" - dynamic enumerator -> DON'T HAVE DEFAULT VALUE!!! Example: ``` "show": { "avalon_auto_sync": { "label": "Avalon auto-sync", "type": "boolean", "write_security_roles": ["API", "Administrator"], "read_security_roles": ["API", "Administrator"] } }, "is_hierarchical": { "fps": { "label": "FPS", "type": "number", "config": {"isdecimal": true} } }, "task": { "library": { "my_attr_name": { "label": "My Attr", "type": "number" } } } ``` """ class CustAttrException(Exception): pass class CustomAttributes(BaseAction): '''Edit meta data action.''' #: Action identifier. identifier = 'create.update.attributes' #: Action label. label = "OpenPype Admin" variant = '- Create/Update Custom Attributes' #: Action description. description = 'Creates required custom attributes in ftrack' icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") settings_key = "create_update_attributes" required_keys = ("key", "label", "type") presetable_keys = ( "default", "write_security_roles", "read_security_roles" ) hierarchical_key = "is_hierarchical" type_posibilities = ( "text", "boolean", "date", "enumerator", "dynamic enumerator", "number" ) def discover(self, session, entities, event): ''' Validation - action is only for Administrators ''' return self.valid_roles(session, entities, event) def launch(self, session, entities, event): # JOB SETTINGS userId = event['source']['user']['id'] user = session.query('User where id is ' + userId).one() job = session.create('Job', { 'user': user, 'status': 'running', 'data': json.dumps({ 'description': 'Custom Attribute creation.' }) }) session.commit() self.app_manager = ApplicationManager() try: self.prepare_global_data(session) self.avalon_mongo_id_attributes(session, event) self.applications_attribute(event) self.tools_attribute(event) self.intent_attribute(event) self.custom_attributes_from_file(event) job['status'] = 'done' session.commit() except Exception: session.rollback() job["status"] = "failed" session.commit() self.log.error( "Creating custom attributes failed ({})", exc_info=True ) return True def prepare_global_data(self, session): self.types_per_name = { attr_type["name"].lower(): attr_type for attr_type in session.query("CustomAttributeType").all() } self.security_roles = { role["name"].lower(): role for role in session.query("SecurityRole").all() } object_types = session.query("ObjectType").all() self.object_types_per_id = { object_type["id"]: object_type for object_type in object_types } self.object_types_per_name = { object_type["name"].lower(): object_type for object_type in object_types } self.groups = {} self.ftrack_settings = get_system_settings()["modules"]["ftrack"] self.attrs_settings = self.prepare_attribute_settings() def prepare_attribute_settings(self): output = {} attr_settings = self.ftrack_settings["custom_attributes"] for entity_type, attr_data in attr_settings.items(): # Lower entity type entity_type = entity_type.lower() # Just store if entity type is not "task" if entity_type != "task": output[entity_type] = attr_data continue # Prepare empty dictionary for entity type if not set yet if entity_type not in output: output[entity_type] = {} # Store presets per lowered object type for obj_type, _preset in attr_data.items(): output[entity_type][obj_type.lower()] = _preset return output def avalon_mongo_id_attributes(self, session, event): self.create_hierarchical_mongo_attr(session, event) hierarchical_attr, object_type_attrs = ( self.mongo_id_custom_attributes(session) ) if object_type_attrs: self.convert_mongo_id_to_hierarchical( hierarchical_attr, object_type_attrs, session, event ) def mongo_id_custom_attributes(self, session): cust_attrs_query = ( "select id, entity_type, object_type_id, is_hierarchical, default" " from CustomAttributeConfiguration" " where key = \"{}\"" ).format(CUST_ATTR_ID_KEY) mongo_id_avalon_attr = session.query(cust_attrs_query).all() heirarchical_attr = None object_type_attrs = [] for cust_attr in mongo_id_avalon_attr: if cust_attr["is_hierarchical"]: heirarchical_attr = cust_attr else: object_type_attrs.append(cust_attr) return heirarchical_attr, object_type_attrs def create_hierarchical_mongo_attr(self, session, event): # Set security roles for attribute data = { "key": CUST_ATTR_ID_KEY, "label": "Avalon/Mongo ID", "type": "text", "default": "", "group": CUST_ATTR_GROUP, "is_hierarchical": True, "config": {"markdown": False} } self.process_attr_data(data, event) def convert_mongo_id_to_hierarchical( self, hierarchical_attr, object_type_attrs, session, event ): user_msg = "Converting old custom attributes. This may take some time." self.show_message(event, user_msg, True) self.log.info(user_msg) object_types_per_id = { object_type["id"]: object_type for object_type in session.query("ObjectType").all() } cust_attr_query = ( "select value, entity_id from CustomAttributeValue" " where configuration_id is {}" ) for attr_def in object_type_attrs: attr_ent_type = attr_def["entity_type"] if attr_ent_type == "show": entity_type_label = "Project" elif attr_ent_type == "task": entity_type_label = ( object_types_per_id[attr_def["object_type_id"]]["name"] ) else: self.log.warning( "Unsupported entity type: \"{}\". Skipping.".format( attr_ent_type ) ) continue self.log.debug(( "Converting Avalon MongoID attr for Entity type \"{}\"." ).format(entity_type_label)) values = session.query( cust_attr_query.format(attr_def["id"]) ).all() for value in values: table_values = collections.OrderedDict([ ("configuration_id", hierarchical_attr["id"]), ("entity_id", value["entity_id"]) ]) session.recorded_operations.push( ftrack_api.operation.UpdateEntityOperation( "ContextCustomAttributeValue", table_values, "value", ftrack_api.symbol.NOT_SET, value["value"] ) ) try: session.commit() except Exception: session.rollback() self.log.warning( ( "Couldn't transfer Avalon Mongo ID" " attribute for entity type \"{}\"." ).format(entity_type_label), exc_info=True ) try: session.delete(attr_def) session.commit() except Exception: session.rollback() self.log.warning( ( "Couldn't delete Avalon Mongo ID" " attribute for entity type \"{}\"." ).format(entity_type_label), exc_info=True ) def applications_attribute(self, event): apps_data = app_definitions_from_app_manager(self.app_manager) applications_custom_attr_data = { "label": "Applications", "key": CUST_ATTR_APPLICATIONS, "type": "enumerator", "entity_type": "show", "group": CUST_ATTR_GROUP, "config": { "multiselect": True, "data": apps_data } } self.process_attr_data(applications_custom_attr_data, event) def tools_attribute(self, event): tools_data = tool_definitions_from_app_manager(self.app_manager) tools_custom_attr_data = { "label": "Tools", "key": CUST_ATTR_TOOLS, "type": "enumerator", "is_hierarchical": True, "group": CUST_ATTR_GROUP, "config": { "multiselect": True, "data": tools_data } } self.process_attr_data(tools_custom_attr_data, event) def intent_attribute(self, event): intent_key_values = self.ftrack_settings["intent"]["items"] intent_values = [] for key, label in intent_key_values.items(): if not key or not label: self.log.info(( "Skipping intent row: {{\"{}\": \"{}\"}}" " because of empty key or label." ).format(key, label)) continue intent_values.append({key: label}) if not intent_values: return intent_custom_attr_data = { "label": "Intent", "key": CUST_ATTR_INTENT, "type": "enumerator", "entity_type": "assetversion", "group": CUST_ATTR_GROUP, "config": { "multiselect": False, "data": intent_values } } self.process_attr_data(intent_custom_attr_data, event) def custom_attributes_from_file(self, event): # Load json with custom attributes configurations cust_attr_def = default_custom_attributes_definition() attrs_data = [] # Prepare data of hierarchical attributes hierarchical_attrs = cust_attr_def.pop(self.hierarchical_key, {}) for key, cust_attr_data in hierarchical_attrs.items(): cust_attr_data["key"] = key cust_attr_data["is_hierarchical"] = True attrs_data.append(cust_attr_data) # Prepare data of entity specific attributes for entity_type, cust_attr_datas in cust_attr_def.items(): if entity_type.lower() != "task": for key, cust_attr_data in cust_attr_datas.items(): cust_attr_data["key"] = key cust_attr_data["entity_type"] = entity_type attrs_data.append(cust_attr_data) continue # Task should have nested level for object type for object_type, _cust_attr_datas in cust_attr_datas.items(): for key, cust_attr_data in _cust_attr_datas.items(): cust_attr_data["key"] = key cust_attr_data["entity_type"] = entity_type cust_attr_data["object_type"] = object_type attrs_data.append(cust_attr_data) # Process prepared data for cust_attr_data in attrs_data: # Add group cust_attr_data["group"] = CUST_ATTR_GROUP self.process_attr_data(cust_attr_data, event) def presets_for_attr_data(self, attr_data): output = {} attr_key = attr_data["key"] if attr_data.get("is_hierarchical"): entity_key = self.hierarchical_key else: entity_key = attr_data["entity_type"] entity_settings = self.attrs_settings.get(entity_key) or {} if entity_key.lower() == "task": object_type = attr_data["object_type"] entity_settings = entity_settings.get(object_type.lower()) or {} key_settings = entity_settings.get(attr_key) or {} for key, value in key_settings.items(): if key in self.presetable_keys and value: output[key] = value return output def process_attr_data(self, cust_attr_data, event): attr_settings = self.presets_for_attr_data(cust_attr_data) cust_attr_data.update(attr_settings) try: data = {} # Get key, label, type data.update(self.get_required(cust_attr_data)) # Get hierarchical/ entity_type/ object_id data.update(self.get_entity_type(cust_attr_data)) # Get group, default, security roles data.update(self.get_optional(cust_attr_data)) # Process data self.process_attribute(data) except CustAttrException as cae: cust_attr_name = cust_attr_data.get("label", cust_attr_data["key"]) if cust_attr_name: msg = 'Custom attribute error "{}" - {}'.format( cust_attr_name, str(cae) ) else: msg = 'Custom attribute error - {}'.format(str(cae)) self.log.warning(msg, exc_info=True) self.show_message(event, msg) def process_attribute(self, data): existing_attrs = self.session.query(( "select is_hierarchical, key, type, entity_type, object_type_id" " from CustomAttributeConfiguration" )).all() matching = [] is_hierarchical = data.get("is_hierarchical", False) for attr in existing_attrs: if ( is_hierarchical != attr["is_hierarchical"] or attr["key"] != data["key"] ): continue if attr["type"]["name"] != data["type"]["name"]: if data["key"] in FPS_KEYS and attr["type"]["name"] == "text": self.log.info("Kept 'fps' as text custom attribute.") return continue if is_hierarchical: matching.append(attr) elif "object_type_id" in data: if ( attr["entity_type"] == data["entity_type"] and attr["object_type_id"] == data["object_type_id"] ): matching.append(attr) else: if attr["entity_type"] == data["entity_type"]: matching.append(attr) if len(matching) == 0: self.session.create("CustomAttributeConfiguration", data) self.session.commit() self.log.debug( "Custom attribute \"{}\" created".format(data["label"]) ) elif len(matching) == 1: attr_update = matching[0] for key in data: if key not in ( "is_hierarchical", "entity_type", "object_type_id" ): attr_update[key] = data[key] self.session.commit() self.log.debug( "Custom attribute \"{}\" updated".format(data["label"]) ) else: raise CustAttrException(( "Custom attribute is duplicated. Key: \"{}\" Type: \"{}\"" ).format(data["key"], data["type"]["name"])) def get_required(self, attr): output = {} for key in self.required_keys: if key not in attr: raise CustAttrException( "BUG: Key \"{}\" is required".format(key) ) if attr['type'].lower() not in self.type_posibilities: raise CustAttrException( 'Type {} is not valid'.format(attr['type']) ) output['key'] = attr['key'] output['label'] = attr['label'] type_name = attr['type'].lower() output['type'] = self.types_per_name[type_name] config = None if type_name == 'number': config = self.get_number_config(attr) elif type_name == 'text': config = self.get_text_config(attr) elif type_name == 'enumerator': config = self.get_enumerator_config(attr) if config is not None: output['config'] = config return output def get_number_config(self, attr): if 'config' in attr and 'isdecimal' in attr['config']: isdecimal = attr['config']['isdecimal'] else: isdecimal = False config = json.dumps({'isdecimal': isdecimal}) return config def get_text_config(self, attr): if 'config' in attr and 'markdown' in attr['config']: markdown = attr['config']['markdown'] else: markdown = False config = json.dumps({'markdown': markdown}) return config def get_enumerator_config(self, attr): if 'config' not in attr: raise CustAttrException('Missing config with data') if 'data' not in attr['config']: raise CustAttrException('Missing data in config') data = [] for item in attr['config']['data']: item_data = {} for key in item: # TODO key check by regex item_data['menu'] = item[key] item_data['value'] = key data.append(item_data) multiSelect = False for k in attr['config']: if k.lower() == 'multiselect': if isinstance(attr['config'][k], bool): multiSelect = attr['config'][k] else: raise CustAttrException('Multiselect must be boolean') break config = json.dumps({ 'multiSelect': multiSelect, 'data': json.dumps(data) }) return config def get_group(self, attr): if isinstance(attr, dict): group_name = attr['group'].lower() else: group_name = attr if group_name in self.groups: return self.groups[group_name] query = 'CustomAttributeGroup where name is "{}"'.format(group_name) groups = self.session.query(query).all() if len(groups) == 1: group = groups[0] self.groups[group_name] = group return group elif len(groups) < 1: group = self.session.create('CustomAttributeGroup', { 'name': group_name, }) self.session.commit() return group else: raise CustAttrException( 'Found more than one group "{}"'.format(group_name) ) def get_security_roles(self, security_roles): security_roles_lowered = tuple(name.lower() for name in security_roles) if ( len(security_roles_lowered) == 0 or "all" in security_roles_lowered ): return list(self.security_roles.values()) output = [] if security_roles_lowered[0] == "except": excepts = security_roles_lowered[1:] for role_name, role in self.security_roles.items(): if role_name not in excepts: output.append(role) else: for role_name in security_roles_lowered: if role_name in self.security_roles: output.append(self.security_roles[role_name]) else: raise CustAttrException(( "Securit role \"{}\" was not found in Ftrack." ).format(role_name)) return output def get_default(self, attr): type = attr['type'] default = attr['default'] if default is None: return default err_msg = 'Default value is not' if type == 'number': if isinstance(default, (str)) and default.isnumeric(): default = float(default) if not isinstance(default, (float, int)): raise CustAttrException('{} integer'.format(err_msg)) elif type == 'text': if not isinstance(default, str): raise CustAttrException('{} string'.format(err_msg)) elif type == 'boolean': if not isinstance(default, bool): raise CustAttrException('{} boolean'.format(err_msg)) elif type == 'enumerator': if not isinstance(default, list): raise CustAttrException( '{} array with strings'.format(err_msg) ) # TODO check if multiSelect is available # and if default is one of data menu if not isinstance(default[0], str): raise CustAttrException('{} array of strings'.format(err_msg)) elif type == 'date': date_items = default.split(' ') try: if len(date_items) == 1: default = arrow.get(default, 'YY.M.D') elif len(date_items) == 2: default = arrow.get(default, 'YY.M.D H:m:s') else: raise Exception except Exception: raise CustAttrException('Date is not in proper format') elif type == 'dynamic enumerator': raise CustAttrException('Dynamic enumerator can\'t have default') return default def get_optional(self, attr): output = {} if "group" in attr: output["group"] = self.get_group(attr) if "default" in attr: output["default"] = self.get_default(attr) roles_read = [] roles_write = [] if "read_security_roles" in attr: roles_read = attr["read_security_roles"] if "write_security_roles" in attr: roles_write = attr["write_security_roles"] output["read_security_roles"] = self.get_security_roles(roles_read) output["write_security_roles"] = self.get_security_roles(roles_write) return output def get_entity_type(self, attr): if attr.get("is_hierarchical", False): return { "is_hierarchical": True, "entity_type": attr.get("entity_type") or "show" } if 'entity_type' not in attr: raise CustAttrException('Missing entity_type') if attr['entity_type'].lower() != 'task': return {'entity_type': attr['entity_type']} if 'object_type' not in attr: raise CustAttrException('Missing object_type') object_type_name = attr['object_type'] object_type_name_low = object_type_name.lower() object_type = self.object_types_per_name.get(object_type_name_low) if not object_type: raise CustAttrException(( 'Object type with name "{}" don\'t exist' ).format(object_type_name)) return { 'entity_type': attr['entity_type'], 'object_type_id': object_type["id"] } def register(session): '''Register plugin. Called when used as an plugin.''' CustomAttributes(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_create_folders.py ================================================ import os import collections import copy from openpype.pipeline import Anatomy from openpype_modules.ftrack.lib import BaseAction, statics_icon class CreateFolders(BaseAction): identifier = "create.folders" label = "Create Folders" icon = statics_icon("ftrack", "action_icons", "CreateFolders.svg") def discover(self, session, entities, event): for entity_item in event["data"]["selection"]: if entity_item.get("entityType").lower() in ("task", "show"): return True return False def interface(self, session, entities, event): if event["data"].get("values", {}): return with_interface = False for entity in entities: if entity.entity_type.lower() != "task": with_interface = True break if "values" not in event["data"]: event["data"]["values"] = {} event["data"]["values"]["with_interface"] = with_interface if not with_interface: return title = "Create folders" entity_name = entity["name"] msg = ( "

Do you want create folders also" " for all children of your selection?

" ) if entity.entity_type.lower() == "project": entity_name = entity["full_name"] msg = msg.replace(" also", "") msg += "

(Project root won't be created if not checked)

" items = [ { "type": "label", "value": msg.format(entity_name) }, { "type": "label", "value": "With all children entities" }, { "name": "children_included", "type": "boolean", "value": False }, { "type": "hidden", "name": "with_interface", "value": with_interface } ] return { "items": items, "title": title } def launch(self, session, entities, event): '''Callback method for custom action.''' if "values" not in event["data"]: return with_interface = event["data"]["values"]["with_interface"] with_childrens = True if with_interface: with_childrens = event["data"]["values"]["children_included"] filtered_entities = [] for entity in entities: low_context_type = entity["context_type"].lower() if low_context_type in ("task", "show"): if not with_childrens and low_context_type == "show": continue filtered_entities.append(entity) if not filtered_entities: return { "success": True, "message": 'Nothing was created' } project_entity = self.get_project_from_entity(filtered_entities[0]) project_name = project_entity["full_name"] project_code = project_entity["name"] task_entities = [] other_entities = [] self.get_all_entities( session, entities, task_entities, other_entities ) hierarchy = self.get_entities_hierarchy( session, task_entities, other_entities ) task_types = session.query("select id, name from Type").all() task_type_names_by_id = { task_type["id"]: task_type["name"] for task_type in task_types } anatomy = Anatomy(project_name) work_keys = ["work", "folder"] work_template = anatomy.templates for key in work_keys: work_template = work_template[key] publish_keys = ["publish", "folder"] publish_template = anatomy.templates for key in publish_keys: publish_template = publish_template[key] project_data = { "project": { "name": project_name, "code": project_code } } collected_paths = [] for item in hierarchy: parent_entity, task_entities = item parent_data = copy.deepcopy(project_data) parents = parent_entity["link"][1:-1] hierarchy_names = [p["name"] for p in parents] hierarchy = "/".join(hierarchy_names) if hierarchy_names: parent_name = hierarchy_names[-1] else: parent_name = project_name parent_data.update({ "asset": parent_entity["name"], "hierarchy": hierarchy, "parent": parent_name }) if not task_entities: # create path for entity collected_paths.append(self.compute_template( anatomy, parent_data, work_keys )) collected_paths.append(self.compute_template( anatomy, parent_data, publish_keys )) continue for task_entity in task_entities: task_type_id = task_entity["type_id"] task_type_name = task_type_names_by_id[task_type_id] task_data = copy.deepcopy(parent_data) task_data["task"] = { "name": task_entity["name"], "type": task_type_name } # Template wok collected_paths.append(self.compute_template( anatomy, task_data, work_keys )) # Template publish collected_paths.append(self.compute_template( anatomy, task_data, publish_keys )) if len(collected_paths) == 0: return { "success": True, "message": "No project folders to create." } self.log.info("Creating folders:") for path in set(collected_paths): self.log.info(path) if not os.path.exists(path): os.makedirs(path) return { "success": True, "message": "Successfully created project folders." } def get_all_entities( self, session, entities, task_entities, other_entities ): if not entities: return no_task_entities = [] for entity in entities: if entity.entity_type.lower() == "task": task_entities.append(entity) else: no_task_entities.append(entity) if not no_task_entities: return task_entities other_entities.extend(no_task_entities) no_task_entity_ids = [entity["id"] for entity in no_task_entities] next_entities = session.query(( "select id, parent_id" " from TypedContext where parent_id in ({})" ).format(self.join_query_keys(no_task_entity_ids))).all() self.get_all_entities( session, next_entities, task_entities, other_entities ) def get_entities_hierarchy(self, session, task_entities, other_entities): task_entity_ids = [entity["id"] for entity in task_entities] full_task_entities = session.query(( "select id, name, type_id, parent_id" " from TypedContext where id in ({})" ).format(self.join_query_keys(task_entity_ids))) task_entities_by_parent_id = collections.defaultdict(list) for entity in full_task_entities: parent_id = entity["parent_id"] task_entities_by_parent_id[parent_id].append(entity) output = [] if not task_entities_by_parent_id: return output other_ids = set() for entity in other_entities: other_ids.add(entity["id"]) other_ids |= set(task_entities_by_parent_id.keys()) parent_entities = session.query(( "select id, name from TypedContext where id in ({})" ).format(self.join_query_keys(other_ids))).all() for parent_entity in parent_entities: parent_id = parent_entity["id"] output.append(( parent_entity, task_entities_by_parent_id[parent_id] )) return output def compute_template(self, anatomy, data, anatomy_keys): filled_template = anatomy.format_all(data) for key in anatomy_keys: filled_template = filled_template[key] if filled_template.solved: return os.path.normpath(filled_template) self.log.warning( "Template \"{}\" was not fully filled \"{}\"".format( filled_template.template, filled_template ) ) return os.path.normpath(filled_template.split("{")[0]) def register(session): """Register plugin. Called when used as an plugin.""" CreateFolders(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_create_project_structure.py ================================================ import re from openpype.pipeline.project_folders import ( get_project_basic_paths, create_project_folders, ) from openpype_modules.ftrack.lib import BaseAction, statics_icon class CreateProjectFolders(BaseAction): """Action create folder structure and may create hierarchy in Ftrack. Creation of folder structure and hierarchy in Ftrack is based on presets. These presets are located in: `~/pype-config/presets/tools/project_folder_structure.json` Example of content: ```json { "__project_root__": { "prod" : {}, "resources" : { "footage": { "plates": {}, "offline": {} }, "audio": {}, "art_dept": {} }, "editorial" : {}, "assets[ftrack.Library]": { "characters[ftrack]": {}, "locations[ftrack]": {} }, "shots[ftrack.Sequence]": { "scripts": {}, "editorial[ftrack.Folder]": {} } } } ``` Key "__project_root__" indicates root folder (or entity). Each key in dictionary represents folder name. Value may contain another dictionary with subfolders. Identifier `[ftrack]` in name says that this should be also created in Ftrack hierarchy. It is possible to specify entity type of item with "." . If key is `assets[ftrack.Library]` then in ftrack will be created entity with name "assets" and entity type "Library". It is expected Library entity type exist in Ftrack. """ identifier = "create.project.structure" label = "Create Project Structure" description = "Creates folder structure" role_list = ["Pypeclub", "Administrator", "Project Manager"] icon = statics_icon("ftrack", "action_icons", "CreateProjectFolders.svg") pattern_array = re.compile(r"\[.*\]") pattern_ftrack = re.compile(r".*\[[.]*ftrack[.]*") pattern_ent_ftrack = re.compile(r"ftrack\.[^.,\],\s,]*") project_root_key = "__project_root__" def discover(self, session, entities, event): if len(entities) != 1: return False if entities[0].entity_type.lower() != "project": return False return True def launch(self, session, entities, event): # Get project entity project_entity = self.get_project_from_entity(entities[0]) project_name = project_entity["full_name"] try: # Get paths based on presets basic_paths = get_project_basic_paths(project_name) if not basic_paths: return { "success": False, "message": "Project structure is not set." } # Invoking OpenPype API to create the project folders create_project_folders(project_name, basic_paths) self.create_ftrack_entities(basic_paths, project_entity) self.trigger_event( "openpype.project.structure.created", {"project_name": project_name} ) except Exception as exc: self.log.warning("Creating of structure crashed.", exc_info=True) session.rollback() return { "success": False, "message": str(exc) } return True def get_ftrack_paths(self, paths_items): all_ftrack_paths = [] for path_items in paths_items: ftrack_path_items = [] is_ftrack = False for item in reversed(path_items): if item == self.project_root_key: continue if is_ftrack: ftrack_path_items.append(item) elif re.match(self.pattern_ftrack, item): ftrack_path_items.append(item) is_ftrack = True ftrack_path_items = list(reversed(ftrack_path_items)) if ftrack_path_items: all_ftrack_paths.append(ftrack_path_items) return all_ftrack_paths def compute_ftrack_items(self, in_list, keys): if len(keys) == 0: return in_list key = keys[0] exist = None for index, subdict in enumerate(in_list): if key in subdict: exist = index break if exist is not None: in_list[exist][key] = self.compute_ftrack_items( in_list[exist][key], keys[1:] ) else: in_list.append({key: self.compute_ftrack_items([], keys[1:])}) return in_list def translate_ftrack_items(self, paths_items): main = [] for path_items in paths_items: main = self.compute_ftrack_items(main, path_items) return main def create_ftrack_entities(self, basic_paths, project_ent): only_ftrack_items = self.get_ftrack_paths(basic_paths) ftrack_paths = self.translate_ftrack_items(only_ftrack_items) for separation in ftrack_paths: parent = project_ent self.trigger_creation(separation, parent) def trigger_creation(self, separation, parent): for item, subvalues in separation.items(): matches = re.findall(self.pattern_array, item) ent_type = "Folder" if len(matches) == 0: name = item else: match = matches[0] name = item.replace(match, "") ent_type_match = re.findall(self.pattern_ent_ftrack, match) if len(ent_type_match) > 0: ent_type_split = ent_type_match[0].split(".") if len(ent_type_split) == 2: ent_type = ent_type_split[1] new_parent = self.create_ftrack_entity(name, ent_type, parent) if subvalues: for subvalue in subvalues: self.trigger_creation(subvalue, new_parent) def create_ftrack_entity(self, name, ent_type, parent): for children in parent["children"]: if children["name"] == name: return children data = { "name": name, "parent_id": parent["id"] } if parent.entity_type.lower() == "project": data["project_id"] = parent["id"] else: data["project_id"] = parent["project"]["id"] existing_entity = self.session.query(( "TypedContext where name is \"{}\" and " "parent_id is \"{}\" and project_id is \"{}\"" ).format(name, data["parent_id"], data["project_id"])).first() if existing_entity: return existing_entity new_ent = self.session.create(ent_type, data) self.session.commit() return new_ent def register(session): CreateProjectFolders(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_delete_asset.py ================================================ import collections import uuid from datetime import datetime from bson.objectid import ObjectId from openpype.client import get_assets, get_subsets from openpype.pipeline import AvalonMongoDB from openpype_modules.ftrack.lib import BaseAction, statics_icon from openpype_modules.ftrack.lib.avalon_sync import create_chunks class DeleteAssetSubset(BaseAction): '''Edit meta data action.''' # Action identifier. identifier = "delete.asset.subset" # Action label. label = "Delete Asset/Subsets" # Action description. description = "Removes from Avalon with all children and asset from Ftrack" icon = statics_icon("ftrack", "action_icons", "DeleteAsset.svg") settings_key = "delete_asset_subset" # Db connection dbcon = None splitter = {"type": "label", "value": "---"} action_data_by_id = {} asset_prefix = "asset:" subset_prefix = "subset:" def __init__(self, *args, **kwargs): self.dbcon = AvalonMongoDB() super(DeleteAssetSubset, self).__init__(*args, **kwargs) def discover(self, session, entities, event): """ Validation """ task_ids = [] for ent_info in event["data"]["selection"]: if ent_info.get("entityType") == "task": task_ids.append(ent_info["entityId"]) is_valid = False for entity in entities: if ( entity["id"] in task_ids and entity.entity_type.lower() != "task" ): is_valid = True break if is_valid: is_valid = self.valid_roles(session, entities, event) return is_valid def _launch(self, event): try: entities = self._translate_event(event) if "values" not in event["data"]: self.dbcon.install() return self._interface(self.session, entities, event) confirmation = self.confirm_delete(entities, event) if confirmation: return confirmation self.dbcon.install() response = self.launch( self.session, entities, event ) finally: self.dbcon.uninstall() return self._handle_result(response) def interface(self, session, entities, event): self.show_message(event, "Preparing data...", True) items = [] title = "Choose items to delete" # Filter selection and get ftrack ids selection = event["data"].get("selection") or [] ftrack_ids = [] project_in_selection = False for entity in selection: entity_type = (entity.get("entityType") or "").lower() if entity_type != "task": if entity_type == "show": project_in_selection = True continue ftrack_id = entity.get("entityId") if ftrack_id: ftrack_ids.append(ftrack_id) if project_in_selection: msg = "It is not possible to use this action on project entity." self.show_message(event, msg, True) # Filter event even more (skip task entities) # - task entities are not relevant for avalon entity_mapping = {} for entity in entities: ftrack_id = entity["id"] if ftrack_id not in ftrack_ids: continue if entity.entity_type.lower() == "task": ftrack_ids.remove(ftrack_id) entity_mapping[ftrack_id] = entity if not ftrack_ids: # It is bug if this happens! return { "success": False, "message": "Invalid selection for this action (Bug)" } project = self.get_project_from_entity(entities[0], session) project_name = project["full_name"] self.dbcon.Session["AVALON_PROJECT"] = project_name asset_docs = list(get_assets( project_name, fields=["_id", "name", "data.ftrackId", "data.parents"] )) selected_av_entities = [] found_ftrack_ids = set() asset_docs_by_name = collections.defaultdict(list) for asset_doc in asset_docs: ftrack_id = asset_doc["data"].get("ftrackId") if ftrack_id: found_ftrack_ids.add(ftrack_id) if ftrack_id in entity_mapping: selected_av_entities.append(asset_doc) asset_name = asset_doc["name"] asset_docs_by_name[asset_name].append(asset_doc) found_without_ftrack_id = {} for ftrack_id, entity in entity_mapping.items(): if ftrack_id in found_ftrack_ids: continue av_ents_by_name = asset_docs_by_name[entity["name"]] if not av_ents_by_name: continue ent_path_items = [ent["name"] for ent in entity["link"]] end_index = len(ent_path_items) - 1 parents = ent_path_items[1:end_index:] # TODO we should say to user that # few of them are missing in avalon for av_ent in av_ents_by_name: if av_ent["data"]["parents"] != parents: continue # TODO we should say to user that found entity # with same name does not match same ftrack id? if "ftrackId" not in av_ent["data"]: selected_av_entities.append(av_ent) found_without_ftrack_id[str(av_ent["_id"])] = ftrack_id break if not selected_av_entities: return { "success": True, "message": ( "Didn't find entities in avalon." " You can use Ftrack's Delete button for the selection." ) } # Remove cached action older than 2 minutes old_action_ids = [] for action_id, data in self.action_data_by_id.items(): created_at = data.get("created_at") if not created_at: old_action_ids.append(action_id) continue cur_time = datetime.now() existing_in_sec = (created_at - cur_time).total_seconds() if existing_in_sec > 60 * 2: old_action_ids.append(action_id) for action_id in old_action_ids: self.action_data_by_id.pop(action_id, None) # Store data for action id action_id = str(uuid.uuid1()) self.action_data_by_id[action_id] = { "attempt": 1, "created_at": datetime.now(), "project_name": project_name, "subset_ids_by_name": {}, "subset_ids_by_parent": {}, "without_ftrack_id": found_without_ftrack_id } id_item = { "type": "hidden", "name": "action_id", "value": action_id } items.append(id_item) asset_ids = [ent["_id"] for ent in selected_av_entities] subsets_for_selection = get_subsets(project_name, asset_ids=asset_ids) asset_ending = "" if len(selected_av_entities) > 1: asset_ending = "s" asset_title = { "type": "label", "value": "# Delete asset{}:".format(asset_ending) } asset_note = { "type": "label", "value": ( "

NOTE: Action will delete checked entities" " in Ftrack and Avalon with all children entities and" " published content.

" ) } items.append(asset_title) items.append(asset_note) asset_items = collections.defaultdict(list) for asset in selected_av_entities: ent_path_items = [project_name] ent_path_items.extend(asset.get("data", {}).get("parents") or []) ent_path_to_parent = "/".join(ent_path_items) + "/" asset_items[ent_path_to_parent].append(asset) for asset_parent_path, assets in sorted(asset_items.items()): items.append({ "type": "label", "value": "## - {}".format(asset_parent_path) }) for asset in assets: items.append({ "label": asset["name"], "name": "{}{}".format( self.asset_prefix, str(asset["_id"]) ), "type": 'boolean', "value": False }) subset_ids_by_name = collections.defaultdict(list) subset_ids_by_parent = collections.defaultdict(list) for subset in subsets_for_selection: subset_id = subset["_id"] name = subset["name"] parent_id = subset["parent"] subset_ids_by_name[name].append(subset_id) subset_ids_by_parent[parent_id].append(subset_id) if not subset_ids_by_name: return { "items": items, "title": title } subset_ending = "" if len(subset_ids_by_name.keys()) > 1: subset_ending = "s" subset_title = { "type": "label", "value": "# Subset{} to delete:".format(subset_ending) } subset_note = { "type": "label", "value": ( "

WARNING: Subset{} will be removed" " for all selected entities.

" ).format(subset_ending) } items.append(self.splitter) items.append(subset_title) items.append(subset_note) for name in subset_ids_by_name: items.append({ "label": "{}".format(name), "name": "{}{}".format(self.subset_prefix, name), "type": "boolean", "value": False }) self.action_data_by_id[action_id]["subset_ids_by_parent"] = ( subset_ids_by_parent ) self.action_data_by_id[action_id]["subset_ids_by_name"] = ( subset_ids_by_name ) return { "items": items, "title": title } def confirm_delete(self, entities, event): values = event["data"]["values"] action_id = values.get("action_id") spec_data = self.action_data_by_id.get(action_id) if not spec_data: # it is a bug if this happens! return { "success": False, "message": "Something bad has happened. Please try again." } # Process Delete confirmation delete_key = values.get("delete_key") if delete_key: delete_key = delete_key.lower().strip() # Go to launch part if user entered `delete` if delete_key == "delete": return # Skip whole process if user didn't enter any text elif delete_key == "": self.action_data_by_id.pop(action_id, None) return { "success": True, "message": "Deleting cancelled (delete entry was empty)" } # Get data to show again to_delete = spec_data["to_delete"] else: to_delete = collections.defaultdict(list) for key, value in values.items(): if not value: continue if key.startswith(self.asset_prefix): _key = key.replace(self.asset_prefix, "") to_delete["assets"].append(_key) elif key.startswith(self.subset_prefix): _key = key.replace(self.subset_prefix, "") to_delete["subsets"].append(_key) self.action_data_by_id[action_id]["to_delete"] = to_delete asset_to_delete = len(to_delete.get("assets") or []) > 0 subset_to_delete = len(to_delete.get("subsets") or []) > 0 if not asset_to_delete and not subset_to_delete: self.action_data_by_id.pop(action_id, None) return { "success": True, "message": "Nothing was selected to delete" } attempt = spec_data["attempt"] if attempt > 3: self.action_data_by_id.pop(action_id, None) return { "success": False, "message": "You didn't enter \"DELETE\" properly 3 times!" } self.action_data_by_id[action_id]["attempt"] += 1 title = "Confirmation of deleting" if asset_to_delete: asset_len = len(to_delete["assets"]) asset_ending = "" if asset_len > 1: asset_ending = "s" title += " {} Asset{}".format(asset_len, asset_ending) if subset_to_delete: title += " and" if subset_to_delete: sub_len = len(to_delete["subsets"]) type_ending = "" sub_ending = "" if sub_len == 1: subset_ids_by_name = spec_data["subset_ids_by_name"] if len(subset_ids_by_name[to_delete["subsets"][0]]) > 1: sub_ending = "s" elif sub_len > 1: type_ending = "s" sub_ending = "s" title += " {} type{} of subset{}".format( sub_len, type_ending, sub_ending ) items = [] id_item = {"type": "hidden", "name": "action_id", "value": action_id} delete_label = { 'type': 'label', 'value': '# Please enter "DELETE" to confirm #' } delete_item = { "name": "delete_key", "type": "text", "value": "", "empty_text": "Type Delete here..." } items.append(id_item) items.append(delete_label) items.append(delete_item) return { "items": items, "title": title } def launch(self, session, entities, event): self.show_message(event, "Processing...", True) values = event["data"]["values"] action_id = values.get("action_id") spec_data = self.action_data_by_id.get(action_id) if not spec_data: # it is a bug if this happens! return { "success": False, "message": "Something bad has happened. Please try again." } report_messages = collections.defaultdict(list) project_name = spec_data["project_name"] to_delete = spec_data["to_delete"] self.dbcon.Session["AVALON_PROJECT"] = project_name assets_to_delete = to_delete.get("assets") or [] subsets_to_delete = to_delete.get("subsets") or [] # Convert asset ids to ObjectId obj assets_to_delete = [ ObjectId(asset_id) for asset_id in assets_to_delete if asset_id ] subset_ids_by_parent = spec_data["subset_ids_by_parent"] subset_ids_by_name = spec_data["subset_ids_by_name"] subset_ids_to_archive = [] asset_ids_to_archive = [] ftrack_ids_to_delete = [] if len(assets_to_delete) > 0: map_av_ftrack_id = spec_data["without_ftrack_id"] # Prepare data when deleting whole avalon asset avalon_assets = get_assets( project_name, fields=["_id", "data.visualParent", "data.ftrackId"] ) avalon_assets_by_parent = collections.defaultdict(list) for asset in avalon_assets: asset_id = asset["_id"] parent_id = asset["data"]["visualParent"] avalon_assets_by_parent[parent_id].append(asset) if asset_id in assets_to_delete: ftrack_id = map_av_ftrack_id.get(str(asset_id)) if not ftrack_id: ftrack_id = asset["data"].get("ftrackId") if ftrack_id: ftrack_ids_to_delete.append(ftrack_id) children_queue = collections.deque() for mongo_id in assets_to_delete: children_queue.append(mongo_id) while children_queue: mongo_id = children_queue.popleft() if mongo_id in asset_ids_to_archive: continue asset_ids_to_archive.append(mongo_id) for subset_id in subset_ids_by_parent.get(mongo_id, []): if subset_id not in subset_ids_to_archive: subset_ids_to_archive.append(subset_id) children = avalon_assets_by_parent.get(mongo_id) if not children: continue for child in children: child_id = child["_id"] if child_id not in asset_ids_to_archive: children_queue.append(child_id) # Prepare names of assets in ftrack and ids of subsets in mongo asset_names_to_delete = [] if len(subsets_to_delete) > 0: for name in subsets_to_delete: asset_names_to_delete.append(name) for subset_id in subset_ids_by_name[name]: if subset_id in subset_ids_to_archive: continue subset_ids_to_archive.append(subset_id) # Get ftrack ids of entities where will be delete only asset not_deleted_entities_id = [] ftrack_id_name_map = {} if asset_names_to_delete: for entity in entities: ftrack_id = entity["id"] ftrack_id_name_map[ftrack_id] = entity["name"] if ftrack_id not in ftrack_ids_to_delete: not_deleted_entities_id.append(ftrack_id) mongo_proc_txt = "MongoProcessing: " ftrack_proc_txt = "Ftrack processing: " if asset_ids_to_archive: self.log.debug("{}Archivation of assets <{}>".format( mongo_proc_txt, ", ".join([str(id) for id in asset_ids_to_archive]) )) self.dbcon.update_many( { "_id": {"$in": asset_ids_to_archive}, "type": "asset" }, {"$set": {"type": "archived_asset"}} ) if subset_ids_to_archive: self.log.debug("{}Archivation of subsets <{}>".format( mongo_proc_txt, ", ".join([str(id) for id in subset_ids_to_archive]) )) self.dbcon.update_many( { "_id": {"$in": subset_ids_to_archive}, "type": "subset" }, {"$set": {"type": "archived_subset"}} ) if ftrack_ids_to_delete: self.log.debug("{}Deleting Ftrack Entities <{}>".format( ftrack_proc_txt, ", ".join(ftrack_ids_to_delete) )) entities_by_link_len = self._prepare_entities_before_delete( ftrack_ids_to_delete, session ) for link_len in sorted(entities_by_link_len.keys(), reverse=True): for entity in entities_by_link_len[link_len]: session.delete(entity) try: session.commit() except Exception: ent_path = "/".join( [ent["name"] for ent in entity["link"]] ) msg = "Failed to delete entity" report_messages[msg].append(ent_path) session.rollback() self.log.warning( "{} <{}>".format(msg, ent_path), exc_info=True ) if not_deleted_entities_id and asset_names_to_delete: joined_not_deleted = ",".join([ "\"{}\"".format(ftrack_id) for ftrack_id in not_deleted_entities_id ]) joined_asset_names = ",".join([ "\"{}\"".format(name) for name in asset_names_to_delete ]) # Find assets of selected entities with names of checked subsets assets = session.query(( "select id from Asset where" " context_id in ({}) and name in ({})" ).format(joined_not_deleted, joined_asset_names)).all() self.log.debug("{}Deleting Ftrack Assets <{}>".format( ftrack_proc_txt, ", ".join([asset["id"] for asset in assets]) )) for asset in assets: session.delete(asset) try: session.commit() except Exception: session.rollback() msg = "Failed to delete asset" report_messages[msg].append(asset["id"]) self.log.warning( "Asset: {} <{}>".format(asset["name"], asset["id"]), exc_info=True ) return self.report_handle(report_messages, project_name, event) def _prepare_entities_before_delete(self, ftrack_ids_to_delete, session): """Filter children entities to avoid CircularDependencyError.""" joined_ids_to_delete = ", ".join( ["\"{}\"".format(id) for id in ftrack_ids_to_delete] ) to_delete_entities = session.query( "select id, link from TypedContext where id in ({})".format( joined_ids_to_delete ) ).all() # Find all children entities and add them to list # - Delete tasks first then their parents and continue parent_ids_to_delete = [ entity["id"] for entity in to_delete_entities ] while parent_ids_to_delete: joined_parent_ids_to_delete = ",".join([ "\"{}\"".format(ftrack_id) for ftrack_id in parent_ids_to_delete ]) _to_delete = session.query(( "select id, link from TypedContext where parent_id in ({})" ).format(joined_parent_ids_to_delete)).all() parent_ids_to_delete = [] for entity in _to_delete: parent_ids_to_delete.append(entity["id"]) to_delete_entities.append(entity) # Unset 'task_id' from AssetVersion entities # - when task is deleted the asset version is not marked for deletion task_ids = set( entity["id"] for entity in to_delete_entities if entity.entity_type.lower() == "task" ) for chunk in create_chunks(task_ids): asset_versions = session.query(( "select id, task_id from AssetVersion where task_id in ({})" ).format(self.join_query_keys(chunk))).all() for asset_version in asset_versions: asset_version["task_id"] = None session.commit() entities_by_link_len = collections.defaultdict(list) for entity in to_delete_entities: entities_by_link_len[len(entity["link"])].append(entity) return entities_by_link_len def report_handle(self, report_messages, project_name, event): if not report_messages: return { "success": True, "message": "Deletion was successful!" } title = "Delete report ({}):".format(project_name) items = [] items.append({ "type": "label", "value": "# Deleting was not completely successful" }) items.append({ "type": "label", "value": "

Check logs for more information

" }) for msg, _items in report_messages.items(): if not _items or not msg: continue items.append({ "type": "label", "value": "# {}".format(msg) }) if isinstance(_items, str): _items = [_items] items.append({ "type": "label", "value": '

{}

'.format("
".join(_items)) }) items.append(self.splitter) self.show_interface(items, title, event) return { "success": False, "message": "Deleting finished. Read report messages." } def register(session): '''Register plugin. Called when used as an plugin.''' DeleteAssetSubset(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py ================================================ import os import collections import uuid import clique from pymongo import UpdateOne from openpype.client import ( get_assets, get_subsets, get_versions, get_representations ) from openpype.lib import ( StringTemplate, TemplateUnsolved, format_file_size, ) from openpype.pipeline import AvalonMongoDB, Anatomy from openpype_modules.ftrack.lib import BaseAction, statics_icon class DeleteOldVersions(BaseAction): identifier = "delete.old.versions" label = "OpenPype Admin" variant = "- Delete old versions" description = ( "Delete files from older publishes so project can be" " archived with only latest versions." ) icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") settings_key = "delete_old_versions" dbcon = AvalonMongoDB() inteface_title = "Choose your preferences" splitter_item = {"type": "label", "value": "---"} sequence_splitter = "__sequence_splitter__" def discover(self, session, entities, event): """ Validation. """ is_valid = False for entity in entities: if entity.entity_type.lower() == "assetversion": is_valid = True break if is_valid: is_valid = self.valid_roles(session, entities, event) return is_valid def interface(self, session, entities, event): # TODO Add roots existence validation items = [] values = event["data"].get("values") if values: versions_count = int(values["last_versions_count"]) if versions_count >= 1: return items.append({ "type": "label", "value": ( "# You have to keep at least 1 version!" ) }) items.append({ "type": "label", "value": ( "WARNING: This will remove published files of older" " versions from disk so we don't recommend use" " this action on \"live\" project." ) }) items.append(self.splitter_item) # How many versions to keep items.append({ "type": "label", "value": "## Choose how many versions you want to keep:" }) items.append({ "type": "label", "value": ( "NOTE: We do recommend to keep 2 versions." ) }) items.append({ "type": "number", "name": "last_versions_count", "label": "Versions", "value": 2 }) items.append(self.splitter_item) items.append({ "type": "label", "value": ( "## Remove publish folder even if there" " are other than published files:" ) }) items.append({ "type": "label", "value": ( "WARNING: This may remove more than you want." ) }) items.append({ "type": "boolean", "name": "force_delete_publish_folder", "label": "Are You sure?", "value": False }) items.append(self.splitter_item) items.append({ "type": "label", "value": ( "This will NOT delete any files and only return the " "total size of the files." ) }) items.append({ "type": "boolean", "name": "only_calculate", "label": "Only calculate size of files.", "value": False }) return { "items": items, "title": self.inteface_title } def launch(self, session, entities, event): values = event["data"].get("values") if not values: return versions_count = int(values["last_versions_count"]) force_to_remove = values["force_delete_publish_folder"] only_calculate = values["only_calculate"] _val1 = "OFF" if force_to_remove: _val1 = "ON" _val3 = "s" if versions_count == 1: _val3 = "" self.log.debug(( "Process started. Force to delete publish folder is set to [{0}]" " and will keep {1} latest version{2}." ).format(_val1, versions_count, _val3)) self.dbcon.install() project = None avalon_asset_names = [] asset_versions_by_parent_id = collections.defaultdict(list) subset_names_by_asset_name = collections.defaultdict(list) ftrack_assets_by_name = {} for entity in entities: ftrack_asset = entity["asset"] parent_ent = ftrack_asset["parent"] parent_ftrack_id = parent_ent["id"] parent_name = parent_ent["name"] if parent_name not in avalon_asset_names: avalon_asset_names.append(parent_name) # Group asset versions by parent entity asset_versions_by_parent_id[parent_ftrack_id].append(entity) # Get project if project is None: project = parent_ent["project"] # Collect subset names per asset subset_name = ftrack_asset["name"] subset_names_by_asset_name[parent_name].append(subset_name) if subset_name not in ftrack_assets_by_name: ftrack_assets_by_name[subset_name] = ftrack_asset # Set Mongo collection project_name = project["full_name"] anatomy = Anatomy(project_name) self.dbcon.Session["AVALON_PROJECT"] = project_name self.log.debug("Project is set to {}".format(project_name)) # Get Assets from avalon database assets = list( get_assets(project_name, asset_names=avalon_asset_names) ) asset_id_to_name_map = { asset["_id"]: asset["name"] for asset in assets } asset_ids = list(asset_id_to_name_map.keys()) self.log.debug("Collected assets ({})".format(len(asset_ids))) # Get Subsets subsets = list( get_subsets(project_name, asset_ids=asset_ids) ) subsets_by_id = {} subset_ids = [] for subset in subsets: asset_id = subset["parent"] asset_name = asset_id_to_name_map[asset_id] available_subsets = subset_names_by_asset_name[asset_name] if subset["name"] not in available_subsets: continue subset_ids.append(subset["_id"]) subsets_by_id[subset["_id"]] = subset self.log.debug("Collected subsets ({})".format(len(subset_ids))) # Get Versions versions = list( get_versions(project_name, subset_ids=subset_ids) ) versions_by_parent = collections.defaultdict(list) for ent in versions: versions_by_parent[ent["parent"]].append(ent) def sort_func(ent): return int(ent["name"]) all_last_versions = [] for parent_id, _versions in versions_by_parent.items(): for idx, version in enumerate( sorted(_versions, key=sort_func, reverse=True) ): if idx >= versions_count: break all_last_versions.append(version) self.log.debug("Collected versions ({})".format(len(versions))) # Filter latest versions for version in all_last_versions: versions.remove(version) # Update versions_by_parent without filtered versions versions_by_parent = collections.defaultdict(list) for ent in versions: versions_by_parent[ent["parent"]].append(ent) # Filter already deleted versions versions_to_pop = [] for version in versions: version_tags = version["data"].get("tags") if version_tags and "deleted" in version_tags: versions_to_pop.append(version) for version in versions_to_pop: subset = subsets_by_id[version["parent"]] asset_id = subset["parent"] asset_name = asset_id_to_name_map[asset_id] msg = "Asset: \"{}\" | Subset: \"{}\" | Version: \"{}\"".format( asset_name, subset["name"], version["name"] ) self.log.warning(( "Skipping version. Already tagged as `deleted`. < {} >" ).format(msg)) versions.remove(version) version_ids = [ent["_id"] for ent in versions] self.log.debug( "Filtered versions to delete ({})".format(len(version_ids)) ) if not version_ids: msg = "Skipping processing. Nothing to delete." self.log.debug(msg) return { "success": True, "message": msg } repres = list( get_representations(project_name, version_ids=version_ids) ) self.log.debug( "Collected representations to remove ({})".format(len(repres)) ) dir_paths = {} file_paths_by_dir = collections.defaultdict(list) for repre in repres: file_path, seq_path = self.path_from_represenation(repre, anatomy) if file_path is None: self.log.warning(( "Could not format path for representation \"{}\"" ).format(str(repre))) continue dir_path = os.path.dirname(file_path) dir_id = None for _dir_id, _dir_path in dir_paths.items(): if _dir_path == dir_path: dir_id = _dir_id break if dir_id is None: dir_id = uuid.uuid4() dir_paths[dir_id] = dir_path file_paths_by_dir[dir_id].append([file_path, seq_path]) dir_ids_to_pop = [] for dir_id, dir_path in dir_paths.items(): if os.path.exists(dir_path): continue dir_ids_to_pop.append(dir_id) # Pop dirs from both dictionaries for dir_id in dir_ids_to_pop: dir_paths.pop(dir_id) paths = file_paths_by_dir.pop(dir_id) # TODO report of missing directories? paths_msg = ", ".join([ "'{}'".format(path[0].replace("\\", "/")) for path in paths ]) self.log.warning(( "Folder does not exist. Deleting it's files skipped: {}" ).format(paths_msg)) # Size of files. size = 0 if only_calculate: if force_to_remove: size = self.delete_whole_dir_paths( dir_paths.values(), delete=False ) else: size = self.delete_only_repre_files( dir_paths, file_paths_by_dir, delete=False ) msg = "Total size of files: {}".format(format_file_size(size)) self.log.warning(msg) return {"success": True, "message": msg} if force_to_remove: size = self.delete_whole_dir_paths(dir_paths.values()) else: size = self.delete_only_repre_files(dir_paths, file_paths_by_dir) mongo_changes_bulk = [] for version in versions: orig_version_tags = version["data"].get("tags") or [] version_tags = [tag for tag in orig_version_tags] if "deleted" not in version_tags: version_tags.append("deleted") if version_tags == orig_version_tags: continue update_query = {"_id": version["_id"]} update_data = {"$set": {"data.tags": version_tags}} mongo_changes_bulk.append(UpdateOne(update_query, update_data)) if mongo_changes_bulk: self.dbcon.bulk_write(mongo_changes_bulk) self.dbcon.uninstall() # Set attribute `is_published` to `False` on ftrack AssetVersions for subset_id, _versions in versions_by_parent.items(): subset_name = None for subset in subsets: if subset["_id"] == subset_id: subset_name = subset["name"] break if subset_name is None: self.log.warning( "Subset with ID `{}` was not found.".format(str(subset_id)) ) continue ftrack_asset = ftrack_assets_by_name.get(subset_name) if not ftrack_asset: self.log.warning(( "Could not find Ftrack asset with name `{}`" ).format(subset_name)) continue version_numbers = [int(ver["name"]) for ver in _versions] for version in ftrack_asset["versions"]: if int(version["version"]) in version_numbers: version["is_published"] = False try: session.commit() except Exception: msg = ( "Could not set `is_published` attribute to `False`" " for selected AssetVersions." ) self.log.warning(msg, exc_info=True) return { "success": False, "message": msg } msg = "Total size of files deleted: {}".format(format_file_size(size)) self.log.warning(msg) return {"success": True, "message": msg} def delete_whole_dir_paths(self, dir_paths, delete=True): size = 0 for dir_path in dir_paths: # Delete all files and fodlers in dir path for root, dirs, files in os.walk(dir_path, topdown=False): for name in files: file_path = os.path.join(root, name) size += os.path.getsize(file_path) if delete: os.remove(file_path) self.log.debug("Removed file: {}".format(file_path)) for name in dirs: if delete: os.rmdir(os.path.join(root, name)) if not delete: continue # Delete even the folder and it's parents folders if they are empty while True: if not os.path.exists(dir_path): dir_path = os.path.dirname(dir_path) continue if len(os.listdir(dir_path)) != 0: break os.rmdir(os.path.join(dir_path)) return size def delete_only_repre_files(self, dir_paths, file_paths, delete=True): size = 0 for dir_id, dir_path in dir_paths.items(): dir_files = os.listdir(dir_path) collections, remainders = clique.assemble(dir_files) for file_path, seq_path in file_paths[dir_id]: file_path_base = os.path.split(file_path)[1] # Just remove file if `frame` key was not in context or # filled path is in remainders (single file sequence) if not seq_path or file_path_base in remainders: if not os.path.exists(file_path): self.log.warning( "File was not found: {}".format(file_path) ) continue size += os.path.getsize(file_path) if delete: os.remove(file_path) self.log.debug("Removed file: {}".format(file_path)) if file_path_base in remainders: remainders.remove(file_path_base) continue seq_path_base = os.path.split(seq_path)[1] head, tail = seq_path_base.split(self.sequence_splitter) final_col = None for collection in collections: if head != collection.head or tail != collection.tail: continue final_col = collection break if final_col is not None: # Fill full path to head final_col.head = os.path.join(dir_path, final_col.head) for _file_path in final_col: if os.path.exists(_file_path): size += os.path.getsize(_file_path) if delete: os.remove(_file_path) self.log.debug( "Removed file: {}".format(_file_path) ) _seq_path = final_col.format("{head}{padding}{tail}") self.log.debug("Removed files: {}".format(_seq_path)) collections.remove(final_col) elif os.path.exists(file_path): size += os.path.getsize(file_path) if delete: os.remove(file_path) self.log.debug("Removed file: {}".format(file_path)) else: self.log.warning( "File was not found: {}".format(file_path) ) # Delete as much as possible parent folders if not delete: return size for dir_path in dir_paths.values(): while True: if not os.path.exists(dir_path): dir_path = os.path.dirname(dir_path) continue if len(os.listdir(dir_path)) != 0: break self.log.debug("Removed folder: {}".format(dir_path)) os.rmdir(dir_path) return size def path_from_represenation(self, representation, anatomy): try: template = representation["data"]["template"] except KeyError: return (None, None) sequence_path = None try: context = representation["context"] context["root"] = anatomy.roots path = StringTemplate.format_strict_template(template, context) if "frame" in context: context["frame"] = self.sequence_splitter sequence_path = os.path.normpath( StringTemplate.format_strict_template( template, context ) ) except (KeyError, TemplateUnsolved): # Template references unavailable data return (None, None) return (os.path.normpath(path), sequence_path) def register(session): '''Register plugin. Called when used as an plugin.''' DeleteOldVersions(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_delivery.py ================================================ import os import copy import json import collections from openpype.client import ( get_project, get_assets, get_subsets, get_versions, get_representations ) from openpype_modules.ftrack.lib import BaseAction, statics_icon from openpype_modules.ftrack.lib.avalon_sync import CUST_ATTR_ID_KEY from openpype_modules.ftrack.lib.custom_attributes import ( query_custom_attributes ) from openpype.lib.dateutils import get_datetime_data from openpype.pipeline import Anatomy from openpype.pipeline.load import get_representation_path_with_anatomy from openpype.pipeline.delivery import ( get_format_dict, check_destination_path, deliver_single_file, deliver_sequence, ) class Delivery(BaseAction): identifier = "delivery.action" label = "Delivery" description = "Deliver data to client" role_list = ["Pypeclub", "Administrator", "Project manager"] icon = statics_icon("ftrack", "action_icons", "Delivery.svg") settings_key = "delivery_action" def discover(self, session, entities, event): is_valid = False for entity in entities: if entity.entity_type.lower() in ("assetversion", "reviewsession"): is_valid = True break if is_valid: is_valid = self.valid_roles(session, entities, event) return is_valid def interface(self, session, entities, event): if event["data"].get("values", {}): return title = "Delivery data to Client" items = [] item_splitter = {"type": "label", "value": "---"} project_entity = self.get_project_from_entity(entities[0]) project_name = project_entity["full_name"] project_doc = get_project(project_name, fields=["name"]) if not project_doc: return { "success": False, "message": ( "Didn't find project \"{}\" in avalon." ).format(project_name) } repre_names = self._get_repre_names(project_name, session, entities) items.append({ "type": "hidden", "name": "__project_name__", "value": project_name }) # Prepare anatomy data anatomy = Anatomy(project_name) new_anatomies = [] first = None for key, template in (anatomy.templates.get("delivery") or {}).items(): # Use only keys with `{root}` or `{root[*]}` in value if isinstance(template, str) and "{root" in template: new_anatomies.append({ "label": key, "value": key }) if first is None: first = key skipped = False # Add message if there are any common components if not repre_names or not new_anatomies: skipped = True items.append({ "type": "label", "value": "

Something went wrong:

" }) items.append({ "type": "hidden", "name": "__skipped__", "value": skipped }) if not repre_names: if len(entities) == 1: items.append({ "type": "label", "value": ( "- Selected entity doesn't have components to deliver." ) }) else: items.append({ "type": "label", "value": ( "- Selected entities don't have common components." ) }) # Add message if delivery anatomies are not set if not new_anatomies: items.append({ "type": "label", "value": ( "- `\"delivery\"` anatomy key is not set in config." ) }) # Skip if there are any data shortcomings if skipped: return { "items": items, "title": title } items.append({ "value": "

Choose Components to deliver

", "type": "label" }) for repre_name in repre_names: items.append({ "type": "boolean", "value": False, "label": repre_name, "name": repre_name }) items.append(item_splitter) items.append({ "value": "

Location for delivery

", "type": "label" }) items.append({ "type": "label", "value": ( "NOTE: It is possible to replace `root` key in anatomy." ) }) items.append({ "type": "text", "name": "__location_path__", "empty_text": "Type location path here...(Optional)" }) items.append(item_splitter) items.append({ "value": "

Anatomy of delivery files

", "type": "label" }) items.append({ "type": "label", "value": ( "

NOTE: These can be set in Anatomy.yaml" " within `delivery` key.

" ) }) items.append({ "type": "enumerator", "name": "__new_anatomies__", "data": new_anatomies, "value": first }) return { "items": items, "title": title } def _get_repre_names(self, project_name, session, entities): version_ids = self._get_interest_version_ids( project_name, session, entities ) if not version_ids: return [] repre_docs = get_representations( project_name, version_ids=version_ids, fields=["name"] ) repre_names = {repre_doc["name"] for repre_doc in repre_docs} return list(sorted(repre_names)) def _get_interest_version_ids(self, project_name, session, entities): # Extract AssetVersion entities asset_versions = self._extract_asset_versions(session, entities) # Prepare Asset ids asset_ids = { asset_version["asset_id"] for asset_version in asset_versions } # Query Asset entities assets = session.query(( "select id, name, context_id from Asset where id in ({})" ).format(self.join_query_keys(asset_ids))).all() assets_by_id = { asset["id"]: asset for asset in assets } parent_ids = set() subset_names = set() version_nums = set() for asset_version in asset_versions: asset_id = asset_version["asset_id"] asset = assets_by_id[asset_id] parent_ids.add(asset["context_id"]) subset_names.add(asset["name"]) version_nums.add(asset_version["version"]) asset_docs_by_ftrack_id = self._get_asset_docs( project_name, session, parent_ids ) subset_docs = self._get_subset_docs( project_name, asset_docs_by_ftrack_id, subset_names, asset_versions, assets_by_id ) version_docs = self._get_version_docs( project_name, asset_docs_by_ftrack_id, subset_docs, version_nums, asset_versions, assets_by_id ) return [version_doc["_id"] for version_doc in version_docs] def _extract_asset_versions(self, session, entities): asset_version_ids = set() review_session_ids = set() for entity in entities: entity_type_low = entity.entity_type.lower() if entity_type_low == "assetversion": asset_version_ids.add(entity["id"]) elif entity_type_low == "reviewsession": review_session_ids.add(entity["id"]) for version_id in self._get_asset_version_ids_from_review_sessions( session, review_session_ids ): asset_version_ids.add(version_id) asset_versions = session.query(( "select id, version, asset_id from AssetVersion where id in ({})" ).format(self.join_query_keys(asset_version_ids))).all() return asset_versions def _get_asset_version_ids_from_review_sessions( self, session, review_session_ids ): if not review_session_ids: return set() review_session_objects = session.query(( "select version_id from ReviewSessionObject" " where review_session_id in ({})" ).format(self.join_query_keys(review_session_ids))).all() return { review_session_object["version_id"] for review_session_object in review_session_objects } def _get_version_docs( self, project_name, asset_docs_by_ftrack_id, subset_docs, version_nums, asset_versions, assets_by_id ): subset_docs_by_id = { subset_doc["_id"]: subset_doc for subset_doc in subset_docs } version_docs = list(get_versions( project_name, subset_ids=subset_docs_by_id.keys(), versions=version_nums )) version_docs_by_parent_id = collections.defaultdict(dict) for version_doc in version_docs: subset_doc = subset_docs_by_id[version_doc["parent"]] asset_id = subset_doc["parent"] subset_name = subset_doc["name"] version = version_doc["name"] if version_docs_by_parent_id[asset_id].get(subset_name) is None: version_docs_by_parent_id[asset_id][subset_name] = {} version_docs_by_parent_id[asset_id][subset_name][version] = ( version_doc ) filtered_versions = [] for asset_version in asset_versions: asset_id = asset_version["asset_id"] asset = assets_by_id[asset_id] parent_id = asset["context_id"] asset_doc = asset_docs_by_ftrack_id.get(parent_id) if not asset_doc: continue subsets_by_name = version_docs_by_parent_id.get(asset_doc["_id"]) if not subsets_by_name: continue subset_name = asset["name"] version_docs_by_version = subsets_by_name.get(subset_name) if not version_docs_by_version: continue version = asset_version["version"] version_doc = version_docs_by_version.get(version) if version_doc: filtered_versions.append(version_doc) return filtered_versions def _get_subset_docs( self, project_name, asset_docs_by_ftrack_id, subset_names, asset_versions, assets_by_id ): asset_doc_ids = [ asset_doc["_id"] for asset_doc in asset_docs_by_ftrack_id.values() ] subset_docs = list(get_subsets( project_name, asset_ids=asset_doc_ids, subset_names=subset_names )) subset_docs_by_parent_id = collections.defaultdict(dict) for subset_doc in subset_docs: asset_id = subset_doc["parent"] subset_name = subset_doc["name"] subset_docs_by_parent_id[asset_id][subset_name] = subset_doc filtered_subsets = [] for asset_version in asset_versions: asset_id = asset_version["asset_id"] asset = assets_by_id[asset_id] parent_id = asset["context_id"] asset_doc = asset_docs_by_ftrack_id.get(parent_id) if not asset_doc: continue subsets_by_name = subset_docs_by_parent_id.get(asset_doc["_id"]) if not subsets_by_name: continue subset_name = asset["name"] subset_doc = subsets_by_name.get(subset_name) if subset_doc: filtered_subsets.append(subset_doc) return filtered_subsets def _get_asset_docs(self, project_name, session, parent_ids): asset_docs = list(get_assets( project_name, fields=["_id", "name", "data.ftrackId"] )) asset_docs_by_id = {} asset_docs_by_name = {} asset_docs_by_ftrack_id = {} for asset_doc in asset_docs: asset_id = str(asset_doc["_id"]) asset_name = asset_doc["name"] ftrack_id = asset_doc["data"].get("ftrackId") asset_docs_by_id[asset_id] = asset_doc asset_docs_by_name[asset_name] = asset_doc if ftrack_id: asset_docs_by_ftrack_id[ftrack_id] = asset_doc attr_def = session.query(( "select id from CustomAttributeConfiguration where key is \"{}\"" ).format(CUST_ATTR_ID_KEY)).first() if attr_def is None: return asset_docs_by_ftrack_id avalon_mongo_id_values = query_custom_attributes( session, [attr_def["id"]], parent_ids, True ) missing_ids = set(parent_ids) for item in avalon_mongo_id_values: if not item["value"]: continue asset_id = item["value"] entity_id = item["entity_id"] asset_doc = asset_docs_by_id.get(asset_id) if asset_doc: asset_docs_by_ftrack_id[entity_id] = asset_doc missing_ids.remove(entity_id) entity_ids_by_name = {} if missing_ids: not_found_entities = session.query(( "select id, name from TypedContext where id in ({})" ).format(self.join_query_keys(missing_ids))).all() entity_ids_by_name = { entity["name"]: entity["id"] for entity in not_found_entities } for asset_name, entity_id in entity_ids_by_name.items(): asset_doc = asset_docs_by_name.get(asset_name) if asset_doc: asset_docs_by_ftrack_id[entity_id] = asset_doc return asset_docs_by_ftrack_id def launch(self, session, entities, event): if "values" not in event["data"]: return { "success": True, "message": "Nothing to do" } values = event["data"]["values"] skipped = values.pop("__skipped__") if skipped: return { "success": False, "message": "Action skipped" } user_id = event["source"]["user"]["id"] user_entity = session.query( "User where id is {}".format(user_id) ).one() job = session.create("Job", { "user": user_entity, "status": "running", "data": json.dumps({ "description": "Delivery processing." }) }) session.commit() try: report = self.real_launch(session, entities, event) except Exception as exc: report = { "success": False, "title": "Delivery failed", "items": [{ "type": "label", "value": ( "Error during delivery action process:
{}" "

Check logs for more information." ).format(str(exc)) }] } self.log.warning( "Failed during processing delivery action.", exc_info=True ) finally: if report["success"]: job["status"] = "done" else: job["status"] = "failed" session.commit() if not report["success"]: self.show_interface( items=report["items"], title=report["title"], event=event ) return { "success": False, "message": "Errors during delivery process. See report." } return report def real_launch(self, session, entities, event): self.log.info("Delivery action just started.") report_items = collections.defaultdict(list) values = event["data"]["values"] location_path = values.pop("__location_path__") anatomy_name = values.pop("__new_anatomies__") project_name = values.pop("__project_name__") repre_names = [] for key, value in values.items(): if value is True: repre_names.append(key) if not repre_names: return { "success": True, "message": "No selected components to deliver." } location_path = location_path.strip() if location_path: location_path = os.path.normpath(location_path) if not os.path.exists(location_path): os.makedirs(location_path) self.log.debug("Collecting representations to process.") version_ids = self._get_interest_version_ids( project_name, session, entities ) repres_to_deliver = list(get_representations( project_name, representation_names=repre_names, version_ids=version_ids )) anatomy = Anatomy(project_name) format_dict = get_format_dict(anatomy, location_path) datetime_data = get_datetime_data() for repre in repres_to_deliver: source_path = repre.get("data", {}).get("path") debug_msg = "Processing representation {}".format(repre["_id"]) if source_path: debug_msg += " with published path {}.".format(source_path) self.log.debug(debug_msg) anatomy_data = copy.deepcopy(repre["context"]) repre_report_items = check_destination_path(repre["_id"], anatomy, anatomy_data, datetime_data, anatomy_name) if repre_report_items: report_items.update(repre_report_items) continue # Get source repre path frame = repre['context'].get('frame') if frame: repre["context"]["frame"] = len(str(frame)) * "#" repre_path = get_representation_path_with_anatomy(repre, anatomy) # TODO add backup solution where root of path from component # is replaced with root args = ( repre_path, repre, anatomy, anatomy_name, anatomy_data, format_dict, report_items, self.log ) if not frame: deliver_single_file(*args) else: deliver_sequence(*args) return self.report(report_items) def report(self, report_items): """Returns dict with final status of delivery (success, fail etc.).""" items = [] for msg, _items in report_items.items(): if not _items: continue if items: items.append({"type": "label", "value": "---"}) items.append({ "type": "label", "value": "# {}".format(msg) }) if not isinstance(_items, (list, tuple)): _items = [_items] __items = [] for item in _items: __items.append(str(item)) items.append({ "type": "label", "value": '

{}

'.format("
".join(__items)) }) if not items: return { "success": True, "message": "Delivery Finished" } return { "items": items, "title": "Delivery report", "success": False } def register(session): '''Register plugin. Called when used as an plugin.''' Delivery(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_djvview.py ================================================ import os import time import subprocess from operator import itemgetter from openpype.lib import ApplicationManager from openpype_modules.ftrack.lib import BaseAction, statics_icon class DJVViewAction(BaseAction): """Launch DJVView action.""" identifier = "djvview-launch-action" label = "DJV View" description = "DJV View Launcher" icon = statics_icon("app_icons", "djvView.png") type = "Application" allowed_types = [ "cin", "dpx", "avi", "dv", "gif", "flv", "mkv", "mov", "mpg", "mpeg", "mp4", "m4v", "mxf", "iff", "z", "ifl", "jpeg", "jpg", "jfif", "lut", "1dl", "exr", "pic", "png", "ppm", "pnm", "pgm", "pbm", "rla", "rpf", "sgi", "rgba", "rgb", "bw", "tga", "tiff", "tif", "img" ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.application_manager = ApplicationManager() self._last_check = time.time() self._check_interval = 10 def _get_djv_apps(self): app_group = self.application_manager.app_groups["djvview"] output = [] for app in app_group: executable = app.find_executable() if executable is not None: output.append(app) return output def get_djv_apps(self): cur_time = time.time() if (cur_time - self._last_check) > self._check_interval: self.application_manager.refresh() return self._get_djv_apps() def discover(self, session, entities, event): """Return available actions based on *event*. """ selection = event["data"].get("selection", []) if len(selection) != 1: return False entityType = selection[0].get("entityType", None) if entityType not in ["assetversion", "task"]: return False if self.get_djv_apps(): return True return False def interface(self, session, entities, event): if event["data"].get("values", {}): return entity = entities[0] versions = [] entity_type = entity.entity_type.lower() if entity_type == "assetversion": if ( entity[ "components" ][0]["file_type"][1:] in self.allowed_types ): versions.append(entity) else: master_entity = entity if entity_type == "task": master_entity = entity["parent"] for asset in master_entity["assets"]: for version in asset["versions"]: # Get only AssetVersion of selected task if ( entity_type == "task" and version["task"]["id"] != entity["id"] ): continue # Get only components with allowed type filetype = version["components"][0]["file_type"] if filetype[1:] in self.allowed_types: versions.append(version) if len(versions) < 1: return { "success": False, "message": "There are no Asset Versions to open." } # TODO sort them (somehow?) enum_items = [] first_value = None for app in self.get_djv_apps(): if first_value is None: first_value = app.full_name enum_items.append({ "value": app.full_name, "label": app.full_label }) if not enum_items: return { "success": False, "message": "Couldn't find DJV executable." } items = [ { "type": "enumerator", "label": "DJV version:", "name": "djv_app_name", "data": enum_items, "value": first_value }, { "type": "label", "value": "---" } ] version_items = [] base_label = "v{0} - {1} - {2}" default_component = None last_available = None select_value = None for version in versions: for component in version["components"]: label = base_label.format( str(version["version"]).zfill(3), version["asset"]["type"]["name"], component["name"] ) try: location = component[ "component_locations" ][0]["location"] file_path = location.get_filesystem_path(component) except Exception: file_path = component[ "component_locations" ][0]["resource_identifier"] if os.path.isdir(os.path.dirname(file_path)): last_available = file_path if component["name"] == default_component: select_value = file_path version_items.append( {"label": label, "value": file_path} ) if len(version_items) == 0: return { "success": False, "message": ( "There are no Asset Versions with accessible path." ) } item = { "label": "Items to view", "type": "enumerator", "name": "path", "data": sorted( version_items, key=itemgetter("label"), reverse=True ) } if select_value is not None: item["value"] = select_value else: item["value"] = last_available items.append(item) return {"items": items} def launch(self, session, entities, event): """Callback method for DJVView action.""" # Launching application event_values = event["data"].get("values") if not event_values: return djv_app_name = event_values["djv_app_name"] app = self.application_manager.applications.get(djv_app_name) executable = None if app is not None: executable = app.find_executable() if not executable: return { "success": False, "message": "Couldn't find DJV executable." } filpath = os.path.normpath(event_values["path"]) cmd = [ # DJV path str(executable), # PATH TO COMPONENT filpath ] try: # Run DJV with these commands _process = subprocess.Popen(cmd) # Keep process in memory for some time time.sleep(0.1) except FileNotFoundError: return { "success": False, "message": "File \"{}\" was not found.".format( os.path.basename(filpath) ) } return True def register(session): """Register hooks.""" DJVViewAction(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_fill_workfile_attr.py ================================================ import os import sys import json import collections import tempfile import datetime import ftrack_api from openpype.client import ( get_project, get_assets, ) from openpype.settings import get_project_settings, get_system_settings from openpype.lib import StringTemplate from openpype.pipeline import Anatomy from openpype.pipeline.template_data import get_template_data from openpype.pipeline.workfile import get_workfile_template_key from openpype_modules.ftrack.lib import BaseAction, statics_icon from openpype_modules.ftrack.lib.avalon_sync import create_chunks NOT_SYNCHRONIZED_TITLE = "Not synchronized" class FillWorkfileAttributeAction(BaseAction): """Action fill work filename into custom attribute on tasks. Prerequirements are that the project is synchronized so it is possible to access project anatomy and project/asset documents. Tasks that are not synchronized are skipped too. """ identifier = "fill.workfile.attr" label = "OpenPype Admin" variant = "- Fill workfile attribute" description = "Precalculate and fill workfile name into a custom attribute" icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") settings_key = "fill_workfile_attribute" def discover(self, session, entities, event): """ Validate selection. """ is_valid = False for ent in event["data"]["selection"]: # Ignore entities that are not tasks or projects if ent["entityType"].lower() in ["show", "task"]: is_valid = True break if is_valid: is_valid = self.valid_roles(session, entities, event) return is_valid def launch(self, session, entities, event): # Separate entities and get project entity project_entity = None for entity in entities: if project_entity is None: project_entity = self.get_project_from_entity(entity) break if not project_entity: return { "message": ( "Couldn't find project entity." " Could be an issue with permissions." ), "success": False } # Get project settings and check if custom attribute where workfile # should be set is defined. project_name = project_entity["full_name"] project_settings = get_project_settings(project_name) custom_attribute_key = ( project_settings .get("ftrack", {}) .get("user_handlers", {}) .get(self.settings_key, {}) .get("custom_attribute_key") ) if not custom_attribute_key: return { "success": False, "message": "Custom attribute key is not set in settings" } # Try to find the custom attribute # - get Task type object id task_obj_type = session.query( "select id from ObjectType where name is \"Task\"" ).one() # - get text custom attribute type text_type = session.query( "select id from CustomAttributeType where name is \"text\"" ).one() # - find the attribute attr_conf = session.query( ( "select id, key from CustomAttributeConfiguration" " where object_type_id is \"{}\"" " and type_id is \"{}\"" " and key is \"{}\"" ).format( task_obj_type["id"], text_type["id"], custom_attribute_key ) ).first() if not attr_conf: return { "success": False, "message": ( "Could not find Task (text) Custom attribute \"{}\"" ).format(custom_attribute_key) } # Store report information report = collections.defaultdict(list) user_entity = session.query( "User where id is {}".format(event["source"]["user"]["id"]) ).one() job_entity = session.create("Job", { "user": user_entity, "status": "running", "data": json.dumps({ "description": "(0/3) Fill of workfiles started" }) }) session.commit() try: self.in_job_process( session, entities, job_entity, project_entity, project_settings, attr_conf, report ) except Exception: self.log.error( "Fill of workfiles to custom attribute failed", exc_info=True ) session.rollback() description = "Fill of workfiles Failed (Download traceback)" self.add_traceback_to_job( job_entity, session, sys.exc_info(), description ) return { "message": ( "Fill of workfiles failed." " Check job for more information" ), "success": False } job_entity["status"] = "done" job_entity["data"] = json.dumps({ "description": "Fill of workfiles completed." }) session.commit() if report: temp_obj = tempfile.NamedTemporaryFile( mode="w", prefix="openpype_ftrack_", suffix=".json", delete=False ) temp_obj.close() temp_filepath = temp_obj.name with open(temp_filepath, "w") as temp_file: json.dump(report, temp_file) component_name = "{}_{}".format( "FillWorkfilesReport", datetime.datetime.now().strftime("%y-%m-%d-%H%M") ) self.add_file_component_to_job( job_entity, session, temp_filepath, component_name ) # Delete temp file os.remove(temp_filepath) self._show_report(event, report, project_name) return { "message": ( "Fill of workfiles finished with few issues." " Check job for more information" ), "success": True } return { "success": True, "message": "Finished with filling of work filenames" } def _show_report(self, event, report, project_name): items = [] title = "Fill workfiles report ({}):".format(project_name) for subtitle, lines in report.items(): if items: items.append({ "type": "label", "value": "---" }) items.append({ "type": "label", "value": "# {}".format(subtitle) }) items.append({ "type": "label", "value": '

{}

'.format("
".join(lines)) }) self.show_interface( items=items, title=title, event=event ) def in_job_process( self, session, entities, job_entity, project_entity, project_settings, attr_conf, report ): task_entities = [] other_entities = [] project_selected = False for entity in entities: ent_type_low = entity.entity_type.lower() if ent_type_low == "project": project_selected = True break elif ent_type_low == "task": task_entities.append(entity) else: other_entities.append(entity) project_name = project_entity["full_name"] # Find matching asset documents and map them by ftrack task entities # - result stored to 'asset_docs_with_task_entities' is list with # tuple `(asset document, [task entitis, ...])` # Quety all asset documents asset_docs = list(get_assets(project_name)) job_entity["data"] = json.dumps({ "description": "(1/3) Asset documents queried." }) session.commit() # When project is selected then we can query whole project if project_selected: asset_docs_with_task_entities = self._get_asset_docs_for_project( session, project_entity, asset_docs, report ) else: asset_docs_with_task_entities = self._get_tasks_for_selection( session, other_entities, task_entities, asset_docs, report ) job_entity["data"] = json.dumps({ "description": "(2/3) Queried related task entities." }) session.commit() # Keep placeholders in the template unfilled host_name = "{app}" extension = "{ext}" project_doc = get_project(project_name) project_settings = get_project_settings(project_name) system_settings = get_system_settings() anatomy = Anatomy(project_name) templates_by_key = {} operations = [] for asset_doc, task_entities in asset_docs_with_task_entities: for task_entity in task_entities: workfile_data = get_template_data( project_doc, asset_doc, task_entity["name"], host_name, system_settings ) # Use version 1 for each workfile workfile_data["version"] = 1 workfile_data["ext"] = extension task_type = workfile_data["task"]["type"] template_key = get_workfile_template_key( task_type, host_name, project_name, project_settings=project_settings ) if template_key in templates_by_key: template = templates_by_key[template_key] else: template = StringTemplate( anatomy.templates[template_key]["file"] ) templates_by_key[template_key] = template result = template.format(workfile_data) if not result.solved: # TODO report pass else: table_values = collections.OrderedDict(( ("configuration_id", attr_conf["id"]), ("entity_id", task_entity["id"]) )) operations.append( ftrack_api.operation.UpdateEntityOperation( "ContextCustomAttributeValue", table_values, "value", ftrack_api.symbol.NOT_SET, str(result) ) ) if operations: for sub_operations in create_chunks(operations, 50): for op in sub_operations: session.recorded_operations.push(op) session.commit() job_entity["data"] = json.dumps({ "description": "(3/3) Set custom attribute values." }) session.commit() def _get_entity_path(self, entity): path_items = [] for item in entity["link"]: if item["type"].lower() != "project": path_items.append(item["name"]) return "/".join(path_items) def _get_asset_docs_for_project( self, session, project_entity, asset_docs, report ): asset_docs_task_names = {} for asset_doc in asset_docs: asset_data = asset_doc["data"] ftrack_id = asset_data.get("ftrackId") if not ftrack_id: hierarchy = list(asset_data.get("parents") or []) hierarchy.append(asset_doc["name"]) path = "/".join(hierarchy) report[NOT_SYNCHRONIZED_TITLE].append(path) continue asset_tasks = asset_data.get("tasks") or {} asset_docs_task_names[ftrack_id] = ( asset_doc, list(asset_tasks.keys()) ) task_entities = session.query(( "select id, name, parent_id, link from Task where project_id is {}" ).format(project_entity["id"])).all() task_entities_by_parent_id = collections.defaultdict(list) for task_entity in task_entities: parent_id = task_entity["parent_id"] task_entities_by_parent_id[parent_id].append(task_entity) output = [] for ftrack_id, item in asset_docs_task_names.items(): asset_doc, task_names = item valid_task_entities = [] for task_entity in task_entities_by_parent_id[ftrack_id]: if task_entity["name"] in task_names: valid_task_entities.append(task_entity) else: path = self._get_entity_path(task_entity) report[NOT_SYNCHRONIZED_TITLE].append(path) if valid_task_entities: output.append((asset_doc, valid_task_entities)) return output def _get_tasks_for_selection( self, session, other_entities, task_entities, asset_docs, report ): all_tasks = object() asset_docs_by_ftrack_id = {} asset_docs_by_parent_id = collections.defaultdict(list) for asset_doc in asset_docs: asset_data = asset_doc["data"] ftrack_id = asset_data.get("ftrackId") parent_id = asset_data.get("visualParent") asset_docs_by_parent_id[parent_id].append(asset_doc) if ftrack_id: asset_docs_by_ftrack_id[ftrack_id] = asset_doc missing_doc_ftrack_ids = {} all_tasks_ids = set() task_names_by_ftrack_id = collections.defaultdict(list) for other_entity in other_entities: ftrack_id = other_entity["id"] if ftrack_id not in asset_docs_by_ftrack_id: missing_doc_ftrack_ids[ftrack_id] = None continue all_tasks_ids.add(ftrack_id) task_names_by_ftrack_id[ftrack_id] = all_tasks for task_entity in task_entities: parent_id = task_entity["parent_id"] if parent_id not in asset_docs_by_ftrack_id: missing_doc_ftrack_ids[parent_id] = None continue if all_tasks_ids not in all_tasks_ids: task_names_by_ftrack_id[ftrack_id].append(task_entity["name"]) ftrack_ids = set() asset_doc_with_task_names_by_id = {} for ftrack_id, task_names in task_names_by_ftrack_id.items(): asset_doc = asset_docs_by_ftrack_id[ftrack_id] asset_data = asset_doc["data"] asset_tasks = asset_data.get("tasks") or {} if task_names is all_tasks: task_names = list(asset_tasks.keys()) else: new_task_names = [] for task_name in task_names: if task_name in asset_tasks: new_task_names.append(task_name) continue if ftrack_id not in missing_doc_ftrack_ids: missing_doc_ftrack_ids[ftrack_id] = [] if missing_doc_ftrack_ids[ftrack_id] is not None: missing_doc_ftrack_ids[ftrack_id].append(task_name) task_names = new_task_names if task_names: ftrack_ids.add(ftrack_id) asset_doc_with_task_names_by_id[ftrack_id] = ( asset_doc, task_names ) task_entities = session.query(( "select id, name, parent_id from Task where parent_id in ({})" ).format(self.join_query_keys(ftrack_ids))).all() task_entitiy_by_parent_id = collections.defaultdict(list) for task_entity in task_entities: parent_id = task_entity["parent_id"] task_entitiy_by_parent_id[parent_id].append(task_entity) output = [] for ftrack_id, item in asset_doc_with_task_names_by_id.items(): asset_doc, task_names = item valid_task_entities = [] for task_entity in task_entitiy_by_parent_id[ftrack_id]: if task_entity["name"] in task_names: valid_task_entities.append(task_entity) else: if ftrack_id not in missing_doc_ftrack_ids: missing_doc_ftrack_ids[ftrack_id] = [] if missing_doc_ftrack_ids[ftrack_id] is not None: missing_doc_ftrack_ids[ftrack_id].append(task_name) if valid_task_entities: output.append((asset_doc, valid_task_entities)) # Store report information about not synchronized entities if missing_doc_ftrack_ids: missing_entities = session.query( "select id, link from TypedContext where id in ({})".format( self.join_query_keys(missing_doc_ftrack_ids.keys()) ) ).all() for missing_entity in missing_entities: path = self._get_entity_path(missing_entity) task_names = missing_doc_ftrack_ids[missing_entity["id"]] if task_names is None: report[NOT_SYNCHRONIZED_TITLE].append(path) else: for task_name in task_names: task_path = "/".join([path, task_name]) report[NOT_SYNCHRONIZED_TITLE].append(task_path) return output def register(session): FillWorkfileAttributeAction(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_job_killer.py ================================================ import json from openpype_modules.ftrack.lib import BaseAction, statics_icon class JobKiller(BaseAction): """Kill jobs that are marked as running.""" identifier = "job.killer" label = "OpenPype Admin" variant = "- Job Killer" description = "Killing selected running jobs" icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") settings_key = "job_killer" def discover(self, session, entities, event): """Check if action is available for user role.""" return self.valid_roles(session, entities, event) def interface(self, session, entities, event): if event["data"].get("values"): return title = "Select jobs to kill" jobs = session.query( "select id, user_id, status, created_at, data from Job" " where status in (\"queued\", \"running\")" ).all() if not jobs: return { "success": True, "message": "Didn't find any running jobs" } # Collect user ids from jobs user_ids = set() for job in jobs: user_id = job["user_id"] if user_id: user_ids.add(user_id) # Store usernames by their ids usernames_by_id = {} if user_ids: users = session.query( "select id, username from User where id in ({})".format( self.join_query_keys(user_ids) ) ).all() for user in users: usernames_by_id[user["id"]] = user["username"] items = [] for job in jobs: try: data = json.loads(job["data"]) description = data["description"] except Exception: description = "*No description*" user_id = job["user_id"] username = usernames_by_id.get(user_id) or "Unknown user" created = job["created_at"].strftime('%d.%m.%Y %H:%M:%S') label = "{} - {} - {}".format( username, description, created ) item_label = { "type": "label", "value": label } item = { "name": job["id"], "type": "boolean", "value": False } if len(items) > 0: items.append({"type": "label", "value": "---"}) items.append(item_label) items.append(item) return { "items": items, "title": title } def launch(self, session, entities, event): if "values" not in event["data"]: return values = event["data"]["values"] if len(values) < 1: return { "success": True, "message": "No jobs to kill!" } job_ids = set() for job_id, kill_job in values.items(): if kill_job: job_ids.add(job_id) jobs = session.query( "select id, status from Job where id in ({})".format( self.join_query_keys(job_ids) ) ).all() # Update all the queried jobs, setting the status to failed. for job in jobs: try: origin_status = job["status"] self.log.debug(( 'Changing Job ({}) status: {} -> failed' ).format(job["id"], origin_status)) job["status"] = "failed" session.commit() except Exception: session.rollback() self.log.warning(( "Changing Job ({}) has failed" ).format(job["id"])) self.log.info("All selected jobs were killed Successfully!") return { "success": True, "message": "All selected jobs were killed Successfully!" } def register(session): '''Register plugin. Called when used as an plugin.''' JobKiller(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_multiple_notes.py ================================================ from openpype_modules.ftrack.lib import BaseAction, statics_icon class MultipleNotes(BaseAction): '''Edit meta data action.''' #: Action identifier. identifier = 'multiple.notes' #: Action label. label = 'Multiple Notes' #: Action description. description = 'Add same note to multiple entities' icon = statics_icon("ftrack", "action_icons", "MultipleNotes.svg") def discover(self, session, entities, event): ''' Validation ''' valid = True # Check for multiple selection. if len(entities) < 2: valid = False # Check for valid entities. valid_entity_types = ['assetversion', 'task'] for entity in entities: if entity.entity_type.lower() not in valid_entity_types: valid = False break return valid def interface(self, session, entities, event): if not event['data'].get('values', {}): note_label = { 'type': 'label', 'value': '# Enter note: #' } note_value = { 'name': 'note', 'type': 'textarea' } category_label = { 'type': 'label', 'value': '## Category: ##' } category_data = [] category_data.append({ 'label': '- None -', 'value': 'none' }) all_categories = session.query('NoteCategory').all() for cat in all_categories: category_data.append({ 'label': cat['name'], 'value': cat['id'] }) category_value = { 'type': 'enumerator', 'name': 'category', 'data': category_data, 'value': 'none' } splitter = { 'type': 'label', 'value': '{}'.format(200 * "-") } items = [] items.append(note_label) items.append(note_value) items.append(splitter) items.append(category_label) items.append(category_value) return items def launch(self, session, entities, event): if 'values' not in event['data']: return values = event['data']['values'] if len(values) <= 0 or 'note' not in values: return False # Get Note text note_value = values['note'] if note_value.lower().strip() == '': return False # Get User user = session.query( 'User where username is "{}"'.format(session.api_user) ).one() # Base note data note_data = { 'content': note_value, 'author': user } # Get category category_value = values['category'] if category_value != 'none': category = session.query( 'NoteCategory where id is "{}"'.format(category_value) ).one() note_data['category'] = category # Create notes for entities for entity in entities: new_note = session.create('Note', note_data) entity['notes'].append(new_note) session.commit() return True def register(session): '''Register plugin. Called when used as an plugin.''' MultipleNotes(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_prepare_project.py ================================================ import json import copy from openpype.client import get_project, create_project from openpype.settings import ProjectSettings, SaveWarningExc from openpype_modules.ftrack.lib import ( BaseAction, statics_icon, get_openpype_attr, CUST_ATTR_AUTO_SYNC ) class PrepareProjectLocal(BaseAction): """Prepare project attributes in Anatomy.""" identifier = "prepare.project.local" label = "Prepare Project" description = "Set basic attributes on the project" icon = statics_icon("ftrack", "action_icons", "PrepareProject.svg") role_list = ["Pypeclub", "Administrator", "Project Manager"] settings_key = "prepare_project" # Key to store info about triggering create folder structure create_project_structure_key = "create_folder_structure" create_project_structure_identifier = "create.project.structure" item_splitter = {"type": "label", "value": "---"} _keys_order = ( "fps", "frameStart", "frameEnd", "handleStart", "handleEnd", "clipIn", "clipOut", "resolutionHeight", "resolutionWidth", "pixelAspect", "applications", "tools_env", "library_project", ) def discover(self, session, entities, event): """Show only on project.""" if ( len(entities) != 1 or entities[0].entity_type.lower() != "project" ): return False return self.valid_roles(session, entities, event) def interface(self, session, entities, event): if event['data'].get('values', {}): return # Inform user that this may take a while self.show_message(event, "Preparing data... Please wait", True) self.log.debug("Preparing data which will be shown") self.log.debug("Loading custom attributes") project_entity = entities[0] project_name = project_entity["full_name"] project_settings = ProjectSettings(project_name) project_anatom_settings = project_settings["project_anatomy"] root_items = self.prepare_root_items(project_anatom_settings) ca_items, multiselect_enumerators = ( self.prepare_custom_attribute_items(project_anatom_settings) ) self.log.debug("Heavy items are ready. Preparing last items group.") title = "Prepare Project" items = [] # Add root items items.extend(root_items) items.append(self.item_splitter) items.append({ "type": "label", "value": "

Set basic Attributes:

" }) items.extend(ca_items) # Set value of auto synchronization auto_sync_value = project_entity["custom_attributes"].get( CUST_ATTR_AUTO_SYNC, False ) auto_sync_item = { "name": CUST_ATTR_AUTO_SYNC, "type": "boolean", "value": auto_sync_value, "label": "AutoSync to Avalon" } # Add autosync attribute items.append(auto_sync_item) # This item will be last before enumerators # Ask if want to trigger Action Create Folder Structure create_project_structure_checked = ( project_settings ["project_settings"] ["ftrack"] ["user_handlers"] ["prepare_project"] ["create_project_structure_checked"] ).value items.append({ "type": "label", "value": "

Want to create basic Folder Structure?

" }) items.append({ "name": self.create_project_structure_key, "type": "boolean", "value": create_project_structure_checked, "label": "Check if Yes" }) # Add enumerator items at the end for item in multiselect_enumerators: items.append(item) return { "items": items, "title": title } def prepare_root_items(self, project_anatom_settings): self.log.debug("Root items preparation begins.") root_items = [] root_items.append({ "type": "label", "value": "

Check your Project root settings

" }) root_items.append({ "type": "label", "value": ( "

NOTE: Roots are crucial for path filling" " (and creating folder structure).

" ) }) root_items.append({ "type": "label", "value": ( "

WARNING: Do not change roots on running project," " that will cause workflow issues.

" ) }) empty_text = "Enter root path here..." roots_entity = project_anatom_settings["roots"] for root_name, root_entity in roots_entity.items(): root_items.append(self.item_splitter) root_items.append({ "type": "label", "value": "Root: \"{}\"".format(root_name) }) for platform_name, value_entity in root_entity.items(): root_items.append({ "label": platform_name, "name": "__root__{}__{}".format(root_name, platform_name), "type": "text", "value": value_entity.value, "empty_text": empty_text }) root_items.append({ "type": "hidden", "name": "__rootnames__", "value": json.dumps(list(roots_entity.keys())) }) self.log.debug("Root items preparation ended.") return root_items def _attributes_to_set(self, project_anatom_settings): attributes_to_set = {} attribute_values_by_key = {} for key, entity in project_anatom_settings["attributes"].items(): attribute_values_by_key[key] = entity.value cust_attrs, hier_cust_attrs = get_openpype_attr(self.session, True) for attr in hier_cust_attrs: key = attr["key"] if key.startswith("avalon_"): continue attributes_to_set[key] = { "label": attr["label"], "object": attr, "default": attribute_values_by_key.get(key) } for attr in cust_attrs: if attr["entity_type"].lower() != "show": continue key = attr["key"] if key.startswith("avalon_"): continue attributes_to_set[key] = { "label": attr["label"], "object": attr, "default": attribute_values_by_key.get(key) } # Sort by label attributes_to_set = dict(sorted( attributes_to_set.items(), key=lambda x: x[1]["label"] )) return attributes_to_set def prepare_custom_attribute_items(self, project_anatom_settings): items = [] multiselect_enumerators = [] attributes_to_set = self._attributes_to_set(project_anatom_settings) self.log.debug("Preparing interface for keys: \"{}\"".format( str([key for key in attributes_to_set]) )) attribute_keys = set(attributes_to_set.keys()) keys_order = [] for key in self._keys_order: if key in attribute_keys: keys_order.append(key) attribute_keys = attribute_keys - set(keys_order) for key in sorted(attribute_keys): keys_order.append(key) for key in keys_order: in_data = attributes_to_set[key] attr = in_data["object"] # initial item definition item = { "name": key, "label": in_data["label"] } # cust attr type - may have different visualization type_name = attr["type"]["name"].lower() easy_types = ["text", "boolean", "date", "number"] easy_type = False if type_name in easy_types: easy_type = True elif type_name == "enumerator": attr_config = json.loads(attr["config"]) attr_config_data = json.loads(attr_config["data"]) if attr_config["multiSelect"] is True: multiselect_enumerators.append(self.item_splitter) multiselect_enumerators.append({ "type": "label", "value": "

{}

".format(in_data["label"]) }) default = in_data["default"] names = [] for option in sorted( attr_config_data, key=lambda x: x["menu"] ): name = option["value"] new_name = "__{}__{}".format(key, name) names.append(new_name) item = { "name": new_name, "type": "boolean", "label": "- {}".format(option["menu"]) } if default: if isinstance(default, (list, tuple)): if name in default: item["value"] = True else: if name == default: item["value"] = True multiselect_enumerators.append(item) multiselect_enumerators.append({ "type": "hidden", "name": "__hidden__{}".format(key), "value": json.dumps(names) }) else: easy_type = True item["data"] = attr_config_data else: self.log.warning(( "Custom attribute \"{}\" has type \"{}\"." " I don't know how to handle" ).format(key, type_name)) items.append({ "type": "label", "value": ( "!!! Can't handle Custom attritubte type \"{}\"" " (key: \"{}\")" ).format(type_name, key) }) if easy_type: item["type"] = type_name # default value in interface default = in_data["default"] if default is not None: item["value"] = default items.append(item) return items, multiselect_enumerators def launch(self, session, entities, event): in_data = event["data"].get("values") if not in_data: return create_project_structure_checked = in_data.pop( self.create_project_structure_key ) root_values = {} root_key = "__root__" for key in tuple(in_data.keys()): if key.startswith(root_key): _key = key[len(root_key):] root_values[_key] = in_data.pop(key) root_names = in_data.pop("__rootnames__", None) root_data = {} for root_name in json.loads(root_names): root_data[root_name] = {} for key, value in tuple(root_values.items()): prefix = "{}__".format(root_name) if not key.startswith(prefix): continue _key = key[len(prefix):] root_data[root_name][_key] = value # Find hidden items for multiselect enumerators keys_to_process = [] for key in in_data: if key.startswith("__hidden__"): keys_to_process.append(key) self.log.debug("Preparing data for Multiselect Enumerators") enumerators = {} for key in keys_to_process: new_key = key.replace("__hidden__", "") enumerator_items = in_data.pop(key) enumerators[new_key] = json.loads(enumerator_items) # find values set for multiselect enumerator for key, enumerator_items in enumerators.items(): in_data[key] = [] name = "__{}__".format(key) for item in enumerator_items: value = in_data.pop(item) if value is True: new_key = item.replace(name, "") in_data[key].append(new_key) self.log.debug("Setting Custom Attribute values") project_entity = entities[0] project_name = project_entity["full_name"] # Try to find project document project_doc = get_project(project_name) # Create project if is not available # - creation is required to be able set project anatomy and attributes if not project_doc: project_code = project_entity["name"] self.log.info("Creating project \"{} [{}]\"".format( project_name, project_code )) create_project(project_name, project_code) self.trigger_event( "openpype.project.created", {"project_name": project_name} ) project_settings = ProjectSettings(project_name) project_anatomy_settings = project_settings["project_anatomy"] project_anatomy_settings["roots"] = root_data custom_attribute_values = {} attributes_entity = project_anatomy_settings["attributes"] for key, value in in_data.items(): if key not in attributes_entity: custom_attribute_values[key] = value else: attributes_entity[key] = value try: project_settings.save() except SaveWarningExc as exc: self.log.info("Few warnings happened during settings save:") for warning in exc.warnings: self.log.info(str(warning)) # Change custom attributes on project if custom_attribute_values: for key, value in custom_attribute_values.items(): project_entity["custom_attributes"][key] = value self.log.debug("- Key \"{}\" set to \"{}\"".format(key, value)) session.commit() # Trigger create project structure action if create_project_structure_checked: trigger_identifier = "{}.{}".format( self.create_project_structure_identifier, self.process_identifier() ) self.trigger_action(trigger_identifier, event) event_data = copy.deepcopy(in_data) event_data["project_name"] = project_name self.trigger_event("openpype.project.prepared", event_data) return True def register(session): '''Register plugin. Called when used as an plugin.''' PrepareProjectLocal(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_rv.py ================================================ import os import subprocess import traceback import json import ftrack_api from openpype.client import ( get_asset_by_name, get_subset_by_name, get_version_by_name, get_representation_by_name ) from openpype.pipeline import ( get_representation_path, AvalonMongoDB, Anatomy, ) from openpype_modules.ftrack.lib import BaseAction, statics_icon class RVAction(BaseAction): """ Launch RV action """ identifier = "rv.launch.action" label = "rv" description = "rv Launcher" icon = statics_icon("ftrack", "action_icons", "RV.png") type = 'Application' allowed_types = ["img", "mov", "exr", "mp4"] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # QUESTION load RV application data from AppplicationManager? rv_path = None # RV_HOME should be set if properly installed if os.environ.get('RV_HOME'): rv_path = os.path.join( os.environ.get('RV_HOME'), 'bin', 'rv' ) if not os.path.exists(rv_path): rv_path = None if not rv_path: self.log.info("RV path was not found.") self.ignore_me = True self.rv_path = rv_path def discover(self, session, entities, event): """Return available actions based on *event*. """ return True def preregister(self): if self.rv_path is None: return ( 'RV is not installed or paths in presets are not set correctly' ) return True def get_components_from_entity(self, session, entity, components): """Get components from various entity types. The components dictionary is modified in place, so nothing is returned. Args: entity (Ftrack entity) components (dict) """ if entity.entity_type.lower() == "assetversion": for component in entity["components"]: if component["file_type"][1:] not in self.allowed_types: continue try: components[entity["asset"]["parent"]["name"]].append( component ) except KeyError: components[entity["asset"]["parent"]["name"]] = [component] return if entity.entity_type.lower() == "task": query = "AssetVersion where task_id is '{0}'".format(entity["id"]) for assetversion in session.query(query): self.get_components_from_entity( session, assetversion, components ) return if entity.entity_type.lower() == "shot": query = "AssetVersion where asset.parent.id is '{0}'".format( entity["id"] ) for assetversion in session.query(query): self.get_components_from_entity( session, assetversion, components ) return raise NotImplementedError( "\"{}\" entity type is not implemented yet.".format( entity.entity_type ) ) def interface(self, session, entities, event): if event['data'].get('values', {}): return user = session.query( "User where username is '{0}'".format( os.environ["FTRACK_API_USER"] ) ).one() job = session.create( "Job", { "user": user, "status": "running", "data": json.dumps({ "description": "RV: Collecting components." }) } ) # Commit to feedback to user. session.commit() items = [] try: items = self.get_interface_items(session, entities) except Exception: self.log.error(traceback.format_exc()) job["status"] = "failed" else: job["status"] = "done" # Commit to end job. session.commit() return {"items": items} def get_interface_items(self, session, entities): components = {} for entity in entities: self.get_components_from_entity(session, entity, components) # Sort by version for parent_name, entities in components.items(): version_mapping = {} for entity in entities: try: version_mapping[entity["version"]["version"]].append( entity ) except KeyError: version_mapping[entity["version"]["version"]] = [entity] # Sort same versions by date. for version, entities in version_mapping.items(): version_mapping[version] = sorted( entities, key=lambda x: x["version"]["date"], reverse=True ) components[parent_name] = [] for version in reversed(sorted(version_mapping.keys())): components[parent_name].extend(version_mapping[version]) # Items to present to user. items = [] label = "{} - v{} - {}" for parent_name, entities in components.items(): data = [] for entity in entities: data.append( { "label": label.format( entity["version"]["asset"]["name"], str(entity["version"]["version"]).zfill(3), entity["file_type"][1:] ), "value": entity["id"] } ) items.append( { "label": parent_name, "type": "enumerator", "name": parent_name, "data": data, "value": data[0]["value"] } ) return items def launch(self, session, entities, event): """Callback method for RV action.""" # Launching application if "values" not in event["data"]: return user = session.query( "User where username is '{0}'".format( os.environ["FTRACK_API_USER"] ) ).one() job = session.create( "Job", { "user": user, "status": "running", "data": json.dumps({ "description": "RV: Collecting file paths." }) } ) # Commit to feedback to user. session.commit() paths = [] try: paths = self.get_file_paths(session, event) except Exception: self.log.error(traceback.format_exc()) job["status"] = "failed" else: job["status"] = "done" # Commit to end job. session.commit() args = [os.path.normpath(self.rv_path)] fps = entities[0].get("custom_attributes", {}).get("fps", None) if fps is not None: args.extend(["-fps", str(fps)]) args.extend(paths) self.log.info("Running rv: {}".format(args)) subprocess.Popen(args) return True def get_file_paths(self, session, event): """Get file paths from selected components.""" link = session.get( "Component", list(event["data"]["values"].values())[0] )["version"]["asset"]["parent"]["link"][0] project = session.get(link["type"], link["id"]) project_name = project["full_name"] dbcon = AvalonMongoDB() dbcon.Session["AVALON_PROJECT"] = project_name anatomy = Anatomy(project_name) location = ftrack_api.Session().pick_location() paths = [] for parent_name in sorted(event["data"]["values"].keys()): component = session.get( "Component", event["data"]["values"][parent_name] ) # Newer publishes have the source referenced in Ftrack. online_source = False for neighbour_component in component["version"]["components"]: if neighbour_component["name"] != "ftrackreview-mp4_src": continue paths.append( location.get_filesystem_path(neighbour_component) ) online_source = True if online_source: continue subset_name = component["version"]["asset"]["name"] version_name = component["version"]["version"] representation_name = component["file_type"][1:] asset_doc = get_asset_by_name( project_name, parent_name, fields=["_id"] ) subset_doc = get_subset_by_name( project_name, subset_name=subset_name, asset_id=asset_doc["_id"] ) version_doc = get_version_by_name( project_name, version=version_name, subset_id=subset_doc["_id"] ) repre_doc = get_representation_by_name( project_name, version_id=version_doc["_id"], representation_name=representation_name ) if not repre_doc: repre_doc = get_representation_by_name( project_name, version_id=version_doc["_id"], representation_name="preview" ) paths.append(get_representation_path( repre_doc, root=anatomy.roots, dbcon=dbcon )) return paths def register(session): """Register hooks.""" RVAction(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_seed.py ================================================ import os from operator import itemgetter from openpype_modules.ftrack.lib import BaseAction, statics_icon class SeedDebugProject(BaseAction): '''Edit meta data action.''' #: Action identifier. identifier = "seed.debug.project" #: Action label. label = "Seed Debug Project" #: Action description. description = "Description" #: priority priority = 100 #: roles that are allowed to register this action icon = statics_icon("ftrack", "action_icons", "SeedProject.svg") # Asset names which will be created in `Assets` entity assets = [ "Addax", "Alpaca", "Ant", "Antelope", "Aye", "Badger", "Bear", "Bee", "Beetle", "Bluebird", "Bongo", "Bontebok", "Butterflie", "Caiman", "Capuchin", "Capybara", "Cat", "Caterpillar", "Coyote", "Crocodile", "Cuckoo", "Deer", "Dragonfly", "Duck", "Eagle", "Egret", "Elephant", "Falcon", "Fossa", "Fox", "Gazelle", "Gecko", "Gerbil", "GiantArmadillo", "Gibbon", "Giraffe", "Goose", "Gorilla", "Grasshoper", "Hare", "Hawk", "Hedgehog", "Heron", "Hog", "Hummingbird", "Hyena", "Chameleon", "Cheetah", "Iguana", "Jackal", "Jaguar", "Kingfisher", "Kinglet", "Kite", "Komodo", "Lemur", "Leopard", "Lion", "Lizard", "Macaw", "Malachite", "Mandrill", "Mantis", "Marmoset", "Meadowlark", "Meerkat", "Mockingbird", "Mongoose", "Monkey", "Nyal", "Ocelot", "Okapi", "Oribi", "Oriole", "Otter", "Owl", "Panda", "Parrot", "Pelican", "Pig", "Porcupine", "Reedbuck", "Rhinocero", "Sandpiper", "Servil", "Skink", "Sloth", "Snake", "Spider", "Squirrel", "Sunbird", "Swallow", "Swift", "Tiger", "Sylph", "Tanager", "Vulture", "Warthog", "Waterbuck", "Woodpecker", "Zebra" ] # Tasks which will be created for Assets asset_tasks = [ "Modeling", "Lookdev", "Rigging" ] # Tasks which will be created for Shots shot_tasks = [ "Animation", "Lighting", "Compositing", "FX" ] # Define how much sequences will be created default_seq_count = 5 # Define how much shots will be created for each sequence default_shots_count = 10 max_entities_created_at_one_commit = 50 existing_projects = None new_project_item = "< New Project >" current_project_item = "< Current Project >" settings_key = "seed_project" def discover(self, session, entities, event): ''' Validation ''' if not self.valid_roles(session, entities, event): return False return True def interface(self, session, entities, event): if event["data"].get("values", {}): return title = "Select Project where you want to create seed data" items = [] item_splitter = {"type": "label", "value": "---"} description_label = { "type": "label", "value": ( "WARNING: Action does NOT check if entities already exist !!!" ) } items.append(description_label) all_projects = session.query("select full_name from Project").all() self.existing_projects = [proj["full_name"] for proj in all_projects] projects_items = [ {"label": proj, "value": proj} for proj in self.existing_projects ] data_items = [] data_items.append({ "label": self.new_project_item, "value": self.new_project_item }) data_items.append({ "label": self.current_project_item, "value": self.current_project_item }) data_items.extend(sorted( projects_items, key=itemgetter("label"), reverse=False )) projects_item = { "label": "Choose Project", "type": "enumerator", "name": "project_name", "data": data_items, "value": self.current_project_item } items.append(projects_item) items.append(item_splitter) items.append({ "label": "Number of assets", "type": "number", "name": "asset_count", "value": len(self.assets) }) items.append({ "label": "Number of sequences", "type": "number", "name": "seq_count", "value": self.default_seq_count }) items.append({ "label": "Number of shots", "type": "number", "name": "shots_count", "value": self.default_shots_count }) items.append(item_splitter) note_label = { "type": "label", "value": ( "

NOTE: Enter project name and choose schema if you " "chose `\"< New Project >\"`(code is optional)

" ) } items.append(note_label) items.append({ "label": "Project name", "name": "new_project_name", "type": "text", "value": "" }) project_schemas = [ sch["name"] for sch in self.session.query("ProjectSchema").all() ] schemas_item = { "label": "Choose Schema", "type": "enumerator", "name": "new_schema_name", "data": [ {"label": sch, "value": sch} for sch in project_schemas ], "value": project_schemas[0] } items.append(schemas_item) items.append({ "label": "*Project code", "name": "new_project_code", "type": "text", "value": "", "empty_text": "Optional..." }) return { "items": items, "title": title } def launch(self, session, in_entities, event): if "values" not in event["data"]: return # THIS IS THE PROJECT PART values = event["data"]["values"] selected_project = values["project_name"] if selected_project == self.new_project_item: project_name = values["new_project_name"] if project_name in self.existing_projects: msg = "Project \"{}\" already exist".format(project_name) self.log.error(msg) return {"success": False, "message": msg} project_code = values["new_project_code"] project_schema_name = values["new_schema_name"] if not project_code: project_code = project_name project_code = project_code.lower().replace(" ", "_").strip() _project = session.query( "Project where name is \"{}\"".format(project_code) ).first() if _project: msg = "Project with code \"{}\" already exist".format( project_code ) self.log.error(msg) return {"success": False, "message": msg} project_schema = session.query( "ProjectSchema where name is \"{}\"".format( project_schema_name ) ).one() # Create the project with the chosen schema. self.log.debug(( "*** Creating Project: name <{}>, code <{}>, schema <{}>" ).format(project_name, project_code, project_schema_name)) project = session.create("Project", { "name": project_code, "full_name": project_name, "project_schema": project_schema }) session.commit() elif selected_project == self.current_project_item: entity = in_entities[0] if entity.entity_type.lower() == "project": project = entity else: if "project" in entity: project = entity["project"] else: project = entity["parent"]["project"] project_schema = project["project_schema"] self.log.debug(( "*** Using Project: name <{}>, code <{}>, schema <{}>" ).format( project["full_name"], project["name"], project_schema["name"] )) else: project = session.query("Project where full_name is \"{}\"".format( selected_project )).one() project_schema = project["project_schema"] self.log.debug(( "*** Using Project: name <{}>, code <{}>, schema <{}>" ).format( project["full_name"], project["name"], project_schema["name"] )) # THIS IS THE MAGIC PART task_types = {} for _type in project_schema["_task_type_schema"]["types"]: if _type["name"] not in task_types: task_types[_type["name"]] = _type self.task_types = task_types asset_count = values.get("asset_count") or len(self.assets) seq_count = values.get("seq_count") or self.default_seq_count shots_count = values.get("shots_count") or self.default_shots_count self.create_assets(project, asset_count) self.create_shots(project, seq_count, shots_count) return True def create_assets(self, project, asset_count): self.log.debug("*** Creating assets:") try: asset_count = int(asset_count) except ValueError: asset_count = 0 if asset_count <= 0: self.log.debug("No assets to create") return main_entity = self.session.create("Folder", { "name": "Assets", "parent": project }) self.log.debug("- Assets") available_assets = len(self.assets) repetitive_times = ( int(asset_count / available_assets) + (asset_count % available_assets > 0) ) index = 0 created_entities = 0 to_create_length = asset_count + (asset_count * len(self.asset_tasks)) for _asset_name in self.assets: if created_entities >= to_create_length: break for asset_num in range(1, repetitive_times + 1): if created_entities >= asset_count: break asset_name = "%s_%02d" % (_asset_name, asset_num) asset = self.session.create("AssetBuild", { "name": asset_name, "parent": main_entity }) self.log.debug("- Assets/{}".format(asset_name)) created_entities += 1 index += 1 if self.temp_commit(index, created_entities, to_create_length): index = 0 for task_name in self.asset_tasks: self.session.create("Task", { "name": task_name, "parent": asset, "type": self.task_types[task_name] }) self.log.debug("- Assets/{}/{}".format( asset_name, task_name )) created_entities += 1 index += 1 if self.temp_commit( index, created_entities, to_create_length ): index = 0 self.log.debug("*** Committing Assets") self.log.debug("Committing entities. {}/{}".format( created_entities, to_create_length )) self.session.commit() def create_shots(self, project, seq_count, shots_count): self.log.debug("*** Creating shots:") # Convert counts to integers try: seq_count = int(seq_count) except ValueError: seq_count = 0 try: shots_count = int(shots_count) except ValueError: shots_count = 0 # Check if both are higher than 0 missing = [] if seq_count <= 0: missing.append("sequences") if shots_count <= 0: missing.append("shots") if missing: self.log.debug("No {} to create".format(" and ".join(missing))) return # Create Folder "Shots" main_entity = self.session.create("Folder", { "name": "Shots", "parent": project }) self.log.debug("- Shots") index = 0 created_entities = 0 to_create_length = ( seq_count + (seq_count * shots_count) + (seq_count * shots_count * len(self.shot_tasks)) ) for seq_num in range(1, seq_count + 1): seq_name = "sq%03d" % seq_num seq = self.session.create("Sequence", { "name": seq_name, "parent": main_entity }) self.log.debug("- Shots/{}".format(seq_name)) created_entities += 1 index += 1 if self.temp_commit(index, created_entities, to_create_length): index = 0 for shot_num in range(1, shots_count + 1): shot_name = "%ssh%04d" % (seq_name, (shot_num * 10)) shot = self.session.create("Shot", { "name": shot_name, "parent": seq }) self.log.debug("- Shots/{}/{}".format(seq_name, shot_name)) created_entities += 1 index += 1 if self.temp_commit(index, created_entities, to_create_length): index = 0 for task_name in self.shot_tasks: self.session.create("Task", { "name": task_name, "parent": shot, "type": self.task_types[task_name] }) self.log.debug("- Shots/{}/{}/{}".format( seq_name, shot_name, task_name )) created_entities += 1 index += 1 if self.temp_commit( index, created_entities, to_create_length ): index = 0 self.log.debug("*** Committing Shots") self.log.debug("Committing entities. {}/{}".format( created_entities, to_create_length )) self.session.commit() def temp_commit(self, index, created_entities, to_create_length): if index < self.max_entities_created_at_one_commit: return False self.log.debug("Committing {} entities. {}/{}".format( index, created_entities, to_create_length )) self.session.commit() return True def register(session): '''Register plugin. Called when used as an plugin.''' SeedDebugProject(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_store_thumbnails_to_avalon.py ================================================ import os import errno import json import requests from bson.objectid import ObjectId from openpype.client import ( get_project, get_asset_by_id, get_assets, get_subset_by_name, get_version_by_name, get_representations ) from openpype_modules.ftrack.lib import BaseAction, statics_icon from openpype.pipeline import AvalonMongoDB, Anatomy from openpype_modules.ftrack.lib.avalon_sync import CUST_ATTR_ID_KEY class StoreThumbnailsToAvalon(BaseAction): # Action identifier identifier = "store.thubmnail.to.avalon" # Action label label = "OpenPype Admin" # Action variant variant = "- Store Thumbnails to avalon" # Action description description = 'Test action' # roles that are allowed to register this action icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") settings_key = "store_thubmnail_to_avalon" thumbnail_key = "AVALON_THUMBNAIL_ROOT" def __init__(self, *args, **kwargs): self.db_con = AvalonMongoDB() super(StoreThumbnailsToAvalon, self).__init__(*args, **kwargs) def discover(self, session, entities, event): is_valid = False for entity in entities: if entity.entity_type.lower() == "assetversion": is_valid = True break if is_valid: is_valid = self.valid_roles(session, entities, event) return is_valid def launch(self, session, entities, event): user = session.query( "User where username is '{0}'".format(session.api_user) ).one() action_job = session.create("Job", { "user": user, "status": "running", "data": json.dumps({ "description": "Storing thumbnails to avalon." }) }) session.commit() project = self.get_project_from_entity(entities[0]) project_name = project["full_name"] anatomy = Anatomy(project_name) if "publish" not in anatomy.templates: msg = "Anatomy does not have set publish key!" action_job["status"] = "failed" session.commit() self.log.warning(msg) return { "success": False, "message": msg } if "thumbnail" not in anatomy.templates["publish"]: msg = ( "There is not set \"thumbnail\"" " template in Antomy for project \"{}\"" ).format(project_name) action_job["status"] = "failed" session.commit() self.log.warning(msg) return { "success": False, "message": msg } thumbnail_roots = os.environ.get(self.thumbnail_key) if ( "{thumbnail_root}" in anatomy.templates["publish"]["thumbnail"] and not thumbnail_roots ): msg = "`{}` environment is not set".format(self.thumbnail_key) action_job["status"] = "failed" session.commit() self.log.warning(msg) return { "success": False, "message": msg } existing_thumbnail_root = None for path in thumbnail_roots.split(os.pathsep): if os.path.exists(path): existing_thumbnail_root = path break if existing_thumbnail_root is None: msg = ( "Can't access paths, set in `{}` ({})" ).format(self.thumbnail_key, thumbnail_roots) action_job["status"] = "failed" session.commit() self.log.warning(msg) return { "success": False, "message": msg } example_template_data = { "_id": "ID", "thumbnail_root": "THUBMNAIL_ROOT", "thumbnail_type": "THUMBNAIL_TYPE", "ext": ".EXT", "project": { "name": "PROJECT_NAME", "code": "PROJECT_CODE" }, "asset": "ASSET_NAME", "subset": "SUBSET_NAME", "version": "VERSION_NAME", "hierarchy": "HIERARCHY" } tmp_filled = anatomy.format_all(example_template_data) thumbnail_result = tmp_filled["publish"]["thumbnail"] if not thumbnail_result.solved: missing_keys = thumbnail_result.missing_keys invalid_types = thumbnail_result.invalid_types submsg = "" if missing_keys: submsg += "Missing keys: {}".format(", ".join( ["\"{}\"".format(key) for key in missing_keys] )) if invalid_types: items = [] for key, value in invalid_types.items(): items.append("{}{}".format(str(key), str(value))) submsg += "Invalid types: {}".format(", ".join(items)) msg = ( "Thumbnail Anatomy template expects more keys than action" " can offer. {}" ).format(submsg) action_job["status"] = "failed" session.commit() self.log.warning(msg) return { "success": False, "message": msg } thumbnail_template = anatomy.templates["publish"]["thumbnail"] self.db_con.install() for entity in entities: # Skip if entity is not AssetVersion (should never happen, but..) if entity.entity_type.lower() != "assetversion": continue # Skip if AssetVersion don't have thumbnail thumbnail_ent = entity["thumbnail"] if thumbnail_ent is None: self.log.debug(( "Skipping. AssetVersion don't " "have set thumbnail. {}" ).format(entity["id"])) continue avalon_ents_result = self.get_avalon_entities_for_assetversion( entity, self.db_con ) version_full_path = ( "Asset: \"{project_name}/{asset_path}\"" " | Subset: \"{subset_name}\"" " | Version: \"{version_name}\"" ).format(**avalon_ents_result) version = avalon_ents_result["version"] if not version: self.log.warning(( "AssetVersion does not have version in avalon. {}" ).format(version_full_path)) continue thumbnail_id = version["data"].get("thumbnail_id") if thumbnail_id: self.log.info(( "AssetVersion skipped, already has thubmanil set. {}" ).format(version_full_path)) continue # Get thumbnail extension file_ext = thumbnail_ent["file_type"] if not file_ext.startswith("."): file_ext = ".{}".format(file_ext) avalon_project = avalon_ents_result["project"] avalon_asset = avalon_ents_result["asset"] hierarchy = "" parents = avalon_asset["data"].get("parents") or [] if parents: hierarchy = "/".join(parents) # Prepare anatomy template fill data # 1. Create new id for thumbnail entity thumbnail_id = ObjectId() template_data = { "_id": str(thumbnail_id), "thumbnail_root": existing_thumbnail_root, "thumbnail_type": "thumbnail", "ext": file_ext, "project": { "name": avalon_project["name"], "code": avalon_project["data"].get("code") }, "asset": avalon_ents_result["asset_name"], "subset": avalon_ents_result["subset_name"], "version": avalon_ents_result["version_name"], "hierarchy": hierarchy } anatomy_filled = anatomy.format(template_data) thumbnail_path = anatomy_filled["publish"]["thumbnail"] thumbnail_path = thumbnail_path.replace("..", ".") thumbnail_path = os.path.normpath(thumbnail_path) downloaded = False for loc in (thumbnail_ent.get("component_locations") or []): res_id = loc.get("resource_identifier") if not res_id: continue thubmnail_url = self.get_thumbnail_url(res_id) if self.download_file(thubmnail_url, thumbnail_path): downloaded = True break if not downloaded: self.log.warning( "Could not download thumbnail for {}".format( version_full_path ) ) continue # Clean template data from keys that are dynamic template_data.pop("_id") template_data.pop("thumbnail_root") thumbnail_entity = { "_id": thumbnail_id, "type": "thumbnail", "schema": "openpype:thumbnail-1.0", "data": { "template": thumbnail_template, "template_data": template_data } } # Create thumbnail entity self.db_con.insert_one(thumbnail_entity) self.log.debug( "Creating entity in database {}".format(str(thumbnail_entity)) ) # Set thumbnail id for version self.db_con.update_one( {"_id": version["_id"]}, {"$set": {"data.thumbnail_id": thumbnail_id}} ) self.db_con.update_one( {"_id": avalon_asset["_id"]}, {"$set": {"data.thumbnail_id": thumbnail_id}} ) action_job["status"] = "done" session.commit() return True def get_thumbnail_url(self, resource_identifier, size=None): # TODO use ftrack_api method rather (find way how to use it) url_string = ( u'{url}/component/thumbnail?id={id}&username={username}' u'&apiKey={apiKey}' ) url = url_string.format( url=self.session.server_url, id=resource_identifier, username=self.session.api_user, apiKey=self.session.api_key ) if size: url += u'&size={0}'.format(size) return url def download_file(self, source_url, dst_file_path): dir_path = os.path.dirname(dst_file_path) try: os.makedirs(dir_path) except OSError as exc: if exc.errno != errno.EEXIST: self.log.warning( "Could not create folder: \"{}\"".format(dir_path) ) return False self.log.debug( "Downloading file \"{}\" -> \"{}\"".format( source_url, dst_file_path ) ) file_open = open(dst_file_path, "wb") try: file_open.write(requests.get(source_url).content) except Exception: self.log.warning( "Download of image `{}` failed.".format(source_url) ) return False finally: file_open.close() return True def get_avalon_entities_for_assetversion(self, asset_version, db_con): output = { "success": True, "message": None, "project": None, "project_name": None, "asset": None, "asset_name": None, "asset_path": None, "subset": None, "subset_name": None, "version": None, "version_name": None, "representations": None } db_con.install() ft_asset = asset_version["asset"] subset_name = ft_asset["name"] version = asset_version["version"] parent = ft_asset["parent"] ent_path = "/".join( [ent["name"] for ent in parent["link"]] ) project = self.get_project_from_entity(asset_version) project_name = project["full_name"] output["project_name"] = project_name output["asset_name"] = parent["name"] output["asset_path"] = ent_path output["subset_name"] = subset_name output["version_name"] = version db_con.Session["AVALON_PROJECT"] = project_name avalon_project = get_project(project_name) output["project"] = avalon_project if not avalon_project: output["success"] = False output["message"] = ( "Project not synchronized to avalon `{}`".format(project_name) ) return output asset_ent = None asset_mongo_id = parent["custom_attributes"].get(CUST_ATTR_ID_KEY) if asset_mongo_id: try: asset_ent = get_asset_by_id(project_name, asset_mongo_id) except Exception: pass if not asset_ent: asset_docs = get_assets(project_name, asset_names=[parent["name"]]) for asset_doc in asset_docs: ftrack_id = asset_doc.get("data", {}).get("ftrackId") if ftrack_id == parent["id"]: asset_ent = asset_doc break output["asset"] = asset_ent if not asset_ent: output["success"] = False output["message"] = ( "Not synchronized entity to avalon `{}`".format(ent_path) ) return output subset_ent = get_subset_by_name( project_name, subset_name=subset_name, asset_id=asset_ent["_id"] ) output["subset"] = subset_ent if not subset_ent: output["success"] = False output["message"] = ( "Subset `{}` does not exist under Asset `{}`" ).format(subset_name, ent_path) return output version_ent = get_version_by_name( project_name, version, subset_ent["_id"] ) output["version"] = version_ent if not version_ent: output["success"] = False output["message"] = ( "Version `{}` does not exist under Subset `{}` | Asset `{}`" ).format(version, subset_name, ent_path) return output repre_ents = list(get_representations( project_name, version_ids=[version_ent["_id"]] )) output["representations"] = repre_ents return output def register(session): StoreThumbnailsToAvalon(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_sync_to_avalon.py ================================================ import time import sys import json import ftrack_api from openpype_modules.ftrack.lib import BaseAction, statics_icon from openpype_modules.ftrack.lib.avalon_sync import SyncEntitiesFactory class SyncToAvalonLocal(BaseAction): """ Synchronizing data action - from Ftrack to Avalon DB Stores all information about entity. - Name(string) - Most important information = identifier of entity - Parent(ObjectId) - Avalon Project Id, if entity is not project itself - Data(dictionary): - VisualParent(ObjectId) - Avalon Id of parent asset - Parents(array of string) - All parent names except project - Tasks(array of string) - Tasks on asset - FtrackId(string) - entityType(string) - entity's type on Ftrack * All Custom attributes in group 'Avalon' - custom attributes that start with 'avalon_' are skipped * These information are stored for entities in whole project. Avalon ID of asset is stored to Ftrack - Custom attribute 'avalon_mongo_id'. - action IS NOT creating this Custom attribute if doesn't exist - run 'Create Custom Attributes' action - or do it manually (Not recommended) """ identifier = "sync.to.avalon.local" label = "OpenPype Admin" variant = "- Sync To Avalon (Local)" priority = 200 icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") settings_key = "sync_to_avalon_local" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.entities_factory = SyncEntitiesFactory(self.log, self.session) def discover(self, session, entities, event): """ Validate selection. """ is_valid = False for ent in event["data"]["selection"]: # Ignore entities that are not tasks or projects if ent["entityType"].lower() in ["show", "task"]: is_valid = True break if is_valid: is_valid = self.valid_roles(session, entities, event) return is_valid def launch(self, session, in_entities, event): self.log.debug("{}: Creating job".format(self.label)) user_entity = session.query( "User where id is {}".format(event["source"]["user"]["id"]) ).one() job_entity = session.create("Job", { "user": user_entity, "status": "running", "data": json.dumps({ "description": "Sync to avalon is running..." }) }) session.commit() project_entity = self.get_project_from_entity(in_entities[0]) project_name = project_entity["full_name"] try: result = self.synchronization(event, project_name) except Exception: self.log.error( "Synchronization failed due to code error", exc_info=True ) description = "Sync to avalon Crashed (Download traceback)" self.add_traceback_to_job( job_entity, session, sys.exc_info(), description ) msg = "An error has happened during synchronization" title = "Synchronization report ({}):".format(project_name) items = [] items.append({ "type": "label", "value": "# {}".format(msg) }) items.append({ "type": "label", "value": ( "

Download report from job for more information.

" ) }) report = {} try: report = self.entities_factory.report() except Exception: pass _items = report.get("items") or [] if _items: items.append(self.entities_factory.report_splitter) items.extend(_items) self.show_interface(items, title, event, submit_btn_label="Ok") return {"success": True, "message": msg} job_entity["status"] = "done" job_entity["data"] = json.dumps({ "description": "Sync to avalon finished." }) session.commit() return result def synchronization(self, event, project_name): time_start = time.time() self.show_message(event, "Synchronization - Preparing data", True) try: output = self.entities_factory.launch_setup(project_name) if output is not None: return output time_1 = time.time() self.entities_factory.set_cutom_attributes() time_2 = time.time() # This must happen before all filtering!!! self.entities_factory.prepare_avalon_entities(project_name) time_3 = time.time() self.entities_factory.filter_by_ignore_sync() time_4 = time.time() self.entities_factory.duplicity_regex_check() time_5 = time.time() self.entities_factory.prepare_ftrack_ent_data() time_6 = time.time() self.entities_factory.synchronize() time_7 = time.time() self.log.debug( "*** Synchronization finished ***" ) self.log.debug( "preparation <{}>".format(time_1 - time_start) ) self.log.debug( "set_cutom_attributes <{}>".format(time_2 - time_1) ) self.log.debug( "prepare_avalon_entities <{}>".format(time_3 - time_2) ) self.log.debug( "filter_by_ignore_sync <{}>".format(time_4 - time_3) ) self.log.debug( "duplicity_regex_check <{}>".format(time_5 - time_4) ) self.log.debug( "prepare_ftrack_ent_data <{}>".format(time_6 - time_5) ) self.log.debug( "synchronize <{}>".format(time_7 - time_6) ) self.log.debug( "* Total time: {}".format(time_7 - time_start) ) if self.entities_factory.project_created: event = ftrack_api.event.base.Event( topic="openpype.project.created", data={"project_name": project_name} ) self.session.event_hub.publish(event) report = self.entities_factory.report() if report and report.get("items"): default_title = "Synchronization report ({}):".format( project_name ) self.show_interface( items=report["items"], title=report.get("title", default_title), event=event ) return { "success": True, "message": "Synchronization Finished" } finally: try: self.entities_factory.dbcon.uninstall() except Exception: pass try: self.entities_factory.session.close() except Exception: pass def register(session): '''Register plugin. Called when used as an plugin.''' SyncToAvalonLocal(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_test.py ================================================ from openpype_modules.ftrack.lib import BaseAction, statics_icon class TestAction(BaseAction): """Action for testing purpose or as base for new actions.""" ignore_me = True identifier = 'test.action' label = 'Test action' description = 'Test action' priority = 10000 role_list = ['Pypeclub'] icon = statics_icon("ftrack", "action_icons", "TestAction.svg") def discover(self, session, entities, event): return True def launch(self, session, entities, event): self.log.info(event) return True def register(session): TestAction(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_thumbnail_to_childern.py ================================================ import json from openpype_modules.ftrack.lib import BaseAction, statics_icon class ThumbToChildren(BaseAction): '''Custom action.''' # Action identifier identifier = 'thumb.to.children' # Action label label = 'Thumbnail' # Action variant variant = " to Children" # Action icon icon = statics_icon("ftrack", "action_icons", "Thumbnail.svg") def discover(self, session, entities, event): """Show only on project.""" if (len(entities) != 1 or entities[0].entity_type in ["Project"]): return False return True def launch(self, session, entities, event): '''Callback method for action.''' userId = event['source']['user']['id'] user = session.query('User where id is ' + userId).one() job = session.create('Job', { 'user': user, 'status': 'running', 'data': json.dumps({ 'description': 'Push thumbnails to Childrens' }) }) session.commit() try: for entity in entities: thumbid = entity['thumbnail_id'] if thumbid: for child in entity['children']: child['thumbnail_id'] = thumbid # inform the user that the job is done job['status'] = 'done' except Exception as exc: session.rollback() # fail the job if something goes wrong job['status'] = 'failed' raise exc finally: session.commit() return { 'success': True, 'message': 'Created job for updating thumbnails!' } def register(session): '''Register action. Called when used as an event plugin.''' ThumbToChildren(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_thumbnail_to_parent.py ================================================ import json from openpype_modules.ftrack.lib import BaseAction, statics_icon class ThumbToParent(BaseAction): '''Custom action.''' # Action identifier identifier = 'thumb.to.parent' # Action label label = 'Thumbnail' # Action variant variant = " to Parent" # Action icon icon = statics_icon("ftrack", "action_icons", "Thumbnail.svg") def discover(self, session, entities, event): '''Return action config if triggered on asset versions.''' if len(entities) <= 0 or entities[0].entity_type in ['Project']: return False return True def launch(self, session, entities, event): '''Callback method for action.''' userId = event['source']['user']['id'] user = session.query('User where id is ' + userId).one() job = session.create('Job', { 'user': user, 'status': 'running', 'data': json.dumps({ 'description': 'Push thumbnails to parents' }) }) session.commit() try: for entity in entities: parent = None thumbid = None if entity.entity_type.lower() == 'assetversion': parent = entity['task'] if parent is None: par_ent = entity['link'][-2] parent = session.get(par_ent['type'], par_ent['id']) else: try: parent = entity['parent'] except Exception as e: msg = ( "During Action 'Thumb to Parent'" " went something wrong" ) self.log.error(msg) raise e thumbid = entity['thumbnail_id'] if parent and thumbid: parent['thumbnail_id'] = thumbid status = 'done' else: raise Exception( "Parent or thumbnail id not found. Parent: {}. " "Thumbnail id: {}".format(parent, thumbid) ) # inform the user that the job is done job['status'] = status or 'done' except Exception as exc: session.rollback() # fail the job if something goes wrong job['status'] = 'failed' raise exc finally: session.commit() return { 'success': True, 'message': 'Created job for updating thumbnails!' } def register(session): '''Register action. Called when used as an event plugin.''' ThumbToParent(session).register() ================================================ FILE: openpype/modules/ftrack/event_handlers_user/action_where_run_ask.py ================================================ import platform import socket import getpass from openpype_modules.ftrack.lib import BaseAction from openpype_modules.ftrack.ftrack_server.lib import get_host_ip class ActionWhereIRun(BaseAction): """Show where same user has running OpenPype instances.""" identifier = "ask.where.i.run" show_identifier = "show.where.i.run" label = "OpenPype Admin" variant = "- Where I run" description = "Show PC info where user have running OpenPype" def _discover(self, _event): return { "items": [{ "label": self.label, "variant": self.variant, "description": self.description, "actionIdentifier": self.discover_identifier, "icon": self.icon, }] } def _launch(self, event): self.trigger_action(self.show_identifier, event) def register(self): # Register default action callbacks super(ActionWhereIRun, self).register() # Add show identifier show_subscription = ( "topic=ftrack.action.launch" " and data.actionIdentifier={}" " and source.user.username={}" ).format( self.show_identifier, self.session.api_user ) self.session.event_hub.subscribe( show_subscription, self._show_info ) def _show_info(self, event): title = "Where Do I Run?" msgs = {} all_keys = ["Hostname", "IP", "Username", "System name", "PC name"] try: host_name = socket.gethostname() msgs["Hostname"] = host_name msgs["IP"] = get_host_ip() or "N/A" except Exception: pass try: system_name, pc_name, *_ = platform.uname() msgs["System name"] = system_name msgs["PC name"] = pc_name except Exception: pass try: msgs["Username"] = getpass.getuser() except Exception: pass for key in all_keys: if not msgs.get(key): msgs[key] = "-Undefined-" items = [] first = True separator = {"type": "label", "value": "---"} for key, value in msgs.items(): if first: first = False else: items.append(separator) self.log.debug("{}: {}".format(key, value)) subtitle = {"type": "label", "value": "

{}

".format(key)} items.append(subtitle) message = {"type": "label", "value": "

{}

".format(value)} items.append(message) self.show_interface(items, title, event=event) def register(session): '''Register plugin. Called when used as an plugin.''' ActionWhereIRun(session).register() ================================================ FILE: openpype/modules/ftrack/ftrack_module.py ================================================ import os import json import collections import platform from openpype.modules import ( click_wrap, OpenPypeModule, ITrayModule, IPluginPaths, ISettingsChangeListener ) from openpype.settings import SaveWarningExc from openpype.lib import Logger FTRACK_MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) _URL_NOT_SET = object() class FtrackModule( OpenPypeModule, ITrayModule, IPluginPaths, ISettingsChangeListener ): name = "ftrack" def initialize(self, settings): ftrack_settings = settings[self.name] self.enabled = ftrack_settings["enabled"] self._settings_ftrack_url = ftrack_settings["ftrack_server"] self._ftrack_url = _URL_NOT_SET current_dir = os.path.dirname(os.path.abspath(__file__)) low_platform = platform.system().lower() # Server event handler paths server_event_handlers_paths = [ os.path.join(current_dir, "event_handlers_server") ] settings_server_paths = ftrack_settings["ftrack_events_path"] if isinstance(settings_server_paths, dict): settings_server_paths = settings_server_paths[low_platform] server_event_handlers_paths.extend(settings_server_paths) # User event handler paths user_event_handlers_paths = [ os.path.join(current_dir, "event_handlers_user") ] settings_action_paths = ftrack_settings["ftrack_actions_path"] if isinstance(settings_action_paths, dict): settings_action_paths = settings_action_paths[low_platform] user_event_handlers_paths.extend(settings_action_paths) # Prepare attribute self.server_event_handlers_paths = server_event_handlers_paths self.user_event_handlers_paths = user_event_handlers_paths self.tray_module = None # TimersManager connection self.timers_manager_connector = None self._timers_manager_module = None def get_ftrack_url(self): """Resolved ftrack url. Resolving is trying to fill missing information in url and tried to connect to the server. Returns: Union[str, None]: Final variant of url or None if url could not be reached. """ if self._ftrack_url is _URL_NOT_SET: self._ftrack_url = resolve_ftrack_url( self._settings_ftrack_url, logger=self.log ) return self._ftrack_url ftrack_url = property(get_ftrack_url) @property def settings_ftrack_url(self): """Ftrack url from settings in a format as it is. Returns: str: Ftrack url from settings. """ return self._settings_ftrack_url def get_global_environments(self): """Ftrack's global environments.""" return { "FTRACK_SERVER": self.ftrack_url } def get_plugin_paths(self): """Ftrack plugin paths.""" return { "publish": [os.path.join(FTRACK_MODULE_DIR, "plugins", "publish")] } def get_launch_hook_paths(self): """Implementation for applications launch hooks.""" return os.path.join(FTRACK_MODULE_DIR, "launch_hooks") def modify_application_launch_arguments(self, application, env): if not application.use_python_2: return self.log.info("Adding Ftrack Python 2 packages to PYTHONPATH.") # Prepare vendor dir path python_2_vendor = os.path.join(FTRACK_MODULE_DIR, "python2_vendor") # Add Python 2 modules python_paths = [ # `python-ftrack-api` os.path.join(python_2_vendor, "ftrack-python-api", "source") ] # Load PYTHONPATH from current launch context python_path = env.get("PYTHONPATH") if python_path: python_paths.append(python_path) # Set new PYTHONPATH to launch context environments env["PYTHONPATH"] = os.pathsep.join(python_paths) def connect_with_modules(self, enabled_modules): for module in enabled_modules: if not hasattr(module, "get_ftrack_event_handler_paths"): continue try: paths_by_type = module.get_ftrack_event_handler_paths() except Exception: continue if not isinstance(paths_by_type, dict): continue for key, value in paths_by_type.items(): if not value: continue if key not in ("server", "user"): self.log.warning( "Unknown event handlers key \"{}\" skipping.".format( key ) ) continue if not isinstance(value, (list, tuple, set)): value = [value] if key == "server": self.server_event_handlers_paths.extend(value) elif key == "user": self.user_event_handlers_paths.extend(value) def on_system_settings_save( self, old_value, new_value, changes, new_value_metadata ): """Implementation of ISettingsChangeListener interface.""" if not self.ftrack_url: raise SaveWarningExc(( "Ftrack URL is not set." " Can't propagate changes to Ftrack server." )) ftrack_changes = changes.get("modules", {}).get("ftrack", {}) url_change_msg = None if "ftrack_server" in ftrack_changes: url_change_msg = ( "Ftrack URL was changed." " This change may need to restart OpenPype to take affect." ) try: session = self.create_ftrack_session() except Exception: self.log.warning("Couldn't create ftrack session.", exc_info=True) if url_change_msg: raise SaveWarningExc(url_change_msg) raise SaveWarningExc(( "Saving of attributes to ftrack wasn't successful," " try running Create/Update Avalon Attributes in ftrack." )) from .lib import ( get_openpype_attr, CUST_ATTR_APPLICATIONS, CUST_ATTR_TOOLS, app_definitions_from_app_manager, tool_definitions_from_app_manager ) from openpype.lib import ApplicationManager query_keys = [ "id", "key", "config" ] custom_attributes = get_openpype_attr( session, split_hierarchical=False, query_keys=query_keys ) app_attribute = None tool_attribute = None for custom_attribute in custom_attributes: key = custom_attribute["key"] if key == CUST_ATTR_APPLICATIONS: app_attribute = custom_attribute elif key == CUST_ATTR_TOOLS: tool_attribute = custom_attribute app_manager = ApplicationManager(new_value_metadata) missing_attributes = [] if not app_attribute: missing_attributes.append(CUST_ATTR_APPLICATIONS) else: config = json.loads(app_attribute["config"]) new_data = app_definitions_from_app_manager(app_manager) prepared_data = [] for item in new_data: for key, label in item.items(): prepared_data.append({ "menu": label, "value": key }) config["data"] = json.dumps(prepared_data) app_attribute["config"] = json.dumps(config) if not tool_attribute: missing_attributes.append(CUST_ATTR_TOOLS) else: config = json.loads(tool_attribute["config"]) new_data = tool_definitions_from_app_manager(app_manager) prepared_data = [] for item in new_data: for key, label in item.items(): prepared_data.append({ "menu": label, "value": key }) config["data"] = json.dumps(prepared_data) tool_attribute["config"] = json.dumps(config) session.commit() if missing_attributes: raise SaveWarningExc(( "Couldn't find custom attribute/s ({}) to update." " Try running Create/Update Avalon Attributes in ftrack." ).format(", ".join(missing_attributes))) if url_change_msg: raise SaveWarningExc(url_change_msg) def on_project_settings_save(self, *_args, **_kwargs): """Implementation of ISettingsChangeListener interface.""" # Ignore return def on_project_anatomy_save( self, old_value, new_value, changes, project_name, new_value_metadata ): """Implementation of ISettingsChangeListener interface.""" if not project_name: return new_attr_values = new_value.get("attributes") if not new_attr_values: return import ftrack_api from openpype_modules.ftrack.lib import ( get_openpype_attr, default_custom_attributes_definition, CUST_ATTR_TOOLS, CUST_ATTR_APPLICATIONS, CUST_ATTR_INTENT ) try: session = self.create_ftrack_session() except Exception: self.log.warning("Couldn't create ftrack session.", exc_info=True) raise SaveWarningExc(( "Saving of attributes to ftrack wasn't successful," " try running Create/Update Avalon Attributes in ftrack." )) project_entity = session.query( "Project where full_name is \"{}\"".format(project_name) ).first() if not project_entity: msg = ( "Ftrack project with name \"{}\" was not found in Ftrack." " Can't push attribute changes." ).format(project_name) self.log.warning(msg) raise SaveWarningExc(msg) project_id = project_entity["id"] ca_defs = default_custom_attributes_definition() hierarchical_attrs = ca_defs.get("is_hierarchical") or {} project_attrs = ca_defs.get("show") or {} ca_keys = ( set(hierarchical_attrs.keys()) | set(project_attrs.keys()) | {CUST_ATTR_TOOLS, CUST_ATTR_APPLICATIONS, CUST_ATTR_INTENT} ) cust_attr, hier_attr = get_openpype_attr(session) cust_attr_by_key = {attr["key"]: attr for attr in cust_attr} hier_attrs_by_key = {attr["key"]: attr for attr in hier_attr} failed = {} missing = {} for key, value in new_attr_values.items(): if key not in ca_keys: continue configuration = hier_attrs_by_key.get(key) if not configuration: configuration = cust_attr_by_key.get(key) if not configuration: self.log.warning( "Custom attribute \"{}\" was not found.".format(key) ) missing[key] = value continue # TODO add add permissions check # TODO add value validations # - value type and list items entity_key = collections.OrderedDict([ ("configuration_id", configuration["id"]), ("entity_id", project_id) ]) session.recorded_operations.push( ftrack_api.operation.UpdateEntityOperation( "ContextCustomAttributeValue", entity_key, "value", ftrack_api.symbol.NOT_SET, value ) ) try: session.commit() self.log.debug( "Changed project custom attribute \"{}\" to \"{}\"".format( key, value ) ) except Exception: self.log.warning( "Failed to set \"{}\" to \"{}\"".format(key, value), exc_info=True ) session.rollback() failed[key] = value if not failed and not missing: return error_msg = ( "Values were not updated on Ftrack which may cause issues." " try running Create/Update Avalon Attributes in ftrack " " and resave project settings." ) if missing: error_msg += "\nMissing Custom attributes on Ftrack: {}.".format( ", ".join([ '"{}"'.format(key) for key in missing.keys() ]) ) if failed: joined_failed = ", ".join([ '"{}": "{}"'.format(key, value) for key, value in failed.items() ]) error_msg += "\nFailed to set: {}".format(joined_failed) raise SaveWarningExc(error_msg) def create_ftrack_session(self, **session_kwargs): import ftrack_api if "server_url" not in session_kwargs: session_kwargs["server_url"] = self.ftrack_url api_key = session_kwargs.get("api_key") api_user = session_kwargs.get("api_user") # First look into environments # - both OpenPype tray and ftrack event server should have set them # - ftrack event server may crash when credentials are tried to load # from keyring if not api_key or not api_user: api_key = os.environ.get("FTRACK_API_KEY") api_user = os.environ.get("FTRACK_API_USER") if not api_key or not api_user: from .lib import credentials cred = credentials.get_credentials() api_user = cred.get("username") api_key = cred.get("api_key") session_kwargs["api_user"] = api_user session_kwargs["api_key"] = api_key return ftrack_api.Session(**session_kwargs) def tray_init(self): from .tray import FtrackTrayWrapper self.tray_module = FtrackTrayWrapper(self) # Module is it's own connector to TimersManager self.timers_manager_connector = self def tray_menu(self, parent_menu): return self.tray_module.tray_menu(parent_menu) def tray_start(self): return self.tray_module.validate() def tray_exit(self): self.tray_module.tray_exit() def set_credentials_to_env(self, username, api_key): os.environ["FTRACK_API_USER"] = username or "" os.environ["FTRACK_API_KEY"] = api_key or "" # --- TimersManager connection methods --- def start_timer(self, data): if self.tray_module: self.tray_module.start_timer_manager(data) def stop_timer(self): if self.tray_module: self.tray_module.stop_timer_manager() def register_timers_manager(self, timer_manager_module): self._timers_manager_module = timer_manager_module def timer_started(self, data): if self._timers_manager_module is not None: self._timers_manager_module.timer_started(self.id, data) def timer_stopped(self): if self._timers_manager_module is not None: self._timers_manager_module.timer_stopped(self.id) def get_task_time(self, project_name, asset_name, task_name): session = self.create_ftrack_session() query = ( 'Task where name is "{}"' ' and parent.name is "{}"' ' and project.full_name is "{}"' ).format(task_name, asset_name, project_name) task_entity = session.query(query).first() if not task_entity: return 0 hours_logged = (task_entity["time_logged"] / 60) / 60 return hours_logged def get_credentials(self): # type: () -> tuple """Get local Ftrack credentials.""" from .lib import credentials cred = credentials.get_credentials(self.ftrack_url) return cred.get("username"), cred.get("api_key") def cli(self, click_group): click_group.add_command(cli_main.to_click_obj()) def _check_ftrack_url(url): import requests try: result = requests.get(url, allow_redirects=False) except requests.exceptions.RequestException: return False if (result.status_code != 200 or "FTRACK_VERSION" not in result.headers): return False return True def resolve_ftrack_url(url, logger=None): """Checks if Ftrack server is responding.""" if logger is None: logger = Logger.get_logger(__name__) url = url.strip("/ ") if not url: logger.error("Ftrack URL is not set!") return None if not url.startswith("http"): url = "https://" + url ftrack_url = None if url and _check_ftrack_url(url): ftrack_url = url if not ftrack_url and not url.endswith("ftrackapp.com"): ftrackapp_url = url + ".ftrackapp.com" if _check_ftrack_url(ftrackapp_url): ftrack_url = ftrackapp_url if not ftrack_url and _check_ftrack_url(url): ftrack_url = url if ftrack_url: logger.debug("Ftrack server \"{}\" is accessible.".format(ftrack_url)) else: logger.error("Ftrack server \"{}\" is not accessible!".format(url)) return ftrack_url @click_wrap.group(FtrackModule.name, help="Ftrack module related commands.") def cli_main(): pass @cli_main.command() @click_wrap.option("-d", "--debug", is_flag=True, help="Print debug messages") @click_wrap.option("--ftrack-url", envvar="FTRACK_SERVER", help="Ftrack server url") @click_wrap.option("--ftrack-user", envvar="FTRACK_API_USER", help="Ftrack api user") @click_wrap.option("--ftrack-api-key", envvar="FTRACK_API_KEY", help="Ftrack api key") @click_wrap.option("--legacy", is_flag=True, help="run event server without mongo storing") @click_wrap.option("--clockify-api-key", envvar="CLOCKIFY_API_KEY", help="Clockify API key.") @click_wrap.option("--clockify-workspace", envvar="CLOCKIFY_WORKSPACE", help="Clockify workspace") def eventserver( debug, ftrack_url, ftrack_user, ftrack_api_key, legacy, clockify_api_key, clockify_workspace ): """Launch ftrack event server. This should be ideally used by system service (such us systemd or upstart on linux and window service). """ if debug: os.environ["OPENPYPE_DEBUG"] = "3" from .ftrack_server.event_server_cli import run_event_server return run_event_server( ftrack_url, ftrack_user, ftrack_api_key, legacy, clockify_api_key, clockify_workspace ) ================================================ FILE: openpype/modules/ftrack/ftrack_server/__init__.py ================================================ from .ftrack_server import FtrackServer __all__ = ( "FtrackServer", ) ================================================ FILE: openpype/modules/ftrack/ftrack_server/event_server_cli.py ================================================ import os import signal import datetime import subprocess import socket import json import getpass import atexit import time import uuid import ftrack_api import pymongo from openpype.client.mongo import ( OpenPypeMongoConnection, validate_mongo_connection, ) from openpype.lib import ( get_openpype_execute_args, get_openpype_version, get_build_version, ) from openpype_modules.ftrack import ( FTRACK_MODULE_DIR, resolve_ftrack_url, ) from openpype_modules.ftrack.lib import credentials from openpype_modules.ftrack.ftrack_server import socket_thread from openpype_modules.ftrack.ftrack_server.lib import get_host_ip class MongoPermissionsError(Exception): """Is used when is created multiple objects of same RestApi class.""" def __init__(self, message=None): if not message: message = "Exiting because have issue with access to MongoDB" super().__init__(message) def check_mongo_url(mongo_uri, log_error=False): """Checks if mongo server is responding""" try: validate_mongo_connection(mongo_uri) except pymongo.errors.InvalidURI as err: if log_error: print("Can't connect to MongoDB at {} because: {}".format( mongo_uri, err )) return False except pymongo.errors.ServerSelectionTimeoutError as err: if log_error: print("Can't connect to MongoDB at {} because: {}".format( mongo_uri, err )) return False return True def validate_credentials(url, user, api): first_validation = True if not user: print('- Ftrack Username is not set') first_validation = False if not api: print('- Ftrack API key is not set') first_validation = False if not first_validation: return False try: session = ftrack_api.Session( server_url=url, api_user=user, api_key=api ) session.close() except Exception as e: print("Can't log into Ftrack with used credentials:") ftrack_cred = { "Ftrack server": str(url), "Username": str(user), "API key": str(api) } item_lens = [len(key) + 1 for key in ftrack_cred.keys()] justify_len = max(*item_lens) for key, value in ftrack_cred.items(): print("{} {}".format( (key + ":").ljust(justify_len, " "), value )) return False print('DEBUG: Credentials Username: "{}", API key: "{}" are valid.'.format( user, api )) return True def legacy_server(ftrack_url): # Current file scripts_dir = os.path.join(FTRACK_MODULE_DIR, "scripts") min_fail_seconds = 5 max_fail_count = 3 wait_time_after_max_fail = 10 subproc = None subproc_path = "{}/sub_legacy_server.py".format(scripts_dir) subproc_last_failed = datetime.datetime.now() subproc_failed_count = 0 ftrack_accessible = False printed_ftrack_error = False while True: if not ftrack_accessible: ftrack_accessible = resolve_ftrack_url(ftrack_url) # Run threads only if Ftrack is accessible if not ftrack_accessible and not printed_ftrack_error: print("Can't access Ftrack {} <{}>".format( ftrack_url, str(datetime.datetime.now()) )) if subproc is not None: if subproc.poll() is None: subproc.terminate() subproc = None printed_ftrack_error = True time.sleep(1) continue printed_ftrack_error = False if subproc is None: if subproc_failed_count < max_fail_count: args = get_openpype_execute_args("run", subproc_path) subproc = subprocess.Popen( args, stdout=subprocess.PIPE ) elif subproc_failed_count == max_fail_count: print(( "Storer failed {}times I'll try to run again {}s later" ).format(str(max_fail_count), str(wait_time_after_max_fail))) subproc_failed_count += 1 elif (( datetime.datetime.now() - subproc_last_failed ).seconds > wait_time_after_max_fail): subproc_failed_count = 0 # If thread failed test Ftrack and Mongo connection elif subproc.poll() is not None: subproc = None ftrack_accessible = False _subproc_last_failed = datetime.datetime.now() delta_time = (_subproc_last_failed - subproc_last_failed).seconds if delta_time < min_fail_seconds: subproc_failed_count += 1 else: subproc_failed_count = 0 subproc_last_failed = _subproc_last_failed time.sleep(1) def main_loop(ftrack_url): """ This is main loop of event handling. Loop is handling threads which handles subprocesses of event storer and processor. When one of threads is stopped it is tested to connect to ftrack and mongo server. Threads are not started when ftrack or mongo server is not accessible. When threads are started it is checked for socket signals as heartbeat. Heartbeat must become at least once per 30sec otherwise thread will be killed. """ os.environ["FTRACK_EVENT_SUB_ID"] = str(uuid.uuid1()) mongo_uri = OpenPypeMongoConnection.get_default_mongo_url() # Current file scripts_dir = os.path.join(FTRACK_MODULE_DIR, "scripts") min_fail_seconds = 5 max_fail_count = 3 wait_time_after_max_fail = 10 # Threads data storer_name = "StorerThread" storer_port = 10001 storer_path = "{}/sub_event_storer.py".format(scripts_dir) storer_thread = None storer_last_failed = datetime.datetime.now() storer_failed_count = 0 processor_name = "ProcessorThread" processor_port = 10011 processor_path = "{}/sub_event_processor.py".format(scripts_dir) processor_thread = None processor_last_failed = datetime.datetime.now() processor_failed_count = 0 statuser_name = "StorerThread" statuser_port = 10021 statuser_path = "{}/sub_event_status.py".format(scripts_dir) statuser_thread = None statuser_last_failed = datetime.datetime.now() statuser_failed_count = 0 ftrack_accessible = False mongo_accessible = False printed_ftrack_error = False printed_mongo_error = False # stop threads on exit # TODO check if works and args have thread objects! def on_exit(processor_thread, storer_thread, statuser_thread): if processor_thread is not None: processor_thread.stop() processor_thread.join() processor_thread = None if storer_thread is not None: storer_thread.stop() storer_thread.join() storer_thread = None if statuser_thread is not None: statuser_thread.stop() statuser_thread.join() statuser_thread = None atexit.register( on_exit, processor_thread=processor_thread, storer_thread=storer_thread, statuser_thread=statuser_thread ) host_name = socket.gethostname() host_ip = get_host_ip() main_info = [ ["created_at", datetime.datetime.now().strftime("%Y.%m.%d %H:%M:%S")], ["Username", getpass.getuser()], ["Host Name", host_name], ["Host IP", host_ip or "N/A"], ["OpenPype executable", get_openpype_execute_args()[-1]], ["OpenPype version", get_openpype_version() or "N/A"], ["OpenPype build version", get_build_version() or "N/A"] ] main_info_str = json.dumps(main_info) # Main loop while True: # Check if accessible Ftrack and Mongo url if not ftrack_accessible: ftrack_accessible = resolve_ftrack_url(ftrack_url) if not mongo_accessible: mongo_accessible = check_mongo_url(mongo_uri) # Run threads only if Ftrack is accessible if not ftrack_accessible or not mongo_accessible: if not mongo_accessible and not printed_mongo_error: print("Can't access Mongo {}".format(mongo_uri)) if not ftrack_accessible and not printed_ftrack_error: print("Can't access Ftrack {}".format(ftrack_url)) if storer_thread is not None: storer_thread.stop() storer_thread.join() storer_thread = None if processor_thread is not None: processor_thread.stop() processor_thread.join() processor_thread = None printed_ftrack_error = True printed_mongo_error = True time.sleep(1) continue printed_ftrack_error = False printed_mongo_error = False # ====== STATUSER ======= if statuser_thread is None: if statuser_failed_count < max_fail_count: statuser_thread = socket_thread.StatusSocketThread( statuser_name, statuser_port, statuser_path, [main_info_str] ) statuser_thread.start() elif statuser_failed_count == max_fail_count: print(( "Statuser failed {}times in row" " I'll try to run again {}s later" ).format(str(max_fail_count), str(wait_time_after_max_fail))) statuser_failed_count += 1 elif (( datetime.datetime.now() - statuser_last_failed ).seconds > wait_time_after_max_fail): statuser_failed_count = 0 # If thread failed test Ftrack and Mongo connection elif not statuser_thread.is_alive(): statuser_thread.join() statuser_thread = None ftrack_accessible = False mongo_accessible = False _processor_last_failed = datetime.datetime.now() delta_time = ( _processor_last_failed - statuser_last_failed ).seconds if delta_time < min_fail_seconds: statuser_failed_count += 1 else: statuser_failed_count = 0 statuser_last_failed = _processor_last_failed elif statuser_thread.stop_subprocess: print("Main process was stopped by action") on_exit(processor_thread, storer_thread, statuser_thread) os.kill(os.getpid(), signal.SIGTERM) return 1 # ====== STORER ======= # Run backup thread which does not require mongo to work if storer_thread is None: if storer_failed_count < max_fail_count: storer_thread = socket_thread.SocketThread( storer_name, storer_port, storer_path ) storer_thread.start() elif storer_failed_count == max_fail_count: print(( "Storer failed {}times I'll try to run again {}s later" ).format(str(max_fail_count), str(wait_time_after_max_fail))) storer_failed_count += 1 elif (( datetime.datetime.now() - storer_last_failed ).seconds > wait_time_after_max_fail): storer_failed_count = 0 # If thread failed test Ftrack and Mongo connection elif not storer_thread.is_alive(): if storer_thread.mongo_error: raise MongoPermissionsError() storer_thread.join() storer_thread = None ftrack_accessible = False mongo_accessible = False _storer_last_failed = datetime.datetime.now() delta_time = (_storer_last_failed - storer_last_failed).seconds if delta_time < min_fail_seconds: storer_failed_count += 1 else: storer_failed_count = 0 storer_last_failed = _storer_last_failed # ====== PROCESSOR ======= if processor_thread is None: if processor_failed_count < max_fail_count: processor_thread = socket_thread.SocketThread( processor_name, processor_port, processor_path ) processor_thread.start() elif processor_failed_count == max_fail_count: print(( "Processor failed {}times in row" " I'll try to run again {}s later" ).format(str(max_fail_count), str(wait_time_after_max_fail))) processor_failed_count += 1 elif (( datetime.datetime.now() - processor_last_failed ).seconds > wait_time_after_max_fail): processor_failed_count = 0 # If thread failed test Ftrack and Mongo connection elif not processor_thread.is_alive(): if processor_thread.mongo_error: raise Exception( "Exiting because have issue with access to MongoDB" ) processor_thread.join() processor_thread = None ftrack_accessible = False mongo_accessible = False _processor_last_failed = datetime.datetime.now() delta_time = ( _processor_last_failed - processor_last_failed ).seconds if delta_time < min_fail_seconds: processor_failed_count += 1 else: processor_failed_count = 0 processor_last_failed = _processor_last_failed if statuser_thread is not None: statuser_thread.set_process("storer", storer_thread) statuser_thread.set_process("processor", processor_thread) time.sleep(1) def run_event_server( ftrack_url, ftrack_user, ftrack_api_key, legacy, clockify_api_key, clockify_workspace ): if not ftrack_user or not ftrack_api_key: print(( "Ftrack user/api key were not passed." " Trying to use credentials from user keyring." )) cred = credentials.get_credentials(ftrack_url) ftrack_user = cred.get("username") ftrack_api_key = cred.get("api_key") if clockify_workspace and clockify_api_key: os.environ["CLOCKIFY_WORKSPACE"] = clockify_workspace os.environ["CLOCKIFY_API_KEY"] = clockify_api_key # Check url regex and accessibility ftrack_url = resolve_ftrack_url(ftrack_url) if not ftrack_url: print('Exiting! < Please enter Ftrack server url >') return 1 # Validate entered credentials if not validate_credentials(ftrack_url, ftrack_user, ftrack_api_key): print('Exiting! < Please enter valid credentials >') return 1 # Set Ftrack environments os.environ["FTRACK_SERVER"] = ftrack_url os.environ["FTRACK_API_USER"] = ftrack_user os.environ["FTRACK_API_KEY"] = ftrack_api_key if legacy: return legacy_server(ftrack_url) return main_loop(ftrack_url) ================================================ FILE: openpype/modules/ftrack/ftrack_server/ftrack_server.py ================================================ import os import time import types import logging import traceback import ftrack_api from openpype.lib import ( Logger, modules_from_path ) """ # Required - Needed for connection to Ftrack FTRACK_SERVER # Ftrack server e.g. "https://myFtrack.ftrackapp.com" FTRACK_API_KEY # Ftrack user's API key "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" FTRACK_API_USER # Ftrack username e.g. "user.name" # Required - Paths to folder with actions FTRACK_ACTIONS_PATH # Paths to folders where are located actions - EXAMPLE: "M:/FtrackApi/../actions/" FTRACK_EVENTS_PATH # Paths to folders where are located actions - EXAMPLE: "M:/FtrackApi/../events/" # Required - Needed for import included modules PYTHONPATH # Path to ftrack_api and paths to all modules used in actions - path to ftrack_action_handler, etc. """ class FtrackServer: def __init__(self, handler_paths=None): """ - 'type' is by default set to 'action' - Runs Action server - enter 'event' for Event server EXAMPLE FOR EVENT SERVER: ... server = FtrackServer() server.run_server() .. """ # set Ftrack logging to Warning only - OPTIONAL ftrack_log = logging.getLogger("ftrack_api") ftrack_log.setLevel(logging.WARNING) self.log = Logger.get_logger(__name__) self.stopped = True self.is_running = False self.handler_paths = handler_paths or [] def stop_session(self): self.stopped = True if self.session.event_hub.connected is True: self.session.event_hub.disconnect() self.session.close() self.session = None def set_files(self, paths): # Iterate all paths register_functions = [] for path in paths: # Try to format path with environments try: path = path.format(**os.environ) except BaseException: pass # Get all modules with functions modules, crashed = modules_from_path(path) for filepath, exc_info in crashed: self.log.warning("Filepath load crashed {}.\n{}".format( filepath, traceback.format_exception(*exc_info) )) for filepath, module in modules: register_function = None for name, attr in module.__dict__.items(): if ( name == "register" and isinstance(attr, types.FunctionType) ): register_function = attr break if not register_function: self.log.warning( "\"{}\" - Missing register method".format(filepath) ) continue register_functions.append( (filepath, register_function) ) if not register_functions: self.log.warning(( "There are no events with `register` function" " in registered paths: \"{}\"" ).format("| ".join(paths))) for filepath, register_func in register_functions: try: register_func(self.session) except Exception: self.log.warning( "\"{}\" - register was not successful".format(filepath), exc_info=True ) def set_handler_paths(self, paths): self.handler_paths = paths if self.is_running: self.stop_session() self.run_server() elif not self.stopped: self.run_server() def run_server(self, session=None, load_files=True): self.stopped = False self.is_running = True if not session: session = ftrack_api.Session(auto_connect_event_hub=True) # Wait until session has connected event hub if session._auto_connect_event_hub_thread: # Use timeout from session (since ftrack-api 2.1.0) timeout = getattr(session, "request_timeout", 60) started = time.time() while not session.event_hub.connected: if (time.time() - started) > timeout: raise RuntimeError(( "Connection to Ftrack was not created in {} seconds" ).format(timeout)) time.sleep(0.1) self.session = session if load_files: if not self.handler_paths: self.log.warning(( "Paths to event handlers are not set." " Ftrack server won't launch." )) self.is_running = False return self.set_files(self.handler_paths) msg = "Registration of event handlers has finished!" self.log.info(len(msg) * "*") self.log.info(msg) # keep event_hub on session running self.session.event_hub.wait() self.is_running = False ================================================ FILE: openpype/modules/ftrack/ftrack_server/lib.py ================================================ import os import sys import logging import getpass import atexit import threading import datetime import time import queue import collections import appdirs import socket import pymongo import requests import ftrack_api import ftrack_api.session import ftrack_api.cache import ftrack_api.operation import ftrack_api._centralized_storage_scenario import ftrack_api.event from ftrack_api.logging import LazyLogMessage as L try: from weakref import WeakMethod except ImportError: from ftrack_api._weakref import WeakMethod from openpype_modules.ftrack.lib import get_ftrack_event_mongo_info from openpype.client import OpenPypeMongoConnection from openpype.lib import Logger TOPIC_STATUS_SERVER = "openpype.event.server.status" TOPIC_STATUS_SERVER_RESULT = "openpype.event.server.status.result" def get_host_ip(): host_name = socket.gethostname() try: return socket.gethostbyname(host_name) except Exception: pass return None class SocketBaseEventHub(ftrack_api.event.hub.EventHub): hearbeat_msg = b"hearbeat" heartbeat_callbacks = [] def __init__(self, *args, **kwargs): self.sock = kwargs.pop("sock") super(SocketBaseEventHub, self).__init__(*args, **kwargs) def _handle_packet(self, code, packet_identifier, path, data): """Override `_handle_packet` which extend heartbeat""" code_name = self._code_name_mapping[code] if code_name == "heartbeat": # Reply with heartbeat. for callback in self.heartbeat_callbacks: callback() self.sock.sendall(self.hearbeat_msg) return self._send_packet(self._code_name_mapping["heartbeat"]) return super(SocketBaseEventHub, self)._handle_packet( code, packet_identifier, path, data ) class StatusEventHub(SocketBaseEventHub): def _handle_packet(self, code, packet_identifier, path, data): """Override `_handle_packet` which extend heartbeat""" code_name = self._code_name_mapping[code] if code_name == "connect": event = ftrack_api.event.base.Event( topic="openpype.status.started", data={}, source={ "id": self.id, "user": {"username": self._api_user} } ) self._event_queue.put(event) return super(StatusEventHub, self)._handle_packet( code, packet_identifier, path, data ) class StorerEventHub(SocketBaseEventHub): hearbeat_msg = b"storer" def _handle_packet(self, code, packet_identifier, path, data): """Override `_handle_packet` which extend heartbeat""" code_name = self._code_name_mapping[code] if code_name == "connect": event = ftrack_api.event.base.Event( topic="openpype.storer.started", data={}, source={ "id": self.id, "user": {"username": self._api_user} } ) self._event_queue.put(event) return super(StorerEventHub, self)._handle_packet( code, packet_identifier, path, data ) class ProcessEventHub(SocketBaseEventHub): hearbeat_msg = b"processor" is_collection_created = False pypelog = Logger.get_logger("Session Processor") def __init__(self, *args, **kwargs): self.mongo_url = None self.dbcon = None super(ProcessEventHub, self).__init__(*args, **kwargs) def prepare_dbcon(self): try: database_name, collection_name = get_ftrack_event_mongo_info() mongo_client = OpenPypeMongoConnection.get_mongo_client() self.dbcon = mongo_client[database_name][collection_name] self.mongo_client = mongo_client except pymongo.errors.AutoReconnect: self.pypelog.error(( "Mongo server \"{}\" is not responding, exiting." ).format(OpenPypeMongoConnection.get_default_mongo_url())) sys.exit(0) except pymongo.errors.OperationFailure: self.pypelog.error(( "Error with Mongo access, probably permissions." "Check if exist database with name \"{}\"" " and collection \"{}\" inside." ).format(self.database, self.collection_name)) self.sock.sendall(b"MongoError") sys.exit(0) def wait(self, duration=None): """Overridden wait Event are loaded from Mongo DB when queue is empty. Handled event is set as processed in Mongo DB. """ started = time.time() self.prepare_dbcon() while True: try: event = self._event_queue.get(timeout=0.1) except queue.Empty: if not self.load_events(): time.sleep(0.5) else: try: self._handle(event) mongo_id = event["data"].get("_event_mongo_id") if mongo_id is None: continue self.dbcon.update_one( {"_id": mongo_id}, {"$set": {"pype_data.is_processed": True}} ) except pymongo.errors.AutoReconnect: self.pypelog.error(( "Mongo server \"{}\" is not responding, exiting." ).format(os.environ["OPENPYPE_MONGO"])) sys.exit(0) # Additional special processing of events. if event['topic'] == 'ftrack.meta.disconnected': break if duration is not None: if (time.time() - started) > duration: break def load_events(self): """Load not processed events sorted by stored date""" ago_date = datetime.datetime.now() - datetime.timedelta(days=3) self.dbcon.delete_many({ "pype_data.stored": {"$lte": ago_date}, "pype_data.is_processed": True }) not_processed_events = self.dbcon.find( {"pype_data.is_processed": False} ).sort( [("pype_data.stored", pymongo.ASCENDING)] ).limit(100) found = False for event_data in not_processed_events: new_event_data = { k: v for k, v in event_data.items() if k not in ["_id", "pype_data"] } try: event = ftrack_api.event.base.Event(**new_event_data) event["data"]["_event_mongo_id"] = event_data["_id"] except Exception: self.logger.exception(L( 'Failed to convert payload into event: {0}', event_data )) continue found = True self._event_queue.put(event) return found def _handle_packet(self, code, packet_identifier, path, data): """Override `_handle_packet` which skip events and extend heartbeat""" code_name = self._code_name_mapping[code] if code_name == "event": return return super()._handle_packet(code, packet_identifier, path, data) class CustomEventHubSession(ftrack_api.session.Session): '''An isolated session for interaction with an ftrack server.''' def __init__( self, server_url=None, api_key=None, api_user=None, auto_populate=True, plugin_paths=None, cache=None, cache_key_maker=None, auto_connect_event_hub=False, schema_cache_path=None, plugin_arguments=None, timeout=60, **kwargs ): self.kwargs = kwargs super(ftrack_api.session.Session, self).__init__() self.logger = logging.getLogger( __name__ + '.' + self.__class__.__name__ ) self._closed = False if server_url is None: server_url = os.environ.get('FTRACK_SERVER') if not server_url: raise TypeError( 'Required "server_url" not specified. Pass as argument or set ' 'in environment variable FTRACK_SERVER.' ) self._server_url = server_url if api_key is None: api_key = os.environ.get( 'FTRACK_API_KEY', # Backwards compatibility os.environ.get('FTRACK_APIKEY') ) if not api_key: raise TypeError( 'Required "api_key" not specified. Pass as argument or set in ' 'environment variable FTRACK_API_KEY.' ) self._api_key = api_key if api_user is None: api_user = os.environ.get('FTRACK_API_USER') if not api_user: try: api_user = getpass.getuser() except Exception: pass if not api_user: raise TypeError( 'Required "api_user" not specified. Pass as argument, set in ' 'environment variable FTRACK_API_USER or one of the standard ' 'environment variables used by Python\'s getpass module.' ) self._api_user = api_user # Currently pending operations. self.recorded_operations = ftrack_api.operation.Operations() # OpenPype change - In new API are operations properties new_api = hasattr(self.__class__, "record_operations") if new_api: self._record_operations = collections.defaultdict( lambda: True ) self._auto_populate = collections.defaultdict( lambda: auto_populate ) else: self.record_operations = True self.auto_populate = auto_populate self.cache_key_maker = cache_key_maker if self.cache_key_maker is None: self.cache_key_maker = ftrack_api.cache.StringKeyMaker() # Enforce always having a memory cache at top level so that the same # in-memory instance is returned from session. self.cache = ftrack_api.cache.LayeredCache([ ftrack_api.cache.MemoryCache() ]) if cache is not None: if callable(cache): cache = cache(self) if cache is not None: self.cache.caches.append(cache) if new_api: self.merge_lock = threading.RLock() self._managed_request = None self._request = requests.Session() self._request.auth = ftrack_api.session.SessionAuthentication( self._api_key, self._api_user ) self.request_timeout = timeout # Fetch server information and in doing so also check credentials. self._server_information = self._fetch_server_information() # Now check compatibility of server based on retrieved information. self.check_server_compatibility() # Construct event hub and load plugins. self._event_hub = self._create_event_hub() self._auto_connect_event_hub_thread = None if auto_connect_event_hub: # Connect to event hub in background thread so as not to block main # session usage waiting for event hub connection. self._auto_connect_event_hub_thread = threading.Thread( target=self._event_hub.connect ) self._auto_connect_event_hub_thread.daemon = True self._auto_connect_event_hub_thread.start() # Register to auto-close session on exit. atexit.register(WeakMethod(self.close)) self._plugin_paths = plugin_paths if self._plugin_paths is None: self._plugin_paths = os.environ.get( 'FTRACK_EVENT_PLUGIN_PATH', '' ).split(os.pathsep) self._discover_plugins(plugin_arguments=plugin_arguments) # TODO: Make schemas read-only and non-mutable (or at least without # rebuilding types)? if schema_cache_path is not False: if schema_cache_path is None: schema_cache_path = appdirs.user_cache_dir() schema_cache_path = os.environ.get( 'FTRACK_API_SCHEMA_CACHE_PATH', schema_cache_path ) schema_cache_path = os.path.join( schema_cache_path, 'ftrack_api_schema_cache.json' ) self.schemas = self._load_schemas(schema_cache_path) self.types = self._build_entity_type_classes(self.schemas) ftrack_api._centralized_storage_scenario.register(self) self._configure_locations() self.event_hub.publish( ftrack_api.event.base.Event( topic='ftrack.api.session.ready', data=dict( session=self ) ), synchronous=True ) def _create_event_hub(self): return ftrack_api.event.hub.EventHub( self._server_url, self._api_user, self._api_key ) class SocketSession(CustomEventHubSession): def _create_event_hub(self): self.sock = self.kwargs["sock"] return self.kwargs["Eventhub"]( self._server_url, self._api_user, self._api_key, sock=self.sock ) ================================================ FILE: openpype/modules/ftrack/ftrack_server/socket_thread.py ================================================ import os import sys import time import socket import threading import traceback import subprocess from openpype.lib import get_openpype_execute_args, Logger class SocketThread(threading.Thread): """Thread that checks suprocess of storer of processor of events""" MAX_TIMEOUT = int(os.environ.get("OPENPYPE_FTRACK_SOCKET_TIMEOUT", 45)) def __init__(self, name, port, filepath, additional_args=[]): super(SocketThread, self).__init__() self.log = Logger.get_logger(self.__class__.__name__) self.setName(name) self.name = name self.port = port self.filepath = filepath self.additional_args = additional_args self.sock = None self.subproc = None self.connection = None self._is_running = False self.finished = False self.mongo_error = False self._temp_data = {} def stop(self): self._is_running = False def run(self): self._is_running = True time_socket = time.time() # Create a TCP/IP socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock = sock # Bind the socket to the port - skip already used ports while True: try: server_address = ("localhost", self.port) sock.bind(server_address) break except OSError: self.port += 1 self.log.debug( "Running Socked thread on {}:{}".format(*server_address) ) env = os.environ.copy() env["OPENPYPE_PROCESS_MONGO_ID"] = str(Logger.mongo_process_id) # OpenPype executable (with path to start script if not build) args = get_openpype_execute_args( # Add `run` command "run", self.filepath, *self.additional_args, str(self.port) ) kwargs = { "env": env, "stdin": subprocess.PIPE } if not sys.stdout: # Redirect to devnull if stdout is None kwargs["stdout"] = subprocess.DEVNULL kwargs["stderr"] = subprocess.DEVNULL self.subproc = subprocess.Popen(args, **kwargs) # Listen for incoming connections sock.listen(1) sock.settimeout(1.0) while True: if not self._is_running: break try: connection, client_address = sock.accept() time_socket = time.time() connection.settimeout(1.0) self.connection = connection except socket.timeout: if (time.time() - time_socket) > self.MAX_TIMEOUT: self.log.error("Connection timeout passed. Terminating.") self._is_running = False self.subproc.terminate() break continue try: time_con = time.time() # Receive the data in small chunks and retransmit it while True: try: if not self._is_running: break data = None try: data = self.get_data_from_con(connection) time_con = time.time() except socket.timeout: if (time.time() - time_con) > self.MAX_TIMEOUT: self.log.error( "Connection timeout passed. Terminating." ) self._is_running = False self.subproc.terminate() break continue except ConnectionResetError: self._is_running = False break self._handle_data(connection, data) except Exception as exc: self.log.error( "Event server process failed", exc_info=True ) finally: # Clean up the connection connection.close() if self.subproc.poll() is None: self.subproc.terminate() self.finished = True def get_data_from_con(self, connection): return connection.recv(16) def _handle_data(self, connection, data): if not data: return if data == b"MongoError": self.mongo_error = True connection.sendall(data) class StatusSocketThread(SocketThread): process_name_mapping = { b"RestartS": "storer", b"RestartP": "processor", b"RestartM": "main" } def __init__(self, *args, **kwargs): self.process_threads = {} self.stop_subprocess = False super(StatusSocketThread, self).__init__(*args, **kwargs) def set_process(self, process_name, thread): try: if not self.subproc: self.process_threads[process_name] = None return if ( process_name in self.process_threads and self.process_threads[process_name] == thread ): return self.process_threads[process_name] = thread self.subproc.stdin.write( str.encode("reset:{}\r\n".format(process_name)) ) self.subproc.stdin.flush() except Exception: print("Could not set thread in StatusSocketThread") traceback.print_exception(*sys.exc_info()) def _handle_data(self, connection, data): if not data: return process_name = self.process_name_mapping.get(data) if process_name: if process_name == "main": self.stop_subprocess = True else: subp = self.process_threads.get(process_name) if subp: subp.stop() connection.sendall(data) ================================================ FILE: openpype/modules/ftrack/launch_hooks/post_ftrack_changes.py ================================================ import os import ftrack_api from openpype.settings import get_project_settings from openpype.lib.applications import PostLaunchHook, LaunchTypes class PostFtrackHook(PostLaunchHook): order = None launch_types = {LaunchTypes.local} def execute(self): project_name = self.data.get("project_name") asset_name = self.data.get("asset_name") task_name = self.data.get("task_name") missing_context_keys = set() if not project_name: missing_context_keys.add("project_name") if not asset_name: missing_context_keys.add("asset_name") if not task_name: missing_context_keys.add("task_name") if missing_context_keys: missing_keys_str = ", ".join([ "\"{}\"".format(key) for key in missing_context_keys ]) self.log.debug("Hook {} skipped. Missing data keys: {}".format( self.__class__.__name__, missing_keys_str )) return required_keys = ("FTRACK_SERVER", "FTRACK_API_USER", "FTRACK_API_KEY") for key in required_keys: if not os.environ.get(key): self.log.debug(( "Missing required environment \"{}\"" " for Ftrack after launch procedure." ).format(key)) return try: session = ftrack_api.Session(auto_connect_event_hub=True) self.log.debug("Ftrack session created") except Exception: self.log.warning("Couldn't create Ftrack session") return try: entity = self.find_ftrack_task_entity( session, project_name, asset_name, task_name ) if entity: self.ftrack_status_change(session, entity, project_name) except Exception: self.log.warning( "Couldn't finish Ftrack procedure.", exc_info=True ) return finally: session.close() def find_ftrack_task_entity( self, session, project_name, asset_name, task_name ): project_entity = session.query( "Project where full_name is \"{}\"".format(project_name) ).first() if not project_entity: self.log.warning( "Couldn't find project \"{}\" in Ftrack.".format(project_name) ) return potential_task_entities = session.query(( "TypedContext where parent.name is \"{}\" and project_id is \"{}\"" ).format(asset_name, project_entity["id"])).all() filtered_entities = [] for _entity in potential_task_entities: if ( _entity.entity_type.lower() == "task" and _entity["name"] == task_name ): filtered_entities.append(_entity) if not filtered_entities: self.log.warning(( "Couldn't find task \"{}\" under parent \"{}\" in Ftrack." ).format(task_name, asset_name)) return if len(filtered_entities) > 1: self.log.warning(( "Found more than one task \"{}\"" " under parent \"{}\" in Ftrack." ).format(task_name, asset_name)) return return filtered_entities[0] def ftrack_status_change(self, session, entity, project_name): project_settings = get_project_settings(project_name) status_update = project_settings["ftrack"]["events"]["status_update"] if not status_update["enabled"]: self.log.debug( "Status changes are disabled for project \"{}\"".format( project_name ) ) return status_mapping = status_update["mapping"] if not status_mapping: self.log.warning( "Project \"{}\" does not have set status changes.".format( project_name ) ) return actual_status = entity["status"]["name"].lower() already_tested = set() ent_path = "/".join( [ent["name"] for ent in entity["link"]] ) while True: next_status_name = None for key, value in status_mapping.items(): if key in already_tested: continue value = [i.lower() for i in value] if actual_status in value or "__any__" in value: if key != "__ignore__": next_status_name = key already_tested.add(key) break already_tested.add(key) if next_status_name is None: break try: query = "Status where name is \"{}\"".format( next_status_name ) status = session.query(query).one() entity["status"] = status session.commit() self.log.debug("Changing status to \"{}\" <{}>".format( next_status_name, ent_path )) break except Exception: session.rollback() msg = ( "Status \"{}\" in presets wasn't found" " on Ftrack entity type \"{}\"" ).format(next_status_name, entity.entity_type) self.log.warning(msg) ================================================ FILE: openpype/modules/ftrack/lib/__init__.py ================================================ from .constants import ( CUST_ATTR_ID_KEY, CUST_ATTR_AUTO_SYNC, CUST_ATTR_GROUP, CUST_ATTR_TOOLS, CUST_ATTR_APPLICATIONS, CUST_ATTR_INTENT, FPS_KEYS ) from .settings import ( get_ftrack_event_mongo_info ) from .custom_attributes import ( default_custom_attributes_definition, app_definitions_from_app_manager, tool_definitions_from_app_manager, get_openpype_attr, query_custom_attributes ) from . import avalon_sync from . import credentials from .ftrack_base_handler import BaseHandler from .ftrack_event_handler import BaseEvent from .ftrack_action_handler import BaseAction, ServerAction, statics_icon __all__ = ( "CUST_ATTR_ID_KEY", "CUST_ATTR_AUTO_SYNC", "CUST_ATTR_GROUP", "CUST_ATTR_TOOLS", "CUST_ATTR_APPLICATIONS", "CUST_ATTR_INTENT", "FPS_KEYS", "get_ftrack_event_mongo_info", "default_custom_attributes_definition", "app_definitions_from_app_manager", "tool_definitions_from_app_manager", "get_openpype_attr", "query_custom_attributes", "avalon_sync", "credentials", "BaseHandler", "BaseEvent", "BaseAction", "ServerAction", "statics_icon" ) ================================================ FILE: openpype/modules/ftrack/lib/avalon_sync.py ================================================ import re import json import collections import copy import numbers import six from openpype.client import ( get_project, get_assets, get_archived_assets, get_subsets, get_versions, get_representations ) from openpype.client.operations import ( CURRENT_ASSET_DOC_SCHEMA, CURRENT_PROJECT_SCHEMA, CURRENT_PROJECT_CONFIG_SCHEMA, ) from openpype.settings import get_anatomy_settings from openpype.lib import ApplicationManager, Logger from openpype.pipeline import AvalonMongoDB, schema from .constants import CUST_ATTR_ID_KEY, FPS_KEYS from .custom_attributes import get_openpype_attr, query_custom_attributes from bson.objectid import ObjectId from bson.errors import InvalidId from pymongo import UpdateOne, ReplaceOne import ftrack_api log = Logger.get_logger(__name__) class InvalidFpsValue(Exception): pass def is_string_number(value): """Can string value be converted to number (float).""" if not isinstance(value, six.string_types): raise TypeError("Expected {} got {}".format( ", ".join(str(t) for t in six.string_types), str(type(value)) )) if value == ".": return False if value.startswith("."): value = "0" + value elif value.endswith("."): value = value + "0" if re.match(r"^\d+(\.\d+)?$", value) is None: return False return True def convert_to_fps(source_value): """Convert value into fps value. Non string values are kept untouched. String is tried to convert. Valid values: "1000" "1000.05" "1000,05" ",05" ".05" "1000," "1000." "1000/1000" "1000.05/1000" "1000/1000.05" "1000.05/1000.05" "1000,05/1000" "1000/1000,05" "1000,05/1000,05" Invalid values: "/" "/1000" "1000/" "," "." ...any other string Returns: float: Converted value. Raises: InvalidFpsValue: When value can't be converted to float. """ if not isinstance(source_value, six.string_types): if isinstance(source_value, numbers.Number): return float(source_value) return source_value value = source_value.strip().replace(",", ".") if not value: raise InvalidFpsValue("Got empty value") subs = value.split("/") if len(subs) == 1: str_value = subs[0] if not is_string_number(str_value): raise InvalidFpsValue( "Value \"{}\" can't be converted to number.".format(value) ) return float(str_value) elif len(subs) == 2: divident, divisor = subs if not divident or not is_string_number(divident): raise InvalidFpsValue( "Divident value \"{}\" can't be converted to number".format( divident ) ) if not divisor or not is_string_number(divisor): raise InvalidFpsValue( "Divisor value \"{}\" can't be converted to number".format( divident ) ) divisor_float = float(divisor) if divisor_float == 0.0: raise InvalidFpsValue("Can't divide by zero") return float(divident) / divisor_float raise InvalidFpsValue( "Value can't be converted to number \"{}\"".format(source_value) ) def create_chunks(iterable, chunk_size=None): """Separate iterable into multiple chunks by size. Args: iterable(list|tuple|set): Object that will be separated into chunks. chunk_size(int): Size of one chunk. Default value is 200. Returns: list: Chunked items. """ chunks = [] tupled_iterable = tuple(iterable) if not tupled_iterable: return chunks iterable_size = len(tupled_iterable) if chunk_size is None: chunk_size = 200 if chunk_size < 1: chunk_size = 1 for idx in range(0, iterable_size, chunk_size): chunks.append(tupled_iterable[idx:idx + chunk_size]) return chunks def check_regex(name, entity_type, in_schema=None, schema_patterns=None): schema_name = "asset-3.0" if in_schema: schema_name = in_schema elif entity_type == "project": schema_name = "project-2.1" elif entity_type == "task": schema_name = "task" name_pattern = None if schema_patterns is not None: name_pattern = schema_patterns.get(schema_name) if not name_pattern: default_pattern = "^[a-zA-Z0-9_.]*$" schema_obj = schema._cache.get(schema_name + ".json") if not schema_obj: name_pattern = default_pattern else: name_pattern = ( schema_obj .get("properties", {}) .get("name", {}) .get("pattern", default_pattern) ) if schema_patterns is not None: schema_patterns[schema_name] = name_pattern if re.match(name_pattern, name): return True return False def join_query_keys(keys): return ",".join(["\"{}\"".format(key) for key in keys]) def get_python_type_for_custom_attribute(cust_attr, cust_attr_type_name=None): """Python type that should value of custom attribute have. This function is mainly for number type which is always float from ftrack. Returns: type: Python type which call be called on object to convert the object to the type or None if can't figure out. """ if cust_attr_type_name is None: cust_attr_type_name = cust_attr["type"]["name"] if cust_attr_type_name == "text": return str if cust_attr_type_name == "boolean": return bool if cust_attr_type_name in ("number", "enumerator"): cust_attr_config = json.loads(cust_attr["config"]) if cust_attr_type_name == "number": if cust_attr_config["isdecimal"]: return float return int if cust_attr_type_name == "enumerator": if cust_attr_config["multiSelect"]: return list return str # "date", "expression", "notificationtype", "dynamic enumerator" return None def from_dict_to_set(data, is_project): """ Converts 'data' into $set part of MongoDB update command. Sets new or modified keys. Tasks are updated completely, not per task. (Eg. change in any of the tasks results in full update of "tasks" from Ftrack. Args: data (dictionary): up-to-date data from Ftrack is_project (boolean): true for project Returns: (dictionary) - { "$set" : "{..}"} """ not_set = object() task_changes = not_set if ( is_project and "config" in data and "tasks" in data["config"] ): task_changes = data["config"].pop("tasks") task_changes_key = "config.tasks" if not data["config"]: data.pop("config") elif ( not is_project and "data" in data and "tasks" in data["data"] ): task_changes = data["data"].pop("tasks") task_changes_key = "data.tasks" if not data["data"]: data.pop("data") result = {"$set": {}} dict_queue = collections.deque() dict_queue.append((None, data)) while dict_queue: _key, _data = dict_queue.popleft() for key, value in _data.items(): new_key = key if _key is not None: new_key = "{}.{}".format(_key, key) if not isinstance(value, dict) or \ (isinstance(value, dict) and not bool(value)): # empty dic result["$set"][new_key] = value continue dict_queue.append((new_key, value)) if task_changes is not not_set and task_changes_key: result["$set"][task_changes_key] = task_changes return result def get_project_apps(in_app_list): """ Application definitions for app name. Args: in_app_list: (list) - names of applications Returns: tuple (list, dictionary) - list of dictionaries with apps definitions dictionary of warnings """ apps = [] warnings = collections.defaultdict(list) if not in_app_list: return apps, warnings missing_app_msg = "Missing definition of application" application_manager = ApplicationManager() for app_name in in_app_list: if application_manager.applications.get(app_name): apps.append({"name": app_name}) else: warnings[missing_app_msg].append(app_name) return apps, warnings def get_hierarchical_attributes_values( session, entity, hier_attrs, cust_attr_types=None ): if not cust_attr_types: cust_attr_types = session.query( "select id, name from CustomAttributeType" ).all() cust_attr_name_by_id = { cust_attr_type["id"]: cust_attr_type["name"] for cust_attr_type in cust_attr_types } # Hierarchical cust attrs attr_key_by_id = {} convert_types_by_attr_id = {} defaults = {} for attr in hier_attrs: attr_id = attr["id"] key = attr["key"] type_id = attr["type_id"] attr_key_by_id[attr_id] = key defaults[key] = attr["default"] cust_attr_type_name = cust_attr_name_by_id[type_id] convert_type = get_python_type_for_custom_attribute( attr, cust_attr_type_name ) convert_types_by_attr_id[attr_id] = convert_type entity_ids = [item["id"] for item in entity["link"]] values = query_custom_attributes( session, list(attr_key_by_id.keys()), entity_ids, True ) hier_values = {} for key, val in defaults.items(): hier_values[key] = val if not values: return hier_values values_by_entity_id = collections.defaultdict(dict) for item in values: value = item["value"] if value is None: continue attr_id = item["configuration_id"] convert_type = convert_types_by_attr_id[attr_id] if convert_type: value = convert_type(value) key = attr_key_by_id[attr_id] entity_id = item["entity_id"] values_by_entity_id[entity_id][key] = value for entity_id in entity_ids: for key in attr_key_by_id.values(): value = values_by_entity_id[entity_id].get(key) if value is not None: hier_values[key] = value return hier_values class SyncEntitiesFactory: dbcon = AvalonMongoDB() cust_attr_query_keys = [ "id", "key", "entity_type", "object_type_id", "is_hierarchical", "config", "default" ] project_query = ( "select full_name, name, custom_attributes" ", project_schema._task_type_schema.types.name" " from Project where full_name is \"{}\"" ) entities_query = ( "select id, name, type_id, parent_id, link, description" " from TypedContext where project_id is \"{}\"" ) ignore_custom_attr_key = "avalon_ignore_sync" ignore_entity_types = ["milestone"] report_splitter = {"type": "label", "value": "---"} def __init__(self, log_obj, session): self.log = log_obj self._server_url = session.server_url self._api_key = session.api_key self._api_user = session.api_user def launch_setup(self, project_full_name): try: self.session.close() except Exception: pass self.session = ftrack_api.Session( server_url=self._server_url, api_key=self._api_key, api_user=self._api_user, auto_connect_event_hub=False ) self.duplicates = {} self.failed_regex = {} self.tasks_failed_regex = collections.defaultdict(list) self.report_items = { "info": collections.defaultdict(list), "warning": collections.defaultdict(list), "error": collections.defaultdict(list) } self.create_list = [] self.project_created = False self.unarchive_list = [] self.updates = collections.defaultdict(dict) self.avalon_project = None self.avalon_entities = None self._avalon_ents_by_id = None self._avalon_ents_by_ftrack_id = None self._avalon_ents_by_name = None self._avalon_ents_by_parent_id = None self._avalon_archived_ents = None self._avalon_archived_by_id = None self._avalon_archived_by_parent_id = None self._avalon_archived_by_name = None self._subsets_by_parent_id = None self._changeability_by_mongo_id = None self._object_types_by_name = None self.all_filtered_entities = {} self.filtered_ids = [] self.not_selected_ids = [] self.hier_cust_attr_ids_by_key = {} self._ent_paths_by_ftrack_id = {} self.ftrack_avalon_mapper = None self.avalon_ftrack_mapper = None self.create_ftrack_ids = None self.update_ftrack_ids = None self.deleted_entities = None # Get Ftrack project ft_project = self.session.query( self.project_query.format(project_full_name) ).one() ft_project_id = ft_project["id"] # Skip if project is ignored if ft_project["custom_attributes"].get( self.ignore_custom_attr_key ) is True: msg = ( "Project \"{}\" has set `Ignore Sync` custom attribute to True" ).format(project_full_name) self.log.warning(msg) return {"success": False, "message": msg} self.log.debug(( "*** Synchronization initialization started <{}>." ).format(project_full_name)) # Check if `avalon_mongo_id` custom attribute exist or is accessible if CUST_ATTR_ID_KEY not in ft_project["custom_attributes"]: items = [] items.append({ "type": "label", "value": ( "# Can't access Custom attribute: \"{}\"" ).format(CUST_ATTR_ID_KEY) }) items.append({ "type": "label", "value": ( "

- Check if your User and API key has permissions" " to access the Custom attribute." "
Username:\"{}\"" "
API key:\"{}\"

" ).format(self._api_user, self._api_key) }) items.append({ "type": "label", "value": "

- Check if the Custom attribute exist

" }) return { "items": items, "title": "Synchronization failed", "success": False, "message": "Synchronization failed" } # Store entities by `id` and `parent_id` entities_dict = collections.defaultdict(lambda: { "children": list(), "parent_id": None, "entity": None, "entity_type": None, "name": None, "custom_attributes": {}, "hier_attrs": {}, "avalon_attrs": {}, "tasks": {} }) # Find all entities in project all_project_entities = self.session.query( self.entities_query.format(ft_project_id) ).all() task_types = self.session.query("select id, name from Type").all() task_type_names_by_id = { task_type["id"]: task_type["name"] for task_type in task_types } for entity in all_project_entities: parent_id = entity["parent_id"] entity_type = entity.entity_type entity_type_low = entity_type.lower() if entity_type_low in self.ignore_entity_types: continue elif entity_type_low == "task": # enrich task info with additional metadata task_type_name = task_type_names_by_id[entity["type_id"]] task = {"type": task_type_name} entities_dict[parent_id]["tasks"][entity["name"]] = task continue entity_id = entity["id"] entities_dict[entity_id].update({ "entity": entity, "parent_id": parent_id, "entity_type": entity_type_low, "entity_type_orig": entity_type, "name": entity["name"] }) entities_dict[parent_id]["children"].append(entity_id) entities_dict[ft_project_id]["entity"] = ft_project entities_dict[ft_project_id]["entity_type"] = ( ft_project.entity_type.lower() ) entities_dict[ft_project_id]["entity_type_orig"] = ( ft_project.entity_type ) entities_dict[ft_project_id]["name"] = ft_project["full_name"] self.ft_project_id = ft_project_id self.entities_dict = entities_dict @property def project_name(self): return self.entities_dict[self.ft_project_id]["name"] @property def avalon_ents_by_id(self): """ Returns dictionary of avalon tracked entities (assets stored in MongoDB) accessible by its '_id' (mongo intenal ID - example ObjectId("5f48de5830a9467b34b69798")) Returns: (dictionary) - {"(_id)": whole entity asset} """ if self._avalon_ents_by_id is None: self._avalon_ents_by_id = {} for entity in self.avalon_entities: self._avalon_ents_by_id[str(entity["_id"])] = entity return self._avalon_ents_by_id @property def avalon_ents_by_ftrack_id(self): """ Returns dictionary of Mongo ids of avalon tracked entities (assets stored in MongoDB) accessible by its 'ftrackId' (id from ftrack) (example '431ee3f2-e91a-11ea-bfa4-92591a5b5e3e') Returns: (dictionary) - {"(ftrackId)": "_id"} """ if self._avalon_ents_by_ftrack_id is None: self._avalon_ents_by_ftrack_id = {} for entity in self.avalon_entities: key = entity.get("data", {}).get("ftrackId") if not key: continue self._avalon_ents_by_ftrack_id[key] = str(entity["_id"]) return self._avalon_ents_by_ftrack_id @property def avalon_ents_by_name(self): """ Returns dictionary of Mongo ids of avalon tracked entities (assets stored in MongoDB) accessible by its 'name' (example 'Hero') Returns: (dictionary) - {"(name)": "_id"} """ if self._avalon_ents_by_name is None: self._avalon_ents_by_name = {} for entity in self.avalon_entities: self._avalon_ents_by_name[entity["name"]] = str(entity["_id"]) return self._avalon_ents_by_name @property def avalon_ents_by_parent_id(self): """ Returns dictionary of avalon tracked entities (assets stored in MongoDB) accessible by its 'visualParent' (example ObjectId("5f48de5830a9467b34b69798")) Fills 'self._avalon_archived_ents' for performance Returns: (dictionary) - {"(_id)": whole entity} """ if self._avalon_ents_by_parent_id is None: self._avalon_ents_by_parent_id = collections.defaultdict(list) for entity in self.avalon_entities: parent_id = entity["data"]["visualParent"] if parent_id is not None: parent_id = str(parent_id) self._avalon_ents_by_parent_id[parent_id].append(entity) return self._avalon_ents_by_parent_id @property def avalon_archived_ents(self): """ Returns list of archived assets from DB (their "type" == 'archived_asset') Fills 'self._avalon_archived_ents' for performance Returns: (list) of assets """ if self._avalon_archived_ents is None: self._avalon_archived_ents = list( get_archived_assets(self.project_name) ) return self._avalon_archived_ents @property def avalon_archived_by_name(self): """ Returns list of archived assets from DB (their "type" == 'archived_asset') Fills 'self._avalon_archived_by_name' for performance Returns: (dictionary of lists) of assets accessible by asset name """ if self._avalon_archived_by_name is None: self._avalon_archived_by_name = collections.defaultdict(list) for ent in self.avalon_archived_ents: self._avalon_archived_by_name[ent["name"]].append(ent) return self._avalon_archived_by_name @property def avalon_archived_by_id(self): """ Returns dictionary of archived assets from DB (their "type" == 'archived_asset') Fills 'self._avalon_archived_by_id' for performance Returns: (dictionary) of assets accessible by asset mongo _id """ if self._avalon_archived_by_id is None: self._avalon_archived_by_id = { str(ent["_id"]): ent for ent in self.avalon_archived_ents } return self._avalon_archived_by_id @property def avalon_archived_by_parent_id(self): """ Returns dictionary of archived assets from DB per their's parent (their "type" == 'archived_asset') Fills 'self._avalon_archived_by_parent_id' for performance Returns: (dictionary of lists) of assets accessible by asset parent mongo _id """ if self._avalon_archived_by_parent_id is None: self._avalon_archived_by_parent_id = collections.defaultdict(list) for entity in self.avalon_archived_ents: parent_id = entity["data"]["visualParent"] if parent_id is not None: parent_id = str(parent_id) self._avalon_archived_by_parent_id[parent_id].append(entity) return self._avalon_archived_by_parent_id @property def subsets_by_parent_id(self): """ Returns dictionary of subsets from Mongo ("type": "subset") grouped by their parent. Fills 'self._subsets_by_parent_id' for performance Returns: (dictionary of lists) """ if self._subsets_by_parent_id is None: self._subsets_by_parent_id = collections.defaultdict(list) for subset in get_subsets(self.project_name): self._subsets_by_parent_id[str(subset["parent"])].append( subset ) return self._subsets_by_parent_id @property def changeability_by_mongo_id(self): if self._changeability_by_mongo_id is None: self._changeability_by_mongo_id = collections.defaultdict( lambda: True ) self._changeability_by_mongo_id[self.avalon_project_id] = False self._bubble_changeability(list(self.subsets_by_parent_id.keys())) return self._changeability_by_mongo_id @property def object_types_by_name(self): if self._object_types_by_name is None: object_types_by_name = self.session.query( "select id, name from ObjectType" ).all() self._object_types_by_name = { object_type["name"]: object_type for object_type in object_types_by_name } return self._object_types_by_name @property def all_ftrack_names(self): """ Returns lists of names of all entities in Ftrack Returns: (list) """ return [ ent_dict["name"] for ent_dict in self.entities_dict.values() if ( ent_dict.get("name") ) ] def duplicity_regex_check(self): self.log.debug("* Checking duplicities and invalid symbols") # Duplicity and regex check entity_ids_by_name = {} duplicates = [] failed_regex = [] task_names = {} _schema_patterns = {} for ftrack_id, entity_dict in self.entities_dict.items(): regex_check = True name = entity_dict["name"] entity_type = entity_dict["entity_type"] # Tasks must be checked too for task in entity_dict["tasks"].items(): task_name, task = task passed = task_names.get(task_name) if passed is None: passed = check_regex( task_name, "task", schema_patterns=_schema_patterns ) task_names[task_name] = passed if not passed: self.tasks_failed_regex[task_name].append(ftrack_id) if name in entity_ids_by_name: duplicates.append(name) else: entity_ids_by_name[name] = [] regex_check = check_regex( name, entity_type, schema_patterns=_schema_patterns ) entity_ids_by_name[name].append(ftrack_id) if not regex_check: failed_regex.append(name) for name in failed_regex: self.failed_regex[name] = entity_ids_by_name[name] for name in duplicates: self.duplicates[name] = entity_ids_by_name[name] self.filter_by_duplicate_regex() def filter_by_duplicate_regex(self): filter_queue = collections.deque() failed_regex_msg = "{} - Entity has invalid symbols in the name" duplicate_msg = "There are multiple entities with the name: \"{}\":" for ids in self.failed_regex.values(): for id in ids: ent_path = self.get_ent_path(id) self.log.warning(failed_regex_msg.format(ent_path)) filter_queue.append(id) for name, ids in self.duplicates.items(): self.log.warning(duplicate_msg.format(name)) for id in ids: ent_path = self.get_ent_path(id) self.log.warning(ent_path) filter_queue.append(id) filtered_ids = [] while filter_queue: ftrack_id = filter_queue.popleft() if ftrack_id in filtered_ids: continue entity_dict = self.entities_dict.pop(ftrack_id, {}) if not entity_dict: continue self.all_filtered_entities[ftrack_id] = entity_dict parent_id = entity_dict.get("parent_id") if parent_id and parent_id in self.entities_dict: if ftrack_id in self.entities_dict[parent_id]["children"]: self.entities_dict[parent_id]["children"].remove(ftrack_id) filtered_ids.append(ftrack_id) for child_id in entity_dict.get("children", []): filter_queue.append(child_id) for name, ids in self.tasks_failed_regex.items(): for id in ids: if id not in self.entities_dict: continue self.entities_dict[id]["tasks"].pop(name) ent_path = self.get_ent_path(id) self.log.warning(failed_regex_msg.format( "/".join([ent_path, name]) )) def filter_by_ignore_sync(self): # skip filtering if `ignore_sync` attribute do not exist if self.entities_dict[self.ft_project_id]["avalon_attrs"].get( self.ignore_custom_attr_key, "_notset_" ) == "_notset_": return filter_queue = collections.deque() filter_queue.append((self.ft_project_id, False)) while filter_queue: parent_id, remove = filter_queue.popleft() if remove: parent_dict = self.entities_dict.pop(parent_id, {}) self.all_filtered_entities[parent_id] = parent_dict self.filtered_ids.append(parent_id) else: parent_dict = self.entities_dict.get(parent_id, {}) for child_id in list(parent_dict.get("children", [])): # keep original `remove` value for all children _remove = (remove is True) if not _remove: if self.entities_dict[child_id]["avalon_attrs"].get( self.ignore_custom_attr_key ): self.entities_dict[parent_id]["children"].remove( child_id ) _remove = True filter_queue.append((child_id, _remove)) def filter_by_selection(self, event): # BUGGY!!!! cause that entities are in deleted list # TODO may be working when filtering happen after preparations # - But this part probably does not have any functional reason # - Time of synchronization probably won't be changed much selected_ids = [] for entity in event["data"]["selection"]: # Skip if project is in selection if entity["entityType"] == "show": return selected_ids.append(entity["entityId"]) sync_ids = [self.ft_project_id] parents_queue = collections.deque() children_queue = collections.deque() for selected_id in selected_ids: # skip if already filtered with ignore sync custom attribute if selected_id in self.filtered_ids: continue parents_queue.append(selected_id) children_queue.append(selected_id) while parents_queue: ftrack_id = parents_queue.popleft() while True: # Stops when parent is in sync_ids if ( ftrack_id in self.filtered_ids or ftrack_id in sync_ids or ftrack_id is None ): break sync_ids.append(ftrack_id) ftrack_id = self.entities_dict[ftrack_id]["parent_id"] while children_queue: parent_id = children_queue.popleft() for child_id in self.entities_dict[parent_id]["children"]: if child_id in sync_ids or child_id in self.filtered_ids: continue sync_ids.append(child_id) children_queue.append(child_id) # separate not selected and to process entities for key, value in self.entities_dict.items(): if key not in sync_ids: self.not_selected_ids.append(key) for ftrack_id in self.not_selected_ids: # pop from entities value = self.entities_dict.pop(ftrack_id) # remove entity from parent's children parent_id = value["parent_id"] if parent_id not in sync_ids: continue self.entities_dict[parent_id]["children"].remove(ftrack_id) def set_cutom_attributes(self): self.log.debug("* Preparing custom attributes") # Get custom attributes and values custom_attrs, hier_attrs = get_openpype_attr( self.session, query_keys=self.cust_attr_query_keys ) ent_types_by_name = self.object_types_by_name # Custom attribute types cust_attr_types = self.session.query( "select id, name from CustomAttributeType" ).all() cust_attr_type_name_by_id = { cust_attr_type["id"]: cust_attr_type["name"] for cust_attr_type in cust_attr_types } # store default values per entity type attrs_per_entity_type = collections.defaultdict(dict) avalon_attrs = collections.defaultdict(dict) # store also custom attribute configuration id for future use (create) attrs_per_entity_type_ca_id = collections.defaultdict(dict) avalon_attrs_ca_id = collections.defaultdict(dict) attribute_key_by_id = {} convert_types_by_attr_id = {} for cust_attr in custom_attrs: key = cust_attr["key"] attr_id = cust_attr["id"] type_id = cust_attr["type_id"] attribute_key_by_id[attr_id] = key cust_attr_type_name = cust_attr_type_name_by_id[type_id] convert_type = get_python_type_for_custom_attribute( cust_attr, cust_attr_type_name ) convert_types_by_attr_id[attr_id] = convert_type ca_ent_type = cust_attr["entity_type"] if key.startswith("avalon_"): if ca_ent_type == "show": avalon_attrs[ca_ent_type][key] = cust_attr["default"] avalon_attrs_ca_id[ca_ent_type][key] = cust_attr["id"] elif ca_ent_type == "task": obj_id = cust_attr["object_type_id"] avalon_attrs[obj_id][key] = cust_attr["default"] avalon_attrs_ca_id[obj_id][key] = cust_attr["id"] continue if ca_ent_type == "show": attrs_per_entity_type[ca_ent_type][key] = cust_attr["default"] attrs_per_entity_type_ca_id[ca_ent_type][key] = cust_attr["id"] elif ca_ent_type == "task": obj_id = cust_attr["object_type_id"] attrs_per_entity_type[obj_id][key] = cust_attr["default"] attrs_per_entity_type_ca_id[obj_id][key] = cust_attr["id"] obj_id_ent_type_map = {} sync_ids = [] for entity_id, entity_dict in self.entities_dict.items(): sync_ids.append(entity_id) entity_type = entity_dict["entity_type"] entity_type_orig = entity_dict["entity_type_orig"] if entity_type == "project": attr_key = "show" else: map_key = obj_id_ent_type_map.get(entity_type_orig) if not map_key: # Put space between capitals # (e.g. 'AssetBuild' -> 'Asset Build') map_key = re.sub( r"(\w)([A-Z])", r"\1 \2", entity_type_orig ) obj_id_ent_type_map[entity_type_orig] = map_key # Get object id of entity type attr_key = ent_types_by_name.get(map_key) # Backup soluction when id is not found by prequeried objects if not attr_key: query = "ObjectType where name is \"{}\"".format(map_key) attr_key = self.session.query(query).one()["id"] ent_types_by_name[map_key] = attr_key prepared_attrs = attrs_per_entity_type.get(attr_key) prepared_avalon_attr = avalon_attrs.get(attr_key) prepared_attrs_ca_id = attrs_per_entity_type_ca_id.get(attr_key) prepared_avalon_attr_ca_id = avalon_attrs_ca_id.get(attr_key) if prepared_attrs: self.entities_dict[entity_id]["custom_attributes"] = ( copy.deepcopy(prepared_attrs) ) if prepared_attrs_ca_id: self.entities_dict[entity_id]["custom_attributes_id"] = ( copy.deepcopy(prepared_attrs_ca_id) ) if prepared_avalon_attr: self.entities_dict[entity_id]["avalon_attrs"] = ( copy.deepcopy(prepared_avalon_attr) ) if prepared_avalon_attr_ca_id: self.entities_dict[entity_id]["avalon_attrs_id"] = ( copy.deepcopy(prepared_avalon_attr_ca_id) ) items = query_custom_attributes( self.session, list(attribute_key_by_id.keys()), sync_ids ) invalid_fps_items = [] for item in items: entity_id = item["entity_id"] attr_id = item["configuration_id"] key = attribute_key_by_id[attr_id] store_key = "custom_attributes" if key.startswith("avalon_"): store_key = "avalon_attrs" convert_type = convert_types_by_attr_id[attr_id] value = item["value"] if convert_type: value = convert_type(value) if key in FPS_KEYS: try: value = convert_to_fps(value) except InvalidFpsValue: invalid_fps_items.append((entity_id, value)) self.entities_dict[entity_id][store_key][key] = value if invalid_fps_items: fps_msg = ( "These entities have invalid fps value in custom attributes" ) items = [] for entity_id, value in invalid_fps_items: ent_path = self.get_ent_path(entity_id) items.append("{} - \"{}\"".format(ent_path, value)) self.report_items["error"][fps_msg] = items # process hierarchical attributes self.set_hierarchical_attribute( hier_attrs, sync_ids, cust_attr_type_name_by_id ) def set_hierarchical_attribute( self, hier_attrs, sync_ids, cust_attr_type_name_by_id ): # collect all hierarchical attribute keys # and prepare default values to project attributes_by_key = {} attribute_key_by_id = {} convert_types_by_attr_id = {} for attr in hier_attrs: key = attr["key"] attr_id = attr["id"] type_id = attr["type_id"] attribute_key_by_id[attr_id] = key attributes_by_key[key] = attr cust_attr_type_name = cust_attr_type_name_by_id[type_id] convert_type = get_python_type_for_custom_attribute( attr, cust_attr_type_name ) convert_types_by_attr_id[attr_id] = convert_type self.hier_cust_attr_ids_by_key[key] = attr["id"] store_key = "hier_attrs" if key.startswith("avalon_"): store_key = "avalon_attrs" default_value = attr["default"] if key in FPS_KEYS: try: default_value = convert_to_fps(default_value) except InvalidFpsValue: pass self.entities_dict[self.ft_project_id][store_key][key] = ( default_value ) # Add attribute ids to entities dictionary avalon_attribute_id_by_key = { attr_key: attr_id for attr_id, attr_key in attribute_key_by_id.items() if attr_key.startswith("avalon_") } for entity_id in self.entities_dict.keys(): if "avalon_attrs_id" not in self.entities_dict[entity_id]: self.entities_dict[entity_id]["avalon_attrs_id"] = {} for attr_key, attr_id in avalon_attribute_id_by_key.items(): self.entities_dict[entity_id]["avalon_attrs_id"][attr_key] = ( attr_id ) # Prepare dict with all hier keys and None values prepare_dict = {} prepare_dict_avalon = {} for key in attributes_by_key.keys(): if key.startswith("avalon_"): prepare_dict_avalon[key] = None else: prepare_dict[key] = None for entity_dict in self.entities_dict.values(): # Skip project because has stored defaults at the moment if entity_dict["entity_type"] == "project": continue entity_dict["hier_attrs"] = copy.deepcopy(prepare_dict) for key, val in prepare_dict_avalon.items(): entity_dict["avalon_attrs"][key] = val items = query_custom_attributes( self.session, list(attribute_key_by_id.keys()), sync_ids, True ) invalid_fps_items = [] avalon_hier = [] for item in items: value = item["value"] # WARNING It is not possible to propagate enumerate hierarchical # attributes with multiselection 100% right. Unsetting all values # will cause inheritance from parent. if ( value is None or (isinstance(value, (tuple, list)) and not value) ): continue attr_id = item["configuration_id"] convert_type = convert_types_by_attr_id[attr_id] if convert_type: value = convert_type(value) entity_id = item["entity_id"] key = attribute_key_by_id[attr_id] if key in FPS_KEYS: try: value = convert_to_fps(value) except InvalidFpsValue: invalid_fps_items.append((entity_id, value)) continue if key.startswith("avalon_"): store_key = "avalon_attrs" avalon_hier.append(key) else: store_key = "hier_attrs" self.entities_dict[entity_id][store_key][key] = value if invalid_fps_items: fps_msg = ( "These entities have invalid fps value in custom attributes" ) items = [] for entity_id, value in invalid_fps_items: ent_path = self.get_ent_path(entity_id) items.append("{} - \"{}\"".format(ent_path, value)) self.report_items["error"][fps_msg] = items # Get dictionary with not None hierarchical values to pull to children top_id = self.ft_project_id project_values = {} for key, value in self.entities_dict[top_id]["hier_attrs"].items(): if value is not None: project_values[key] = value for key in avalon_hier: if key == CUST_ATTR_ID_KEY: continue value = self.entities_dict[top_id]["avalon_attrs"][key] if value is not None: project_values[key] = value hier_down_queue = collections.deque() hier_down_queue.append((project_values, top_id)) while hier_down_queue: hier_values, parent_id = hier_down_queue.popleft() for child_id in self.entities_dict[parent_id]["children"]: _hier_values = copy.deepcopy(hier_values) for key in attributes_by_key.keys(): if key.startswith("avalon_"): store_key = "avalon_attrs" else: store_key = "hier_attrs" value = self.entities_dict[child_id][store_key][key] if value is not None: _hier_values[key] = value self.entities_dict[child_id]["hier_attrs"].update(_hier_values) hier_down_queue.append((_hier_values, child_id)) def remove_from_archived(self, mongo_id): entity = self.avalon_archived_by_id.pop(mongo_id, None) if not entity: return if self._avalon_archived_ents is not None: if entity in self._avalon_archived_ents: self._avalon_archived_ents.remove(entity) if self._avalon_archived_by_name is not None: name = entity["name"] if name in self._avalon_archived_by_name: name_ents = self._avalon_archived_by_name[name] if entity in name_ents: if len(name_ents) == 1: self._avalon_archived_by_name.pop(name) else: self._avalon_archived_by_name[name].remove(entity) # TODO use custom None instead of __NOTSET__ if self._avalon_archived_by_parent_id is not None: parent_id = entity.get("data", {}).get( "visualParent", "__NOTSET__" ) if parent_id is not None: parent_id = str(parent_id) if parent_id in self._avalon_archived_by_parent_id: parent_list = self._avalon_archived_by_parent_id[parent_id] if entity not in parent_list: self._avalon_archived_by_parent_id[parent_id].remove( entity ) def _get_input_links(self, ftrack_ids): tupled_ids = tuple(ftrack_ids) mapping_by_to_id = { ftrack_id: set() for ftrack_id in tupled_ids } ids_len = len(tupled_ids) chunk_size = int(5000 / ids_len) all_links = [] for chunk in create_chunks(ftrack_ids, chunk_size): entity_ids_joined = join_query_keys(chunk) all_links.extend(self.session.query(( "select from_id, to_id from" " TypedContextLink where to_id in ({})" ).format(entity_ids_joined)).all()) for context_link in all_links: to_id = context_link["to_id"] from_id = context_link["from_id"] if from_id == to_id: continue mapping_by_to_id[to_id].add(from_id) return mapping_by_to_id def prepare_ftrack_ent_data(self): not_set_ids = [] for ftrack_id, entity_dict in self.entities_dict.items(): entity = entity_dict["entity"] if entity is None: not_set_ids.append(ftrack_id) continue self.entities_dict[ftrack_id]["final_entity"] = {} self.entities_dict[ftrack_id]["final_entity"]["name"] = ( entity_dict["name"] ) data = {} data["ftrackId"] = entity["id"] data["entityType"] = entity_dict["entity_type_orig"] for key, val in entity_dict.get("custom_attributes", []).items(): data[key] = val for key, val in entity_dict.get("hier_attrs", []).items(): data[key] = val if ftrack_id != self.ft_project_id: data["description"] = entity["description"] ent_path_items = [ent["name"] for ent in entity["link"]] parents = ent_path_items[1:len(ent_path_items) - 1:] data["parents"] = parents data["tasks"] = self.entities_dict[ftrack_id].pop("tasks", {}) self.entities_dict[ftrack_id]["final_entity"]["data"] = data self.entities_dict[ftrack_id]["final_entity"]["type"] = "asset" continue project_name = entity["full_name"] data["code"] = entity["name"] self.entities_dict[ftrack_id]["final_entity"]["data"] = data self.entities_dict[ftrack_id]["final_entity"]["type"] = ( "project" ) proj_schema = entity["project_schema"] task_types = proj_schema["_task_type_schema"]["types"] proj_apps, warnings = get_project_apps( data.pop("applications", []) ) for msg, items in warnings.items(): if not msg or not items: continue self.report_items["warning"][msg] = items current_project_anatomy_data = get_anatomy_settings( project_name, exclude_locals=True ) anatomy_tasks = current_project_anatomy_data["tasks"] tasks = {} default_type_data = { "short_name": "" } for task_type in task_types: task_type_name = task_type["name"] tasks[task_type_name] = copy.deepcopy( anatomy_tasks.get(task_type_name) or default_type_data ) project_config = { "tasks": tasks, "apps": proj_apps } for key, value in current_project_anatomy_data.items(): if key in project_config or key == "attributes": continue project_config[key] = value self.entities_dict[ftrack_id]["final_entity"]["config"] = ( project_config ) if not_set_ids: self.log.debug(( "- Debug information: Filtering bug, there are empty dicts" "in entities dict (functionality should not be affected) <{}>" ).format("| ".join(not_set_ids))) for id in not_set_ids: self.entities_dict.pop(id) def get_ent_path(self, ftrack_id): ent_path = self._ent_paths_by_ftrack_id.get(ftrack_id) if not ent_path: entity = self.entities_dict[ftrack_id]["entity"] ent_path = "/".join( [ent["name"] for ent in entity["link"]] ) self._ent_paths_by_ftrack_id[ftrack_id] = ent_path return ent_path def prepare_avalon_entities(self, ft_project_name): self.log.debug(( "* Preparing avalon entities " "(separate to Create, Update and Deleted groups)" )) # Avalon entities self.dbcon.install() self.dbcon.Session["AVALON_PROJECT"] = ft_project_name avalon_project = get_project(ft_project_name) avalon_entities = get_assets(ft_project_name) self.avalon_project = avalon_project self.avalon_entities = avalon_entities ftrack_avalon_mapper = {} avalon_ftrack_mapper = {} create_ftrack_ids = [] update_ftrack_ids = [] same_mongo_id = [] all_mongo_ids = {} for ftrack_id, entity_dict in self.entities_dict.items(): mongo_id = entity_dict["avalon_attrs"].get(CUST_ATTR_ID_KEY) if not mongo_id: continue if mongo_id in all_mongo_ids: same_mongo_id.append(mongo_id) else: all_mongo_ids[mongo_id] = [] all_mongo_ids[mongo_id].append(ftrack_id) if avalon_project: mongo_id = str(avalon_project["_id"]) ftrack_avalon_mapper[self.ft_project_id] = mongo_id avalon_ftrack_mapper[mongo_id] = self.ft_project_id update_ftrack_ids.append(self.ft_project_id) else: create_ftrack_ids.append(self.ft_project_id) # make it go hierarchically prepare_queue = collections.deque() for child_id in self.entities_dict[self.ft_project_id]["children"]: prepare_queue.append(child_id) while prepare_queue: ftrack_id = prepare_queue.popleft() for child_id in self.entities_dict[ftrack_id]["children"]: prepare_queue.append(child_id) entity_dict = self.entities_dict[ftrack_id] ent_path = self.get_ent_path(ftrack_id) mongo_id = entity_dict["avalon_attrs"].get(CUST_ATTR_ID_KEY) av_ent_by_mongo_id = self.avalon_ents_by_id.get(mongo_id) if av_ent_by_mongo_id: av_ent_ftrack_id = av_ent_by_mongo_id.get("data", {}).get( "ftrackId" ) is_right = False else_match_better = False if av_ent_ftrack_id and av_ent_ftrack_id == ftrack_id: is_right = True elif mongo_id not in same_mongo_id: is_right = True else: ftrack_ids_with_same_mongo = all_mongo_ids[mongo_id] for _ftrack_id in ftrack_ids_with_same_mongo: if _ftrack_id == av_ent_ftrack_id: continue _entity_dict = self.entities_dict[_ftrack_id] _mongo_id = ( _entity_dict["avalon_attrs"][CUST_ATTR_ID_KEY] ) _av_ent_by_mongo_id = self.avalon_ents_by_id.get( _mongo_id ) _av_ent_ftrack_id = _av_ent_by_mongo_id.get( "data", {} ).get("ftrackId") if _av_ent_ftrack_id == ftrack_id: else_match_better = True break if not is_right and not else_match_better: entity = entity_dict["entity"] ent_path_items = [ent["name"] for ent in entity["link"]] parents = ent_path_items[1:len(ent_path_items) - 1:] av_parents = av_ent_by_mongo_id["data"]["parents"] if av_parents == parents: is_right = True else: name = entity_dict["name"] av_name = av_ent_by_mongo_id["name"] if name == av_name: is_right = True if is_right: self.log.debug( "Existing (by MongoID) <{}>".format(ent_path) ) ftrack_avalon_mapper[ftrack_id] = mongo_id avalon_ftrack_mapper[mongo_id] = ftrack_id update_ftrack_ids.append(ftrack_id) continue mongo_id = self.avalon_ents_by_ftrack_id.get(ftrack_id) if not mongo_id: mongo_id = self.avalon_ents_by_name.get(entity_dict["name"]) if mongo_id: self.log.debug( "Existing (by matching name) <{}>".format(ent_path) ) else: self.log.debug( "Existing (by FtrackID in mongo) <{}>".format(ent_path) ) if mongo_id: ftrack_avalon_mapper[ftrack_id] = mongo_id avalon_ftrack_mapper[mongo_id] = ftrack_id update_ftrack_ids.append(ftrack_id) continue self.log.debug("New <{}>".format(ent_path)) create_ftrack_ids.append(ftrack_id) deleted_entities = [] for mongo_id in self.avalon_ents_by_id: if mongo_id in avalon_ftrack_mapper: continue deleted_entities.append(mongo_id) av_ent = self.avalon_ents_by_id[mongo_id] av_ent_path_items = list(av_ent["data"]["parents"]) av_ent_path_items.append(av_ent["name"]) self.log.debug("Deleted <{}>".format("/".join(av_ent_path_items))) self.ftrack_avalon_mapper = ftrack_avalon_mapper self.avalon_ftrack_mapper = avalon_ftrack_mapper self.create_ftrack_ids = create_ftrack_ids self.update_ftrack_ids = update_ftrack_ids self.deleted_entities = deleted_entities self.log.debug(( "Ftrack -> Avalon comparison: New <{}> " "| Existing <{}> | Deleted <{}>" ).format( len(create_ftrack_ids), len(update_ftrack_ids), len(deleted_entities) )) def filter_with_children(self, ftrack_id): if ftrack_id not in self.entities_dict: return ent_dict = self.entities_dict[ftrack_id] parent_id = ent_dict["parent_id"] self.entities_dict[parent_id]["children"].remove(ftrack_id) children_queue = collections.deque() children_queue.append(ftrack_id) while children_queue: _ftrack_id = children_queue.popleft() entity_dict = self.entities_dict.pop(_ftrack_id, {"children": []}) for child_id in entity_dict["children"]: children_queue.append(child_id) def set_input_links(self): ftrack_ids = set(self.create_ftrack_ids) | set(self.update_ftrack_ids) input_links_by_ftrack_id = self._get_input_links(ftrack_ids) for ftrack_id in ftrack_ids: input_links = [] final_entity = self.entities_dict[ftrack_id]["final_entity"] final_entity["data"]["inputLinks"] = input_links link_ids = input_links_by_ftrack_id[ftrack_id] if not link_ids: continue for ftrack_link_id in link_ids: mongo_id = self.ftrack_avalon_mapper.get(ftrack_link_id) if mongo_id is not None: input_links.append({ "id": ObjectId(mongo_id), "linkedBy": "ftrack", "type": "breakdown" }) def prepare_changes(self): self.log.debug("* Preparing changes for avalon/ftrack") hierarchy_changing_ids = [] ignore_keys = collections.defaultdict(list) update_queue = collections.deque() for ftrack_id in self.update_ftrack_ids: update_queue.append(ftrack_id) while update_queue: ftrack_id = update_queue.popleft() if ftrack_id == self.ft_project_id: changes = self.prepare_project_changes() if changes: self.updates[self.avalon_project_id] = changes continue ftrack_ent_dict = self.entities_dict[ftrack_id] # *** check parents parent_check = False ftrack_parent_id = ftrack_ent_dict["parent_id"] avalon_id = self.ftrack_avalon_mapper[ftrack_id] avalon_entity = self.avalon_ents_by_id[avalon_id] avalon_parent_id = avalon_entity["data"]["visualParent"] if avalon_parent_id is not None: avalon_parent_id = str(avalon_parent_id) ftrack_parent_mongo_id = self.ftrack_avalon_mapper[ ftrack_parent_id ] # if parent is project if (ftrack_parent_mongo_id == avalon_parent_id) or ( ftrack_parent_id == self.ft_project_id and avalon_parent_id is None ): parent_check = True # check name ftrack_name = ftrack_ent_dict["name"] avalon_name = avalon_entity["name"] name_check = ftrack_name == avalon_name # IDEAL STATE: both parent and name check passed if parent_check and name_check: continue # If entity is changeable then change values of parent or name if self.changeability_by_mongo_id[avalon_id]: # TODO logging if not parent_check: if ftrack_parent_mongo_id == str(self.avalon_project_id): new_parent_name = self.entities_dict[ self.ft_project_id]["name"] new_parent_id = None else: new_parent_name = self.avalon_ents_by_id[ ftrack_parent_mongo_id]["name"] new_parent_id = ObjectId(ftrack_parent_mongo_id) if avalon_parent_id == str(self.avalon_project_id): old_parent_name = self.entities_dict[ self.ft_project_id]["name"] else: old_parent_name = "N/A" if ftrack_parent_mongo_id in self.avalon_ents_by_id: old_parent_name = ( self.avalon_ents_by_id [ftrack_parent_mongo_id] ["name"] ) self.updates[avalon_id]["data"] = { "visualParent": new_parent_id } ignore_keys[ftrack_id].append("data.visualParent") self.log.debug(( "Avalon entity \"{}\" changed parent \"{}\" -> \"{}\"" ).format(avalon_name, old_parent_name, new_parent_name)) if not name_check: self.updates[avalon_id]["name"] = ftrack_name ignore_keys[ftrack_id].append("name") self.log.debug( "Avalon entity \"{}\" was renamed to \"{}\"".format( avalon_name, ftrack_name ) ) continue # parents and hierarchy must be recalculated hierarchy_changing_ids.append(ftrack_id) # Parent is project if avalon_parent_id is set to None if avalon_parent_id is None: avalon_parent_id = str(self.avalon_project_id) if not name_check: ent_path = self.get_ent_path(ftrack_id) # TODO report # TODO logging self.entities_dict[ftrack_id]["name"] = avalon_name self.entities_dict[ftrack_id]["entity"]["name"] = ( avalon_name ) self.entities_dict[ftrack_id]["final_entity"]["name"] = ( avalon_name ) self.log.warning("Name was changed back to {} <{}>".format( avalon_name, ent_path )) self._ent_paths_by_ftrack_id.pop(ftrack_id, None) msg = ( " It is not possible to change" " the name of an entity or it's parents, " " if it already contained published data." ) self.report_items["warning"][msg].append(ent_path) # skip parent oricessing if hierarchy didn't change if parent_check: continue # Logic when parenting(hierarchy) has changed and should not old_ftrack_parent_id = self.avalon_ftrack_mapper.get( avalon_parent_id ) # If last ftrack parent id from mongo entity exist then just # remap paren_id on entity if old_ftrack_parent_id: # TODO report # TODO logging ent_path = self.get_ent_path(ftrack_id) msg = ( " It is not possible" " to change the hierarchy of an entity or it's parents," " if it already contained published data." ) self.report_items["warning"][msg].append(ent_path) self.log.warning(( " Entity contains published data so it was moved" " back to it's original hierarchy <{}>" ).format(ent_path)) self.entities_dict[ftrack_id]["entity"]["parent_id"] = ( old_ftrack_parent_id ) self.entities_dict[ftrack_id]["parent_id"] = ( old_ftrack_parent_id ) self.entities_dict[old_ftrack_parent_id][ "children" ].append(ftrack_id) continue old_parent_ent = self.avalon_ents_by_id.get(avalon_parent_id) if not old_parent_ent: old_parent_ent = self.avalon_archived_by_id.get( avalon_parent_id ) # TODO report # TODO logging if not old_parent_ent: self.log.warning(( "Parent entity was not found by id" " - Trying to find by parent name" )) ent_path = self.get_ent_path(ftrack_id) parents = avalon_entity["data"]["parents"] parent_name = parents[-1] matching_entity_id = None for id, entity_dict in self.entities_dict.items(): if entity_dict["name"] == parent_name: matching_entity_id = id break if matching_entity_id is None: # TODO logging # TODO report (turn off auto-sync?) self.log.error(( "The entity contains published data but it was moved" " to a different place in the hierarchy and it's" " previous parent cannot be found." " It's impossible to solve this programmatically <{}>" ).format(ent_path)) msg = ( " Hierarchy of an entity" " can't be changed due to published data and missing" " previous parent" ) self.report_items["error"][msg].append(ent_path) self.filter_with_children(ftrack_id) continue matching_ent_dict = self.entities_dict.get(matching_entity_id) match_ent_parents = matching_ent_dict.get( "final_entity", {}).get( "data", {}).get( "parents", ["__NOTSET__"] ) # TODO logging # TODO report if ( len(match_ent_parents) >= len(parents) or match_ent_parents[:-1] != parents ): ent_path = self.get_ent_path(ftrack_id) self.log.error(( "The entity contains published data but it was moved" " to a different place in the hierarchy and it's" " previous parents were moved too." " It's impossible to solve this programmatically <{}>" ).format(ent_path)) msg = ( " Hierarchy of an entity" " can't be changed due to published data and scrambled" "hierarchy" ) continue old_parent_ent = matching_ent_dict["final_entity"] parent_id = self.ft_project_id entities_to_create = [] # TODO logging self.log.warning( "Ftrack entities must be recreated because they were deleted," " but they contain published data." ) _avalon_ent = old_parent_ent self.updates[avalon_parent_id] = {"type": "asset"} success = True while True: _vis_par = _avalon_ent["data"]["visualParent"] _name = _avalon_ent["name"] if _name in self.all_ftrack_names: av_ent_path_items = list(_avalon_ent["data"]["parents"]) av_ent_path_items.append(_name) av_ent_path = "/".join(av_ent_path_items) # TODO report # TODO logging self.log.error(( "Can't recreate the entity in Ftrack because an entity" " with the same name already exists in a different" " place in the hierarchy <{}>" ).format(av_ent_path)) msg = ( " Hierarchy of an entity" " can't be changed. I contains published data and it's" " previous parent had a name, that is duplicated at a " " different hierarchy level" ) self.report_items["error"][msg].append(av_ent_path) self.filter_with_children(ftrack_id) success = False break entities_to_create.append(_avalon_ent) if _vis_par is None: break _vis_par = str(_vis_par) _mapped = self.avalon_ftrack_mapper.get(_vis_par) if _mapped: parent_id = _mapped break _avalon_ent = self.avalon_ents_by_id.get(_vis_par) if not _avalon_ent: _avalon_ent = self.avalon_archived_by_id.get(_vis_par) if success is False: continue new_entity_id = None for av_entity in reversed(entities_to_create): new_entity_id = self.create_ftrack_ent_from_avalon_ent( av_entity, parent_id ) update_queue.append(new_entity_id) if new_entity_id: ftrack_ent_dict["entity"]["parent_id"] = new_entity_id if hierarchy_changing_ids: self.reload_parents(hierarchy_changing_ids) for ftrack_id in self.update_ftrack_ids: if ftrack_id == self.ft_project_id: continue avalon_id = self.ftrack_avalon_mapper[ftrack_id] avalon_entity = self.avalon_ents_by_id[avalon_id] avalon_attrs = self.entities_dict[ftrack_id]["avalon_attrs"] if ( CUST_ATTR_ID_KEY not in avalon_attrs or avalon_attrs[CUST_ATTR_ID_KEY] != avalon_id ): configuration_id = self.entities_dict[ftrack_id][ "avalon_attrs_id"][CUST_ATTR_ID_KEY] _entity_key = collections.OrderedDict([ ("configuration_id", configuration_id), ("entity_id", ftrack_id) ]) self.session.recorded_operations.push( ftrack_api.operation.UpdateEntityOperation( "ContextCustomAttributeValue", _entity_key, "value", ftrack_api.symbol.NOT_SET, avalon_id ) ) # Prepare task changes as they have to be stored as one key final_doc = self.entities_dict[ftrack_id]["final_entity"] final_doc_tasks = final_doc["data"].pop("tasks", None) or {} current_doc_tasks = avalon_entity["data"].get("tasks") or {} if not final_doc_tasks: update_tasks = True else: update_tasks = final_doc_tasks != current_doc_tasks # check rest of data data_changes = self.compare_dict( final_doc, avalon_entity, ignore_keys[ftrack_id] ) if data_changes: self.updates[avalon_id] = self.merge_dicts( data_changes, self.updates[avalon_id] ) # Add tasks back to final doc object final_doc["data"]["tasks"] = final_doc_tasks # Add tasks to updates if there are different if update_tasks: if "data" not in self.updates[avalon_id]: self.updates[avalon_id]["data"] = {} self.updates[avalon_id]["data"]["tasks"] = final_doc_tasks def synchronize(self): self.log.debug("* Synchronization begins") avalon_project_id = self.ftrack_avalon_mapper.get(self.ft_project_id) if avalon_project_id: self.avalon_project_id = ObjectId(avalon_project_id) # remove filtered ftrack ids from create/update list for ftrack_id in self.all_filtered_entities: if ftrack_id in self.create_ftrack_ids: self.create_ftrack_ids.remove(ftrack_id) elif ftrack_id in self.update_ftrack_ids: self.update_ftrack_ids.remove(ftrack_id) self.log.debug("* Processing entities for archivation") self.delete_entities() self.log.debug("* Processing new entities") # Create not created entities for ftrack_id in self.create_ftrack_ids: # CHECK it is possible that entity was already created # because is parent of another entity which was processed first if ftrack_id not in self.ftrack_avalon_mapper: self.create_avalon_entity(ftrack_id) self.set_input_links() unarchive_writes = [] for item in self.unarchive_list: mongo_id = item["_id"] unarchive_writes.append(ReplaceOne( {"_id": mongo_id}, item )) av_ent_path_items = list(item["data"]["parents"]) av_ent_path_items.append(item["name"]) av_ent_path = "/".join(av_ent_path_items) self.log.debug( "Entity was unarchived <{}>".format(av_ent_path) ) self.remove_from_archived(mongo_id) if unarchive_writes: self.dbcon.bulk_write(unarchive_writes) if len(self.create_list) > 0: self.dbcon.insert_many(self.create_list) self.session.commit() self.log.debug("* Processing entities for update") self.prepare_changes() self.update_entities() self.session.commit() def create_avalon_entity(self, ftrack_id): if ftrack_id == self.ft_project_id: self.create_avalon_project() return entity_dict = self.entities_dict[ftrack_id] parent_ftrack_id = entity_dict["parent_id"] avalon_parent = None if parent_ftrack_id != self.ft_project_id: avalon_parent = self.ftrack_avalon_mapper.get(parent_ftrack_id) # if not avalon_parent: # self.create_avalon_entity(parent_ftrack_id) # avalon_parent = self.ftrack_avalon_mapper[parent_ftrack_id] avalon_parent = ObjectId(avalon_parent) # avalon_archived_by_id avalon_archived_by_name current_id = ( entity_dict["avalon_attrs"].get(CUST_ATTR_ID_KEY) or "" ).strip() mongo_id = current_id name = entity_dict["name"] # Check if exist archived asset in mongo - by ID unarchive = False unarchive_id = self.check_unarchivation(ftrack_id, mongo_id, name) if unarchive_id is not None: unarchive = True mongo_id = unarchive_id item = entity_dict["final_entity"] try: new_id = ObjectId(mongo_id) if mongo_id in self.avalon_ftrack_mapper: new_id = ObjectId() except InvalidId: new_id = ObjectId() item["_id"] = new_id item["parent"] = self.avalon_project_id item["schema"] = CURRENT_ASSET_DOC_SCHEMA item["data"]["visualParent"] = avalon_parent new_id_str = str(new_id) self.ftrack_avalon_mapper[ftrack_id] = new_id_str self.avalon_ftrack_mapper[new_id_str] = ftrack_id self._avalon_ents_by_id[new_id_str] = item self._avalon_ents_by_ftrack_id[ftrack_id] = new_id_str self._avalon_ents_by_name[item["name"]] = new_id_str if current_id != new_id_str: # store mongo id to ftrack entity configuration_id = self.hier_cust_attr_ids_by_key.get( CUST_ATTR_ID_KEY ) if not configuration_id: # NOTE this is for cases when CUST_ATTR_ID_KEY key is not # hierarchical custom attribute but per entity type configuration_id = self.entities_dict[ftrack_id][ "avalon_attrs_id" ][CUST_ATTR_ID_KEY] _entity_key = collections.OrderedDict({ "configuration_id": configuration_id, "entity_id": ftrack_id }) self.session.recorded_operations.push( ftrack_api.operation.UpdateEntityOperation( "ContextCustomAttributeValue", _entity_key, "value", ftrack_api.symbol.NOT_SET, new_id_str ) ) if unarchive is False: self.create_list.append(item) else: self.unarchive_list.append(item) def check_unarchivation(self, ftrack_id, mongo_id, name): archived_by_id = self.avalon_archived_by_id.get(mongo_id) archived_by_name = self.avalon_archived_by_name.get(name) # if not found in archived then skip if not archived_by_id and not archived_by_name: return None entity_dict = self.entities_dict[ftrack_id] final_parents = entity_dict["final_entity"]["data"]["parents"] if archived_by_id: # if is changeable then unarchive (nothing to check here) if self.changeability_by_mongo_id[mongo_id]: return mongo_id # TODO replace `__NOTSET__` with custom None constant archived_parent_id = archived_by_id["data"].get( "visualParent", "__NOTSET__" ) archived_parents = archived_by_id["data"].get("parents") archived_name = archived_by_id["name"] if ( archived_name != entity_dict["name"] or archived_parents != final_parents ): return None return mongo_id # First check if there is any that have same parents for archived in archived_by_name: mongo_id = str(archived["_id"]) archived_parents = archived.get("data", {}).get("parents") if archived_parents == final_parents: return mongo_id # Secondly try to find more close to current ftrack entity first_changeable = None for archived in archived_by_name: mongo_id = str(archived["_id"]) if not self.changeability_by_mongo_id[mongo_id]: continue if first_changeable is None: first_changeable = mongo_id ftrack_parent_id = entity_dict["parent_id"] map_ftrack_parent_id = self.ftrack_avalon_mapper.get( ftrack_parent_id ) # TODO replace `__NOTSET__` with custom None constant archived_parent_id = archived.get("data", {}).get( "visualParent", "__NOTSET__" ) if archived_parent_id is not None: archived_parent_id = str(archived_parent_id) # skip if parent is archived - How this should be possible? parent_entity = self.avalon_ents_by_id.get(archived_parent_id) if ( parent_entity and ( map_ftrack_parent_id is not None and map_ftrack_parent_id == str(parent_entity["_id"]) ) ): return mongo_id # Last return first changeable with same name (or None) return first_changeable def create_avalon_project(self): project_item = self.entities_dict[self.ft_project_id]["final_entity"] mongo_id = ( self.entities_dict[self.ft_project_id]["avalon_attrs"].get( CUST_ATTR_ID_KEY ) or "" ).strip() try: new_id = ObjectId(mongo_id) except InvalidId: new_id = ObjectId() project_item["_id"] = new_id project_item["parent"] = None project_item["schema"] = CURRENT_PROJECT_SCHEMA project_item["config"]["schema"] = CURRENT_PROJECT_CONFIG_SCHEMA self.ftrack_avalon_mapper[self.ft_project_id] = new_id self.avalon_ftrack_mapper[new_id] = self.ft_project_id self.avalon_project_id = new_id self._avalon_ents_by_id[str(new_id)] = project_item if self._avalon_ents_by_ftrack_id is None: self._avalon_ents_by_ftrack_id = {} self._avalon_ents_by_ftrack_id[self.ft_project_id] = str(new_id) if self._avalon_ents_by_name is None: self._avalon_ents_by_name = {} self._avalon_ents_by_name[project_item["name"]] = str(new_id) self.create_list.append(project_item) self.project_created = True # store mongo id to ftrack entity entity = self.entities_dict[self.ft_project_id]["entity"] entity["custom_attributes"][CUST_ATTR_ID_KEY] = str(new_id) def _bubble_changeability(self, unchangeable_ids): unchangeable_queue = collections.deque() for entity_id in unchangeable_ids: unchangeable_queue.append((entity_id, False)) processed_parents_ids = [] subsets_to_remove = [] while unchangeable_queue: entity_id, child_is_archived = unchangeable_queue.popleft() # skip if already processed if entity_id in processed_parents_ids: continue entity = self.avalon_ents_by_id.get(entity_id) # if entity is not archived but unchageable child was then skip # - archived entities should not affect not archived? if entity and child_is_archived: continue # set changeability of current entity to False self._changeability_by_mongo_id[entity_id] = False processed_parents_ids.append(entity_id) # if not entity then is probably archived if not entity: entity = self.avalon_archived_by_id.get(entity_id) child_is_archived = True if not entity: # if entity is not found then it is subset without parent if entity_id in unchangeable_ids: subsets_to_remove.append(entity_id) else: # TODO logging - What is happening here? self.log.warning(( "Avalon contains entities without valid parents that" " lead to Project (should not cause errors)" " - MongoId <{}>" ).format(str(entity_id))) continue # skip if parent is project parent_id = entity["data"]["visualParent"] if parent_id is None: continue unchangeable_queue.append( (str(parent_id), child_is_archived) ) self._delete_subsets_without_asset(subsets_to_remove) def _delete_subsets_without_asset(self, not_existing_parents): repre_ids = [] to_delete = [] subset_ids = [] for parent_id in not_existing_parents: subsets = self.subsets_by_parent_id.get(parent_id) if not subsets: continue for subset in subsets: if subset.get("type") == "subset": subset_ids.append(subset["_id"]) db_versions = get_versions( self.project_name, subset_ids=subset_ids, fields=["_id"] ) version_ids = [ver["_id"] for ver in db_versions] db_repres = get_representations( self.project_name, version_ids=version_ids, fields=["_id"] ) repre_ids = [repre["_id"] for repre in db_repres] to_delete.extend(subset_ids) to_delete.extend(version_ids) to_delete.extend(repre_ids) if to_delete: self.dbcon.delete_many({"_id": {"$in": to_delete}}) # Probably deprecated def _check_changeability(self, parent_id=None): for entity in self.avalon_ents_by_parent_id[parent_id]: mongo_id = str(entity["_id"]) is_changeable = self._changeability_by_mongo_id.get(mongo_id) if is_changeable is not None: continue self._check_changeability(mongo_id) is_changeable = True for child in self.avalon_ents_by_parent_id[parent_id]: if not self._changeability_by_mongo_id[str(child["_id"])]: is_changeable = False break if is_changeable is True: is_changeable = (mongo_id in self.subsets_by_parent_id) self._changeability_by_mongo_id[mongo_id] = is_changeable def update_entities(self): """ Runs changes converted to "$set" queries in bulk. """ mongo_changes_bulk = [] for mongo_id, changes in self.updates.items(): mongo_id = ObjectId(mongo_id) is_project = mongo_id == self.avalon_project_id change_data = from_dict_to_set(changes, is_project) filter = {"_id": mongo_id} mongo_changes_bulk.append(UpdateOne(filter, change_data)) if not mongo_changes_bulk: # TODO LOG return self.dbcon.bulk_write(mongo_changes_bulk) def reload_parents(self, hierarchy_changing_ids): parents_queue = collections.deque() parents_queue.append((self.ft_project_id, [], False)) while parents_queue: ftrack_id, parent_parents, changed = parents_queue.popleft() _parents = copy.deepcopy(parent_parents) if ftrack_id not in hierarchy_changing_ids and not changed: if ftrack_id != self.ft_project_id: _parents.append(self.entities_dict[ftrack_id]["name"]) for child_id in self.entities_dict[ftrack_id]["children"]: parents_queue.append( (child_id, _parents, changed) ) continue changed = True parents = list(_parents) self.entities_dict[ftrack_id][ "final_entity"]["data"]["parents"] = parents _parents.append(self.entities_dict[ftrack_id]["name"]) for child_id in self.entities_dict[ftrack_id]["children"]: parents_queue.append( (child_id, _parents, changed) ) if ftrack_id in self.create_ftrack_ids: mongo_id = self.ftrack_avalon_mapper[ftrack_id] if "data" not in self.updates[mongo_id]: self.updates[mongo_id]["data"] = {} self.updates[mongo_id]["data"]["parents"] = parents def prepare_project_changes(self): ftrack_ent_dict = self.entities_dict[self.ft_project_id] ftrack_entity = ftrack_ent_dict["entity"] avalon_code = self.avalon_project["data"]["code"] # TODO Is possible to sync if full name was changed? # if ftrack_ent_dict["name"] != self.avalon_project["name"]: # ftrack_entity["full_name"] = avalon_name # self.entities_dict[self.ft_project_id]["name"] = avalon_name # self.entities_dict[self.ft_project_id]["final_entity"][ # "name" # ] = avalon_name # TODO logging # TODO report # TODO May this happen? Is possible to change project code? if ftrack_entity["name"] != avalon_code: ftrack_entity["name"] = avalon_code self.entities_dict[self.ft_project_id]["final_entity"]["data"][ "code" ] = avalon_code self.session.commit() sub_msg = ( "Project code was changed back to \"{}\"".format(avalon_code) ) msg = ( "It is not possible to change" " project code after synchronization" ) self.report_items["warning"][msg] = sub_msg self.log.warning(sub_msg) # Compare tasks from current project schema and previous project schema final_doc_data = self.entities_dict[self.ft_project_id]["final_entity"] final_doc_tasks = final_doc_data["config"].pop("tasks") current_doc_tasks = self.avalon_project.get("config", {}).get("tasks") # Update project's task types if not current_doc_tasks: update_tasks = True else: # Check if task types are same update_tasks = False for task_type in final_doc_tasks: if task_type not in current_doc_tasks: update_tasks = True break # Update new task types # - but keep data about existing types and only add new one if update_tasks: for task_type, type_data in current_doc_tasks.items(): final_doc_tasks[task_type] = type_data changes = self.compare_dict(final_doc_data, self.avalon_project) # Put back tasks data to final entity object final_doc_data["config"]["tasks"] = final_doc_tasks # Add tasks updates if tasks changed if update_tasks: if "config" not in changes: changes["config"] = {} changes["config"]["tasks"] = final_doc_tasks return changes def compare_dict(self, dict_new, dict_old, _ignore_keys=[]): """ Recursively compares and list changes between dictionaries 'dict_new' and 'dict_old'. Keys in '_ignore_keys' are skipped and not compared. Args: dict_new (dictionary): dict_old (dictionary): _ignore_keys (list): Returns: (dictionary) of new or updated keys and theirs values """ # _ignore_keys may be used for keys nested dict like"data.visualParent" changes = {} ignore_keys = [] for key_val in _ignore_keys: key_items = key_val.split(".") if len(key_items) == 1: ignore_keys.append(key_items[0]) for key, value in dict_new.items(): if key in ignore_keys: continue if key not in dict_old: changes[key] = value continue if isinstance(value, dict): if not isinstance(dict_old[key], dict): changes[key] = value continue _new_ignore_keys = [] for key_val in _ignore_keys: key_items = key_val.split(".") if len(key_items) <= 1: continue _new_ignore_keys.append(".".join(key_items[1:])) _changes = self.compare_dict( value, dict_old[key], _new_ignore_keys ) if _changes: changes[key] = _changes continue if value != dict_old[key]: changes[key] = value return changes def merge_dicts(self, dict_new, dict_old): """ Apply all new or updated keys from 'dict_new' on 'dict_old'. Recursively. Doesn't recognise that 'dict_new' doesn't contain some keys anymore. Args: dict_new (dictionary): from Ftrack most likely dict_old (dictionary): current in DB Returns: (dictionary) of applied changes to original dictionary """ for key, value in dict_new.items(): if key not in dict_old: dict_old[key] = value continue if isinstance(value, dict): dict_old[key] = self.merge_dicts(value, dict_old[key]) continue dict_old[key] = value return dict_old def delete_entities(self): if not self.deleted_entities: return # Try to order so child is not processed before parent deleted_entities = [] _deleted_entities = [id for id in self.deleted_entities] while True: if not _deleted_entities: break _ready = [] for mongo_id in _deleted_entities: ent = self.avalon_ents_by_id[mongo_id] vis_par = ent["data"]["visualParent"] if ( vis_par is not None and str(vis_par) in _deleted_entities ): continue _ready.append(mongo_id) for id in _ready: deleted_entities.append(id) _deleted_entities.remove(id) delete_ids = [] for mongo_id in deleted_entities: # delete if they are deletable if self.changeability_by_mongo_id[mongo_id]: delete_ids.append(ObjectId(mongo_id)) continue # check if any new created entity match same entity # - name and parents must match deleted_entity = self.avalon_ents_by_id[mongo_id] name = deleted_entity["name"] parents = deleted_entity["data"]["parents"] similar_ent_id = None for ftrack_id in self.create_ftrack_ids: _ent_final = self.entities_dict[ftrack_id]["final_entity"] if _ent_final["name"] != name: continue if _ent_final["data"]["parents"] != parents: continue # If in create is "same" then we can "archive" current # since will be unarchived in create method similar_ent_id = ftrack_id break # If similar entity(same name and parents) is in create # entities list then just change from create to update if similar_ent_id is not None: self.create_ftrack_ids.remove(similar_ent_id) self.update_ftrack_ids.append(similar_ent_id) self.avalon_ftrack_mapper[mongo_id] = similar_ent_id self.ftrack_avalon_mapper[similar_ent_id] = mongo_id continue found_by_name_id = None for ftrack_id, ent_dict in self.entities_dict.items(): if not ent_dict.get("name"): continue if name == ent_dict["name"]: found_by_name_id = ftrack_id break if found_by_name_id is not None: # * THESE conditins are too complex to implement in first stage # - probably not possible to solve if this happen # if found_by_name_id in self.create_ftrack_ids: # # reparent entity of the new one create? # pass # # elif found_by_name_id in self.update_ftrack_ids: # found_mongo_id = self.ftrack_avalon_mapper[found_by_name_id] # # ent_dict = self.entities_dict[found_by_name_id] # TODO report - CRITICAL entity with same name already exists # in different hierarchy - can't recreate entity continue _vis_parent = deleted_entity["data"]["visualParent"] if _vis_parent is None: _vis_parent = self.avalon_project_id _vis_parent = str(_vis_parent) ftrack_parent_id = self.avalon_ftrack_mapper[_vis_parent] self.create_ftrack_ent_from_avalon_ent( deleted_entity, ftrack_parent_id ) filter = {"_id": {"$in": delete_ids}, "type": "asset"} self.dbcon.update_many(filter, {"$set": {"type": "archived_asset"}}) def create_ftrack_ent_from_avalon_ent(self, av_entity, parent_id): new_entity = None parent_entity = self.entities_dict[parent_id]["entity"] _name = av_entity["name"] _type = av_entity["data"].get("entityType") # Check existence of object type if _type and _type not in self.object_types_by_name: _type = None if not _type: _type = "Folder" self.log.debug(( "Re-ceating deleted entity {} <{}>" ).format(_name, _type)) new_entity = self.session.create(_type, { "name": _name, "parent": parent_entity }) self.session.commit() final_entity = {} for k, v in av_entity.items(): final_entity[k] = v if final_entity.get("type") != "asset": final_entity["type"] = "asset" new_entity_id = new_entity["id"] new_entity_data = { "entity": new_entity, "parent_id": parent_id, "entity_type": _type.lower(), "entity_type_orig": _type, "name": _name, "final_entity": final_entity } for k, v in new_entity_data.items(): self.entities_dict[new_entity_id][k] = v p_chilren = self.entities_dict[parent_id]["children"] if new_entity_id not in p_chilren: self.entities_dict[parent_id]["children"].append(new_entity_id) cust_attr, _ = get_openpype_attr(self.session) for _attr in cust_attr: key = _attr["key"] if key not in av_entity["data"]: continue if key not in new_entity["custom_attributes"]: continue value = av_entity["data"][key] if not value: continue new_entity["custom_attributes"][key] = value av_entity_id = str(av_entity["_id"]) new_entity["custom_attributes"][CUST_ATTR_ID_KEY] = av_entity_id self.ftrack_avalon_mapper[new_entity_id] = av_entity_id self.avalon_ftrack_mapper[av_entity_id] = new_entity_id self.session.commit() ent_path = self.get_ent_path(new_entity_id) msg = ( "Deleted entity was recreated because it or its children" " contain published data" ) self.report_items["info"][msg].append(ent_path) return new_entity_id def regex_duplicate_interface(self): items = [] if self.failed_regex or self.tasks_failed_regex: subtitle = "Entity names contain prohibited symbols:" items.append({ "type": "label", "value": "# {}".format(subtitle) }) items.append({ "type": "label", "value": ( "

NOTE: You can use Letters( a-Z )," " Numbers( 0-9 ) and Underscore( _ )

" ) }) log_msgs = [] for name, ids in self.failed_regex.items(): error_title = { "type": "label", "value": "## {}".format(name) } items.append(error_title) paths = [] for entity_id in ids: ent_path = self.get_ent_path(entity_id) paths.append(ent_path) error_message = { "type": "label", "value": '

{}

'.format("
".join(paths)) } items.append(error_message) log_msgs.append("<{}> ({})".format(name, ",".join(paths))) for name, ids in self.tasks_failed_regex.items(): error_title = { "type": "label", "value": "## Task: {}".format(name) } items.append(error_title) paths = [] for entity_id in ids: ent_path = self.get_ent_path(entity_id) ent_path = "/".join([ent_path, name]) paths.append(ent_path) error_message = { "type": "label", "value": '

{}

'.format("
".join(paths)) } items.append(error_message) log_msgs.append("<{}> ({})".format(name, ",".join(paths))) self.log.warning("{}{}".format(subtitle, ", ".join(log_msgs))) if self.duplicates: subtitle = "Duplicated entity names:" items.append({ "type": "label", "value": "# {}".format(subtitle) }) items.append({ "type": "label", "value": ( "

NOTE: It is not allowed to use the same name" " for multiple entities in the same project

" ) }) log_msgs = [] for name, ids in self.duplicates.items(): error_title = { "type": "label", "value": "## {}".format(name) } items.append(error_title) paths = [] for entity_id in ids: ent_path = self.get_ent_path(entity_id) paths.append(ent_path) error_message = { "type": "label", "value": '

{}

'.format("
".join(paths)) } items.append(error_message) log_msgs.append("<{}> ({})".format(name, ", ".join(paths))) self.log.warning("{}{}".format(subtitle, ", ".join(log_msgs))) return items def report(self): items = [] title = "Synchronization report ({}):".format(self.project_name) keys = ["error", "warning", "info"] for key in keys: subitems = [] if key == "warning": for _item in self.regex_duplicate_interface(): subitems.append(_item) for msg, _items in self.report_items[key].items(): if not _items: continue subitems.append({ "type": "label", "value": "# {}".format(msg) }) if isinstance(_items, str): _items = [_items] subitems.append({ "type": "label", "value": '

{}

'.format("
".join(_items)) }) if items and subitems: items.append(self.report_splitter) items.extend(subitems) return { "items": items, "title": title, "success": False, "message": "Synchronization Finished" } ================================================ FILE: openpype/modules/ftrack/lib/constants.py ================================================ # Group name of custom attributes CUST_ATTR_GROUP = "openpype" # name of Custom attribute that stores mongo_id from avalon db CUST_ATTR_ID_KEY = "avalon_mongo_id" # Auto sync of project CUST_ATTR_AUTO_SYNC = "avalon_auto_sync" # Applications custom attribute name CUST_ATTR_APPLICATIONS = "applications" # Environment tools custom attribute CUST_ATTR_TOOLS = "tools_env" # Intent custom attribute name CUST_ATTR_INTENT = "intent" FPS_KEYS = { "fps", # For development purposes "fps_string" } ================================================ FILE: openpype/modules/ftrack/lib/credentials.py ================================================ import os import ftrack_api try: from urllib.parse import urlparse except ImportError: from urlparse import urlparse from openpype.lib import OpenPypeSecureRegistry USERNAME_KEY = "username" API_KEY_KEY = "api_key" def get_ftrack_hostname(ftrack_server=None): if not ftrack_server: ftrack_server = os.environ.get("FTRACK_SERVER") if not ftrack_server: return None if "//" not in ftrack_server: ftrack_server = "//" + ftrack_server return urlparse(ftrack_server).hostname def _get_ftrack_secure_key(hostname, key): """Secure item key for entered hostname.""" return "/".join(("ftrack", hostname, key)) def get_credentials(ftrack_server=None): output = { USERNAME_KEY: None, API_KEY_KEY: None } hostname = get_ftrack_hostname(ftrack_server) if not hostname: return output username_name = _get_ftrack_secure_key(hostname, USERNAME_KEY) api_key_name = _get_ftrack_secure_key(hostname, API_KEY_KEY) username_registry = OpenPypeSecureRegistry(username_name) api_key_registry = OpenPypeSecureRegistry(api_key_name) output[USERNAME_KEY] = username_registry.get_item(USERNAME_KEY, None) output[API_KEY_KEY] = api_key_registry.get_item(API_KEY_KEY, None) return output def save_credentials(username, api_key, ftrack_server=None): hostname = get_ftrack_hostname(ftrack_server) username_name = _get_ftrack_secure_key(hostname, USERNAME_KEY) api_key_name = _get_ftrack_secure_key(hostname, API_KEY_KEY) # Clear credentials clear_credentials(ftrack_server) username_registry = OpenPypeSecureRegistry(username_name) api_key_registry = OpenPypeSecureRegistry(api_key_name) username_registry.set_item(USERNAME_KEY, username) api_key_registry.set_item(API_KEY_KEY, api_key) def clear_credentials(ftrack_server=None): hostname = get_ftrack_hostname(ftrack_server) username_name = _get_ftrack_secure_key(hostname, USERNAME_KEY) api_key_name = _get_ftrack_secure_key(hostname, API_KEY_KEY) username_registry = OpenPypeSecureRegistry(username_name) api_key_registry = OpenPypeSecureRegistry(api_key_name) current_username = username_registry.get_item(USERNAME_KEY, None) current_api_key = api_key_registry.get_item(API_KEY_KEY, None) if current_username is not None: username_registry.delete_item(USERNAME_KEY) if current_api_key is not None: api_key_registry.delete_item(API_KEY_KEY) def check_credentials(username, api_key, ftrack_server=None): if not ftrack_server: ftrack_server = os.environ.get("FTRACK_SERVER") if not ftrack_server or not username or not api_key: return False user_exists = False try: session = ftrack_api.Session( server_url=ftrack_server, api_key=api_key, api_user=username ) # Validated that the username actually exists user = session.query("User where username is \"{}\"".format(username)) user_exists = user is not None session.close() except Exception: pass return user_exists ================================================ FILE: openpype/modules/ftrack/lib/custom_attributes.json ================================================ { "show": { "avalon_auto_sync": { "label": "Avalon auto-sync", "type": "boolean" }, "library_project": { "label": "Library Project", "type": "boolean" } }, "is_hierarchical": { "fps": { "label": "FPS", "type": "number", "config": {"isdecimal": true} }, "clipIn": { "label": "Clip in", "type": "number" }, "clipOut": { "label": "Clip out", "type": "number" }, "frameStart": { "label": "Frame start", "type": "number" }, "frameEnd": { "label": "Frame end", "type": "number" }, "resolutionWidth": { "label": "Resolution Width", "type": "number" }, "resolutionHeight": { "label": "Resolution Height", "type": "number" }, "pixelAspect": { "label": "Pixel aspect", "type": "number", "config": {"isdecimal": true} }, "handleStart": { "label": "Frame handles start", "type": "number" }, "handleEnd": { "label": "Frame handles end", "type": "number" } } } ================================================ FILE: openpype/modules/ftrack/lib/custom_attributes.py ================================================ import os import json from .constants import CUST_ATTR_GROUP def default_custom_attributes_definition(): json_file_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), "custom_attributes.json" ) with open(json_file_path, "r") as json_stream: data = json.load(json_stream) return data def app_definitions_from_app_manager(app_manager): _app_definitions = [] for app_name, app in app_manager.applications.items(): if app.enabled: _app_definitions.append( (app_name, app.full_label) ) # Sort items by label app_definitions = [] for key, label in sorted(_app_definitions, key=lambda item: item[1]): app_definitions.append({key: label}) if not app_definitions: app_definitions.append({"empty": "< Empty >"}) return app_definitions def tool_definitions_from_app_manager(app_manager): _tools_data = [] for tool_name, tool in app_manager.tools.items(): _tools_data.append( (tool_name, tool.label) ) # Sort items by label tools_data = [] for key, label in sorted(_tools_data, key=lambda item: item[1]): tools_data.append({key: label}) # Make sure there is at least one item if not tools_data: tools_data.append({"empty": "< Empty >"}) return tools_data def get_openpype_attr(session, split_hierarchical=True, query_keys=None): custom_attributes = [] hier_custom_attributes = [] if not query_keys: query_keys = [ "id", "entity_type", "object_type_id", "is_hierarchical", "default" ] # TODO remove deprecated "pype" group from query cust_attrs_query = ( "select {}" " from CustomAttributeConfiguration" # Kept `pype` for Backwards Compatibility " where group.name in (\"pype\", \"ayon\", \"{}\")" ).format(", ".join(query_keys), CUST_ATTR_GROUP) all_avalon_attr = session.query(cust_attrs_query).all() for cust_attr in all_avalon_attr: if split_hierarchical and cust_attr["is_hierarchical"]: hier_custom_attributes.append(cust_attr) continue custom_attributes.append(cust_attr) if split_hierarchical: # return tuple return custom_attributes, hier_custom_attributes return custom_attributes def join_query_keys(keys): """Helper to join keys to query.""" return ",".join(["\"{}\"".format(key) for key in keys]) def query_custom_attributes( session, conf_ids, entity_ids, only_set_values=False ): """Query custom attribute values from ftrack database. Using ftrack call method result may differ based on used table name and version of ftrack server. For hierarchical attributes you shou always use `only_set_values=True` otherwise result will be default value of custom attribute and it would not be possible to differentiate if value is set on entity or default value is used. Args: session(ftrack_api.Session): Connected ftrack session. conf_id(list, set, tuple): Configuration(attribute) ids which are queried. entity_ids(list, set, tuple): Entity ids for which are values queried. only_set_values(bool): Entities that don't have explicitly set value won't return a value. If is set to False then default custom attribute value is returned if value is not set. """ output = [] # Just skip if not conf_ids or not entity_ids: return output if only_set_values: table_name = "CustomAttributeValue" else: table_name = "ContextCustomAttributeValue" # Prepare values to query attributes_joined = join_query_keys(conf_ids) attributes_len = len(conf_ids) # Query values in chunks chunk_size = int(5000 / attributes_len) # Make sure entity_ids is `list` for chunk selection entity_ids = list(entity_ids) for idx in range(0, len(entity_ids), chunk_size): entity_ids_joined = join_query_keys( entity_ids[idx:idx + chunk_size] ) output.extend( session.query( ( "select value, entity_id, configuration_id from {}" " where entity_id in ({}) and configuration_id in ({})" ).format( table_name, entity_ids_joined, attributes_joined ) ).all() ) return output ================================================ FILE: openpype/modules/ftrack/lib/ftrack_action_handler.py ================================================ import os from .ftrack_base_handler import BaseHandler def statics_icon(*icon_statics_file_parts): statics_server = os.environ.get("OPENPYPE_STATICS_SERVER") if not statics_server: return None return "/".join((statics_server, *icon_statics_file_parts)) class BaseAction(BaseHandler): '''Custom Action base class `label` a descriptive string identifying your action. `varaint` To group actions together, give them the same label and specify a unique variant per action. `identifier` a unique identifier for your action. `description` a verbose descriptive text for you action ''' label = None variant = None identifier = None description = None icon = None type = 'Action' _discover_identifier = None _launch_identifier = None settings_frack_subkey = "user_handlers" settings_enabled_key = "enabled" def __init__(self, session): '''Expects a ftrack_api.Session instance''' if self.label is None: raise ValueError('Action missing label.') if self.identifier is None: raise ValueError('Action missing identifier.') super().__init__(session) @property def discover_identifier(self): if self._discover_identifier is None: self._discover_identifier = "{}.{}".format( self.identifier, self.process_identifier() ) return self._discover_identifier @property def launch_identifier(self): if self._launch_identifier is None: self._launch_identifier = "{}.{}".format( self.identifier, self.process_identifier() ) return self._launch_identifier def register(self): ''' Registers the action, subscribing the the discover and launch topics. - highest priority event will show last ''' self.session.event_hub.subscribe( 'topic=ftrack.action.discover and source.user.username={0}'.format( self.session.api_user ), self._discover, priority=self.priority ) launch_subscription = ( 'topic=ftrack.action.launch' ' and data.actionIdentifier={0}' ' and source.user.username={1}' ).format( self.launch_identifier, self.session.api_user ) self.session.event_hub.subscribe( launch_subscription, self._launch ) def _discover(self, event): entities = self._translate_event(event) if not entities: return accepts = self.discover(self.session, entities, event) if not accepts: return self.log.debug(u'Discovering action with selection: {0}'.format( event['data'].get('selection', []) )) return { 'items': [{ 'label': self.label, 'variant': self.variant, 'description': self.description, 'actionIdentifier': self.discover_identifier, 'icon': self.icon, }] } def discover(self, session, entities, event): '''Return true if we can handle the selected entities. *session* is a `ftrack_api.Session` instance *entities* is a list of tuples each containing the entity type and the entity id. If the entity is a hierarchical you will always get the entity type TypedContext, once retrieved through a get operation you will have the "real" entity type ie. example Shot, Sequence or Asset Build. *event* the unmodified original event ''' return False def _interface(self, session, entities, event): interface = self.interface(session, entities, event) if not interface: return if isinstance(interface, (tuple, list)): return {"items": interface} if isinstance(interface, dict): if ( "items" in interface or ("success" in interface and "message" in interface) ): return interface raise ValueError(( "Invalid interface output expected key: \"items\" or keys:" " \"success\" and \"message\". Got: \"{}\"" ).format(str(interface))) raise ValueError( "Invalid interface output type \"{}\"".format( str(type(interface)) ) ) def interface(self, session, entities, event): '''Return a interface if applicable or None *session* is a `ftrack_api.Session` instance *entities* is a list of tuples each containing the entity type and the entity id. If the entity is a hierarchical you will always get the entity type TypedContext, once retrieved through a get operation you will have the "real" entity type ie. example Shot, Sequence or Asset Build. *event* the unmodified original event ''' return None def _launch(self, event): entities = self._translate_event(event) if not entities: return preactions_launched = self._handle_preactions(self.session, event) if preactions_launched is False: return interface = self._interface(self.session, entities, event) if interface: return interface response = self.launch(self.session, entities, event) return self._handle_result(response) def _handle_result(self, result): '''Validate the returned result from the action callback''' if isinstance(result, bool): if result is True: msg = 'Action {0} finished.' else: msg = 'Action {0} failed.' return { 'success': result, 'message': msg.format(self.label) } if isinstance(result, dict): if 'items' in result: if not isinstance(result['items'], list): raise ValueError('Invalid items format, must be list!') else: for key in ('success', 'message'): if key not in result: raise KeyError( "Missing required key: {0}.".format(key) ) return result self.log.warning(( 'Invalid result type \"{}\" must be bool or dictionary!' ).format(str(type(result)))) return result @staticmethod def roles_check(settings_roles, user_roles, default=True): """Compare roles from setting and user's roles. Args: settings_roles(list): List of role names from settings. user_roles(list): User's lowered role names. default(bool): If `settings_roles` is empty list. Returns: bool: `True` if user has at least one role from settings or default if `settings_roles` is empty. """ if not settings_roles: return default user_roles = { role_name.lower() for role_name in user_roles } for role_name in settings_roles: if role_name.lower() in user_roles: return True return False @classmethod def get_user_entity_from_event(cls, session, event): """Query user entity from event.""" not_set = object() # Check if user is already stored in event data user_entity = event["data"].get("user_entity", not_set) if user_entity is not_set: # Query user entity from event user_info = event.get("source", {}).get("user", {}) user_id = user_info.get("id") username = user_info.get("username") if user_id: user_entity = session.query( "User where id is {}".format(user_id) ).first() if not user_entity and username: user_entity = session.query( "User where username is {}".format(username) ).first() event["data"]["user_entity"] = user_entity return user_entity @classmethod def get_user_roles_from_event(cls, session, event, lower=True): """Get user roles based on data in event. Args: session (ftrack_api.Session): Prepared ftrack session. event (ftrack_api.event.Event): Event which is processed. lower (Optional[bool]): Lower the role names. Default 'True'. """ not_set = object() user_roles = event["data"].get("user_roles", not_set) if user_roles is not_set: user_roles = [] user_entity = cls.get_user_entity_from_event(session, event) for role in user_entity["user_security_roles"]: role_name = role["security_role"]["name"] if lower: role_name = role_name.lower() user_roles.append(role_name) event["data"]["user_roles"] = user_roles return user_roles def get_project_name_from_event(self, session, event, entities): """Load or query and fill project entity from/to event data. Project data are stored by ftrack id because in most cases it is easier to access project id than project name. Args: session (ftrack_api.Session): Current session. event (ftrack_api.Event): Processed event by session. entities (list): Ftrack entities of selection. """ # Try to get project entity from event project_name = event["data"].get("project_name") if not project_name: project_entity = self.get_project_from_entity( entities[0], session ) project_name = project_entity["full_name"] event["data"]["project_name"] = project_name return project_name def get_ftrack_settings(self, session, event, entities): project_name = self.get_project_name_from_event( session, event, entities ) project_settings = self.get_project_settings_from_event( event, project_name ) return project_settings["ftrack"] def valid_roles(self, session, entities, event): """Validate user roles by settings. Method requires to have set `settings_key` attribute. """ ftrack_settings = self.get_ftrack_settings(session, event, entities) settings = ( ftrack_settings[self.settings_frack_subkey][self.settings_key] ) if self.settings_enabled_key: if not settings.get(self.settings_enabled_key, True): return False user_role_list = self.get_user_roles_from_event( session, event, lower=False) if not self.roles_check(settings.get("role_list"), user_role_list): return False return True class LocalAction(BaseAction): """Action that warn user when more Processes with same action are running. Action is launched all the time but if id does not match id of current instanace then message is shown to user. Handy for actions where matters if is executed on specific machine. """ _full_launch_identifier = None @property def discover_identifier(self): if self._discover_identifier is None: self._discover_identifier = "{}.{}".format( self.identifier, self.process_identifier() ) return self._discover_identifier @property def launch_identifier(self): """Catch all topics with same identifier.""" if self._launch_identifier is None: self._launch_identifier = "{}.*".format(self.identifier) return self._launch_identifier @property def full_launch_identifier(self): """Catch all topics with same identifier.""" if self._full_launch_identifier is None: self._full_launch_identifier = "{}.{}".format( self.identifier, self.process_identifier() ) return self._full_launch_identifier def _discover(self, event): entities = self._translate_event(event) if not entities: return accepts = self.discover(self.session, entities, event) if not accepts: return self.log.debug("Discovering action with selection: {0}".format( event["data"].get("selection", []) )) return { "items": [{ "label": self.label, "variant": self.variant, "description": self.description, "actionIdentifier": self.discover_identifier, "icon": self.icon, }] } def _launch(self, event): event_identifier = event["data"]["actionIdentifier"] # Check if identifier is same # - show message that acion may not be triggered on this machine if event_identifier != self.full_launch_identifier: return { "success": False, "message": ( "There are running more OpenPype processes" " where this action could be launched." ) } return super(LocalAction, self)._launch(event) class ServerAction(BaseAction): """Action class meant to be used on event server. Unlike the `BaseAction` roles are not checked on register but on discover. For the same reason register is modified to not filter topics by username. """ settings_frack_subkey = "events" @property def discover_identifier(self): return self.identifier @property def launch_identifier(self): return self.identifier def register(self): """Register subcription to Ftrack event hub.""" self.session.event_hub.subscribe( "topic=ftrack.action.discover", self._discover, priority=self.priority ) launch_subscription = ( "topic=ftrack.action.launch and data.actionIdentifier={0}" ).format(self.launch_identifier) self.session.event_hub.subscribe(launch_subscription, self._launch) ================================================ FILE: openpype/modules/ftrack/lib/ftrack_base_handler.py ================================================ import os import tempfile import json import functools import uuid import datetime import traceback import time from openpype.lib import Logger from openpype.settings import get_project_settings import ftrack_api from openpype_modules.ftrack import ftrack_server class MissingPermision(Exception): def __init__(self, message=None): if message is None: message = 'Ftrack' super().__init__(message) class PreregisterException(Exception): def __init__(self, message=None): if not message: message = "Pre-registration conditions were not met" super().__init__(message) class BaseHandler(object): '''Custom Action base class